config-audit/scanners/lib/hook-additional-context.mjs
Kjell Tore Guttormsen d2c45a3bb8 feat(hooks): additionalContext injection advisory + filter-before lever (v5.10 B5) [skip-docs]
HKV now flags hooks that build hookSpecificOutput.additionalContext from
un-grepped command output as an INFO advisory (weight 0, never severity-bearing
— excluded from the self-audit nonInfo set). That field enters Claude's context
every time the hook fires (plain stdout on exit 0 does not), so an unfiltered
payload is a recurring per-turn token cost.

- New lib scanners/lib/hook-additional-context.mjs: pure assessHookAdditionalContext
  (unit-tested, no IO) + IO wrapper assessHookContextForRepo (walk hooks->scripts).
  Heuristic: additionalContext + verbose-prone capture (cat/git log/execSync/…) &&
  no filter (grep/head/jq/.slice). Deliberately low precision -> advisory only.
- HKV: emits the advisory inline on scripts it already reads (after the M5 verbose
  check). Additive, info severity -> frozen v5.0.0 + SC-5 snapshots untouched.
- feature-gap: new filterHookLeverFinding companion, fires ONLY when >=1 chatty
  hook is detected — surfaces the filter-before-Claude-reads lever (CC
  filter-test-output.sh). Silent otherwise (opportunity, not noise).
- docs: README HKV + GAP scanner rows; scanner-internals.md HKV row + full B5
  implementation note. CLAUDE.md kept lean ([skip-docs]); B5 fully documented in
  README + scanner-internals.

Mechanism verified 2026-06-23 against code.claude.com/docs context-window.md
(additionalContext enters context; plain stdout does not). Suite 1239 -> 1254 green.
Version/badges/CHANGELOG wait for the v5.10 release cut.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:28:33 +02:00

160 lines
6.9 KiB
JavaScript

/**
* 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<Array<{ event: string, scriptPath: string, file: string,
* assessment: ReturnType<typeof assessHookAdditionalContext> }>>}
*/
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;
}