118 lines
3.1 KiB
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
|
|
}
|