mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 22:15:47 +00:00
619 lines
22 KiB
Go
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)
|
|
}
|
|
}
|