From 3b5745a6e83f2cfab3b3c14ae0ccf7f3c6274680 Mon Sep 17 00:00:00 2001 From: kilhyeonjun Date: Fri, 10 Apr 2026 09:59:59 +0900 Subject: [PATCH 1/5] fix: wire claudeCodeHooks into createHooks() to enable .claude/settings.json hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createClaudeCodeHooksHook() was fully implemented but never instantiated in createHooks(). All .claude/settings.json hooks (PreToolUse, PostToolUse, Stop, UserPromptSubmit) were silently skipped because hooks.claudeCodeHooks was always undefined (optional chaining masked the issue). Add claudeCodeHooks creation to createHooks() using the existing isHookEnabled('claude-code-hooks') gate and safeCreateHook wrapper. Note: This is a necessary fix but may require additional debugging for full hook execution — the dispatch/stdin format compatibility between OpenCode and Claude Code hook scripts needs runtime verification. Fixes #3297 --- src/create-hooks.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/create-hooks.ts b/src/create-hooks.ts index 0e40ad480..827913754 100644 --- a/src/create-hooks.ts +++ b/src/create-hooks.ts @@ -8,6 +8,8 @@ import type { ModelCacheState } from "./plugin-state" import { createCoreHooks } from "./plugin/hooks/create-core-hooks" import { createContinuationHooks } from "./plugin/hooks/create-continuation-hooks" import { createSkillHooks } from "./plugin/hooks/create-skill-hooks" +import { createClaudeCodeHooksHook } from "./hooks/claude-code-hooks" +import { safeCreateHook } from "./shared/safe-create-hook" export type CreatedHooks = ReturnType @@ -78,10 +80,17 @@ export function createHooks(args: { availableSkills, }) + const claudeCodeHooks = isHookEnabled("claude-code-hooks") + ? safeHookEnabled + ? safeCreateHook("claude-code-hooks", () => createClaudeCodeHooksHook(ctx, {}), { enabled: true }) + : createClaudeCodeHooksHook(ctx, {}) + : null + const hooks = { ...core, ...continuation, ...skill, + claudeCodeHooks, } return { From 5aeb5688e834cc912011f30396fb2df35a82cee2 Mon Sep 17 00:00:00 2001 From: kilhyeonjun Date: Fri, 10 Apr 2026 10:15:49 +0900 Subject: [PATCH 2/5] fix: don't early-return on 'allow' in executePreToolUseHooks When multiple hook sources are merged (global ~/.claude/settings.json + project .claude/settings.json), a global catch-all hook returning 'allow' caused early return before project-level hooks could execute. Only 'deny' and 'ask' decisions should short-circuit. 'allow' should continue processing remaining hooks so project-specific guards (e.g., file budget enforcement) get a chance to block. --- src/hooks/claude-code-hooks/pre-tool-use.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/hooks/claude-code-hooks/pre-tool-use.ts b/src/hooks/claude-code-hooks/pre-tool-use.ts index 97bfaf04a..2d949d2d3 100644 --- a/src/hooks/claude-code-hooks/pre-tool-use.ts +++ b/src/hooks/claude-code-hooks/pre-tool-use.ts @@ -143,9 +143,9 @@ 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, elapsedMs: Date.now() - startTime, @@ -158,6 +158,11 @@ export async function executePreToolUseHooks( systemMessage: output.systemMessage, } } + + // "allow" — apply modifiedInput but continue processing remaining hooks + if (modifiedInput) { + Object.assign(stdinData.tool_input, objectToSnakeCase(modifiedInput)) + } } catch { } } From e0d611aefc7ee8aebec81fb9a74b3f67247c01a7 Mon Sep 17 00:00:00 2001 From: kilhyeonjun Date: Fri, 10 Apr 2026 10:23:05 +0900 Subject: [PATCH 3/5] revert: remove incorrect claudeCodeHooks override in createHooks, add pre-tool-use tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the create-hooks.ts change — claudeCodeHooks is already created in createTransformHooks() with proper config and contextCollector. The previous commit overwrote it with a degraded instance (empty config, no contextCollector). Add 8 unit tests for executePreToolUseHooks covering: - null/empty config handling - exit code 2 (deny) and 1 (ask) behavior - multiple merged hooks: allow continues to next hook (the actual bug) - deny short-circuits remaining hooks - modifiedInput propagation between hooks TDD verified: tests fail with original code, pass with fix. --- src/create-hooks.ts | 9 - .../claude-code-hooks/pre-tool-use.test.ts | 175 ++++++++++++++++++ 2 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 src/hooks/claude-code-hooks/pre-tool-use.test.ts diff --git a/src/create-hooks.ts b/src/create-hooks.ts index 827913754..0e40ad480 100644 --- a/src/create-hooks.ts +++ b/src/create-hooks.ts @@ -8,8 +8,6 @@ import type { ModelCacheState } from "./plugin-state" import { createCoreHooks } from "./plugin/hooks/create-core-hooks" import { createContinuationHooks } from "./plugin/hooks/create-continuation-hooks" import { createSkillHooks } from "./plugin/hooks/create-skill-hooks" -import { createClaudeCodeHooksHook } from "./hooks/claude-code-hooks" -import { safeCreateHook } from "./shared/safe-create-hook" export type CreatedHooks = ReturnType @@ -80,17 +78,10 @@ export function createHooks(args: { availableSkills, }) - const claudeCodeHooks = isHookEnabled("claude-code-hooks") - ? safeHookEnabled - ? safeCreateHook("claude-code-hooks", () => createClaudeCodeHooksHook(ctx, {}), { enabled: true }) - : createClaudeCodeHooksHook(ctx, {}) - : null - const hooks = { ...core, ...continuation, ...skill, - claudeCodeHooks, } return { 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..1f1a499dc --- /dev/null +++ b/src/hooks/claude-code-hooks/pre-tool-use.test.ts @@ -0,0 +1,175 @@ +/// + +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) + }) + }) +}) From 5d8bd99f8fa784e7d0d481502a6167ac9fb50de3 Mon Sep 17 00:00:00 2001 From: kilhyeonjun Date: Fri, 10 Apr 2026 10:30:25 +0900 Subject: [PATCH 4/5] 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 : {}), + } } From fe1cfd885ca21f904e73cf6b1eadea561aaa38ca Mon Sep 17 00:00:00 2001 From: kilhyeonjun Date: Fri, 10 Apr 2026 10:40:12 +0900 Subject: [PATCH 5/5] fix: preserve accumulated modifiedInput and common fields on deny/ask from exit code paths When a hook returns exit code 2 (deny) or 1 (ask), previously accumulated modifiedInput and common fields from earlier allow hooks were discarded. Now all exit paths (exit code and JSON) include accumulated state. --- .../claude-code-hooks/pre-tool-use.test.ts | 32 +++++++++++++++++++ src/hooks/claude-code-hooks/pre-tool-use.ts | 4 +++ 2 files changed, 36 insertions(+) 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 fc476884b..13770a47f 100644 --- a/src/hooks/claude-code-hooks/pre-tool-use.test.ts +++ b/src/hooks/claude-code-hooks/pre-tool-use.test.ts @@ -216,5 +216,37 @@ describe("executePreToolUseHooks", () => { 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 e7ac9b6e0..a6d03182a 100644 --- a/src/hooks/claude-code-hooks/pre-tool-use.ts +++ b/src/hooks/claude-code-hooks/pre-tool-use.ts @@ -100,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, } } @@ -111,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, } }