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:
Kjell Tore Guttormsen 2026-07-04 23:04:00 +02:00
commit b227c278eb
2 changed files with 77 additions and 4 deletions

View file

@ -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;

View file

@ -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');
});