Files
ECC/tests/hooks/hook-input.test.js
T
Gaurav DubeyandClaude Opus 5.5 ce37e4d16d test(hooks): pin hook flags in the held-open stdin helper
The held-open subprocess cases inherited ECC_HOOKS_ENABLED and
ECC_DISABLED_HOOKS from the caller, so disabling block-no-verify or all
hooks made them fail even though the dispatcher behaved correctly. Pin
both flags in the spawned environment.

Refs #3665

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:52:59 +00:00

361 lines
12 KiB
JavaScript

/**
* Regression tests for bounded hook stdin reads.
*/
'use strict';
const assert = require('assert');
const path = require('path');
const { spawn } = require('child_process');
const { PassThrough } = require('stream');
const {
DEFAULT_STDIN_IDLE_TIMEOUT_MS,
readStdinRaw,
resolveStdinIdleTimeout
} = require('../../scripts/hooks/hook-input');
const { run: runConfigProtection } = require('../../scripts/hooks/config-protection');
const TEST_STDIN_LIMIT = 1024;
const STREAM_SETTLEMENT_TIMEOUT_MS = 500;
const TEST_IDLE_TIMEOUT_MS = 50;
const HOOKS_DIR = path.join(__dirname, '..', '..', 'scripts', 'hooks');
const PRE_BASH_DISPATCHER = path.join(HOOKS_DIR, 'pre-bash-dispatcher.js');
const BASH_HOOK_DISPATCHER = path.join(HOOKS_DIR, 'bash-hook-dispatcher.js');
async function test(name, fn) {
try {
await fn();
console.log(` ✓ ${name}`);
return true;
} catch (error) {
console.log(` ✗ ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
async function readFromErroredStream(partialInput) {
const stream = new PassThrough();
const resultPromise = readStdinRaw(stream, { maxStdin: TEST_STDIN_LIMIT });
stream.write(partialInput);
stream.destroy(new Error('simulated stdin read failure'));
return resultPromise;
}
async function readFromClosedStream(partialInput) {
const stream = new PassThrough();
const resultPromise = readStdinRaw(stream, { maxStdin: TEST_STDIN_LIMIT });
stream.write(partialInput);
stream.destroy();
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error('readStdinRaw did not settle after stream close')),
STREAM_SETTLEMENT_TIMEOUT_MS
);
resultPromise.then(
result => {
clearTimeout(timer);
resolve(result);
},
error => {
clearTimeout(timer);
reject(error);
}
);
});
}
function settleWithin(promise, ms, label) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${label} did not settle within ${ms} ms`)), ms);
promise.then(
result => {
clearTimeout(timer);
resolve(result);
},
error => {
clearTimeout(timer);
reject(error);
}
);
});
}
// The writer keeps the stream open and never sends EOF (#3665).
function readFromHeldOpenStream(chunks, options = {}) {
const stream = new PassThrough();
const resultPromise = readStdinRaw(stream, {
maxStdin: TEST_STDIN_LIMIT,
idleTimeoutMs: TEST_IDLE_TIMEOUT_MS,
...options
});
for (const chunk of chunks) stream.write(chunk);
return { stream, resultPromise };
}
// Spawn a hook entry point, write the input, and never close stdin.
// Resolve on 'close' so stdout and stderr are fully drained before asserting.
// Pin the hook flags so a caller's ECC_HOOKS_ENABLED or ECC_DISABLED_HOOKS
// cannot switch off the hooks these cases assert on.
async function runWithHeldOpenStdin(args, input) {
const child = spawn(process.execPath, args, {
env: {
...process.env,
ECC_HOOK_INPUT_IDLE_TIMEOUT_MS: '100',
ECC_HOOK_PROFILE: 'standard',
ECC_HOOKS_ENABLED: 'true',
ECC_DISABLED_HOOKS: ''
},
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
child.stdout.on('data', chunk => {
stdout += chunk;
});
child.stderr.on('data', chunk => {
stderr += chunk;
});
child.stdin.on('error', () => {});
child.stdin.write(input);
try {
const exitCode = await settleWithin(
new Promise(resolve => child.once('close', code => resolve(code))),
5000,
args.map(arg => path.basename(arg)).join(' ')
);
return { exitCode, stdout, stderr };
} finally {
child.stdin.destroy();
child.kill();
}
}
async function runTests() {
console.log('\nHook input reader tests:');
let passed = 0;
let failed = 0;
if (
await test('clean end preserves complete input', async () => {
const stream = new PassThrough();
const resultPromise = readStdinRaw(stream, { maxStdin: TEST_STDIN_LIMIT });
stream.end('{"complete":true}');
assert.deepStrictEqual(await resultPromise, {
raw: '{"complete":true}',
truncated: false
});
})
)
passed++;
else failed++;
if (
await test('stream error marks partial input as truncated', async () => {
const partialInput = '{"tool_name":"Write","tool_input":{';
const result = await readFromErroredStream(partialInput);
assert.strictEqual(result.raw, partialInput);
assert.strictEqual(result.truncated, true);
})
)
passed++;
else failed++;
if (
await test('close without end marks partial input as truncated', async () => {
const partialInput = '{"tool_name":"Write","tool_input":{';
const result = await readFromClosedStream(partialInput);
assert.strictEqual(result.raw, partialInput);
assert.strictEqual(result.truncated, true);
})
)
passed++;
else failed++;
if (
await test('errored partial PreToolUse input remains fail closed', async () => {
const partialInput = '{"tool_name":"Write","tool_input":{"file_path":".eslintrc.js"';
const inputResult = await readFromErroredStream(partialInput);
const hookResult = runConfigProtection(inputResult.raw, {
truncated: inputResult.truncated,
maxStdin: TEST_STDIN_LIMIT
});
assert.strictEqual(inputResult.truncated, true);
assert.strictEqual(hookResult.exitCode, 2);
assert.match(hookResult.stderr, /Refusing to bypass config-protection/);
})
)
passed++;
else failed++;
if (
await test('held-open stream with no input settles as truncated after the idle deadline', async () => {
const { resultPromise } = readFromHeldOpenStream([]);
const result = await settleWithin(resultPromise, STREAM_SETTLEMENT_TIMEOUT_MS, 'readStdinRaw');
assert.deepStrictEqual(result, { raw: '', truncated: true });
})
)
passed++;
else failed++;
if (
await test('held-open stream with a partial request settles as truncated', async () => {
const partialInput = '{"tool_name":"Write","tool_input":{';
const { resultPromise } = readFromHeldOpenStream([partialInput]);
const result = await settleWithin(resultPromise, STREAM_SETTLEMENT_TIMEOUT_MS, 'readStdinRaw');
assert.strictEqual(result.raw, partialInput);
assert.strictEqual(result.truncated, true);
})
)
passed++;
else failed++;
if (
await test('held-open stream with a complete JSON object settles as complete', async () => {
const input = '{"tool_name":"Bash","tool_input":{"command":"ls"}}';
const { resultPromise } = readFromHeldOpenStream([input.slice(0, 10), input.slice(10)]);
const result = await settleWithin(resultPromise, STREAM_SETTLEMENT_TIMEOUT_MS, 'readStdinRaw');
assert.deepStrictEqual(result, { raw: input, truncated: false });
})
)
passed++;
else failed++;
if (
await test('held-open stream with a JSON array settles as truncated', async () => {
const { resultPromise } = readFromHeldOpenStream(['[]']);
const result = await settleWithin(resultPromise, STREAM_SETTLEMENT_TIMEOUT_MS, 'readStdinRaw');
assert.deepStrictEqual(result, { raw: '[]', truncated: true });
})
)
passed++;
else failed++;
if (
await test('a complete JSON object past the byte cap stays truncated on the idle deadline', async () => {
const input = JSON.stringify({ padding: 'x'.repeat(TEST_STDIN_LIMIT) });
const { resultPromise } = readFromHeldOpenStream([input]);
const result = await settleWithin(resultPromise, STREAM_SETTLEMENT_TIMEOUT_MS, 'readStdinRaw');
assert.strictEqual(result.truncated, true);
assert.strictEqual(Buffer.byteLength(result.raw), TEST_STDIN_LIMIT);
})
)
passed++;
else failed++;
if (
await test('each chunk resets the idle deadline', async () => {
const { stream, resultPromise } = readFromHeldOpenStream([]);
let settled = false;
resultPromise.then(() => {
settled = true;
});
for (const part of ['{"a":', '1', '}']) {
await new Promise(resolve => setTimeout(resolve, TEST_IDLE_TIMEOUT_MS / 2));
stream.write(part);
}
assert.strictEqual(settled, false, 'reader settled while chunks were still arriving');
stream.end();
assert.deepStrictEqual(await resultPromise, { raw: '{"a":1}', truncated: false });
})
)
passed++;
else failed++;
if (
await test('idleTimeoutMs 0 disables the deadline', async () => {
const { stream, resultPromise } = readFromHeldOpenStream([], { idleTimeoutMs: 0 });
let settled = false;
resultPromise.then(() => {
settled = true;
});
await new Promise(resolve => setTimeout(resolve, TEST_IDLE_TIMEOUT_MS * 3));
assert.strictEqual(settled, false, 'reader settled with the deadline disabled');
stream.end('{}');
assert.deepStrictEqual(await resultPromise, { raw: '{}', truncated: false });
})
)
passed++;
else failed++;
if (
await test('resolveStdinIdleTimeout validates ECC_HOOK_INPUT_IDLE_TIMEOUT_MS', async () => {
const diagnostics = [];
const writeDiagnostic = message => diagnostics.push(message);
assert.strictEqual(resolveStdinIdleTimeout(undefined), DEFAULT_STDIN_IDLE_TIMEOUT_MS);
assert.strictEqual(resolveStdinIdleTimeout(''), DEFAULT_STDIN_IDLE_TIMEOUT_MS);
assert.strictEqual(resolveStdinIdleTimeout('750'), 750);
assert.strictEqual(resolveStdinIdleTimeout('0'), 0);
assert.strictEqual(resolveStdinIdleTimeout(' '), DEFAULT_STDIN_IDLE_TIMEOUT_MS);
assert.strictEqual(resolveStdinIdleTimeout(' 750 '), 750);
assert.strictEqual(resolveStdinIdleTimeout('-1', { writeDiagnostic }), DEFAULT_STDIN_IDLE_TIMEOUT_MS);
assert.strictEqual(resolveStdinIdleTimeout('soon', { writeDiagnostic }), DEFAULT_STDIN_IDLE_TIMEOUT_MS);
// setTimeout would fire after 1 ms for a larger delay, so it is clamped.
assert.strictEqual(resolveStdinIdleTimeout('9999999999', { writeDiagnostic }), 2 ** 31 - 1);
assert.strictEqual(diagnostics.length, 3);
assert.match(diagnostics[0], /ECC_HOOK_INPUT_IDLE_TIMEOUT_MS/);
assert.match(diagnostics[2], /clamping/);
})
)
passed++;
else failed++;
for (const [label, args, expectedExit, diagnostic] of [
['pre-bash dispatcher', [PRE_BASH_DISPATCHER], 2, /stdin stayed open without EOF for 100 ms/],
['bash-hook-dispatcher pre', [BASH_HOOK_DISPATCHER, 'pre'], 2, /incomplete stdin for bash-hook-dispatcher pre; blocking/],
['bash-hook-dispatcher post', [BASH_HOOK_DISPATCHER, 'post'], 0, /incomplete stdin for bash-hook-dispatcher post; skipping/]
]) {
if (
await test(`${label} exits with ${expectedExit} when stdin is never closed`, async () => {
const { exitCode, stdout, stderr } = await runWithHeldOpenStdin(args, '{"tool_name":"Bash","tool_input":{');
assert.strictEqual(exitCode, expectedExit);
assert.strictEqual(stdout, '');
assert.match(stderr, diagnostic);
})
)
passed++;
else failed++;
}
if (
await test('bash-hook-dispatcher pre still runs safety hooks on a complete request when stdin is never closed', async () => {
const input = JSON.stringify({
tool_name: 'Bash',
tool_input: { command: 'git commit --no-verify -m "x"' }
});
const { exitCode, stdout, stderr } = await runWithHeldOpenStdin([BASH_HOOK_DISPATCHER, 'pre'], input);
assert.strictEqual(exitCode, 2);
assert.strictEqual(stdout, '');
assert.match(stderr, /--no-verify flag is not allowed/);
assert.doesNotMatch(stderr, /incomplete stdin/);
})
)
passed++;
else failed++;
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}\n`);
process.exitCode = failed > 0 ? 1 : 0;
}
runTests().catch(error => {
console.error(error);
process.exitCode = 1;
});