diff --git a/internal/hooks/builtin/_placeholder.js b/internal/hooks/builtin/_placeholder.js deleted file mode 100644 index bf490386..00000000 --- a/internal/hooks/builtin/_placeholder.js +++ /dev/null @@ -1,2 +0,0 @@ -// Placeholder to satisfy `//go:embed *.js` until the first real builtin -// ships. Phase 05 adds pii-redactor.js and this file is removed. diff --git a/internal/hooks/builtin/builtins.yaml b/internal/hooks/builtin/builtins.yaml index bf1a13aa..7128a6d9 100644 --- a/internal/hooks/builtin/builtins.yaml +++ b/internal/hooks/builtin/builtins.yaml @@ -25,4 +25,24 @@ # Phase 04 ships this registry empty. Phase 05 adds the first exemplar # (pii-redactor). When adding a builtin, also add the .js file + remove # the _placeholder.js once at least one real source_file exists. -builtins: [] +builtins: + - id: pii-redactor + version: 1 + events: [user_prompt_submit, pre_tool_use] + scope: global + matcher: "" + if_expr: "" + timeout_ms: 2000 + # Fail-open on timeout: missing one redaction is less harmful than + # blocking the whole conversation. Operators who want strict behavior + # can toggle the row to matcher/disabled via config. + on_timeout: allow + # High priority so the redactor runs BEFORE other hooks see raw input. + priority: 900 + mutable_fields: + - rawInput + - toolInput.command + - toolInput.query + - toolInput.content + source_file: pii-redactor.js + description: "Masks email addresses and E.164-ish phone numbers in user input and common tool-input fields." diff --git a/internal/hooks/builtin/loader.go b/internal/hooks/builtin/loader.go index c689fd98..f7ee6382 100644 --- a/internal/hooks/builtin/loader.go +++ b/internal/hooks/builtin/loader.go @@ -128,3 +128,11 @@ func source(file string) ([]byte, bool) { b, ok := sourceCache[file] return b, ok } + +// Source returns the raw bytes of an embedded builtin source file without +// going through Load()/the in-memory cache. Primarily for handler tests that +// want to exercise the real embedded JS in isolation. Returns the usual +// fs.ErrNotExist when the file is missing. +func Source(name string) ([]byte, error) { + return fs.ReadFile(name) +} diff --git a/internal/hooks/builtin/loader_test.go b/internal/hooks/builtin/loader_test.go index 6f191976..d422ad90 100644 --- a/internal/hooks/builtin/loader_test.go +++ b/internal/hooks/builtin/loader_test.go @@ -6,15 +6,42 @@ import ( "github.com/google/uuid" ) -// TestLoad_EmptyYaml ensures the Phase 04 ship state (empty builtins list + -// _placeholder.js) loads without error and leaves the registry empty. -func TestLoad_EmptyYaml(t *testing.T) { +// TestLoad_ShippedRegistry verifies the baked-in builtins parse clean at +// build time. Phase 05 ships pii-redactor (2 events), so we assert the +// registry contains at least that spec and that its embedded source file +// loaded successfully. +func TestLoad_ShippedRegistry(t *testing.T) { if err := Load(); err != nil { t.Fatalf("Load: %v", err) } - if got := RegisteredSpecs(); len(got) != 0 { - t.Errorf("registered=%d, want 0 (empty builtins.yaml)", len(got)) + specs := RegisteredSpecs() + var pii *Spec + for i := range specs { + if specs[i].ID == "pii-redactor" { + pii = &specs[i] + break + } } + if pii == nil { + t.Fatalf("pii-redactor not in registry; have %v", specIDs(specs)) + } + if pii.SourceFile != "pii-redactor.js" { + t.Errorf("pii-redactor source_file=%q, want pii-redactor.js", pii.SourceFile) + } + if len(pii.Events) != 2 { + t.Errorf("pii-redactor events=%v, want 2", pii.Events) + } + if src, ok := source("pii-redactor.js"); !ok || len(src) == 0 { + t.Error("pii-redactor.js bytes not cached after Load") + } +} + +func specIDs(ss []Spec) []string { + out := make([]string, 0, len(ss)) + for _, s := range ss { + out = append(out, s.ID) + } + return out } // TestBuiltinNamespace_Stable guards against a silent namespace change that diff --git a/internal/hooks/builtin/pii-redactor.js b/internal/hooks/builtin/pii-redactor.js new file mode 100644 index 00000000..d88ea642 --- /dev/null +++ b/internal/hooks/builtin/pii-redactor.js @@ -0,0 +1,56 @@ +// Builtin: PII redactor (email + phone) +// Version: 1 — matches builtins.yaml +// Runs on: user_prompt_submit, pre_tool_use +// Mutates: rawInput, toolInput.command, toolInput.query, toolInput.content +// +// ES5.1 only — goja runtime. No let/const, no arrow functions, no template literals. + +var EMAIL_RE = /[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/g; +// E.164-ish: optional +, leading 1-9, 7-14 more digits; guarded so embedded +// digits inside long tokens (ids, paths, filenames) are left alone. +var PHONE_RE = /(^|[^0-9+])(\+?[1-9][0-9]{7,14})(?=[^0-9]|$)/g; + +function redact(s) { + if (typeof s !== "string" || s.length === 0) return s; + var out = s.replace(EMAIL_RE, "[REDACTED_EMAIL]"); + out = out.replace(PHONE_RE, function (_m, lead) { + return lead + "[REDACTED_PHONE]"; + }); + return out; +} + +function handle(event) { + var changed = false; + var updated = { toolInput: {} }; + + if (typeof event.rawInput === "string") { + var r = redact(event.rawInput); + if (r !== event.rawInput) { + updated.rawInput = r; + changed = true; + } + } + + var fields = ["command", "query", "content"]; + var ti = event.toolInput || {}; + for (var i = 0; i < fields.length; i++) { + var k = fields[i]; + var v = ti[k]; + if (typeof v === "string") { + var rv = redact(v); + if (rv !== v) { + updated.toolInput[k] = rv; + changed = true; + } + } + } + + if (!changed) { + return { decision: "allow" }; + } + var tiKeys = Object.keys(updated.toolInput); + if (tiKeys.length === 0) { + delete updated.toolInput; + } + return { decision: "allow", updatedInput: updated }; +} diff --git a/internal/hooks/dispatcher_test.go b/internal/hooks/dispatcher_test.go index 10b4914f..7c7f673a 100644 --- a/internal/hooks/dispatcher_test.go +++ b/internal/hooks/dispatcher_test.go @@ -478,3 +478,148 @@ func TestDispatcher_ScriptMutation_UISourceDenied(t *testing.T) { t.Fatalf("non-builtin mutation leaked: UpdatedRawInput=%v", *r.UpdatedRawInput) } } + +// ── Phase 05: dotted-path allowlist walker (exercises Phase-03 walker) ────── +// +// Phase 05 ships the pii-redactor whose mutable_fields are +// [rawInput, toolInput.command, toolInput.query, toolInput.content]. +// These tests drive the dispatcher's applyBuiltinMutation via a mutating +// handler + a custom lookup to prove the dotted-path and wildcard semantics +// continue to hold with that specific allowlist. No actual JS runs here. + +func TestDispatcher_Walker_DottedPathOnlyAllowedKeys(t *testing.T) { + prev := installAllowlist(t, map[string][]string{ + "pii": {"toolInput.command"}, + }) + defer prev() + + cfg := newBaseHook(hooks.HandlerScript, hooks.EventPreToolUse) + cfg.Source = hooks.SourceBuiltin + cfg.ID = allowlistID("pii") + fs := &fakeStore{hooks: []hooks.HookConfig{cfg}} + + // Handler "tries" to mutate both command (allowed) AND path (not allowed). + // The dispatcher must apply only the allowed key; path stays at its original + // event value — NOT the handler's forged override. + h := &mutatingHandler{updated: map[string]any{ + "toolInput": map[string]any{ + "command": "REDACTED", + "path": "EVIL_OVERRIDE", + }, + }} + d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{ + Store: fs, + Audit: hooks.NewAuditWriter(fs, ""), + Handlers: map[hooks.HandlerType]hooks.Handler{hooks.HandlerScript: h}, + }) + + r, err := d.Fire(context.Background(), hooks.Event{ + EventID: "e", + HookEvent: hooks.EventPreToolUse, + ToolInput: map[string]any{"path": "/safe/path", "command": "old"}, + }) + if err != nil { + t.Fatalf("Fire: %v", err) + } + if r.UpdatedToolInput == nil { + t.Fatal("walker dropped allowed key") + } + if got, _ := r.UpdatedToolInput["command"].(string); got != "REDACTED" { + t.Errorf("command=%q, want REDACTED", got) + } + if got, _ := r.UpdatedToolInput["path"].(string); got != "/safe/path" { + t.Errorf("disallowed key slipped through: path=%q, want /safe/path", got) + } +} + +func TestDispatcher_Walker_RawInputOnly_IgnoresToolInput(t *testing.T) { + prev := installAllowlist(t, map[string][]string{"raw": {"rawInput"}}) + defer prev() + + cfg := newBaseHook(hooks.HandlerScript, hooks.EventUserPromptSubmit) + cfg.Source = hooks.SourceBuiltin + cfg.ID = allowlistID("raw") + fs := &fakeStore{hooks: []hooks.HookConfig{cfg}} + + h := &mutatingHandler{updated: map[string]any{ + "rawInput": "redacted", + "toolInput": map[string]any{"command": "should-be-stripped"}, + }} + d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{ + Store: fs, + Audit: hooks.NewAuditWriter(fs, ""), + Handlers: map[hooks.HandlerType]hooks.Handler{hooks.HandlerScript: h}, + }) + + r, err := d.Fire(context.Background(), hooks.Event{ + EventID: "e", + HookEvent: hooks.EventUserPromptSubmit, + RawInput: "original", + ToolInput: map[string]any{"command": "orig"}, + }) + if err != nil { + t.Fatalf("Fire: %v", err) + } + if r.UpdatedRawInput == nil || *r.UpdatedRawInput != "redacted" { + t.Fatalf("UpdatedRawInput=%v, want 'redacted'", r.UpdatedRawInput) + } + // UpdatedToolInput may be non-nil (dispatcher returns the evMut toolInput + // once any mutation happens), but each key must still equal its original + // event value — the handler's forged "should-be-stripped" must NOT appear. + if r.UpdatedToolInput != nil { + if got, _ := r.UpdatedToolInput["command"].(string); got != "orig" { + t.Errorf("toolInput.command leaked handler forge: got %q, want orig", got) + } + } +} + +func TestDispatcher_Walker_ToolInputWildcard_MergesAll(t *testing.T) { + prev := installAllowlist(t, map[string][]string{"wild": {"toolInput"}}) + defer prev() + + cfg := newBaseHook(hooks.HandlerScript, hooks.EventPreToolUse) + cfg.Source = hooks.SourceBuiltin + cfg.ID = allowlistID("wild") + fs := &fakeStore{hooks: []hooks.HookConfig{cfg}} + + h := &mutatingHandler{updated: map[string]any{ + "toolInput": map[string]any{"a": 1, "b": 2, "c": 3}, + }} + d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{ + Store: fs, + Audit: hooks.NewAuditWriter(fs, ""), + Handlers: map[hooks.HandlerType]hooks.Handler{hooks.HandlerScript: h}, + }) + + r, err := d.Fire(context.Background(), hooks.Event{ + EventID: "e", + HookEvent: hooks.EventPreToolUse, + ToolInput: map[string]any{}, + }) + if err != nil { + t.Fatalf("Fire: %v", err) + } + for _, k := range []string{"a", "b", "c"} { + if _, ok := r.UpdatedToolInput[k]; !ok { + t.Errorf("wildcard merge dropped key %q", k) + } + } +} + +// installAllowlist swaps the dispatcher's per-id lookup for the duration of +// a test. Keys in the map are resolved via allowlistID(name). Returns the +// restore func; callers defer it. +func installAllowlist(t *testing.T, m map[string][]string) func() { + t.Helper() + ids := map[uuid.UUID][]string{} + for name, fields := range m { + ids[allowlistID(name)] = fields + } + hooks.SetBuiltinAllowlistLookup(func(id uuid.UUID) []string { return ids[id] }) + return func() { hooks.SetBuiltinAllowlistLookup(nil) } +} + +func allowlistID(name string) uuid.UUID { + // Stable deterministic UUID per label so cfg.ID matches the lookup. + return uuid.NewSHA1(uuid.NameSpaceDNS, []byte("test.allowlist/"+name)) +} diff --git a/internal/hooks/handlers/script_pii_redactor_test.go b/internal/hooks/handlers/script_pii_redactor_test.go new file mode 100644 index 00000000..ba2d45f3 --- /dev/null +++ b/internal/hooks/handlers/script_pii_redactor_test.go @@ -0,0 +1,182 @@ +package handlers + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/hooks" + "github.com/nextlevelbuilder/goclaw/internal/hooks/builtin" +) + +// End-to-end tests for the Phase-05 pii-redactor builtin. These run the REAL +// embedded JS through the REAL ScriptHandler (no stubs) so we catch any ES5.1 +// / goja regex compatibility issue at CI time. + +func piiHook(t *testing.T, event hooks.HookEvent) hooks.HookConfig { + t.Helper() + src, err := builtin.Source("pii-redactor.js") + if err != nil { + t.Fatalf("load pii-redactor.js: %v", err) + } + return hooks.HookConfig{ + ID: builtin.BuiltinEventID("pii-redactor", string(event)), + TenantID: hooks.SentinelTenantID, + Event: event, + HandlerType: hooks.HandlerScript, + Scope: hooks.ScopeGlobal, + Source: hooks.SourceBuiltin, + Config: map[string]any{"source": string(src)}, + Metadata: map[string]any{"builtin": true, "version": 1}, + TimeoutMS: 2000, + OnTimeout: hooks.DecisionAllow, + Priority: 900, + Enabled: true, + Version: 1, + } +} + +func runPII(t *testing.T, ev hooks.Event) *hooks.ScriptResult { + t.Helper() + h := newTestHandler() + res := &hooks.ScriptResult{} + ctx, cancel := context.WithTimeout(hooks.WithScriptResult(context.Background(), res), 500*time.Millisecond) + defer cancel() + dec, err := h.Execute(ctx, piiHook(t, ev.HookEvent), ev) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if dec != hooks.DecisionAllow { + t.Fatalf("decision=%v, want allow", dec) + } + return res +} + +func TestPIIRedactor_RawInputEmail(t *testing.T) { + ev := hooks.Event{ + EventID: "e", + TenantID: uuid.New(), + AgentID: uuid.New(), + HookEvent: hooks.EventUserPromptSubmit, + RawInput: "email me at user@example.com please", + } + res := runPII(t, ev) + got, _ := res.UpdatedInput["rawInput"].(string) + if got != "email me at [REDACTED_EMAIL] please" { + t.Fatalf("rawInput: got %q", got) + } +} + +func TestPIIRedactor_RawInputPhone(t *testing.T) { + ev := hooks.Event{ + EventID: "e", + HookEvent: hooks.EventUserPromptSubmit, + RawInput: "call me at +14155551212 tonight", + } + res := runPII(t, ev) + got, _ := res.UpdatedInput["rawInput"].(string) + if !strings.Contains(got, "[REDACTED_PHONE]") || strings.Contains(got, "+14155551212") { + t.Fatalf("rawInput: got %q; want phone masked", got) + } +} + +// toolInput.command is in mutable_fields → gets redacted. +func TestPIIRedactor_ToolInputCommandRedacted(t *testing.T) { + ev := hooks.Event{ + EventID: "e", + HookEvent: hooks.EventPreToolUse, + ToolName: "shell", + ToolInput: map[string]any{ + "command": "curl -d 'to=alice@co.com' https://svc", + "path": "/etc/passwd", + }, + } + res := runPII(t, ev) + ti, _ := res.UpdatedInput["toolInput"].(map[string]any) + if ti == nil { + t.Fatal("toolInput missing in updatedInput") + } + cmd, _ := ti["command"].(string) + if !strings.Contains(cmd, "[REDACTED_EMAIL]") || strings.Contains(cmd, "alice@co.com") { + t.Fatalf("command: got %q", cmd) + } + // toolInput.path is NOT in the JS's allowlist, so it stays unmutated. + if _, ok := ti["path"]; ok { + t.Fatalf("path should not be in updatedInput; got %v", ti["path"]) + } +} + +// No PII present → script returns {decision: "allow"} with no updatedInput. +func TestPIIRedactor_NoPII_NoMutation(t *testing.T) { + ev := hooks.Event{ + EventID: "e", + HookEvent: hooks.EventUserPromptSubmit, + RawInput: "how do I reverse a list in Python?", + } + res := runPII(t, ev) + if len(res.UpdatedInput) > 0 { + t.Fatalf("updatedInput non-empty on clean input: %v", res.UpdatedInput) + } +} + +// Idempotency: once redacted, running again must be a no-op. +func TestPIIRedactor_Idempotent(t *testing.T) { + once := hooks.Event{ + EventID: "e", + HookEvent: hooks.EventUserPromptSubmit, + RawInput: "user@example.com and +14155551212", + } + r1 := runPII(t, once) + redacted, _ := r1.UpdatedInput["rawInput"].(string) + if redacted == "" { + t.Fatal("first pass produced no mutation") + } + twice := hooks.Event{ + EventID: "e2", + HookEvent: hooks.EventUserPromptSubmit, + RawInput: redacted, + } + r2 := runPII(t, twice) + if len(r2.UpdatedInput) > 0 { + t.Fatalf("second pass mutated already-redacted text: %v", r2.UpdatedInput) + } +} + +// BenchmarkPIIRedactor_1KiB sanity-checks the <1ms median target. Informational. +func BenchmarkPIIRedactor_1KiB(b *testing.B) { + src, err := builtin.Source("pii-redactor.js") + if err != nil { + b.Fatalf("load: %v", err) + } + cfg := hooks.HookConfig{ + ID: builtin.BuiltinEventID("pii-redactor", "user_prompt_submit"), + TenantID: hooks.SentinelTenantID, + Event: hooks.EventUserPromptSubmit, + HandlerType: hooks.HandlerScript, + Source: hooks.SourceBuiltin, + Config: map[string]any{"source": string(src)}, + Version: 1, + Enabled: true, + } + // ~1 KiB mostly-clean text with one email at the tail. + base := strings.Repeat("the quick brown fox jumps over the lazy dog. ", 24) // 1080 bytes + payload := base[:1010] + " u@example.com" + ev := hooks.Event{ + EventID: "bench", + HookEvent: hooks.EventUserPromptSubmit, + RawInput: payload, + } + + h := NewScriptHandler(8, 4, 64) + b.ResetTimer() + for b.Loop() { + res := &hooks.ScriptResult{} + ctx := hooks.WithScriptResult(context.Background(), res) + if _, err := h.Execute(ctx, cfg, ev); err != nil { + b.Fatalf("exec: %v", err) + } + } +}