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');