270 lines
9.5 KiB
JavaScript
270 lines
9.5 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
|
|
|
|
/**
|
|
* 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) {
|
|
try {
|
|
await stat(scriptPath);
|
|
} 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,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|