From 910567d66137c223f4a34a4e6322b81238a62b1c Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 1 May 2026 07:05:45 +0200 Subject: [PATCH] feat(config-audit): HKV flags verbose hook output (v5 M5) [skip-docs] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static heuristic — counts console.log / process.stdout.write lines per referenced hook script. > 50 → low CA-HKV-NNN finding. New fixtures: - hooks-verbose/ (61 verbose lines → triggers) - hooks-quiet/ (5 lines → no finding) 580 → 582 tests, all green. --- .../config-audit/scanners/hook-validator.mjs | 46 +++++++++++++ .../fixtures/hooks-quiet/hooks/hooks.json | 7 ++ .../hooks-quiet/hooks/scripts/quiet.mjs | 7 ++ .../fixtures/hooks-verbose/hooks/hooks.json | 7 ++ .../hooks-verbose/hooks/scripts/loud.mjs | 64 +++++++++++++++++++ .../tests/scanners/hook-validator.test.mjs | 22 +++++++ 6 files changed, 153 insertions(+) create mode 100644 plugins/config-audit/tests/fixtures/hooks-quiet/hooks/hooks.json create mode 100644 plugins/config-audit/tests/fixtures/hooks-quiet/hooks/scripts/quiet.mjs create mode 100644 plugins/config-audit/tests/fixtures/hooks-verbose/hooks/hooks.json create mode 100644 plugins/config-audit/tests/fixtures/hooks-verbose/hooks/scripts/loud.mjs diff --git a/plugins/config-audit/scanners/hook-validator.mjs b/plugins/config-audit/scanners/hook-validator.mjs index b85bc1c..5f03612 100644 --- a/plugins/config-audit/scanners/hook-validator.mjs +++ b/plugins/config-audit/scanners/hook-validator.mjs @@ -36,6 +36,11 @@ const VALID_TYPES = new Set(['command', 'http', 'prompt', 'agent']); const MIN_TIMEOUT = 1000; const MAX_TIMEOUT = 300000; // 5 minutes +/** v5 M5: hook scripts that flood stdout fragment the cache prefix on every + * fire and slow Claude Code's UI. Static heuristic — count log lines. */ +const VERBOSE_HOOK_LINE_THRESHOLD = 50; +const VERBOSE_HOOK_LINE_RX = /\b(?:console\.log|process\.stdout\.write)\s*\(/; + /** * Scan all hooks.json files and hook configs in settings.json. * @param {string} targetPath @@ -198,8 +203,10 @@ async function validateHooksObject(hooks, file, findings, baseDir) { if (hook.type === 'command' && hook.command) { const scriptPath = extractScriptPath(hook.command, baseDir); if (scriptPath) { + let scriptExists = false; try { await stat(scriptPath); + scriptExists = true; } catch { findings.push(finding({ scanner: SCANNER, @@ -212,6 +219,31 @@ async function validateHooksObject(hooks, file, findings, baseDir) { autoFixable: false, })); } + + // v5 M5: count verbose stdout writes when the script exists. + if (scriptExists) { + const verboseCount = await countVerboseLines(scriptPath); + if (verboseCount > VERBOSE_HOOK_LINE_THRESHOLD) { + findings.push(finding({ + scanner: SCANNER, + severity: SEVERITY.low, + title: 'Verbose hook output (loud script)', + description: + `${file.relPath}: "${event}" runs ${scriptPath.split('/').slice(-2).join('/')} ` + + `which has ${verboseCount} console.log / process.stdout.write lines ` + + `(>${VERBOSE_HOOK_LINE_THRESHOLD}). Loud hooks slow the UI and bloat ` + + 'session transcripts on every fire.', + file: scriptPath, + evidence: + `console_log_or_stdout_lines=${verboseCount}; ` + + `threshold=${VERBOSE_HOOK_LINE_THRESHOLD}`, + recommendation: + 'Trim debug logging from hooks. Keep hook output to actionable signals; ' + + 'route verbose diagnostics to a log file instead of stdout.', + autoFixable: false, + })); + } + } } } @@ -246,6 +278,20 @@ async function validateHooksObject(hooks, file, findings, baseDir) { } } +/** + * Count lines containing console.log( or process.stdout.write( in a hook script. + * Static heuristic — does not execute the script. + */ +async function countVerboseLines(scriptPath) { + const content = await readTextFile(scriptPath); + if (!content) return 0; + let count = 0; + for (const line of content.split('\n')) { + if (VERBOSE_HOOK_LINE_RX.test(line)) count++; + } + return count; +} + /** * Extract a filesystem path from a hook command string. * Handles ${CLAUDE_PLUGIN_ROOT} variable substitution. diff --git a/plugins/config-audit/tests/fixtures/hooks-quiet/hooks/hooks.json b/plugins/config-audit/tests/fixtures/hooks-quiet/hooks/hooks.json new file mode 100644 index 0000000..a3c1149 --- /dev/null +++ b/plugins/config-audit/tests/fixtures/hooks-quiet/hooks/hooks.json @@ -0,0 +1,7 @@ +{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "node ./scripts/quiet.mjs", "timeout": 5000 }] } + ] + } +} diff --git a/plugins/config-audit/tests/fixtures/hooks-quiet/hooks/scripts/quiet.mjs b/plugins/config-audit/tests/fixtures/hooks-quiet/hooks/scripts/quiet.mjs new file mode 100644 index 0000000..e2c8a54 --- /dev/null +++ b/plugins/config-audit/tests/fixtures/hooks-quiet/hooks/scripts/quiet.mjs @@ -0,0 +1,7 @@ +#!/usr/bin/env node +// Quiet hook +console.log("step 0"); +console.log("step 1"); +console.log("step 2"); +console.log("step 3"); +console.log("step 4"); diff --git a/plugins/config-audit/tests/fixtures/hooks-verbose/hooks/hooks.json b/plugins/config-audit/tests/fixtures/hooks-verbose/hooks/hooks.json new file mode 100644 index 0000000..b93bdf6 --- /dev/null +++ b/plugins/config-audit/tests/fixtures/hooks-verbose/hooks/hooks.json @@ -0,0 +1,7 @@ +{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "node ./scripts/loud.mjs", "timeout": 5000 }] } + ] + } +} diff --git a/plugins/config-audit/tests/fixtures/hooks-verbose/hooks/scripts/loud.mjs b/plugins/config-audit/tests/fixtures/hooks-verbose/hooks/scripts/loud.mjs new file mode 100644 index 0000000..1fd4937 --- /dev/null +++ b/plugins/config-audit/tests/fixtures/hooks-verbose/hooks/scripts/loud.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +// Verbose hook for v5 M5 fixture +console.log("step 0"); +console.log("step 1"); +console.log("step 2"); +console.log("step 3"); +console.log("step 4"); +console.log("step 5"); +console.log("step 6"); +console.log("step 7"); +console.log("step 8"); +console.log("step 9"); +console.log("step 10"); +console.log("step 11"); +console.log("step 12"); +console.log("step 13"); +console.log("step 14"); +console.log("step 15"); +console.log("step 16"); +console.log("step 17"); +console.log("step 18"); +console.log("step 19"); +console.log("step 20"); +console.log("step 21"); +console.log("step 22"); +console.log("step 23"); +console.log("step 24"); +console.log("step 25"); +console.log("step 26"); +console.log("step 27"); +console.log("step 28"); +console.log("step 29"); +console.log("step 30"); +console.log("step 31"); +console.log("step 32"); +console.log("step 33"); +console.log("step 34"); +console.log("step 35"); +console.log("step 36"); +console.log("step 37"); +console.log("step 38"); +console.log("step 39"); +console.log("step 40"); +console.log("step 41"); +console.log("step 42"); +console.log("step 43"); +console.log("step 44"); +console.log("step 45"); +console.log("step 46"); +console.log("step 47"); +console.log("step 48"); +console.log("step 49"); +console.log("step 50"); +console.log("step 51"); +console.log("step 52"); +console.log("step 53"); +console.log("step 54"); +console.log("step 55"); +console.log("step 56"); +console.log("step 57"); +console.log("step 58"); +console.log("step 59"); +process.stdout.write("trailing +"); diff --git a/plugins/config-audit/tests/scanners/hook-validator.test.mjs b/plugins/config-audit/tests/scanners/hook-validator.test.mjs index 8530c80..624e907 100644 --- a/plugins/config-audit/tests/scanners/hook-validator.test.mjs +++ b/plugins/config-audit/tests/scanners/hook-validator.test.mjs @@ -71,6 +71,28 @@ describe('HKV scanner — broken project', () => { }); }); +describe('HKV scanner — verbose hook output (v5 M5)', () => { + it('flags hook script with > 50 console.log/stdout.write lines (low)', async () => { + resetCounter(); + const path = resolve(FIXTURES, 'hooks-verbose'); + const discovery = await discoverConfigFiles(path); + const result = await scan(path, discovery); + const f = result.findings.find(x => /verbose hook output/i.test(x.title || '')); + assert.ok(f, `expected verbose-hook finding; got: ${result.findings.map(x => x.title).join(' | ')}`); + assert.equal(f.severity, 'low', `expected low, got ${f.severity}`); + assert.match(f.evidence || '', /console_log_or_stdout_lines=6\d/); + }); + + it('does NOT flag a quiet hook script', async () => { + resetCounter(); + const path = resolve(FIXTURES, 'hooks-quiet'); + const discovery = await discoverConfigFiles(path); + const result = await scan(path, discovery); + const f = result.findings.find(x => /verbose hook output/i.test(x.title || '')); + assert.equal(f, undefined, `expected no verbose-hook finding; got: ${f?.title}`); + }); +}); + describe('HKV scanner — empty project', () => { let result; beforeEach(async () => {