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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue