feat: CommandExecutor — shell hook execution with stdin/stdout protocol
This commit is contained in:
70
internal/hook/command.go
Normal file
70
internal/hook/command.go
Normal file
@@ -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
|
||||
}
|
||||
235
internal/hook/command_test.go
Normal file
235
internal/hook/command_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user