diff --git a/internal/hook/event.go b/internal/hook/event.go new file mode 100644 index 0000000..3c8238d --- /dev/null +++ b/internal/hook/event.go @@ -0,0 +1,126 @@ +package hook + +import "fmt" + +// EventType identifies when a hook fires. +type EventType int + +const ( + PreToolUse EventType = iota + 1 + PostToolUse + SessionStart + SessionEnd + PreCompact + Stop +) + +func (e EventType) String() string { + switch e { + case PreToolUse: + return "pre_tool_use" + case PostToolUse: + return "post_tool_use" + case SessionStart: + return "session_start" + case SessionEnd: + return "session_end" + case PreCompact: + return "pre_compact" + case Stop: + return "stop" + default: + return fmt.Sprintf("unknown_event(%d)", int(e)) + } +} + +// ParseEventType parses a TOML event string into an EventType. +func ParseEventType(s string) (EventType, error) { + switch s { + case "pre_tool_use": + return PreToolUse, nil + case "post_tool_use": + return PostToolUse, nil + case "session_start": + return SessionStart, nil + case "session_end": + return SessionEnd, nil + case "pre_compact": + return PreCompact, nil + case "stop": + return Stop, nil + default: + return 0, fmt.Errorf("hook: unknown event type %q", s) + } +} + +// CommandType is the mechanism a hook uses to evaluate. +type CommandType int + +const ( + CommandTypeShell CommandType = iota + 1 // run a shell command + CommandTypePrompt // send a prompt to an LLM + CommandTypeAgent // spawn an elf +) + +func (c CommandType) String() string { + switch c { + case CommandTypeShell: + return "command" + case CommandTypePrompt: + return "prompt" + case CommandTypeAgent: + return "agent" + default: + return fmt.Sprintf("unknown_command(%d)", int(c)) + } +} + +// ParseCommandType parses a TOML type string into a CommandType. +func ParseCommandType(s string) (CommandType, error) { + switch s { + case "command": + return CommandTypeShell, nil + case "prompt": + return CommandTypePrompt, nil + case "agent": + return CommandTypeAgent, nil + default: + return 0, fmt.Errorf("hook: unknown command type %q", s) + } +} + +// Action is the outcome of a hook execution. +type Action int + +const ( + Allow Action = iota + 1 // exit 0 — hook approves + Deny // exit 2 — hook rejects + Skip // exit 1 — hook abstains +) + +func (a Action) String() string { + switch a { + case Allow: + return "allow" + case Deny: + return "deny" + case Skip: + return "skip" + default: + return fmt.Sprintf("unknown_action(%d)", int(a)) + } +} + +// ParseAction maps a shell exit code to an Action. +func ParseAction(exitCode int) (Action, error) { + switch exitCode { + case 0: + return Allow, nil + case 1: + return Skip, nil + case 2: + return Deny, nil + default: + return 0, fmt.Errorf("hook: unrecognised exit code %d", exitCode) + } +} diff --git a/internal/hook/event_test.go b/internal/hook/event_test.go new file mode 100644 index 0000000..5d2ae28 --- /dev/null +++ b/internal/hook/event_test.go @@ -0,0 +1,163 @@ +package hook + +import ( + "testing" +) + +func TestEventType_String(t *testing.T) { + tests := []struct { + event EventType + want string + }{ + {PreToolUse, "pre_tool_use"}, + {PostToolUse, "post_tool_use"}, + {SessionStart, "session_start"}, + {SessionEnd, "session_end"}, + {PreCompact, "pre_compact"}, + {Stop, "stop"}, + } + for _, tt := range tests { + if got := tt.event.String(); got != tt.want { + t.Errorf("EventType(%d).String() = %q, want %q", tt.event, got, tt.want) + } + } +} + +func TestParseEventType(t *testing.T) { + tests := []struct { + input string + want EventType + wantErr bool + }{ + {"pre_tool_use", PreToolUse, false}, + {"post_tool_use", PostToolUse, false}, + {"session_start", SessionStart, false}, + {"session_end", SessionEnd, false}, + {"pre_compact", PreCompact, false}, + {"stop", Stop, false}, + {"unknown", 0, true}, + {"", 0, true}, + {"PRE_TOOL_USE", 0, true}, // case-sensitive + } + for _, tt := range tests { + got, err := ParseEventType(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseEventType(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + continue + } + if !tt.wantErr && got != tt.want { + t.Errorf("ParseEventType(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestCommandType_String(t *testing.T) { + tests := []struct { + ct CommandType + want string + }{ + {CommandTypeShell, "command"}, + {CommandTypePrompt, "prompt"}, + {CommandTypeAgent, "agent"}, + } + for _, tt := range tests { + if got := tt.ct.String(); got != tt.want { + t.Errorf("CommandType(%d).String() = %q, want %q", tt.ct, got, tt.want) + } + } +} + +func TestParseCommandType(t *testing.T) { + tests := []struct { + input string + want CommandType + wantErr bool + }{ + {"command", CommandTypeShell, false}, + {"prompt", CommandTypePrompt, false}, + {"agent", CommandTypeAgent, false}, + {"shell", 0, true}, + {"", 0, true}, + } + for _, tt := range tests { + got, err := ParseCommandType(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseCommandType(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + continue + } + if !tt.wantErr && got != tt.want { + t.Errorf("ParseCommandType(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestAction_String(t *testing.T) { + tests := []struct { + action Action + want string + }{ + {Allow, "allow"}, + {Deny, "deny"}, + {Skip, "skip"}, + } + for _, tt := range tests { + if got := tt.action.String(); got != tt.want { + t.Errorf("Action(%d).String() = %q, want %q", tt.action, got, tt.want) + } + } +} + +func TestParseAction(t *testing.T) { + tests := []struct { + exitCode int + want Action + wantErr bool + }{ + {0, Allow, false}, + {1, Skip, false}, + {2, Deny, false}, + {3, 0, true}, + {-1, 0, true}, + {127, 0, true}, + } + for _, tt := range tests { + got, err := ParseAction(tt.exitCode) + if (err != nil) != tt.wantErr { + t.Errorf("ParseAction(%d) error = %v, wantErr %v", tt.exitCode, err, tt.wantErr) + continue + } + if !tt.wantErr && got != tt.want { + t.Errorf("ParseAction(%d) = %v, want %v", tt.exitCode, got, tt.want) + } + } +} + +func TestHookDef_Validate(t *testing.T) { + valid := HookDef{ + Name: "test-hook", + Event: PreToolUse, + Command: CommandTypeShell, + Exec: "echo hello", + } + + tests := []struct { + name string + def HookDef + wantErr bool + }{ + {"valid", valid, false}, + {"empty name", withName(valid, ""), true}, + {"empty exec", withExec(valid, ""), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.def.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("HookDef.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func withName(d HookDef, name string) HookDef { d.Name = name; return d } +func withExec(d HookDef, exec string) HookDef { d.Exec = exec; return d } diff --git a/internal/hook/hook.go b/internal/hook/hook.go new file mode 100644 index 0000000..024e1cb --- /dev/null +++ b/internal/hook/hook.go @@ -0,0 +1,56 @@ +package hook + +import ( + "context" + "fmt" + "time" +) + +// HookDef is a parsed hook definition from config. +type HookDef struct { + Name string + Event EventType + Command CommandType + Exec string + Timeout time.Duration // default 30s if zero + FailOpen bool // true = allow on error/timeout; false = deny + ToolPattern string // glob for tool name filtering (PreToolUse/PostToolUse only) +} + +// Validate reports an error if the definition is unusable. +func (d HookDef) Validate() error { + if d.Name == "" { + return fmt.Errorf("hook: name is required") + } + if d.Exec == "" { + return fmt.Errorf("hook %q: exec is required", d.Name) + } + return nil +} + +// timeout returns the effective timeout, defaulting to 30s. +func (d HookDef) timeout() time.Duration { + if d.Timeout > 0 { + return d.Timeout + } + return 30 * time.Second +} + +// HookResult is returned by an Executor after running a hook. +type HookResult struct { + Action Action + Output []byte // transformed payload (nil = no transform) + Error error + Duration time.Duration +} + +// Executor runs a single hook. +type Executor interface { + Execute(ctx context.Context, payload []byte) (HookResult, error) +} + +// Handler pairs a definition with its executor. +type Handler struct { + def HookDef + executor Executor +}