feat(hooks/builtin): ship pii-redactor exemplar — email + phone masking

First real builtin under Phase 04 infrastructure. Registers two DB rows
(user_prompt_submit + pre_tool_use) on startup, runs the embedded JS with
source-tier gate + dispatcher dotted-path allowlist.

- internal/hooks/builtin/pii-redactor.js (ES5.1, goja-compatible):
  handle(event) returns updatedInput only when email or E.164-ish phone
  actually matched. Redacts rawInput + toolInput.{command,query,content}.
- builtins.yaml: pii-redactor entry (version 1, priority 900, timeout 2s,
  on_timeout=allow — missing redaction < blocking conversation).
- Drop _placeholder.js; pii-redactor.js is now the first real //go:embed match.
- Export builtin.Source(name) so handler tests load embedded JS without
  going through Load()/cache state.
- Tests:
  * loader_test: registry contains pii-redactor after Load().
  * script_pii_redactor_test (handler-level, real goja runtime): email
    masked in rawInput; +E164 phone masked; toolInput.command redacted,
    toolInput.path left alone (not in mutable_fields); no PII → no
    mutation; redact(redact(s)) == redact(s).
  * dispatcher_test: 3 dotted-path walker cases (toolInput.command only
    lets that key through; rawInput-only doesn't let forged toolInput
    override original event keys; toolInput wildcard merges all keys).

Bench: ~242µs/op on 1 KiB Apple M4 Max, well under <1ms target.
This commit is contained in:
viettranx committed 2026-04-16 14:17:47 +07:00
1 parent 8ee540dc7e
commit a6592fd65f
7 files changed
+444 -8

No files matched your search

-2
View File
@@ -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.
+21 -1
View File
@@ -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."
+8
View File
@@ -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)
}
+32 -5
View File
@@ -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
+56
View File
@@ -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 };
}
+145
View File
@@ -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))
}
@@ -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)
}
}
}