ms-ai-architect/tests/kb-update/test-scan-adversarial-content.test.mjs
Kjell Tore Guttormsen 89dcd80cbc feat(ms-ai-architect): Layer B ingestion-gate — deterministisk adversariell-innhold-skann før skriving/commit (G6 §8 / R6 punkt d, TDD) [skip-docs]
Load-bearing gaten i den to-lags ingestion-sikkerheten: en deterministisk,
alltid-på node-skann (unicode/injection/base64, prosa + fenced code blocks) over
kandidat skills/**/*.md, wiret inn som sibling til validate-kb-file.mjs ved det
ENESTE skrive-chokepunktet — dekker kb-update + generate-skills + fremtidig R7.

- lib/adversarial-scan.mjs: ren disposition-kjerne (provenance-tiering + BLOCK/
  WARN-matrise). Ortogonal til korrekthets-judgen.
- lib/adversarial-detect.mjs: bro til de DELTE llm-security-detektorene
  (scanForInjection-lexikon + unicode-scanner + base64/entropi) — ingen kopi av
  lexikonet. Fail-closed hvis llm-security fraværende.
- scan-adversarial-content.mjs: CLI (speiler validate-kb-file.mjs); exit 1=BLOCK
  (aldri skriv), 2=WARN (flagg → menneske), 0=ren.
- 30 tester (19 kjerne + 7 CLI + 4 integrasjon mot ekte llm-security). Suite 692/0.

Premiss-verifisert mot live kode: research/research-agent skriver ingenting
(kun Layer A); R7-judge re-bruker samme create-guard; CLI-scan alene misset
injection+base64 for markdown → importerer rene primitiver i stedet.
2026-07-04 07:13:43 +02:00

82 lines
4 KiB
JavaScript

// tests/kb-update/test-scan-adversarial-content.test.mjs
// Layer B create-guard CLI (G6 §8 / R6 punkt d): scripts/kb-update/scan-adversarial-content.mjs
// is the sibling to validate-kb-file.mjs — the gate any generator (kb-update, generate-skills)
// calls over candidate files. A file that BLOCKs is never written / never committed; a file
// that WARNs is flagged for the operator (never auto-committed). Exit: 0 clean, 1 block, 2 warn.
//
// scanPaths is dependency-injected (readFile + detect), so these tests feed synthetic content
// and a fake detector — they never touch disk nor llm-security. FAIL-CLOSED behaviour (a read
// error or an unavailable detector BLOCKS, never silently passes) is asserted here because it
// is the whole point of a security gate.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { scanPaths } from '../../scripts/kb-update/scan-adversarial-content.mjs';
const CLEAN = '# T\n\n**Source:** https://learn.microsoft.com/azure/x\n\nProse.\n';
function reader(map) {
return (p) => {
if (!(p in map)) throw new Error(`ENOENT: ${p}`);
return map[p];
};
}
// Fake detectors keyed by returning canned raw findings regardless of content.
const detectClean = async () => [];
const detectBlock = async () => [{ class: 'injection', severity: 'critical', line: 5, evidence: 'spoofed tag: <system>' }];
const detectWarn = async () => [{ class: 'injection', severity: 'high', line: 5, evidence: 'imperative in prose' }];
test('scanPaths — all clean → ok:true, no block, no warn', async () => {
const r = await scanPaths(['a.md', 'b.md'], { readFile: reader({ 'a.md': CLEAN, 'b.md': CLEAN }), detect: detectClean });
assert.equal(r.ok, true);
assert.equal(r.blocked, false);
assert.equal(r.warned, false);
assert.equal(r.results.length, 2);
assert.ok(r.results.every((x) => x.disposition === 'clean'));
});
test('scanPaths — a blocked file fails the gate (ok:false, blocked:true)', async () => {
const r = await scanPaths(['bad.md'], { readFile: reader({ 'bad.md': CLEAN }), detect: detectBlock });
assert.equal(r.ok, false);
assert.equal(r.blocked, true);
assert.equal(r.results[0].disposition, 'block');
});
test('scanPaths — a warn-only file is flagged, not clean (warned:true, blocked:false)', async () => {
const r = await scanPaths(['warn.md'], { readFile: reader({ 'warn.md': CLEAN }), detect: detectWarn });
assert.equal(r.ok, false);
assert.equal(r.blocked, false);
assert.equal(r.warned, true);
assert.equal(r.results[0].disposition, 'warn');
});
test('scanPaths — one blocked file in a batch fails the whole gate', async () => {
const detect = async (content) => (content === 'POISON' ? detectBlock() : []);
const r = await scanPaths(['ok.md', 'bad.md'], { readFile: reader({ 'ok.md': CLEAN, 'bad.md': 'POISON' }), detect });
assert.equal(r.blocked, true);
assert.equal(r.results.find((x) => x.path === 'ok.md').disposition, 'clean');
assert.equal(r.results.find((x) => x.path === 'bad.md').disposition, 'block');
});
test('scanPaths — an unreadable path FAILS CLOSED (blocked), not thrown', async () => {
const r = await scanPaths(['missing.md'], { readFile: () => { throw new Error('ENOENT'); }, detect: detectClean });
assert.equal(r.blocked, true);
assert.equal(r.results[0].disposition, 'block');
assert.ok(r.results[0].findings.some((f) => /read|enoent/i.test(f.evidence || '')));
});
test('scanPaths — a detector that throws (llm-security unavailable) FAILS CLOSED (blocked)', async () => {
const r = await scanPaths(['x.md'], {
readFile: reader({ 'x.md': CLEAN }),
detect: async () => { throw new Error('LLM_SECURITY_UNAVAILABLE'); },
});
assert.equal(r.blocked, true);
assert.equal(r.results[0].disposition, 'block');
assert.ok(r.results[0].findings.some((f) => /llm_security|scanner/i.test(f.evidence || '')));
});
test('scanPaths — no paths → ok:true (nothing to gate)', async () => {
const r = await scanPaths([], { readFile: reader({}), detect: detectClean });
assert.equal(r.ok, true);
assert.deepEqual(r.results, []);
});