ktg-plugin-marketplace/plugins/config-audit/scanners/hook-validator.mjs
Kjell Tore Guttormsen 910567d661 feat(config-audit): HKV flags verbose hook output (v5 M5) [skip-docs]
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.
2026-05-01 07:05:45 +02:00

316 lines
12 KiB
JavaScript

/**
* 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<object>}
*/
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);
}