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 : {}), + } }