diff --git a/src/hooks/claude-code-hooks/pre-tool-use.test.ts b/src/hooks/claude-code-hooks/pre-tool-use.test.ts
new file mode 100644
index 000000000..13770a47f
--- /dev/null
+++ b/src/hooks/claude-code-hooks/pre-tool-use.test.ts
@@ -0,0 +1,252 @@
+///
+
+import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test"
+import type { ClaudeHooksConfig } from "./types"
+import type { PreToolUseContext } from "./pre-tool-use"
+import * as dispatchHookModule from "./dispatch-hook"
+import * as logger from "../../shared/logger"
+import { executePreToolUseHooks } from "./pre-tool-use"
+
+function createContext(overrides?: Partial): PreToolUseContext {
+ return {
+ sessionId: "test-session",
+ toolName: "write",
+ toolInput: { file_path: "/tmp/test.md", content: "hello" },
+ cwd: "/tmp",
+ ...overrides,
+ }
+}
+
+function createConfig(matchers: ClaudeHooksConfig["PreToolUse"]): ClaudeHooksConfig {
+ return { PreToolUse: matchers }
+}
+
+describe("executePreToolUseHooks", () => {
+ let dispatchSpy: ReturnType
+
+ beforeEach(() => {
+ dispatchSpy = spyOn(dispatchHookModule, "dispatchHook")
+ spyOn(logger, "log").mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ mock.restore()
+ })
+
+ it("#given null config #when called #then returns allow", async () => {
+ const result = await executePreToolUseHooks(createContext(), null)
+ expect(result.decision).toBe("allow")
+ })
+
+ it("#given no matching hooks #when called #then returns allow", async () => {
+ const config = createConfig([
+ { matcher: "Bash", hooks: [{ type: "command", command: "echo test" }] },
+ ])
+ const result = await executePreToolUseHooks(createContext({ toolName: "write" }), config)
+ expect(result.decision).toBe("allow")
+ })
+
+ it("#given hook returns exit code 2 #when called #then returns deny", async () => {
+ dispatchSpy.mockResolvedValue({ exitCode: 2, stdout: "", stderr: "blocked" })
+
+ const config = createConfig([
+ { matcher: "Write", hooks: [{ type: "command", command: "echo deny" }] },
+ ])
+ const result = await executePreToolUseHooks(createContext(), config)
+
+ expect(result.decision).toBe("deny")
+ expect(result.reason).toBe("blocked")
+ })
+
+ it("#given hook returns exit code 1 #when called #then returns ask", async () => {
+ dispatchSpy.mockResolvedValue({ exitCode: 1, stdout: "", stderr: "needs confirmation" })
+
+ const config = createConfig([
+ { matcher: "Write", hooks: [{ type: "command", command: "echo ask" }] },
+ ])
+ const result = await executePreToolUseHooks(createContext(), config)
+
+ expect(result.decision).toBe("ask")
+ expect(result.reason).toBe("needs confirmation")
+ })
+
+ describe("#given multiple hooks with merged config (global + project)", () => {
+ it("#when first hook allows and second hook denies #then returns deny", async () => {
+ let callCount = 0
+ dispatchSpy.mockImplementation(async () => {
+ callCount++
+ if (callCount === 1) {
+ // Global catch-all hook returns "allow" via JSON
+ return {
+ exitCode: 0,
+ stdout: JSON.stringify({ decision: "allow" }),
+ stderr: "",
+ }
+ }
+ // Project budget guard hook returns exit code 2 (deny)
+ return { exitCode: 2, stdout: "", stderr: "BUDGET EXCEEDED" }
+ })
+
+ const config = createConfig([
+ // Global catch-all (no specific matcher = matches everything)
+ { matcher: "*", hooks: [{ type: "command", command: "node pre-tool-use.mjs" }] },
+ // Project budget guard
+ { matcher: "Edit|Write", hooks: [{ type: "command", command: "bash budget-guard.sh" }] },
+ ])
+
+ const result = await executePreToolUseHooks(createContext(), config)
+
+ expect(callCount).toBe(2)
+ expect(result.decision).toBe("deny")
+ expect(result.reason).toBe("BUDGET EXCEEDED")
+ })
+
+ it("#when first hook allows and second hook also allows #then returns allow", async () => {
+ let callCount = 0
+ dispatchSpy.mockImplementation(async () => {
+ callCount++
+ if (callCount === 1) {
+ return {
+ exitCode: 0,
+ stdout: JSON.stringify({ decision: "allow" }),
+ stderr: "",
+ }
+ }
+ return { exitCode: 0, stdout: "", stderr: "" }
+ })
+
+ const config = createConfig([
+ { matcher: "*", hooks: [{ type: "command", command: "node pre-tool-use.mjs" }] },
+ { matcher: "Edit|Write", hooks: [{ type: "command", command: "bash budget-guard.sh" }] },
+ ])
+
+ const result = await executePreToolUseHooks(createContext(), config)
+
+ expect(callCount).toBe(2)
+ expect(result.decision).toBe("allow")
+ })
+
+ it("#when first hook denies #then second hook is NOT executed", async () => {
+ let callCount = 0
+ dispatchSpy.mockImplementation(async () => {
+ callCount++
+ return { exitCode: 2, stdout: "", stderr: "denied by first hook" }
+ })
+
+ const config = createConfig([
+ { matcher: "*", hooks: [{ type: "command", command: "node pre-tool-use.mjs" }] },
+ { matcher: "Edit|Write", hooks: [{ type: "command", command: "bash budget-guard.sh" }] },
+ ])
+
+ const result = await executePreToolUseHooks(createContext(), config)
+
+ expect(callCount).toBe(1)
+ expect(result.decision).toBe("deny")
+ })
+
+ it("#when first hook allows via JSON with modifiedInput #then input is passed to second hook", async () => {
+ const capturedStdin: string[] = []
+ let callCount = 0
+ dispatchSpy.mockImplementation(async (_hook: unknown, stdinJson: string) => {
+ capturedStdin.push(stdinJson)
+ callCount++
+ if (callCount === 1) {
+ return {
+ exitCode: 0,
+ stdout: JSON.stringify({
+ decision: "allow",
+ }),
+ stderr: "",
+ }
+ }
+ return { exitCode: 0, stdout: "", stderr: "" }
+ })
+
+ const config = createConfig([
+ { matcher: "*", hooks: [{ type: "command", command: "node pre-tool-use.mjs" }] },
+ { matcher: "Edit|Write", hooks: [{ type: "command", command: "bash budget-guard.sh" }] },
+ ])
+
+ await executePreToolUseHooks(createContext(), config)
+
+ 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")
+ })
+
+ it("#when first hook allows with modifiedInput and second hook denies #then deny includes accumulated modifiedInput", async () => {
+ let callCount = 0
+ dispatchSpy.mockImplementation(async () => {
+ callCount++
+ if (callCount === 1) {
+ return {
+ exitCode: 0,
+ stdout: JSON.stringify({
+ decision: "allow",
+ hookSpecificOutput: {
+ permissionDecision: "allow",
+ updatedInput: { file_path: "/tmp/modified.md" },
+ },
+ }),
+ stderr: "",
+ }
+ }
+ return { exitCode: 2, stdout: "", stderr: "BUDGET EXCEEDED" }
+ })
+
+ const config = createConfig([
+ { matcher: "*", hooks: [{ type: "command", command: "node modifier.mjs" }] },
+ { matcher: "Edit|Write", hooks: [{ type: "command", command: "bash budget-guard.sh" }] },
+ ])
+
+ const result = await executePreToolUseHooks(createContext(), config)
+
+ expect(callCount).toBe(2)
+ expect(result.decision).toBe("deny")
+ expect(result.modifiedInput).toEqual({ file_path: "/tmp/modified.md" })
+ })
+ })
+})
diff --git a/src/hooks/claude-code-hooks/pre-tool-use.ts b/src/hooks/claude-code-hooks/pre-tool-use.ts
index 97bfaf04a..a6d03182a 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
@@ -93,10 +100,12 @@ export async function executePreToolUseHooks(
return {
decision: "deny",
reason: result.stderr || result.stdout || "Hook blocked the operation",
+ modifiedInput: accumulatedModifiedInput,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
+ ...accumulatedCommonFields,
}
}
@@ -104,10 +113,12 @@ export async function executePreToolUseHooks(
return {
decision: "ask",
reason: result.stderr || result.stdout,
+ modifiedInput: accumulatedModifiedInput,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
+ ...accumulatedCommonFields,
}
}
@@ -143,26 +154,40 @@ export async function executePreToolUseHooks(
output.suppressOutput !== undefined ||
output.systemMessage !== undefined
- if (decision || hasCommonFields) {
+ if (decision === "deny" || decision === "ask") {
return {
- decision: decision ?? "allow",
+ 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" — 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 : {}),
+ }
}