Files
goclaw/docs/agent-hooks.md
T
viettranx 95bdb23a36 docs(hooks): user guide + example configs + changelog + Make targets
- docs/agent-hooks.md: handler reference, lifecycle events, security model
- examples/hooks/: 5 runnable JSON configs (audit, lint, block-rm-rf,
  Discord notify, context injector)
- docs/17-changelog.md: Wave 0 entry
- Makefile: hooks-specific test targets
- CLAUDE.md: cross-reference
2026-04-16 14:17:47 +07:00

8.7 KiB

Agent Hooks

Lifecycle hooks let you intercept, observe, or inject behavior at defined points in the agent loop. Use cases: block unsafe tool calls, auto-lint after writes, inject session context, notify on stop, audit tool usage.

Concepts

Events

Seven lifecycle events fire during an agent session:

Event Blocking When it fires
session_start no A new session is established.
user_prompt_submit yes Before the user's message enters the pipeline.
pre_tool_use yes Before any tool call executes.
post_tool_use no After a tool call completes.
stop no The agent session terminates normally.
subagent_start yes A sub-agent is spawned.
subagent_stop no A sub-agent finishes.

Blocking events wait for the sync chain to return an allow/block decision. Non-blocking events fire hooks asynchronously for observation only.

Handler types

Handler Editions Notes
command Lite only Local shell command; exit 2 → block, exit 0 → allow.
http Lite + Standard POST to endpoint; JSON body → decision. SSRF-protected.
prompt Lite + Standard LLM-based evaluation with structured tool-call output. Prompt-injection-resistant, budget-bounded. Requires matcher or if_expr.

Scopes

  • global — applies to all tenants. Master scope required to create.
  • tenant — applies to one tenant (any agent).
  • agent — applies to a specific agent within a tenant.

Hooks resolve in priority order, highest first. A single block decision short-circuits the chain.

Security model

  • Edition gating: command handler blocked on Standard at both config-time AND dispatch-time (defense in depth).
  • Tenant isolation: all reads/writes scope by tenant_id unless caller is in master scope. Global hooks use a sentinel tenant id.
  • SSRF protection: HTTP handler validates URLs before request, pins resolved IP, blocks loopback/link-local/private ranges.
  • PII redaction: audit rows truncate error text to 256 chars; full error is encrypted (AES-256-GCM) in error_detail.
  • Fail-closed: any unhandled error in a blocking event yields block. Timeouts respect OnTimeout (default block for blocking events).
  • Circuit breaker: N consecutive blocks/timeouts in a rolling window auto-disables the hook and persists enabled=false.
  • Loop detection: sub-agent hook chains bounded at depth 3 (MaxLoopDepth).

Handler reference

command

{
  "handler_type": "command",
  "config": {
    "command": "bash /path/to/script.sh",
    "allowed_env_vars": ["MY_VAR"],
    "cwd": "/workspace"
  }
}
  • Stdin: JSON-encoded event payload.
  • Stdout (exit 0): optional {"continue": false} → block; anything else → allow.
  • Exit 2: block.
  • Other non-zero exits: error (fail-closed for blocking events).
  • Env allowlist: only listed keys are passed through to prevent secret leakage.

http

{
  "handler_type": "http",
  "config": {
    "url": "https://example.com/webhook",
    "headers": {"Authorization": "<AES-encrypted>"}
  }
}
  • Method: POST, body = event JSON.
  • Authorization header values are stored AES-256-GCM encrypted; decrypted at dispatch.
  • 1 MiB response cap.
  • Retries once on 5xx with 1s backoff; 4xx fail-closed (no retry).
  • Response body:
    { "decision": "allow" | "block", "additionalContext": "...", "updatedInput": {}, "continue": true }
    
  • Non-JSON 2xx → allow.

prompt

{
  "handler_type": "prompt",
  "matcher": "^(exec|shell|write_file)$",
  "config": {
    "prompt_template": "Evaluate safety of this tool call.",
    "model": "haiku",
    "max_invocations_per_turn": 5
  }
}

Required:

  • prompt_template — system-level instruction the evaluator receives.
  • matcher or if_expr — runaway-cost guard; prevents firing the LLM on every event.

Safeguards:

  • Structured output: evaluator MUST call a decide(decision, reason, injection_detected, updated_input) tool. Free-text responses fail-closed.
  • Sanitized input: only tool_input reaches the evaluator (sandwiched in a fenced USER INPUT block with anti-injection preamble); the raw user message is never included.
  • Decision cache: 60s TTL keyed by sha256(hook_id || version || tool_name || tool_input). Hook edits bump version → busts cache.
  • Per-turn cap: default 5 invocations per user turn; configurable.
  • Per-tenant monthly budget: atomic UPDATE deduct; warn at 20% remaining, block at 0.

Matchers

  • matcher — POSIX-ish regex applied to tool_name. Example: ^(exec|shell|write_file)$.
  • if_exprcel-go expression evaluated against {tool_name, tool_input, depth}. Example: tool_name == "exec" && size(tool_input.cmd) > 80.

Both are optional for command / http. At least one is required for prompt.

Safeguards summary

Safeguard Default Overridable per hook
Per-hook timeout 5s yes (timeout_ms, max 10s)
Chain budget 10s no (dispatcher constant)
Circuit threshold 5 blocks in 1 minute no (dispatcher constant)
Prompt per-turn cap 5 yes (max_invocations_per_turn)
Prompt decision cache TTL 60s no
Tenant monthly token budget 1,000,000 seeded per tenant

Web UI walkthrough

Navigate to Hooks in the sidebar. The list view shows all hooks visible under the current role + tenant scope.

  1. Create → pick event, handler type (command disabled on Standard), scope, matcher, then fill the handler-specific sub-form.
  2. Test panel → fires the hook with a sample event (dryRun=true, no audit row written). Shows decision badge, duration, stdout/stderr (command), status code (http), reason (prompt). If the response includes updatedInput, a side-by-side JSON diff is rendered.
  3. History tab → paginated executions from hook_executions (full implementation lands with operational pagination; current release shows the note state).
  4. Overview tab → summary card with event, type, scope, matcher.

Observability

Audit table (hook_executions)

Column Notes
hook_id ON DELETE SET NULL → executions preserved after hook deletion.
dedup_key Unique index prevents double rows on retry (H6).
error Truncated to 256 chars; full detail in error_detail (encrypted).
metadata JSONB: matcher_matched, cel_eval_result, stdout_len, http_status, prompt_model, prompt_tokens, trace_id.

Tracing spans

Every hook execution emits a span named hook.<handler_type>.<event> (e.g. hook.prompt.pre_tool_use) with:

  • statuscompleted or error.
  • duration_ms.
  • metadata.decisionallow/block/error/timeout.
  • parent_span_id — nested under the current pipeline span when one exists.

No-op when no tracing collector is attached to ctx (safe in tests and for tenants without tracing enabled).

Metrics (slog keys)

  • security.hook.circuit_breaker — tripped breaker.
  • security.hook.audit_write_failed — audit row write error.
  • security.hook.resolve_error — store resolve error (blocking event → fail-closed).
  • security.hook.loop_depth_exceededMaxLoopDepth violation.
  • security.hook.prompt_parse_error — evaluator returned malformed structured output.
  • security.hook.budget_deduct_failed / budget_precheck_failed — budget store error.

Troubleshooting

Symptom Likely cause Fix
HTTP hook always returns error SSRF block on loopback (test). Call security.SetAllowLoopbackForTest(true) in tests.
Prompt hook blocks everything Evaluator returning free-text (no tool call). Review prompt_template; keep it short + imperative.
Hook stopped firing Circuit breaker tripped (5 blocks/min). UPDATE agent_hooks SET enabled=true WHERE id=... after fixing upstream cause.
UI command radio greyed out Standard edition. Use HTTP or prompt, or upgrade to Lite.
Per-turn cap hit max_invocations_per_turn too low. Raise in hook config; review matcher tightness.
Budget exceeded Tenant spent monthly token budget. Raise tenant_hook_budget.budget_total or wait for rollover.

Migration notes

No data migration required when upgrading from a pre-hooks release. Migration 000052_agent_hooks creates agent_hooks, hook_executions, and tenant_hook_budget. SQLite users must also have schema version 20 or later.

Known limitations

  • Prompt decision cache is per-process (no cluster-wide Redis yet).
  • No skill-frontmatter hooks (planned post-MVP).
  • No agent handler type (inter-agent delegation via prompt instead).
  • Desktop Wails UI has list/detail parity but no dedicated mobile-optimized form.