/** * HKV Scanner — Hook Validator * Validates hooks.json format, script existence, event validity, timeouts. * Finding IDs: CA-HKV-NNN */ 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 { stat } from 'node:fs/promises'; import { resolve, dirname } from 'node:path'; const SCANNER = 'HKV'; /** All valid hook events (as of April 2026) */ const VALID_EVENTS = new Set([ 'SessionStart', 'InstructionsLoaded', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'PermissionDenied', 'PostToolUse', 'PostToolUseFailure', 'SubagentStart', 'SubagentStop', 'TaskCreated', 'TaskCompleted', 'Stop', 'StopFailure', 'TeammateIdle', 'Notification', 'ConfigChange', 'CwdChanged', 'FileChanged', 'WorktreeCreate', 'WorktreeRemove', 'PreCompact', 'PostCompact', 'Elicitation', 'ElicitationResult', 'SessionEnd', ]); /** Valid hook handler types */ const VALID_TYPES = new Set(['command', 'http', 'prompt', 'agent']); /** Reasonable timeout range */ 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 * @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery * @returns {Promise} */ export async function scan(targetPath, discovery) { const start = Date.now(); const hooksFiles = discovery.files.filter(f => f.type === 'hooks-json'); const settingsFiles = discovery.files.filter(f => f.type === 'settings-json'); const findings = []; let filesScanned = 0; // Scan standalone hooks.json files for (const file of hooksFiles) { const content = await readTextFile(file.absPath); if (!content) continue; filesScanned++; const parsed = parseJson(content); if (parsed === null) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.critical, title: 'Invalid JSON in hooks.json', description: `${file.relPath} contains invalid JSON. All hooks in this file will be ignored.`, file: file.absPath, recommendation: 'Fix JSON syntax errors.', autoFixable: false, })); continue; } const hooksConfig = parsed.hooks || parsed; await validateHooksObject(hooksConfig, file, findings, dirname(file.absPath)); } // Scan hooks in settings.json files for (const file of settingsFiles) { const content = await readTextFile(file.absPath); if (!content) continue; const parsed = parseJson(content); if (!parsed || !parsed.hooks) continue; filesScanned++; if (Array.isArray(parsed.hooks)) { // Already reported by settings-validator, skip here continue; } await validateHooksObject(parsed.hooks, file, findings, dirname(file.absPath)); } if (hooksFiles.length === 0 && !settingsFiles.some(async f => { const c = await readTextFile(f.absPath); const p = c ? parseJson(c) : null; return p && p.hooks; })) { // No hooks at all — this is noted but not an error return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start); } return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start); } /** * Validate a hooks object (event key → handler array). */ async function validateHooksObject(hooks, file, findings, baseDir) { if (typeof hooks !== 'object' || Array.isArray(hooks)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.critical, title: 'Hooks must be an object with event keys', description: `${file.relPath}: hooks is ${Array.isArray(hooks) ? 'an array' : typeof hooks}. Expected object with event names as keys.`, file: file.absPath, recommendation: 'Use format: { "PreToolUse": [...], "Stop": [...] }', autoFixable: false, })); return; } for (const [event, handlers] of Object.entries(hooks)) { // Validate event name if (!VALID_EVENTS.has(event)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.high, title: 'Unknown hook event', description: `${file.relPath}: "${event}" is not a valid hook event. This hook will never fire.`, file: file.absPath, evidence: event, recommendation: `Valid events: ${[...VALID_EVENTS].slice(0, 8).join(', ')}... (26 total)`, autoFixable: false, })); continue; } if (!Array.isArray(handlers)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.high, title: 'Hook handlers must be an array', description: `${file.relPath}: handlers for "${event}" is not an array.`, file: file.absPath, evidence: `"${event}": ${typeof handlers}`, recommendation: `Use format: "${event}": [{ "hooks": [...] }]`, autoFixable: false, })); continue; } for (const handlerGroup of handlers) { // Validate matcher format if (handlerGroup.matcher !== undefined) { if (typeof handlerGroup.matcher === 'object') { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.high, title: 'Matcher must be a string, not an object', description: `${file.relPath}: "${event}" has a matcher that is an object. Matcher should be a simple string like "Bash" or "Edit|Write".`, file: file.absPath, evidence: JSON.stringify(handlerGroup.matcher), recommendation: 'Change matcher to a string: "matcher": "Bash"', autoFixable: true, })); } } if (!handlerGroup.hooks || !Array.isArray(handlerGroup.hooks)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.high, title: 'Missing hooks array in handler group', description: `${file.relPath}: "${event}" handler group is missing the "hooks" array.`, file: file.absPath, recommendation: 'Add "hooks": [{ "type": "command", "command": "..." }]', autoFixable: false, })); continue; } for (const hook of handlerGroup.hooks) { // Validate handler type if (!hook.type || !VALID_TYPES.has(hook.type)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.high, title: 'Invalid hook handler type', description: `${file.relPath}: "${event}" has handler with type "${hook.type || '(missing)'}".`, file: file.absPath, evidence: `type: "${hook.type || ''}"`, recommendation: `Valid types: ${[...VALID_TYPES].join(', ')}`, autoFixable: false, })); } // For command hooks, check script existence 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, severity: SEVERITY.high, title: 'Hook script not found', description: `${file.relPath}: "${event}" references script that does not exist.`, file: file.absPath, evidence: hook.command, recommendation: `Create the script at: ${scriptPath}`, 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, })); } } } } // Timeout validation if (hook.timeout !== undefined) { if (typeof hook.timeout !== 'number') { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: 'Hook timeout must be a number', description: `${file.relPath}: "${event}" has non-numeric timeout.`, file: file.absPath, evidence: `timeout: ${JSON.stringify(hook.timeout)}`, recommendation: 'Set timeout to a number (milliseconds).', autoFixable: true, })); } else if (hook.timeout < MIN_TIMEOUT || hook.timeout > MAX_TIMEOUT) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.low, title: 'Hook timeout outside recommended range', description: `${file.relPath}: "${event}" timeout is ${hook.timeout}ms. Recommended range: ${MIN_TIMEOUT}-${MAX_TIMEOUT}ms.`, file: file.absPath, evidence: `timeout: ${hook.timeout}`, recommendation: `Set timeout between ${MIN_TIMEOUT} and ${MAX_TIMEOUT}ms.`, autoFixable: false, })); } } } } } } /** * 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. */ function extractScriptPath(command, baseDir) { // Extract the script path from common patterns: // "bash /path/to/script.sh" // "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/foo.mjs" const match = command.match(/(?:bash|node|sh)\s+(.+?)(?:\s|$)/); if (!match) return null; let scriptPath = match[1].trim(); // Replace ${CLAUDE_PLUGIN_ROOT} with baseDir (best guess) scriptPath = scriptPath.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, resolve(baseDir, '..')); scriptPath = scriptPath.replace(/\$CLAUDE_PLUGIN_ROOT/g, resolve(baseDir, '..')); // Don't validate absolute paths that use env vars we can't resolve if (scriptPath.includes('$')) return null; return resolve(baseDir, scriptPath); }