#!/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 { dirname, resolve } from 'node:path'; import { classifyFindings } from './lib/adversarial-scan.mjs'; import { detectAdversarial } from './lib/adversarial-detect.mjs'; /** Adjudicated Layer B allowlist (Enhet A2) — repo-relative, committed, human-reviewed. */ const ALLOWLIST_PATH = resolve(dirname(fileURLToPath(import.meta.url)), 'data/layerb-allowlist.json'); /** * Load the adjudicated allowlist. FAIL-SAFE in the strict direction: a missing, * unreadable, malformed, or mis-shaped file yields an EMPTY allowlist — the gate can * only be relaxed by a well-formed, committed file, never by an error. * @param {{readFile?: (p: string) => string, path?: string}} [deps] * @returns {Array} allowlist entries */ export function loadAllowlist(deps = {}) { const readFile = deps.readFile ?? ((p) => readFileSync(p, 'utf8')); try { const parsed = JSON.parse(readFile(deps.path ?? ALLOWLIST_PATH)); return Array.isArray(parsed?.entries) ? parsed.entries : []; } catch { return []; } } /** * 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 * @param {Array} [deps.allowlist] — adjudicated allowlist entries (defaults to the * committed data/layerb-allowlist.json; missing/malformed → empty = fully strict) * @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 allowlist = deps.allowlist ?? loadAllowlist(); 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 allow = allowlist.filter((e) => e.path === path); const { disposition, findings } = classifyFindings(content, raw, { allow }); 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') { const allowed = (r.findings ?? []).filter((f) => f.disposition === 'allow').length; const note = allowed > 0 ? ` (${allowed} allowlisted)` : ''; process.stdout.write(`OK ${r.path}${note}\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);