Files

619 lines
22 KiB
Go

package hooks
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"maps"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
)
// ── Public surface ───────────────────────────────────────────────────────────
// Handler executes a single hook config against an event. Returned Decision
// drives the blocking-path outcome; the audit writer records the row.
type Handler interface {
Execute(ctx context.Context, cfg HookConfig, ev Event) (Decision, error)
}
// Dispatcher is the pipeline integration surface. Stages call Fire and act on
// FireResult.Decision (allow → continue; block → abort with i18n error).
// FireResult.Updated* carry mutations authored by builtin-source hooks —
// callers apply them to their own tc.Arguments / state.Input when non-nil.
type Dispatcher interface {
Fire(ctx context.Context, ev Event) (FireResult, error)
}
// MaxLoopDepth caps nested hook invocation (M5). Depth increments when a hook
// triggers a sub-agent whose own events feed back into the dispatcher.
const MaxLoopDepth = 3
// ErrLoopDepthExceeded signals the M5 circuit: refuse to process further
// events once the chain exceeds MaxLoopDepth.
var ErrLoopDepthExceeded = errors.New("hooks: loop depth exceeded")
// Defaults for the dispatcher knobs. Set via StdDispatcherOpts when a test or
// deployment needs tighter timings (unit tests use sub-second values).
const (
defaultPerHookTimeout = 5 * time.Second
defaultChainBudget = 10 * time.Second
defaultCircuitThreshold = 5
defaultCircuitWindow = 1 * time.Minute
)
// ctxDepthKey is the context-value key for the nested-hook depth counter.
// Private type prevents collisions with other packages' context keys.
type ctxDepthKey struct{}
// WithDepth stores d in ctx; Fire reads it to enforce MaxLoopDepth (M5).
// Callers that dispatch sub-agent events must increment before re-entering.
func WithDepth(ctx context.Context, d int) context.Context {
return context.WithValue(ctx, ctxDepthKey{}, d)
}
func depthFromCtx(ctx context.Context) int {
if v, ok := ctx.Value(ctxDepthKey{}).(int); ok {
return v
}
return 0
}
// DepthFrom returns the current hook loop depth from ctx (0 if unset).
func DepthFrom(ctx context.Context) int { return depthFromCtx(ctx) }
// IncDepth returns a new ctx with depth incremented by 1.
// Callers wrapping sub-agent invocations must call this before re-entering.
func IncDepth(ctx context.Context) context.Context {
return WithDepth(ctx, depthFromCtx(ctx)+1)
}
// StdDispatcherOpts configures the production dispatcher. Unset fields fall
// back to the default* constants above.
type StdDispatcherOpts struct {
Store HookStore
Audit *AuditWriter
Handlers map[HandlerType]Handler
PerHookTimeout time.Duration
ChainBudget time.Duration
CircuitThreshold int
CircuitWindow time.Duration
// Now is injectable for tests that need deterministic circuit-breaker timing.
Now func() time.Time
}
// NewStdDispatcher returns the production Dispatcher with circuit-breaker,
// per-hook timeouts, and audit writing wired up.
func NewStdDispatcher(opts StdDispatcherOpts) Dispatcher {
if opts.PerHookTimeout <= 0 {
opts.PerHookTimeout = defaultPerHookTimeout
}
if opts.ChainBudget <= 0 {
opts.ChainBudget = defaultChainBudget
}
if opts.CircuitThreshold <= 0 {
opts.CircuitThreshold = defaultCircuitThreshold
}
if opts.CircuitWindow <= 0 {
opts.CircuitWindow = defaultCircuitWindow
}
if opts.Now == nil {
opts.Now = time.Now
}
if opts.Handlers == nil {
opts.Handlers = map[HandlerType]Handler{}
}
return &stdDispatcher{
store: opts.Store,
audit: opts.Audit,
handlers: opts.Handlers,
perTimeout: opts.PerHookTimeout,
chainBudget: opts.ChainBudget,
now: opts.Now,
cb: &circuitBreaker{
threshold: opts.CircuitThreshold,
window: opts.CircuitWindow,
hits: map[uuid.UUID][]time.Time{},
tripped: map[uuid.UUID]bool{},
},
}
}
// NewNoopDispatcher returns a dispatcher that always allows. Used when the
// hook system is disabled (e.g. CI without DB) or before init completes.
func NewNoopDispatcher() Dispatcher {
return noopDispatcher{}
}
type noopDispatcher struct{}
func (noopDispatcher) Fire(context.Context, Event) (FireResult, error) {
return FireResult{Decision: DecisionAllow}, nil
}
// ── stdDispatcher ────────────────────────────────────────────────────────────
type stdDispatcher struct {
store HookStore
audit *AuditWriter
handlers map[HandlerType]Handler
perTimeout time.Duration
chainBudget time.Duration
now func() time.Time
cb *circuitBreaker
}
func (d *stdDispatcher) Fire(ctx context.Context, ev Event) (FireResult, error) {
if depthFromCtx(ctx) > MaxLoopDepth {
slog.Warn("security.hook.loop_depth_exceeded",
"event_id", ev.EventID,
"hook_event", ev.HookEvent,
)
return FireResult{Decision: DecisionError}, ErrLoopDepthExceeded
}
hooks, err := d.store.ResolveForEvent(ctx, ev)
if err != nil {
// Fail-closed: a DB blip must not let a pre-tool gate open silently.
slog.Warn("security.hook.resolve_error", "err", err, "event_id", ev.EventID)
if ev.HookEvent.IsBlocking() {
return FireResult{Decision: DecisionBlock}, err
}
return FireResult{Decision: DecisionAllow}, err
}
if len(hooks) == 0 {
return FireResult{Decision: DecisionAllow}, nil
}
if ev.HookEvent.IsBlocking() {
return d.runSync(ctx, ev, hooks)
}
d.runAsync(ctx, ev, hooks)
return FireResult{Decision: DecisionAllow}, nil
}
// runSync executes the blocking chain with a wall-time budget and per-hook
// timeouts. First block wins; any fail-closed condition aborts to Block.
//
// Mutation propagation (Phase 03): the dispatcher keeps a local evMut copy
// with its own ToolInput map so a builtin-source hook's updatedInput becomes
// visible to downstream hooks in the chain (and to the caller via FireResult)
// without leaking mutations back into the caller's original event on error
// paths. Non-builtin scripts returning updatedInput get their mutation
// dropped + logged — source tier enforced at the dispatcher.
func (d *stdDispatcher) runSync(ctx context.Context, ev Event, chain []HookConfig) (FireResult, error) {
chainCtx, cancel := context.WithTimeout(ctx, d.chainBudget)
defer cancel()
evMut := ev
if ev.ToolInput != nil {
evMut.ToolInput = cloneMap(ev.ToolInput)
}
mutated := false
additionalContexts := make([]string, 0, len(chain))
for _, cfg := range chain {
if !cfg.Enabled {
continue
}
slog.Debug("hooks.prefilter.considered",
"hook_id", cfg.ID, "tool_name", evMut.ToolName, "agent_id", evMut.AgentID, "hook_event", evMut.HookEvent)
if d.cb.isTripped(cfg.ID, d.now()) {
slog.Warn("hooks.dispatch.decision",
"hook_id", cfg.ID, "decision", "block", "reason", "circuit breaker open")
d.writeExec(ctx, cfg, evMut, DecisionBlock, 0, "circuit breaker open", "")
return FireResult{Decision: DecisionBlock, DecisionReason: "circuit breaker open"}, nil
}
pf := d.prefilter(cfg, evMut)
if pf.errored {
// Fail-closed (narrow scope): the matcher matched this call, but
// the CEL condition then failed to compile/evaluate. We cannot
// know whether the hook intended to block or allow, so we treat
// this specific tool call as blocked rather than silently
// letting it through. Other hooks/calls are unaffected.
blockMsg := fmt.Sprintf("hook evaluation failed: %v", pf.err)
slog.Warn("hooks.dispatch.decision",
"hook_id", cfg.ID, "decision", "block", "reason", blockMsg)
d.writeExec(ctx, cfg, evMut, DecisionBlock, 0, blockMsg, "")
return FireResult{Decision: DecisionBlock, DecisionReason: blockMsg}, nil
}
if !pf.match {
slog.Debug("hooks.dispatch.decision",
"hook_id", cfg.ID, "decision", "skip", "reason", "prefilter did not match")
continue
}
// Attach fresh ScriptResult carrier per-hook so the script handler can
// report its non-decision outputs (reason/updatedInput/stdout). Other
// handler types ignore the ctx value — zero-cost.
scriptRes := &ScriptResult{}
hctx := WithScriptResult(chainCtx, scriptRes)
dec, execErr, duration := d.runOne(hctx, cfg, evMut)
errMsg := ""
if execErr != nil {
errMsg = execErr.Error()
}
slog.Debug("hooks.dispatch.decision",
"hook_id", cfg.ID, "decision", string(dec), "duration_ms", duration.Milliseconds(), "err", errMsg)
d.writeExec(ctx, cfg, evMut, dec, duration, errMsg, scriptRes.Stdout)
// Apply mutation to the local copy only when the hook is builtin-source.
// Non-builtin scripts get their updatedInput stripped + warned.
if cfg.HandlerType == HandlerScript && dec == DecisionAllow && scriptRes.UpdatedInput != nil {
if cfg.Source == SourceBuiltin {
applyBuiltinMutation(&evMut, scriptRes.UpdatedInput, builtinAllowlistFor(cfg.ID))
mutated = true
} else {
slog.Warn("hooks.script_mutation_denied",
"hook_id", cfg.ID,
"source", cfg.Source,
"field_count", len(scriptRes.UpdatedInput),
)
}
}
if dec == DecisionAllow && scriptRes.AdditionalContext != "" {
additionalContexts = append(additionalContexts, scriptRes.AdditionalContext)
}
switch dec {
case DecisionBlock:
d.cb.record(ctx, cfg.ID, d.now(), d.store)
return FireResult{Decision: DecisionBlock, DecisionReason: scriptRes.Reason}, nil
case DecisionTimeout:
d.cb.record(ctx, cfg.ID, d.now(), d.store)
if cfg.OnTimeout == DecisionBlock {
return FireResult{Decision: DecisionBlock, DecisionReason: "hook timeout"}, nil
}
// OnTimeout=allow: degrade gracefully but keep scanning.
case DecisionError:
// Unexpected error in a blocking chain → fail-closed.
return FireResult{Decision: DecisionBlock, DecisionReason: "hook execution error: " + errMsg}, nil
}
if chainCtx.Err() != nil {
// Chain wall-time budget exhausted (H3): fail-closed.
return FireResult{Decision: DecisionBlock, DecisionReason: "hook chain timeout"}, nil
}
}
result := FireResult{Decision: DecisionAllow}
result.AdditionalContext = strings.Join(additionalContexts, "\n\n")
if mutated {
if evMut.ToolInput != nil {
result.UpdatedToolInput = evMut.ToolInput
}
if evMut.RawInput != ev.RawInput {
s := evMut.RawInput
result.UpdatedRawInput = &s
}
}
return result, nil
}
// cloneMap returns a shallow copy of m. Used so runSync's mutations on the
// local evMut don't leak back to the caller's event when a downstream hook
// blocks or the chain aborts.
func cloneMap(m map[string]any) map[string]any {
out := make(map[string]any, len(m))
maps.Copy(out, m)
return out
}
// applyBuiltinMutation applies the builtin hook's updatedInput fields into ev,
// gated by the per-builtin allowlist. The allowlist is loaded in Phase 04 from
// builtins.yaml; Phase 03 uses a permissive default (rawInput + toolInput).
func applyBuiltinMutation(ev *Event, updated map[string]any, allowlist []string) {
if updated == nil {
return
}
allowSet := make(map[string]struct{}, len(allowlist))
for _, f := range allowlist {
allowSet[f] = struct{}{}
}
if _, ok := allowSet["rawInput"]; ok {
if s, ok2 := updated["rawInput"].(string); ok2 {
ev.RawInput = s
}
}
if m, ok := updated["toolInput"].(map[string]any); ok {
if _, wildcard := allowSet["toolInput"]; wildcard {
if ev.ToolInput == nil {
ev.ToolInput = map[string]any{}
}
maps.Copy(ev.ToolInput, m)
} else {
for k, v := range m {
if _, ok := allowSet["toolInput."+k]; ok {
if ev.ToolInput == nil {
ev.ToolInput = map[string]any{}
}
ev.ToolInput[k] = v
}
}
}
}
}
// builtinAllowlistLookup is set at startup by the builtin package wiring
// (cmd/gateway_managed.go calls SetBuiltinAllowlistLookup(builtin.AllowlistFor)).
// When nil (tests that don't wire the registry) we fall back to the Phase 03
// permissive default so legacy mutation tests stay green.
//
// atomic.Pointer guards against the data race that fires when parallel tests
// install/clear the lookup while the dispatcher reads it on another goroutine.
var builtinAllowlistLookup atomic.Pointer[func(uuid.UUID) []string]
// SetBuiltinAllowlistLookup installs the per-id allowlist function. Called
// once at startup from cmd/gateway_managed.go. Safe to leave unset in tests —
// callers get the permissive default (rawInput + toolInput) below. Pass nil
// to clear (used by `defer` cleanup in tests).
func SetBuiltinAllowlistLookup(f func(uuid.UUID) []string) {
if f == nil {
builtinAllowlistLookup.Store(nil)
return
}
builtinAllowlistLookup.Store(&f)
}
// builtinAllowlistFor returns the field allowlist for a builtin hook row.
// Registered builtin → YAML mutable_fields. Unknown id under a wired lookup
// → empty allowlist (mutations stripped, defense-in-depth). Unset lookup
// (unit tests) → Phase 03 permissive default.
func builtinAllowlistFor(id uuid.UUID) []string {
if fp := builtinAllowlistLookup.Load(); fp != nil {
return (*fp)(id)
}
return []string{"rawInput", "toolInput"}
}
// SourceBuiltin is the `source` value marking a hook seeded by the builtin
// infrastructure (Phase 04 loader). Only builtin-source hooks are allowed to
// mutate event input through the script handler's updatedInput field.
const SourceBuiltin = "builtin"
// runAsync fires non-blocking hooks concurrently. Phase 1 uses a simple
// goroutine-per-hook; Phase 2 will route through the eventbus worker pool.
func (d *stdDispatcher) runAsync(ctx context.Context, ev Event, chain []HookConfig) {
for _, cfg := range chain {
if !cfg.Enabled {
continue
}
slog.Debug("hooks.prefilter.considered",
"hook_id", cfg.ID, "tool_name", ev.ToolName, "agent_id", ev.AgentID, "hook_event", ev.HookEvent)
pf := d.prefilter(cfg, ev)
if pf.errored {
// Non-blocking event: there is no tool call to block, so we log
// loudly and skip this hook rather than fail-closed. Fail-closed
// blocking only applies to the synchronous (blocking) chain.
slog.Warn("hooks.dispatch.decision",
"hook_id", cfg.ID, "decision", "skip", "reason", fmt.Sprintf("prefilter error: %v", pf.err))
continue
}
if !pf.match {
slog.Debug("hooks.dispatch.decision",
"hook_id", cfg.ID, "decision", "skip", "reason", "prefilter did not match")
continue
}
go func(c HookConfig) {
scriptRes := &ScriptResult{}
hctx := WithScriptResult(ctx, scriptRes)
dec, execErr, duration := d.runOne(hctx, c, ev)
errMsg := ""
if execErr != nil {
errMsg = execErr.Error()
}
slog.Debug("hooks.dispatch.decision",
"hook_id", c.ID, "decision", string(dec), "duration_ms", duration.Milliseconds(), "err", errMsg)
// Use WithoutCancel to preserve context values (TenantID, UserID)
// but detach from parent deadline, then add a timeout to prevent indefinite hang
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
d.writeExec(writeCtx, c, ev, dec, duration, errMsg, scriptRes.Stdout)
}(cfg)
}
}
// runOne executes a single hook with its per-hook timeout. Returns the
// decision, any error from the handler, and the elapsed duration.
func (d *stdDispatcher) runOne(ctx context.Context, cfg HookConfig, ev Event) (Decision, error, time.Duration) {
handler, ok := d.handlers[cfg.HandlerType]
if !ok {
return DecisionError, fmt.Errorf("hook: no handler registered for %q", cfg.HandlerType), 0
}
timeout := d.perTimeout
if cfg.TimeoutMS > 0 {
timeout = time.Duration(cfg.TimeoutMS) * time.Millisecond
}
hctx, hcancel := context.WithTimeout(ctx, timeout)
defer hcancel()
start := d.now()
dec, err := handler.Execute(hctx, cfg, ev)
duration := d.now().Sub(start)
if errors.Is(hctx.Err(), context.DeadlineExceeded) {
return DecisionTimeout, hctx.Err(), duration
}
return dec, err, duration
}
// prefilterResult distinguishes "hook legitimately did not match" (match=false,
// errored=false — normal pass-through, no side effects) from "matcher matched
// but the CEL condition then failed to compile/evaluate" (errored=true — the
// caller must fail-closed for blocking events). Matcher regex compile
// failures are treated as a non-match (pre-existing behavior, out of the
// narrow fail-closed scope requested) since there is no successful match to
// fail closed on.
type prefilterResult struct {
match bool
errored bool
err error
}
// prefilter applies the matcher + CEL gate; Phase 1 keeps it lightweight and
// pushes the actual compile through matcher.go's cached helpers.
func (d *stdDispatcher) prefilter(cfg HookConfig, ev Event) prefilterResult {
if cfg.Matcher != "" {
re, err := CompileMatcher(cfg.Matcher)
if err != nil {
slog.Warn("hooks.prefilter.error",
"hook_id", cfg.ID, "stage", "matcher_compile", "matcher", cfg.Matcher, "err", err)
return prefilterResult{match: false}
}
matched := MatchToolName(re, ev.ToolName)
slog.Debug("hooks.prefilter.matcher_checked",
"hook_id", cfg.ID, "tool_name", ev.ToolName, "matcher", cfg.Matcher, "matched", matched)
if !matched {
return prefilterResult{match: false}
}
}
if cfg.IfExpr != "" {
prg, err := CompileCELExpr(cfg.IfExpr)
if err != nil {
slog.Warn("hooks.prefilter.error",
"hook_id", cfg.ID, "stage", "cel_compile", "expr", cfg.IfExpr, "err", err)
return prefilterResult{errored: true, err: fmt.Errorf("CEL compile failed: %w", err)}
}
ok, err := EvalCEL(prg, map[string]any{
"tool_name": ev.ToolName,
"tool_input": ev.ToolInput,
"depth": ev.Depth,
})
if err != nil {
slog.Warn("hooks.prefilter.error",
"hook_id", cfg.ID, "stage", "cel_eval", "err", err)
return prefilterResult{errored: true, err: fmt.Errorf("CEL evaluation failed: %w", err)}
}
slog.Debug("hooks.prefilter.condition_evaluated",
"hook_id", cfg.ID, "result", ok)
if !ok {
return prefilterResult{match: false}
}
}
return prefilterResult{match: true}
}
// writeExec assembles the audit row and routes it through AuditWriter which
// handles truncation, redaction, and encryption. Failures are logged but do
// not propagate — audit is observability, not policy.
func (d *stdDispatcher) writeExec(ctx context.Context, cfg HookConfig, ev Event, dec Decision, duration time.Duration, errMsg string, consoleOutput string) {
if d.audit == nil {
return
}
inputHash, _ := CanonicalInputHash(ev.ToolName, ev.ToolInput)
hookID := cfg.ID
metadata := map[string]any{}
if consoleOutput != "" {
// No dedicated DB column for console output — mirrored into the
// already-persisted JSON metadata column so it survives AuditWriter.Log
// without a schema migration.
metadata["console_output"] = consoleOutput
}
exec := HookExecution{
ID: uuid.New(),
HookID: &hookID,
TenantID: &ev.TenantID,
SessionID: ev.SessionID,
Event: ev.HookEvent,
InputHash: inputHash,
Decision: dec,
DurationMS: int(duration / time.Millisecond),
DedupKey: cfg.ID.String() + ":" + ev.EventID,
Error: errMsg,
Metadata: metadata,
ConsoleOutput: consoleOutput,
CreatedAt: d.now(),
}
if err := d.audit.Log(ctx, exec); err != nil {
slog.Warn("security.hook.audit_write_failed", "err", err, "hook_id", cfg.ID)
}
// Emit a trace span for this hook execution so hook input/output/decision are
// visible alongside model and tool spans for agent troubleshooting. ctx here
// already carries the parent span ID set by the tool_stage wrappers
// (pre_tool_use → llm span; post_tool_use → tool span). For non-tool hooks
// (session start/stop), parent falls back to the agent span (ctx default).
durationMS := int(duration / time.Millisecond)
startedAt := time.Now().UTC().Add(-duration)
hookInput, _ := json.Marshal(map[string]any{
"event": ev.HookEvent,
"tool_name": ev.ToolName,
"tool_input": ev.ToolInput,
"raw_input": ev.RawInput,
"session_id": ev.SessionID,
"agent_id": ev.AgentID,
"depth": ev.Depth,
})
EmitHookSpan(ctx, ev.HookEvent, cfg.HandlerType, cfg, startedAt, dec, durationMS, errMsg, string(hookInput), consoleOutput)
}
// ── circuitBreaker ───────────────────────────────────────────────────────────
// circuitBreaker tracks recent block/timeout timestamps per hook; once the
// count inside the rolling window hits the threshold it persists the hook as
// disabled and short-circuits subsequent Fires (C4 mitigation).
type circuitBreaker struct {
mu sync.Mutex
threshold int
window time.Duration
hits map[uuid.UUID][]time.Time
tripped map[uuid.UUID]bool
}
func (cb *circuitBreaker) isTripped(id uuid.UUID, _ time.Time) bool {
cb.mu.Lock()
defer cb.mu.Unlock()
return cb.tripped[id]
}
// record appends a block/timeout event and, if the window count hits the
// threshold, trips the breaker and asks the store to persist enabled=false.
// Persistence failure is logged only — the in-memory trip still protects the
// current process.
func (cb *circuitBreaker) record(ctx context.Context, id uuid.UUID, now time.Time, store HookStore) {
cb.mu.Lock()
cutoff := now.Add(-cb.window)
kept := cb.hits[id][:0]
for _, t := range cb.hits[id] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
kept = append(kept, now)
cb.hits[id] = kept
justTripped := !cb.tripped[id] && len(kept) >= cb.threshold
if justTripped {
cb.tripped[id] = true
}
cb.mu.Unlock()
if !justTripped {
return
}
slog.Warn("security.hook.circuit_breaker",
"hook_id", id,
"window", cb.window.String(),
"threshold", cb.threshold,
)
if store == nil {
return
}
// Use WithoutCancel to preserve context values, add 2s timeout for store update
storeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
if err := store.Update(storeCtx, id, map[string]any{"enabled": false}); err != nil {
slog.Warn("security.hook.circuit_breaker_persist_failed", "hook_id", id, "err", err)
}
}