// trigger-scanner.mjs — TRG: trigger/activation-abuse scanner // // Inspects command/agent/skill `name` + `description` frontmatter for three // activation-surface abuses: // - TRG-shadow : name collides with a built-in command/tool (intercepts it) // - TRG-baiting : description uses maximally-activating phrases ("anything", // "always", "all files", …) to bait indiscriminate invocation // - TRG-broad : a broad/generic name AND a universal-applicability claim, // so the unit auto-activates far beyond its stated purpose // // Skills/agents auto-activate on their description, so this is a real, // skill-native attack surface not covered by the permission or taint scanners. // // Descriptions are run through the decode pipeline (zero-width strip -> // homoglyph fold -> normalizeForScan) before phrase matching, so obfuscated // baiting (zero-width / homoglyph / entity / base64) still trips. // // OWASP coverage: LLM06 (excessive agency); AST04 (skills framework). // Zero external dependencies. import { finding, scannerResult } from './lib/output.mjs'; import { SEVERITY } from './lib/severity.mjs'; import { readTextFile } from './lib/file-discovery.mjs'; import { parseFrontmatter } from './lib/yaml-frontmatter.mjs'; import { normalizeForScan, foldHomoglyphs } from './lib/string-utils.mjs'; import { getPolicyValue } from './lib/policy-loader.mjs'; // --------------------------------------------------------------------------- // Defaults — mirrored into DEFAULT_POLICY.trg (policy-loader.mjs) so an // operator can override any of these via .llm-security/policy.json. // --------------------------------------------------------------------------- const DEFAULT_BAITING_PHRASES = [ 'anything', 'everything', 'always', 'whenever', 'no matter what', 'any request', 'any task', 'any file', 'every time', 'all files', 'all messages', 'all requests', ]; const DEFAULT_BUILTIN_NAMES = [ 'read', 'write', 'edit', 'bash', 'glob', 'grep', 'task', 'webfetch', 'websearch', 'notebookedit', 'todowrite', 'ls', 'cat', 'agent', 'search', 'fetch', ]; const DEFAULT_BROAD_SINGLE_WORDS = [ 'run', 'do', 'go', 'help', 'fix', 'use', 'get', 'set', 'all', 'any', 'it', 'this', 'that', ]; // Bare universal-applicability claim words used only for the TRG-broad combo. const UNIVERSAL_CLAIM_RE = /\b(any|all|every|everything|anything|universal|whenever|always|no matter what)\b/i; // Invisible / zero-width characters used to split keywords (ZWSP, ZWNJ, ZWJ, // word-joiner, BOM/zero-width-no-break-space). const ZERO_WIDTH_RE = /[\u200B-\u200D\u2060\uFEFF]/g; /** True if relPath is a command, agent, or skill definition file. */ function isTriggerFile(relPath) { const p = String(relPath).replace(/\\/g, '/'); return /(^|\/)commands\/[^/]+\.md$/i.test(p) || /(^|\/)agents\/[^/]+\.md$/i.test(p) || /(^|\/)skills\/.+\/SKILL\.md$/i.test(p); } /** Coarse type label for messaging. */ function fileTypeOf(relPath) { const p = String(relPath).replace(/\\/g, '/'); if (/(^|\/)commands\//i.test(p)) return 'command'; if (/(^|\/)agents\//i.test(p)) return 'agent'; return 'skill'; } /** Strip zero-width chars, fold homoglyphs, decode obfuscation layers, lowercase. */ function normalizeText(s) { if (!s) return ''; const noZeroWidth = String(s).replace(ZERO_WIDTH_RE, ''); return normalizeForScan(foldHomoglyphs(noZeroWidth)).toLowerCase(); } /** * Scan command/agent/skill definitions for trigger/activation abuse. * * @param {string} targetPath - Absolute root path being scanned * @param {{ files: import('./lib/file-discovery.mjs').FileInfo[] }} discovery * @returns {Promise} - scannerResult envelope */ export async function scan(targetPath, discovery) { const startMs = Date.now(); const findings = []; let filesScanned = 0; try { const baitingPhrases = (getPolicyValue('trg', 'baiting_phrases', DEFAULT_BAITING_PHRASES, targetPath) || []) .map(p => String(p).toLowerCase()); const builtinNames = (getPolicyValue('trg', 'builtin_names', DEFAULT_BUILTIN_NAMES, targetPath) || []) .map(n => String(n).toLowerCase()); const broadWords = (getPolicyValue('trg', 'broad_single_words', DEFAULT_BROAD_SINGLE_WORDS, targetPath) || []) .map(w => String(w).toLowerCase()); for (const fileInfo of discovery.files) { if (!isTriggerFile(fileInfo.relPath)) continue; const content = await readTextFile(fileInfo.absPath); if (content === null) continue; const fm = parseFrontmatter(content); if (!fm || !fm.name) continue; filesScanned++; const name = String(fm.name).trim(); const nameLower = name.toLowerCase(); const rawDesc = typeof fm.description === 'string' ? fm.description : ''; const normDesc = normalizeText(rawDesc); const fileType = fileTypeOf(fileInfo.relPath); const isBroadName = broadWords.includes(nameLower) || nameLower.length <= 2; const hasUniversalClaim = UNIVERSAL_CLAIM_RE.test(normDesc); // TRG-shadow — name collides with a built-in command/tool. if (builtinNames.includes(nameLower)) { findings.push(finding({ scanner: 'TRG', severity: SEVERITY.MEDIUM, title: `Built-in shadowing: trigger name "${name}"`, description: `The ${fileType} "${name}" uses a name that collides with a built-in command/tool. ` + `A shadowing trigger can intercept invocations intended for the built-in.`, file: fileInfo.relPath, evidence: `name: ${name}`, owasp: 'LLM06', recommendation: 'Rename the command/agent/skill so its name does not collide with a built-in tool.', })); } // TRG-broad — broad/generic name AND a universal-applicability claim (HIGH). if (isBroadName && hasUniversalClaim) { findings.push(finding({ scanner: 'TRG', severity: SEVERITY.HIGH, title: `Overly broad trigger: "${name}"`, description: `The ${fileType} "${name}" pairs a broad/generic name with a description claiming ` + `universal applicability, so it may auto-activate far beyond its stated purpose.`, file: fileInfo.relPath, evidence: `name: ${name}`, owasp: 'LLM06', recommendation: 'Give the unit a specific name and scope the description to a single well-bounded task.', })); } // TRG-baiting — maximally-activating phrases in the (decoded) description. const matched = baitingPhrases.filter(p => p && normDesc.includes(p)); if (matched.length > 0) { findings.push(finding({ scanner: 'TRG', severity: SEVERITY.MEDIUM, title: `Activation baiting phrase in ${fileType} description`, description: `The ${fileType} "${name}" description uses maximally-activating phrasing that baits the ` + `model into invoking it indiscriminately${rawDesc === normDesc ? '' : ' (recovered from obfuscation)'}.`, file: fileInfo.relPath, evidence: `phrases: ${matched.join(', ')}`, owasp: 'LLM06', recommendation: 'Remove universal/maximal activation phrasing; describe concrete, scoped trigger conditions.', })); } } const durationMs = Date.now() - startMs; return scannerResult('trigger-scanner', 'ok', findings, filesScanned, durationMs); } catch (err) { const durationMs = Date.now() - startMs; return scannerResult('trigger-scanner', 'error', findings, filesScanned, durationMs, err.message); } }