From 5701bc2033245583cb6d85a2a47a98ba2a5aaf73 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Tue, 7 Apr 2026 00:48:08 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Dispatcher=20=E2=80=94=20handler=20chai?= =?UTF-8?q?n=20dispatch,=20filtering,=20transform=20chaining?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/hook/dispatcher.go | 117 ++++++++++++ internal/hook/dispatcher_test.go | 297 +++++++++++++++++++++++++++++++ 2 files changed, 414 insertions(+) create mode 100644 internal/hook/dispatcher.go create mode 100644 internal/hook/dispatcher_test.go diff --git a/internal/hook/dispatcher.go b/internal/hook/dispatcher.go new file mode 100644 index 0000000..0fddbd0 --- /dev/null +++ b/internal/hook/dispatcher.go @@ -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 +} diff --git a/internal/hook/dispatcher_test.go b/internal/hook/dispatcher_test.go new file mode 100644 index 0000000..8df242a --- /dev/null +++ b/internal/hook/dispatcher_test.go @@ -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 +}