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

285 lines
9.2 KiB
JavaScript

#!/usr/bin/env node
/**
* Hookify field extraction and bounded rule evaluation.
*/
'use strict';
const path = require('path');
const { Worker } = require('worker_threads');
const { evaluateTasks } = require('./hookify-regex-worker');
const WORKER_PATH = path.join(__dirname, 'hookify-regex-worker.js');
const WORKER_RESULT_BYTES = 64 * 1024;
const HEADER_BYTES = Int32Array.BYTES_PER_ELEMENT * 2;
const DEFAULT_TIMEOUT_MS = 250;
const MAX_FIELD_BYTES = 256 * 1024;
// Every JSON array entry consumes at least one input byte. This ceiling keeps
// direct callers bounded without excluding any payload accepted by the runner.
const MAX_EDIT_ENTRIES = MAX_FIELD_BYTES;
function truncateUtf8(value, maxBytes = MAX_FIELD_BYTES) {
const input = String(value);
const encoded = Buffer.from(input, 'utf8');
if (encoded.length <= maxBytes) return input;
let end = maxBytes;
while (end > 0 && (encoded[end] & 0xc0) === 0x80) end -= 1;
return encoded.subarray(0, end).toString('utf8');
}
function normalizeFieldByteLimit(value) {
const requested = Number(value);
return Number.isInteger(requested) && requested > 0
? Math.min(requested, MAX_FIELD_BYTES)
: MAX_FIELD_BYTES;
}
function stringField(value, maxBytes) {
return typeof value === 'string' ? truncateUtf8(value, maxBytes) : null;
}
function editValues(toolInput, field, maxBytes) {
if (!Array.isArray(toolInput.edits)) return null;
const chunks = [];
let acceptedValues = 0;
let bytes = 0;
let inspectedEntries = 0;
for (const edit of toolInput.edits) {
if (inspectedEntries >= MAX_EDIT_ENTRIES) break;
inspectedEntries += 1;
if (!edit || typeof edit !== 'object' || Array.isArray(edit)) continue;
if (typeof edit[field] !== 'string') continue;
const separatorBytes = acceptedValues > 0 ? 1 : 0;
const contentBudget = maxBytes - bytes - separatorBytes;
if (contentBudget < 0) break;
const value = truncateUtf8(edit[field], contentBudget);
if (edit[field].length > 0 && value.length === 0 && contentBudget === 0) break;
if (separatorBytes > 0) chunks.push('\n');
chunks.push(value);
acceptedValues += 1;
bytes += separatorBytes + Buffer.byteLength(value, 'utf8');
if (Buffer.byteLength(edit[field], 'utf8') > contentBudget) break;
}
return chunks.join('');
}
function fileContent(toolName, toolInput, maxBytes) {
if (toolName === 'MultiEdit') {
return editValues(toolInput, 'new_string', maxBytes) || '';
}
if (toolName === 'NotebookEdit') {
return stringField(toolInput.new_source, maxBytes) ?? '';
}
return (
stringField(toolInput.content, maxBytes) ??
stringField(toolInput.new_text, maxBytes) ??
stringField(toolInput.new_string, maxBytes) ??
''
);
}
function extractConditionValue(field, input, options = {}) {
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
const maxBytes = normalizeFieldByteLimit(options.maxFieldBytes);
const toolName = typeof input.tool_name === 'string' ? input.tool_name : '';
const toolInput = input.tool_input &&
typeof input.tool_input === 'object' &&
!Array.isArray(input.tool_input)
? input.tool_input
: {};
switch (field) {
case 'command':
return toolName === 'Bash' ? stringField(toolInput.command, maxBytes) : null;
case 'file_path':
return ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(toolName)
? stringField(toolInput.file_path, maxBytes) ??
stringField(toolInput.notebook_path, maxBytes)
: null;
case 'new_text':
if (toolName === 'MultiEdit') return editValues(toolInput, 'new_string', maxBytes);
if (!['Edit', 'Write', 'NotebookEdit'].includes(toolName)) return null;
return (
stringField(toolInput.new_text, maxBytes) ??
stringField(toolInput.new_string, maxBytes) ??
stringField(toolInput.new_source, maxBytes) ??
stringField(toolInput.content, maxBytes)
);
case 'old_text':
if (toolName === 'MultiEdit') return editValues(toolInput, 'old_string', maxBytes);
if (!['Edit', 'Write', 'NotebookEdit'].includes(toolName)) return null;
return (
stringField(toolInput.old_text, maxBytes) ??
stringField(toolInput.old_string, maxBytes)
);
case 'user_prompt':
return input.hook_event_name === 'UserPromptSubmit'
? stringField(input.prompt, maxBytes)
: null;
case 'content':
if (input.hook_event_name === 'Stop') {
return stringField(input.last_assistant_message, maxBytes) ?? '';
}
if (input.hook_event_name === 'UserPromptSubmit') {
return stringField(input.prompt, maxBytes) ?? '';
}
if (toolName === 'Bash') return stringField(toolInput.command, maxBytes) ?? '';
if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(toolName)) {
return fileContent(toolName, toolInput, maxBytes);
}
return null;
default:
return null;
}
}
function matchesTool(matcher, toolName) {
if (!matcher || matcher === '*') return true;
return matcher.split('|').includes(toolName);
}
function safeWorkerResult() {
return {
matchedIndexes: [],
diagnostics: [{
code: 'HOOKIFY_REGEX_WORKER_FAILED',
message: 'Hookify skipped rule evaluation: isolated worker failed.',
}],
};
}
function runWorker(tasks, values, timeoutMs) {
const deadline = Date.now() + timeoutMs;
const sharedBuffer = new SharedArrayBuffer(WORKER_RESULT_BYTES);
const header = new Int32Array(sharedBuffer, 0, 2);
let worker;
try {
worker = new Worker(WORKER_PATH, {
workerData: { tasks, values, sharedBuffer },
resourceLimits: {
maxOldGenerationSizeMb: 32,
maxYoungGenerationSizeMb: 8,
codeRangeSizeMb: 8,
stackSizeMb: 2,
},
});
worker.on('error', () => {});
worker.unref();
} catch {
return safeWorkerResult();
}
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
worker.terminate().catch(() => {});
return {
matchedIndexes: [],
diagnostics: [{
code: 'HOOKIFY_REGEX_TIMEOUT',
message: 'Hookify skipped rule evaluation: regular-expression deadline exceeded.',
}],
};
}
const waitResult = Atomics.wait(header, 0, 0, remainingMs);
if (waitResult === 'timed-out') {
worker.terminate().catch(() => {});
return {
matchedIndexes: [],
diagnostics: [{
code: 'HOOKIFY_REGEX_TIMEOUT',
message: 'Hookify skipped rule evaluation: regular-expression deadline exceeded.',
}],
};
}
const state = Atomics.load(header, 0);
const outputLength = Atomics.load(header, 1);
worker.terminate().catch(() => {});
if (
state < 1 ||
outputLength < 1 ||
outputLength > WORKER_RESULT_BYTES - HEADER_BYTES
) {
return safeWorkerResult();
}
try {
const bytes = new Uint8Array(sharedBuffer, HEADER_BYTES, outputLength);
const parsed = JSON.parse(Buffer.from(bytes).toString('utf8'));
if (
!Array.isArray(parsed.matchedIndexes) ||
!Array.isArray(parsed.diagnostics)
) {
return safeWorkerResult();
}
return parsed;
} catch {
return safeWorkerResult();
}
}
function evaluateRules(rules, input, options = {}) {
if (!Array.isArray(rules) || rules.length === 0) {
return { matches: [], diagnostics: [] };
}
const toolName = typeof input?.tool_name === 'string' ? input.tool_name : '';
const tasks = [];
const fields = new Set();
for (let index = 0; index < rules.length; index += 1) {
const rule = rules[index];
if (!matchesTool(rule.toolMatcher, toolName)) continue;
tasks.push({
index,
source: rule.source,
conditions: rule.conditions.map(condition => ({
field: condition.field,
operator: condition.operator,
pattern: condition.pattern,
})),
});
for (const condition of rule.conditions) fields.add(condition.field);
}
if (tasks.length === 0) return { matches: [], diagnostics: [] };
const maxFieldBytes = normalizeFieldByteLimit(options.maxFieldBytes);
const values = {};
for (const field of fields) {
values[field] = extractConditionValue(field, input, { maxFieldBytes });
}
const literalTasks = tasks.filter(task =>
task.conditions.every(condition => condition.operator !== 'regex_match')
);
const regexTasks = tasks.filter(task =>
task.conditions.some(condition => condition.operator === 'regex_match')
);
const literalResult = evaluateTasks(literalTasks, values);
const requestedTimeout = Number(options.timeoutMs);
const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0
? Math.min(Math.floor(requestedTimeout), 1000)
: DEFAULT_TIMEOUT_MS;
const regexResult = regexTasks.length > 0
? runWorker(regexTasks, values, timeoutMs)
: { matchedIndexes: [], diagnostics: [] };
const matched = new Set(
[...literalResult.matchedIndexes, ...regexResult.matchedIndexes].filter(
index => Number.isInteger(index) && index >= 0 && index < rules.length
)
);
return {
matches: rules.filter((_rule, index) => matched.has(index)),
diagnostics: [...literalResult.diagnostics, ...regexResult.diagnostics],
};
}
module.exports = {
evaluateRules,
extractConditionValue,
matchesTool,
truncateUtf8,
};