From b227c278eb48bfddbf204774364f0c1422aac288 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 4 Jul 2026 23:04:00 +0200 Subject: [PATCH] =?UTF-8?q?fix(ms-ai-architect):=20G6=20Layer=20B=20?= =?UTF-8?q?=E2=80=94=20unicode/carrier-scan=20kj=C3=B8rer=20pre-write=20vi?= =?UTF-8?q?a=20temp-fil=20(lukker=20ingestion-brief=20=C2=A78-akseptansega?= =?UTF-8?q?p,=20TDD)=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/kb-update/lib/adversarial-detect.mjs | 28 ++++++++-- .../test-scan-adversarial-content.test.mjs | 53 +++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/scripts/kb-update/lib/adversarial-detect.mjs b/scripts/kb-update/lib/adversarial-detect.mjs index efe0830..f038cf2 100644 --- a/scripts/kb-update/lib/adversarial-detect.mjs +++ b/scripts/kb-update/lib/adversarial-detect.mjs @@ -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>} */ 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; diff --git a/tests/kb-update/test-scan-adversarial-content.test.mjs b/tests/kb-update/test-scan-adversarial-content.test.mjs index 416a5da..2ccd577 100644 --- a/tests/kb-update/test-scan-adversarial-content.test.mjs +++ b/tests/kb-update/test-scan-adversarial-content.test.mjs @@ -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'); +});