fix(ms-ai-architect): G6 Layer B — unicode/carrier-scan kjører pre-write via temp-fil (lukker ingestion-brief §8-akseptansegap, TDD) [skip-docs]
This commit is contained in:
parent
b5c44e6c6e
commit
b227c278eb
2 changed files with 77 additions and 4 deletions
|
|
@ -23,7 +23,9 @@
|
|||
// pattern as scripts/kb-eval/score-skill.mjs.
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve, basename } from 'node:path';
|
||||
import { dirname, resolve, basename, join } from 'node:path';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
|
@ -80,7 +82,9 @@ const ENCODED_MIN_ENTROPY = 4.0;
|
|||
* driven with a single-file discovery so no charset is re-derived here.
|
||||
*
|
||||
* @param {string} content — the candidate file content
|
||||
* @param {{path?: string}} [opts] — path is required for unicode detection (the scanner reads it)
|
||||
* @param {{path?: string}} [opts] — optional on-disk path; when absent the unicode scan runs
|
||||
* PRE-WRITE against a temp file materialized from `content` (the ingestion gate), so unicode
|
||||
* coverage no longer depends on the file already existing on disk
|
||||
* @returns {Promise<Array<{class: string, subtype?: string, severity: string, line: number, evidence: string}>>}
|
||||
*/
|
||||
export async function detectAdversarial(content, opts = {}) {
|
||||
|
|
@ -108,8 +112,22 @@ export async function detectAdversarial(content, opts = {}) {
|
|||
}
|
||||
|
||||
// --- unicode (disk-based scanner; needs a real path) ---
|
||||
if (opts.path) {
|
||||
const discovery = { files: [{ absPath: resolve(opts.path), relPath: basename(opts.path) }] };
|
||||
// The llm-security unicode scanner reads a file off disk. When a caller passes content
|
||||
// WITHOUT a path — the pre-write ingestion gate: scan the COMPOSED artefact before it is
|
||||
// ever written — materialize a temp file so the SAME scanner runs pre-write. This closes
|
||||
// the ingestion-brief §8 acceptance test (zero-width/bidi rejected BEFORE write); the old
|
||||
// `if (opts.path)` guard skipped unicode entirely on the content path, leaving pre-write
|
||||
// carriers undetected. Fail-closed posture is unchanged — a scanner error still propagates
|
||||
// as a throw → the caller BLOCKs.
|
||||
let scanPath = opts.path;
|
||||
let tmpDir = null;
|
||||
try {
|
||||
if (!scanPath) {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'kb-adv-'));
|
||||
scanPath = join(tmpDir, 'candidate.md');
|
||||
writeFileSync(scanPath, String(content ?? ''), 'utf8');
|
||||
}
|
||||
const discovery = { files: [{ absPath: resolve(scanPath), relPath: basename(scanPath) }] };
|
||||
const res = await unicodeScan('.', discovery);
|
||||
for (const f of res.findings || []) {
|
||||
findings.push({
|
||||
|
|
@ -120,6 +138,8 @@ export async function detectAdversarial(content, opts = {}) {
|
|||
evidence: f.evidence || f.title || 'unicode anomaly',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return findings;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@
|
|||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { scanPaths } from '../../scripts/kb-update/scan-adversarial-content.mjs';
|
||||
import { detectAdversarial } from '../../scripts/kb-update/lib/adversarial-detect.mjs';
|
||||
|
||||
const CLEAN = '# T\n\n**Source:** https://learn.microsoft.com/azure/x\n\nProse.\n';
|
||||
|
||||
|
|
@ -80,3 +84,52 @@ test('scanPaths — no paths → ok:true (nothing to gate)', async () => {
|
|||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.results, []);
|
||||
});
|
||||
|
||||
// --- detectAdversarial: pre-write unicode scan (G6 Layer B bugfix, ingestion-brief §8) ----
|
||||
// Unlike the scanPaths tests above (fake detectors, never touch disk/llm-security), these
|
||||
// exercise the REAL bridge. The bug: the disk-based unicode scanner only ran when opts.path
|
||||
// was supplied, so a candidate scanned as CONTENT (the pre-write ingestion gate) got zero
|
||||
// unicode coverage — violating the committed §8 acceptance test (zero-width/bidi rejected
|
||||
// BEFORE write). The fix materialises a temp file so the same scanner runs pre-write. These
|
||||
// tests are skipped when the llm-security sibling is absent (fresh clone) so the maintainer
|
||||
// suite stays green either way.
|
||||
let llmSecurityAvailable = false;
|
||||
try {
|
||||
await detectAdversarial('probe'); // loads the detectors; throws LLM_SECURITY_UNAVAILABLE if absent
|
||||
llmSecurityAvailable = true;
|
||||
} catch { /* sibling not resolvable — the integration tests below skip */ }
|
||||
|
||||
const ZW_BIDI = 'Ren tekst med zero-width og bidi override.\n';
|
||||
const PLAIN = 'Helt ren prosatekst uten anomalier.\n';
|
||||
const advTmpDirs = () => readdirSync(tmpdir()).filter((n) => n.startsWith('kb-adv-'));
|
||||
|
||||
test('detectAdversarial flags zero-width/bidi in content WITHOUT a path (pre-write §8)', { skip: !llmSecurityAvailable }, async () => {
|
||||
const findings = await detectAdversarial(ZW_BIDI);
|
||||
const unicode = findings.filter((f) => f.class === 'unicode');
|
||||
assert.ok(unicode.length >= 2, `expected ≥2 unicode findings pre-write, got ${JSON.stringify(findings)}`);
|
||||
assert.ok(unicode.some((f) => f.subtype === 'zero-width'), 'zero-width carrier must be caught pre-write');
|
||||
assert.ok(unicode.some((f) => f.subtype === 'bidi'), 'bidi override must be caught pre-write');
|
||||
});
|
||||
|
||||
test('detectAdversarial returns no unicode findings for clean content WITHOUT a path', { skip: !llmSecurityAvailable }, async () => {
|
||||
const findings = await detectAdversarial(PLAIN);
|
||||
assert.equal(findings.filter((f) => f.class === 'unicode').length, 0);
|
||||
});
|
||||
|
||||
test('detectAdversarial disk-path route is unchanged (regression)', { skip: !llmSecurityAvailable }, async () => {
|
||||
const d = mkdtempSync(join(tmpdir(), 'kbreg-'));
|
||||
const p = join(d, 'candidate.md');
|
||||
try {
|
||||
writeFileSync(p, ZW_BIDI, 'utf8');
|
||||
const viaPath = await detectAdversarial(ZW_BIDI, { path: p });
|
||||
assert.ok(viaPath.filter((f) => f.class === 'unicode').length >= 2, 'explicit-path route must still flag unicode');
|
||||
} finally {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('detectAdversarial cleans up its pre-write temp file (no leak)', { skip: !llmSecurityAvailable }, async () => {
|
||||
const before = advTmpDirs().length;
|
||||
await detectAdversarial(ZW_BIDI);
|
||||
assert.equal(advTmpDirs().length, before, 'pre-write temp dir must be removed in finally');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue