From b163ad9911e8659e8f5daab26206fb52bfa3303a Mon Sep 17 00:00:00 2001 From: Marko Medic <15942498+medicm@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:04:51 +0400 Subject: [PATCH] fix(hook): preserve tool_input fields when rewriting commands Hosts replace the tool input with the hook's updatedInput rather than merging it, and every adapter emitted a command-only object. For Claude Bash that dropped run_in_background, timeout, description and dangerouslyDisableSandbox: background launches silently ran in the foreground (auto-backgrounded at 120s, killed at the 30min cap). Rewrite updatedInput as the original tool_input with only the command key replaced, so every other field survives byte-for-byte with its JSON type, including unknown future keys. Applies to the claude, codex, copilot (VS Code), cursor and gemini adapters; copilot CLI already preserved args. --- internal/hook/claude.go | 18 ++-- internal/hook/claude_test.go | 107 +++++++++++++++++++- internal/hook/claude_worktree_test.go | 2 +- internal/hook/codex.go | 34 ++++--- internal/hook/codex_test.go | 39 ++++++- internal/hook/copilot.go | 33 +++--- internal/hook/copilot_test.go | 14 +++ internal/hook/cursor.go | 32 +++--- internal/hook/cursor_test.go | 14 +++ internal/hook/gemini.go | 30 +++--- internal/hook/gemini_test.go | 14 +++ internal/hook/input.go | 25 +++++ internal/hook/testdata/cursor_output.golden | 2 +- 13 files changed, 291 insertions(+), 73 deletions(-) diff --git a/internal/hook/claude.go b/internal/hook/claude.go index 94dc3bc..2a19072 100644 --- a/internal/hook/claude.go +++ b/internal/hook/claude.go @@ -29,14 +29,10 @@ type claudeOutput struct { } type claudeHookOutput struct { - HookEventName string `json:"hookEventName"` - PermissionDecision string `json:"permissionDecision"` - PermissionDecisionReason string `json:"permissionDecisionReason,omitempty"` - UpdatedInput *claudeUpdatedInput `json:"updatedInput,omitempty"` -} - -type claudeUpdatedInput struct { - Command string `json:"command"` + HookEventName string `json:"hookEventName"` + PermissionDecision string `json:"permissionDecision"` + PermissionDecisionReason string `json:"permissionDecisionReason,omitempty"` + UpdatedInput json.RawMessage `json:"updatedInput,omitempty"` } // Claude handles a Claude Code PreToolUse payload. For Bash: if the command is @@ -102,11 +98,15 @@ func claudeBash(in claudeInput, w io.Writer) error { if isolatedGit && rules.Decide(rewritten) != permission.Allow { return nil } + updated := toolInputWithCommand(in.ToolInput, rewritten) + if updated == nil { + return nil + } return json.NewEncoder(w).Encode(claudeOutput{ HookSpecificOutput: claudeHookOutput{ HookEventName: "PreToolUse", PermissionDecision: "allow", - UpdatedInput: &claudeUpdatedInput{Command: rewritten}, + UpdatedInput: updated, }, }) } diff --git a/internal/hook/claude_test.go b/internal/hook/claude_test.go index 1a6047f..fa1c4c9 100644 --- a/internal/hook/claude_test.go +++ b/internal/hook/claude_test.go @@ -3,6 +3,7 @@ package hook import ( "bytes" "encoding/json" + "reflect" "strings" "testing" ) @@ -31,11 +32,113 @@ func TestClaudeRewritesBashCommand(t *testing.T) { if got.HookSpecificOutput.PermissionDecision != "allow" { t.Errorf("permissionDecision = %q, want allow", got.HookSpecificOutput.PermissionDecision) } - if want := "ctx-wire run --agent claude git status"; got.HookSpecificOutput.UpdatedInput.Command != want { - t.Errorf("rewritten command = %q, want %q", got.HookSpecificOutput.UpdatedInput.Command, want) + if want := "ctx-wire run --agent claude git status"; updatedCommand(t, got.HookSpecificOutput.UpdatedInput) != want { + t.Errorf("rewritten command = %q, want %q", updatedCommand(t, got.HookSpecificOutput.UpdatedInput), want) } } +// Claude Code REPLACES tool_input with updatedInput, so the rewrite must carry +// every original field, not just command: dropping run_in_background made every +// long background launch run in the foreground (auto-backgrounded at 120s, +// killed at 30 min). Fields must survive with their JSON types intact. +func TestClaudeRewritePreservesToolInputFields(t *testing.T) { + t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir()) + cases := []struct { + name string + toolInput string + }{ + {"run_in_background bool", `{"command":"orch-agent -s m -l x | tail -15","run_in_background":true}`}, + {"timeout number", `{"command":"go test ./...","timeout":5400000}`}, + {"description string", `{"command":"go build ./...","description":"Build everything"}`}, + {"dangerouslyDisableSandbox bool", `{"command":"make deploy","dangerouslyDisableSandbox":false}`}, + {"all Bash fields together", `{"command":"orch-agent -s m -l x | tail -15","run_in_background":true,"timeout":5400000,"description":"d","dangerouslyDisableSandbox":false}`}, + {"unknown future key", `{"command":"git status","future_field":{"nested":[1,2,3]},"other":null}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + payload := `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":` + c.toolInput + `}` + var out bytes.Buffer + if err := Claude(strings.NewReader(payload), &out); err != nil { + t.Fatalf("Claude: %v", err) + } + var got claudeOutput + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, out.String()) + } + updated := got.HookSpecificOutput.UpdatedInput + if updated == nil { + t.Fatalf("expected a rewrite, got %s", out.String()) + } + var origFields, updatedFields map[string]json.RawMessage + if err := json.Unmarshal([]byte(c.toolInput), &origFields); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(updated, &updatedFields); err != nil { + t.Fatalf("updatedInput is not a JSON object: %v\n%s", err, updated) + } + if len(updatedFields) != len(origFields) { + t.Errorf("updatedInput has %d keys, want %d (dropped fields?)\n%s", len(updatedFields), len(origFields), updated) + } + for key, want := range origFields { + if key == "command" { + continue + } + got, ok := updatedFields[key] + if !ok { + t.Errorf("updatedInput dropped key %q\n%s", key, updated) + continue + } + if !jsonEqual(got, want) { + t.Errorf("key %q = %s, want %s (type changed?)", key, got, want) + } + } + var cmd string + if err := json.Unmarshal(updatedFields["command"], &cmd); err != nil || !strings.Contains(cmd, "ctx-wire run --agent claude") { + t.Errorf("command not rewritten: %q (err %v)", cmd, err) + } + }) + } +} + +// A command ctx-wire does not rewrite must stay a silent passthrough even when +// tool_input carries extra fields: emitting nothing lets Claude run the original +// input untouched. +func TestClaudeNoRewriteKeepsSilentWithExtraFields(t *testing.T) { + payload := `{"tool_name":"Bash","tool_input":{"command":"cd /tmp","run_in_background":true,"timeout":60000,"description":"d"}}` + var out bytes.Buffer + if err := Claude(strings.NewReader(payload), &out); err != nil { + t.Fatalf("Claude: %v", err) + } + if out.Len() != 0 { + t.Errorf("expected no output (passthrough) for non-rewritten command, got %q", out.String()) + } +} + +// updatedCommand extracts the rewritten command from a raw updatedInput. +func updatedCommand(t *testing.T, raw json.RawMessage) string { + t.Helper() + var ti struct { + Command string `json:"command"` + } + if err := json.Unmarshal(raw, &ti); err != nil { + t.Fatalf("updatedInput is not an object with a command: %v\n%s", err, raw) + } + return ti.Command +} + +// jsonEqual compares two raw JSON values semantically (number formatting and +// object key order aside). +func jsonEqual(a, b json.RawMessage) bool { + var va, vb any + if err := json.Unmarshal(a, &va); err != nil { + return false + } + if err := json.Unmarshal(b, &vb); err != nil { + return false + } + return reflect.DeepEqual(va, vb) +} + func TestClaudeNoopForBuiltin(t *testing.T) { payload := `{"tool_name":"Bash","tool_input":{"command":"cd /tmp"}}` var out bytes.Buffer diff --git a/internal/hook/claude_worktree_test.go b/internal/hook/claude_worktree_test.go index d258e32..26fcaca 100644 --- a/internal/hook/claude_worktree_test.go +++ b/internal/hook/claude_worktree_test.go @@ -110,7 +110,7 @@ func runClaudeHook(t *testing.T, cwd, command string) string { if got.HookSpecificOutput.UpdatedInput == nil { t.Fatalf("hook answered without an updated command: %s", out.String()) } - return got.HookSpecificOutput.UpdatedInput.Command + return updatedCommand(t, got.HookSpecificOutput.UpdatedInput) } // The reported bug: inside an isolated worktree, Claude Code refuses the diff --git a/internal/hook/codex.go b/internal/hook/codex.go index fc35a26..4c63683 100644 --- a/internal/hook/codex.go +++ b/internal/hook/codex.go @@ -11,11 +11,9 @@ import ( ) type codexInput struct { - HookEventName string `json:"hook_event_name"` - ToolName string `json:"tool_name"` - ToolInput struct { - Command string `json:"command"` - } `json:"tool_input"` + HookEventName string `json:"hook_event_name"` + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` } type codexOutput struct { @@ -25,14 +23,10 @@ type codexOutput struct { type codexHookOutput struct { HookEventName string `json:"hookEventName"` PermissionDecision string `json:"permissionDecision,omitempty"` - UpdatedInput *codexUpdatedInput `json:"updatedInput,omitempty"` + UpdatedInput json.RawMessage `json:"updatedInput,omitempty"` Decision *codexDecisionWrapper `json:"decision,omitempty"` } -type codexUpdatedInput struct { - Command string `json:"command"` -} - type codexDecisionWrapper struct { Behavior string `json:"behavior"` Message string `json:"message,omitempty"` @@ -59,11 +53,17 @@ func Codex(r io.Reader, w io.Writer) error { if err := json.Unmarshal(data, &in); err != nil { return nil } - if in.ToolName != "Bash" || in.ToolInput.Command == "" { + var ti struct { + Command string `json:"command"` + } + if json.Unmarshal(in.ToolInput, &ti) != nil || ti.Command == "" { + return nil + } + if in.ToolName != "Bash" { return nil } if in.HookEventName == "PermissionRequest" { - if !allowCodexPermissionCommand(in.ToolInput.Command) { + if !allowCodexPermissionCommand(ti.Command) { return nil // not ours, or safe-mode declined → codex's own prompt } return json.NewEncoder(w).Encode(codexOutput{ @@ -76,8 +76,8 @@ func Codex(r io.Reader, w io.Writer) error { }, }) } - rewritten := rewrite.LineForAgent(in.ToolInput.Command, "codex") - if rewritten == in.ToolInput.Command { + rewritten := rewrite.LineForAgent(ti.Command, "codex") + if rewritten == ti.Command { return nil // not rewritable (already wrapped, a redirect, ...) → codex decides } // Codex REJECTS a PreToolUse response that carries updatedInput without @@ -87,11 +87,15 @@ func Codex(r io.Reader, w io.Writer) error { if !allowCodexPermissionCommand(rewritten) { return nil } + updated := toolInputWithCommand(in.ToolInput, rewritten) + if updated == nil { + return nil + } return json.NewEncoder(w).Encode(codexOutput{ HookSpecificOutput: codexHookOutput{ HookEventName: "PreToolUse", PermissionDecision: "allow", - UpdatedInput: &codexUpdatedInput{Command: rewritten}, + UpdatedInput: updated, }, }) } diff --git a/internal/hook/codex_test.go b/internal/hook/codex_test.go index ec4dc6f..082b7ee 100644 --- a/internal/hook/codex_test.go +++ b/internal/hook/codex_test.go @@ -27,6 +27,41 @@ func TestCodexGoldenRewrite(t *testing.T) { } } +// updatedInput replaces the tool input, so a rewrite must keep every original +// field (run_in_background, timeout, description) with its JSON type intact. +func TestCodexRewritePreservesToolInputFields(t *testing.T) { + t.Setenv("CTX_WIRE_CODEX_SAFE", "") + payload := `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"go test ./...","run_in_background":true,"timeout":5400000,"description":"d"}}` + var out bytes.Buffer + if err := Codex(strings.NewReader(payload), &out); err != nil { + t.Fatalf("Codex: %v", err) + } + var got codexOutput + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("output not JSON: %v\n%s", err, out.String()) + } + h := got.HookSpecificOutput + if h.PermissionDecision != "allow" { + t.Fatalf("permissionDecision = %q, want allow", h.PermissionDecision) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(h.UpdatedInput, &fields); err != nil { + t.Fatalf("updatedInput is not an object: %v\n%s", err, h.UpdatedInput) + } + for key, want := range map[string]string{ + "run_in_background": "true", + "timeout": "5400000", + "description": `"d"`, + } { + if got := string(fields[key]); got != want { + t.Errorf("key %q = %s, want %s (dropped or type-changed)", key, got, want) + } + } + if cmd := updatedCommand(t, h.UpdatedInput); !strings.Contains(cmd, "ctx-wire run") { + t.Errorf("command not rewritten: %q", cmd) + } +} + func TestCodexNoopForBuiltin(t *testing.T) { var out bytes.Buffer if err := Codex(strings.NewReader(`{"tool_name":"Bash","tool_input":{"command":"cd /tmp"}}`), &out); err != nil { @@ -171,8 +206,8 @@ func TestCodexDefaultPreToolUseRewritesAndAllows(t *testing.T) { if h.PermissionDecision != "allow" { t.Errorf("default PreToolUse must auto-approve %q, got %q", cmd, h.PermissionDecision) } - if h.UpdatedInput == nil || !strings.Contains(h.UpdatedInput.Command, "ctx-wire run") { - t.Errorf("default PreToolUse must rewrite %q for filtering, got %+v", cmd, h.UpdatedInput) + if h.UpdatedInput == nil || !strings.Contains(updatedCommand(t, h.UpdatedInput), "ctx-wire run") { + t.Errorf("default PreToolUse must rewrite %q for filtering, got %s", cmd, h.UpdatedInput) } } } diff --git a/internal/hook/copilot.go b/internal/hook/copilot.go index ba64035..a4f1cd1 100644 --- a/internal/hook/copilot.go +++ b/internal/hook/copilot.go @@ -10,10 +10,8 @@ import ( ) type copilotVSCodeInput struct { - ToolName string `json:"tool_name"` - ToolInput struct { - Command string `json:"command"` - } `json:"tool_input"` + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` } type copilotCLIInput struct { @@ -32,14 +30,10 @@ type copilotVSCodeOutput struct { } type copilotHookOutput struct { - HookEventName string `json:"hookEventName"` - PermissionDecision string `json:"permissionDecision"` - PermissionDecisionReason string `json:"permissionDecisionReason"` - UpdatedInput copilotUpdatedInput `json:"updatedInput"` -} - -type copilotUpdatedInput struct { - Command string `json:"command"` + HookEventName string `json:"hookEventName"` + PermissionDecision string `json:"permissionDecision"` + PermissionDecisionReason string `json:"permissionDecisionReason"` + UpdatedInput json.RawMessage `json:"updatedInput"` } // Copilot handles both VS Code Copilot Chat and GitHub Copilot CLI pre-tool @@ -74,11 +68,18 @@ func copilotVSCode(data []byte, w io.Writer) error { if in.ToolName != "runTerminalCommand" && in.ToolName != "Bash" && in.ToolName != "bash" { return nil } - if in.ToolInput.Command == "" { + var ti struct { + Command string `json:"command"` + } + if json.Unmarshal(in.ToolInput, &ti) != nil || ti.Command == "" { + return nil + } + rewritten := rewrite.LineForAgent(ti.Command, "copilot") + if rewritten == ti.Command { return nil } - rewritten := rewrite.LineForAgent(in.ToolInput.Command, "copilot") - if rewritten == in.ToolInput.Command { + updated := toolInputWithCommand(in.ToolInput, rewritten) + if updated == nil { return nil } return json.NewEncoder(w).Encode(copilotVSCodeOutput{ @@ -86,7 +87,7 @@ func copilotVSCode(data []byte, w io.Writer) error { HookEventName: "PreToolUse", PermissionDecision: "allow", PermissionDecisionReason: "ctx-wire rewrite", - UpdatedInput: copilotUpdatedInput{Command: rewritten}, + UpdatedInput: updated, }, }) } diff --git a/internal/hook/copilot_test.go b/internal/hook/copilot_test.go index 2d0936e..09dcf88 100644 --- a/internal/hook/copilot_test.go +++ b/internal/hook/copilot_test.go @@ -18,6 +18,20 @@ func TestCopilotVSCodeRewrite(t *testing.T) { } } +// The VS Code rewrite replaces the tool input with updatedInput, so every +// original field must survive, not just command. +func TestCopilotVSCodeRewritePreservesToolInputFields(t *testing.T) { + var out bytes.Buffer + in := `{"tool_name":"runTerminalCommand","tool_input":{"command":"git status","run_in_background":true,"timeout":5400000,"description":"d"}}` + if err := Copilot(strings.NewReader(in), &out); err != nil { + t.Fatalf("Copilot: %v", err) + } + want := "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"ctx-wire rewrite\",\"updatedInput\":{\"command\":\"ctx-wire run --agent copilot git status\",\"description\":\"d\",\"run_in_background\":true,\"timeout\":5400000}}}\n" + if out.String() != want { + t.Fatalf("output = %q, want %q", out.String(), want) + } +} + func TestCopilotCLIDenyWithSuggestionByDefault(t *testing.T) { var out bytes.Buffer in := `{"toolName":"bash","toolArgs":"{\"command\":\"git status\",\"description\":\"Run requested command\"}"}` diff --git a/internal/hook/cursor.go b/internal/hook/cursor.go index c9293a2..caca12f 100644 --- a/internal/hook/cursor.go +++ b/internal/hook/cursor.go @@ -8,19 +8,13 @@ import ( ) type cursorInput struct { - ToolName string `json:"tool_name"` - ToolInput struct { - Command string `json:"command"` - } `json:"tool_input"` + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` } type cursorOutput struct { - Permission string `json:"permission,omitempty"` - UpdatedInput *cursorUpdatedInput `json:"updated_input,omitempty"` -} - -type cursorUpdatedInput struct { - Command string `json:"command"` + Permission string `json:"permission,omitempty"` + UpdatedInput json.RawMessage `json:"updated_input,omitempty"` } // Cursor handles a Cursor preToolUse payload for the Shell tool. If the command @@ -44,15 +38,25 @@ func Cursor(r io.Reader, w io.Writer) error { if err := json.Unmarshal(data, &in); err != nil { return abstain() } - if in.ToolName != "Shell" || in.ToolInput.Command == "" { + var ti struct { + Command string `json:"command"` + } + if json.Unmarshal(in.ToolInput, &ti) != nil || ti.Command == "" { return abstain() } - rewritten := rewrite.LineForAgent(in.ToolInput.Command, "cursor") - if rewritten == in.ToolInput.Command { + if in.ToolName != "Shell" { + return abstain() + } + rewritten := rewrite.LineForAgent(ti.Command, "cursor") + if rewritten == ti.Command { return abstain() // builtin, redirect, unattestable, ...: let Cursor decide } + updated := toolInputWithCommand(in.ToolInput, rewritten) + if updated == nil { + return abstain() + } return json.NewEncoder(w).Encode(cursorOutput{ Permission: "allow", - UpdatedInput: &cursorUpdatedInput{Command: rewritten}, + UpdatedInput: updated, }) } diff --git a/internal/hook/cursor_test.go b/internal/hook/cursor_test.go index 897ba4d..2a05692 100644 --- a/internal/hook/cursor_test.go +++ b/internal/hook/cursor_test.go @@ -26,6 +26,20 @@ func TestCursorGoldenRewrite(t *testing.T) { } } +// updated_input replaces the tool input, so a rewrite must keep every original +// field (run_in_background, timeout, ...) with its JSON type, not just command. +func TestCursorRewritePreservesToolInputFields(t *testing.T) { + var out bytes.Buffer + in := `{"tool_name":"Shell","tool_input":{"command":"git status","run_in_background":true,"timeout":5400000,"description":"d"}}` + if err := Cursor(strings.NewReader(in), &out); err != nil { + t.Fatalf("Cursor: %v", err) + } + want := "{\"permission\":\"allow\",\"updated_input\":{\"command\":\"ctx-wire run --agent cursor git status\",\"description\":\"d\",\"run_in_background\":true,\"timeout\":5400000}}\n" + if out.String() != want { + t.Fatalf("output = %q, want %q", out.String(), want) + } +} + func TestCursorAbstainsForBuiltin(t *testing.T) { var out bytes.Buffer if err := Cursor(strings.NewReader(`{"tool_name":"Shell","tool_input":{"command":"cd /tmp"}}`), &out); err != nil { diff --git a/internal/hook/gemini.go b/internal/hook/gemini.go index 92eb0da..ad82dce 100644 --- a/internal/hook/gemini.go +++ b/internal/hook/gemini.go @@ -8,10 +8,8 @@ import ( ) type geminiInput struct { - ToolName string `json:"tool_name"` - ToolInput struct { - Command string `json:"command"` - } `json:"tool_input"` + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` } type geminiOutput struct { @@ -20,11 +18,7 @@ type geminiOutput struct { } type geminiHookOutput struct { - ToolInput geminiUpdatedInput `json:"tool_input"` -} - -type geminiUpdatedInput struct { - Command string `json:"command"` + ToolInput json.RawMessage `json:"tool_input"` } // Gemini handles a Gemini CLI BeforeTool payload. If the command is rewritable @@ -46,17 +40,27 @@ func Gemini(r io.Reader, w io.Writer) error { if err := json.Unmarshal(data, &in); err != nil { return abstain() } - if in.ToolName != "run_shell_command" || in.ToolInput.Command == "" { + var ti struct { + Command string `json:"command"` + } + if json.Unmarshal(in.ToolInput, &ti) != nil || ti.Command == "" { return abstain() } - rewritten := rewrite.LineForAgent(in.ToolInput.Command, "gemini") - if rewritten == in.ToolInput.Command { + if in.ToolName != "run_shell_command" { + return abstain() + } + rewritten := rewrite.LineForAgent(ti.Command, "gemini") + if rewritten == ti.Command { return abstain() // builtin, redirect, unattestable, ...: let Gemini decide } + updated := toolInputWithCommand(in.ToolInput, rewritten) + if updated == nil { + return abstain() + } return json.NewEncoder(w).Encode(geminiOutput{ Decision: "allow", HookSpecificOutput: &geminiHookOutput{ - ToolInput: geminiUpdatedInput{Command: rewritten}, + ToolInput: updated, }, }) } diff --git a/internal/hook/gemini_test.go b/internal/hook/gemini_test.go index dcc6fa9..1763914 100644 --- a/internal/hook/gemini_test.go +++ b/internal/hook/gemini_test.go @@ -18,6 +18,20 @@ func TestGeminiRewrite(t *testing.T) { } } +// A rewritten tool_input must keep every original field, not just command: the +// host replaces the tool input with what the hook returns. +func TestGeminiRewritePreservesToolInputFields(t *testing.T) { + var out bytes.Buffer + in := `{"tool_name":"run_shell_command","tool_input":{"command":"git status","timeout":5400000,"run_in_background":true}}` + if err := Gemini(strings.NewReader(in), &out); err != nil { + t.Fatalf("Gemini: %v", err) + } + want := "{\"decision\":\"allow\",\"hookSpecificOutput\":{\"tool_input\":{\"command\":\"ctx-wire run --agent gemini git status\",\"run_in_background\":true,\"timeout\":5400000}}}\n" + if out.String() != want { + t.Fatalf("output = %q, want %q", out.String(), want) + } +} + func TestGeminiAbstainsForPassthrough(t *testing.T) { var out bytes.Buffer in := `{"tool_name":"run_shell_command","tool_input":{"command":"cd /tmp"}}` diff --git a/internal/hook/input.go b/internal/hook/input.go index 98551e0..a63d084 100644 --- a/internal/hook/input.go +++ b/internal/hook/input.go @@ -2,6 +2,7 @@ package hook import ( "bytes" + "encoding/json" "errors" "io" ) @@ -32,3 +33,27 @@ func readHookInput(r io.Reader) ([]byte, error) { } return bytes.TrimPrefix(data, utf8BOM), nil } + +// toolInputWithCommand returns toolInput with only its "command" key replaced by +// command. Every other key is carried over byte-for-byte (booleans stay booleans, +// numbers stay numbers, unknown future keys survive), because hosts like Claude +// Code REPLACE the tool input with the hook's updatedInput rather than merging +// it: emitting only {"command": ...} would silently drop fields such as +// run_in_background, timeout, description, or dangerouslyDisableSandbox. +// It returns nil when toolInput is not a JSON object; callers fail open. +func toolInputWithCommand(toolInput json.RawMessage, command string) json.RawMessage { + var obj map[string]json.RawMessage + if err := json.Unmarshal(toolInput, &obj); err != nil || obj == nil { + return nil + } + raw, err := json.Marshal(command) + if err != nil { + return nil + } + obj["command"] = raw + out, err := json.Marshal(obj) + if err != nil { + return nil + } + return out +} diff --git a/internal/hook/testdata/cursor_output.golden b/internal/hook/testdata/cursor_output.golden index 7269803..ca5e1c7 100644 --- a/internal/hook/testdata/cursor_output.golden +++ b/internal/hook/testdata/cursor_output.golden @@ -1 +1 @@ -{"permission":"allow","updated_input":{"command":"ctx-wire run --agent cursor git status"}} +{"permission":"allow","updated_input":{"command":"ctx-wire run --agent cursor git status","working_directory":"/work"}}