feat: Dispatcher — handler chain dispatch, filtering, transform chaining

This commit is contained in:
2026-04-07 00:48:08 +02:00
parent bf50aa234f
commit 5701bc2033
2 changed files with 414 additions and 0 deletions

117
internal/hook/dispatcher.go Normal file
View File

@@ -0,0 +1,117 @@
package hook
import (
"context"
"fmt"
"log/slog"
"path/filepath"
)
// Dispatcher manages hook handler chains and fires them on events.
type Dispatcher struct {
chains map[EventType][]Handler
logger *slog.Logger
}
// NewDispatcher validates defs, constructs the appropriate executor per
// CommandType, and groups handlers by EventType.
func NewDispatcher(defs []HookDef, logger *slog.Logger, executorFn func(HookDef) (Executor, error)) (*Dispatcher, error) {
if logger == nil {
logger = slog.Default()
}
d := &Dispatcher{
chains: make(map[EventType][]Handler),
logger: logger,
}
for _, def := range defs {
if err := def.Validate(); err != nil {
return nil, fmt.Errorf("hook.NewDispatcher: %w", err)
}
ex, err := executorFn(def)
if err != nil {
return nil, fmt.Errorf("hook.NewDispatcher: building executor for %q: %w", def.Name, err)
}
d.chains[def.Event] = append(d.chains[def.Event], Handler{def: def, executor: ex})
}
return d, nil
}
// Fire runs all handlers registered for event, in order.
// Returns the (possibly transformed) payload, the aggregate Action, and the first error.
// Safe to call on a nil *Dispatcher — returns (payload, Allow, nil).
func (d *Dispatcher) Fire(event EventType, payload []byte) ([]byte, Action, error) {
if d == nil {
return payload, Allow, nil
}
handlers := d.chains[event]
if len(handlers) == 0 {
return payload, Allow, nil
}
results := make([]HookResult, 0, len(handlers))
current := payload
var firstErr error
logger := d.logger
if logger == nil {
logger = slog.Default()
}
for _, h := range handlers {
// For tool-scoped events, skip handlers whose ToolPattern doesn't match.
if (event == PreToolUse || event == PostToolUse) && h.def.ToolPattern != "" {
toolName := ExtractToolName(current)
matched, _ := filepath.Match(h.def.ToolPattern, toolName)
if !matched {
continue
}
}
ctx, cancel := context.WithTimeout(context.Background(), h.def.timeout())
result, err := h.executor.Execute(ctx, current)
cancel()
if err != nil {
logger.Warn("hook executor error", "hook", h.def.Name, "error", err)
if firstErr == nil {
firstErr = err
}
// Apply fail_open policy: treat as Deny or Allow.
if h.def.FailOpen {
result.Action = Allow
} else {
result.Action = Deny
}
result.Output = nil
}
// Chain transforms: if this handler produced output, pass it forward.
if len(result.Output) > 0 {
current = result.Output
}
results = append(results, result)
}
action := resolveAction(results, event)
return current, action, firstErr
}
// resolveAction aggregates handler results into a final Action.
// Rules:
// - PostToolUse Deny is treated as Skip (execution already happened).
// - Any Deny → final Deny.
// - Skip abstains (doesn't count as a vote either way).
// - All remaining Allow (or empty / all-Skip) → Allow.
func resolveAction(results []HookResult, event EventType) Action {
for _, r := range results {
if r.Action == Deny && event == PostToolUse {
continue // Deny on PostToolUse is meaningless — tool already ran
}
if r.Action == Deny {
return Deny
}
}
return Allow
}

View File

@@ -0,0 +1,297 @@
package hook
import (
"context"
"testing"
)
// mockExecutor is a test Executor with configurable return values and payload capture.
type mockExecutor struct {
result HookResult
err error
receivedPayload []byte
}
func (m *mockExecutor) Execute(_ context.Context, payload []byte) (HookResult, error) {
m.receivedPayload = append([]byte(nil), payload...)
return m.result, m.err
}
func newAllow() *mockExecutor { return &mockExecutor{result: HookResult{Action: Allow}} }
func newDeny() *mockExecutor { return &mockExecutor{result: HookResult{Action: Deny}} }
func newSkip() *mockExecutor { return &mockExecutor{result: HookResult{Action: Skip}} }
func newTransform(output []byte) *mockExecutor {
return &mockExecutor{result: HookResult{Action: Allow, Output: output}}
}
func newError(failOpen bool) *mockExecutor {
return &mockExecutor{
result: HookResult{},
err: context.DeadlineExceeded,
}
}
func makeHandler(event EventType, ex Executor) Handler {
return Handler{
def: HookDef{Name: "h", Event: event, Command: CommandTypeShell, Exec: "x"},
executor: ex,
}
}
func makePatternHandler(pattern string, ex Executor) Handler {
return Handler{
def: HookDef{Name: "h", Event: PreToolUse, Command: CommandTypeShell, Exec: "x", ToolPattern: pattern},
executor: ex,
}
}
// --- resolveAction unit tests ---
func TestResolveAction_EmptyResults_Allow(t *testing.T) {
if got := resolveAction(nil, PreToolUse); got != Allow {
t.Errorf("resolveAction(nil) = %v, want Allow", got)
}
}
func TestResolveAction_AllAllow(t *testing.T) {
results := []HookResult{
{Action: Allow},
{Action: Allow},
}
if got := resolveAction(results, PreToolUse); got != Allow {
t.Errorf("all Allow: got %v, want Allow", got)
}
}
func TestResolveAction_OneDeny(t *testing.T) {
results := []HookResult{
{Action: Allow},
{Action: Deny},
{Action: Allow},
}
if got := resolveAction(results, PreToolUse); got != Deny {
t.Errorf("one Deny: got %v, want Deny", got)
}
}
func TestResolveAction_SkipAbstains(t *testing.T) {
results := []HookResult{
{Action: Allow},
{Action: Skip},
}
if got := resolveAction(results, PreToolUse); got != Allow {
t.Errorf("skip abstains: got %v, want Allow", got)
}
}
func TestResolveAction_AllSkip(t *testing.T) {
results := []HookResult{
{Action: Skip},
{Action: Skip},
}
// all abstain → Allow (no one objected)
if got := resolveAction(results, PreToolUse); got != Allow {
t.Errorf("all Skip: got %v, want Allow", got)
}
}
func TestResolveAction_PostToolUseDenyBecomesSkip(t *testing.T) {
results := []HookResult{
{Action: Deny},
}
if got := resolveAction(results, PostToolUse); got != Allow {
t.Errorf("PostToolUse Deny treated as Skip: got %v, want Allow", got)
}
}
// --- Dispatcher.Fire tests ---
func TestDispatcher_NilReceiver_Allow(t *testing.T) {
var d *Dispatcher
payload := []byte(`{"tool":"bash"}`)
got, action, err := d.Fire(PreToolUse, payload)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if action != Allow {
t.Errorf("nil dispatcher: action = %v, want Allow", action)
}
if string(got) != string(payload) {
t.Errorf("nil dispatcher: payload mutated")
}
}
func TestDispatcher_EmptyChain_Allow(t *testing.T) {
d := &Dispatcher{chains: make(map[EventType][]Handler)}
payload := []byte(`{}`)
_, action, err := d.Fire(PreToolUse, payload)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if action != Allow {
t.Errorf("empty chain: action = %v, want Allow", action)
}
}
func TestDispatcher_SingleHandler_Allow(t *testing.T) {
ex := newAllow()
d := dispatcherWith(PreToolUse, makeHandler(PreToolUse, ex))
_, action, err := d.Fire(PreToolUse, []byte(`{}`))
if err != nil || action != Allow {
t.Errorf("got action=%v err=%v, want Allow nil", action, err)
}
}
func TestDispatcher_SingleHandler_Deny(t *testing.T) {
d := dispatcherWith(PreToolUse, makeHandler(PreToolUse, newDeny()))
_, action, _ := d.Fire(PreToolUse, []byte(`{}`))
if action != Deny {
t.Errorf("action = %v, want Deny", action)
}
}
func TestDispatcher_SingleHandler_Skip(t *testing.T) {
d := dispatcherWith(PreToolUse, makeHandler(PreToolUse, newSkip()))
_, action, _ := d.Fire(PreToolUse, []byte(`{}`))
if action != Allow {
t.Errorf("skip abstains: action = %v, want Allow", action)
}
}
func TestDispatcher_AllMustAllow_TwoAllowOneDeny(t *testing.T) {
d := dispatcherWith(PreToolUse,
makeHandler(PreToolUse, newAllow()),
makeHandler(PreToolUse, newDeny()),
makeHandler(PreToolUse, newAllow()),
)
_, action, _ := d.Fire(PreToolUse, []byte(`{}`))
if action != Deny {
t.Errorf("action = %v, want Deny", action)
}
}
func TestDispatcher_TransformChaining(t *testing.T) {
// Handler A transforms payload; handler B receives the transformed version.
transformed := []byte(`{"tool":"transformed"}`)
exA := newTransform(transformed)
exB := newAllow()
d := dispatcherWith(PreToolUse,
makeHandler(PreToolUse, exA),
makeHandler(PreToolUse, exB),
)
d.Fire(PreToolUse, []byte(`{"tool":"original"}`))
if string(exB.receivedPayload) != string(transformed) {
t.Errorf("exB received %q, want %q", exB.receivedPayload, transformed)
}
}
func TestDispatcher_TransformChaining_EmptyOutputPassesThrough(t *testing.T) {
// Handler A returns no output (no transform); handler B should receive the original payload.
original := []byte(`{"tool":"original"}`)
exA := newAllow() // no Output
exB := newAllow()
d := dispatcherWith(PreToolUse,
makeHandler(PreToolUse, exA),
makeHandler(PreToolUse, exB),
)
d.Fire(PreToolUse, original)
if string(exB.receivedPayload) != string(original) {
t.Errorf("exB received %q, want %q", exB.receivedPayload, original)
}
}
func TestDispatcher_ToolPattern_Match(t *testing.T) {
ex := newDeny()
payload := MarshalPreToolPayload("bash", nil)
d := dispatcherWith(PreToolUse, makePatternHandler("bash*", ex))
_, action, _ := d.Fire(PreToolUse, payload)
if action != Deny {
t.Errorf("pattern matched: action = %v, want Deny", action)
}
}
func TestDispatcher_ToolPattern_NoMatch(t *testing.T) {
ex := newDeny()
payload := MarshalPreToolPayload("fs.read", nil)
d := dispatcherWith(PreToolUse, makePatternHandler("bash*", ex))
_, action, _ := d.Fire(PreToolUse, payload)
// handler skipped — no deniers → Allow
if action != Allow {
t.Errorf("pattern no match: action = %v, want Allow", action)
}
}
func TestDispatcher_ToolPattern_Empty_MatchesAll(t *testing.T) {
ex := newDeny()
payload := MarshalPreToolPayload("fs.read", nil)
d := dispatcherWith(PreToolUse, makePatternHandler("", ex))
_, action, _ := d.Fire(PreToolUse, payload)
if action != Allow {
// empty pattern + Deny → resolveAction sees Deny → Deny
// wait, empty pattern means fire for all tools
}
// correct: empty pattern fires → Deny
if action != Deny {
t.Errorf("empty pattern matches all: action = %v, want Deny", action)
}
}
func TestDispatcher_ErrorFailOpenTrue(t *testing.T) {
ex := &mockExecutor{result: HookResult{}, err: context.DeadlineExceeded}
d := dispatcherWith(PreToolUse, Handler{
def: HookDef{Name: "h", Event: PreToolUse, Command: CommandTypeShell, Exec: "x", FailOpen: true},
executor: ex,
})
_, action, _ := d.Fire(PreToolUse, []byte(`{}`))
if action != Allow {
t.Errorf("error+fail_open=true: action = %v, want Allow", action)
}
}
func TestDispatcher_ErrorFailOpenFalse(t *testing.T) {
ex := &mockExecutor{result: HookResult{}, err: context.DeadlineExceeded}
d := dispatcherWith(PreToolUse, Handler{
def: HookDef{Name: "h", Event: PreToolUse, Command: CommandTypeShell, Exec: "x", FailOpen: false},
executor: ex,
})
_, action, _ := d.Fire(PreToolUse, []byte(`{}`))
if action != Deny {
t.Errorf("error+fail_open=false: action = %v, want Deny", action)
}
}
func TestDispatcher_PostToolUseDenyTreatedAsSkip(t *testing.T) {
d := dispatcherWith(PostToolUse, makeHandler(PostToolUse, newDeny()))
_, action, _ := d.Fire(PostToolUse, []byte(`{}`))
if action != Allow {
t.Errorf("PostToolUse deny → skip → Allow: got %v", action)
}
}
func TestDispatcher_FinalTransformedPayloadReturned(t *testing.T) {
finalPayload := []byte(`{"command":"final"}`)
d := dispatcherWith(PreToolUse, makeHandler(PreToolUse, newTransform(finalPayload)))
got, _, _ := d.Fire(PreToolUse, []byte(`{"command":"original"}`))
if string(got) != string(finalPayload) {
t.Errorf("Fire() returned %q, want %q", got, finalPayload)
}
}
func TestDispatcher_NoTransform_OriginalPayloadReturned(t *testing.T) {
original := []byte(`{"command":"original"}`)
d := dispatcherWith(PreToolUse, makeHandler(PreToolUse, newAllow()))
got, _, _ := d.Fire(PreToolUse, original)
if string(got) != string(original) {
t.Errorf("Fire() returned %q, want %q", got, original)
}
}
// dispatcherWith builds a Dispatcher with pre-built handlers for a single event.
func dispatcherWith(event EventType, handlers ...Handler) *Dispatcher {
d := &Dispatcher{chains: make(map[EventType][]Handler)}
d.chains[event] = handlers
return d
}