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
126
scripts/kb-update/lib/adversarial-detect.mjs
Normal file
126
scripts/kb-update/lib/adversarial-detect.mjs
Normal 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;
|
||||
}
|
||||
149
scripts/kb-update/lib/adversarial-scan.mjs
Normal file
149
scripts/kb-update/lib/adversarial-scan.mjs
Normal 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 };
|
||||
}
|
||||
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