/** * Hook additionalContext injection advisory (v5.10 B5). * * A hook that emits `hookSpecificOutput.additionalContext` has that content * injected into Claude's context EVERY time the hook fires. Plain stdout on * exit 0 does NOT enter context (it goes to the debug log only). So a hook that * dumps large, unfiltered command output into additionalContext is a recurring * per-turn token cost that compounds and is compaction-sensitive. * * This module is a STATIC heuristic over hook SCRIPT SOURCE. It is deliberately * LOW PRECISION — it cannot run the script or measure the real payload — so it * ships as an INFO advisory (weight 0, never severity-bearing), paired with a * feature-gap "filter-before-Claude-reads" lever. The signal: * * the script references `additionalContext` * AND captures output from a verbose-prone command (cat/find/git log/test/curl…) * AND applies no truncating/filtering tool anywhere (grep/head/jq/.slice…). * * A script that pipes through a filter, or only captures cheap output ($(date)), * is assumed bounded and is not flagged. Conversely a verbose capture with no * filter is the un-grepped pattern worth surfacing. * * Mechanism verified 2026-06-23 against code.claude.com/docs: * context-window.md — "A PostToolUse hook … reports back via * hookSpecificOutput.additionalContext. That field enters Claude's context. * Plain stdout on exit 0 does not." + tip: keep output concise; it enters * context without truncation. The remediation lever is the documented * filter-test-output.sh pattern (grep before Claude reads). * * The pure `assessHookAdditionalContext` takes already-read script text so it is * fully unit-testable without IO. `assessHookContextForRepo` is the thin IO * wrapper (walk hooks → scripts → assess) shared by feature-gap; the HKV scanner * calls the pure function inline on scripts it already reads. */ import { readTextFile } from './file-discovery.mjs'; import { parseJson } from './yaml-parser.mjs'; import { stat } from 'node:fs/promises'; import { resolve, dirname } from 'node:path'; // The field that actually enters Claude's context (vs. plain stdout / debug log). const ADDITIONAL_CONTEXT_RX = /additionalContext/; // Commands whose UNfiltered output can be large. A capture invoking one of these // with no filter anywhere is the low-precision "un-grepped output" signal. // Shell substitution AND node child_process / file reads are both covered. const VERBOSE_CAPTURE_RX = /\b(?:cat|find|ls|git\s+(?:log|diff|status|show)|npm|yarn|pnpm|pytest|jest|go\s+test|cargo\s+test|curl|wget|env|printenv|dmesg|journalctl|execSync|spawnSync|readFileSync)\b/; // Truncating / filtering tools that BOUND a payload before it reaches context. // Their presence anywhere in the script suppresses the advisory (assumed bounded). // Shell filters + the common node-side bounding operations. const FILTER_RX = /\b(?:grep|egrep|rg|head|tail|sed|awk|jq|cut|wc|uniq|sort)\b|\.(?:slice|substring|substr)\s*\(/; /** * Assess one hook script's source for unfiltered additionalContext injection. * * @param {{ scriptContent?: string }} [args] * @returns {{ * buildsAdditionalContext: boolean, * hasVerboseCapture: boolean, * hasFilter: boolean, * capturesUnfiltered: boolean, * flagged: boolean, * }} */ export function assessHookAdditionalContext({ scriptContent } = {}) { const content = typeof scriptContent === 'string' ? scriptContent : ''; const buildsAdditionalContext = ADDITIONAL_CONTEXT_RX.test(content); const hasVerboseCapture = VERBOSE_CAPTURE_RX.test(content); const hasFilter = FILTER_RX.test(content); const capturesUnfiltered = hasVerboseCapture && !hasFilter; return { buildsAdditionalContext, hasVerboseCapture, hasFilter, capturesUnfiltered, flagged: buildsAdditionalContext && capturesUnfiltered, }; } /** * Extract a filesystem script path from a hook command string. * Mirrors hook-validator's extractScriptPath (kept local so the shared lib has * no upward dependency on a scanner). Handles ${CLAUDE_PLUGIN_ROOT}. */ function extractScriptPath(command, baseDir) { const match = command.match(/(?:bash|node|sh)\s+(.+?)(?:\s|$)/); if (!match) return null; let scriptPath = match[1].trim(); scriptPath = scriptPath.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, resolve(baseDir, '..')); scriptPath = scriptPath.replace(/\$CLAUDE_PLUGIN_ROOT/g, resolve(baseDir, '..')); if (scriptPath.includes('$')) return null; return resolve(baseDir, scriptPath); } /** Yield every command-hook { event, command } from a hooks object. */ function* iterateCommandHooks(hooks) { if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return; for (const [event, handlers] of Object.entries(hooks)) { if (!Array.isArray(handlers)) continue; for (const group of handlers) { const hookList = group && Array.isArray(group.hooks) ? group.hooks : []; for (const hook of hookList) { if (hook && hook.type === 'command' && typeof hook.command === 'string') { yield { event, command: hook.command }; } } } } } /** * IO wrapper: walk discovered hooks (hooks.json + settings.json hooks), resolve * each command hook's script, and return the ones flagged by the heuristic. * Shared by feature-gap (the HKV scanner assesses inline on scripts it reads). * * @param {{ files: import('./file-discovery.mjs').ConfigFile[] }} discovery * @returns {Promise }>>} */ export async function assessHookContextForRepo(discovery) { const flagged = []; const files = (discovery && Array.isArray(discovery.files)) ? discovery.files : []; const hooksObjects = []; for (const file of files.filter((f) => f.type === 'hooks-json')) { const content = await readTextFile(file.absPath); const parsed = content ? parseJson(content) : null; if (parsed) hooksObjects.push({ hooks: parsed.hooks || parsed, file }); } for (const file of files.filter((f) => f.type === 'settings-json')) { const content = await readTextFile(file.absPath); const parsed = content ? parseJson(content) : null; if (parsed && parsed.hooks && !Array.isArray(parsed.hooks)) { hooksObjects.push({ hooks: parsed.hooks, file }); } } for (const { hooks, file } of hooksObjects) { const baseDir = dirname(file.absPath); for (const { event, command } of iterateCommandHooks(hooks)) { const scriptPath = extractScriptPath(command, baseDir); if (!scriptPath) continue; try { await stat(scriptPath); } catch { continue; // missing script — HKV reports that separately } const scriptContent = await readTextFile(scriptPath); const assessment = assessHookAdditionalContext({ scriptContent }); if (assessment.flagged) { flagged.push({ event, scriptPath, file: file.absPath, assessment }); } } } return flagged; }