/** Orchestrator: normalize -> decode -> scan -> aggregate. No pi imports. */ import { aggregate, isTerminal, mkFinding, type Finding, type RiskLevel, type ScanResult, } from "./types.ts"; import { findDecodedBlobs } from "./encoding.ts"; import { scanUnicode } from "./unicode.ts"; import { stripCodeBlocks, stripHtmlComments, scanMarkdown, findCodeBlocks, } from "./markdown.ts"; import { scanRules, scanExternalRefs } from "./rules.ts"; import { scanCommand } from "./commands.ts"; import { validateRegex } from "../match.ts"; export type Provenance = "user" | "downloaded" | "untrusted-project"; const DOWNLOAD_AGE_MS = 3600000; // 1 hour // Hard cap on findings per file: bounds memory and evidence output. The // aggregate still saturates well below this (2000 medium => critical), so // capping never hides a real risk level. Terminal/critical findings are // exempt (overflow bounded by MAX_TERMINAL): the cap must never mask the // finding that decides the file's level. export const MAX_FINDINGS = 2000; /** Overflow allowance for terminal/critical findings beyond MAX_FINDINGS. */ const MAX_TERMINAL = 100; /** * Findings collector: the cap drops nothing but findings that cannot change * the level — terminal/critical findings always survive (overflow bounded at * MAX_TERMINAL). Loop push (never spread): a 300KB decoded blob yields * ~225k findings — `push(...arr)` would blow the call stack (RangeError). */ function makeSink() { const findings: Finding[] = []; let overflowTerminals = 0; const isTerm = (f: Finding) => f.terminal === true || f.severity === "critical"; const push = (f: Finding): void => { if (findings.length < MAX_FINDINGS) { findings.push(f); return; } if (isTerm(f) && overflowTerminals < MAX_TERMINAL) { findings.push(f); overflowTerminals++; } }; return { findings, push, pushAll: (src: Finding[]) => { for (const f of src) push(f); }, }; } /** * Classify where a rule file came from, from best-effort signals. * 'downloaded' wins (a recent untracked file is, by definition, not authored * in this project); an untrusted project marks everything it contains. */ export function provenance( _file: string, signals: { isProjectTrusted: boolean; now?: Date; mtimeMs?: number; gitTracked?: boolean; }, ): Provenance { const now = signals.now ?? new Date(); if ( signals.gitTracked === false && signals.mtimeMs !== undefined && now.getTime() - signals.mtimeMs < DOWNLOAD_AGE_MS ) { return "downloaded"; } if (!signals.isProjectTrusted) return "untrusted-project"; return "user"; } const NEXT_LEVEL: RiskLevel[] = ["none", "low", "medium", "high", "critical"]; /** * Provenance nudge (Task 13): raise the level one step for downloaded / * untrusted-project files with findings. Never raises 'none', and high only * rises to critical when a terminal finding is present (a CRITICAL decision * always needs a terminal finding or a >= critical score). */ export function nudgeLevel( level: RiskLevel, prov: Provenance, findings: Finding[], ): RiskLevel { if (level === "none" || level === "critical") return level; if (findings.length === 0) return level; if (prov !== "downloaded" && prov !== "untrusted-project") return level; if (level === "high") return isTerminal(findings) ? "critical" : "high"; return NEXT_LEVEL[NEXT_LEVEL.indexOf(level) + 1]; } /** F13: decoded blobs that are mostly control chars are binary data, not * hidden text — scanning them floods uni-ctrl findings (one per byte). */ function isLikelyBinary(t: string): boolean { const n = Math.min(t.length, 1000); let bad = 0; for (let i = 0; i < n; i++) { const cp = t.codePointAt(i)!; if ((cp < 0x20 && cp !== 0x09 && cp !== 0x0a && cp !== 0x0d) || cp === 0x7f) bad++; } return n > 0 && bad / n > 0.1; } /** Scan a context rule file (raw markdown). `file` is reserved for provenance. */ export function scanContext(raw: string, _file: string): ScanResult { const text = raw.replace(/\r\n/g, "\n"); const sink = makeSink(); const { findings } = sink; const { decoded, findings: encFindings } = findDecodedBlobs(text); sink.pushAll(encFindings); sink.pushAll(scanUnicode(text, MAX_FINDINGS)); for (const blob of decoded) { if (!isLikelyBinary(blob.text)) { sink.pushAll(scanUnicode(blob.text, MAX_FINDINGS)); } } // Hidden-content scan runs on the code-block-stripped text; the visible // injection scan additionally strips html comments (anti-false-positive). const noCode = stripCodeBlocks(text); const visible = stripHtmlComments(noCode); sink.pushAll(scanMarkdown(noCode)); sink.pushAll(scanRules(visible)); sink.pushAll(scanExternalRefs(visible)); // H-1: fenced content is injected verbatim into the system prompt — scan it. // Cap: code blocks are often legit examples => severity capped at high, // terminal cleared. Result: instruction-like fenced content is never silent // (>= medium => confirm) but never auto-critical. for (const cb of findCodeBlocks(text)) { const label = cb.closed ? "code block" : "unclosed code block"; for (const f of [ ...scanRules(cb.text, { lineOffset: cb.line, label }), ...scanExternalRefs(cb.text, { lineOffset: cb.line, label }), ]) { if (f.severity === "critical" || f.terminal) { f.severity = "high"; f.score = 10; f.terminal = false; } sink.push(f); } } for (const blob of decoded) { const label = `decoded ${blob.from}`; sink.pushAll(scanRules(blob.text, { label })); sink.pushAll(scanExternalRefs(blob.text, { label })); } return { level: aggregate(findings), findings, decoded: decoded.length > 0 ? decoded : undefined, }; } /** * Recursively collect every `regex` string under a match spec: `any` arrays, * input/command/result/cwd keys, `{contains, regex}` entries. A match spec is * a plain data tree, so one generic walk covers all shapes. */ function collectMatchRegexes(node: unknown, out: string[]): void { if (node === null || typeof node !== "object") return; if (Array.isArray(node)) { for (const item of node) collectMatchRegexes(item, out); return; } for (const [k, v] of Object.entries(node as Record)) { if (k === "regex") { if (typeof v === "string") out.push(v); else if (Array.isArray(v)) { for (const s of v) if (typeof s === "string") out.push(s); } } else { collectMatchRegexes(v, out); } } } /** Scan parsed frontmatter as behavior (tool-hijack signals). */ export function scanFrontmatter(meta: Record): ScanResult { const findings: Finding[] = []; // F6: user match.regex is validated at load — hostile or invalid regex // files hit the gate (one high finding per invalid pattern) and never load // silently. The runtime matcher additionally skips invalid patterns. const matchMeta = meta.match; if (matchMeta !== undefined) { const regexes: string[] = []; collectMatchRegexes(matchMeta, regexes); for (const r of regexes) { const err = validateRegex(r); if (err === null) continue; findings.push( mkFinding( "th-bad-regex", "tool-hijack", "high", "high", `match.regex rejected: ${err} (${r.slice(0, 60)})`, ), ); } } const action = meta.action; if (action && typeof action === "object") { const a = action as Record; const type = typeof a.type === "string" ? a.type : ""; if ( type === "modify" || type === "tools" || type === "transform" || type === "handled" || type === "confirm" ) { findings.push( mkFinding( "th-action-present", "tool-hijack", "info", "low", `frontmatter action.type "${type}"`, ), ); } if (type === "tools") { findings.push( mkFinding( "th-tools", "tool-hijack", "high", "high", "frontmatter action enables tools", ), ); } if (type === "modify" && a.command && typeof a.command === "object") { const cmd = a.command as Record; for (const key of ["prepend", "append"]) { const val = typeof cmd[key] === "string" ? cmd[key] : ""; if (val === "") continue; const network = /https?:\/\/|\b(curl|wget|nc|ncat|fetch)\b/i.test(val); const exec = /\|\s*(?:ba|z|da)?sh\b|\b(?:ba|z|da)?sh\s+-c\b|\b(node|python|python3|perl|ruby|eval|execute)\b|\bbase64\s+-d\b|\$\(|`/i.test( val, ); if (network && exec) { findings.push( mkFinding( "th-modify-netexec", "tool-hijack", "high", "high", `frontmatter action.modify.command.${key} performs network + execution`, { terminal: true }, ), ); } else if (network || exec) { findings.push( mkFinding( "th-modify-net", "tool-hijack", "medium", "medium", `frontmatter action.modify.command.${key} contains ${ network ? "network" : "execution" }`, ), ); } } // H-7: the prepend/append IS code that runs on every matching user_bash — // scan it with the full command scanner (destructive, exfil, dl-exec...). for (const key of ["prepend", "append"]) { const val = typeof cmd[key] === "string" ? cmd[key] : ""; if (val === "") continue; const sr = scanCommand(val); for (const f of sr.findings) { findings.push({ ...f, evidence: `frontmatter action.modify.command.${key}: ${f.evidence}`, }); } } } if (type === "confirm") { const msg = typeof a.message === "string" ? a.message.trim() : ""; const generic = msg === "" || msg.length < 8 || /^(are you sure|proceed|confirm|continue|ok|yes|allow|go ahead)[?.!]*$/i.test( msg, ); if (generic) { findings.push( mkFinding( "th-confirm-generic", "tool-hijack", "medium", "medium", "frontmatter confirm uses a generic message", ), ); } } } return { level: aggregate(findings), findings }; }