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:
viettranx committed 2026-04-15 14:25:59 +07:00
1 parent 86d564967f
commit 3fe5ae0b50
34 files changed
+4866 -27

No files matched your search

+2
View File
@@ -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 |
---
+2
View File
@@ -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
View File
@@ -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.
---
+4 -1
View File
@@ -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
)
+7
View File
@@ -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=
+164
View File
@@ -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
}
}
+196
View File
@@ -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)
}
+160
View File
@@ -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
}
}
+256
View File
@@ -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")
}
}
+366
View File
@@ -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)
}
}
+401
View File
@@ -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)
}
}
+28
View File
@@ -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"
}
}
+67
View File
@@ -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)
}
}
+117
View File
@@ -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
}
+186
View File
@@ -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")
}
}
+53
View File
@@ -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
}
+80
View File
@@ -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)
}
+193
View File
@@ -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.")
}
}
+195
View File
@@ -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
}
+117
View File
@@ -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)
}
}
}
+1
View File
@@ -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.
+1
View File
@@ -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
}
+445
View File
@@ -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
}
+383
View File
@@ -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))
}
}
+1
View File
@@ -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
}
+474
View File
@@ -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
}
+427
View File
@@ -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")
}
}
+74 -1
View File
@@ -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.
+86
View File
@@ -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'))
);
+4
View File
@@ -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
}
+1 -1
View File
@@ -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
+4
View File
@@ -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;
+110
View File
@@ -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()
);
+227
View File
@@ -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