mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-16 08:26:52 +08:00
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:
parent
0ce14a423c
commit
d293941643
@ -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,
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user