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) {
|
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,
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user