fix(hooks): stop pre/post Bash dispatcher from echoing the input event (#2240)

runHooks() returned the unmodified raw stdin (the PreToolUse/PostToolUse
input event) on stdout whenever no sub-hook produced additionalContext.
Claude Code parses a hook's stdout as JSON and validates it against the
hook-output schema, so echoing the input object
({session_id, hook_event_name, tool_name, tool_input, ...}) fails with
"Hook JSON output validation failed — (root): Invalid input" on nearly
every Bash command.

Track whether a sub-hook deliberately set stdout (string / {stdout}, e.g.
GateGuard) via a rawModified flag and emit '' in the pass-through case
instead of the echoed input. Preserves GateGuard pass-through and
block-no-verify's exit-2 blocking.

Update the three dispatcher tests that codified the buggy echo behavior to
expect empty stdout, and add a regression test for a plain pass-through
command.

Fixes #2239

Co-authored-by: WOZCODE <contact@withwoz.com>
This commit is contained in:
fiedler-itlabs 2026-06-15 17:48:46 +00:00 committed by GitHub
parent 0ce14a423c
commit d293941643
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 31 additions and 5 deletions

View File

@ -122,6 +122,12 @@ function normalizeHookResult(previousRaw, output) {
function runHooks(rawInput, hooks) { function runHooks(rawInput, hooks) {
let currentRaw = rawInput; let currentRaw = rawInput;
// Track whether a sub-hook deliberately produced stdout (a string or
// {stdout}) versus currentRaw still being the untouched input event.
// Echoing the unmodified input event back to stdout fails Claude Code's
// hook-output JSON schema validation ("(root): Invalid input"), so in the
// pass-through case we must emit nothing instead.
let rawModified = false;
let stderr = ''; let stderr = '';
let additionalContext = ''; let additionalContext = '';
@ -132,6 +138,9 @@ function runHooks(rawInput, hooks) {
try { try {
const result = normalizeHookResult(currentRaw, hook.run(currentRaw)); const result = normalizeHookResult(currentRaw, hook.run(currentRaw));
if (result.raw !== currentRaw) {
rawModified = true;
}
currentRaw = result.raw; currentRaw = result.raw;
if (result.stderr) { if (result.stderr) {
stderr += result.stderr.endsWith('\n') ? result.stderr : `${result.stderr}\n`; stderr += result.stderr.endsWith('\n') ? result.stderr : `${result.stderr}\n`;
@ -140,7 +149,12 @@ function runHooks(rawInput, hooks) {
additionalContext = combineAdditionalContext(additionalContext, result.additionalContext); additionalContext = combineAdditionalContext(additionalContext, result.additionalContext);
} }
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
return { output: currentRaw, stderr, additionalContext, exitCode: result.exitCode }; return {
output: rawModified ? currentRaw : '',
stderr,
additionalContext,
exitCode: result.exitCode,
};
} }
} catch (error) { } catch (error) {
stderr += `[Hook] ${hook.id} failed: ${error.message}\n`; stderr += `[Hook] ${hook.id} failed: ${error.message}\n`;
@ -150,7 +164,9 @@ function runHooks(rawInput, hooks) {
return { return {
output: additionalContext output: additionalContext
? buildPreToolUseAdditionalContext(additionalContext) ? buildPreToolUseAdditionalContext(additionalContext)
: currentRaw, : rawModified
? currentRaw
: '',
stderr, stderr,
additionalContext, additionalContext,
exitCode: 0, exitCode: 0,

View File

@ -53,6 +53,16 @@ function runTests() {
assert.strictEqual(result.stdout, '', 'Blocking hook should not pass through stdout'); assert.strictEqual(result.stdout, '', 'Blocking hook should not pass through stdout');
})) passed++; else failed++; })) passed++; else failed++;
if (test('pre dispatcher emits no stdout for a plain command (regression: issue #2239)', () => {
// A pass-through command (no sub-hook adds context) must NOT echo the
// input event back to stdout — Claude Code validates hook stdout against
// the hook-output schema and the input event fails as "(root): Invalid input".
const input = { tool_input: { command: 'ls -la' } };
const result = runScript(preDispatcher, input, { ECC_HOOK_PROFILE: 'standard' });
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, '', `Pass-through must emit empty stdout, got: ${result.stdout}`);
})) passed++; else failed++;
if (test('pre dispatcher still honors per-hook disable flags', () => { if (test('pre dispatcher still honors per-hook disable flags', () => {
const input = { tool_input: { command: 'git push origin main' } }; const input = { tool_input: { command: 'git push origin main' } };
@ -69,7 +79,7 @@ function runTests() {
ECC_DISABLED_HOOKS: 'pre:bash:git-push-reminder', ECC_DISABLED_HOOKS: 'pre:bash:git-push-reminder',
}); });
assert.strictEqual(disabled.status, 0); assert.strictEqual(disabled.status, 0);
assert.strictEqual(disabled.stdout, JSON.stringify(input), 'Disabled hook should pass through original input'); assert.strictEqual(disabled.stdout, '', 'Disabled hook should emit no stdout (echoing the input event fails hook-output schema validation)');
assert.ok(!disabled.stderr.includes('Review changes before push'), 'Disabled hook should not emit reminder'); assert.ok(!disabled.stderr.includes('Review changes before push'), 'Disabled hook should not emit reminder');
})) passed++; else failed++; })) passed++; else failed++;
@ -78,7 +88,7 @@ function runTests() {
const result = runScript(preDispatcher, input, { ECC_HOOK_PROFILE: 'minimal' }); const result = runScript(preDispatcher, input, { ECC_HOOK_PROFILE: 'minimal' });
assert.strictEqual(result.status, 0); assert.strictEqual(result.status, 0);
assert.strictEqual(result.stderr, '', 'Strict-only reminders should stay disabled in minimal profile'); assert.strictEqual(result.stderr, '', 'Strict-only reminders should stay disabled in minimal profile');
assert.strictEqual(result.stdout, JSON.stringify(input)); assert.strictEqual(result.stdout, '', 'Pass-through must emit no stdout, not echo the input event');
})) passed++; else failed++; })) passed++; else failed++;
if (test('post dispatcher writes both bash audit and cost logs in one pass', () => { if (test('post dispatcher writes both bash audit and cost logs in one pass', () => {
@ -91,7 +101,7 @@ function runTests() {
USERPROFILE: homeDir, USERPROFILE: homeDir,
}); });
assert.strictEqual(result.status, 0); assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, JSON.stringify(payload)); assert.strictEqual(result.stdout, '', 'Post dispatcher pass-through must emit no stdout, not echo the input event');
const auditLog = fs.readFileSync(path.join(homeDir, '.claude', 'bash-commands.log'), 'utf8'); const auditLog = fs.readFileSync(path.join(homeDir, '.claude', 'bash-commands.log'), 'utf8');
const costLog = fs.readFileSync(path.join(homeDir, '.claude', 'cost-tracker.log'), 'utf8'); const costLog = fs.readFileSync(path.join(homeDir, '.claude', 'cost-tracker.log'), 'utf8');