feat: hook core types — EventType, Action, CommandType, HookDef, Executor
This commit is contained in:
126
internal/hook/event.go
Normal file
126
internal/hook/event.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
163
internal/hook/event_test.go
Normal file
163
internal/hook/event_test.go
Normal file
@@ -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 }
|
||||
56
internal/hook/hook.go
Normal file
56
internal/hook/hook.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user