mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 12:15:29 +02:00
Carry forward PR #3055 at92f53c4contoef648e01, preserving contributor attribution and current main. Focused maintainer fixes reuse shared input ceilings, preserve late root context through outer bootstrap, bound frontmatter before immutable parsing, and assert real oversized input in the limits workflow.
350 lines
14 KiB
JavaScript
350 lines
14 KiB
JavaScript
/**
|
|
* Hook registration and dispatcher integration tests for Hookify (#2561).
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
|
|
const repoRoot = path.join(__dirname, '..', '..');
|
|
const runtimePath = path.join(repoRoot, 'scripts', 'hooks', 'hookify-runtime.js');
|
|
const hooksPath = path.join(repoRoot, 'hooks', 'hooks.json');
|
|
const dispatcherPath = path.join(repoRoot, 'scripts', 'hooks', 'posttooluse-dispatcher.js');
|
|
const { readHooksConfig } = require('../../scripts/lib/hooks-config');
|
|
const {
|
|
REGISTERED_HOOK_MAX_STDIN_BYTES,
|
|
createHookContextScanner,
|
|
} = require('../../scripts/hooks/hook-input-limits');
|
|
|
|
function readRegisteredHooks() {
|
|
return readHooksConfig(hooksPath).hooks;
|
|
}
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(' \u2713 ' + name);
|
|
return true;
|
|
} catch (error) {
|
|
console.log(' \u2717 ' + name);
|
|
console.log(' Error: ' + error.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function createProject() {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hookify-registration-'));
|
|
fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
|
|
return root;
|
|
}
|
|
|
|
function writeRule(root, slug, frontmatter, message) {
|
|
const file = path.join(root, '.claude', 'hookify.' + slug + '.local.md');
|
|
fs.writeFileSync(file, '---\n' + frontmatter.trim() + '\n---\n' + message + '\n');
|
|
}
|
|
|
|
function runRegisteredEntry(root, entry, input, env = {}) {
|
|
return spawnSync(entry.hooks[0].command, {
|
|
cwd: root,
|
|
input: JSON.stringify(input),
|
|
encoding: 'utf8',
|
|
shell: true,
|
|
env: {
|
|
...process.env,
|
|
CLAUDE_PLUGIN_ROOT: repoRoot,
|
|
CLAUDE_PROJECT_DIR: root,
|
|
ECC_HOOK_PROFILE: 'minimal',
|
|
...env,
|
|
},
|
|
timeout: 15000,
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
});
|
|
}
|
|
|
|
console.log('\nHookify registration tests (#2561)');
|
|
console.log('\u2500'.repeat(50));
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
if (test('streaming context scanner keeps only root hook fields across chunks', () => {
|
|
const scanner = createHookContextScanner();
|
|
scanner.push('{"tool_input":{"tool_name":"Bash","content":"');
|
|
scanner.push('x'.repeat(REGISTERED_HOOK_MAX_STDIN_BYTES + 1));
|
|
scanner.push('"},"hook_event_name":"PostToolUse","tool_name":"Write",');
|
|
scanner.push('"stop_hook_active":true}');
|
|
assert.deepStrictEqual(scanner.context, {
|
|
hookEventName: 'PostToolUse',
|
|
toolName: 'Write',
|
|
stopHookActive: true,
|
|
});
|
|
})) passed++; else failed++;
|
|
|
|
if (test('registers all four events and keeps PostToolUse consolidated', () => {
|
|
const hooks = readRegisteredHooks();
|
|
assert.ok(hooks.PreToolUse.some(entry => entry.id === 'pre:hookify-runtime'));
|
|
assert.ok(hooks.UserPromptSubmit.some(entry => entry.id === 'prompt:hookify-runtime'));
|
|
assert.ok(hooks.Stop.some(entry => entry.id === 'stop:hookify-runtime'));
|
|
assert.strictEqual(hooks.PostToolUse.length, 2, 'PostToolUse must remain consolidated');
|
|
const dispatcher = require(dispatcherPath);
|
|
assert.ok(dispatcher.SYNC_HOOKS.some(entry => entry.id === 'post:hookify-runtime'));
|
|
})) passed++; else failed++;
|
|
|
|
if (test('registered hook commands enforce rules across all four events', () => {
|
|
const root = createProject();
|
|
try {
|
|
writeRule(root, 'all-events', 'name: all-events\nevent: all\naction: block\npattern: HOOKIFY_SENTINEL', 'Registered runtime matched.');
|
|
const hooks = readRegisteredHooks();
|
|
const entries = [
|
|
[hooks.PreToolUse.find(entry => entry.id === 'pre:hookify-runtime'),
|
|
{ hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'echo HOOKIFY_SENTINEL' } },
|
|
output => output.hookSpecificOutput?.permissionDecision === 'deny'],
|
|
[hooks.UserPromptSubmit.find(entry => entry.id === 'prompt:hookify-runtime'),
|
|
{ hook_event_name: 'UserPromptSubmit', prompt: 'HOOKIFY_SENTINEL' },
|
|
output => output.decision === 'block'],
|
|
[hooks.PostToolUse.find(entry => entry.id === 'post:dispatcher:sync'),
|
|
{ hook_event_name: 'PostToolUse', tool_name: 'Read', tool_input: { file_path: 'HOOKIFY_SENTINEL' }, tool_response: {} },
|
|
output => output.decision === 'block'],
|
|
[hooks.Stop.find(entry => entry.id === 'stop:hookify-runtime'),
|
|
{ hook_event_name: 'Stop', stop_hook_active: false, last_assistant_message: 'HOOKIFY_SENTINEL' },
|
|
output => output.decision === 'block'],
|
|
];
|
|
for (const [entry, input, assertion] of entries) {
|
|
const result = runRegisteredEntry(root, entry, input, { ECC_DISABLED_HOOKS: 'post:ecc-metrics-bridge' });
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
const output = JSON.parse(result.stdout);
|
|
assert.ok(assertion(output), entry.id + ' did not preserve the blocking decision');
|
|
assert.match(JSON.stringify(output), /Registered runtime matched/);
|
|
}
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('registered Hookify IDs can be disabled independently', () => {
|
|
const root = createProject();
|
|
try {
|
|
writeRule(root, 'disable', 'name: block-disabled\nevent: bash\naction: block\npattern: .*', 'Must not run.');
|
|
const hooks = readRegisteredHooks();
|
|
const entry = hooks.PreToolUse.find(item => item.id === 'pre:hookify-runtime');
|
|
const result = runRegisteredEntry(root, entry, {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: 'pwd' },
|
|
}, { ECC_DISABLED_HOOKS: 'pre:hookify-runtime' });
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(result.stdout, '');
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('direct entrypoint emits valid JSON for a matched prompt rule', () => {
|
|
const root = createProject();
|
|
try {
|
|
writeRule(root, 'prompt', 'name: warn-password\nevent: prompt\npattern: password', 'Do not paste credentials.');
|
|
const result = spawnSync(process.execPath, [runtimePath], {
|
|
cwd: root,
|
|
input: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'Here is my password' }),
|
|
encoding: 'utf8',
|
|
env: { ...process.env, CLAUDE_PROJECT_DIR: root },
|
|
timeout: 10000,
|
|
});
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
const output = JSON.parse(result.stdout);
|
|
assert.strictEqual(output.hookSpecificOutput.hookEventName, 'UserPromptSubmit');
|
|
assert.match(output.hookSpecificOutput.additionalContext, /Do not paste credentials/);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('registered PreToolUse hook fails closed on oversized file input', () => {
|
|
const root = createProject();
|
|
try {
|
|
writeRule(root, 'oversized-file', 'name: oversized-file\nevent: file\naction: block\npattern: BLOCK_ME', 'Oversized writes require review.');
|
|
const hooks = readRegisteredHooks();
|
|
const entry = hooks.PreToolUse.find(item => item.id === 'pre:hookify-runtime');
|
|
const result = runRegisteredEntry(root, entry, {
|
|
tool_input: {
|
|
tool_name: 'Bash',
|
|
file_path: 'large.txt',
|
|
content: 'x'.repeat(REGISTERED_HOOK_MAX_STDIN_BYTES + 1),
|
|
},
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Write',
|
|
});
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(JSON.parse(result.stdout).hookSpecificOutput.permissionDecision, 'deny');
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('registered stdin limit counts multibyte UTF-8 bytes', () => {
|
|
const root = createProject();
|
|
try {
|
|
writeRule(root, 'unicode', 'name: unicode\nevent: file\naction: block\npattern: NEVER_MATCH', 'Oversized input must be reviewed.');
|
|
const hooks = readRegisteredHooks();
|
|
const entry = hooks.PreToolUse.find(item => item.id === 'pre:hookify-runtime');
|
|
const result = runRegisteredEntry(root, entry, {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Write',
|
|
tool_input: { content: '\u754c'.repeat(400000) },
|
|
});
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(JSON.parse(result.stdout).hookSpecificOutput.permissionDecision, 'deny');
|
|
assert.match(result.stderr, /stdin exceeded 1048576 bytes/);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('oversized registered Write ignores an unrelated bash-only block rule', () => {
|
|
const root = createProject();
|
|
try {
|
|
writeRule(
|
|
root,
|
|
'bash-only',
|
|
'name: bash-only\nevent: bash\naction: block\npattern: .*',
|
|
'Bash only.'
|
|
);
|
|
const hooks = readRegisteredHooks();
|
|
const entry = hooks.PreToolUse.find(item => item.id === 'pre:hookify-runtime');
|
|
const result = runRegisteredEntry(root, entry, {
|
|
tool_input: {
|
|
tool_name: 'Bash',
|
|
file_path: 'large.txt',
|
|
content: 'x'.repeat(REGISTERED_HOOK_MAX_STDIN_BYTES + 1),
|
|
},
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Write',
|
|
});
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(result.stdout, '');
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('oversized registered PostToolUse keeps its tool type after truncation', () => {
|
|
const root = createProject();
|
|
try {
|
|
writeRule(
|
|
root,
|
|
'bash-only',
|
|
'name: bash-only\nevent: bash\naction: block\npattern: .*',
|
|
'Bash only.'
|
|
);
|
|
const hooks = readRegisteredHooks();
|
|
const entry = hooks.PostToolUse.find(item => item.id === 'post:dispatcher:sync');
|
|
const result = runRegisteredEntry(root, entry, {
|
|
tool_input: {
|
|
tool_name: 'Bash',
|
|
content: 'x'.repeat(REGISTERED_HOOK_MAX_STDIN_BYTES + 1),
|
|
},
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Write',
|
|
}, { ECC_DISABLED_HOOKS: 'post:ecc-metrics-bridge' });
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(result.stdout, '');
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('oversized recursive Stop remains non-blocking', () => {
|
|
const root = createProject();
|
|
try {
|
|
writeRule(
|
|
root,
|
|
'stop',
|
|
'name: stop\nevent: stop\naction: block\npattern: .*',
|
|
'Stop once.'
|
|
);
|
|
const hooks = readRegisteredHooks();
|
|
const entry = hooks.Stop.find(item => item.id === 'stop:hookify-runtime');
|
|
const result = runRegisteredEntry(root, entry, {
|
|
last_assistant_message: 'x'.repeat(REGISTERED_HOOK_MAX_STDIN_BYTES + 1),
|
|
hook_event_name: 'Stop',
|
|
stop_hook_active: true,
|
|
});
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(result.stdout, '');
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('PostToolUse dispatcher preserves Hookify block decisions over sibling output', () => {
|
|
const dispatcher = require(dispatcherPath);
|
|
const raw = JSON.stringify({ hook_event_name: 'PostToolUse', tool_name: 'Write' });
|
|
const result = dispatcher.runHooks(raw, [
|
|
{ id: 'post:test:block', matcher: '*', profiles: 'standard,strict', run: () => ({ stdout: JSON.stringify({ decision: 'block', reason: 'Keep the block.' }) }) },
|
|
{ id: 'post:test:warning', matcher: '*', profiles: 'standard,strict', run: () => ({ additionalContext: 'Secondary warning.' }) },
|
|
{ id: 'post:test:failure', matcher: '*', profiles: 'standard,strict', run: () => ({ exitCode: 7 }) },
|
|
], { toolName: 'Write', env: { ECC_HOOK_PROFILE: 'standard' } });
|
|
const output = JSON.parse(result.stdout);
|
|
assert.strictEqual(output.decision, 'block');
|
|
assert.match(output.reason, /Keep the block/);
|
|
assert.match(output.hookSpecificOutput.additionalContext, /Secondary warning/);
|
|
assert.strictEqual(result.exitCode, 0);
|
|
assert.match(result.stderr, /post:test:failure exited with code 7/);
|
|
|
|
const withRawOutput = dispatcher.mergeHookStdout([
|
|
{ id: 'post:test:block', stdout: JSON.stringify({ decision: 'block', reason: 'Keep the block.' }) },
|
|
{ id: 'post:test:raw', stdout: 'unstructured output' },
|
|
]);
|
|
assert.strictEqual(JSON.parse(withRawOutput.stdout).decision, 'block');
|
|
assert.match(withRawOutput.warning, /post:test:raw/);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('standalone Hookify honors the shared input ceiling and preserves root event context', () => {
|
|
const root = createProject();
|
|
try {
|
|
writeRule(root, 'standalone-limit', 'name: limit\nevent: all\naction: block\npattern: DROP', 'Do not run DROP.');
|
|
for (const event of ['PreToolUse', 'PostToolUse', 'Stop']) {
|
|
const input = JSON.stringify({
|
|
tool_input: { content: 'x'.repeat(600), hook_event_name: 'PreToolUse' },
|
|
hook_event_name: event,
|
|
tool_name: event === 'PreToolUse' ? 'Bash' : 'Write',
|
|
stop_hook_active: event === 'Stop',
|
|
});
|
|
const invoke = limit => spawnSync(process.execPath, [runtimePath], {
|
|
cwd: root,
|
|
input,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, CLAUDE_PROJECT_DIR: root, ECC_HOOK_INPUT_MAX_BYTES: String(limit) },
|
|
timeout: 15000,
|
|
});
|
|
const bounded = invoke(100);
|
|
assert.strictEqual(bounded.status, 0, bounded.stderr);
|
|
if (event === 'Stop') {
|
|
assert.strictEqual(bounded.stdout, '', 'recursive Stop must stay non-blocking after truncation');
|
|
} else {
|
|
const output = JSON.parse(bounded.stdout);
|
|
if (event === 'PreToolUse') {
|
|
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
|
assert.match(output.hookSpecificOutput.permissionDecisionReason, /exceeded 100 bytes/);
|
|
} else {
|
|
assert.strictEqual(output.decision, 'block');
|
|
assert.match(output.reason, /exceeded 100 bytes/);
|
|
}
|
|
}
|
|
const complete = invoke(2000);
|
|
assert.strictEqual(complete.status, 0, complete.stderr);
|
|
assert.strictEqual(complete.stdout, '', 'complete harmless input should pass');
|
|
}
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
console.log('\nPassed: ' + passed);
|
|
console.log('Failed: ' + failed);
|
|
process.exit(failed > 0 ? 1 : 0);
|