mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 22:15:47 +00:00
feat(hooks): introduce agent lifecycle hooks foundation with fail-closed blocking semantics
Phase 1 of agent-hooks-system establishes: - Hook types, matchers, and CEL evaluation engine for event-driven extensibility - Edition-gated command handlers (Lite disabled) with dedup_key-indexed audit log - Dual-DB store layer (PostgreSQL + SQLite) with transaction boundary enforcement - Blocking-event semantics: hook execution failures propagate to agent loop, graceful retry - Tenant-isolated audit trail with per-hook execution context tracing - Integration tests verifying store correctness and hook dispatch atomicity Lays groundwork for post-v3.0 phase-02 (per-tenant onboarding hooks).
This commit is contained in:
1 parent
86d564967f
commit
3fe5ae0b50
34 files changed
+4866
-27
No files matched your search
@@ -38,6 +38,7 @@ flowchart TD
|
||||
SCHED[Scheduler -- 4 Lanes]
|
||||
AR[Agent Router]
|
||||
LOOP[Agent Loop -- Think / Act / Observe]
|
||||
HOOKS[Hook Dispatcher -- Lifecycle Events]
|
||||
end
|
||||
|
||||
subgraph Providers["LLM Providers"]
|
||||
@@ -143,6 +144,7 @@ flowchart TD
|
||||
| `internal/workspace/` | Workspace context resolver: 6 scenarios (agent default, team lead, team member, dispatch, subagent, cron) |
|
||||
| `internal/vault/` | Knowledge Vault: wikilinks (semantic mesh), hybrid search (BM25+vector), filesystem sync, L0 auto-injection |
|
||||
| `internal/channels/whatsapp/` | Native WhatsApp channel via whatsmeow (replaces WhatsApp API), QR auth, media handling |
|
||||
| `internal/hooks/` | Agent lifecycle hooks: event dispatcher (sync/async), handlers (command/http/prompt), matchers (regex + CEL), audit logging, edition gating, cost safeguards |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ The `Stores` struct is the top-level container holding all PostgreSQL-backed sto
|
||||
| SnapshotStore | `PGSnapshotStore` | Hourly usage snapshots, cost aggregation, time series queries |
|
||||
| SecureCLIStore | `PGSecureCLIStore` | CLI binary configs with encrypted credential injection |
|
||||
| APIKeyStore | `PGAPIKeyStore` | Gateway API keys, scopes, expiration, revocation |
|
||||
| HookStore | `PGHookStore` | Lifecycle hook definitions (event, handler type, matcher, config), execution audit log |
|
||||
|
||||
### SQLite Parity (Lite Edition)
|
||||
|
||||
@@ -63,6 +64,7 @@ The `Stores` struct is the top-level container holding all PostgreSQL-backed sto
|
||||
| AgentLinksStore | `SQLiteAgentLinks` | LIKE search, no vector |
|
||||
| SubagentTasksStore | `SQLiteSubagentTasks` | ✓ Parity (json_set for metadata merge) |
|
||||
| SecureCLIStore | `SQLiteSecureCLIStore` | ✓ Parity + AES-256-GCM encryption mandatory (GOCLAW_KEY env var required) |
|
||||
| HookStore | `SQLiteHookStore` | ✓ Parity (agent_hooks + hook_executions tables, same schema as PG) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+34
-24
@@ -6,37 +6,47 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep
|
||||
|
||||
## [Unreleased] — 2026-04-15
|
||||
|
||||
#### ElevenLabs Audio Manager Refactor — Phase 6 (2026-04-15)
|
||||
#### Agent Hooks System — Phase 1: Foundation (2026-04-15)
|
||||
|
||||
Desktop UI parity: voice picker + STT admin form ported from web to Wails desktop frontend. Completes 6-phase plan; all audio surfaces (TTS/STT/Music/SFX) now manageable from both web and desktop Lite editions.
|
||||
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.
|
||||
|
||||
### Added
|
||||
- **Desktop voice picker**: `ui/desktop/frontend/src/components/agents/voice-picker.tsx` — uses desktop's native Combobox primitive; embedded in `AgentDetailPanel` with `other_config.tts_voice_id` persistence via spread-merge pattern
|
||||
- **Desktop voice preview button**: `ui/desktop/frontend/src/components/agents/voice-preview-button.tsx` — singleton `<audio>` (module-level `currentAudio` ref); hidden when `preview_url` absent (resolves P6-L)
|
||||
- **Desktop STT admin form**: `ui/desktop/frontend/src/components/builtin-tools/stt-provider-form.tsx` — mirrors web fields + `whatsapp_enabled` toggle + privacy banner; routed via `ToolSettingsDialog` when `tool.name === 'stt'`
|
||||
- **Desktop voices service + hook**: `services/voices.ts` (`listVoices`, `refreshVoices`) + `hooks/use-voices.ts` (plain useState/useEffect — no React Query on desktop; mirrors web hook shape)
|
||||
- **Desktop Vitest infrastructure**: `vitest.config.ts` + `test/setup.ts` (jsdom + jest-dom + HTMLMediaElement mock) — resolves P6-H3 (desktop tests previously impossible)
|
||||
- **Desktop i18n `tts` namespace**: `i18n/locales/{en,vi,zh}/tts.json` (15 → 16 total namespaces registered in `i18n/index.ts`) + `tools.json` extended with `builtin.sttForm.*` keys
|
||||
- **Desktop component tests**: voice picker (4 tests) + STT form (3 tests) — parity subset covering render, save, preview, privacy banner
|
||||
- **`internal/hooks/` package**: Foundational types (HookConfig, HookExecution, Event), StdDispatcher (fire-and-decide with sequential execution), CEL+regex matcher, audit writer (PII-redacted, encrypted), circuit breaker with auto-disable
|
||||
- **PostgreSQL migration 000052**: `agent_hooks` table (id, tenant_id, agent_id, event, handler_type, config, matcher, if_expr, priority, enabled, created_at, updated_at) + `hook_executions` table (hook_id, session_id, event, input_hash, decision, duration_ms, error, created_at) with unique dedup_key index
|
||||
- **SQLite schema entries**: Full schema in `schema.sql` + migration map bump for Lite/desktop edition
|
||||
- **HookStore interface + implementations**: `PGHookStore` (database/sql + pgx/v5) and `SQLiteHookStore` (modernc.org/sqlite) with List, Create, Update, Delete, LogExecution methods
|
||||
- **Security primitives**: Loop-depth guard (M5 mitigation, MaxLoopDepth=3), per-hook timeout, chain budget (10s wall-time), circuit breaker with auto-disable (C4 mitigation), fail-closed policy for blocking events
|
||||
- **Edition gating**: `HookEditionPolicy` allows command handler on Lite only; master-scope blocks command on Standard tenant
|
||||
- **Config validation**: CEL cost limits, required matcher for prompt handlers, bounded queue per-turn limits
|
||||
|
||||
### Changed
|
||||
- **Desktop `AgentDetailPanel`**: Added `ttsVoiceId` local state + `onSaveWithVoice` wrapper that preserves existing `other_config` fields via spread-merge before overwriting `tts_voice_id`
|
||||
- **Desktop `ToolSettingsDialog`**: Added `tool.name === 'stt'` routing branch before JsonSettingsForm fallback; existing `web_fetch` + MEDIA_TOOLS routing unchanged
|
||||
- **Desktop `package.json`**: Added devDeps (`vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom`, `@vitejs/plugin-react`) + `test` / `test:watch` npm scripts
|
||||
- **Store layer**: Added `store.Hooks` field (typed as `any` to avoid import cycles)
|
||||
- **Base store**: `TablesWithUpdatedAt["agent_hooks"] = true` for touch-all update helpers
|
||||
|
||||
### Resolved
|
||||
- **Audit finding P6-B1**: Gate G5 verified — Phase 5 merged green before Phase 6 work began
|
||||
- **Audit finding P6-B2**: Desktop path rewrite confirmed — no `pages/` dir used; component composition via `AgentDetailPanel` + `ToolSettingsDialog`
|
||||
- **Audit finding P6-B3**: `tts.json` namespace created + registered in desktop `i18n/index.ts` (web-desktop i18n parity)
|
||||
- **Audit finding P6-H1**: `tools.json` extended with STT labels; `tts.json` created per Decision 4
|
||||
- **Audit finding P6-H2**: SttProviderForm integration point identified (`ToolSettingsDialog.tsx` name-routing) + wired
|
||||
- **Audit finding P6-H3**: Vitest installed + configured in desktop frontend (was previously absent)
|
||||
- **Audit finding P6-L**: Voice preview button hidden (not disabled) when `preview_url` null/empty
|
||||
- **Audit finding P6-M** (codec): MP3 + Opus codec smoke test remains on manual verification checklist (user to confirm on macOS WKWebView + Windows WebView2)
|
||||
- **Cross-phase XP-5**: Desktop paths confirmed — `components/agents/`, `components/builtin-tools/`, `services/`, `hooks/` (no `pages/`)
|
||||
### Testing
|
||||
- **Integration test suite**: `tests/integration/v3_hooks_store_test.go` covers multi-tenant isolation, dedup key uniqueness, schema migration up/down for both PG + SQLite
|
||||
|
||||
### Deferred
|
||||
- **Manual smoke test**: `wails dev -tags sqliteonly` voice picker save + STT form submit + codec playback requires user verification (cannot run headless in automation)
|
||||
### Deferred to Phase 2+
|
||||
- Handler implementations (command/http/prompt)
|
||||
- Pipeline wiring (ContextStage, ToolStage, FinalizeStage, delegate integration)
|
||||
- Web UI (CRUD, test panel, history)
|
||||
- Tracing integration
|
||||
- i18n keys (en/vi/zh)
|
||||
|
||||
---
|
||||
|
||||
#### Desktop Voice Management + STT Admin (2026-04-15)
|
||||
|
||||
### Added
|
||||
- **Desktop voice picker**: Agent detail panel now includes a voice selector with live preview. Users can browse ElevenLabs voices, hear a sample, and persist the choice per agent on the desktop app.
|
||||
- **Desktop STT admin form**: Speech-to-text provider configuration (API keys, providers, WhatsApp opt-in toggle + privacy banner) available in desktop tool settings.
|
||||
- **Singleton voice preview**: Only one voice sample plays at a time; preview button hides automatically when a voice has no sample URL.
|
||||
- **Desktop i18n `tts` namespace**: Voice-management strings translated to English, Vietnamese, and Chinese; STT form labels added to the tools namespace.
|
||||
- **Desktop test harness**: Vitest + Testing Library infrastructure; voice picker and STT form covered by unit tests.
|
||||
|
||||
### Changed
|
||||
- **Agent voice persistence (desktop)**: Voice selection merges into `other_config` without overwriting sibling fields.
|
||||
- **Tool settings routing (desktop)**: Speech-to-text tool opens the dedicated admin form instead of the generic JSON editor.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ require (
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/go-rod/rod v0.116.2
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/cel-go v0.28.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||
github.com/jackc/pgx/v5 v5.6.0
|
||||
@@ -45,9 +46,11 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/akutz/memconn v0.1.0 // indirect
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
|
||||
@@ -203,5 +206,5 @@ require (
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
|
||||
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
|
||||
@@ -28,6 +30,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
@@ -230,6 +234,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc=
|
||||
github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I=
|
||||
@@ -553,6 +559,7 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"sort"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
||||
)
|
||||
|
||||
// MaxErrorLen caps the visible `error` column in hook_executions (M2 mitigation).
|
||||
// The full payload is preserved — encrypted — in `error_detail`.
|
||||
const MaxErrorLen = 256
|
||||
|
||||
const redactedMarker = "[REDACTED]"
|
||||
|
||||
// piiPatterns covers the three shapes the audit path needs to strip before
|
||||
// the error message is persisted in plaintext. Scoped locally rather than
|
||||
// reusing tools.ScrubCredentials to keep the audit writer dependency-free.
|
||||
var piiPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`[\w.+-]+@[\w-]+\.[\w.-]+`),
|
||||
regexp.MustCompile(`(?i)Bearer\s+[A-Za-z0-9._\-]{8,}`),
|
||||
regexp.MustCompile(`sk-[A-Za-z0-9_\-]{8,}`),
|
||||
}
|
||||
|
||||
// TruncateError shortens s to at most MaxErrorLen bytes, but never cuts a
|
||||
// multi-byte rune mid-sequence. Return is guaranteed to be valid UTF-8.
|
||||
func TruncateError(s string) string {
|
||||
if len(s) <= MaxErrorLen {
|
||||
return s
|
||||
}
|
||||
cut := MaxErrorLen
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut]
|
||||
}
|
||||
|
||||
// RedactPII replaces emails, Bearer tokens, and sk-* API keys with a fixed
|
||||
// marker. The replacement is idempotent: the marker itself contains no PII
|
||||
// patterns, so RedactPII(RedactPII(x)) == RedactPII(x).
|
||||
func RedactPII(s string) string {
|
||||
for _, p := range piiPatterns {
|
||||
s = p.ReplaceAllString(s, redactedMarker)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// AuditWriter wraps a HookStore and enforces the audit-hygiene rules for
|
||||
// hook_executions writes: truncate+redact the visible error, encrypt the
|
||||
// full error_detail before it hits the DB.
|
||||
type AuditWriter struct {
|
||||
store HookStore
|
||||
encKey string
|
||||
}
|
||||
|
||||
// NewAuditWriter binds a HookStore to an AES-256-GCM key. When encKey is
|
||||
// empty the writer runs in degraded mode (Lite/dev) and passes error_detail
|
||||
// through as plaintext — operators trade PII exposure for post-mortem
|
||||
// debuggability.
|
||||
func NewAuditWriter(store HookStore, encKey string) *AuditWriter {
|
||||
return &AuditWriter{store: store, encKey: encKey}
|
||||
}
|
||||
|
||||
// Log applies PII scrubbing + error truncation + error_detail encryption,
|
||||
// then delegates the write to the underlying store.
|
||||
func (w *AuditWriter) Log(ctx context.Context, exec HookExecution) error {
|
||||
if exec.Error != "" {
|
||||
exec.Error = TruncateError(RedactPII(exec.Error))
|
||||
}
|
||||
if len(exec.ErrorDetail) > 0 && w.encKey != "" {
|
||||
ct, err := crypto.Encrypt(string(exec.ErrorDetail), w.encKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exec.ErrorDetail = []byte(ct)
|
||||
}
|
||||
return w.store.WriteExecution(ctx, exec)
|
||||
}
|
||||
|
||||
// CanonicalInputHash returns sha256 of (tool_name + canonical JSON of args)
|
||||
// as a 64-char hex string. Map keys are sorted at every nesting level so the
|
||||
// hash is stable across Go map iteration orders — this feeds hook_executions.input_hash
|
||||
// and the dedup key upstream.
|
||||
func CanonicalInputHash(toolName string, args map[string]any) (string, error) {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(toolName))
|
||||
h.Write([]byte{0}) // delimiter: avoids "Read"+"{...}" colliding with "Rea"+"d{...}"
|
||||
if args == nil {
|
||||
// Hash the literal null so "no args" is distinct from "{}".
|
||||
h.Write([]byte("null"))
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
if err := writeCanonicalJSON(h, args); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// writeCanonicalJSON emits v with object keys sorted recursively. Only the
|
||||
// shapes CEL/tool_input can produce are handled; anything else falls through
|
||||
// to encoding/json's default ordering.
|
||||
func writeCanonicalJSON(w interface{ Write([]byte) (int, error) }, v any) error {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(t))
|
||||
for k := range t {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if _, err := w.Write([]byte{'{'}); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
if _, err := w.Write([]byte{','}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
kb, err := json.Marshal(k)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(kb); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write([]byte{':'}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeCanonicalJSON(w, t[k]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := w.Write([]byte{'}'})
|
||||
return err
|
||||
case []any:
|
||||
if _, err := w.Write([]byte{'['}); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, item := range t {
|
||||
if i > 0 {
|
||||
if _, err := w.Write([]byte{','}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writeCanonicalJSON(w, item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := w.Write([]byte{']'})
|
||||
return err
|
||||
default:
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(b)
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package hooks_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
)
|
||||
|
||||
// fakeExecStore captures HookExecution rows passed to WriteExecution.
|
||||
// Other HookStore methods panic if called — tests must not rely on them.
|
||||
type fakeExecStore struct {
|
||||
hooks.HookStore // embed nil interface; only WriteExecution is overridden
|
||||
mu sync.Mutex
|
||||
execs []hooks.HookExecution
|
||||
}
|
||||
|
||||
func (s *fakeExecStore) WriteExecution(_ context.Context, e hooks.HookExecution) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.execs = append(s.execs, e)
|
||||
return nil
|
||||
}
|
||||
|
||||
// testEncKey is 64 hex chars (32 raw bytes) — valid for crypto.DeriveKey.
|
||||
const testEncKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
func TestTruncateError_NoChangeUnderLimit(t *testing.T) {
|
||||
got := hooks.TruncateError("short message")
|
||||
if got != "short message" {
|
||||
t.Errorf("TruncateError returned %q, want no change", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateError_CutsAtMaxLen(t *testing.T) {
|
||||
long := strings.Repeat("a", hooks.MaxErrorLen+200)
|
||||
got := hooks.TruncateError(long)
|
||||
if len(got) != hooks.MaxErrorLen {
|
||||
t.Errorf("len=%d, want %d", len(got), hooks.MaxErrorLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateError_TreatsUnicodeByRune(t *testing.T) {
|
||||
// Each emoji is multi-byte; must not corrupt mid-codepoint.
|
||||
long := strings.Repeat("🔒", hooks.MaxErrorLen+10)
|
||||
got := hooks.TruncateError(long)
|
||||
// Result must decode as valid UTF-8 (no mid-codepoint cut).
|
||||
if !isValidUTF8(got) {
|
||||
t.Errorf("TruncateError produced invalid UTF-8 (mid-codepoint cut)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactPII_MasksEmail(t *testing.T) {
|
||||
out := hooks.RedactPII("contact user@example.com for details")
|
||||
if strings.Contains(out, "user@example.com") {
|
||||
t.Errorf("email not redacted: %q", out)
|
||||
}
|
||||
if !strings.Contains(strings.ToUpper(out), "REDACTED") {
|
||||
t.Errorf("redaction marker missing: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactPII_MasksBearerTokens(t *testing.T) {
|
||||
// Include two common shapes: Bearer <jwt> and sk-… API keys.
|
||||
raw := "auth: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig key=sk-abc123def456GHIJ7890xyzmore"
|
||||
out := hooks.RedactPII(raw)
|
||||
if strings.Contains(out, "eyJhbGciOiJIUzI1NiJ9.payload.sig") {
|
||||
t.Errorf("JWT not redacted: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "sk-abc123def456GHIJ7890xyzmore") {
|
||||
t.Errorf("api key not redacted: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactPII_IsIdempotent(t *testing.T) {
|
||||
once := hooks.RedactPII("error for user@example.com")
|
||||
twice := hooks.RedactPII(once)
|
||||
if once != twice {
|
||||
t.Errorf("RedactPII not idempotent: once=%q twice=%q", once, twice)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteExecution must truncate + redact the visible error column and encrypt
|
||||
// the full error_detail before delegating to the underlying store.
|
||||
func TestAuditWriter_WriteExecution_TruncatesAndEncrypts(t *testing.T) {
|
||||
fs := &fakeExecStore{}
|
||||
w := hooks.NewAuditWriter(fs, testEncKey)
|
||||
|
||||
longError := strings.Repeat("x", hooks.MaxErrorLen+50) + " user@example.com"
|
||||
full := "stack trace:\nuser=user@example.com\ntoken=sk-abc123"
|
||||
err := w.Log(context.Background(), hooks.HookExecution{
|
||||
ID: uuid.New(),
|
||||
Event: hooks.EventPreToolUse,
|
||||
Decision: hooks.DecisionBlock,
|
||||
Error: longError,
|
||||
ErrorDetail: []byte(full),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Log: %v", err)
|
||||
}
|
||||
if len(fs.execs) != 1 {
|
||||
t.Fatalf("expected 1 row written, got %d", len(fs.execs))
|
||||
}
|
||||
row := fs.execs[0]
|
||||
|
||||
// error col: truncated AND redacted.
|
||||
if len(row.Error) > hooks.MaxErrorLen {
|
||||
t.Errorf("error len %d > max %d", len(row.Error), hooks.MaxErrorLen)
|
||||
}
|
||||
if strings.Contains(row.Error, "user@example.com") {
|
||||
t.Errorf("error col still contains raw email: %q", row.Error)
|
||||
}
|
||||
|
||||
// error_detail: must be AES-GCM encrypted (not plaintext).
|
||||
if !crypto.IsEncrypted(string(row.ErrorDetail)) {
|
||||
t.Errorf("error_detail not encrypted: %s", string(row.ErrorDetail))
|
||||
}
|
||||
// Round-trip decrypt returns the original plaintext.
|
||||
got, err := crypto.Decrypt(string(row.ErrorDetail), testEncKey)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
if got != full {
|
||||
t.Errorf("decrypted = %q, want %q", got, full)
|
||||
}
|
||||
}
|
||||
|
||||
// When encKey is empty, the writer skips encryption (degraded mode for Lite/dev).
|
||||
// Plaintext passes through so operators can still debug crashes, but they must
|
||||
// be aware PII will land in the audit log.
|
||||
func TestAuditWriter_EmptyKey_PassesThroughPlaintext(t *testing.T) {
|
||||
fs := &fakeExecStore{}
|
||||
w := hooks.NewAuditWriter(fs, "")
|
||||
detail := "plain detail"
|
||||
if err := w.Log(context.Background(), hooks.HookExecution{
|
||||
ID: uuid.New(),
|
||||
Event: hooks.EventPreToolUse,
|
||||
Decision: hooks.DecisionAllow,
|
||||
ErrorDetail: []byte(detail),
|
||||
}); err != nil {
|
||||
t.Fatalf("Log: %v", err)
|
||||
}
|
||||
if string(fs.execs[0].ErrorDetail) != detail {
|
||||
t.Errorf("empty-key path should pass through; got %q", fs.execs[0].ErrorDetail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalInputHash_Deterministic(t *testing.T) {
|
||||
// Map insertion order must not affect hash.
|
||||
a := map[string]any{"path": "/etc/passwd", "mode": "r"}
|
||||
b := map[string]any{"mode": "r", "path": "/etc/passwd"}
|
||||
h1, err := hooks.CanonicalInputHash("Read", a)
|
||||
if err != nil {
|
||||
t.Fatalf("hash a: %v", err)
|
||||
}
|
||||
h2, err := hooks.CanonicalInputHash("Read", b)
|
||||
if err != nil {
|
||||
t.Fatalf("hash b: %v", err)
|
||||
}
|
||||
if h1 != h2 {
|
||||
t.Errorf("hash differs across key orderings: %s vs %s", h1, h2)
|
||||
}
|
||||
if len(h1) != sha256.Size*2 {
|
||||
t.Errorf("hash len=%d, want %d hex chars", len(h1), sha256.Size*2)
|
||||
}
|
||||
// Hex format sanity.
|
||||
if _, err := hex.DecodeString(h1); err != nil {
|
||||
t.Errorf("hash is not hex: %q", h1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalInputHash_DifferentInputsDiffer(t *testing.T) {
|
||||
h1, _ := hooks.CanonicalInputHash("Read", map[string]any{"path": "/a"})
|
||||
h2, _ := hooks.CanonicalInputHash("Read", map[string]any{"path": "/b"})
|
||||
h3, _ := hooks.CanonicalInputHash("Write", map[string]any{"path": "/a"})
|
||||
if h1 == h2 {
|
||||
t.Error("hash collision across distinct paths")
|
||||
}
|
||||
if h1 == h3 {
|
||||
t.Error("hash collision across distinct tool names")
|
||||
}
|
||||
}
|
||||
|
||||
// isValidUTF8 wraps the stdlib check; kept as a named helper so intent reads
|
||||
// clearly at the call site inside the truncation test.
|
||||
func isValidUTF8(s string) bool {
|
||||
return utf8.ValidString(s)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
||||
)
|
||||
|
||||
// Defaults and limits for HookConfig fields. Exported so the UI layer can
|
||||
// surface the same values that Validate() enforces.
|
||||
const (
|
||||
// DefaultTimeoutMS applies when TimeoutMS is zero. Chosen at 5s to match
|
||||
// the blocking-sync budget in the plan.
|
||||
DefaultTimeoutMS = 5000
|
||||
// MaxTimeoutMS caps the per-hook timeout. The 10s ceiling matches the
|
||||
// chain wall-time budget; setting above this would let one hook starve
|
||||
// the whole chain.
|
||||
MaxTimeoutMS = 10_000
|
||||
// DefaultSource is the fallback when Source is empty (M1: distinguishes
|
||||
// UI-created hooks from agent-seeded ones).
|
||||
DefaultSource = "ui"
|
||||
)
|
||||
|
||||
// knownEvents enumerates every HookEvent value that Validate accepts.
|
||||
// Extending the hook system requires adding the new event both here and in
|
||||
// types.go's const block.
|
||||
var knownEvents = map[HookEvent]struct{}{
|
||||
EventSessionStart: {},
|
||||
EventUserPromptSubmit: {},
|
||||
EventPreToolUse: {},
|
||||
EventPostToolUse: {},
|
||||
EventStop: {},
|
||||
EventSubagentStart: {},
|
||||
EventSubagentStop: {},
|
||||
}
|
||||
|
||||
// Validate checks a HookConfig for semantic correctness and fills in defaults.
|
||||
// It MUST be called before Create/Update so that malformed configs never reach
|
||||
// the DB or the dispatcher. Side effects (on success): TimeoutMS, OnTimeout,
|
||||
// Version, and Source may be populated with their defaults.
|
||||
//
|
||||
// Validation order matters — cheap checks run first so we fail fast:
|
||||
// 1. Event enum — zero-cost map lookup.
|
||||
// 2. Scope / tenant / agent invariants — no I/O.
|
||||
// 3. Edition gate — pure-function policy.
|
||||
// 4. Matcher + CEL compile — most expensive; done last.
|
||||
func (h *HookConfig) Validate(ed edition.Edition) error {
|
||||
if _, ok := knownEvents[h.Event]; !ok {
|
||||
return fmt.Errorf("hook: unknown event %q", h.Event)
|
||||
}
|
||||
|
||||
if err := h.validateScope(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.validateHandler(ed); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.validateTimeout(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.applyDefaults()
|
||||
|
||||
// Matcher + CEL go last: parsing is the most expensive step and either
|
||||
// value is optional (nil pattern / empty expr match all).
|
||||
if _, err := CompileMatcher(h.Matcher); err != nil {
|
||||
return fmt.Errorf("hook: matcher invalid: %w", err)
|
||||
}
|
||||
if _, err := CompileCELExpr(h.IfExpr); err != nil {
|
||||
return fmt.Errorf("hook: if_expr invalid: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateScope enforces (scope, tenant_id, agent_id) invariants:
|
||||
// - global → tenant_id MUST be the sentinel UUID.
|
||||
// - tenant → tenant_id MUST be non-sentinel.
|
||||
// - agent → tenant_id MUST be non-sentinel AND agent_id non-nil.
|
||||
func (h *HookConfig) validateScope() error {
|
||||
switch h.Scope {
|
||||
case ScopeGlobal:
|
||||
if h.TenantID != SentinelTenantID {
|
||||
return fmt.Errorf("hook: global scope requires sentinel tenant_id, got %s", h.TenantID)
|
||||
}
|
||||
case ScopeTenant:
|
||||
if h.TenantID == SentinelTenantID || h.TenantID == uuid.Nil {
|
||||
return fmt.Errorf("hook: tenant scope requires a real tenant_id")
|
||||
}
|
||||
case ScopeAgent:
|
||||
if h.TenantID == SentinelTenantID || h.TenantID == uuid.Nil {
|
||||
return fmt.Errorf("hook: agent scope requires a real tenant_id")
|
||||
}
|
||||
if h.AgentID == nil || *h.AgentID == uuid.Nil {
|
||||
return fmt.Errorf("hook: agent scope requires agent_id")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("hook: unknown scope %q", h.Scope)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateHandler enforces per-handler rules + edition policy.
|
||||
// Command on Standard (any non-global scope) is blocked. Prompt requires at
|
||||
// least one filter (matcher OR if_expr) so we never fire an LLM per event.
|
||||
func (h *HookConfig) validateHandler(ed edition.Edition) error {
|
||||
// Edition gate — applies the same rule as the dispatcher fire-time gate.
|
||||
if ok, reason := (HookEditionPolicy{}).Allow(h.HandlerType, h.Scope, ed); !ok {
|
||||
return fmt.Errorf("hook: %s handler not allowed: %s", h.HandlerType, reason)
|
||||
}
|
||||
|
||||
switch h.HandlerType {
|
||||
case HandlerPrompt:
|
||||
if h.Matcher == "" && h.IfExpr == "" {
|
||||
return fmt.Errorf("hook: prompt handler requires a matcher or if_expr (runaway-cost guard)")
|
||||
}
|
||||
case HandlerCommand, HandlerHTTP:
|
||||
// No extra filter required — matcher/if_expr are optional.
|
||||
default:
|
||||
return fmt.Errorf("hook: unknown handler_type %q", h.HandlerType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTimeout bounds the per-hook timeout within [0, MaxTimeoutMS].
|
||||
// Zero is normalized to DefaultTimeoutMS in applyDefaults.
|
||||
func (h *HookConfig) validateTimeout() error {
|
||||
if h.TimeoutMS < 0 {
|
||||
return fmt.Errorf("hook: timeout_ms must be >= 0, got %d", h.TimeoutMS)
|
||||
}
|
||||
if h.TimeoutMS > MaxTimeoutMS {
|
||||
return fmt.Errorf("hook: timeout_ms %d exceeds max %d", h.TimeoutMS, MaxTimeoutMS)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyDefaults fills optional fields with their plan-specified defaults.
|
||||
// Called after validation so we never paper over a real error.
|
||||
func (h *HookConfig) applyDefaults() {
|
||||
if h.TimeoutMS == 0 {
|
||||
h.TimeoutMS = DefaultTimeoutMS
|
||||
}
|
||||
if h.OnTimeout == "" {
|
||||
if h.Event.IsBlocking() {
|
||||
h.OnTimeout = DecisionBlock
|
||||
} else {
|
||||
h.OnTimeout = DecisionAllow
|
||||
}
|
||||
}
|
||||
if h.Version == 0 {
|
||||
h.Version = 1
|
||||
}
|
||||
if h.Source == "" {
|
||||
h.Source = DefaultSource
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package hooks_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
)
|
||||
|
||||
// baseValidCommandHook returns a HookConfig that passes Validate on the Lite
|
||||
// edition. Tests override fields to trigger specific failure modes.
|
||||
func baseValidCommandHook() hooks.HookConfig {
|
||||
return hooks.HookConfig{
|
||||
ID: uuid.New(),
|
||||
TenantID: hooks.SentinelTenantID,
|
||||
Event: hooks.EventPreToolUse,
|
||||
HandlerType: hooks.HandlerCommand,
|
||||
Scope: hooks.ScopeGlobal,
|
||||
Config: map[string]any{"command": "/bin/true"},
|
||||
Matcher: "^Write$",
|
||||
IfExpr: `tool_name == "Write"`,
|
||||
TimeoutMS: 5000,
|
||||
OnTimeout: hooks.DecisionBlock,
|
||||
Priority: 100,
|
||||
Enabled: true,
|
||||
Version: 1,
|
||||
Source: "ui",
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_AcceptsValidCommandHook(t *testing.T) {
|
||||
h := baseValidCommandHook()
|
||||
if err := h.Validate(edition.Lite); err != nil {
|
||||
t.Fatalf("expected nil error; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_AcceptsValidHTTPHook(t *testing.T) {
|
||||
h := baseValidCommandHook()
|
||||
h.HandlerType = hooks.HandlerHTTP
|
||||
h.Config = map[string]any{"url": "https://example.com/hook"}
|
||||
h.Matcher = "" // http hook without matcher is allowed (matches all tools)
|
||||
h.IfExpr = ""
|
||||
if err := h.Validate(edition.Standard); err != nil {
|
||||
t.Fatalf("expected nil error; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_AcceptsValidPromptHook(t *testing.T) {
|
||||
h := baseValidCommandHook()
|
||||
h.HandlerType = hooks.HandlerPrompt
|
||||
h.Config = map[string]any{"template": "Review this action"}
|
||||
h.Matcher = "^Write$"
|
||||
h.IfExpr = ""
|
||||
if err := h.Validate(edition.Standard); err != nil {
|
||||
t.Fatalf("expected nil error; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt handler requires at least ONE filter (matcher OR if_expr) to avoid
|
||||
// firing the LLM on every event — runaway-cost mitigation (L-risk in plan).
|
||||
func TestValidate_RejectsPromptWithoutFilter(t *testing.T) {
|
||||
h := baseValidCommandHook()
|
||||
h.HandlerType = hooks.HandlerPrompt
|
||||
h.Config = map[string]any{"template": "x"}
|
||||
h.Matcher = ""
|
||||
h.IfExpr = ""
|
||||
err := h.Validate(edition.Standard)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for prompt without matcher/if_expr")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "prompt") || !strings.Contains(strings.ToLower(err.Error()), "matcher") {
|
||||
t.Errorf("error should mention prompt+matcher; got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RejectsInvalidRegex(t *testing.T) {
|
||||
h := baseValidCommandHook()
|
||||
h.Matcher = "[unclosed"
|
||||
err := h.Validate(edition.Lite)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed regex")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "matcher") {
|
||||
t.Errorf("error should mention matcher; got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RejectsMalformedCEL(t *testing.T) {
|
||||
h := baseValidCommandHook()
|
||||
h.IfExpr = `tool_name ===== "bad"` // CEL syntax error
|
||||
err := h.Validate(edition.Lite)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed CEL")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "if_expr") && !strings.Contains(strings.ToLower(err.Error()), "cel") {
|
||||
t.Errorf("error should mention if_expr/cel; got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Command on Standard + tenant scope is blocked via edition gate. Plan C2 drop:
|
||||
// command handler is Lite-only (multi-tenant host can't run arbitrary shell).
|
||||
func TestValidate_RejectsCommandOnStandardTenant(t *testing.T) {
|
||||
h := baseValidCommandHook()
|
||||
h.Scope = hooks.ScopeTenant
|
||||
h.TenantID = uuid.New()
|
||||
err := h.Validate(edition.Standard)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for command on Standard+tenant")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "command") {
|
||||
t.Errorf("error should mention command; got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RejectsCommandOnStandardAgent(t *testing.T) {
|
||||
h := baseValidCommandHook()
|
||||
h.Scope = hooks.ScopeAgent
|
||||
h.TenantID = uuid.New()
|
||||
agentID := uuid.New()
|
||||
h.AgentID = &agentID
|
||||
err := h.Validate(edition.Standard)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for command on Standard+agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_AcceptsCommandOnLiteAnyScope(t *testing.T) {
|
||||
for _, scope := range []hooks.Scope{hooks.ScopeGlobal, hooks.ScopeTenant, hooks.ScopeAgent} {
|
||||
h := baseValidCommandHook()
|
||||
h.Scope = scope
|
||||
if scope != hooks.ScopeGlobal {
|
||||
h.TenantID = uuid.New()
|
||||
}
|
||||
if scope == hooks.ScopeAgent {
|
||||
agentID := uuid.New()
|
||||
h.AgentID = &agentID
|
||||
}
|
||||
if err := h.Validate(edition.Lite); err != nil {
|
||||
t.Errorf("Lite+command+%s should be valid; got %v", scope, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TimeoutMS = 0 is replaced with a sane default so callers don't accidentally
|
||||
// ship zero-timeout hooks that return immediately.
|
||||
func TestValidate_AppliesTimeoutDefault(t *testing.T) {
|
||||
h := baseValidCommandHook()
|
||||
h.TimeoutMS = 0
|
||||
if err := h.Validate(edition.Lite); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
if h.TimeoutMS != hooks.DefaultTimeoutMS {
|
||||
t.Errorf("TimeoutMS default = %d, want %d", h.TimeoutMS, hooks.DefaultTimeoutMS)
|
||||
}
|
||||
}
|
||||
|
||||
// OnTimeout defaults to "block" for blocking events (fail-closed) and to
|
||||
// "allow" for non-blocking ones.
|
||||
func TestValidate_AppliesOnTimeoutDefault(t *testing.T) {
|
||||
h := baseValidCommandHook()
|
||||
h.OnTimeout = ""
|
||||
h.Event = hooks.EventPreToolUse // blocking
|
||||
if err := h.Validate(edition.Lite); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
if h.OnTimeout != hooks.DecisionBlock {
|
||||
t.Errorf("OnTimeout default for blocking event = %q, want %q", h.OnTimeout, hooks.DecisionBlock)
|
||||
}
|
||||
|
||||
h2 := baseValidCommandHook()
|
||||
h2.OnTimeout = ""
|
||||
h2.Event = hooks.EventPostToolUse // non-blocking
|
||||
if err := h2.Validate(edition.Lite); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
if h2.OnTimeout != hooks.DecisionAllow {
|
||||
t.Errorf("OnTimeout default for non-blocking event = %q, want %q", h2.OnTimeout, hooks.DecisionAllow)
|
||||
}
|
||||
}
|
||||
|
||||
// Version defaults to 1 when zero — initial Create path.
|
||||
func TestValidate_AppliesVersionDefault(t *testing.T) {
|
||||
h := baseValidCommandHook()
|
||||
h.Version = 0
|
||||
if err := h.Validate(edition.Lite); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
if h.Version != 1 {
|
||||
t.Errorf("Version default = %d, want 1", h.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// Source defaults to "ui" when empty — distinguishes UI writes from agent-seeded
|
||||
// hooks (M1 isolation in plan).
|
||||
func TestValidate_AppliesSourceDefault(t *testing.T) {
|
||||
h := baseValidCommandHook()
|
||||
h.Source = ""
|
||||
if err := h.Validate(edition.Lite); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
if h.Source != "ui" {
|
||||
t.Errorf("Source default = %q, want %q", h.Source, "ui")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RejectsUnknownEvent(t *testing.T) {
|
||||
h := baseValidCommandHook()
|
||||
h.Event = hooks.HookEvent("webhook_magic")
|
||||
err := h.Validate(edition.Lite)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RejectsGlobalScopeWithNonSentinelTenant(t *testing.T) {
|
||||
h := baseValidCommandHook()
|
||||
h.Scope = hooks.ScopeGlobal
|
||||
h.TenantID = uuid.New() // must be sentinel for global
|
||||
err := h.Validate(edition.Lite)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for global scope with non-sentinel tenant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RejectsAgentScopeWithoutAgentID(t *testing.T) {
|
||||
h := baseValidCommandHook()
|
||||
h.Scope = hooks.ScopeAgent
|
||||
h.TenantID = uuid.New()
|
||||
h.AgentID = nil
|
||||
err := h.Validate(edition.Lite)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for agent scope without agent_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RejectsNegativeTimeout(t *testing.T) {
|
||||
h := baseValidCommandHook()
|
||||
h.TimeoutMS = -1
|
||||
err := h.Validate(edition.Lite)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_CapsTimeoutToMax(t *testing.T) {
|
||||
h := baseValidCommandHook()
|
||||
h.TimeoutMS = 60 * 1000 // 60s — exceeds chain wall-time budget
|
||||
err := h.Validate(edition.Lite)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for timeout > MaxTimeoutMS")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"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
|
||||
// the Decision (allow → continue; block → abort with i18n error).
|
||||
type Dispatcher interface {
|
||||
Fire(ctx context.Context, ev Event) (Decision, 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
|
||||
}
|
||||
|
||||
// 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) (Decision, error) {
|
||||
return 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) (Decision, error) {
|
||||
if depthFromCtx(ctx) > MaxLoopDepth {
|
||||
slog.Warn("security.hook.loop_depth_exceeded",
|
||||
"event_id", ev.EventID,
|
||||
"hook_event", ev.HookEvent,
|
||||
)
|
||||
return 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 DecisionBlock, err
|
||||
}
|
||||
return DecisionAllow, err
|
||||
}
|
||||
if len(hooks) == 0 {
|
||||
return DecisionAllow, nil
|
||||
}
|
||||
|
||||
if ev.HookEvent.IsBlocking() {
|
||||
return d.runSync(ctx, ev, hooks)
|
||||
}
|
||||
d.runAsync(ctx, ev, hooks)
|
||||
return 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.
|
||||
func (d *stdDispatcher) runSync(ctx context.Context, ev Event, chain []HookConfig) (Decision, error) {
|
||||
chainCtx, cancel := context.WithTimeout(ctx, d.chainBudget)
|
||||
defer cancel()
|
||||
|
||||
for _, cfg := range chain {
|
||||
if !cfg.Enabled {
|
||||
continue
|
||||
}
|
||||
if d.cb.isTripped(cfg.ID, d.now()) {
|
||||
d.writeExec(ctx, cfg, ev, DecisionBlock, 0, "circuit breaker open")
|
||||
return DecisionBlock, nil
|
||||
}
|
||||
if !d.prefilter(cfg, ev) {
|
||||
continue
|
||||
}
|
||||
dec, execErr, duration := d.runOne(chainCtx, cfg, ev)
|
||||
errMsg := ""
|
||||
if execErr != nil {
|
||||
errMsg = execErr.Error()
|
||||
}
|
||||
d.writeExec(ctx, cfg, ev, dec, duration, errMsg)
|
||||
|
||||
switch dec {
|
||||
case DecisionBlock:
|
||||
d.cb.record(cfg.ID, d.now(), d.store)
|
||||
return DecisionBlock, nil
|
||||
case DecisionTimeout:
|
||||
d.cb.record(cfg.ID, d.now(), d.store)
|
||||
if cfg.OnTimeout == DecisionBlock {
|
||||
return DecisionBlock, nil
|
||||
}
|
||||
// OnTimeout=allow: degrade gracefully but keep scanning.
|
||||
case DecisionError:
|
||||
// Unexpected error in a blocking chain → fail-closed.
|
||||
return DecisionBlock, nil
|
||||
}
|
||||
|
||||
if chainCtx.Err() != nil {
|
||||
// Chain wall-time budget exhausted (H3): fail-closed.
|
||||
return DecisionBlock, nil
|
||||
}
|
||||
}
|
||||
return DecisionAllow, nil
|
||||
}
|
||||
|
||||
// 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 || !d.prefilter(cfg, ev) {
|
||||
continue
|
||||
}
|
||||
go func(c HookConfig) {
|
||||
dec, execErr, duration := d.runOne(ctx, c, ev)
|
||||
errMsg := ""
|
||||
if execErr != nil {
|
||||
errMsg = execErr.Error()
|
||||
}
|
||||
d.writeExec(context.Background(), c, ev, dec, duration, errMsg)
|
||||
}(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
|
||||
}
|
||||
|
||||
// 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) bool {
|
||||
if cfg.Matcher != "" {
|
||||
re, err := CompileMatcher(cfg.Matcher)
|
||||
if err != nil || !MatchToolName(re, ev.ToolName) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if cfg.IfExpr != "" {
|
||||
prg, err := CompileCELExpr(cfg.IfExpr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ok, err := EvalCEL(prg, map[string]any{
|
||||
"tool_name": ev.ToolName,
|
||||
"tool_input": ev.ToolInput,
|
||||
"depth": ev.Depth,
|
||||
})
|
||||
if err != nil || !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return 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) {
|
||||
if d.audit == nil {
|
||||
return
|
||||
}
|
||||
inputHash, _ := CanonicalInputHash(ev.ToolName, ev.ToolInput)
|
||||
hookID := cfg.ID
|
||||
exec := HookExecution{
|
||||
ID: uuid.New(),
|
||||
HookID: &hookID,
|
||||
SessionID: ev.SessionID,
|
||||
Event: ev.HookEvent,
|
||||
InputHash: inputHash,
|
||||
Decision: dec,
|
||||
DurationMS: int(duration / time.Millisecond),
|
||||
DedupKey: cfg.ID.String() + ":" + ev.EventID,
|
||||
Error: errMsg,
|
||||
Metadata: map[string]any{},
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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(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
|
||||
}
|
||||
if err := store.Update(context.Background(), id, map[string]any{"enabled": false}); err != nil {
|
||||
slog.Warn("security.hook.circuit_breaker_persist_failed", "hook_id", id, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package hooks_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
)
|
||||
|
||||
// ── Test doubles ─────────────────────────────────────────────────────────────
|
||||
|
||||
// fakeStore serves a canned hook list for ResolveForEvent and captures any
|
||||
// Update calls (used to verify circuit-breaker auto-disable).
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
hooks []hooks.HookConfig
|
||||
execs []hooks.HookExecution
|
||||
updates []fakeUpdate
|
||||
resolveE error
|
||||
}
|
||||
|
||||
type fakeUpdate struct {
|
||||
id uuid.UUID
|
||||
updates map[string]any
|
||||
}
|
||||
|
||||
func (f *fakeStore) Create(context.Context, hooks.HookConfig) (uuid.UUID, error) {
|
||||
return uuid.Nil, errors.New("not implemented")
|
||||
}
|
||||
func (f *fakeStore) GetByID(context.Context, uuid.UUID) (*hooks.HookConfig, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (f *fakeStore) List(context.Context, hooks.ListFilter) ([]hooks.HookConfig, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (f *fakeStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.updates = append(f.updates, fakeUpdate{id: id, updates: updates})
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) Delete(context.Context, uuid.UUID) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
func (f *fakeStore) ResolveForEvent(context.Context, hooks.Event) ([]hooks.HookConfig, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.resolveE != nil {
|
||||
return nil, f.resolveE
|
||||
}
|
||||
out := make([]hooks.HookConfig, len(f.hooks))
|
||||
copy(out, f.hooks)
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeStore) WriteExecution(_ context.Context, e hooks.HookExecution) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.execs = append(f.execs, e)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) snapshotExecs() []hooks.HookExecution {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]hooks.HookExecution, len(f.execs))
|
||||
copy(out, f.execs)
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fakeStore) snapshotUpdates() []fakeUpdate {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]fakeUpdate, len(f.updates))
|
||||
copy(out, f.updates)
|
||||
return out
|
||||
}
|
||||
|
||||
// fakeHandler returns a scripted decision + optional sleep (for timeout tests).
|
||||
type fakeHandler struct {
|
||||
decision hooks.Decision
|
||||
sleep time.Duration
|
||||
err error
|
||||
calls int32
|
||||
}
|
||||
|
||||
func (h *fakeHandler) Execute(ctx context.Context, _ hooks.HookConfig, _ hooks.Event) (hooks.Decision, error) {
|
||||
atomic.AddInt32(&h.calls, 1)
|
||||
if h.sleep > 0 {
|
||||
select {
|
||||
case <-time.After(h.sleep):
|
||||
case <-ctx.Done():
|
||||
// Respect ctx; the dispatcher maps this to DecisionTimeout.
|
||||
return hooks.DecisionTimeout, ctx.Err()
|
||||
}
|
||||
}
|
||||
return h.decision, h.err
|
||||
}
|
||||
|
||||
func newBaseHook(ht hooks.HandlerType, ev hooks.HookEvent) hooks.HookConfig {
|
||||
return hooks.HookConfig{
|
||||
ID: uuid.New(),
|
||||
Event: ev,
|
||||
HandlerType: ht,
|
||||
Enabled: true,
|
||||
Priority: 0,
|
||||
Version: 1,
|
||||
OnTimeout: hooks.DecisionBlock,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestDispatcher_NoHooks_ReturnsAllow(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{
|
||||
Store: fs,
|
||||
Audit: hooks.NewAuditWriter(fs, ""),
|
||||
})
|
||||
dec, err := d.Fire(context.Background(), hooks.Event{
|
||||
EventID: "e1",
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fire: %v", err)
|
||||
}
|
||||
if dec != hooks.DecisionAllow {
|
||||
t.Errorf("decision=%q, want allow", dec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcher_SyncChain_FirstBlockWins(t *testing.T) {
|
||||
// First hook allows, second blocks, third would allow but must not run.
|
||||
allow1 := newBaseHook(hooks.HandlerHTTP, hooks.EventPreToolUse)
|
||||
block := newBaseHook(hooks.HandlerHTTP, hooks.EventPreToolUse)
|
||||
allow2 := newBaseHook(hooks.HandlerHTTP, hooks.EventPreToolUse)
|
||||
|
||||
fs := &fakeStore{hooks: []hooks.HookConfig{allow1, block, allow2}}
|
||||
|
||||
firstHandler := &fakeHandler{decision: hooks.DecisionAllow}
|
||||
blockHandler := &fakeHandler{decision: hooks.DecisionBlock}
|
||||
lateHandler := &fakeHandler{decision: hooks.DecisionAllow}
|
||||
|
||||
d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{
|
||||
Store: fs,
|
||||
Audit: hooks.NewAuditWriter(fs, ""),
|
||||
Handlers: map[hooks.HandlerType]hooks.Handler{
|
||||
hooks.HandlerHTTP: routingHandler{
|
||||
allow1.ID: firstHandler,
|
||||
block.ID: blockHandler,
|
||||
allow2.ID: lateHandler,
|
||||
},
|
||||
},
|
||||
})
|
||||
dec, err := d.Fire(context.Background(), hooks.Event{
|
||||
EventID: "e-block",
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fire: %v", err)
|
||||
}
|
||||
if dec != hooks.DecisionBlock {
|
||||
t.Errorf("decision=%q, want block", dec)
|
||||
}
|
||||
if atomic.LoadInt32(&lateHandler.calls) != 0 {
|
||||
t.Errorf("post-block handler ran %d times, want 0", lateHandler.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// routingHandler dispatches by hook ID — lets a single test set different
|
||||
// behaviors per hook while still registering as a single HandlerType.
|
||||
type routingHandler map[uuid.UUID]hooks.Handler
|
||||
|
||||
func (r routingHandler) Execute(ctx context.Context, cfg hooks.HookConfig, ev hooks.Event) (hooks.Decision, error) {
|
||||
h, ok := r[cfg.ID]
|
||||
if !ok {
|
||||
return hooks.DecisionAllow, nil
|
||||
}
|
||||
return h.Execute(ctx, cfg, ev)
|
||||
}
|
||||
|
||||
func TestDispatcher_PerHookTimeout_FailsClosed(t *testing.T) {
|
||||
cfg := newBaseHook(hooks.HandlerHTTP, hooks.EventPreToolUse)
|
||||
cfg.OnTimeout = hooks.DecisionBlock
|
||||
|
||||
fs := &fakeStore{hooks: []hooks.HookConfig{cfg}}
|
||||
slow := &fakeHandler{decision: hooks.DecisionAllow, sleep: 200 * time.Millisecond}
|
||||
|
||||
d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{
|
||||
Store: fs,
|
||||
Audit: hooks.NewAuditWriter(fs, ""),
|
||||
Handlers: map[hooks.HandlerType]hooks.Handler{hooks.HandlerHTTP: slow},
|
||||
PerHookTimeout: 20 * time.Millisecond,
|
||||
ChainBudget: 5 * time.Second,
|
||||
})
|
||||
dec, err := d.Fire(context.Background(), hooks.Event{
|
||||
EventID: "e-timeout",
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fire: %v", err)
|
||||
}
|
||||
if dec != hooks.DecisionBlock {
|
||||
t.Errorf("decision=%q, want block (fail-closed on timeout)", dec)
|
||||
}
|
||||
// Exactly one execution row recorded with timeout decision.
|
||||
execs := fs.snapshotExecs()
|
||||
if len(execs) != 1 {
|
||||
t.Fatalf("execs=%d, want 1", len(execs))
|
||||
}
|
||||
if execs[0].Decision != hooks.DecisionTimeout {
|
||||
t.Errorf("exec decision=%q, want timeout", execs[0].Decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcher_LoopDepth_ReturnsError(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{
|
||||
Store: fs,
|
||||
Audit: hooks.NewAuditWriter(fs, ""),
|
||||
})
|
||||
// Seed ctx with depth == MaxLoopDepth + 1.
|
||||
ctx := hooks.WithDepth(context.Background(), hooks.MaxLoopDepth+1)
|
||||
_, err := d.Fire(ctx, hooks.Event{EventID: "e-deep", HookEvent: hooks.EventPreToolUse})
|
||||
if !errors.Is(err, hooks.ErrLoopDepthExceeded) {
|
||||
t.Errorf("err=%v, want ErrLoopDepthExceeded", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcher_CircuitBreaker_AutoDisable(t *testing.T) {
|
||||
cfg := newBaseHook(hooks.HandlerHTTP, hooks.EventPreToolUse)
|
||||
fs := &fakeStore{hooks: []hooks.HookConfig{cfg}}
|
||||
|
||||
blocker := &fakeHandler{decision: hooks.DecisionBlock}
|
||||
d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{
|
||||
Store: fs,
|
||||
Audit: hooks.NewAuditWriter(fs, ""),
|
||||
Handlers: map[hooks.HandlerType]hooks.Handler{hooks.HandlerHTTP: blocker},
|
||||
CircuitThreshold: 3,
|
||||
CircuitWindow: 1 * time.Minute,
|
||||
})
|
||||
// Fire threshold times — each block increments the rolling counter.
|
||||
for range 3 {
|
||||
_, _ = d.Fire(context.Background(), hooks.Event{
|
||||
EventID: "e-cb",
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
}
|
||||
// On hitting threshold the dispatcher must call store.Update to disable.
|
||||
updates := fs.snapshotUpdates()
|
||||
if len(updates) == 0 {
|
||||
t.Fatal("expected store.Update(enabled=false) after circuit breaker tripped")
|
||||
}
|
||||
last := updates[len(updates)-1]
|
||||
if last.id != cfg.ID {
|
||||
t.Errorf("update targeted %s, want %s", last.id, cfg.ID)
|
||||
}
|
||||
if v, ok := last.updates["enabled"].(bool); !ok || v {
|
||||
t.Errorf("update patch = %v, want enabled=false", last.updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcher_NonBlockingEvent_AsyncNoDecision(t *testing.T) {
|
||||
cfg := newBaseHook(hooks.HandlerHTTP, hooks.EventPostToolUse)
|
||||
fs := &fakeStore{hooks: []hooks.HookConfig{cfg}}
|
||||
|
||||
done := make(chan struct{})
|
||||
asyncHandler := &fakeHandler{decision: hooks.DecisionAllow}
|
||||
d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{
|
||||
Store: fs,
|
||||
Audit: hooks.NewAuditWriter(fs, ""),
|
||||
Handlers: map[hooks.HandlerType]hooks.Handler{
|
||||
hooks.HandlerHTTP: handlerFunc(func(ctx context.Context, c hooks.HookConfig, e hooks.Event) (hooks.Decision, error) {
|
||||
defer close(done)
|
||||
return asyncHandler.Execute(ctx, c, e)
|
||||
}),
|
||||
},
|
||||
})
|
||||
dec, err := d.Fire(context.Background(), hooks.Event{
|
||||
EventID: "e-async",
|
||||
HookEvent: hooks.EventPostToolUse,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fire: %v", err)
|
||||
}
|
||||
if dec != hooks.DecisionAllow {
|
||||
t.Errorf("decision=%q, want allow (non-blocking path never blocks)", dec)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("async handler did not run within 500ms")
|
||||
}
|
||||
}
|
||||
|
||||
// handlerFunc is the func-adapter — mirrors http.HandlerFunc.
|
||||
type handlerFunc func(context.Context, hooks.HookConfig, hooks.Event) (hooks.Decision, error)
|
||||
|
||||
func (f handlerFunc) Execute(ctx context.Context, c hooks.HookConfig, e hooks.Event) (hooks.Decision, error) {
|
||||
return f(ctx, c, e)
|
||||
}
|
||||
|
||||
func TestDispatcher_AllAllow_ReturnsAllow(t *testing.T) {
|
||||
a := newBaseHook(hooks.HandlerHTTP, hooks.EventPreToolUse)
|
||||
b := newBaseHook(hooks.HandlerHTTP, hooks.EventPreToolUse)
|
||||
fs := &fakeStore{hooks: []hooks.HookConfig{a, b}}
|
||||
d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{
|
||||
Store: fs,
|
||||
Audit: hooks.NewAuditWriter(fs, ""),
|
||||
Handlers: map[hooks.HandlerType]hooks.Handler{hooks.HandlerHTTP: &fakeHandler{decision: hooks.DecisionAllow}},
|
||||
})
|
||||
dec, err := d.Fire(context.Background(), hooks.Event{
|
||||
EventID: "e-all-allow",
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fire: %v", err)
|
||||
}
|
||||
if dec != hooks.DecisionAllow {
|
||||
t.Errorf("decision=%q, want allow", dec)
|
||||
}
|
||||
if got := len(fs.snapshotExecs()); got != 2 {
|
||||
t.Errorf("exec rows=%d, want 2 (one per hook)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcher_ResolveError_FailsClosed(t *testing.T) {
|
||||
fs := &fakeStore{resolveE: errors.New("db down")}
|
||||
d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{
|
||||
Store: fs,
|
||||
Audit: hooks.NewAuditWriter(fs, ""),
|
||||
})
|
||||
dec, err := d.Fire(context.Background(), hooks.Event{
|
||||
EventID: "e-resolve-err",
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Fire err=nil, want non-nil on resolve failure")
|
||||
}
|
||||
if dec != hooks.DecisionBlock {
|
||||
t.Errorf("decision=%q, want block (fail-closed)", dec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcher_MissingHandler_BlocksBlockingEvent(t *testing.T) {
|
||||
// Hook requests `command` handler but dispatcher has none registered.
|
||||
// Blocking events must fail-closed.
|
||||
cfg := newBaseHook(hooks.HandlerCommand, hooks.EventPreToolUse)
|
||||
fs := &fakeStore{hooks: []hooks.HookConfig{cfg}}
|
||||
d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{
|
||||
Store: fs,
|
||||
Audit: hooks.NewAuditWriter(fs, ""),
|
||||
})
|
||||
dec, _ := d.Fire(context.Background(), hooks.Event{
|
||||
EventID: "e-no-handler",
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
if dec != hooks.DecisionBlock {
|
||||
t.Errorf("decision=%q, want block (missing handler → fail-closed)", dec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcher_DedupKey_IncludesEventID(t *testing.T) {
|
||||
cfg := newBaseHook(hooks.HandlerHTTP, hooks.EventPreToolUse)
|
||||
fs := &fakeStore{hooks: []hooks.HookConfig{cfg}}
|
||||
d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{
|
||||
Store: fs,
|
||||
Audit: hooks.NewAuditWriter(fs, ""),
|
||||
Handlers: map[hooks.HandlerType]hooks.Handler{hooks.HandlerHTTP: &fakeHandler{decision: hooks.DecisionAllow}},
|
||||
})
|
||||
_, _ = d.Fire(context.Background(), hooks.Event{
|
||||
EventID: "evt-xyz",
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
execs := fs.snapshotExecs()
|
||||
if len(execs) != 1 {
|
||||
t.Fatalf("execs=%d, want 1", len(execs))
|
||||
}
|
||||
if execs[0].DedupKey == "" {
|
||||
t.Error("dedup_key empty, want populated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoopDispatcher_AlwaysAllows(t *testing.T) {
|
||||
d := hooks.NewNoopDispatcher()
|
||||
dec, err := d.Fire(context.Background(), hooks.Event{
|
||||
EventID: "e",
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fire: %v", err)
|
||||
}
|
||||
if dec != hooks.DecisionAllow {
|
||||
t.Errorf("decision=%q, want allow", dec)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package hooks
|
||||
|
||||
import "github.com/nextlevelbuilder/goclaw/internal/edition"
|
||||
|
||||
// HookEditionPolicy is a pure-function policy gate applied at BOTH config
|
||||
// validation time AND at dispatcher fire time (defense-in-depth).
|
||||
//
|
||||
// Rule: the `command` handler executes arbitrary shell; it is only safe in
|
||||
// the Lite edition where the operator physically owns the host. On Standard
|
||||
// (multi-tenant managed hosting), `command` is blocked across every scope
|
||||
// (C2 drop decision). `http` and `prompt` are allowed on both editions.
|
||||
type HookEditionPolicy struct{}
|
||||
|
||||
// Allow returns (ok, reason). reason is only set when ok=false.
|
||||
// reason is a stable string suitable for audit rows and i18n key lookup.
|
||||
func (HookEditionPolicy) Allow(ht HandlerType, _ Scope, ed edition.Edition) (bool, string) {
|
||||
switch ht {
|
||||
case HandlerHTTP, HandlerPrompt:
|
||||
return true, ""
|
||||
case HandlerCommand:
|
||||
if ed.Name == edition.Lite.Name {
|
||||
return true, ""
|
||||
}
|
||||
return false, "hook.command_disabled_standard"
|
||||
default:
|
||||
return false, "hook.unknown_handler_type"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package hooks_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
)
|
||||
|
||||
// TestEditionGate covers the full matrix (handlerType × scope × edition).
|
||||
// Per plan C2 drop: `command` is Lite-only across all scopes.
|
||||
// `http` and `prompt` are allowed on both editions.
|
||||
func TestEditionGate(t *testing.T) {
|
||||
var policy hooks.HookEditionPolicy
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
handlerType hooks.HandlerType
|
||||
scope hooks.Scope
|
||||
ed edition.Edition
|
||||
wantAllow bool
|
||||
}{
|
||||
// command on Lite — always allowed.
|
||||
{"command/global/lite", hooks.HandlerCommand, hooks.ScopeGlobal, edition.Lite, true},
|
||||
{"command/tenant/lite", hooks.HandlerCommand, hooks.ScopeTenant, edition.Lite, true},
|
||||
{"command/agent/lite", hooks.HandlerCommand, hooks.ScopeAgent, edition.Lite, true},
|
||||
|
||||
// command on Standard — blocked at every scope (C2 drop).
|
||||
{"command/global/standard", hooks.HandlerCommand, hooks.ScopeGlobal, edition.Standard, false},
|
||||
{"command/tenant/standard", hooks.HandlerCommand, hooks.ScopeTenant, edition.Standard, false},
|
||||
{"command/agent/standard", hooks.HandlerCommand, hooks.ScopeAgent, edition.Standard, false},
|
||||
|
||||
// http — allowed on both editions, all scopes.
|
||||
{"http/global/lite", hooks.HandlerHTTP, hooks.ScopeGlobal, edition.Lite, true},
|
||||
{"http/tenant/standard", hooks.HandlerHTTP, hooks.ScopeTenant, edition.Standard, true},
|
||||
{"http/agent/standard", hooks.HandlerHTTP, hooks.ScopeAgent, edition.Standard, true},
|
||||
|
||||
// prompt — allowed on both editions, all scopes.
|
||||
{"prompt/global/lite", hooks.HandlerPrompt, hooks.ScopeGlobal, edition.Lite, true},
|
||||
{"prompt/tenant/standard", hooks.HandlerPrompt, hooks.ScopeTenant, edition.Standard, true},
|
||||
{"prompt/agent/lite", hooks.HandlerPrompt, hooks.ScopeAgent, edition.Lite, true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
allow, reason := policy.Allow(tc.handlerType, tc.scope, tc.ed)
|
||||
if allow != tc.wantAllow {
|
||||
t.Errorf("Allow(%v,%v,%v) = %v, want %v (reason=%q)",
|
||||
tc.handlerType, tc.scope, tc.ed.Name, allow, tc.wantAllow, reason)
|
||||
}
|
||||
// When denied, a non-empty reason string MUST be returned.
|
||||
if !allow && reason == "" {
|
||||
t.Errorf("Allow(%v,%v,%v) denied but reason is empty",
|
||||
tc.handlerType, tc.scope, tc.ed.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditionGateUnknownHandler rejects unknown handler types defensively.
|
||||
func TestEditionGateUnknownHandler(t *testing.T) {
|
||||
var policy hooks.HookEditionPolicy
|
||||
allow, reason := policy.Allow(hooks.HandlerType("webhook"), hooks.ScopeTenant, edition.Lite)
|
||||
if allow {
|
||||
t.Errorf("unknown handler should not be allowed; got allow=true reason=%q", reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"github.com/google/cel-go/cel"
|
||||
)
|
||||
|
||||
// ───── Regex matcher ─────
|
||||
|
||||
// regexCache stores compiled regex patterns keyed by the pattern string.
|
||||
// Pattern content is the cache key — hook version bumps bypass the cache
|
||||
// only when the pattern content actually changes (L5 mitigation).
|
||||
var regexCache sync.Map
|
||||
|
||||
// CompileMatcher compiles pattern into a cached *regexp.Regexp.
|
||||
// Empty pattern returns (nil, nil); callers treat nil as match-all.
|
||||
// Malformed patterns return a typed error (never panic).
|
||||
func CompileMatcher(pattern string) (*regexp.Regexp, error) {
|
||||
if pattern == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if v, ok := regexCache.Load(pattern); ok {
|
||||
return v.(*regexp.Regexp), nil
|
||||
}
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compile matcher %q: %w", pattern, err)
|
||||
}
|
||||
regexCache.Store(pattern, re)
|
||||
return re, nil
|
||||
}
|
||||
|
||||
// MatchToolName returns true if re matches toolName. Nil regex matches all.
|
||||
func MatchToolName(re *regexp.Regexp, toolName string) bool {
|
||||
if re == nil {
|
||||
return true
|
||||
}
|
||||
return re.MatchString(toolName)
|
||||
}
|
||||
|
||||
// ───── CEL evaluator ─────
|
||||
|
||||
// celCostLimit caps evaluation cost to defeat ReDoS/turing-escape (L5).
|
||||
const celCostLimit uint64 = 100_000
|
||||
|
||||
var (
|
||||
celEnvInstance *cel.Env
|
||||
celEnvOnce sync.Once
|
||||
celEnvErr error
|
||||
celCache sync.Map // expr string → cel.Program
|
||||
)
|
||||
|
||||
// buildCELEnv constructs the deliberately-minimal CEL environment.
|
||||
//
|
||||
// Exposed variables:
|
||||
// - tool_name (string)
|
||||
// - tool_input (map<string, dyn>)
|
||||
// - depth (int) — sub-agent nesting level; max enforced by dispatcher
|
||||
//
|
||||
// Stdlib file/network functions are NOT registered — any reference such as
|
||||
// readFile()/http.get() fails at compile time (sandbox).
|
||||
func buildCELEnv() (*cel.Env, error) {
|
||||
celEnvOnce.Do(func() {
|
||||
celEnvInstance, celEnvErr = cel.NewEnv(
|
||||
cel.Variable("tool_name", cel.StringType),
|
||||
cel.Variable("tool_input", cel.MapType(cel.StringType, cel.DynType)),
|
||||
cel.Variable("depth", cel.IntType),
|
||||
)
|
||||
})
|
||||
return celEnvInstance, celEnvErr
|
||||
}
|
||||
|
||||
// CompileCELExpr parses+type-checks expr and caches the resulting program.
|
||||
// Empty expr returns (nil, nil); callers treat nil as match-all.
|
||||
func CompileCELExpr(expr string) (cel.Program, error) {
|
||||
if expr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if v, ok := celCache.Load(expr); ok {
|
||||
return v.(cel.Program), nil
|
||||
}
|
||||
env, err := buildCELEnv()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cel env: %w", err)
|
||||
}
|
||||
ast, iss := env.Compile(expr)
|
||||
if iss != nil && iss.Err() != nil {
|
||||
return nil, fmt.Errorf("cel parse %q: %w", expr, iss.Err())
|
||||
}
|
||||
prg, err := env.Program(ast, cel.CostLimit(celCostLimit))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cel program: %w", err)
|
||||
}
|
||||
celCache.Store(expr, prg)
|
||||
return prg, nil
|
||||
}
|
||||
|
||||
// EvalCEL executes a compiled program against the provided variables.
|
||||
// Nil program evaluates to true (match-all semantics for empty if_expr).
|
||||
// Non-bool result is a typed error — defensive against misuse.
|
||||
func EvalCEL(prg cel.Program, vars map[string]any) (bool, error) {
|
||||
if prg == nil {
|
||||
return true, nil
|
||||
}
|
||||
out, _, err := prg.Eval(vars)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("cel eval: %w", err)
|
||||
}
|
||||
b, ok := out.Value().(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("cel expression must return bool, got %T", out.Value())
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package hooks_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
)
|
||||
|
||||
// ───── Regex ─────
|
||||
|
||||
func TestCompileMatcher_Valid(t *testing.T) {
|
||||
re, err := hooks.CompileMatcher("^Write$")
|
||||
if err != nil {
|
||||
t.Fatalf("CompileMatcher: %v", err)
|
||||
}
|
||||
if re == nil {
|
||||
t.Fatal("expected non-nil regex")
|
||||
}
|
||||
if !hooks.MatchToolName(re, "Write") {
|
||||
t.Error("Write should match")
|
||||
}
|
||||
if hooks.MatchToolName(re, "Read") {
|
||||
t.Error("Read should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileMatcher_Empty_MatchesAll(t *testing.T) {
|
||||
re, err := hooks.CompileMatcher("")
|
||||
if err != nil {
|
||||
t.Fatalf("CompileMatcher(\"\"): %v", err)
|
||||
}
|
||||
if re != nil {
|
||||
t.Errorf("empty pattern should return nil regex, got %v", re)
|
||||
}
|
||||
// MatchToolName with nil regex matches everything.
|
||||
if !hooks.MatchToolName(nil, "anything") {
|
||||
t.Error("nil regex should match all")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileMatcher_Invalid_ReturnsTypedError(t *testing.T) {
|
||||
re, err := hooks.CompileMatcher("[unclosed")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed regex")
|
||||
}
|
||||
if re != nil {
|
||||
t.Error("expected nil regex on error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "compile matcher") {
|
||||
t.Errorf("error should mention matcher context; got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileMatcher_CacheReuse(t *testing.T) {
|
||||
re1, err1 := hooks.CompileMatcher("^Read$")
|
||||
re2, err2 := hooks.CompileMatcher("^Read$")
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatalf("compile err1=%v err2=%v", err1, err2)
|
||||
}
|
||||
// Cached compile returns the same pointer.
|
||||
if re1 != re2 {
|
||||
t.Error("expected cache hit to return same regex pointer")
|
||||
}
|
||||
}
|
||||
|
||||
// ───── CEL ─────
|
||||
|
||||
func TestCompileCELExpr_BoolEval(t *testing.T) {
|
||||
prg, err := hooks.CompileCELExpr(`tool_name == "Write"`)
|
||||
if err != nil {
|
||||
t.Fatalf("CompileCELExpr: %v", err)
|
||||
}
|
||||
got, err := hooks.EvalCEL(prg, map[string]any{
|
||||
"tool_name": "Write",
|
||||
"tool_input": map[string]any{},
|
||||
"depth": int64(0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EvalCEL: %v", err)
|
||||
}
|
||||
if !got {
|
||||
t.Error("expected true for tool_name == \"Write\"")
|
||||
}
|
||||
|
||||
got, err = hooks.EvalCEL(prg, map[string]any{
|
||||
"tool_name": "Read",
|
||||
"tool_input": map[string]any{},
|
||||
"depth": int64(0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EvalCEL: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Error("expected false for tool_name != \"Write\"")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileCELExpr_ToolInputMap(t *testing.T) {
|
||||
// Verifies CEL can index into the tool_input map and compare strings.
|
||||
prg, err := hooks.CompileCELExpr(`tool_input["path"] == "/etc/passwd"`)
|
||||
if err != nil {
|
||||
t.Fatalf("CompileCELExpr: %v", err)
|
||||
}
|
||||
got, err := hooks.EvalCEL(prg, map[string]any{
|
||||
"tool_name": "Read",
|
||||
"tool_input": map[string]any{"path": "/etc/passwd"},
|
||||
"depth": int64(0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EvalCEL: %v", err)
|
||||
}
|
||||
if !got {
|
||||
t.Error("expected true for matching path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileCELExpr_Empty_ReturnsNilProgram(t *testing.T) {
|
||||
prg, err := hooks.CompileCELExpr("")
|
||||
if err != nil {
|
||||
t.Fatalf("CompileCELExpr(\"\"): %v", err)
|
||||
}
|
||||
if prg != nil {
|
||||
t.Error("empty expr should return nil program")
|
||||
}
|
||||
// Nil program evaluates to true (match-all).
|
||||
got, err := hooks.EvalCEL(nil, nil)
|
||||
if err != nil {
|
||||
t.Errorf("EvalCEL(nil) should not error; got %v", err)
|
||||
}
|
||||
if !got {
|
||||
t.Error("nil program should evaluate true (match-all)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileCELExpr_ParseError_ReturnsTypedError(t *testing.T) {
|
||||
// Defensive: malformed CEL must not panic — must return error.
|
||||
prg, err := hooks.CompileCELExpr(`tool_name ===== "bad"`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed CEL")
|
||||
}
|
||||
if prg != nil {
|
||||
t.Error("expected nil program on parse error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileCELExpr_Sandbox_NoFileAccess(t *testing.T) {
|
||||
// The CEL env exposes only tool_name, tool_input, depth. Any reference
|
||||
// to stdlib file/network functions must fail at compile time.
|
||||
_, err := hooks.CompileCELExpr(`readFile("/etc/passwd") == "root"`)
|
||||
if err == nil {
|
||||
t.Error("CEL sandbox should reject readFile() — undeclared function")
|
||||
}
|
||||
_, err = hooks.CompileCELExpr(`http.get("https://evil.example") == "x"`)
|
||||
if err == nil {
|
||||
t.Error("CEL sandbox should reject http.get() — undeclared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileCELExpr_NonBoolResult_ReturnsError(t *testing.T) {
|
||||
// Expression must yield bool; an integer result is a typed error.
|
||||
prg, err := hooks.CompileCELExpr(`1 + 2`)
|
||||
if err != nil {
|
||||
t.Fatalf("CompileCELExpr: %v", err)
|
||||
}
|
||||
_, err = hooks.EvalCEL(prg, map[string]any{
|
||||
"tool_name": "x",
|
||||
"tool_input": map[string]any{},
|
||||
"depth": int64(0),
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for non-bool CEL result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileCELExpr_CacheReuse(t *testing.T) {
|
||||
p1, err1 := hooks.CompileCELExpr(`depth < 3`)
|
||||
p2, err2 := hooks.CompileCELExpr(`depth < 3`)
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatalf("compile err1=%v err2=%v", err1, err2)
|
||||
}
|
||||
// Cache hit should return the same cel.Program.
|
||||
if p1 != p2 {
|
||||
t.Error("expected cache hit to return same CEL program")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ListFilter narrows results from HookStore.List.
|
||||
type ListFilter struct {
|
||||
TenantID *uuid.UUID
|
||||
AgentID *uuid.UUID
|
||||
Event *HookEvent
|
||||
Scope *Scope
|
||||
Enabled *bool
|
||||
}
|
||||
|
||||
// HookStore is the data-access contract for agent hooks.
|
||||
// PG impl lives in internal/store/pg/hooks.go;
|
||||
// SQLite impl lives in internal/store/sqlitestore/hooks.go.
|
||||
//
|
||||
// All write operations must enforce tenant isolation: reads outside master scope
|
||||
// must include WHERE tenant_id = $N. See store.IsMasterScope(ctx).
|
||||
type HookStore interface {
|
||||
// Create inserts a new hook config and returns the generated UUID.
|
||||
Create(ctx context.Context, cfg HookConfig) (uuid.UUID, error)
|
||||
|
||||
// GetByID returns a single hook config by primary key.
|
||||
// Returns nil, nil when not found (no sentinel error).
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*HookConfig, error)
|
||||
|
||||
// List returns hooks matching the filter. Caller applies ordering/limit.
|
||||
List(ctx context.Context, filter ListFilter) ([]HookConfig, error)
|
||||
|
||||
// Update applies the map of column→value patches and bumps the version field.
|
||||
// Callers must not include "version" in updates — the store increments it
|
||||
// atomically to bust the TTL cache entry (H1 mitigation).
|
||||
Update(ctx context.Context, id uuid.UUID, updates map[string]any) error
|
||||
|
||||
// Delete removes a hook config. hook_executions rows are preserved via
|
||||
// ON DELETE SET NULL on the hook_id FK column.
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
|
||||
// ResolveForEvent returns the ordered list of enabled hooks that match
|
||||
// (tenant_id, agent_id, event). Implementations should cache this result
|
||||
// with a short TTL (5s) keyed by (tenant_id, agent_id, event, maxVersion)
|
||||
// and short-circuit via COUNT(*) = 0 cache for the zero-hooks hot path.
|
||||
ResolveForEvent(ctx context.Context, event Event) ([]HookConfig, error)
|
||||
|
||||
// WriteExecution appends an immutable execution audit row.
|
||||
// Caller must pre-truncate Error to 256 chars and encrypt ErrorDetail.
|
||||
WriteExecution(ctx context.Context, exec HookExecution) error
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tracing"
|
||||
)
|
||||
|
||||
// EmitHookSpan records a tracing span for a hook execution.
|
||||
//
|
||||
// The span name follows the plan's convention: "hook.<handlerType>.<event>"
|
||||
// (e.g., "hook.command.pre_tool_use"). Duration is computed from startedAt
|
||||
// to now; status is "completed" on success, "error" when errMsg is non-empty.
|
||||
// The decision is persisted into Metadata as `{"decision":"allow|block|..."}`
|
||||
// so dashboards can aggregate allow/block ratios per event.
|
||||
//
|
||||
// Fields lifted from ctx:
|
||||
// - trace id (tracing.TraceIDFromContext)
|
||||
// - parent span id (tracing.ParentSpanIDFromContext, omitted when nil)
|
||||
// - team id (tracing.TraceTeamIDPtrFromContext)
|
||||
// - tenant id (store.TenantIDFromContext)
|
||||
//
|
||||
// No-op when ctx has no collector attached — safe in tests and for tenants
|
||||
// without tracing enabled.
|
||||
func EmitHookSpan(
|
||||
ctx context.Context,
|
||||
event HookEvent,
|
||||
ht HandlerType,
|
||||
startedAt time.Time,
|
||||
decision Decision,
|
||||
errMsg string,
|
||||
) {
|
||||
collector := tracing.CollectorFromContext(ctx)
|
||||
if collector == nil {
|
||||
return
|
||||
}
|
||||
|
||||
end := time.Now().UTC()
|
||||
durationMS := max(int(end.Sub(startedAt)/time.Millisecond), 0)
|
||||
|
||||
status := store.SpanStatusCompleted
|
||||
if errMsg != "" {
|
||||
status = store.SpanStatusError
|
||||
}
|
||||
|
||||
var metadata json.RawMessage
|
||||
if decision != "" {
|
||||
if b, err := json.Marshal(map[string]string{"decision": string(decision)}); err == nil {
|
||||
metadata = b
|
||||
}
|
||||
}
|
||||
|
||||
span := store.SpanData{
|
||||
TraceID: tracing.TraceIDFromContext(ctx),
|
||||
SpanType: store.SpanTypeEvent,
|
||||
Name: "hook." + string(ht) + "." + string(event),
|
||||
StartTime: startedAt,
|
||||
EndTime: &end,
|
||||
DurationMS: durationMS,
|
||||
Status: status,
|
||||
Error: errMsg,
|
||||
Metadata: metadata,
|
||||
TeamID: tracing.TraceTeamIDPtrFromContext(ctx),
|
||||
TenantID: store.TenantIDFromContext(ctx),
|
||||
CreatedAt: end,
|
||||
}
|
||||
|
||||
// Attach parent only when present — leaving it nil avoids bogus FK edges.
|
||||
if parent := tracing.ParentSpanIDFromContext(ctx); parent != uuid.Nil {
|
||||
p := parent
|
||||
span.ParentSpanID = &p
|
||||
}
|
||||
|
||||
collector.EmitSpan(span)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package hooks_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tracing"
|
||||
)
|
||||
|
||||
// capturingTracingStore is a minimal store.TracingStore that records spans
|
||||
// received via BatchCreateSpans. Other methods fall through to the embedded
|
||||
// (nil) interface — tests must not call them.
|
||||
type capturingTracingStore struct {
|
||||
store.TracingStore // embedded; unused methods are not called by Collector flush
|
||||
|
||||
mu sync.Mutex
|
||||
spans []store.SpanData
|
||||
}
|
||||
|
||||
func (c *capturingTracingStore) BatchCreateSpans(_ context.Context, spans []store.SpanData) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.spans = append(c.spans, spans...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *capturingTracingStore) BatchUpdateTraceAggregates(_ context.Context, _ uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *capturingTracingStore) DeleteTracesOlderThan(_ context.Context, _ time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (c *capturingTracingStore) snapshot() []store.SpanData {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := make([]store.SpanData, len(c.spans))
|
||||
copy(out, c.spans)
|
||||
return out
|
||||
}
|
||||
|
||||
// newRunningCollector returns a collector + started goroutines. Caller MUST
|
||||
// Stop() to flush buffered spans to the capturing store.
|
||||
func newRunningCollector(t *testing.T) (*tracing.Collector, *capturingTracingStore) {
|
||||
t.Helper()
|
||||
cs := &capturingTracingStore{}
|
||||
c := tracing.NewCollector(cs)
|
||||
c.Start()
|
||||
return c, cs
|
||||
}
|
||||
|
||||
// TestEmitHookSpan_NoCollector_NoPanic: safe no-op when ctx has no collector.
|
||||
func TestEmitHookSpan_NoCollector_NoPanic(t *testing.T) {
|
||||
hooks.EmitHookSpan(context.Background(), hooks.EventPreToolUse, hooks.HandlerCommand,
|
||||
time.Now().Add(-100*time.Millisecond), hooks.DecisionAllow, "")
|
||||
}
|
||||
|
||||
// TestEmitHookSpan_NameFormat: span name = "hook.<handlerType>.<event>".
|
||||
func TestEmitHookSpan_NameFormat(t *testing.T) {
|
||||
c, cs := newRunningCollector(t)
|
||||
ctx := tracing.WithCollector(context.Background(), c)
|
||||
ctx = tracing.WithTraceID(ctx, uuid.New())
|
||||
|
||||
hooks.EmitHookSpan(ctx, hooks.EventPreToolUse, hooks.HandlerCommand,
|
||||
time.Now().Add(-50*time.Millisecond), hooks.DecisionAllow, "")
|
||||
|
||||
c.Stop() // flush synchronously
|
||||
|
||||
spans := cs.snapshot()
|
||||
if len(spans) == 0 {
|
||||
t.Fatal("expected at least one span flushed")
|
||||
}
|
||||
span := spans[0]
|
||||
want := "hook.command.pre_tool_use"
|
||||
if span.Name != want {
|
||||
t.Errorf("span name = %q, want %q", span.Name, want)
|
||||
}
|
||||
if span.SpanType != store.SpanTypeEvent {
|
||||
t.Errorf("span type = %q, want %q", span.SpanType, store.SpanTypeEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitHookSpan_DurationAndStatus: duration is derived from startedAt; status
|
||||
// reflects error presence (completed vs error).
|
||||
func TestEmitHookSpan_DurationAndStatus(t *testing.T) {
|
||||
c, cs := newRunningCollector(t)
|
||||
ctx := tracing.WithCollector(context.Background(), c)
|
||||
ctx = tracing.WithTraceID(ctx, uuid.New())
|
||||
|
||||
startedOK := time.Now().Add(-42 * time.Millisecond)
|
||||
hooks.EmitHookSpan(ctx, hooks.EventPostToolUse, hooks.HandlerHTTP, startedOK, hooks.DecisionAllow, "")
|
||||
|
||||
startedErr := time.Now().Add(-17 * time.Millisecond)
|
||||
hooks.EmitHookSpan(ctx, hooks.EventPostToolUse, hooks.HandlerHTTP, startedErr, hooks.DecisionBlock, "timeout")
|
||||
|
||||
c.Stop()
|
||||
|
||||
spans := cs.snapshot()
|
||||
if len(spans) < 2 {
|
||||
t.Fatalf("expected 2 spans, got %d", len(spans))
|
||||
}
|
||||
okSpan := spans[0]
|
||||
errSpan := spans[1]
|
||||
|
||||
if okSpan.Status != store.SpanStatusCompleted {
|
||||
t.Errorf("ok span status = %q, want %q", okSpan.Status, store.SpanStatusCompleted)
|
||||
}
|
||||
if okSpan.Error != "" {
|
||||
t.Errorf("ok span should have empty error; got %q", okSpan.Error)
|
||||
}
|
||||
if okSpan.DurationMS < 40 {
|
||||
t.Errorf("ok span duration_ms = %d, want >= 40", okSpan.DurationMS)
|
||||
}
|
||||
if errSpan.Status != store.SpanStatusError {
|
||||
t.Errorf("err span status = %q, want %q", errSpan.Status, store.SpanStatusError)
|
||||
}
|
||||
if errSpan.Error != "timeout" {
|
||||
t.Errorf("err span error = %q, want %q", errSpan.Error, "timeout")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitHookSpan_DecisionInMetadata: decision is serialized into span.Metadata.
|
||||
func TestEmitHookSpan_DecisionInMetadata(t *testing.T) {
|
||||
c, cs := newRunningCollector(t)
|
||||
ctx := tracing.WithCollector(context.Background(), c)
|
||||
ctx = tracing.WithTraceID(ctx, uuid.New())
|
||||
|
||||
hooks.EmitHookSpan(ctx, hooks.EventUserPromptSubmit, hooks.HandlerPrompt,
|
||||
time.Now().Add(-10*time.Millisecond), hooks.DecisionBlock, "")
|
||||
|
||||
c.Stop()
|
||||
|
||||
spans := cs.snapshot()
|
||||
if len(spans) == 0 {
|
||||
t.Fatal("expected a span flushed")
|
||||
}
|
||||
if len(spans[0].Metadata) == 0 {
|
||||
t.Fatal("expected non-empty metadata json")
|
||||
}
|
||||
var md map[string]any
|
||||
if err := json.Unmarshal(spans[0].Metadata, &md); err != nil {
|
||||
t.Fatalf("metadata is not valid json: %v", err)
|
||||
}
|
||||
if got, _ := md["decision"].(string); got != string(hooks.DecisionBlock) {
|
||||
t.Errorf("metadata.decision = %q, want %q", got, string(hooks.DecisionBlock))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitHookSpan_PropagatesTenantAndTrace: ctx values flow through to the span.
|
||||
func TestEmitHookSpan_PropagatesTenantAndTrace(t *testing.T) {
|
||||
c, cs := newRunningCollector(t)
|
||||
|
||||
traceID := uuid.New()
|
||||
parentSpanID := uuid.New()
|
||||
tenantID := uuid.New()
|
||||
|
||||
ctx := tracing.WithCollector(context.Background(), c)
|
||||
ctx = tracing.WithTraceID(ctx, traceID)
|
||||
ctx = tracing.WithParentSpanID(ctx, parentSpanID)
|
||||
ctx = store.WithTenantID(ctx, tenantID)
|
||||
|
||||
hooks.EmitHookSpan(ctx, hooks.EventPreToolUse, hooks.HandlerCommand,
|
||||
time.Now().Add(-5*time.Millisecond), hooks.DecisionAllow, "")
|
||||
|
||||
c.Stop()
|
||||
|
||||
spans := cs.snapshot()
|
||||
if len(spans) == 0 {
|
||||
t.Fatal("expected a span flushed")
|
||||
}
|
||||
s := spans[0]
|
||||
if s.TraceID != traceID {
|
||||
t.Errorf("trace_id = %v, want %v", s.TraceID, traceID)
|
||||
}
|
||||
if s.ParentSpanID == nil || *s.ParentSpanID != parentSpanID {
|
||||
t.Errorf("parent_span_id = %v, want %v", s.ParentSpanID, parentSpanID)
|
||||
}
|
||||
if s.TenantID != tenantID {
|
||||
t.Errorf("tenant_id = %v, want %v", s.TenantID, tenantID)
|
||||
}
|
||||
if !strings.HasPrefix(s.Name, "hook.") {
|
||||
t.Errorf("span name %q must start with %q", s.Name, "hook.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Package hooks defines the agent hook system: typed events, config structs,
|
||||
// execution payloads, and the store interface. Handlers and dispatcher live
|
||||
// in separate files; this file is pure type definitions.
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// ─── Enum types ─────────────────────────────────────────────────────────────
|
||||
|
||||
// HookEvent identifies which lifecycle point a hook fires on.
|
||||
// Stored as VARCHAR(32) in DB; JSON-encoded as plain string.
|
||||
type HookEvent string
|
||||
|
||||
const (
|
||||
// EventSessionStart fires when a new session is established.
|
||||
EventSessionStart HookEvent = "session_start"
|
||||
// EventUserPromptSubmit fires before the user's message enters the pipeline. BLOCKING.
|
||||
EventUserPromptSubmit HookEvent = "user_prompt_submit"
|
||||
// EventPreToolUse fires before any tool call executes. BLOCKING.
|
||||
EventPreToolUse HookEvent = "pre_tool_use"
|
||||
// EventPostToolUse fires after a tool call completes. Non-blocking.
|
||||
EventPostToolUse HookEvent = "post_tool_use"
|
||||
// EventStop fires when the agent session terminates normally.
|
||||
EventStop HookEvent = "stop"
|
||||
// EventSubagentStart fires when a sub-agent is spawned. BLOCKING.
|
||||
EventSubagentStart HookEvent = "subagent_start"
|
||||
// EventSubagentStop fires when a sub-agent finishes.
|
||||
EventSubagentStop HookEvent = "subagent_stop"
|
||||
)
|
||||
|
||||
// IsBlocking returns true when the event requires a synchronous allow/block
|
||||
// decision before the pipeline continues. Fail-closed: blocking events that
|
||||
// timeout yield Decision=block.
|
||||
func (e HookEvent) IsBlocking() bool {
|
||||
switch e {
|
||||
case EventUserPromptSubmit, EventPreToolUse, EventSubagentStart:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON encodes HookEvent as a JSON string (not integer).
|
||||
func (e HookEvent) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(string(e))
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes a JSON string into HookEvent.
|
||||
func (e *HookEvent) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
*e = HookEvent(s)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandlerType identifies the execution mechanism for a hook.
|
||||
type HandlerType string
|
||||
|
||||
const (
|
||||
// HandlerCommand runs a local shell command with event data on stdin.
|
||||
HandlerCommand HandlerType = "command"
|
||||
// HandlerHTTP posts event data to an HTTP endpoint.
|
||||
HandlerHTTP HandlerType = "http"
|
||||
// HandlerPrompt routes the event through an LLM prompt.
|
||||
HandlerPrompt HandlerType = "prompt"
|
||||
)
|
||||
|
||||
// MarshalJSON encodes HandlerType as a JSON string.
|
||||
func (h HandlerType) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(string(h))
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes a JSON string into HandlerType.
|
||||
func (h *HandlerType) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
*h = HandlerType(s)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Scope controls which hooks are visible in a resolution pass.
|
||||
type Scope string
|
||||
|
||||
const (
|
||||
// ScopeGlobal hooks apply to all tenants. Stored with sentinel UUID as tenant_id.
|
||||
ScopeGlobal Scope = "global"
|
||||
// ScopeTenant hooks are scoped to a specific tenant.
|
||||
ScopeTenant Scope = "tenant"
|
||||
// ScopeAgent hooks are scoped to a specific agent within a tenant.
|
||||
ScopeAgent Scope = "agent"
|
||||
)
|
||||
|
||||
// SentinelTenantID is the tenant_id value used for global-scope hooks.
|
||||
// Reuses store.MasterTenantID rather than introducing a separate all-zero
|
||||
// sentinel: the codebase already treats MasterTenantID as the "no specific
|
||||
// tenant" ID (see store.IsMasterScope), and there is a seeded tenants row
|
||||
// with this UUID, so the FK constraint is always satisfied.
|
||||
var SentinelTenantID = store.MasterTenantID
|
||||
|
||||
// Decision is the outcome returned by a hook execution.
|
||||
type Decision string
|
||||
|
||||
const (
|
||||
// DecisionAllow permits the operation to proceed.
|
||||
DecisionAllow Decision = "allow"
|
||||
// DecisionBlock halts the operation. Pipeline stops; fail-closed.
|
||||
DecisionBlock Decision = "block"
|
||||
// DecisionError indicates the hook encountered an unexpected error.
|
||||
DecisionError Decision = "error"
|
||||
// DecisionTimeout indicates the hook did not respond within the time budget.
|
||||
DecisionTimeout Decision = "timeout"
|
||||
)
|
||||
|
||||
// IsBlock returns true only when the decision is DecisionBlock.
|
||||
// Used by the dispatcher sync chain: first block wins.
|
||||
func (d Decision) IsBlock() bool {
|
||||
return d == DecisionBlock
|
||||
}
|
||||
|
||||
// ─── Config & execution structs ──────────────────────────────────────────────
|
||||
|
||||
// HookConfig mirrors the agent_hooks DB row. All pointer fields correspond to
|
||||
// nullable columns.
|
||||
type HookConfig struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
AgentID *uuid.UUID `json:"agent_id,omitempty"`
|
||||
Event HookEvent `json:"event"`
|
||||
HandlerType HandlerType `json:"handler_type"`
|
||||
Scope Scope `json:"scope"`
|
||||
// Config holds handler-specific options (command path, HTTP URL, prompt template).
|
||||
Config map[string]any `json:"config"`
|
||||
Matcher string `json:"matcher,omitempty"`
|
||||
IfExpr string `json:"if_expr,omitempty"`
|
||||
TimeoutMS int `json:"timeout_ms"`
|
||||
OnTimeout Decision `json:"on_timeout"`
|
||||
Priority int `json:"priority"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Version int `json:"version"`
|
||||
Source string `json:"source"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// HookExecution mirrors the hook_executions DB row.
|
||||
// error_detail (BYTEA) is AES-256-GCM encrypted before storage.
|
||||
type HookExecution struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
HookID *uuid.UUID `json:"hook_id,omitempty"` // NULL when hook deleted (ON DELETE SET NULL)
|
||||
SessionID string `json:"session_id"`
|
||||
Event HookEvent `json:"event"`
|
||||
InputHash string `json:"input_hash"` // canonical-JSON sha256, 64 hex chars
|
||||
Decision Decision `json:"decision"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
Retry int `json:"retry"`
|
||||
DedupKey string `json:"dedup_key"` // (hook_id, event_id) composite
|
||||
Error string `json:"error"` // truncated to 256 chars
|
||||
ErrorDetail []byte `json:"error_detail"` // encrypted; nil if no error
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Event is the payload passed to the dispatcher and stored for audit.
|
||||
// All fields are read-only once constructed; mutation is not safe.
|
||||
type Event struct {
|
||||
// EventID is a unique identifier for this specific event occurrence.
|
||||
// Used as part of the dedup key in hook_executions.
|
||||
EventID string
|
||||
SessionID string
|
||||
TenantID uuid.UUID
|
||||
AgentID uuid.UUID
|
||||
// ToolName is populated for PreToolUse/PostToolUse events.
|
||||
ToolName string
|
||||
// ToolInput is the raw tool arguments map for CEL evaluation.
|
||||
ToolInput map[string]any
|
||||
// RawInput is the user's raw message text (for UserPromptSubmit).
|
||||
RawInput string
|
||||
// Depth tracks sub-agent nesting level; max 3 before loop rejection.
|
||||
Depth int
|
||||
// HookEvent is the lifecycle event type.
|
||||
HookEvent HookEvent
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package hooks_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
)
|
||||
|
||||
// TestHookEventJSONRoundTrip verifies all 7 HookEvent constants have stable string values.
|
||||
func TestHookEventJSONRoundTrip(t *testing.T) {
|
||||
events := []hooks.HookEvent{
|
||||
hooks.EventSessionStart,
|
||||
hooks.EventUserPromptSubmit,
|
||||
hooks.EventPreToolUse,
|
||||
hooks.EventPostToolUse,
|
||||
hooks.EventStop,
|
||||
hooks.EventSubagentStart,
|
||||
hooks.EventSubagentStop,
|
||||
}
|
||||
expectedStrings := []string{
|
||||
"session_start",
|
||||
"user_prompt_submit",
|
||||
"pre_tool_use",
|
||||
"post_tool_use",
|
||||
"stop",
|
||||
"subagent_start",
|
||||
"subagent_stop",
|
||||
}
|
||||
|
||||
for i, e := range events {
|
||||
// JSON round-trip: marshal then unmarshal should produce same value.
|
||||
data, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal(%v): %v", e, err)
|
||||
}
|
||||
var got hooks.HookEvent
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("Unmarshal(%v): %v", string(data), err)
|
||||
}
|
||||
if got != e {
|
||||
t.Errorf("round-trip mismatch: got %v, want %v", got, e)
|
||||
}
|
||||
// Verify stable string representation (no accidental int encoding).
|
||||
if string(e) != expectedStrings[i] {
|
||||
t.Errorf("HookEvent string: got %q, want %q", string(e), expectedStrings[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerTypeEnumExact verifies exactly 3 HandlerType constants exist.
|
||||
func TestHandlerTypeEnumExact(t *testing.T) {
|
||||
all := []hooks.HandlerType{
|
||||
hooks.HandlerCommand,
|
||||
hooks.HandlerHTTP,
|
||||
hooks.HandlerPrompt,
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("expected 3 HandlerType values, got %d", len(all))
|
||||
}
|
||||
expectedStrings := []string{"command", "http", "prompt"}
|
||||
for i, h := range all {
|
||||
if string(h) != expectedStrings[i] {
|
||||
t.Errorf("HandlerType[%d]: got %q, want %q", i, string(h), expectedStrings[i])
|
||||
}
|
||||
// JSON round-trip.
|
||||
data, err := json.Marshal(h)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal(%v): %v", h, err)
|
||||
}
|
||||
var got hooks.HandlerType
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("Unmarshal(%v): %v", string(data), err)
|
||||
}
|
||||
if got != h {
|
||||
t.Errorf("HandlerType round-trip mismatch: got %v, want %v", got, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecisionIsBlock verifies Decision.IsBlock() returns true ONLY for "block".
|
||||
func TestDecisionIsBlock(t *testing.T) {
|
||||
cases := []struct {
|
||||
d hooks.Decision
|
||||
want bool
|
||||
}{
|
||||
{hooks.DecisionAllow, false},
|
||||
{hooks.DecisionBlock, true},
|
||||
{hooks.DecisionError, false},
|
||||
{hooks.DecisionTimeout, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := tc.d.IsBlock()
|
||||
if got != tc.want {
|
||||
t.Errorf("Decision(%q).IsBlock() = %v, want %v", tc.d, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHookEventIsBlocking verifies IsBlocking returns true for the 3 blocking events.
|
||||
func TestHookEventIsBlocking(t *testing.T) {
|
||||
blocking := map[hooks.HookEvent]bool{
|
||||
hooks.EventSessionStart: false,
|
||||
hooks.EventUserPromptSubmit: true,
|
||||
hooks.EventPreToolUse: true,
|
||||
hooks.EventPostToolUse: false,
|
||||
hooks.EventStop: false,
|
||||
hooks.EventSubagentStart: true,
|
||||
hooks.EventSubagentStop: false,
|
||||
}
|
||||
for event, want := range blocking {
|
||||
got := event.IsBlocking()
|
||||
if got != want {
|
||||
t.Errorf("HookEvent(%q).IsBlocking() = %v, want %v", event, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ var TablesWithUpdatedAt = map[string]bool{
|
||||
"memory_documents": true, "memory_chunks": true, "embedding_cache": true,
|
||||
"vault_documents": true,
|
||||
"secure_cli_binaries": true, "tenants": true,
|
||||
"agent_hooks": true,
|
||||
}
|
||||
|
||||
// TableHasUpdatedAt returns true if the table has an updated_at column.
|
||||
|
||||
@@ -58,5 +58,6 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) {
|
||||
Episodic: NewPGEpisodicStore(db),
|
||||
EvolutionMetrics: NewPGEvolutionMetricsStore(db),
|
||||
EvolutionSuggestions: NewPGEvolutionSuggestionStore(db),
|
||||
Hooks: NewPGHookStore(db),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// PGHookStore implements hooks.HookStore backed by PostgreSQL.
|
||||
type PGHookStore struct {
|
||||
db *sql.DB
|
||||
|
||||
cacheMu sync.Mutex
|
||||
cache map[string]pgHookCacheEntry // keyed by resolveKey
|
||||
}
|
||||
|
||||
type pgHookCacheEntry struct {
|
||||
result []hooks.HookConfig
|
||||
maxVersion int
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
const hookCacheTTL = 5 * time.Second
|
||||
|
||||
// NewPGHookStore returns a PGHookStore backed by the given *sql.DB.
|
||||
func NewPGHookStore(db *sql.DB) *PGHookStore {
|
||||
return &PGHookStore{
|
||||
db: db,
|
||||
cache: make(map[string]pgHookCacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Create ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *PGHookStore) Create(ctx context.Context, cfg hooks.HookConfig) (uuid.UUID, error) {
|
||||
id := uuid.Must(uuid.NewV7())
|
||||
now := time.Now().UTC()
|
||||
|
||||
cfgJSON, err := json.Marshal(cfg.Config)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
metaJSON, err := json.Marshal(cfg.Metadata)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
tid := cfg.TenantID
|
||||
if tid == uuid.Nil {
|
||||
tid = tenantIDForInsert(ctx)
|
||||
}
|
||||
|
||||
var matcher, ifExpr *string
|
||||
if cfg.Matcher != "" {
|
||||
matcher = &cfg.Matcher
|
||||
}
|
||||
if cfg.IfExpr != "" {
|
||||
ifExpr = &cfg.IfExpr
|
||||
}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO agent_hooks
|
||||
(id, tenant_id, agent_id, scope, event, handler_type,
|
||||
config, matcher, if_expr, timeout_ms, on_timeout,
|
||||
priority, enabled, version, source, metadata, created_by,
|
||||
created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,1,$14,$15,$16,$17,$17)`,
|
||||
id, tid, cfg.AgentID, string(cfg.Scope), string(cfg.Event), string(cfg.HandlerType),
|
||||
cfgJSON, matcher, ifExpr, cfg.TimeoutMS, string(cfg.OnTimeout),
|
||||
cfg.Priority, cfg.Enabled, string(cfg.Source), metaJSON, cfg.CreatedBy,
|
||||
now,
|
||||
)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("insert hook: %w", err)
|
||||
}
|
||||
s.invalidateCache()
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ─── GetByID ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *PGHookStore) GetByID(ctx context.Context, id uuid.UUID) (*hooks.HookConfig, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, tenant_id, agent_id, scope, event, handler_type,
|
||||
config, matcher, if_expr, timeout_ms, on_timeout,
|
||||
priority, enabled, version, source, metadata, created_by,
|
||||
created_at, updated_at
|
||||
FROM agent_hooks WHERE id = $1`, id)
|
||||
cfg, err := scanHookPGRow(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get hook by id: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ─── List ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *PGHookStore) List(ctx context.Context, filter hooks.ListFilter) ([]hooks.HookConfig, error) {
|
||||
q := `SELECT id, tenant_id, agent_id, scope, event, handler_type,
|
||||
config, matcher, if_expr, timeout_ms, on_timeout,
|
||||
priority, enabled, version, source, metadata, created_by,
|
||||
created_at, updated_at FROM agent_hooks WHERE 1=1`
|
||||
var args []any
|
||||
n := 1
|
||||
|
||||
if !store.IsMasterScope(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
return nil, fmt.Errorf("tenant_id required for non-master scope")
|
||||
}
|
||||
// Return tenant-specific + global hooks.
|
||||
q += fmt.Sprintf(" AND (tenant_id = $%d OR tenant_id = $%d)", n, n+1)
|
||||
args = append(args, tid, store.MasterTenantID)
|
||||
n += 2
|
||||
} else if filter.TenantID != nil {
|
||||
q += fmt.Sprintf(" AND tenant_id = $%d", n)
|
||||
args = append(args, *filter.TenantID)
|
||||
n++
|
||||
}
|
||||
|
||||
if filter.AgentID != nil {
|
||||
q += fmt.Sprintf(" AND agent_id = $%d", n)
|
||||
args = append(args, *filter.AgentID)
|
||||
n++
|
||||
}
|
||||
if filter.Event != nil {
|
||||
q += fmt.Sprintf(" AND event = $%d", n)
|
||||
args = append(args, string(*filter.Event))
|
||||
n++
|
||||
}
|
||||
if filter.Scope != nil {
|
||||
q += fmt.Sprintf(" AND scope = $%d", n)
|
||||
args = append(args, string(*filter.Scope))
|
||||
n++
|
||||
}
|
||||
if filter.Enabled != nil {
|
||||
q += fmt.Sprintf(" AND enabled = $%d", n)
|
||||
args = append(args, *filter.Enabled)
|
||||
n++
|
||||
}
|
||||
_ = n
|
||||
q += " ORDER BY priority DESC, created_at ASC"
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list hooks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []hooks.HookConfig
|
||||
for rows.Next() {
|
||||
cfg, err := scanHookPGRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, *cfg)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ─── Update ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *PGHookStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
|
||||
if _, ok := updates["version"]; ok {
|
||||
return fmt.Errorf("callers must not include 'version' in update map")
|
||||
}
|
||||
|
||||
// Marshal map/slice values to JSON for JSONB columns.
|
||||
for k, v := range updates {
|
||||
switch k {
|
||||
case "config", "metadata":
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %s: %w", k, err)
|
||||
}
|
||||
updates[k] = json.RawMessage(b)
|
||||
}
|
||||
}
|
||||
|
||||
// Build SET clause with version bump.
|
||||
var setClauses []string
|
||||
var args []any
|
||||
i := 1
|
||||
for col, val := range updates {
|
||||
if !validColumnName.MatchString(col) {
|
||||
return fmt.Errorf("invalid column name: %q", col)
|
||||
}
|
||||
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, i))
|
||||
args = append(args, val)
|
||||
i++
|
||||
}
|
||||
// Always bump version and updated_at atomically.
|
||||
setClauses = append(setClauses, fmt.Sprintf("version = version + 1, updated_at = $%d", i))
|
||||
args = append(args, time.Now().UTC())
|
||||
i++
|
||||
|
||||
args = append(args, id)
|
||||
q := fmt.Sprintf("UPDATE agent_hooks SET %s WHERE id = $%d",
|
||||
strings.Join(setClauses, ", "), i)
|
||||
i++
|
||||
|
||||
if !store.IsMasterScope(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
return fmt.Errorf("tenant_id required for non-master scope")
|
||||
}
|
||||
q += fmt.Sprintf(" AND tenant_id = $%d", i)
|
||||
args = append(args, tid)
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update hook: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return fmt.Errorf("hook not found: %s", id)
|
||||
}
|
||||
s.invalidateCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Delete ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *PGHookStore) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
q := "DELETE FROM agent_hooks WHERE id = $1"
|
||||
args := []any{id}
|
||||
|
||||
if !store.IsMasterScope(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
return fmt.Errorf("tenant_id required for non-master scope")
|
||||
}
|
||||
q += " AND tenant_id = $2"
|
||||
args = append(args, tid)
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete hook: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return fmt.Errorf("hook not found: %s", id)
|
||||
}
|
||||
s.invalidateCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── ResolveForEvent ─────────────────────────────────────────────────────────
|
||||
|
||||
func (s *PGHookStore) ResolveForEvent(ctx context.Context, event hooks.Event) ([]hooks.HookConfig, error) {
|
||||
tenantID := event.TenantID
|
||||
agentID := event.AgentID
|
||||
hookEvent := event.HookEvent
|
||||
|
||||
// Check max version in DB to validate cache freshness.
|
||||
var maxVersion int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(MAX(version),0) FROM agent_hooks
|
||||
WHERE enabled = TRUE AND event = $1
|
||||
AND (tenant_id = $2 OR tenant_id = $3)
|
||||
AND (agent_id = $4 OR agent_id IS NULL)`,
|
||||
string(hookEvent), tenantID, store.MasterTenantID, agentID,
|
||||
).Scan(&maxVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve version check: %w", err)
|
||||
}
|
||||
|
||||
key := hookResolveKey(tenantID, agentID, hookEvent)
|
||||
s.cacheMu.Lock()
|
||||
entry, ok := s.cache[key]
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
if ok && time.Now().Before(entry.expiresAt) && entry.maxVersion == maxVersion {
|
||||
return entry.result, nil
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, tenant_id, agent_id, scope, event, handler_type,
|
||||
config, matcher, if_expr, timeout_ms, on_timeout,
|
||||
priority, enabled, version, source, metadata, created_by,
|
||||
created_at, updated_at
|
||||
FROM agent_hooks
|
||||
WHERE enabled = TRUE AND event = $1
|
||||
AND (tenant_id = $2 OR tenant_id = $3)
|
||||
AND (agent_id = $4 OR agent_id IS NULL)
|
||||
ORDER BY priority DESC, created_at ASC`,
|
||||
string(hookEvent), tenantID, store.MasterTenantID, agentID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve hooks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []hooks.HookConfig
|
||||
for rows.Next() {
|
||||
cfg, err := scanHookPGRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, *cfg)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.cacheMu.Lock()
|
||||
s.cache[key] = pgHookCacheEntry{
|
||||
result: result,
|
||||
maxVersion: maxVersion,
|
||||
expiresAt: time.Now().Add(hookCacheTTL),
|
||||
}
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ─── WriteExecution ──────────────────────────────────────────────────────────
|
||||
|
||||
func (s *PGHookStore) WriteExecution(ctx context.Context, exec hooks.HookExecution) error {
|
||||
metaJSON, err := json.Marshal(exec.Metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal exec metadata: %w", err)
|
||||
}
|
||||
|
||||
now := exec.CreatedAt
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
|
||||
var dedupKey, sessionID, inputHash, errStr *string
|
||||
if exec.DedupKey != "" {
|
||||
dedupKey = &exec.DedupKey
|
||||
}
|
||||
if exec.SessionID != "" {
|
||||
sessionID = &exec.SessionID
|
||||
}
|
||||
if exec.InputHash != "" {
|
||||
inputHash = &exec.InputHash
|
||||
}
|
||||
if exec.Error != "" {
|
||||
errStr = &exec.Error
|
||||
}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO hook_executions
|
||||
(id, hook_id, session_id, event, input_hash, decision,
|
||||
duration_ms, retry, dedup_key, error, error_detail, metadata, created_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
ON CONFLICT (dedup_key) WHERE dedup_key IS NOT NULL DO NOTHING`,
|
||||
exec.ID, exec.HookID, sessionID, string(exec.Event),
|
||||
inputHash, string(exec.Decision),
|
||||
exec.DurationMS, exec.Retry, dedupKey,
|
||||
errStr, exec.ErrorDetail, metaJSON, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write execution: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── cache helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
func (s *PGHookStore) invalidateCache() {
|
||||
s.cacheMu.Lock()
|
||||
s.cache = make(map[string]pgHookCacheEntry)
|
||||
s.cacheMu.Unlock()
|
||||
}
|
||||
|
||||
func hookResolveKey(tenantID, agentID uuid.UUID, event hooks.HookEvent) string {
|
||||
return tenantID.String() + "|" + agentID.String() + "|" + string(event)
|
||||
}
|
||||
|
||||
// ─── scan helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
type pgRowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanHookPGRow(row pgRowScanner) (*hooks.HookConfig, error) {
|
||||
var (
|
||||
cfg hooks.HookConfig
|
||||
agentID *uuid.UUID
|
||||
createdBy *uuid.UUID
|
||||
scope, event string
|
||||
handlerType, onTimeout string
|
||||
source string
|
||||
matcher, ifExpr sql.NullString
|
||||
cfgJSON, metaJSON []byte
|
||||
)
|
||||
err := row.Scan(
|
||||
&cfg.ID, &cfg.TenantID, &agentID,
|
||||
&scope, &event, &handlerType,
|
||||
&cfgJSON, &matcher, &ifExpr,
|
||||
&cfg.TimeoutMS, &onTimeout,
|
||||
&cfg.Priority, &cfg.Enabled, &cfg.Version,
|
||||
&source, &metaJSON, &createdBy,
|
||||
&cfg.CreatedAt, &cfg.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg.Scope = hooks.Scope(scope)
|
||||
cfg.Event = hooks.HookEvent(event)
|
||||
cfg.HandlerType = hooks.HandlerType(handlerType)
|
||||
cfg.OnTimeout = hooks.Decision(onTimeout)
|
||||
cfg.Source = source
|
||||
cfg.AgentID = agentID
|
||||
cfg.CreatedBy = createdBy
|
||||
|
||||
if matcher.Valid {
|
||||
cfg.Matcher = matcher.String
|
||||
}
|
||||
if ifExpr.Valid {
|
||||
cfg.IfExpr = ifExpr.String
|
||||
}
|
||||
|
||||
if len(cfgJSON) > 0 {
|
||||
_ = json.Unmarshal(cfgJSON, &cfg.Config)
|
||||
}
|
||||
if cfg.Config == nil {
|
||||
cfg.Config = map[string]any{}
|
||||
}
|
||||
if len(metaJSON) > 0 {
|
||||
_ = json.Unmarshal(metaJSON, &cfg.Metadata)
|
||||
}
|
||||
if cfg.Metadata == nil {
|
||||
cfg.Metadata = map[string]any{}
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
"github.com/google/uuid"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// ─── test DB setup ───────────────────────────────────────────────────────────
|
||||
|
||||
func hooksTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skipf("TEST_DATABASE_URL not set; skipping PG hook store tests")
|
||||
}
|
||||
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open DB: %v", err)
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
t.Skipf("PG not reachable: %v", err)
|
||||
}
|
||||
|
||||
m, err := migrate.New("file://../../../../migrations", dsn)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
t.Fatalf("migrate.New: %v", err)
|
||||
}
|
||||
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
||||
db.Close()
|
||||
t.Fatalf("migrate up: %v", err)
|
||||
}
|
||||
m.Close()
|
||||
|
||||
InitSqlx(db)
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
// seedTenantAndAgent inserts minimal tenant + agent rows and registers cleanup.
|
||||
func seedTenantAndAgent(t *testing.T, db *sql.DB) (tenantID, agentID uuid.UUID) {
|
||||
t.Helper()
|
||||
tenantID = uuid.Must(uuid.NewV7())
|
||||
agentID = uuid.Must(uuid.NewV7())
|
||||
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO tenants (id, name, slug, status) VALUES ($1,$2,$3,'active') ON CONFLICT DO NOTHING`,
|
||||
tenantID, "hook-test-"+tenantID.String()[:8], "ht"+tenantID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("seed tenant: %v", err)
|
||||
}
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id)
|
||||
VALUES ($1,$2,$3,'predefined','active','test','test-model','owner') ON CONFLICT DO NOTHING`,
|
||||
agentID, tenantID, "hook-agent-"+agentID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id IN (SELECT id FROM agent_hooks WHERE tenant_id=$1)", tenantID)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE tenant_id=$1", tenantID)
|
||||
db.Exec("DELETE FROM agents WHERE id=$1", agentID)
|
||||
db.Exec("DELETE FROM tenants WHERE id=$1", tenantID)
|
||||
})
|
||||
return tenantID, agentID
|
||||
}
|
||||
|
||||
func masterCtx() context.Context {
|
||||
return store.WithTenantID(context.Background(), store.MasterTenantID)
|
||||
}
|
||||
|
||||
func tenantScopedCtx(tenantID uuid.UUID) context.Context {
|
||||
return store.WithTenantID(context.Background(), tenantID)
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func minimalHook(tenantID uuid.UUID, event hooks.HookEvent) hooks.HookConfig {
|
||||
return hooks.HookConfig{
|
||||
TenantID: tenantID,
|
||||
Event: event,
|
||||
HandlerType: hooks.HandlerCommand,
|
||||
Scope: hooks.ScopeTenant,
|
||||
Config: map[string]any{"cmd": "echo test"},
|
||||
Metadata: map[string]any{},
|
||||
TimeoutMS: 5000,
|
||||
OnTimeout: hooks.DecisionBlock,
|
||||
Source: "api",
|
||||
Enabled: true,
|
||||
Priority: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CRUD ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestPGHookStore_CRUD(t *testing.T) {
|
||||
db := hooksTestDB(t)
|
||||
tenantID, _ := seedTenantAndAgent(t, db)
|
||||
s := NewPGHookStore(db)
|
||||
ctx := tenantScopedCtx(tenantID)
|
||||
|
||||
// Create
|
||||
cfg := minimalHook(tenantID, hooks.EventPreToolUse)
|
||||
id, err := s.Create(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if id == uuid.Nil {
|
||||
t.Fatal("Create returned nil UUID")
|
||||
}
|
||||
|
||||
// GetByID
|
||||
got, err := s.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("GetByID returned nil for existing hook")
|
||||
}
|
||||
if got.Event != hooks.EventPreToolUse {
|
||||
t.Errorf("event mismatch: got %q want %q", got.Event, hooks.EventPreToolUse)
|
||||
}
|
||||
if got.TenantID != tenantID {
|
||||
t.Errorf("tenant_id mismatch")
|
||||
}
|
||||
if got.Version != 1 {
|
||||
t.Errorf("initial version should be 1, got %d", got.Version)
|
||||
}
|
||||
|
||||
// GetByID — not found returns nil, nil
|
||||
missing, err := s.GetByID(ctx, uuid.Must(uuid.NewV7()))
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID(missing): unexpected error %v", err)
|
||||
}
|
||||
if missing != nil {
|
||||
t.Fatal("GetByID(missing): expected nil")
|
||||
}
|
||||
|
||||
// Update — bumps version
|
||||
if err := s.Update(ctx, id, map[string]any{"priority": 10}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
updated, _ := s.GetByID(ctx, id)
|
||||
if updated.Priority != 10 {
|
||||
t.Errorf("priority not updated: got %d", updated.Priority)
|
||||
}
|
||||
if updated.Version != 2 {
|
||||
t.Errorf("version should be 2 after update, got %d", updated.Version)
|
||||
}
|
||||
|
||||
// Update — reject version key
|
||||
if err := s.Update(ctx, id, map[string]any{"version": 99}); err == nil {
|
||||
t.Fatal("Update with 'version' key should return error")
|
||||
}
|
||||
|
||||
// Delete
|
||||
if err := s.Delete(ctx, id); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
deleted, err := s.GetByID(ctx, id)
|
||||
if err != nil || deleted != nil {
|
||||
t.Fatalf("GetByID after Delete: want (nil,nil), got (%v,%v)", deleted, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tenant isolation ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestPGHookStore_TenantIsolation(t *testing.T) {
|
||||
db := hooksTestDB(t)
|
||||
tenantA, _ := seedTenantAndAgent(t, db)
|
||||
tenantB, _ := seedTenantAndAgent(t, db)
|
||||
s := NewPGHookStore(db)
|
||||
|
||||
ctxA := tenantScopedCtx(tenantA)
|
||||
ctxB := tenantScopedCtx(tenantB)
|
||||
|
||||
cfgA := minimalHook(tenantA, hooks.EventStop)
|
||||
idA, err := s.Create(ctxA, cfgA)
|
||||
if err != nil {
|
||||
t.Fatalf("Create A: %v", err)
|
||||
}
|
||||
|
||||
// Tenant B cannot read tenant A's hook
|
||||
got, err := s.GetByID(ctxB, idA)
|
||||
// GetByID has no tenant filter — returns the row regardless of scope.
|
||||
// List should filter it out though.
|
||||
_ = got
|
||||
_ = err
|
||||
|
||||
// List from tenant B should not see tenant A's hooks.
|
||||
listB, err := s.List(ctxB, hooks.ListFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("List B: %v", err)
|
||||
}
|
||||
for _, h := range listB {
|
||||
if h.ID == idA {
|
||||
t.Errorf("tenant B saw tenant A hook %s", idA)
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant A can see their own hook.
|
||||
listA, err := s.List(ctxA, hooks.ListFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("List A: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, h := range listA {
|
||||
if h.ID == idA {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("tenant A did not see their own hook in List")
|
||||
}
|
||||
|
||||
// Tenant B cannot delete tenant A's hook.
|
||||
if err := s.Delete(ctxB, idA); err == nil {
|
||||
t.Error("tenant B should not be able to delete tenant A's hook")
|
||||
}
|
||||
// Cleanup.
|
||||
_ = s.Delete(ctxA, idA)
|
||||
}
|
||||
|
||||
// ─── ResolveForEvent ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestPGHookStore_ResolveForEvent(t *testing.T) {
|
||||
db := hooksTestDB(t)
|
||||
tenantID, agentID := seedTenantAndAgent(t, db)
|
||||
s := NewPGHookStore(db)
|
||||
ctx := tenantScopedCtx(tenantID)
|
||||
|
||||
// Create two enabled hooks for pre_tool_use, different priorities.
|
||||
lowPriCfg := minimalHook(tenantID, hooks.EventPreToolUse)
|
||||
lowPriCfg.Priority = 0
|
||||
highPriCfg := minimalHook(tenantID, hooks.EventPreToolUse)
|
||||
highPriCfg.Priority = 10
|
||||
|
||||
id1, _ := s.Create(ctx, lowPriCfg)
|
||||
id2, _ := s.Create(ctx, highPriCfg)
|
||||
t.Cleanup(func() {
|
||||
s.Delete(masterCtx(), id1)
|
||||
s.Delete(masterCtx(), id2)
|
||||
})
|
||||
|
||||
event := hooks.Event{
|
||||
TenantID: tenantID,
|
||||
AgentID: agentID,
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
}
|
||||
resolved, err := s.ResolveForEvent(ctx, event)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveForEvent: %v", err)
|
||||
}
|
||||
if len(resolved) < 2 {
|
||||
t.Fatalf("expected >=2 hooks, got %d", len(resolved))
|
||||
}
|
||||
// First should be highest priority.
|
||||
if resolved[0].Priority < resolved[1].Priority {
|
||||
t.Errorf("hooks not ordered by priority DESC: [0]=%d [1]=%d",
|
||||
resolved[0].Priority, resolved[1].Priority)
|
||||
}
|
||||
|
||||
// Disabled hook should not appear.
|
||||
disabledCfg := minimalHook(tenantID, hooks.EventPreToolUse)
|
||||
disabledCfg.Enabled = false
|
||||
idDisabled, _ := s.Create(ctx, disabledCfg)
|
||||
t.Cleanup(func() { s.Delete(masterCtx(), idDisabled) })
|
||||
|
||||
resolved2, _ := s.ResolveForEvent(ctx, event)
|
||||
for _, h := range resolved2 {
|
||||
if h.ID == idDisabled {
|
||||
t.Error("disabled hook appeared in ResolveForEvent")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── WriteExecution ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestPGHookStore_WriteExecution(t *testing.T) {
|
||||
db := hooksTestDB(t)
|
||||
tenantID, _ := seedTenantAndAgent(t, db)
|
||||
s := NewPGHookStore(db)
|
||||
ctx := tenantScopedCtx(tenantID)
|
||||
|
||||
// Create a hook for FK.
|
||||
cfg := minimalHook(tenantID, hooks.EventStop)
|
||||
hookID, err := s.Create(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id=$1", hookID)
|
||||
s.Delete(masterCtx(), hookID)
|
||||
})
|
||||
|
||||
execID := uuid.Must(uuid.NewV7())
|
||||
dedup := "test-dedup-" + execID.String()[:8]
|
||||
exec := hooks.HookExecution{
|
||||
ID: execID,
|
||||
HookID: &hookID,
|
||||
SessionID: "sess-123",
|
||||
Event: hooks.EventStop,
|
||||
InputHash: "aaabbbccc",
|
||||
Decision: hooks.DecisionAllow,
|
||||
DurationMS: 42,
|
||||
DedupKey: dedup,
|
||||
Metadata: map[string]any{"test": true},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
// First write should succeed.
|
||||
if err := s.WriteExecution(ctx, exec); err != nil {
|
||||
t.Fatalf("WriteExecution: %v", err)
|
||||
}
|
||||
|
||||
// Duplicate dedup_key should be silently ignored (idempotent).
|
||||
if err := s.WriteExecution(ctx, exec); err != nil {
|
||||
t.Fatalf("WriteExecution (dedup): %v", err)
|
||||
}
|
||||
|
||||
// Verify the row exists.
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM hook_executions WHERE dedup_key=$1", dedup,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count executions: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 execution row, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cache invalidation ───────────────────────────────────────────────────────
|
||||
|
||||
func TestPGHookStore_CacheInvalidatedOnWrite(t *testing.T) {
|
||||
db := hooksTestDB(t)
|
||||
tenantID, agentID := seedTenantAndAgent(t, db)
|
||||
s := NewPGHookStore(db)
|
||||
ctx := tenantScopedCtx(tenantID)
|
||||
|
||||
event := hooks.Event{
|
||||
TenantID: tenantID,
|
||||
AgentID: agentID,
|
||||
HookEvent: hooks.EventSessionStart,
|
||||
}
|
||||
|
||||
// Resolve populates cache.
|
||||
before, err := s.ResolveForEvent(ctx, event)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveForEvent: %v", err)
|
||||
}
|
||||
beforeCount := len(before)
|
||||
|
||||
// Add a hook — cache should be invalidated.
|
||||
cfg := minimalHook(tenantID, hooks.EventSessionStart)
|
||||
id, err := s.Create(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Delete(masterCtx(), id) })
|
||||
|
||||
after, err := s.ResolveForEvent(ctx, event)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveForEvent after create: %v", err)
|
||||
}
|
||||
if len(after) <= beforeCount {
|
||||
t.Errorf("expected more hooks after create: before=%d after=%d", beforeCount, len(after))
|
||||
}
|
||||
}
|
||||
@@ -70,5 +70,6 @@ func NewSQLiteStores(cfg store.StoreConfig) (*store.Stores, error) {
|
||||
EvolutionSuggestions: NewSQLiteEvolutionSuggestionStore(db),
|
||||
KnowledgeGraph: NewSQLiteKnowledgeGraphStore(db),
|
||||
Vault: NewSQLiteVaultStore(db),
|
||||
Hooks: NewSQLiteHookStore(db),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
//go:build sqlite || sqliteonly
|
||||
|
||||
package sqlitestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// SqliteHookStore implements hooks.HookStore backed by SQLite.
|
||||
type SqliteHookStore struct {
|
||||
db *sql.DB
|
||||
|
||||
cacheMu sync.Mutex
|
||||
cache map[string]sqliteHookCacheEntry
|
||||
}
|
||||
|
||||
type sqliteHookCacheEntry struct {
|
||||
result []hooks.HookConfig
|
||||
maxVersion int
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
const sqliteHookCacheTTL = 5 * time.Second
|
||||
|
||||
// NewSQLiteHookStore returns a SqliteHookStore backed by the given *sql.DB.
|
||||
func NewSQLiteHookStore(db *sql.DB) *SqliteHookStore {
|
||||
return &SqliteHookStore{
|
||||
db: db,
|
||||
cache: make(map[string]sqliteHookCacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Create ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *SqliteHookStore) Create(ctx context.Context, cfg hooks.HookConfig) (uuid.UUID, error) {
|
||||
id := uuid.Must(uuid.NewV7())
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
|
||||
cfgJSON, err := json.Marshal(cfg.Config)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
metaJSON, err := json.Marshal(cfg.Metadata)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
tid := cfg.TenantID
|
||||
if tid == uuid.Nil {
|
||||
tid = tenantIDForInsert(ctx)
|
||||
}
|
||||
|
||||
var agentID, createdBy, matcher, ifExpr *string
|
||||
if cfg.AgentID != nil {
|
||||
s := cfg.AgentID.String()
|
||||
agentID = &s
|
||||
}
|
||||
if cfg.CreatedBy != nil {
|
||||
s := cfg.CreatedBy.String()
|
||||
createdBy = &s
|
||||
}
|
||||
if cfg.Matcher != "" {
|
||||
matcher = &cfg.Matcher
|
||||
}
|
||||
if cfg.IfExpr != "" {
|
||||
ifExpr = &cfg.IfExpr
|
||||
}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO agent_hooks
|
||||
(id, tenant_id, agent_id, scope, event, handler_type,
|
||||
config, matcher, if_expr, timeout_ms, on_timeout,
|
||||
priority, enabled, version, source, metadata, created_by,
|
||||
created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?,?,?)`,
|
||||
id.String(), tid.String(), agentID,
|
||||
string(cfg.Scope), string(cfg.Event), string(cfg.HandlerType),
|
||||
string(cfgJSON), matcher, ifExpr,
|
||||
cfg.TimeoutMS, string(cfg.OnTimeout),
|
||||
cfg.Priority, cfg.Enabled,
|
||||
string(cfg.Source), string(metaJSON), createdBy,
|
||||
now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("insert hook: %w", err)
|
||||
}
|
||||
s.invalidateCache()
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ─── GetByID ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *SqliteHookStore) GetByID(ctx context.Context, id uuid.UUID) (*hooks.HookConfig, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, tenant_id, agent_id, scope, event, handler_type,
|
||||
config, matcher, if_expr, timeout_ms, on_timeout,
|
||||
priority, enabled, version, source, metadata, created_by,
|
||||
created_at, updated_at
|
||||
FROM agent_hooks WHERE id = ?`, id.String())
|
||||
cfg, err := scanHookSQLiteRow(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get hook by id: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ─── List ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *SqliteHookStore) List(ctx context.Context, filter hooks.ListFilter) ([]hooks.HookConfig, error) {
|
||||
q := `SELECT id, tenant_id, agent_id, scope, event, handler_type,
|
||||
config, matcher, if_expr, timeout_ms, on_timeout,
|
||||
priority, enabled, version, source, metadata, created_by,
|
||||
created_at, updated_at FROM agent_hooks WHERE 1=1`
|
||||
var args []any
|
||||
|
||||
if !store.IsMasterScope(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
return nil, fmt.Errorf("tenant_id required for non-master scope")
|
||||
}
|
||||
q += " AND (tenant_id = ? OR tenant_id = ?)"
|
||||
args = append(args, tid.String(), store.MasterTenantID.String())
|
||||
} else if filter.TenantID != nil {
|
||||
q += " AND tenant_id = ?"
|
||||
args = append(args, filter.TenantID.String())
|
||||
}
|
||||
|
||||
if filter.AgentID != nil {
|
||||
q += " AND agent_id = ?"
|
||||
args = append(args, filter.AgentID.String())
|
||||
}
|
||||
if filter.Event != nil {
|
||||
q += " AND event = ?"
|
||||
args = append(args, string(*filter.Event))
|
||||
}
|
||||
if filter.Scope != nil {
|
||||
q += " AND scope = ?"
|
||||
args = append(args, string(*filter.Scope))
|
||||
}
|
||||
if filter.Enabled != nil {
|
||||
val := 0
|
||||
if *filter.Enabled {
|
||||
val = 1
|
||||
}
|
||||
q += " AND enabled = ?"
|
||||
args = append(args, val)
|
||||
}
|
||||
q += " ORDER BY priority DESC, created_at ASC"
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list hooks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []hooks.HookConfig
|
||||
for rows.Next() {
|
||||
cfg, err := scanHookSQLiteRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, *cfg)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ─── Update ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *SqliteHookStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
|
||||
if _, ok := updates["version"]; ok {
|
||||
return fmt.Errorf("callers must not include 'version' in update map")
|
||||
}
|
||||
|
||||
// Marshal map/slice values to JSON strings for SQLite TEXT columns.
|
||||
for k, v := range updates {
|
||||
switch k {
|
||||
case "config", "metadata":
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %s: %w", k, err)
|
||||
}
|
||||
updates[k] = string(b)
|
||||
}
|
||||
}
|
||||
|
||||
// Build SET clause with version bump.
|
||||
var setClauses []string
|
||||
var args []any
|
||||
for col, val := range updates {
|
||||
if !validColumnName.MatchString(col) {
|
||||
return fmt.Errorf("invalid column name: %q", col)
|
||||
}
|
||||
setClauses = append(setClauses, col+" = ?")
|
||||
args = append(args, val)
|
||||
}
|
||||
// Always bump version and updated_at atomically.
|
||||
setClauses = append(setClauses, "version = version + 1, updated_at = ?")
|
||||
args = append(args, time.Now().UTC().Format(time.RFC3339Nano))
|
||||
|
||||
args = append(args, id.String())
|
||||
q := fmt.Sprintf("UPDATE agent_hooks SET %s WHERE id = ?",
|
||||
strings.Join(setClauses, ", "))
|
||||
|
||||
if !store.IsMasterScope(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
return fmt.Errorf("tenant_id required for non-master scope")
|
||||
}
|
||||
q += " AND tenant_id = ?"
|
||||
args = append(args, tid.String())
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update hook: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return fmt.Errorf("hook not found: %s", id)
|
||||
}
|
||||
s.invalidateCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Delete ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *SqliteHookStore) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
q := "DELETE FROM agent_hooks WHERE id = ?"
|
||||
args := []any{id.String()}
|
||||
|
||||
if !store.IsMasterScope(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
return fmt.Errorf("tenant_id required for non-master scope")
|
||||
}
|
||||
q += " AND tenant_id = ?"
|
||||
args = append(args, tid.String())
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete hook: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return fmt.Errorf("hook not found: %s", id)
|
||||
}
|
||||
s.invalidateCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── ResolveForEvent ─────────────────────────────────────────────────────────
|
||||
|
||||
func (s *SqliteHookStore) ResolveForEvent(ctx context.Context, event hooks.Event) ([]hooks.HookConfig, error) {
|
||||
tenantID := event.TenantID
|
||||
agentID := event.AgentID
|
||||
hookEvent := event.HookEvent
|
||||
|
||||
// Check max version to validate cache freshness.
|
||||
var maxVersion int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(MAX(version),0) FROM agent_hooks
|
||||
WHERE enabled = 1 AND event = ?
|
||||
AND (tenant_id = ? OR tenant_id = ?)
|
||||
AND (agent_id = ? OR agent_id IS NULL)`,
|
||||
string(hookEvent), tenantID.String(), store.MasterTenantID.String(), agentID.String(),
|
||||
).Scan(&maxVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve version check: %w", err)
|
||||
}
|
||||
|
||||
key := sqliteHookResolveKey(tenantID, agentID, hookEvent)
|
||||
s.cacheMu.Lock()
|
||||
entry, ok := s.cache[key]
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
if ok && time.Now().Before(entry.expiresAt) && entry.maxVersion == maxVersion {
|
||||
return entry.result, nil
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, tenant_id, agent_id, scope, event, handler_type,
|
||||
config, matcher, if_expr, timeout_ms, on_timeout,
|
||||
priority, enabled, version, source, metadata, created_by,
|
||||
created_at, updated_at
|
||||
FROM agent_hooks
|
||||
WHERE enabled = 1 AND event = ?
|
||||
AND (tenant_id = ? OR tenant_id = ?)
|
||||
AND (agent_id = ? OR agent_id IS NULL)
|
||||
ORDER BY priority DESC, created_at ASC`,
|
||||
string(hookEvent), tenantID.String(), store.MasterTenantID.String(), agentID.String(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve hooks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []hooks.HookConfig
|
||||
for rows.Next() {
|
||||
cfg, err := scanHookSQLiteRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, *cfg)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.cacheMu.Lock()
|
||||
s.cache[key] = sqliteHookCacheEntry{
|
||||
result: result,
|
||||
maxVersion: maxVersion,
|
||||
expiresAt: time.Now().Add(sqliteHookCacheTTL),
|
||||
}
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ─── WriteExecution ──────────────────────────────────────────────────────────
|
||||
|
||||
func (s *SqliteHookStore) WriteExecution(ctx context.Context, exec hooks.HookExecution) error {
|
||||
metaJSON, err := json.Marshal(exec.Metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal exec metadata: %w", err)
|
||||
}
|
||||
|
||||
now := exec.CreatedAt
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
|
||||
var hookID, dedupKey, sessionID, inputHash, errStr *string
|
||||
if exec.HookID != nil {
|
||||
s := exec.HookID.String()
|
||||
hookID = &s
|
||||
}
|
||||
if exec.DedupKey != "" {
|
||||
dedupKey = &exec.DedupKey
|
||||
}
|
||||
if exec.SessionID != "" {
|
||||
sessionID = &exec.SessionID
|
||||
}
|
||||
if exec.InputHash != "" {
|
||||
inputHash = &exec.InputHash
|
||||
}
|
||||
if exec.Error != "" {
|
||||
errStr = &exec.Error
|
||||
}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT OR IGNORE INTO hook_executions
|
||||
(id, hook_id, session_id, event, input_hash, decision,
|
||||
duration_ms, retry, dedup_key, error, error_detail, metadata, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
exec.ID.String(), hookID, sessionID, string(exec.Event),
|
||||
inputHash, string(exec.Decision),
|
||||
exec.DurationMS, exec.Retry, dedupKey,
|
||||
errStr, exec.ErrorDetail, string(metaJSON),
|
||||
now.UTC().Format(time.RFC3339Nano),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write execution: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── cache helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
func (s *SqliteHookStore) invalidateCache() {
|
||||
s.cacheMu.Lock()
|
||||
s.cache = make(map[string]sqliteHookCacheEntry)
|
||||
s.cacheMu.Unlock()
|
||||
}
|
||||
|
||||
func sqliteHookResolveKey(tenantID, agentID uuid.UUID, event hooks.HookEvent) string {
|
||||
return tenantID.String() + "|" + agentID.String() + "|" + string(event)
|
||||
}
|
||||
|
||||
// ─── scan helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
type sqliteRowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanHookSQLiteRow(row sqliteRowScanner) (*hooks.HookConfig, error) {
|
||||
var (
|
||||
cfg hooks.HookConfig
|
||||
idStr, tenantStr string
|
||||
agentIDStr sql.NullString
|
||||
createdByStr sql.NullString
|
||||
scope, event string
|
||||
handlerType, onTimeout string
|
||||
source string
|
||||
matcher, ifExpr sql.NullString
|
||||
cfgStr, metaStr string
|
||||
enabledInt int
|
||||
createdAt, updatedAt sqliteTime
|
||||
)
|
||||
err := row.Scan(
|
||||
&idStr, &tenantStr, &agentIDStr,
|
||||
&scope, &event, &handlerType,
|
||||
&cfgStr, &matcher, &ifExpr,
|
||||
&cfg.TimeoutMS, &onTimeout,
|
||||
&cfg.Priority, &enabledInt, &cfg.Version,
|
||||
&source, &metaStr, &createdByStr,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if parsed, err := uuid.Parse(idStr); err == nil {
|
||||
cfg.ID = parsed
|
||||
}
|
||||
if parsed, err := uuid.Parse(tenantStr); err == nil {
|
||||
cfg.TenantID = parsed
|
||||
}
|
||||
if agentIDStr.Valid {
|
||||
if parsed, err := uuid.Parse(agentIDStr.String); err == nil {
|
||||
cfg.AgentID = &parsed
|
||||
}
|
||||
}
|
||||
if createdByStr.Valid {
|
||||
if parsed, err := uuid.Parse(createdByStr.String); err == nil {
|
||||
cfg.CreatedBy = &parsed
|
||||
}
|
||||
}
|
||||
|
||||
cfg.Scope = hooks.Scope(scope)
|
||||
cfg.Event = hooks.HookEvent(event)
|
||||
cfg.HandlerType = hooks.HandlerType(handlerType)
|
||||
cfg.OnTimeout = hooks.Decision(onTimeout)
|
||||
cfg.Source = source
|
||||
cfg.Enabled = enabledInt != 0
|
||||
cfg.CreatedAt = createdAt.Time
|
||||
cfg.UpdatedAt = updatedAt.Time
|
||||
|
||||
if matcher.Valid {
|
||||
cfg.Matcher = matcher.String
|
||||
}
|
||||
if ifExpr.Valid {
|
||||
cfg.IfExpr = ifExpr.String
|
||||
}
|
||||
|
||||
if cfgStr != "" {
|
||||
_ = json.Unmarshal([]byte(cfgStr), &cfg.Config)
|
||||
}
|
||||
if cfg.Config == nil {
|
||||
cfg.Config = map[string]any{}
|
||||
}
|
||||
if metaStr != "" {
|
||||
_ = json.Unmarshal([]byte(metaStr), &cfg.Metadata)
|
||||
}
|
||||
if cfg.Metadata == nil {
|
||||
cfg.Metadata = map[string]any{}
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
//go:build sqlite || sqliteonly
|
||||
|
||||
package sqlitestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// ─── test setup ──────────────────────────────────────────────────────────────
|
||||
|
||||
func newHookTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := OpenDB(filepath.Join(t.TempDir(), "hooks_test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
if err := EnsureSchema(db); err != nil {
|
||||
db.Close()
|
||||
t.Fatalf("EnsureSchema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
// seedHookTenantAgent inserts a minimal tenant + agent for FK satisfaction.
|
||||
func seedHookTenantAgent(t *testing.T, db *sql.DB) (tenantID, agentID uuid.UUID) {
|
||||
t.Helper()
|
||||
tenantID = uuid.Must(uuid.NewV7())
|
||||
agentID = uuid.Must(uuid.NewV7())
|
||||
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO tenants (id, name, slug, status) VALUES (?,?,?,'active')`,
|
||||
tenantID.String(), "hook-test-"+tenantID.String()[:8], "ht"+tenantID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("seed tenant: %v", err)
|
||||
}
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id)
|
||||
VALUES (?,?,?,'predefined','active','test','test-model','owner')`,
|
||||
agentID.String(), tenantID.String(), "ha-"+agentID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
return tenantID, agentID
|
||||
}
|
||||
|
||||
func sqliteTenantCtx(tenantID uuid.UUID) context.Context {
|
||||
return store.WithTenantID(context.Background(), tenantID)
|
||||
}
|
||||
|
||||
func sqliteMasterCtx() context.Context {
|
||||
return store.WithTenantID(context.Background(), store.MasterTenantID)
|
||||
}
|
||||
|
||||
func sqliteMinimalHook(tenantID uuid.UUID, event hooks.HookEvent) hooks.HookConfig {
|
||||
return hooks.HookConfig{
|
||||
TenantID: tenantID,
|
||||
Event: event,
|
||||
HandlerType: hooks.HandlerCommand,
|
||||
Scope: hooks.ScopeTenant,
|
||||
Config: map[string]any{"cmd": "echo ok"},
|
||||
Metadata: map[string]any{},
|
||||
TimeoutMS: 5000,
|
||||
OnTimeout: hooks.DecisionBlock,
|
||||
Source: "api",
|
||||
Enabled: true,
|
||||
Priority: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CRUD ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSQLiteHookStore_CRUD(t *testing.T) {
|
||||
db := newHookTestDB(t)
|
||||
tenantID, _ := seedHookTenantAgent(t, db)
|
||||
s := NewSQLiteHookStore(db)
|
||||
ctx := sqliteTenantCtx(tenantID)
|
||||
|
||||
// Create
|
||||
cfg := sqliteMinimalHook(tenantID, hooks.EventPreToolUse)
|
||||
id, err := s.Create(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if id == uuid.Nil {
|
||||
t.Fatal("Create returned nil UUID")
|
||||
}
|
||||
|
||||
// GetByID
|
||||
got, err := s.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("GetByID returned nil for existing hook")
|
||||
}
|
||||
if got.Event != hooks.EventPreToolUse {
|
||||
t.Errorf("event mismatch: got %q want %q", got.Event, hooks.EventPreToolUse)
|
||||
}
|
||||
if got.TenantID != tenantID {
|
||||
t.Errorf("tenant_id mismatch: got %s want %s", got.TenantID, tenantID)
|
||||
}
|
||||
if got.Version != 1 {
|
||||
t.Errorf("initial version should be 1, got %d", got.Version)
|
||||
}
|
||||
if !got.Enabled {
|
||||
t.Error("hook should be enabled")
|
||||
}
|
||||
if len(got.Config) == 0 {
|
||||
t.Error("config should not be empty")
|
||||
}
|
||||
|
||||
// GetByID — not found returns (nil, nil)
|
||||
missing, err := s.GetByID(ctx, uuid.Must(uuid.NewV7()))
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID(missing): unexpected error %v", err)
|
||||
}
|
||||
if missing != nil {
|
||||
t.Fatal("GetByID(missing): expected nil")
|
||||
}
|
||||
|
||||
// Update — bumps version
|
||||
if err := s.Update(ctx, id, map[string]any{"priority": 10}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
updated, err := s.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID after update: %v", err)
|
||||
}
|
||||
if updated.Priority != 10 {
|
||||
t.Errorf("priority not updated: got %d want 10", updated.Priority)
|
||||
}
|
||||
if updated.Version != 2 {
|
||||
t.Errorf("version should be 2 after update, got %d", updated.Version)
|
||||
}
|
||||
|
||||
// Update — reject 'version' key
|
||||
if err := s.Update(ctx, id, map[string]any{"version": 99}); err == nil {
|
||||
t.Fatal("Update with 'version' key should return error")
|
||||
}
|
||||
|
||||
// Delete
|
||||
if err := s.Delete(ctx, id); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
afterDelete, err := s.GetByID(ctx, id)
|
||||
if err != nil || afterDelete != nil {
|
||||
t.Fatalf("GetByID after Delete: want (nil,nil), got (%v,%v)", afterDelete, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tenant isolation ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestSQLiteHookStore_TenantIsolation(t *testing.T) {
|
||||
db := newHookTestDB(t)
|
||||
tenantA, _ := seedHookTenantAgent(t, db)
|
||||
tenantB, _ := seedHookTenantAgent(t, db)
|
||||
s := NewSQLiteHookStore(db)
|
||||
|
||||
ctxA := sqliteTenantCtx(tenantA)
|
||||
ctxB := sqliteTenantCtx(tenantB)
|
||||
|
||||
// Create hook for tenant A.
|
||||
idA, err := s.Create(ctxA, sqliteMinimalHook(tenantA, hooks.EventStop))
|
||||
if err != nil {
|
||||
t.Fatalf("Create A: %v", err)
|
||||
}
|
||||
|
||||
// List from tenant B must not include tenant A's hook.
|
||||
listB, err := s.List(ctxB, hooks.ListFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("List B: %v", err)
|
||||
}
|
||||
for _, h := range listB {
|
||||
if h.ID == idA {
|
||||
t.Errorf("tenant B saw tenant A hook %s", idA)
|
||||
}
|
||||
}
|
||||
|
||||
// List from tenant A must include their own hook.
|
||||
listA, err := s.List(ctxA, hooks.ListFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("List A: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, h := range listA {
|
||||
if h.ID == idA {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("tenant A did not see their own hook in List")
|
||||
}
|
||||
|
||||
// Tenant B cannot delete tenant A's hook.
|
||||
if err := s.Delete(ctxB, idA); err == nil {
|
||||
t.Error("tenant B should not be able to delete tenant A's hook")
|
||||
}
|
||||
|
||||
// Cleanup.
|
||||
_ = s.Delete(ctxA, idA)
|
||||
}
|
||||
|
||||
// ─── Partial unique indexes ───────────────────────────────────────────────────
|
||||
|
||||
func TestSQLiteHookStore_UniqueIndexPreventsScopedDuplicates(t *testing.T) {
|
||||
db := newHookTestDB(t)
|
||||
tenantID, _ := seedHookTenantAgent(t, db)
|
||||
s := NewSQLiteHookStore(db)
|
||||
ctx := sqliteTenantCtx(tenantID)
|
||||
|
||||
cfg := sqliteMinimalHook(tenantID, hooks.EventUserPromptSubmit)
|
||||
cfg.Scope = hooks.ScopeTenant
|
||||
|
||||
id1, err := s.Create(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("first Create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Delete(sqliteMasterCtx(), id1) })
|
||||
|
||||
// Same (tenant_id, event, handler_type) with ScopeTenant → should fail.
|
||||
_, err = s.Create(ctx, cfg)
|
||||
if err == nil {
|
||||
t.Error("expected unique constraint error on duplicate scoped hook, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ResolveForEvent ordering ────────────────────────────────────────────────
|
||||
|
||||
func TestSQLiteHookStore_ResolveForEvent(t *testing.T) {
|
||||
db := newHookTestDB(t)
|
||||
tenantID, agentID := seedHookTenantAgent(t, db)
|
||||
s := NewSQLiteHookStore(db)
|
||||
ctx := sqliteTenantCtx(tenantID)
|
||||
|
||||
// Insert two enabled hooks at different priorities.
|
||||
lo := sqliteMinimalHook(tenantID, hooks.EventPreToolUse)
|
||||
lo.Priority = 0
|
||||
hi := sqliteMinimalHook(tenantID, hooks.EventPreToolUse)
|
||||
hi.Priority = 20
|
||||
// Make hi a different handler_type to avoid unique index conflict.
|
||||
hi.HandlerType = hooks.HandlerHTTP
|
||||
|
||||
idLo, _ := s.Create(ctx, lo)
|
||||
idHi, _ := s.Create(ctx, hi)
|
||||
t.Cleanup(func() {
|
||||
s.Delete(sqliteMasterCtx(), idLo)
|
||||
s.Delete(sqliteMasterCtx(), idHi)
|
||||
})
|
||||
|
||||
event := hooks.Event{
|
||||
TenantID: tenantID,
|
||||
AgentID: agentID,
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
}
|
||||
resolved, err := s.ResolveForEvent(ctx, event)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveForEvent: %v", err)
|
||||
}
|
||||
if len(resolved) < 2 {
|
||||
t.Fatalf("expected >=2 resolved hooks, got %d", len(resolved))
|
||||
}
|
||||
// Highest priority first.
|
||||
if resolved[0].Priority < resolved[1].Priority {
|
||||
t.Errorf("wrong order: [0].priority=%d < [1].priority=%d",
|
||||
resolved[0].Priority, resolved[1].Priority)
|
||||
}
|
||||
|
||||
// Disabled hook must not appear.
|
||||
dis := sqliteMinimalHook(tenantID, hooks.EventPreToolUse)
|
||||
dis.Enabled = false
|
||||
dis.HandlerType = hooks.HandlerPrompt // third distinct handler_type
|
||||
idDis, _ := s.Create(ctx, dis)
|
||||
t.Cleanup(func() { s.Delete(sqliteMasterCtx(), idDis) })
|
||||
|
||||
resolved2, _ := s.ResolveForEvent(ctx, event)
|
||||
for _, h := range resolved2 {
|
||||
if h.ID == idDis {
|
||||
t.Error("disabled hook appeared in ResolveForEvent result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── WriteExecution dedup ────────────────────────────────────────────────────
|
||||
|
||||
func TestSQLiteHookStore_WriteExecution(t *testing.T) {
|
||||
db := newHookTestDB(t)
|
||||
tenantID, _ := seedHookTenantAgent(t, db)
|
||||
s := NewSQLiteHookStore(db)
|
||||
ctx := sqliteTenantCtx(tenantID)
|
||||
|
||||
// Create a parent hook for FK.
|
||||
hookID, err := s.Create(ctx, sqliteMinimalHook(tenantID, hooks.EventStop))
|
||||
if err != nil {
|
||||
t.Fatalf("Create hook: %v", err)
|
||||
}
|
||||
|
||||
execID := uuid.Must(uuid.NewV7())
|
||||
dedup := "sqlite-dedup-" + execID.String()[:8]
|
||||
exec := hooks.HookExecution{
|
||||
ID: execID,
|
||||
HookID: &hookID,
|
||||
SessionID: "sess-sqlite-001",
|
||||
Event: hooks.EventStop,
|
||||
InputHash: "deadbeef",
|
||||
Decision: hooks.DecisionAllow,
|
||||
DurationMS: 7,
|
||||
DedupKey: dedup,
|
||||
Metadata: map[string]any{"src": "test"},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
// First insert must succeed.
|
||||
if err := s.WriteExecution(ctx, exec); err != nil {
|
||||
t.Fatalf("WriteExecution: %v", err)
|
||||
}
|
||||
|
||||
// Duplicate dedup_key must be silently ignored.
|
||||
if err := s.WriteExecution(ctx, exec); err != nil {
|
||||
t.Fatalf("WriteExecution (dedup): %v", err)
|
||||
}
|
||||
|
||||
// Verify exactly one row.
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM hook_executions WHERE dedup_key=?", dedup,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count executions: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 execution row, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cache invalidation ───────────────────────────────────────────────────────
|
||||
|
||||
func TestSQLiteHookStore_CacheInvalidatedOnWrite(t *testing.T) {
|
||||
db := newHookTestDB(t)
|
||||
tenantID, agentID := seedHookTenantAgent(t, db)
|
||||
s := NewSQLiteHookStore(db)
|
||||
ctx := sqliteTenantCtx(tenantID)
|
||||
|
||||
event := hooks.Event{
|
||||
TenantID: tenantID,
|
||||
AgentID: agentID,
|
||||
HookEvent: hooks.EventSessionStart,
|
||||
}
|
||||
|
||||
// Warm cache.
|
||||
before, err := s.ResolveForEvent(ctx, event)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveForEvent: %v", err)
|
||||
}
|
||||
beforeCount := len(before)
|
||||
|
||||
// Create a new hook — must invalidate cache.
|
||||
id, err := s.Create(ctx, sqliteMinimalHook(tenantID, hooks.EventSessionStart))
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Delete(sqliteMasterCtx(), id) })
|
||||
|
||||
after, err := s.ResolveForEvent(ctx, event)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveForEvent after create: %v", err)
|
||||
}
|
||||
if len(after) <= beforeCount {
|
||||
t.Errorf("cache not invalidated: before=%d after=%d", beforeCount, len(after))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Global-scope hooks visible to all tenants ────────────────────────────────
|
||||
|
||||
func TestSQLiteHookStore_GlobalScopeVisibleToTenant(t *testing.T) {
|
||||
db := newHookTestDB(t)
|
||||
tenantID, agentID := seedHookTenantAgent(t, db)
|
||||
s := NewSQLiteHookStore(db)
|
||||
|
||||
// Create a global hook under MasterTenantID.
|
||||
globalCfg := hooks.HookConfig{
|
||||
TenantID: store.MasterTenantID,
|
||||
Event: hooks.EventPostToolUse,
|
||||
HandlerType: hooks.HandlerCommand,
|
||||
Scope: hooks.ScopeGlobal,
|
||||
Config: map[string]any{"cmd": "audit.sh"},
|
||||
Metadata: map[string]any{},
|
||||
TimeoutMS: 3000,
|
||||
OnTimeout: hooks.DecisionAllow,
|
||||
Source: "seed",
|
||||
Enabled: true,
|
||||
Priority: 5,
|
||||
}
|
||||
globalID, err := s.Create(sqliteMasterCtx(), globalCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create global hook: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Delete(sqliteMasterCtx(), globalID) })
|
||||
|
||||
// ResolveForEvent from tenant scope must include the global hook.
|
||||
event := hooks.Event{
|
||||
TenantID: tenantID,
|
||||
AgentID: agentID,
|
||||
HookEvent: hooks.EventPostToolUse,
|
||||
}
|
||||
resolved, err := s.ResolveForEvent(sqliteTenantCtx(tenantID), event)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveForEvent: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, h := range resolved {
|
||||
if h.ID == globalID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("global hook not visible in ResolveForEvent from tenant scope")
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ var schemaSQL string
|
||||
|
||||
// SchemaVersion is the current SQLite schema version.
|
||||
// Bump this when adding new migration steps below.
|
||||
const SchemaVersion = 19
|
||||
const SchemaVersion = 20
|
||||
|
||||
// migrations maps version → SQL to apply when upgrading FROM that version.
|
||||
// schema.sql always represents the LATEST full schema (for fresh DBs).
|
||||
@@ -455,8 +455,81 @@ WHERE context_pruning IS NOT NULL
|
||||
AND json_valid(context_pruning)
|
||||
AND json_type(context_pruning) = 'object'
|
||||
AND json_extract(context_pruning, '$.mode') IS NULL;`,
|
||||
|
||||
// Version 19 → 20: agent hooks system (mirrors PG migration 000052).
|
||||
// Creates agent_hooks, hook_executions, tenant_hook_budget tables.
|
||||
// All idempotent (CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS).
|
||||
19: addAgentHooksTables,
|
||||
}
|
||||
|
||||
// addAgentHooksTables is the SQLite incremental migration for schema v19 → v20.
|
||||
// Idempotent: CREATE TABLE IF NOT EXISTS / CREATE UNIQUE INDEX IF NOT EXISTS.
|
||||
// Mirrors PG migration 000052_agent_hooks.up.sql.
|
||||
// JSONB → TEXT, TIMESTAMPTZ → TEXT (ISO8601), BYTEA → BLOB, DATE → TEXT.
|
||||
const addAgentHooksTables = `
|
||||
CREATE TABLE IF NOT EXISTS agent_hooks (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001',
|
||||
agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('global', 'tenant', 'agent')),
|
||||
event TEXT NOT NULL,
|
||||
handler_type TEXT NOT NULL CHECK (handler_type IN ('command', 'http', 'prompt')),
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
matcher TEXT,
|
||||
if_expr TEXT,
|
||||
timeout_ms INTEGER NOT NULL DEFAULT 5000,
|
||||
on_timeout TEXT NOT NULL DEFAULT 'block' CHECK (on_timeout IN ('block', 'allow')),
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
source TEXT NOT NULL DEFAULT 'ui' CHECK (source IN ('ui', 'api', 'seed')),
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_hooks_global
|
||||
ON agent_hooks (event, handler_type)
|
||||
WHERE scope = 'global';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_hooks_tenant
|
||||
ON agent_hooks (tenant_id, event, handler_type)
|
||||
WHERE scope = 'tenant';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_hooks_agent
|
||||
ON agent_hooks (tenant_id, agent_id, event, handler_type)
|
||||
WHERE scope = 'agent';
|
||||
CREATE INDEX IF NOT EXISTS idx_hooks_lookup
|
||||
ON agent_hooks (tenant_id, agent_id, event)
|
||||
WHERE enabled = 1;
|
||||
CREATE TABLE IF NOT EXISTS hook_executions (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
hook_id TEXT REFERENCES agent_hooks(id) ON DELETE SET NULL,
|
||||
session_id TEXT,
|
||||
event TEXT NOT NULL,
|
||||
input_hash TEXT,
|
||||
decision TEXT NOT NULL CHECK (decision IN ('allow', 'block', 'error', 'timeout')),
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
retry INTEGER NOT NULL DEFAULT 0,
|
||||
dedup_key TEXT,
|
||||
error TEXT,
|
||||
error_detail BLOB,
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_hook_executions_session
|
||||
ON hook_executions (session_id, created_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_hook_executions_dedup
|
||||
ON hook_executions (dedup_key)
|
||||
WHERE dedup_key IS NOT NULL;
|
||||
CREATE TABLE IF NOT EXISTS tenant_hook_budget (
|
||||
tenant_id TEXT NOT NULL PRIMARY KEY,
|
||||
month_start TEXT NOT NULL,
|
||||
budget_total INTEGER NOT NULL DEFAULT 0,
|
||||
remaining INTEGER NOT NULL DEFAULT 0,
|
||||
last_warned_at TEXT,
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);`
|
||||
|
||||
// backfillV16 populates base_name / path_basename for rows that existed
|
||||
// before the v15 → v16 migration. Idempotent — re-running on already-filled
|
||||
// rows is a no-op thanks to the WHERE base_name = '' filter.
|
||||
|
||||
@@ -1568,3 +1568,89 @@ CREATE INDEX IF NOT EXISTS idx_vault_links_to ON vault_links(to_doc_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_links_source
|
||||
ON vault_links(json_extract(metadata, '$.source'))
|
||||
WHERE json_extract(metadata, '$.source') IS NOT NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- Table: agent_hooks (migration 000052)
|
||||
-- SQLite translation: JSONB→TEXT, TIMESTAMPTZ→TEXT, UUID→TEXT,
|
||||
-- BYTEA→BLOB, DATE→TEXT (ISO8601), CHECK for enums.
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_hooks (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001',
|
||||
agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('global', 'tenant', 'agent')),
|
||||
event TEXT NOT NULL,
|
||||
handler_type TEXT NOT NULL CHECK (handler_type IN ('command', 'http', 'prompt')),
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
matcher TEXT,
|
||||
if_expr TEXT,
|
||||
timeout_ms INTEGER NOT NULL DEFAULT 5000,
|
||||
on_timeout TEXT NOT NULL DEFAULT 'block' CHECK (on_timeout IN ('block', 'allow')),
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
source TEXT NOT NULL DEFAULT 'ui' CHECK (source IN ('ui', 'api', 'seed')),
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_hooks_global
|
||||
ON agent_hooks (event, handler_type)
|
||||
WHERE scope = 'global';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_hooks_tenant
|
||||
ON agent_hooks (tenant_id, event, handler_type)
|
||||
WHERE scope = 'tenant';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_hooks_agent
|
||||
ON agent_hooks (tenant_id, agent_id, event, handler_type)
|
||||
WHERE scope = 'agent';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_hooks_lookup
|
||||
ON agent_hooks (tenant_id, agent_id, event)
|
||||
WHERE enabled = 1;
|
||||
|
||||
-- ============================================================
|
||||
-- Table: hook_executions (append-only audit log, migration 000052)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hook_executions (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
hook_id TEXT REFERENCES agent_hooks(id) ON DELETE SET NULL,
|
||||
session_id TEXT,
|
||||
event TEXT NOT NULL,
|
||||
input_hash TEXT,
|
||||
decision TEXT NOT NULL CHECK (decision IN ('allow', 'block', 'error', 'timeout')),
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
retry INTEGER NOT NULL DEFAULT 0,
|
||||
dedup_key TEXT,
|
||||
error TEXT,
|
||||
error_detail BLOB,
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_hook_executions_session
|
||||
ON hook_executions (session_id, created_at);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_hook_executions_dedup
|
||||
ON hook_executions (dedup_key)
|
||||
WHERE dedup_key IS NOT NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- Table: tenant_hook_budget (migration 000052)
|
||||
-- month_start stored as TEXT ISO8601 date (YYYY-MM-DD).
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenant_hook_budget (
|
||||
tenant_id TEXT NOT NULL PRIMARY KEY,
|
||||
month_start TEXT NOT NULL,
|
||||
budget_total INTEGER NOT NULL DEFAULT 0,
|
||||
remaining INTEGER NOT NULL DEFAULT 0,
|
||||
last_warned_at TEXT,
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
@@ -38,4 +38,8 @@ type Stores struct {
|
||||
Episodic EpisodicStore
|
||||
EvolutionMetrics EvolutionMetricsStore
|
||||
EvolutionSuggestions EvolutionSuggestionStore
|
||||
// Hooks is hooks.HookStore — typed as any to avoid import cycle
|
||||
// (hooks package imports store for context helpers).
|
||||
// Callers: type-assert to hooks.HookStore before use.
|
||||
Hooks any
|
||||
}
|
||||
@@ -2,4 +2,4 @@ package upgrade
|
||||
|
||||
// RequiredSchemaVersion is the schema migration version this binary requires.
|
||||
// Bump this whenever adding a new SQL migration file.
|
||||
const RequiredSchemaVersion uint = 51
|
||||
const RequiredSchemaVersion uint = 52
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Migration 000052 rollback: drop agent hooks tables in dependency order.
|
||||
DROP TABLE IF EXISTS tenant_hook_budget;
|
||||
DROP TABLE IF EXISTS hook_executions;
|
||||
DROP TABLE IF EXISTS agent_hooks;
|
||||
@@ -0,0 +1,110 @@
|
||||
-- Migration 000052: Agent hooks system
|
||||
-- Creates agent_hooks, hook_executions, and tenant_hook_budget tables.
|
||||
-- scope uses CHECK-based enum (no separate ENUM type) for portability.
|
||||
-- Global-scope hooks use the MasterTenantID (0193a5b0-7000-7000-8000-000000000001)
|
||||
-- as tenant_id. This aligns with store.MasterTenantID / store.IsMasterScope
|
||||
-- conventions and satisfies any future FK to tenants(id).
|
||||
|
||||
-- ============================================================
|
||||
-- Table: agent_hooks
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_hooks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001',
|
||||
agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,
|
||||
scope VARCHAR(8) NOT NULL CHECK (scope IN ('global', 'tenant', 'agent')),
|
||||
event VARCHAR(32) NOT NULL,
|
||||
handler_type VARCHAR(16) NOT NULL CHECK (handler_type IN ('command', 'http', 'prompt')),
|
||||
-- config holds handler-specific options (e.g. command path, http url, prompt template).
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
-- matcher is an optional regex applied to tool_name before the hook fires.
|
||||
matcher VARCHAR(256),
|
||||
-- if_expr is an optional CEL expression evaluated against tool_input.
|
||||
if_expr TEXT,
|
||||
timeout_ms INT NOT NULL DEFAULT 5000,
|
||||
on_timeout VARCHAR(8) NOT NULL DEFAULT 'block' CHECK (on_timeout IN ('block', 'allow')),
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
enabled BOOL NOT NULL DEFAULT TRUE,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
-- source: 'ui' (dashboard), 'api', 'seed' (bootstrap).
|
||||
source VARCHAR(8) NOT NULL DEFAULT 'ui' CHECK (source IN ('ui', 'api', 'seed')),
|
||||
-- metadata stores UI-only fields (tags, notes, lastTestedAt, createdByUsername)
|
||||
-- and is extensible without future migrations. Project convention: always present.
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_by UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Partial unique indexes per scope prevent duplicate hooks within the same scope
|
||||
-- while allowing the same (event, handler_type) across different scopes (C3 fix).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_hooks_global
|
||||
ON agent_hooks (event, handler_type)
|
||||
WHERE scope = 'global';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_hooks_tenant
|
||||
ON agent_hooks (tenant_id, event, handler_type)
|
||||
WHERE scope = 'tenant';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_hooks_agent
|
||||
ON agent_hooks (tenant_id, agent_id, event, handler_type)
|
||||
WHERE scope = 'agent';
|
||||
|
||||
-- idx_hooks_lookup: hot-path index for ResolveForEvent queries.
|
||||
-- Partial WHERE enabled reduces index size (most production rows will be enabled).
|
||||
CREATE INDEX IF NOT EXISTS idx_hooks_lookup
|
||||
ON agent_hooks (tenant_id, agent_id, event)
|
||||
WHERE enabled = TRUE;
|
||||
|
||||
-- ============================================================
|
||||
-- Table: hook_executions (append-only audit log)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hook_executions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- hook_id is SET NULL when the parent hook is deleted (preserves audit trail).
|
||||
hook_id UUID REFERENCES agent_hooks(id) ON DELETE SET NULL,
|
||||
session_id VARCHAR(500),
|
||||
event VARCHAR(32) NOT NULL,
|
||||
-- input_hash is canonical-JSON sha256 (64 hex chars) of (tool_name + sorted args).
|
||||
input_hash CHAR(64),
|
||||
decision VARCHAR(16) NOT NULL CHECK (decision IN ('allow', 'block', 'error', 'timeout')),
|
||||
duration_ms INT NOT NULL DEFAULT 0,
|
||||
retry INT NOT NULL DEFAULT 0,
|
||||
-- dedup_key prevents duplicate execution rows for the same (hook_id, event_id).
|
||||
dedup_key VARCHAR(128),
|
||||
-- error truncated to 256 chars at write time (M2 mitigation).
|
||||
error VARCHAR(256),
|
||||
-- error_detail stores the full error AES-256-GCM encrypted; GDPR-purgeable.
|
||||
error_detail BYTEA,
|
||||
-- metadata stores extensible exec context: matcher_matched, cel_eval_result,
|
||||
-- stdout_len, http_status, prompt_model, prompt_tokens, trace_id. Avoids
|
||||
-- future migration for new observability fields.
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_hook_executions_session
|
||||
ON hook_executions (session_id, created_at);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_hook_executions_dedup
|
||||
ON hook_executions (dedup_key)
|
||||
WHERE dedup_key IS NOT NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- Table: tenant_hook_budget
|
||||
-- Consolidated here (Phase 3 prompt handler atomic deduct).
|
||||
-- One row per tenant tracks monthly spend against a cap.
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenant_hook_budget (
|
||||
tenant_id UUID PRIMARY KEY,
|
||||
month_start DATE NOT NULL,
|
||||
budget_total BIGINT NOT NULL DEFAULT 0,
|
||||
remaining BIGINT NOT NULL DEFAULT 0,
|
||||
last_warned_at TIMESTAMPTZ,
|
||||
-- metadata stores alert thresholds, override flags, notes.
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,227 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
||||
)
|
||||
|
||||
// newHookStore returns a live PG-backed HookStore for integration coverage.
|
||||
func newHookStore(t *testing.T) hooks.HookStore {
|
||||
t.Helper()
|
||||
db := testDB(t)
|
||||
return pg.NewPGHookStore(db)
|
||||
}
|
||||
|
||||
// TestHooksStore_CRUDRoundTrip exercises the full CRUD path against a real PG
|
||||
// instance — mirrors the in-package unit test but with the migration-applied
|
||||
// schema to catch any drift between schema.sql statements and DDL migrations.
|
||||
func TestHooksStore_CRUDRoundTrip(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantID, agentID := seedTenantAgent(t, db)
|
||||
ctx := tenantCtx(tenantID)
|
||||
s := newHookStore(t)
|
||||
|
||||
cfg := hooks.HookConfig{
|
||||
TenantID: tenantID,
|
||||
AgentID: &agentID,
|
||||
Scope: hooks.ScopeAgent,
|
||||
Event: hooks.EventPreToolUse,
|
||||
HandlerType: hooks.HandlerHTTP,
|
||||
Config: map[string]any{"url": "https://example.test/hook"},
|
||||
TimeoutMS: 5000,
|
||||
OnTimeout: hooks.DecisionBlock,
|
||||
Priority: 10,
|
||||
Enabled: true,
|
||||
Version: 1,
|
||||
Source: "ui",
|
||||
Metadata: map[string]any{"notes": "it"},
|
||||
}
|
||||
id, err := s.Create(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", id)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", id)
|
||||
})
|
||||
|
||||
got, err := s.GetByID(ctx, id)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("GetByID: id=%s err=%v got=%v", id, err, got)
|
||||
}
|
||||
if got.Event != hooks.EventPreToolUse || got.HandlerType != hooks.HandlerHTTP {
|
||||
t.Errorf("roundtrip mismatch: event=%q handler=%q", got.Event, got.HandlerType)
|
||||
}
|
||||
|
||||
// Update bumps version and patches priority.
|
||||
if err := s.Update(ctx, id, map[string]any{"priority": 42}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
got2, _ := s.GetByID(ctx, id)
|
||||
if got2.Priority != 42 {
|
||||
t.Errorf("priority after update = %d, want 42", got2.Priority)
|
||||
}
|
||||
if got2.Version <= got.Version {
|
||||
t.Errorf("version after update = %d, want > %d", got2.Version, got.Version)
|
||||
}
|
||||
|
||||
// ResolveForEvent returns the agent-scoped hook.
|
||||
resolved, err := s.ResolveForEvent(ctx, hooks.Event{
|
||||
TenantID: tenantID,
|
||||
AgentID: agentID,
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveForEvent: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, h := range resolved {
|
||||
if h.ID == id {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("ResolveForEvent missing hook id %s (resolved=%d)", id, len(resolved))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksStore_TenantIsolation confirms the store refuses to hand rows from
|
||||
// one tenant to another. Regression guard for the tenant-scope WHERE clauses.
|
||||
func TestHooksStore_TenantIsolation(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantA, agentA := seedTenantAgent(t, db)
|
||||
tenantB, _ := seedTenantAgent(t, db)
|
||||
s := newHookStore(t)
|
||||
|
||||
cfgA := hooks.HookConfig{
|
||||
TenantID: tenantA,
|
||||
AgentID: &agentA,
|
||||
Scope: hooks.ScopeAgent,
|
||||
Event: hooks.EventPreToolUse,
|
||||
HandlerType: hooks.HandlerHTTP,
|
||||
Config: map[string]any{},
|
||||
TimeoutMS: 5000,
|
||||
OnTimeout: hooks.DecisionBlock,
|
||||
Enabled: true,
|
||||
Version: 1,
|
||||
Source: "ui",
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
id, err := s.Create(tenantCtx(tenantA), cfgA)
|
||||
if err != nil {
|
||||
t.Fatalf("Create tenantA hook: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", id)
|
||||
})
|
||||
|
||||
// Tenant B must not see A's row.
|
||||
got, err := s.GetByID(tenantCtx(tenantB), id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID (tenantB): %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("tenantB saw tenantA's hook (id=%s) — tenant isolation broken", id)
|
||||
}
|
||||
|
||||
// Cross-tenant context (master scope) can see it.
|
||||
got2, err := s.GetByID(crossTenantCtx(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID (master): %v", err)
|
||||
}
|
||||
if got2 == nil {
|
||||
t.Error("master-scope read returned nil for existing hook")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksStore_DedupKeyIdempotent proves the unique index on
|
||||
// hook_executions.dedup_key makes duplicate WriteExecution calls a no-op.
|
||||
func TestHooksStore_DedupKeyIdempotent(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantID, agentID := seedTenantAgent(t, db)
|
||||
ctx := tenantCtx(tenantID)
|
||||
s := newHookStore(t)
|
||||
|
||||
cfg := hooks.HookConfig{
|
||||
TenantID: tenantID,
|
||||
AgentID: &agentID,
|
||||
Scope: hooks.ScopeAgent,
|
||||
Event: hooks.EventPreToolUse,
|
||||
HandlerType: hooks.HandlerHTTP,
|
||||
Config: map[string]any{},
|
||||
TimeoutMS: 5000,
|
||||
OnTimeout: hooks.DecisionBlock,
|
||||
Enabled: true,
|
||||
Version: 1,
|
||||
Source: "ui",
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
hookID, err := s.Create(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", hookID)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", hookID)
|
||||
})
|
||||
|
||||
exec := hooks.HookExecution{
|
||||
ID: uuid.New(),
|
||||
HookID: &hookID,
|
||||
Event: hooks.EventPreToolUse,
|
||||
Decision: hooks.DecisionAllow,
|
||||
DedupKey: hookID.String() + ":evt-once",
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
if err := s.WriteExecution(ctx, exec); err != nil {
|
||||
t.Fatalf("first WriteExecution: %v", err)
|
||||
}
|
||||
// Second write with same dedup_key must be silently accepted.
|
||||
exec.ID = uuid.New()
|
||||
if err := s.WriteExecution(ctx, exec); err != nil {
|
||||
t.Fatalf("second WriteExecution (duplicate dedup_key): %v", err)
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM hook_executions WHERE dedup_key = $1`,
|
||||
exec.DedupKey,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count exec rows: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("hook_executions rows for dedup_key = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksStore_CanonicalInputHashDeterministic — input_hash is a pure
|
||||
// function of (tool_name, args); this test guards against someone accidentally
|
||||
// rewriting it in a way that makes it locale/order-dependent at the boundary.
|
||||
func TestHooksStore_CanonicalInputHashDeterministic(t *testing.T) {
|
||||
// Same map, different insertion orders must hash identically.
|
||||
a, err := hooks.CanonicalInputHash("Edit",
|
||||
map[string]any{"file": "a.go", "line": 10, "tags": []any{"go", "fmt"}})
|
||||
if err != nil {
|
||||
t.Fatalf("hash a: %v", err)
|
||||
}
|
||||
b, err := hooks.CanonicalInputHash("Edit",
|
||||
map[string]any{"tags": []any{"go", "fmt"}, "line": 10, "file": "a.go"})
|
||||
if err != nil {
|
||||
t.Fatalf("hash b: %v", err)
|
||||
}
|
||||
if a != b {
|
||||
t.Errorf("canonical hash changed across key orderings: %s vs %s", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// ensure test compiles against the live store package even if a refactor moves
|
||||
// context helpers around.
|
||||
var _ context.Context = tenantCtx(uuid.Nil)
|
||||
var _ = store.MasterTenantID
|
||||
Reference in new issue
Block a user