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
This commit is contained in:
viettranx committed 2026-04-16 14:17:47 +07:00
1 parent f90f11a9f9
commit 95bdb23a36
10 files changed
+431 -1

No files matched your search

+62
View File
@@ -6,6 +6,68 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep
## [Unreleased] — 2026-04-15
#### Agent Hooks System — Phase 3: Prompt Handler + Web UI (2026-04-15)
Third handler type (`prompt`) with full cost safeguards, WS RPC surface, complete Web UI, 3-language i18n, and production wiring. Closes Issue #875 feature parity with Claude-Code-style hooks.
### Added
- **`internal/hooks/handlers/prompt.go`**: LLM hook handler with structured tool-call output (`decide` tool), anti-injection system preamble, fenced user-input delimiter, in-memory decision cache (60s TTL keyed by `sha256(hook_id||version||tool_name||tool_input)`), per-turn invocation cap (default 5), provider resolver abstraction
- **`internal/hooks/handlers/prompt_resolver.go`**: `RegistryResolver` maps model aliases (haiku/sonnet/opus, gpt-*, gemini-*, qwen-*) to tenant providers; falls back to system configs (`hooks.prompt.provider`, `background.provider`) then first registered
- **`internal/hooks/budget/` package**: `Store` + `Dialect` interface for atomic per-tenant monthly token budget deduction; `ShouldWarn` helper at 20% threshold
- **Budget store implementations**: `pg.PGHookBudget` (single UPDATE + RETURNING with UPSERT seed on month rollover) and `sqlitestore.SqliteHookBudget` (tx-wrapped equivalent)
- **WS RPC methods** (`internal/gateway/methods/hooks.go`): `hooks.list` (viewer+), `hooks.create`/`update`/`delete`/`toggle` (admin+, master-scope for global), `hooks.test` (operator+, dryRun no audit write), `hooks.history` (viewer+, stub pending phase 4 pagination)
- **Protocol constants**: `MethodHooksList/Create/Update/Delete/Toggle/Test/History` in `pkg/protocol/methods.go`
- **Web UI `/hooks` page**: single route with `useParams().id` — list view when no id, detail view when present (CLAUDE.md "route params as source of truth"). Tabs: Overview | Config | Test | History
- **Web UI components**: `hook-list-row`, `hook-form-dialog` (3 sub-forms per handler type; command radio disabled on Standard with tooltip), `hook-test-panel` (fires `dryRun=true`, decision badge + duration + stdout/stderr/status), `hook-diff-viewer` (side-by-side JSON diff via highlight.js), `hook-history-table`, `hook-overview-tab`
- **Zod + TanStack Query**: `schemas/hooks.schema.ts` (prompt requires matcher|if_expr + prompt_template), `hooks/use-hooks.ts` (useHooksList/Hook/CreateHook/UpdateHook/DeleteHook/ToggleHook/TestHook/HookHistory)
- **i18n backend**: 6 new keys (`hook.invalid_matcher`, `hook.command_disabled_standard`, `hook.prompt_requires_matcher`, `hook.circuit_breaker_tripped`, `hook.budget_exceeded`, `hook.per_turn_cap_reached`) in en/vi/zh catalogs
- **i18n UI**: `ui/web/src/i18n/locales/{en,vi,zh}/hooks.json` with identical key ordering (per CLAUDE.md memory rule)
- **Production wiring**: `cmd/gateway_managed.go` registers `HandlerPrompt` in dispatcher handlers map; `cmd/gateway.go` registers `HookMethods` in router
- **Sidebar nav**: "Hooks" entry with `Webhook` icon in Capabilities group
### Security / cost safeguards
- **Structured output enforcement**: evaluator MUST call the `decide` tool; free-text responses fail-closed to block
- **Sanitized input**: only `tool_input` reaches the evaluator inside a fenced `USER INPUT` block; raw user message never included
- **Injection detection flag**: evaluator can signal `injection_detected=true` via structured output
- **Version-busted cache**: hook config edits bump `version` → cache key changes → forces re-evaluation
- **Atomic budget deduct**: no select-then-update race (L2 mitigation)
### Testing
- **Unit**: `prompt_test.go` (14 tests — cache hit/miss, version bust, per-turn cap, fail-closed paths, schema assertions), `prompt_injection_test.go` (4 tests — hostile inputs, unicode homoglyphs, nested JSON, fenced delimiter integrity), `budget_test.go` (9 tests — atomic deduct, exceed-returns-err, month rollover, zero-cost, nil tenant, warn threshold)
- **WS RPC unit**: `gateway/methods/hooks_test.go` (param parsing + HookTestResult struct round-trip)
- **UI**: 56/56 vitest passing including 20 new hook-specific tests (schema validation, list rendering, test panel, diff viewer, i18n contracts)
#### Agent Hooks System — Phase 4: Hardening + Docs (2026-04-15)
Integration / chaos / RBAC / tracing coverage + user documentation + copy-paste example hooks library. Merge gate.
### Added
- **Integration tests** (`tests/integration/hooks_e2e_test.go`): 7 events × HTTP/command handler coverage, tenant isolation (cross-tenant events don't hit other tenants' hooks), context-update injection path, edition gate at dispatch time
- **Chaos tests** (`hooks_chaos_test.go`): provider down (fail-closed block on blocking event), per-hook timeout respected, circuit breaker auto-disables after 3 consecutive blocks and persists `enabled=false`, `ErrLoopDepthExceeded` when depth > `MaxLoopDepth` (M5), `dedup_key` unique index suppresses double audit rows on retry (H6), 5xx retry-once-then-error
- **RBAC tests** (`hooks_rbac_test.go`): store tenant isolation (List/Get/Update/Delete all respect tenant_id), global-scope visible to all tenants, ResolveForEvent unions tenant + global hooks, `HasMinRole` matrix over the hooks.* method surface
- **Tracing tests** (`hooks_tracing_test.go`): `EmitHookSpan` writes row with canonical name `hook.<handler_type>.<event>`; dispatcher + traced-handler wrapper emits spans end-to-end
- **User docs** `docs/agent-hooks.md`: concepts, security model, handler reference, matcher guide, safeguard table, Web UI walkthrough, observability (audit table + tracing spans + slog keys), troubleshooting, migration notes, known limitations
- **Example library** `examples/hooks/`: `block-rm-rf.json` (command, Lite), `auto-lint-after-write.json` (http async), `audit-tool-usage.json` (tenant-wide audit), `session-context-injector.json` (context bootstrap), `notify-discord-on-stop.json` (webhook notification), `README.md` with safety guidance
### Fixed
- **`PGHookStore.GetByID` + `SqliteHookStore.GetByID`** now enforce tenant scope: non-master callers only see own + global rows. Previously returned any row by UUID which leaked cross-tenant.
- **Existing pipeline integration tests** now call `security.SetAllowLoopbackForTest(true)` via shared helper; no longer flake on SSRF block for httptest endpoints.
### Testing
- All hook integration tests pass under `TEST_DATABASE_URL` PG container (7 new test files, 18 new top-level tests)
- `go build` + `go build -tags sqliteonly` + `go vet` clean
- `pnpm test` 56/56 pass
- Race detector clean for `internal/hooks/...` + `internal/gateway/methods/`
### Deferred (post-MVP)
- Cluster-wide prompt decision cache via Redis
- Skill-frontmatter hooks
- `agent` handler type (inter-agent delegation via prompt instead)
- Desktop Wails UI parity for hooks page
- Load/throughput benchmarks (explicitly dropped from Phase 4 scope)
---
#### Agent Hooks System — Phase 1: Foundation (2026-04-15)
Lifecycle hook infrastructure: event dispatcher (sync/async paths), audit logging, database schema (PostgreSQL + SQLite), store interface, config validation, edition gating. Handlers + pipeline integration deferred to Phase 2.
+193
View File
@@ -0,0 +1,193 @@
# 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
```json
{
"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
```json
{
"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:
```json
{ "decision": "allow" | "block", "additionalContext": "...", "updatedInput": {}, "continue": true }
```
- Non-JSON 2xx → allow.
### prompt
```json
{
"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_expr** — [cel-go](https://github.com/google/cel-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:
- `status` — `completed` or `error`.
- `duration_ms`.
- `metadata.decision` — `allow`/`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_exceeded` — `MaxLoopDepth` 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.