ms-ai-architect/scripts/kb-update/lib/adversarial-detect.mjs
Kjell Tore Guttormsen 89dcd80cbc 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.
2026-07-04 07:13:43 +02:00

126 lines
5.6 KiB
JavaScript

// 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;
}