From 5d8bd99f8fa784e7d0d481502a6167ac9fb50de3 Mon Sep 17 00:00:00 2001 From: kilhyeonjun Date: Fri, 10 Apr 2026 10:30:25 +0900 Subject: [PATCH] fix: accumulate modifiedInput and common fields from allow hooks When a hook returns 'allow' with updatedInput or common fields (suppressOutput, systemMessage, etc.), these values were silently dropped. Now they are accumulated across hooks and included in the final result, matching Claude Code's behavior where allow hooks can still modify tool input and set metadata. --- .../claude-code-hooks/pre-tool-use.test.ts | 45 +++++++++++++++++++ src/hooks/claude-code-hooks/pre-tool-use.ts | 30 ++++++++++--- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/hooks/claude-code-hooks/pre-tool-use.test.ts b/src/hooks/claude-code-hooks/pre-tool-use.test.ts index 1f1a499dc..fc476884b 100644 --- a/src/hooks/claude-code-hooks/pre-tool-use.test.ts +++ b/src/hooks/claude-code-hooks/pre-tool-use.test.ts @@ -171,5 +171,50 @@ describe("executePreToolUseHooks", () => { expect(callCount).toBe(2) }) + + it("#when hook returns allow with updatedInput #then modifiedInput is included in final result", async () => { + dispatchSpy.mockResolvedValue({ + exitCode: 0, + stdout: JSON.stringify({ + decision: "allow", + hookSpecificOutput: { + permissionDecision: "allow", + updatedInput: { file_path: "/tmp/modified.md" }, + }, + }), + stderr: "", + }) + + const config = createConfig([ + { matcher: "Write", hooks: [{ type: "command", command: "bash modifier.sh" }] }, + ]) + + const result = await executePreToolUseHooks(createContext(), config) + + expect(result.decision).toBe("allow") + expect(result.modifiedInput).toEqual({ file_path: "/tmp/modified.md" }) + }) + + it("#when hook returns allow with common fields #then fields are included in final result", async () => { + dispatchSpy.mockResolvedValue({ + exitCode: 0, + stdout: JSON.stringify({ + decision: "allow", + suppressOutput: true, + systemMessage: "Budget warning: approaching limit", + }), + stderr: "", + }) + + const config = createConfig([ + { matcher: "Write", hooks: [{ type: "command", command: "bash checker.sh" }] }, + ]) + + const result = await executePreToolUseHooks(createContext(), config) + + expect(result.decision).toBe("allow") + expect(result.suppressOutput).toBe(true) + expect(result.systemMessage).toBe("Budget warning: approaching limit") + }) }) }) diff --git a/src/hooks/claude-code-hooks/pre-tool-use.ts b/src/hooks/claude-code-hooks/pre-tool-use.ts index 2d949d2d3..e7ac9b6e0 100644 --- a/src/hooks/claude-code-hooks/pre-tool-use.ts +++ b/src/hooks/claude-code-hooks/pre-tool-use.ts @@ -73,6 +73,13 @@ export async function executePreToolUseHooks( const startTime = Date.now() let firstHookName: string | undefined const inputLines = buildInputLines(ctx.toolInput) + let accumulatedModifiedInput: Record | undefined + let accumulatedCommonFields: { + continue?: boolean + stopReason?: string + suppressOutput?: boolean + systemMessage?: string + } = {} for (const matcher of matchers) { if (!matcher.hooks || matcher.hooks.length === 0) continue @@ -147,27 +154,36 @@ export async function executePreToolUseHooks( return { decision, reason, - modifiedInput, + modifiedInput: modifiedInput ?? accumulatedModifiedInput, elapsedMs: Date.now() - startTime, hookName: firstHookName, toolName: transformedToolName, inputLines, - continue: output.continue, - stopReason: output.stopReason, - suppressOutput: output.suppressOutput, - systemMessage: output.systemMessage, + continue: output.continue ?? accumulatedCommonFields.continue, + stopReason: output.stopReason ?? accumulatedCommonFields.stopReason, + suppressOutput: output.suppressOutput ?? accumulatedCommonFields.suppressOutput, + systemMessage: output.systemMessage ?? accumulatedCommonFields.systemMessage, } } - // "allow" — apply modifiedInput but continue processing remaining hooks + // "allow" — accumulate modifiedInput and common fields, continue to next hook if (modifiedInput) { + accumulatedModifiedInput = { ...accumulatedModifiedInput, ...modifiedInput } Object.assign(stdinData.tool_input, objectToSnakeCase(modifiedInput)) } + if (output.continue !== undefined) accumulatedCommonFields.continue = output.continue + if (output.stopReason !== undefined) accumulatedCommonFields.stopReason = output.stopReason + if (output.suppressOutput !== undefined) accumulatedCommonFields.suppressOutput = output.suppressOutput + if (output.systemMessage !== undefined) accumulatedCommonFields.systemMessage = output.systemMessage } catch { } } } } - return { decision: "allow" } + return { + decision: "allow" as const, + ...(accumulatedModifiedInput ? { modifiedInput: accumulatedModifiedInput } : {}), + ...(Object.keys(accumulatedCommonFields).length > 0 ? accumulatedCommonFields : {}), + } }