From d29394164385856023bb99597a1a64579acf7317 Mon Sep 17 00:00:00 2001 From: fiedler-itlabs <94529792+fiedler-itlabs@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:48:46 +0000 Subject: [PATCH] fix(hooks): stop pre/post Bash dispatcher from echoing the input event (#2240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/hooks/bash-hook-dispatcher.js | 20 ++++++++++++++++++-- tests/hooks/bash-hook-dispatcher.test.js | 16 +++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/scripts/hooks/bash-hook-dispatcher.js b/scripts/hooks/bash-hook-dispatcher.js index f9f0e763..5a13202d 100644 --- a/scripts/hooks/bash-hook-dispatcher.js +++ b/scripts/hooks/bash-hook-dispatcher.js @@ -122,6 +122,12 @@ function normalizeHookResult(previousRaw, output) { function runHooks(rawInput, hooks) { 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 additionalContext = ''; @@ -132,6 +138,9 @@ function runHooks(rawInput, hooks) { try { const result = normalizeHookResult(currentRaw, hook.run(currentRaw)); + if (result.raw !== currentRaw) { + rawModified = true; + } currentRaw = result.raw; if (result.stderr) { stderr += result.stderr.endsWith('\n') ? result.stderr : `${result.stderr}\n`; @@ -140,7 +149,12 @@ function runHooks(rawInput, hooks) { additionalContext = combineAdditionalContext(additionalContext, result.additionalContext); } if (result.exitCode !== 0) { - return { output: currentRaw, stderr, additionalContext, exitCode: result.exitCode }; + return { + output: rawModified ? currentRaw : '', + stderr, + additionalContext, + exitCode: result.exitCode, + }; } } catch (error) { stderr += `[Hook] ${hook.id} failed: ${error.message}\n`; @@ -150,7 +164,9 @@ function runHooks(rawInput, hooks) { return { output: additionalContext ? buildPreToolUseAdditionalContext(additionalContext) - : currentRaw, + : rawModified + ? currentRaw + : '', stderr, additionalContext, exitCode: 0, diff --git a/tests/hooks/bash-hook-dispatcher.test.js b/tests/hooks/bash-hook-dispatcher.test.js index 2047462b..9832f50c 100644 --- a/tests/hooks/bash-hook-dispatcher.test.js +++ b/tests/hooks/bash-hook-dispatcher.test.js @@ -53,6 +53,16 @@ function runTests() { assert.strictEqual(result.stdout, '', 'Blocking hook should not pass through stdout'); })) 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', () => { const input = { tool_input: { command: 'git push origin main' } }; @@ -69,7 +79,7 @@ function runTests() { ECC_DISABLED_HOOKS: 'pre:bash:git-push-reminder', }); 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'); })) passed++; else failed++; @@ -78,7 +88,7 @@ function runTests() { const result = runScript(preDispatcher, input, { ECC_HOOK_PROFILE: 'minimal' }); assert.strictEqual(result.status, 0); 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++; if (test('post dispatcher writes both bash audit and cost logs in one pass', () => { @@ -91,7 +101,7 @@ function runTests() { USERPROFILE: homeDir, }); 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 costLog = fs.readFileSync(path.join(homeDir, '.claude', 'cost-tracker.log'), 'utf8');