diff --git a/internal/hook/command.go b/internal/hook/command.go new file mode 100644 index 0000000..92e8b99 --- /dev/null +++ b/internal/hook/command.go @@ -0,0 +1,70 @@ +package hook + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "time" +) + +// CommandExecutor runs a shell command and interprets its stdin/stdout. +type CommandExecutor struct { + def HookDef +} + +// NewCommandExecutor constructs a CommandExecutor for the given definition. +func NewCommandExecutor(def HookDef) *CommandExecutor { + return &CommandExecutor{def: def} +} + +// Execute runs the hook command, pipes payload to stdin, and reads stdout. +func (c *CommandExecutor) Execute(ctx context.Context, payload []byte) (HookResult, error) { + timeout := c.def.timeout() + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + start := time.Now() + cmd := exec.CommandContext(ctx, "sh", "-c", c.def.Exec) + cmd.Stdin = bytes.NewReader(payload) + + var stdout bytes.Buffer + cmd.Stdout = &stdout + + runErr := cmd.Run() + duration := time.Since(start) + + // Determine exit code and whether it was a timeout. + exitCode := 0 + if runErr != nil { + if ctx.Err() != nil { + // Context deadline exceeded — apply fail_open policy. + action := Deny + if c.def.FailOpen { + action = Allow + } + return HookResult{Action: action, Duration: duration}, fmt.Errorf("hook %q: timed out after %v", c.def.Name, timeout) + } + if exitErr, ok := runErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + // Unexpected error launching the process. + action := Deny + if c.def.FailOpen { + action = Allow + } + return HookResult{Action: action, Duration: duration}, fmt.Errorf("hook %q: %w", c.def.Name, runErr) + } + } + + action, transformed, err := ParseHookOutput(stdout.Bytes(), exitCode) + if err != nil { + failAction := Deny + if c.def.FailOpen { + failAction = Allow + } + return HookResult{Action: failAction, Duration: duration}, fmt.Errorf("hook %q: %w", c.def.Name, err) + } + + return HookResult{Action: action, Output: transformed, Duration: duration}, nil +} diff --git a/internal/hook/command_test.go b/internal/hook/command_test.go new file mode 100644 index 0000000..f78b5fd --- /dev/null +++ b/internal/hook/command_test.go @@ -0,0 +1,235 @@ +package hook + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" +) + +func TestCommandExecutor_ExitZero_Allow(t *testing.T) { + def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeShell, Exec: "exit 0"} + ex := NewCommandExecutor(def) + result, err := ex.Execute(context.Background(), []byte(`{}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Action != Allow { + t.Errorf("action = %v, want Allow", result.Action) + } +} + +func TestCommandExecutor_ExitOne_Skip(t *testing.T) { + def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeShell, Exec: "exit 1"} + ex := NewCommandExecutor(def) + result, err := ex.Execute(context.Background(), []byte(`{}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Action != Skip { + t.Errorf("action = %v, want Skip", result.Action) + } +} + +func TestCommandExecutor_ExitTwo_Deny(t *testing.T) { + def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeShell, Exec: "exit 2"} + ex := NewCommandExecutor(def) + result, err := ex.Execute(context.Background(), []byte(`{}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Action != Deny { + t.Errorf("action = %v, want Deny", result.Action) + } +} + +func TestCommandExecutor_StdinDelivered(t *testing.T) { + // Hook reads stdin and echoes it back to stdout as JSON with action=allow + def := HookDef{ + Name: "test", + Event: PreToolUse, + Command: CommandTypeShell, + Exec: `read -r line; echo '{"action":"allow","transformed":'"$line"'}'; exit 0`, + } + ex := NewCommandExecutor(def) + payload := []byte(`{"tool":"bash"}`) + result, err := ex.Execute(context.Background(), payload) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Action != Allow { + t.Errorf("action = %v, want Allow", result.Action) + } +} + +func TestCommandExecutor_StdoutJSON_Parsed(t *testing.T) { + // Hook outputs JSON with transformed payload + def := HookDef{ + Name: "test", + Event: PreToolUse, + Command: CommandTypeShell, + Exec: `echo '{"action":"deny","transformed":{"command":"safe"}}'`, + } + ex := NewCommandExecutor(def) + result, err := ex.Execute(context.Background(), []byte(`{}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Action != Deny { + t.Errorf("action = %v, want Deny", result.Action) + } + if result.Output == nil { + t.Error("expected non-nil transformed output") + } +} + +func TestCommandExecutor_StdoutJSON_ActionOverridesExitCode(t *testing.T) { + // exit 0, but stdout says deny + def := HookDef{ + Name: "test", + Event: PreToolUse, + Command: CommandTypeShell, + Exec: `echo '{"action":"deny"}'; exit 0`, + } + ex := NewCommandExecutor(def) + result, err := ex.Execute(context.Background(), []byte(`{}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Action != Deny { + t.Errorf("action = %v, want Deny", result.Action) + } +} + +func TestCommandExecutor_EmptyStdout_ExitCodeFallback(t *testing.T) { + def := HookDef{ + Name: "test", + Event: PreToolUse, + Command: CommandTypeShell, + Exec: `exit 2`, + } + ex := NewCommandExecutor(def) + result, err := ex.Execute(context.Background(), []byte(`{}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Action != Deny { + t.Errorf("action = %v, want Deny", result.Action) + } + if result.Output != nil { + t.Error("expected nil output for empty stdout") + } +} + +func TestCommandExecutor_Timeout_FailOpenTrue(t *testing.T) { + def := HookDef{ + Name: "test", + Event: PreToolUse, + Command: CommandTypeShell, + Exec: "sleep 10", + Timeout: 50 * time.Millisecond, + FailOpen: true, + } + ex := NewCommandExecutor(def) + result, err := ex.Execute(context.Background(), []byte(`{}`)) + if err == nil { + t.Fatal("expected error on timeout") + } + if result.Action != Allow { + t.Errorf("fail_open=true: action = %v, want Allow", result.Action) + } +} + +func TestCommandExecutor_Timeout_FailOpenFalse(t *testing.T) { + def := HookDef{ + Name: "test", + Event: PreToolUse, + Command: CommandTypeShell, + Exec: "sleep 10", + Timeout: 50 * time.Millisecond, + FailOpen: false, + } + ex := NewCommandExecutor(def) + result, err := ex.Execute(context.Background(), []byte(`{}`)) + if err == nil { + t.Fatal("expected error on timeout") + } + if result.Action != Deny { + t.Errorf("fail_open=false: action = %v, want Deny", result.Action) + } +} + +func TestCommandExecutor_InvalidJSON_Stdout(t *testing.T) { + // Hook writes garbage — should error + def := HookDef{ + Name: "test", + Event: PreToolUse, + Command: CommandTypeShell, + Exec: `echo "not valid json"`, + } + ex := NewCommandExecutor(def) + result, err := ex.Execute(context.Background(), []byte(`{}`)) + if err == nil { + t.Fatal("expected error for invalid JSON stdout") + } + // fail_open=false (default) → Deny on error + if result.Action != Deny { + t.Errorf("action = %v, want Deny on error with fail_open=false", result.Action) + } +} + +func TestCommandExecutor_Duration_Recorded(t *testing.T) { + def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeShell, Exec: "exit 0"} + ex := NewCommandExecutor(def) + result, _ := ex.Execute(context.Background(), []byte(`{}`)) + if result.Duration <= 0 { + t.Error("expected Duration > 0") + } +} + +func TestCommandExecutor_PreToolPayload_HasToolField(t *testing.T) { + // Verify that a real pre_tool_use payload round-trips through the executor + args := json.RawMessage(`{"command":"ls"}`) + payload := MarshalPreToolPayload("bash", args) + + def := HookDef{ + Name: "test", + Event: PreToolUse, + Command: CommandTypeShell, + // Read tool name from stdin, allow if it's "bash" + Exec: `input=$(cat); tool=$(echo "$input" | grep -o '"tool":"[^"]*"' | cut -d'"' -f4); [ "$tool" = "bash" ] && exit 0 || exit 2`, + } + ex := NewCommandExecutor(def) + result, err := ex.Execute(context.Background(), payload) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Action != Allow { + t.Errorf("action = %v, want Allow for tool=bash", result.Action) + } +} + +// Verify the executor honours context cancellation in addition to its own timeout. +func TestCommandExecutor_ContextCancelled(t *testing.T) { + def := HookDef{ + Name: "test", + Event: PreToolUse, + Command: CommandTypeShell, + Exec: "sleep 10", + FailOpen: true, + } + ex := NewCommandExecutor(def) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + result, err := ex.Execute(ctx, []byte(`{}`)) + if err == nil { + t.Fatal("expected error on context cancellation") + } + if !strings.Contains(err.Error(), "hook") { + t.Logf("error = %v", err) // just informational + } + if result.Action != Allow { // fail_open=true + t.Errorf("action = %v, want Allow (fail_open=true)", result.Action) + } +}