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:
Kjell Tore Guttormsen 2026-07-04 07:13:43 +02:00
commit 89dcd80cbc
8 changed files with 770 additions and 14 deletions

View file

@ -0,0 +1,126 @@
// adversarial-detect.mjs — the BRIDGE from Layer B to the shared llm-security detectors.
//
// This is the ONE place in ms-ai-architect that reaches into the sibling llm-security plugin.
// It imports the deterministic, ToS-safe (no-Claude) detectors and normalizes their output to a
// uniform raw-finding shape the pure disposition core (adversarial-scan.mjs) consumes:
//
// { class: 'injection'|'unicode'|'encoded', subtype?: string, severity: 'critical'|'high'|'medium'|'low',
// line: number, evidence: string }
//
// House policy (ingen lokale løsninger / no drifting lexicon copies): the injection PATTERN
// lexicon and the unicode charsets are NOT copied here — they are imported from llm-security so
// there is exactly one implementation and one dataset. The near-term consumption is in-process
// import (operator decision 2026-07-04); the target is extraction into a shared
// `llm-ingestion-pipeline-security` library that both this plugin and claude-code-llm-wiki depend
// on (brief §6, two-horizon).
//
// FAIL-CLOSED: if llm-security cannot be resolved/loaded, detectAdversarial THROWS — the caller
// (scan-adversarial-content.mjs) turns that into a BLOCK. A security gate must never silently pass
// when it cannot actually scan. Override the sibling location with env LLM_SECURITY_ROOT.
//
// kb-update / generate-skills are maintainer-side workflows (external users consume the KB, they
// do not regenerate it), so the sibling-path coupling is acceptable — the same maintainer-only
// pattern as scripts/kb-eval/score-skill.mjs.
import { fileURLToPath } from 'node:url';
import { dirname, resolve, basename } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
/** Marketplace layout: …/ms-ai-architect/scripts/kb-update/lib → …/llm-security */
function llmSecurityRoot() {
return process.env.LLM_SECURITY_ROOT || resolve(__dirname, '../../../../llm-security');
}
// Lazily loaded + cached llm-security modules (so import cost is paid once, and absence
// surfaces as a thrown error at scan time — fail closed — not at module load).
let _mods = null;
async function loadDetectors() {
if (_mods) return _mods;
const root = llmSecurityRoot();
try {
const [inj, stru, uni] = await Promise.all([
import(`${root}/scanners/lib/injection-patterns.mjs`),
import(`${root}/scanners/lib/string-utils.mjs`),
import(`${root}/scanners/unicode-scanner.mjs`),
]);
_mods = {
scanForInjection: inj.scanForInjection,
isBase64Like: stru.isBase64Like,
shannonEntropy: stru.shannonEntropy,
redact: stru.redact,
unicodeScan: uni.scan,
};
return _mods;
} catch (err) {
throw new Error(`LLM_SECURITY_UNAVAILABLE at ${root}: ${err.message}`);
}
}
/** Map an llm-security unicode-scanner finding title to our carrier subtype. */
function unicodeSubtype(title = '') {
if (/zero-width/i.test(title)) return 'zero-width';
if (/unicode tag/i.test(title)) return 'unicode-tag';
if (/bidi/i.test(title)) return 'bidi';
if (/homoglyph/i.test(title)) return 'homoglyph';
return 'other';
}
// Encoded-blob thresholds: long enough to hide an instruction payload, high-entropy enough to
// be an encoded blob rather than prose. Tuned so a smuggled base64 instruction (≥ ~30 bytes)
// trips while ordinary identifiers / short tokens in legitimate code samples do not.
const ENCODED_MIN_LEN = 32;
const ENCODED_MIN_ENTROPY = 4.0;
/**
* Detect adversarial content in a candidate KB file. Combines the shared llm-security detectors:
* - injection: per-line scanForInjection (the pattern lexicon) gives severity + line.
* - encoded: per-line base64/hex-blob detection (isBase64Like + Shannon entropy).
* - unicode: unicode-scanner over the file (zero-width / bidi / unicode-tag / homoglyph),
* driven with a single-file discovery so no charset is re-derived here.
*
* @param {string} content the candidate file content
* @param {{path?: string}} [opts] path is required for unicode detection (the scanner reads it)
* @returns {Promise<Array<{class: string, subtype?: string, severity: string, line: number, evidence: string}>>}
*/
export async function detectAdversarial(content, opts = {}) {
const { scanForInjection, isBase64Like, shannonEntropy, redact, unicodeScan } = await loadDetectors();
const findings = [];
const lines = String(content ?? '').split('\n');
// --- injection (content) + encoded (content), per line for precise line numbers ---
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNo = i + 1;
const inj = scanForInjection(line);
if (inj && inj.found) {
for (const p of inj.patterns || []) {
findings.push({ class: 'injection', severity: p.severity, line: lineNo, evidence: p.label });
}
}
for (const tok of line.split(/[\s"'`,:{}()[\]<>]+/)) {
if (tok.length >= ENCODED_MIN_LEN && isBase64Like(tok) && shannonEntropy(tok) >= ENCODED_MIN_ENTROPY) {
findings.push({ class: 'encoded', severity: 'high', line: lineNo, evidence: `base64-like blob: ${redact(tok)}` });
}
}
}
// --- unicode (disk-based scanner; needs a real path) ---
if (opts.path) {
const discovery = { files: [{ absPath: resolve(opts.path), relPath: basename(opts.path) }] };
const res = await unicodeScan('.', discovery);
for (const f of res.findings || []) {
findings.push({
class: 'unicode',
subtype: unicodeSubtype(f.title),
severity: f.severity,
line: f.line || 0,
evidence: f.evidence || f.title || 'unicode anomaly',
});
}
}
return findings;
}

View file

@ -0,0 +1,149 @@
// adversarial-scan.mjs — Layer B (G6 §8 / R6 punkt d): the PURE disposition core of
// the ingestion security gate. It is the ms-ai-architect-specific brain that decides,
// for each raw adversarial-content finding, whether it BLOCKs the write (hard-fail,
// never committed), WARNs (flag → same human-in-loop as a status-claim flag), or passes.
//
// Design:
// - Detection (the injection lexicon, unicode charsets, base64/entropy) is the SHARED
// llm-security asset — imported by the bridge (adversarial-detect.mjs), never copied
// here (house policy: no drifting lexicon copies). This module receives already-detected
// raw findings and is therefore pure + sync + trivially unit-testable.
// - Disposition is PROVENANCE-TIERED (brief §5): source trust is a first-class input.
// A payload in a low-trust surface (fenced code sample, localized string) is far more
// likely a real attack → hard-fail; a payload-looking string in authored, en-locale
// prose is more likely a legitimate doc artifact → WARN + human review, not a silent block.
// - Orthogonal to the correctness judge: a factually-correct file that carries a payload
// is still blocked. This gate answers "is this trying to inject / smuggle?", not "is
// this claim true?".
//
// Never writes. Mirrors verify-out.mjs / transform.mjs: a pure classifier.
import { parseSourceHeader } from './kb-headers.mjs';
const FENCE_RE = /^\s*(```|~~~)/;
/**
* Line spans (1-indexed, inclusive of both fence lines) of every fenced code block.
* An unterminated fence treats the remainder of the file as code (fail-safe: we would
* rather over-classify a region as low-trust code than let a smuggled payload ride in
* an "open" fence and be treated as authored prose).
* @param {string} content
* @returns {Array<[number, number]>}
*/
export function findFencedCodeRanges(content) {
const lines = String(content ?? '').split('\n');
const ranges = [];
let open = null;
for (let i = 0; i < lines.length; i++) {
if (FENCE_RE.test(lines[i])) {
if (open === null) open = i + 1;
else {
ranges.push([open, i + 1]);
open = null;
}
}
}
if (open !== null) ranges.push([open, lines.length]);
return ranges;
}
/** Is a 1-indexed line inside any fenced code range? */
export function lineInCode(line, ranges) {
return (ranges ?? []).some(([s, e]) => line >= s && line <= e);
}
/**
* The Microsoft Learn locale segment of a Source URL, lowercased, or null.
* e.g. https://learn.microsoft.com/nb-no/azure/x → "nb-no".
* @param {string|null} sourceUrl
* @returns {string|null}
*/
export function localeFromSource(sourceUrl) {
if (!sourceUrl) return null;
const m = String(sourceUrl).match(/learn\.microsoft\.com\/([a-z]{2}(?:-[a-z]{2,4})?)\//i);
return m ? m[1].toLowerCase() : null;
}
/**
* Provenance trust tier for a finding's line. Low-trust surfaces (adversary-reachable):
* fenced code samples and localized (non-English) strings the surfaces the threat
* model (brief §3) calls community-contributable / machine-ingested. High-trust:
* authored, English-locale prose.
* @param {{line: number, ranges: Array<[number,number]>, sourceUrl: string|null}} args
* @returns {'code-sample'|'localized'|'authored-doc'}
*/
export function provenanceTier({ line, ranges, sourceUrl }) {
if (lineInCode(line, ranges)) return 'code-sample';
const loc = localeFromSource(sourceUrl);
if (loc && !loc.startsWith('en')) return 'localized';
return 'authored-doc';
}
/** Low-trust tiers are the adversary-reachable surfaces. */
export function isLowTrust(tier) {
return tier === 'code-sample' || tier === 'localized';
}
/** Invisible-carrier unicode subtypes — never legitimate in a KB reference file. */
const CARRIER_SUBTYPES = new Set(['zero-width', 'bidi', 'unicode-tag']);
/**
* Disposition for a single finding given its provenance tier.
* block hard-fail, never written / never committed.
* warn flag for human review (same human-in-loop as a status-claim flag).
* pass benign.
*
* Rationale (brief §5, §8):
* - Invisible unicode carriers (zero-width / bidi / unicode-tag) have NO legitimate
* reason to appear in authored Microsoft Learn content block in any tier.
* - Encoded blobs (base64/hex): block on a low-trust surface (the "base64 inside a
* code sample" vector); WARN if they surface in authored prose (rarer, likelier FP).
* - Injection patterns are provenance-tiered: critical (spoofed <system>, override+
* identity) is unambiguous block anywhere; high blocks on a low-trust surface but
* WARNs in authored prose (could be a doc literally discussing the pattern);
* medium/low WARN.
* @param {{class: string, subtype?: string, severity: string}} finding
* @param {string} tier
* @returns {'block'|'warn'|'pass'}
*/
export function disposition(finding, tier) {
const cls = finding.class;
const sev = finding.severity;
if (cls === 'unicode') {
return CARRIER_SUBTYPES.has(finding.subtype) ? 'block' : 'warn';
}
if (cls === 'encoded') {
return isLowTrust(tier) ? 'block' : 'warn';
}
if (cls === 'injection') {
if (sev === 'critical') return 'block';
if (sev === 'high') return isLowTrust(tier) ? 'block' : 'warn';
return 'warn';
}
// Unknown finding classes (e.g. read/scanner errors surfaced as findings) → block:
// fail closed, never let an unclassifiable signal pass silently.
return 'block';
}
const RANK = { block: 2, warn: 1, clean: 0 };
/**
* Classify a batch of raw findings against the file content. Pure + sync.
* @param {string} content the full candidate file content (for code-fence + Source tiering)
* @param {Array<object>} rawFindings [{class, subtype?, severity, line, evidence}]
* @param {{sourceUrl?: string}} [opts] sourceUrl overrides the in-file **Source:** header
* @returns {{disposition: 'block'|'warn'|'clean', findings: Array<object>}}
*/
export function classifyFindings(content, rawFindings, opts = {}) {
const ranges = findFencedCodeRanges(content);
const sourceUrl = opts.sourceUrl ?? parseSourceHeader(content) ?? null;
const findings = (rawFindings ?? []).map((f) => {
const tier = provenanceTier({ line: f.line, ranges, sourceUrl });
return { ...f, tier, disposition: disposition(f, tier) };
});
let worst = 'clean';
for (const f of findings) {
if (RANK[f.disposition] > RANK[worst]) worst = f.disposition;
}
return { disposition: worst, findings };
}

View 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);