#!/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 [ ...] // // 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} [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 [ ...]\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);