// 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, join } from 'node:path'; import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; 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] — optional on-disk path; when absent the unicode scan runs * PRE-WRITE against a temp file materialized from `content` (the ingestion gate), so unicode * coverage no longer depends on the file already existing on disk * @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) --- // The llm-security unicode scanner reads a file off disk. When a caller passes content // WITHOUT a path — the pre-write ingestion gate: scan the COMPOSED artefact before it is // ever written — materialize a temp file so the SAME scanner runs pre-write. This closes // the ingestion-brief §8 acceptance test (zero-width/bidi rejected BEFORE write); the old // `if (opts.path)` guard skipped unicode entirely on the content path, leaving pre-write // carriers undetected. Fail-closed posture is unchanged — a scanner error still propagates // as a throw → the caller BLOCKs. let scanPath = opts.path; let tmpDir = null; try { if (!scanPath) { tmpDir = mkdtempSync(join(tmpdir(), 'kb-adv-')); scanPath = join(tmpDir, 'candidate.md'); writeFileSync(scanPath, String(content ?? ''), 'utf8'); } const discovery = { files: [{ absPath: resolve(scanPath), relPath: basename(scanPath) }] }; 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', }); } } finally { if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); } return findings; }