mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-20 14:32:24 +02:00
505 lines
17 KiB
JavaScript
505 lines
17 KiB
JavaScript
/**
|
|
* Unit tests for Hookify condition evaluation.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const assert = require('assert');
|
|
|
|
const {
|
|
evaluateRules,
|
|
extractConditionValue,
|
|
truncateUtf8,
|
|
} = require('../../scripts/hooks/hookify-engine');
|
|
const {
|
|
evaluateTasks,
|
|
MAX_DIAGNOSTICS,
|
|
writeResult,
|
|
} = require('../../scripts/hooks/hookify-regex-worker');
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(` ✓ ${name}`);
|
|
return true;
|
|
} catch (error) {
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` Error: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function rule(overrides = {}) {
|
|
return {
|
|
name: 'test-rule',
|
|
enabled: true,
|
|
event: 'bash',
|
|
action: 'warn',
|
|
toolMatcher: null,
|
|
conditions: [{
|
|
field: 'command',
|
|
operator: 'regex_match',
|
|
pattern: 'npm\\s+publish',
|
|
}],
|
|
message: 'Check the release.',
|
|
source: 'hookify.test-rule.local.md',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function deepFreeze(value) {
|
|
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
|
Object.freeze(value);
|
|
for (const child of Object.values(value)) deepFreeze(child);
|
|
return value;
|
|
}
|
|
|
|
function runTests() {
|
|
console.log('\n=== Hookify engine tests ===\n');
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
if (test('evaluates regexes case-insensitively and requires every condition', () => {
|
|
const rules = [
|
|
rule({
|
|
conditions: [
|
|
{ field: 'command', operator: 'regex_match', pattern: 'NPM\\s+PUBLISH' },
|
|
{ field: 'command', operator: 'not_contains', pattern: '--dry-run' },
|
|
],
|
|
}),
|
|
];
|
|
const matching = {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: 'npm publish' },
|
|
};
|
|
const notMatching = {
|
|
...matching,
|
|
tool_input: { command: 'npm publish --dry-run' },
|
|
};
|
|
|
|
assert.deepStrictEqual(evaluateRules(rules, matching).matches.map(item => item.name), ['test-rule']);
|
|
assert.deepStrictEqual(evaluateRules(rules, notMatching).matches, []);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('supports every documented non-regex operator', () => {
|
|
const input = {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Write',
|
|
tool_input: {
|
|
file_path: '/repo/.env',
|
|
content: 'PREFIX=abc\nAPI_KEY=secret\nSUFFIX=xyz',
|
|
},
|
|
};
|
|
const rules = [
|
|
rule({
|
|
event: 'file',
|
|
conditions: [
|
|
{ field: 'file_path', operator: 'ends_with', pattern: '.env' },
|
|
{ field: 'content', operator: 'contains', pattern: 'API_KEY' },
|
|
{ field: 'content', operator: 'starts_with', pattern: 'PREFIX' },
|
|
{ field: 'content', operator: 'ends_with', pattern: 'xyz' },
|
|
{ field: 'file_path', operator: 'equals', pattern: '/repo/.env' },
|
|
{ field: 'content', operator: 'not_contains', pattern: 'SAFE_PLACEHOLDER' },
|
|
],
|
|
}),
|
|
];
|
|
|
|
assert.deepStrictEqual(evaluateRules(rules, input).matches.map(item => item.name), ['test-rule']);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('extracts safe event fields without reading transcript paths', () => {
|
|
const multiEdit = {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'MultiEdit',
|
|
tool_input: {
|
|
file_path: '/repo/index.js',
|
|
edits: [
|
|
{ old_string: 'before one', new_string: 'after one' },
|
|
{ old_string: 'before two', new_string: 'after two' },
|
|
],
|
|
},
|
|
};
|
|
const stop = {
|
|
hook_event_name: 'Stop',
|
|
transcript_path: '/private/transcript.jsonl',
|
|
last_assistant_message: 'Finished safely.',
|
|
};
|
|
|
|
assert.strictEqual(extractConditionValue('new_text', multiEdit), 'after one\nafter two');
|
|
assert.strictEqual(extractConditionValue('old_text', multiEdit), 'before one\nbefore two');
|
|
assert.strictEqual(extractConditionValue('content', stop), 'Finished safely.');
|
|
assert.strictEqual(extractConditionValue('transcript', stop), null);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('uses the Claude prompt field for user_prompt conditions', () => {
|
|
const input = {
|
|
hook_event_name: 'UserPromptSubmit',
|
|
prompt: 'deploy production now',
|
|
user_prompt: 'this compatibility field must not be trusted',
|
|
};
|
|
const promptRule = rule({
|
|
event: 'prompt',
|
|
conditions: [{
|
|
field: 'user_prompt',
|
|
operator: 'contains',
|
|
pattern: 'deploy production',
|
|
}],
|
|
});
|
|
|
|
assert.deepStrictEqual(evaluateRules([promptRule], input).matches.map(item => item.name), ['test-rule']);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('honors exact pipe-separated tool matchers', () => {
|
|
const matchedRule = rule({ toolMatcher: 'Bash|Write' });
|
|
const bash = {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: 'npm publish' },
|
|
};
|
|
const edit = {
|
|
...bash,
|
|
tool_name: 'Edit',
|
|
tool_input: { command: 'npm publish' },
|
|
};
|
|
|
|
assert.strictEqual(evaluateRules([matchedRule], bash).matches.length, 1);
|
|
assert.strictEqual(evaluateRules([matchedRule], edit).matches.length, 0);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('does not mutate hook input while evaluating file edits', () => {
|
|
const input = deepFreeze({
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Edit',
|
|
tool_input: {
|
|
file_path: '/repo/index.js',
|
|
old_string: 'old',
|
|
new_string: 'new API_KEY',
|
|
},
|
|
});
|
|
const snapshot = JSON.stringify(input);
|
|
const fileRule = rule({
|
|
event: 'file',
|
|
conditions: [{
|
|
field: 'new_text',
|
|
operator: 'contains',
|
|
pattern: 'API_KEY',
|
|
}],
|
|
});
|
|
|
|
assert.strictEqual(evaluateRules([fileRule], input).matches.length, 1);
|
|
assert.strictEqual(JSON.stringify(input), snapshot);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('blocking rules inspect complete accepted fields beyond the old 64 KiB boundary', () => {
|
|
const sentinel = 'HOOKIFY_BLOCK_AFTER_64_KIB';
|
|
const longPrefix = 'x'.repeat(70 * 1024);
|
|
const cases = [
|
|
{
|
|
field: 'command',
|
|
input: {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: `${longPrefix}${sentinel}` },
|
|
},
|
|
},
|
|
{
|
|
field: 'content',
|
|
input: {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Write',
|
|
tool_input: {
|
|
file_path: '/repo/output.txt',
|
|
content: `${longPrefix}${sentinel}`,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
field: 'user_prompt',
|
|
input: {
|
|
hook_event_name: 'UserPromptSubmit',
|
|
prompt: `${longPrefix}${sentinel}`,
|
|
},
|
|
},
|
|
{
|
|
field: 'content',
|
|
input: {
|
|
hook_event_name: 'Stop',
|
|
last_assistant_message: `${longPrefix}${sentinel}`,
|
|
},
|
|
},
|
|
{
|
|
field: 'new_text',
|
|
input: {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'MultiEdit',
|
|
tool_input: {
|
|
file_path: '/repo/output.txt',
|
|
edits: [
|
|
{ old_string: 'before', new_string: longPrefix },
|
|
{ old_string: 'after', new_string: sentinel },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
for (const { field, input } of cases) {
|
|
assert.ok(
|
|
Buffer.byteLength(JSON.stringify(input), 'utf8') < 256 * 1024,
|
|
`${field} fixture must fit within the accepted hook input`
|
|
);
|
|
const blockingRule = rule({
|
|
action: 'block',
|
|
conditions: [{ field, operator: 'contains', pattern: sentinel }],
|
|
});
|
|
assert.deepStrictEqual(
|
|
evaluateRules([blockingRule], input).matches.map(item => item.name),
|
|
['test-rule'],
|
|
`${field} must be evaluated through its complete accepted value`
|
|
);
|
|
}
|
|
|
|
const manyEditsInput = {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'MultiEdit',
|
|
tool_input: {
|
|
file_path: '/repo/output.txt',
|
|
edits: [
|
|
...Array.from(
|
|
{ length: 300 },
|
|
() => ({ old_string: 'before', new_string: 'ordinary edit' })
|
|
),
|
|
{ old_string: 'after', new_string: sentinel },
|
|
],
|
|
},
|
|
};
|
|
assert.ok(
|
|
Buffer.byteLength(JSON.stringify(manyEditsInput), 'utf8') < 256 * 1024,
|
|
'MultiEdit fixture must fit within the accepted hook input'
|
|
);
|
|
assert.deepStrictEqual(
|
|
evaluateRules([
|
|
rule({
|
|
action: 'block',
|
|
conditions: [{ field: 'new_text', operator: 'contains', pattern: sentinel }],
|
|
}),
|
|
], manyEditsInput).matches.map(item => item.name),
|
|
['test-rule'],
|
|
'every edit in an accepted MultiEdit payload must be evaluated'
|
|
);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('fails open with a sanitized diagnostic for invalid regex syntax', () => {
|
|
const input = {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: 'anything' },
|
|
};
|
|
const result = evaluateRules([
|
|
rule({
|
|
name: 'bad-regex',
|
|
source: 'hookify.bad-regex.local.md',
|
|
conditions: [{ field: 'command', operator: 'regex_match', pattern: '(' }],
|
|
}),
|
|
], input);
|
|
|
|
assert.deepStrictEqual(result.matches, []);
|
|
assert.strictEqual(result.diagnostics.length, 1);
|
|
assert.strictEqual(result.diagnostics[0].code, 'HOOKIFY_REGEX_INVALID');
|
|
assert.ok(!result.diagnostics[0].message.includes('anything'));
|
|
})) passed++; else failed++;
|
|
|
|
if (test('caps invalid-regex diagnostics without discarding valid matches', () => {
|
|
const input = {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: 'npm publish' },
|
|
};
|
|
const invalidRules = Array.from(
|
|
{ length: MAX_DIAGNOSTICS + 20 },
|
|
(_value, index) => rule({
|
|
name: `invalid-${index}`,
|
|
source: `hookify.invalid-${index}.local.md`,
|
|
conditions: [{ field: 'command', operator: 'regex_match', pattern: '(' }],
|
|
})
|
|
);
|
|
const validRule = rule({ name: 'valid-regex' });
|
|
|
|
const result = evaluateRules([...invalidRules, validRule], input);
|
|
|
|
assert.deepStrictEqual(result.matches.map(item => item.name), ['valid-regex']);
|
|
assert.ok(result.diagnostics.length <= MAX_DIAGNOSTICS + 1);
|
|
assert.ok(result.diagnostics.some(item => item.code === 'HOOKIFY_DIAGNOSTICS_TRUNCATED'));
|
|
})) passed++; else failed++;
|
|
|
|
if (test('a regex timeout preserves an unrelated literal-only block match', () => {
|
|
const input = {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: `literal-hit ${'a'.repeat(30000)}!` },
|
|
};
|
|
const dangerousRules = Array.from({ length: 8 }, (_, index) => rule({
|
|
name: `danger-${index}`,
|
|
source: `hookify.danger-${index}.local.md`,
|
|
conditions: [{ field: 'command', operator: 'regex_match', pattern: '(a+)+$' }],
|
|
}));
|
|
const literalBlock = rule({
|
|
name: 'literal-block',
|
|
action: 'block',
|
|
conditions: [{ field: 'command', operator: 'contains', pattern: 'literal-hit' }],
|
|
});
|
|
const startedAt = Date.now();
|
|
|
|
const result = evaluateRules([...dangerousRules, literalBlock], input, { timeoutMs: 100 });
|
|
const elapsed = Date.now() - startedAt;
|
|
|
|
assert.deepStrictEqual(result.matches.map(item => item.name), ['literal-block']);
|
|
assert.ok(result.diagnostics.some(item => item.code === 'HOOKIFY_REGEX_TIMEOUT'));
|
|
assert.ok(elapsed < 1500, `regex worker should be terminated promptly, took ${elapsed}ms`);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('bounds multibyte fields and handles absent or incompatible field shapes', () => {
|
|
const acceptedValue = `${'a'.repeat(70 * 1024)}界tail`;
|
|
const preservedValue = truncateUtf8(acceptedValue);
|
|
assert.strictEqual(
|
|
Buffer.byteLength(preservedValue, 'utf8'),
|
|
Buffer.byteLength(acceptedValue, 'utf8'),
|
|
'a field within the hook input cap must not be partially evaluated'
|
|
);
|
|
assert.ok(preservedValue.endsWith('界tail'));
|
|
|
|
const longValue = `${'a'.repeat(256 * 1024 - 1)}界tail`;
|
|
const truncated = truncateUtf8(longValue);
|
|
assert.ok(Buffer.byteLength(truncated, 'utf8') <= 256 * 1024);
|
|
assert.ok(!truncated.includes('\ufffd'));
|
|
|
|
assert.strictEqual(extractConditionValue('command', null), null);
|
|
assert.strictEqual(extractConditionValue('command', []), null);
|
|
assert.strictEqual(extractConditionValue('command', {
|
|
tool_name: 'Read',
|
|
tool_input: {},
|
|
}), null);
|
|
assert.strictEqual(extractConditionValue('file_path', {
|
|
tool_name: 'Read',
|
|
tool_input: {},
|
|
}), null);
|
|
assert.strictEqual(extractConditionValue('new_text', {
|
|
tool_name: 'Read',
|
|
tool_input: {},
|
|
}), null);
|
|
assert.strictEqual(extractConditionValue('old_text', {
|
|
tool_name: 'Write',
|
|
tool_input: { old_text: 'before' },
|
|
}), 'before');
|
|
assert.strictEqual(extractConditionValue('user_prompt', {
|
|
hook_event_name: 'Stop',
|
|
prompt: 'ignored',
|
|
}), null);
|
|
assert.strictEqual(extractConditionValue('content', {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Read',
|
|
tool_input: {},
|
|
}), null);
|
|
assert.strictEqual(extractConditionValue('unknown', {}), null);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('normalizes Write/Edit fallbacks and skips malformed MultiEdit entries', () => {
|
|
assert.strictEqual(extractConditionValue('content', {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Write',
|
|
tool_input: { new_text: 'new text' },
|
|
}), 'new text');
|
|
assert.strictEqual(extractConditionValue('content', {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Edit',
|
|
tool_input: { new_string: 'new string' },
|
|
}), 'new string');
|
|
assert.strictEqual(extractConditionValue('new_text', {
|
|
tool_name: 'MultiEdit',
|
|
tool_input: {
|
|
edits: [null, [], { new_string: 42 }, { new_string: 'accepted' }],
|
|
},
|
|
}), 'accepted');
|
|
assert.strictEqual(extractConditionValue('old_text', {
|
|
tool_name: 'MultiEdit',
|
|
tool_input: {},
|
|
}), null);
|
|
const notebookEdit = {
|
|
tool_name: 'NotebookEdit',
|
|
tool_input: {
|
|
notebook_path: '/repo/analysis.ipynb',
|
|
new_source: 'print("safe")',
|
|
},
|
|
};
|
|
assert.strictEqual(
|
|
extractConditionValue('file_path', notebookEdit),
|
|
'/repo/analysis.ipynb'
|
|
);
|
|
assert.strictEqual(extractConditionValue('new_text', notebookEdit), 'print("safe")');
|
|
assert.strictEqual(extractConditionValue('content', notebookEdit), 'print("safe")');
|
|
})) passed++; else failed++;
|
|
|
|
if (test('handles empty rule sets, filtered tools, and bounded timeout options', () => {
|
|
const input = {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: 'npm publish' },
|
|
};
|
|
assert.deepStrictEqual(evaluateRules(null, input), { matches: [], diagnostics: [] });
|
|
assert.deepStrictEqual(evaluateRules([], input), { matches: [], diagnostics: [] });
|
|
assert.deepStrictEqual(
|
|
evaluateRules([rule({ toolMatcher: 'Write' })], input),
|
|
{ matches: [], diagnostics: [] }
|
|
);
|
|
assert.strictEqual(evaluateRules([rule()], input, { timeoutMs: -1 }).matches.length, 1);
|
|
assert.strictEqual(evaluateRules([rule()], input, { timeoutMs: 5000 }).matches.length, 1);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('worker helper bounds result serialization and rejects unsupported operators', () => {
|
|
const evaluated = evaluateTasks([
|
|
{
|
|
index: 0,
|
|
source: '../unsafe-name',
|
|
conditions: [{ field: 'command', operator: 'regex_match', pattern: '(' }],
|
|
},
|
|
{
|
|
index: 1,
|
|
source: 'hookify.operator.local.md',
|
|
conditions: [{ field: 'command', operator: 'unsupported', pattern: 'x' }],
|
|
},
|
|
], { command: 'anything' });
|
|
assert.deepStrictEqual(evaluated.matchedIndexes, []);
|
|
assert.strictEqual(evaluated.diagnostics[0].code, 'HOOKIFY_REGEX_INVALID');
|
|
assert.ok(evaluated.diagnostics[0].message.includes('a Hookify rule'));
|
|
|
|
const shared = new SharedArrayBuffer(512);
|
|
writeResult(shared, {
|
|
matchedIndexes: [2],
|
|
diagnostics: [],
|
|
});
|
|
let header = new Int32Array(shared, 0, 2);
|
|
assert.strictEqual(Atomics.load(header, 0), 1);
|
|
let bytes = new Uint8Array(shared, 8, Atomics.load(header, 1));
|
|
let result = JSON.parse(Buffer.from(bytes).toString('utf8'));
|
|
assert.deepStrictEqual(result.matchedIndexes, [2]);
|
|
|
|
writeResult(shared, {
|
|
matchedIndexes: [],
|
|
diagnostics: [{ code: 'X', message: 'x'.repeat(1000) }],
|
|
});
|
|
header = new Int32Array(shared, 0, 2);
|
|
assert.strictEqual(Atomics.load(header, 0), 2);
|
|
bytes = new Uint8Array(shared, 8, Atomics.load(header, 1));
|
|
result = JSON.parse(Buffer.from(bytes).toString('utf8'));
|
|
assert.strictEqual(result.diagnostics[0].code, 'HOOKIFY_REGEX_WORKER_FAILED');
|
|
})) passed++; else failed++;
|
|
|
|
console.log(`\nPassed: ${passed}`);
|
|
console.log(`Failed: ${failed}`);
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
runTests();
|