Files
ECC/tests/hooks/hookify-runner.test.js
T

664 lines
23 KiB
JavaScript

/**
* Integration tests for the Hookify hook runner and Claude output contracts.
*/
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const {
LIMITS,
boundOutput,
buildOutput,
run,
validateInput,
} = require('../../scripts/hooks/hookify-runner');
const RUNNER_PATH = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'hookify-runner.js');
const REPO_ROOT = path.join(__dirname, '..', '..');
const HOOKS_PATH = path.join(REPO_ROOT, 'hooks', 'hooks.json');
function test(name, fn) {
try {
fn();
console.log(` ✓ ${name}`);
return true;
} catch (error) {
console.log(` ✗ ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function withProject(fn) {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hookify-runner-'));
const claudeDir = path.join(projectRoot, '.claude');
fs.mkdirSync(claudeDir);
try {
return fn({ projectRoot, claudeDir });
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
}
function writeRule(claudeDir, {
name = 'test-rule',
enabled = true,
event = 'bash',
action = 'warn',
pattern = 'danger',
message = 'Correct this behavior.',
}) {
fs.writeFileSync(
path.join(claudeDir, `hookify.${name}.local.md`),
[
'---',
`name: ${name}`,
`enabled: ${enabled}`,
`event: ${event}`,
`action: ${action}`,
`pattern: ${pattern}`,
'---',
message,
'',
].join('\n')
);
}
function invoke(projectRoot, eventName, payload, context = {}) {
const result = run(JSON.stringify(payload), {
projectRoot,
expectedEvent: eventName,
...context,
});
assert.strictEqual(result.exitCode, 0);
assert.strictEqual(result.stderr, '');
return JSON.parse(result.stdout);
}
function runConfiguredCommand(entry, projectRoot, payload, env = {}) {
return spawnSync(entry.hooks[0].command, {
shell: true,
cwd: projectRoot,
input: JSON.stringify(payload),
encoding: 'utf8',
env: {
...process.env,
CLAUDE_PLUGIN_ROOT: REPO_ROOT,
ECC_PLUGIN_ROOT: REPO_ROOT,
...env,
},
timeout: 10000,
});
}
function runTests() {
console.log('\n=== Hookify runner tests ===\n');
let passed = 0;
let failed = 0;
if (test('PreToolUse warnings reach Claude through additionalContext', () => {
withProject(({ projectRoot, claudeDir }) => {
writeRule(claudeDir, { message: 'Use a safer command.' });
const output = invoke(projectRoot, 'PreToolUse', {
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'danger --now' },
});
assert.deepStrictEqual(Object.keys(output), ['hookSpecificOutput']);
assert.strictEqual(output.hookSpecificOutput.hookEventName, 'PreToolUse');
assert.ok(output.hookSpecificOutput.additionalContext.includes('[test-rule]'));
assert.ok(output.hookSpecificOutput.additionalContext.includes('Use a safer command.'));
assert.ok(!('updatedInput' in output.hookSpecificOutput), 'Hookify must not mutate tool input');
});
})) passed++; else failed++;
if (test('PreToolUse blocks use permissionDecision deny without changing input', () => {
withProject(({ projectRoot, claudeDir }) => {
writeRule(claudeDir, { action: 'block', message: 'This command is prohibited.' });
const payload = {
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'danger' },
};
const snapshot = JSON.stringify(payload);
const output = invoke(projectRoot, 'PreToolUse', payload);
assert.deepStrictEqual(output, {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: '**[test-rule]**\nThis command is prohibited.',
},
});
assert.strictEqual(JSON.stringify(payload), snapshot);
});
})) passed++; else failed++;
if (test('PreToolUse blocks inspect accepted command text beyond 64 KiB', () => {
withProject(({ projectRoot, claudeDir }) => {
const sentinel = 'HOOKIFY_BLOCK_AFTER_64_KIB';
writeRule(claudeDir, {
action: 'block',
pattern: sentinel,
message: 'This late command content is prohibited.',
});
const payload = {
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: `${'x'.repeat(70 * 1024)}${sentinel}` },
};
assert.ok(Buffer.byteLength(JSON.stringify(payload), 'utf8') < LIMITS.maxInputBytes);
const output = invoke(projectRoot, 'PreToolUse', payload);
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(
output.hookSpecificOutput.permissionDecisionReason.includes(
'This late command content is prohibited.'
)
);
});
})) passed++; else failed++;
if (test('simple file patterns block matching file paths rather than changed content', () => {
withProject(({ projectRoot, claudeDir }) => {
writeRule(claudeDir, {
event: 'file',
action: 'block',
pattern: '\\.env$',
message: 'Environment files are protected.',
});
const blocked = invoke(projectRoot, 'PreToolUse', {
hook_event_name: 'PreToolUse',
tool_name: 'Write',
tool_input: { file_path: '/repo/.env', content: 'SAFE=value' },
});
assert.strictEqual(blocked.hookSpecificOutput.permissionDecision, 'deny');
const contentOnly = invoke(projectRoot, 'PreToolUse', {
hook_event_name: 'PreToolUse',
tool_name: 'Write',
tool_input: { file_path: '/repo/README.md', content: 'mentions .env' },
});
assert.deepStrictEqual(contentOnly, {});
});
})) passed++; else failed++;
if (test('PostToolUse block feedback truthfully says the completed tool is not undone', () => {
withProject(({ projectRoot, claudeDir }) => {
writeRule(claudeDir, { action: 'block', message: 'Repair the result.' });
const output = invoke(projectRoot, 'PostToolUse', {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'danger' },
tool_response: { ok: true },
});
assert.strictEqual(output.decision, 'block');
assert.ok(output.reason.includes('already completed'));
assert.ok(output.reason.includes('cannot undo'));
assert.ok(output.reason.includes('Repair the result.'));
});
})) passed++; else failed++;
if (test('UserPromptSubmit blocks with the documented top-level decision shape', () => {
withProject(({ projectRoot, claudeDir }) => {
writeRule(claudeDir, {
event: 'prompt',
action: 'block',
pattern: 'production',
message: 'Clarify the deployment target.',
});
const output = invoke(projectRoot, 'UserPromptSubmit', {
hook_event_name: 'UserPromptSubmit',
prompt: 'deploy production',
});
assert.deepStrictEqual(output, {
decision: 'block',
reason: '**[test-rule]**\nClarify the deployment target.',
});
});
})) passed++; else failed++;
if (test('Stop blocks continue Claude but Stop warnings are only non-blocking systemMessage output', () => {
withProject(({ projectRoot, claudeDir }) => {
writeRule(claudeDir, {
name: 'block-stop',
event: 'stop',
action: 'block',
pattern: 'unfinished',
message: 'Finish verification before stopping.',
});
writeRule(claudeDir, {
name: 'warn-stop',
event: 'stop',
action: 'warn',
pattern: 'unfinished',
message: 'A non-blocking Stop warning.',
});
const output = invoke(projectRoot, 'Stop', {
hook_event_name: 'Stop',
stop_hook_active: false,
last_assistant_message: 'Work is unfinished.',
transcript_path: '/must/not/be/read.jsonl',
});
assert.strictEqual(output.decision, 'block');
assert.ok(output.reason.includes('Finish verification before stopping.'));
assert.ok(output.systemMessage.includes('A non-blocking Stop warning.'));
const recursiveStop = invoke(projectRoot, 'Stop', {
hook_event_name: 'Stop',
stop_hook_active: true,
last_assistant_message: 'Work is unfinished.',
});
assert.deepStrictEqual(
recursiveStop,
{},
'an active Stop hook must not re-block and create an infinite continuation loop'
);
fs.unlinkSync(path.join(claudeDir, 'hookify.block-stop.local.md'));
const warningOnly = invoke(projectRoot, 'Stop', {
hook_event_name: 'Stop',
stop_hook_active: false,
last_assistant_message: 'Work is unfinished.',
});
assert.deepStrictEqual(Object.keys(warningOnly), ['systemMessage']);
assert.ok(!('decision' in warningOnly));
assert.ok(!('hookSpecificOutput' in warningOnly));
});
})) passed++; else failed++;
if (test('never reads transcript_path while evaluating Stop rules', () => {
withProject(({ projectRoot, claudeDir }) => {
const transcriptPath = path.join(projectRoot, 'transcript.jsonl');
fs.writeFileSync(transcriptPath, 'TRANSCRIPT_SENTINEL');
writeRule(claudeDir, {
event: 'stop',
action: 'block',
pattern: 'TRANSCRIPT_SENTINEL',
});
const output = invoke(projectRoot, 'Stop', {
hook_event_name: 'Stop',
last_assistant_message: 'Safe final response.',
transcript_path: transcriptPath,
});
assert.deepStrictEqual(output, {});
});
})) passed++; else failed++;
if (test('malformed rules fail open and diagnostics reach Claude without leaking paths', () => {
withProject(({ projectRoot, claudeDir }) => {
fs.writeFileSync(
path.join(claudeDir, 'hookify.broken.local.md'),
'---\nname: broken\nenabled: maybe\nevent: bash\npattern: danger\n---\nBroken.\n'
);
const output = invoke(projectRoot, 'PreToolUse', {
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'danger' },
});
assert.ok(output.hookSpecificOutput.additionalContext.includes('Hookify diagnostic'));
assert.ok(output.hookSpecificOutput.additionalContext.includes('hookify.broken.local.md'));
assert.ok(!output.hookSpecificOutput.additionalContext.includes(projectRoot));
assert.ok(!('permissionDecision' in output.hookSpecificOutput));
});
})) passed++; else failed++;
if (test('rejects mismatched, malformed, and oversized hook inputs with bounded fail-open JSON', () => {
withProject(({ projectRoot }) => {
for (const [raw, context] of [
['{bad json', { expectedEvent: 'PreToolUse' }],
[JSON.stringify({ hook_event_name: 'Stop' }), { expectedEvent: 'PreToolUse' }],
[JSON.stringify({ hook_event_name: 'PreToolUse' }), { expectedEvent: 'PreToolUse', truncated: true }],
]) {
const result = run(raw, { projectRoot, ...context });
assert.strictEqual(result.exitCode, 0);
assert.strictEqual(result.stderr, '');
assert.ok(Buffer.byteLength(result.stdout) <= LIMITS.maxOutputBytes);
const output = JSON.parse(result.stdout);
assert.ok(output.hookSpecificOutput.additionalContext.includes('Hookify diagnostic'));
assert.ok(!('permissionDecision' in output.hookSpecificOutput));
}
});
})) passed++; else failed++;
if (test('caps combined messages and CLI stdin while always emitting valid structured JSON', () => {
withProject(({ projectRoot, claudeDir }) => {
for (let index = 0; index < 12; index += 1) {
writeRule(claudeDir, {
name: `warning-${index}`,
message: `Message ${index}: ${'界'.repeat(1400)}`,
});
}
const output = run(JSON.stringify({
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'danger' },
}), {
projectRoot,
expectedEvent: 'PreToolUse',
});
assert.ok(Buffer.byteLength(output.stdout) <= LIMITS.maxOutputBytes);
JSON.parse(output.stdout);
const oversized = JSON.stringify({
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'x'.repeat(LIMITS.maxInputBytes + 1024) },
});
const cli = spawnSync(process.execPath, [RUNNER_PATH, 'PreToolUse'], {
cwd: projectRoot,
input: oversized,
encoding: 'utf8',
timeout: 5000,
});
assert.strictEqual(cli.status, 0, cli.stderr);
assert.strictEqual(cli.stderr, '');
assert.ok(Buffer.byteLength(cli.stdout) <= LIMITS.maxOutputBytes);
const cliOutput = JSON.parse(cli.stdout);
assert.ok(cliOutput.hookSpecificOutput.additionalContext.includes('input exceeded'));
});
})) passed++; else failed++;
if (test('hooks.json registers bounded Hookify entrypoints for all four events', () => {
const hooks = JSON.parse(fs.readFileSync(HOOKS_PATH, 'utf8')).hooks;
const pre = hooks.PreToolUse.find(entry => entry.id === 'pre:hookify');
const stop = hooks.Stop.find(entry => entry.id === 'stop:hookify');
const prompt = hooks.UserPromptSubmit.find(entry => entry.id === 'prompt:hookify');
for (const [eventName, entry] of [
['PreToolUse', pre],
['Stop', stop],
['UserPromptSubmit', prompt],
]) {
assert.ok(entry, `${eventName} should register Hookify`);
assert.ok(entry.hooks[0].command.includes('hookify-runner.js'));
assert.ok(entry.hooks[0].command.includes(eventName));
assert.ok(!entry.hooks[0].command.includes('plugin-hook-bootstrap.js'));
assert.ok(!entry.hooks[0].command.includes('run-with-flags.js'));
assert.ok(!entry.hooks[0].command.includes("readFileSync(0"));
assert.ok(!entry.hooks[0].command.includes('spawnSync'));
assert.ok(entry.hooks[0].timeout > 0);
}
const dispatcherSource = fs.readFileSync(
path.join(REPO_ROOT, 'scripts', 'hooks', 'posttooluse-dispatcher.js'),
'utf8'
);
assert.ok(dispatcherSource.includes("id: 'post:hookify'"));
assert.ok(dispatcherSource.includes("expectedEvent: 'PostToolUse'"));
assert.deepStrictEqual(
hooks.PostToolUse.map(entry => entry.id),
['post:dispatcher:sync', 'post:dispatcher:async'],
'PostToolUse should retain its two-process dispatcher contract'
);
})) passed++; else failed++;
if (test('configured hook commands enforce project rules end to end and ignore payload cwd', () => {
withProject(({ projectRoot, claudeDir }) => {
writeRule(claudeDir, {
event: 'all',
pattern: 'danger',
message: 'Configured Hookify warning.',
});
const hooks = JSON.parse(fs.readFileSync(HOOKS_PATH, 'utf8')).hooks;
const cases = [
{
eventName: 'PreToolUse',
entry: hooks.PreToolUse.find(entry => entry.id === 'pre:hookify'),
payload: {
hook_event_name: 'PreToolUse',
cwd: path.join(projectRoot, 'untrusted-cwd'),
tool_name: 'Bash',
tool_input: { command: 'danger' },
},
field: 'hookSpecificOutput',
},
{
eventName: 'UserPromptSubmit',
entry: hooks.UserPromptSubmit.find(entry => entry.id === 'prompt:hookify'),
payload: {
hook_event_name: 'UserPromptSubmit',
cwd: path.join(projectRoot, 'untrusted-cwd'),
prompt: 'danger',
},
field: 'hookSpecificOutput',
},
{
eventName: 'Stop',
entry: hooks.Stop.find(entry => entry.id === 'stop:hookify'),
payload: {
hook_event_name: 'Stop',
cwd: path.join(projectRoot, 'untrusted-cwd'),
last_assistant_message: 'danger',
},
field: 'systemMessage',
},
{
eventName: 'PostToolUse',
entry: hooks.PostToolUse.find(entry => entry.id === 'post:dispatcher:sync'),
payload: {
hook_event_name: 'PostToolUse',
cwd: path.join(projectRoot, 'untrusted-cwd'),
tool_name: 'Bash',
tool_input: { command: 'danger' },
tool_response: {},
},
field: 'hookSpecificOutput',
env: {
ECC_HOOK_PROFILE: 'minimal',
ECC_DISABLED_HOOKS: 'post:ecc-metrics-bridge',
},
},
];
for (const item of cases) {
const result = runConfiguredCommand(
item.entry,
projectRoot,
item.payload,
item.env
);
assert.strictEqual(result.status, 0, `${item.eventName}: ${result.stderr}`);
const output = JSON.parse(result.stdout);
const text = item.field === 'systemMessage'
? output.systemMessage
: output.hookSpecificOutput?.additionalContext;
assert.ok(
text.includes('Configured Hookify warning.'),
`${item.eventName} should return the configured warning`
);
}
});
})) passed++; else failed++;
if (test('configured Hookify entrypoints return bounded fail-open output for oversized input', () => {
withProject(({ projectRoot }) => {
const hooks = JSON.parse(fs.readFileSync(HOOKS_PATH, 'utf8')).hooks;
const cases = [
{
eventName: 'PreToolUse',
entry: hooks.PreToolUse.find(entry => entry.id === 'pre:hookify'),
payload: {
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'x'.repeat(LIMITS.maxInputBytes + 64 * 1024) },
},
message(output) {
return output.hookSpecificOutput?.additionalContext;
},
},
{
eventName: 'UserPromptSubmit',
entry: hooks.UserPromptSubmit.find(entry => entry.id === 'prompt:hookify'),
payload: {
hook_event_name: 'UserPromptSubmit',
prompt: 'x'.repeat(LIMITS.maxInputBytes + 64 * 1024),
},
message(output) {
return output.hookSpecificOutput?.additionalContext;
},
},
{
eventName: 'Stop',
entry: hooks.Stop.find(entry => entry.id === 'stop:hookify'),
payload: {
hook_event_name: 'Stop',
last_assistant_message: 'x'.repeat(LIMITS.maxInputBytes + 64 * 1024),
},
message(output) {
return output.systemMessage;
},
},
];
for (const item of cases) {
const serializedInput = JSON.stringify(item.payload);
const result = runConfiguredCommand(item.entry, projectRoot, item.payload);
assert.strictEqual(result.status, 0, `${item.eventName}: ${result.stderr}`);
assert.ok(
Buffer.byteLength(result.stdout) <= LIMITS.maxOutputBytes,
`${item.eventName} output must stay within the Hookify limit`
);
assert.ok(
Buffer.byteLength(result.stdout) < Buffer.byteLength(serializedInput),
`${item.eventName} must not echo the oversized input`
);
const output = JSON.parse(result.stdout);
assert.ok(
item.message(output)?.includes('input exceeded'),
`${item.eventName} should return an event-correct fail-open diagnostic`
);
}
});
})) passed++; else failed++;
if (test('strict hook input validation rejects malformed event-specific fields', () => {
const cases = [
[null, 'PreToolUse'],
[[], 'PreToolUse'],
[{ hook_event_name: 'Stop' }, 'PreToolUse'],
[{ hook_event_name: 'PreToolUse', tool_name: '\u0000', tool_input: {} }, 'PreToolUse'],
[{ hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: [] }, 'PreToolUse'],
[{ hook_event_name: 'UserPromptSubmit', prompt: 42 }, 'UserPromptSubmit'],
[{ hook_event_name: 'Stop', last_assistant_message: 42 }, 'Stop'],
[{ hook_event_name: 'Stop', stop_hook_active: 'false' }, 'Stop'],
];
for (const [payload, eventName] of cases) {
assert.strictEqual(typeof validateInput(payload, eventName), 'string');
}
assert.strictEqual(validateInput({
hook_event_name: 'Stop',
last_assistant_message: 'done',
stop_hook_active: true,
}, 'Stop'), null);
})) passed++; else failed++;
if (test('block plus warning output preserves both event decisions and bounded context', () => {
const matches = [
{
name: 'block-rule',
action: 'block',
message: 'Block.',
},
{
name: 'warn-rule',
action: 'warn',
message: 'Warn.',
},
];
const prompt = buildOutput('UserPromptSubmit', matches, []);
assert.strictEqual(prompt.decision, 'block');
assert.ok(prompt.hookSpecificOutput.additionalContext.includes('Warn.'));
const stop = buildOutput('Stop', matches, []);
assert.strictEqual(stop.decision, 'block');
assert.ok(stop.systemMessage.includes('Warn.'));
for (const output of [
{
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: '\\'.repeat(20000),
additionalContext: '\\'.repeat(20000),
},
},
{
decision: 'block',
reason: '\\'.repeat(20000),
systemMessage: '\\'.repeat(20000),
},
]) {
const serialized = boundOutput(output);
assert.ok(Buffer.byteLength(serialized) <= LIMITS.maxOutputBytes);
JSON.parse(serialized);
}
})) passed++; else failed++;
if (test('hook IDs select their registered event and non-string input fails open', () => {
withProject(({ projectRoot }) => {
const cases = [
['pre:hookify', 'PreToolUse', {
hook_event_name: 'PreToolUse',
tool_name: 'Read',
tool_input: {},
}],
['post:hookify', 'PostToolUse', {
hook_event_name: 'PostToolUse',
tool_name: 'Read',
tool_input: {},
}],
['stop:hookify', 'Stop', {
hook_event_name: 'Stop',
last_assistant_message: '',
}],
['prompt:hookify', 'UserPromptSubmit', {
hook_event_name: 'UserPromptSubmit',
prompt: '',
}],
];
for (const [hookId, eventName, payload] of cases) {
const output = run(JSON.stringify(payload), { projectRoot, hookId });
assert.strictEqual(output.exitCode, 0);
JSON.parse(output.stdout);
const mismatched = run(JSON.stringify({
...payload,
hook_event_name: eventName === 'Stop' ? 'PreToolUse' : 'Stop',
}), { projectRoot, hookId });
const serialized = JSON.stringify(JSON.parse(mismatched.stdout));
assert.ok(serialized.includes('hook event did not match'));
}
const invalid = run(Buffer.from('not accepted'), {
projectRoot,
expectedEvent: 'PreToolUse',
});
assert.ok(
JSON.parse(invalid.stdout).hookSpecificOutput.additionalContext.includes(
'not valid JSON'
)
);
});
})) passed++; else failed++;
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();