Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions internal/hook/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
})
}
107 changes: 105 additions & 2 deletions internal/hook/claude_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package hook
import (
"bytes"
"encoding/json"
"reflect"
"strings"
"testing"
)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/hook/claude_worktree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 19 additions & 15 deletions internal/hook/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"`
Expand All @@ -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{
Expand All @@ -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
Expand All @@ -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,
},
})
}
Expand Down
39 changes: 37 additions & 2 deletions internal/hook/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
33 changes: 17 additions & 16 deletions internal/hook/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -74,19 +68,26 @@ 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{
HookSpecificOutput: copilotHookOutput{
HookEventName: "PreToolUse",
PermissionDecision: "allow",
PermissionDecisionReason: "ctx-wire rewrite",
UpdatedInput: copilotUpdatedInput{Command: rewritten},
UpdatedInput: updated,
},
})
}
Expand Down
14 changes: 14 additions & 0 deletions internal/hook/copilot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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\"}"}`
Expand Down
Loading