Files
gnoma/internal/hook/dispatcher.go

118 lines
3.1 KiB
Go

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
}