/** * 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();