Files
ECC/scripts/hooks/hookify-runner.js
T

424 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* Event-aware Hookify runner. It always exits 0 and emits structured JSON.
*/
'use strict';
const { StringDecoder } = require('string_decoder');
const { isDryRun, isHookEnabled } = require('../lib/hook-flags');
const {
LIMITS: LOADER_LIMITS,
loadRules,
} = require('./hookify-loader');
const {
evaluateRules,
truncateUtf8,
} = require('./hookify-engine');
const LIMITS = Object.freeze({
...LOADER_LIMITS,
maxInputBytes: 256 * 1024,
maxOutputBytes: 8192,
maxContextBytes: 7000,
maxContextBytesWithBlock: 3000,
maxBlockReasonBytes: 3800,
regexTimeoutMs: 250,
});
const EVENTS = new Set([
'PreToolUse',
'PostToolUse',
'Stop',
'UserPromptSubmit',
]);
const FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
function plainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function hasUnsafeControlCharacters(value) {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code === 0x7f || (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d)) {
return true;
}
}
return false;
}
function safeString(value, maxBytes) {
return (
typeof value === 'string' &&
Buffer.byteLength(value, 'utf8') <= maxBytes &&
!hasUnsafeControlCharacters(value)
);
}
function expectedEventFromContext(context, payload) {
if (EVENTS.has(context.expectedEvent)) return context.expectedEvent;
if (context.hookId === 'pre:hookify') return 'PreToolUse';
if (context.hookId === 'post:hookify') return 'PostToolUse';
if (context.hookId === 'stop:hookify') return 'Stop';
if (context.hookId === 'prompt:hookify') return 'UserPromptSubmit';
return EVENTS.has(payload?.hook_event_name) ? payload.hook_event_name : null;
}
function validateInput(payload, expectedEvent) {
if (!plainObject(payload)) return 'hook input must be a JSON object';
if (!expectedEvent || payload.hook_event_name !== expectedEvent) {
return 'hook event did not match the registered runtime event';
}
if (expectedEvent === 'PreToolUse' || expectedEvent === 'PostToolUse') {
if (!safeString(payload.tool_name, 128) || !plainObject(payload.tool_input)) {
return 'tool hooks require bounded tool_name and tool_input fields';
}
} else if (expectedEvent === 'UserPromptSubmit') {
if (!safeString(payload.prompt, LIMITS.maxInputBytes)) {
return 'UserPromptSubmit requires a bounded prompt string';
}
} else if (expectedEvent === 'Stop') {
if (
payload.last_assistant_message !== undefined &&
!safeString(payload.last_assistant_message, LIMITS.maxInputBytes)
) {
return 'Stop last_assistant_message must be a bounded string';
}
if (
payload.stop_hook_active !== undefined &&
typeof payload.stop_hook_active !== 'boolean'
) {
return 'Stop stop_hook_active must be true or false';
}
}
return null;
}
function ruleEventForInput(eventName, payload) {
if (eventName === 'UserPromptSubmit') return 'prompt';
if (eventName === 'Stop') return 'stop';
if (payload.tool_name === 'Bash') return 'bash';
if (FILE_TOOLS.has(payload.tool_name)) return 'file';
return null;
}
function textByteLength(value) {
return Buffer.byteLength(String(value || ''), 'utf8');
}
function truncateText(value, maxBytes) {
const text = String(value || '');
if (textByteLength(text) <= maxBytes) return text;
const marker = '\n… [Hookify output truncated]';
return `${truncateUtf8(text, Math.max(0, maxBytes - textByteLength(marker)))}${marker}`;
}
function joinBounded(items, maxBytes) {
return truncateText(items.filter(Boolean).join('\n\n'), maxBytes);
}
function formatRule(rule) {
return `**[${rule.name}]**\n${rule.message}`;
}
function formatDiagnostics(diagnostics) {
return diagnostics.map(item => `**[Hookify diagnostic]**\n${item.message}`);
}
function contextOutput(eventName, text) {
if (!text) return {};
if (eventName === 'Stop') return { systemMessage: text };
return {
hookSpecificOutput: {
hookEventName: eventName,
additionalContext: text,
},
};
}
function immutableSetText(output, key, value) {
if (key === 'additionalContext') {
return {
...output,
hookSpecificOutput: {
...output.hookSpecificOutput,
additionalContext: value,
},
};
}
if (key === 'permissionDecisionReason') {
return {
...output,
hookSpecificOutput: {
...output.hookSpecificOutput,
permissionDecisionReason: value,
},
};
}
return { ...output, [key]: value };
}
function boundOutput(output) {
let bounded = output;
const textKeys = [
'additionalContext',
'systemMessage',
'reason',
'permissionDecisionReason',
];
for (let attempt = 0; attempt < 12; attempt += 1) {
const serialized = JSON.stringify(bounded);
if (Buffer.byteLength(serialized, 'utf8') <= LIMITS.maxOutputBytes) {
return serialized;
}
let largest = null;
for (const key of textKeys) {
const value = key === 'additionalContext' || key === 'permissionDecisionReason'
? bounded.hookSpecificOutput?.[key]
: bounded[key];
if (typeof value !== 'string') continue;
const bytes = textByteLength(value);
if (!largest || bytes > largest.bytes) largest = { key, value, bytes };
}
if (!largest || largest.bytes <= 96) break;
bounded = immutableSetText(
bounded,
largest.key,
truncateText(largest.value, Math.max(96, Math.floor(largest.bytes * 0.6)))
);
}
if (bounded.hookSpecificOutput?.permissionDecision === 'deny') {
return JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: 'A Hookify rule blocked this tool call; details were truncated.',
},
});
}
if (bounded.decision === 'block') {
return JSON.stringify({
decision: 'block',
reason: 'A Hookify rule blocked this event; details were truncated.',
});
}
return JSON.stringify(contextOutput(
bounded.hookSpecificOutput?.hookEventName || 'PreToolUse',
'Hookify diagnostic: output exceeded its configured limit.'
));
}
function buildOutput(eventName, matches, diagnostics) {
const blocking = matches.filter(rule => rule.action === 'block');
const warnings = matches.filter(rule => rule.action === 'warn');
const contextItems = [
...warnings.map(formatRule),
...formatDiagnostics(diagnostics),
];
const contextLimit = blocking.length > 0
? LIMITS.maxContextBytesWithBlock
: LIMITS.maxContextBytes;
const additionalContext = joinBounded(contextItems, contextLimit);
const blockReason = joinBounded(blocking.map(formatRule), LIMITS.maxBlockReasonBytes);
if (blocking.length === 0) {
return contextOutput(eventName, additionalContext);
}
if (eventName === 'PreToolUse') {
const hookSpecificOutput = {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: blockReason,
...(additionalContext ? { additionalContext } : {}),
};
return { hookSpecificOutput };
}
const reason = eventName === 'PostToolUse'
? [
'The PostToolUse action already completed; this decision cannot undo it.',
'Correct the result before continuing.',
blockReason,
].join('\n\n')
: blockReason;
const output = {
decision: 'block',
reason,
};
if (!additionalContext) return output;
if (eventName === 'Stop') return { ...output, systemMessage: additionalContext };
return {
...output,
hookSpecificOutput: {
hookEventName: eventName,
additionalContext,
},
};
}
function failOpen(eventName, message) {
const safeEvent = EVENTS.has(eventName) ? eventName : 'PreToolUse';
return buildOutput(safeEvent, [], [{
code: 'HOOKIFY_INPUT_INVALID',
message: `Hookify diagnostic: ${message}. Rules were not enforced for this event.`,
}]);
}
function run(rawInput, context = {}) {
const raw = typeof rawInput === 'string' ? rawInput : '';
const preliminaryEvent = EVENTS.has(context.expectedEvent)
? context.expectedEvent
: expectedEventFromContext(context, null);
try {
if (
context.truncated === true ||
Buffer.byteLength(raw, 'utf8') > LIMITS.maxInputBytes
) {
return {
stdout: boundOutput(failOpen(preliminaryEvent, 'input exceeded the byte limit')),
stderr: '',
exitCode: 0,
};
}
let payload;
try {
payload = JSON.parse(raw);
} catch {
return {
stdout: boundOutput(failOpen(preliminaryEvent, 'hook input was not valid JSON')),
stderr: '',
exitCode: 0,
};
}
const eventName = expectedEventFromContext(context, payload);
const inputError = validateInput(payload, eventName);
if (inputError) {
return {
stdout: boundOutput(failOpen(eventName || preliminaryEvent, inputError)),
stderr: '',
exitCode: 0,
};
}
if (eventName === 'Stop' && payload.stop_hook_active === true) {
return {
stdout: '{}',
stderr: '',
exitCode: 0,
};
}
const loaded = loadRules({
projectRoot: context.projectRoot || process.cwd(),
event: ruleEventForInput(eventName, payload),
});
const evaluated = evaluateRules(loaded.rules, payload, {
timeoutMs: LIMITS.regexTimeoutMs,
maxFieldBytes: LIMITS.maxInputBytes,
});
const output = buildOutput(eventName, evaluated.matches, [
...loaded.diagnostics,
...evaluated.diagnostics,
]);
return {
stdout: boundOutput(output),
stderr: '',
exitCode: 0,
};
} catch {
return {
stdout: boundOutput(failOpen(preliminaryEvent, 'internal runtime failure')),
stderr: '',
exitCode: 0,
};
}
}
function readStdinBounded() {
return new Promise(resolve => {
const decoder = new StringDecoder('utf8');
let raw = '';
let bytesRead = 0;
let truncated = false;
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
if (!truncated) raw += decoder.end();
resolve({ raw, truncated });
};
process.stdin.on('data', chunk => {
if (settled) return;
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
const remaining = Math.max(0, LIMITS.maxInputBytes - bytesRead);
const accepted = buffer.subarray(0, remaining);
if (accepted.length > 0) {
raw += decoder.write(accepted);
bytesRead += accepted.length;
}
if (accepted.length < buffer.length) {
truncated = true;
process.stdin.destroy();
finish();
}
});
process.stdin.once('end', finish);
process.stdin.once('error', finish);
});
}
async function cli() {
const expectedEvent = EVENTS.has(process.argv[2]) ? process.argv[2] : null;
const hookId = typeof process.argv[3] === 'string' ? process.argv[3] : '';
const profiles = typeof process.argv[4] === 'string'
? process.argv[4]
: 'minimal,standard,strict';
const input = await readStdinBounded();
const passthrough = input.truncated ? '' : input.raw;
if (hookId && !isHookEnabled(hookId, { profiles })) {
process.exitCode = 0;
process.stdout.write(passthrough);
return;
}
if (isDryRun()) {
process.exitCode = 0;
process.stderr.write(
`[DryRun] Hook "${hookId || 'hookify'}" would evaluate ${expectedEvent || 'an unknown event'} rules\n`
);
process.stdout.write(passthrough);
return;
}
const result = run(input.raw, {
expectedEvent,
truncated: input.truncated,
projectRoot: process.cwd(),
});
process.exitCode = 0;
process.stdout.write(result.stdout);
}
if (require.main === module) {
cli().catch(() => {
process.exitCode = 0;
process.stdout.write(boundOutput(failOpen(process.argv[2], 'internal runtime failure')));
});
}
module.exports = {
LIMITS,
boundOutput,
buildOutput,
cli,
run,
validateInput,
};