mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-30 21:45:13 +02:00
fix: wire Hookify runtime enforcement
Load bounded project-local Hookify rules, evaluate untrusted regexes in a resource-limited worker, and emit event-correct structured warn/block outputs for PreToolUse, PostToolUse, UserPromptSubmit, and Stop. Register bounded entrypoints, prevent recursive Stop loops, document the runtime contract, and package the implementation.\n\nCloses #2561
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
#!/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,
|
||||
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 ? 3000 : 7000;
|
||||
const additionalContext = joinBounded(contextItems, contextLimit);
|
||||
const blockReason = joinBounded(blocking.map(formatRule), 3800);
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user