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>
This commit is contained in:
parent
8f7e196046
commit
d2c45a3bb8
11 changed files with 468 additions and 4 deletions
|
|
@ -14,6 +14,7 @@ import { SEVERITY } from './lib/severity.mjs';
|
|||
import { findImports, parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
import { measureActiveSkillListing, isBundledSkillsDisabled, BUDGET_CALIBRATION_NOTE } from './lib/skill-listing-budget.mjs';
|
||||
import { assessMcpDeferralForRepo } from './lib/mcp-deferral.mjs';
|
||||
import { assessHookContextForRepo } from './lib/hook-additional-context.mjs';
|
||||
|
||||
const SCANNER = 'GAP';
|
||||
|
||||
|
|
@ -175,6 +176,41 @@ export function cliOverMcpLeverFinding({ assessment } = {}) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* filter-before-Claude-reads lever — remediation companion to HKV's B5 advisory
|
||||
* (v5.10). Fires ONLY when ≥1 active hook was detected injecting unfiltered
|
||||
* command output into additionalContext, i.e. when there is a measured chatty
|
||||
* hook to fix. When no such hook exists there is nothing to recommend and we
|
||||
* stay silent — opportunity, not noise (same "fire only under measured pressure"
|
||||
* contract as the cliOverMcp / bundledSkills levers).
|
||||
*
|
||||
* Pure and exported for unit testing.
|
||||
*
|
||||
* @param {{ flaggedHooks: Array<{event:string, scriptPath:string}> }} args
|
||||
* @returns {object|null} a GAP finding, or null when no chatty hook was detected
|
||||
*/
|
||||
export function filterHookLeverFinding({ flaggedHooks } = {}) {
|
||||
const hooks = Array.isArray(flaggedHooks) ? flaggedHooks : [];
|
||||
if (hooks.length === 0) return null;
|
||||
const scripts = hooks.map((h) => h.scriptPath.split('/').slice(-1)[0]).join(', ');
|
||||
return finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.info,
|
||||
title: 'Filter hook output before it enters context',
|
||||
description:
|
||||
`${hooks.length} active hook${hooks.length === 1 ? '' : 's'} build hookSpecificOutput.additionalContext ` +
|
||||
"from un-grepped command output (see HKV advisory). That field enters Claude's context on every fire, " +
|
||||
'so filtering verbose output down to what matters BEFORE Claude reads it reclaims per-turn tokens — the ' +
|
||||
'documented filter-test-output.sh pattern (grep ERROR and return only matches instead of a 10,000-line log).',
|
||||
evidence: `chatty_hooks=${hooks.length}; scripts=${scripts} (companion to HKV additionalContext advisory)`,
|
||||
recommendation:
|
||||
'In each flagged hook, pipe the command output through grep/head/jq to keep only the actionable lines ' +
|
||||
'before assigning additionalContext. Reserve additionalContext for concise signals; leave bulk diagnostics ' +
|
||||
'on plain stdout (exit 0) so they go to the debug log, not context.',
|
||||
category: 'token-efficiency',
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {GapCheck[]} */
|
||||
const GAP_CHECKS = [
|
||||
// --- Tier 1: Foundation ---
|
||||
|
|
@ -489,6 +525,12 @@ export async function scan(targetPath, sharedDiscovery) {
|
|||
const cliLever = cliOverMcpLeverFinding({ assessment: mcpAssessment });
|
||||
if (cliLever) findings.push(cliLever);
|
||||
|
||||
// filter-before-Claude-reads lever — companion to HKV's B5 advisory: fires
|
||||
// only when an active hook injects unfiltered output into additionalContext.
|
||||
const flaggedHooks = await assessHookContextForRepo(discovery);
|
||||
const hookLever = filterHookLeverFinding({ flaggedHooks });
|
||||
if (hookLever) findings.push(hookLever);
|
||||
|
||||
const filesScanned = discovery.files.length;
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { readTextFile, discoverConfigFiles } from './lib/file-discovery.mjs';
|
|||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseJson } from './lib/yaml-parser.mjs';
|
||||
import { assessHookAdditionalContext } from './lib/hook-additional-context.mjs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
|
||||
|
|
@ -248,6 +249,35 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// v5.10 B5: advisory (info) — a hook that injects unfiltered
|
||||
// command output into hookSpecificOutput.additionalContext pays
|
||||
// that whole payload into Claude's context on every fire (plain
|
||||
// stdout does not). Low-precision static heuristic, so info only.
|
||||
const scriptContent = await readTextFile(scriptPath);
|
||||
const ac = assessHookAdditionalContext({ scriptContent });
|
||||
if (ac.flagged) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.info,
|
||||
title: 'Hook injects unfiltered output into context',
|
||||
description:
|
||||
`${file.relPath}: "${event}" runs ${scriptPath.split('/').slice(-2).join('/')} ` +
|
||||
'which builds hookSpecificOutput.additionalContext from un-grepped command ' +
|
||||
"output. That field enters Claude's context every time the hook fires (plain " +
|
||||
'stdout does not), so an unfiltered payload is a recurring per-turn token cost. ' +
|
||||
'Advisory only — low-precision static heuristic; verify the real payload size.',
|
||||
file: scriptPath,
|
||||
evidence:
|
||||
'additional_context_unfiltered=true; ' +
|
||||
`verbose_capture=${ac.hasVerboseCapture}; filter_applied=${ac.hasFilter}`,
|
||||
recommendation:
|
||||
'Filter before Claude reads: grep/head the command output down to what matters ' +
|
||||
'before putting it in additionalContext (the documented filter-test-output.sh ' +
|
||||
'pattern), or keep large diagnostics on plain stdout so they stay out of context.',
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
160
scanners/lib/hook-additional-context.mjs
Normal file
160
scanners/lib/hook-additional-context.mjs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue