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.
This commit is contained in:
parent
687a15138e
commit
89dcd80cbc
8 changed files with 770 additions and 14 deletions
112
scripts/kb-update/scan-adversarial-content.mjs
Normal file
112
scripts/kb-update/scan-adversarial-content.mjs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env node
|
||||
// scan-adversarial-content.mjs — Layer B of the ingestion security gate (G6 §8 / R6 punkt d).
|
||||
//
|
||||
// The deterministic, always-on adversarial-content scan over candidate skills/**/*.md files.
|
||||
// It is the SIBLING to validate-kb-file.mjs at the same create-guard chokepoint: any generator
|
||||
// (kb-update apply, generate-skills, a future R7 judge-pass) runs it AFTER composing a candidate
|
||||
// file and BEFORE writing/committing it. A file that BLOCKs is never written; a file that WARNs
|
||||
// is flagged for the operator (same human-in-loop as a status-claim flag — never auto-committed).
|
||||
//
|
||||
// It is orthogonal to the correctness machinery (validate-kb-file / the judge / verify-out):
|
||||
// those answer "is this claim true against its authority?"; this answers "is this fetched chunk
|
||||
// trying to inject instructions or smuggle an invisible/encoded payload?". Both gates run.
|
||||
//
|
||||
// Detection is the SHARED llm-security asset (imported by the bridge, adversarial-detect.mjs —
|
||||
// no lexicon copy). This file is the thin, DI'd orchestration + exit-code contract, mirroring
|
||||
// validate-kb-file.mjs so it is unit-testable without disk or llm-security.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/kb-update/scan-adversarial-content.mjs <file.md> [<file2.md> ...]
|
||||
//
|
||||
// Exit code: 0 = all clean (safe to auto-proceed); 1 = at least one BLOCK (hard-fail, never
|
||||
// write/commit); 2 = at least one WARN (no block) — flag for operator, do not auto-commit.
|
||||
|
||||
import { readFileSync, realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { classifyFindings } from './lib/adversarial-scan.mjs';
|
||||
import { detectAdversarial } from './lib/adversarial-detect.mjs';
|
||||
|
||||
/**
|
||||
* Scan a batch of candidate KB files. Pure + dependency-injected (readFile + detect) so it is
|
||||
* unit-testable without disk or llm-security. FAIL-CLOSED: a path that cannot be read, or a
|
||||
* detector that throws (e.g. llm-security unavailable), is reported as BLOCKED — a security gate
|
||||
* must never silently pass when it cannot actually scan.
|
||||
*
|
||||
* @param {string[]} paths
|
||||
* @param {object} [deps]
|
||||
* @param {(p: string) => string} [deps.readFile] — reader (defaults to readFileSync utf8)
|
||||
* @param {(content: string, opts: {path: string}) => Promise<object[]>} [deps.detect] — raw detector
|
||||
* @returns {Promise<{ok: boolean, blocked: boolean, warned: boolean, results: Array<{path: string, disposition: string, findings: object[]}>}>}
|
||||
*/
|
||||
export async function scanPaths(paths, deps = {}) {
|
||||
const readFile = deps.readFile ?? ((p) => readFileSync(p, 'utf8'));
|
||||
const detect = deps.detect ?? detectAdversarial;
|
||||
const results = [];
|
||||
for (const path of paths ?? []) {
|
||||
let content;
|
||||
try {
|
||||
content = readFile(path);
|
||||
} catch (err) {
|
||||
results.push({
|
||||
path,
|
||||
disposition: 'block',
|
||||
findings: [{ class: 'read-error', severity: 'critical', line: 0, evidence: `read-error: ${err.message}`, disposition: 'block' }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let raw;
|
||||
try {
|
||||
raw = await detect(content, { path });
|
||||
} catch (err) {
|
||||
results.push({
|
||||
path,
|
||||
disposition: 'block',
|
||||
findings: [{ class: 'scanner-error', severity: 'critical', line: 0, evidence: `scanner-error: ${err.message}`, disposition: 'block' }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const { disposition, findings } = classifyFindings(content, raw, {});
|
||||
results.push({ path, disposition, findings });
|
||||
}
|
||||
const blocked = results.some((r) => r.disposition === 'block');
|
||||
const warned = results.some((r) => r.disposition === 'warn');
|
||||
return { ok: !blocked && !warned, blocked, warned, results };
|
||||
}
|
||||
|
||||
function report(results) {
|
||||
for (const r of results) {
|
||||
if (r.disposition === 'clean') {
|
||||
process.stdout.write(`OK ${r.path}\n`);
|
||||
continue;
|
||||
}
|
||||
const marker = r.disposition === 'block' ? 'BLOCK' : 'WARN ';
|
||||
process.stdout.write(`${marker} ${r.path}\n`);
|
||||
for (const f of r.findings) {
|
||||
if (f.disposition === 'clean' || f.disposition === 'pass') continue;
|
||||
const tier = f.tier ? ` [${f.tier}]` : '';
|
||||
process.stdout.write(` ${f.disposition.toUpperCase()} ${f.class}/${f.subtype ?? f.severity}${tier} line ${f.line}: ${f.evidence}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main(argv) {
|
||||
const paths = argv.slice(2);
|
||||
if (paths.length === 0) {
|
||||
process.stderr.write('usage: scan-adversarial-content.mjs <file.md> [<file2.md> ...]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const { blocked, warned, results } = await scanPaths(paths);
|
||||
report(results);
|
||||
if (blocked) process.exit(1);
|
||||
if (warned) process.exit(2);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const isMain = (() => {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
if (isMain) main(process.argv);
|
||||
Loading…
Add table
Add a link
Reference in a new issue