mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-20 22:42:22 +02:00
424 lines
12 KiB
JavaScript
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,
|
|
};
|