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