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.
|
// pattern as scripts/kb-eval/score-skill.mjs.
|
||||||
|
|
||||||
import { fileURLToPath } from 'node:url';
|
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));
|
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.
|
* driven with a single-file discovery so no charset is re-derived here.
|
||||||
*
|
*
|
||||||
* @param {string} content — the candidate file content
|
* @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}>>}
|
* @returns {Promise<Array<{class: string, subtype?: string, severity: string, line: number, evidence: string}>>}
|
||||||
*/
|
*/
|
||||||
export async function detectAdversarial(content, opts = {}) {
|
export async function detectAdversarial(content, opts = {}) {
|
||||||
|
|
@ -108,8 +112,22 @@ export async function detectAdversarial(content, opts = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- unicode (disk-based scanner; needs a real path) ---
|
// --- unicode (disk-based scanner; needs a real path) ---
|
||||||
if (opts.path) {
|
// The llm-security unicode scanner reads a file off disk. When a caller passes content
|
||||||
const discovery = { files: [{ absPath: resolve(opts.path), relPath: basename(opts.path) }] };
|
// 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);
|
const res = await unicodeScan('.', discovery);
|
||||||
for (const f of res.findings || []) {
|
for (const f of res.findings || []) {
|
||||||
findings.push({
|
findings.push({
|
||||||
|
|
@ -120,6 +138,8 @@ export async function detectAdversarial(content, opts = {}) {
|
||||||
evidence: f.evidence || f.title || 'unicode anomaly',
|
evidence: f.evidence || f.title || 'unicode anomaly',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return findings;
|
return findings;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,11 @@
|
||||||
|
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 { 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';
|
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.equal(r.ok, true);
|
||||||
assert.deepEqual(r.results, []);
|
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