Files
viettranx a6592fd65f 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.
2026-04-16 14:17:47 +07:00

124 lines
3.5 KiB
Go

package builtin
import (
"testing"
"github.com/google/uuid"
)
// 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)
}
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
// would orphan every already-seeded row at the next boot.
func TestBuiltinNamespace_Stable(t *testing.T) {
want := uuid.MustParse("082ab084-a25f-52b4-a4a4-eb8a816bd9a8")
if BuiltinNamespace != want {
t.Fatalf("namespace drifted: have %s, want %s", BuiltinNamespace, want)
}
}
// TestBuiltinID_Deterministic locks in the per-id hash so that a renamed id
// gets caught in review — downgrade-safety depends on stable IDs.
func TestBuiltinID_Deterministic(t *testing.T) {
a := BuiltinID("pii-redactor")
b := BuiltinID("pii-redactor")
if a != b {
t.Fatalf("BuiltinID non-deterministic: %s vs %s", a, b)
}
c := BuiltinID("sql-guard")
if a == c {
t.Fatal("distinct ids produced equal UUID — namespace collision")
}
}
// TestBuiltinEventID_Distinct ensures each event of a multi-event spec gets
// its own row id so per-event enabled toggles don't clobber siblings.
func TestBuiltinEventID_Distinct(t *testing.T) {
a := BuiltinEventID("pii-redactor", "user_prompt_submit")
b := BuiltinEventID("pii-redactor", "pre_tool_use")
if a == b {
t.Fatal("event UUIDs collided for different events")
}
if a == BuiltinID("pii-redactor") {
t.Fatal("per-event UUID must differ from bare spec id")
}
}
// TestAllowlistFor_Unknown returns nil for ids that aren't registered.
// Dispatcher treats nil as strip → defense-in-depth against scripts with
// forged source='builtin' on a random UUID.
func TestAllowlistFor_Unknown(t *testing.T) {
if err := Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if got := AllowlistFor(uuid.New()); got != nil {
t.Errorf("unknown id → allowlist=%v, want nil", got)
}
}
func TestIsBuiltin_Unknown(t *testing.T) {
if err := Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if IsBuiltin(uuid.New()) {
t.Error("random UUID reported as builtin")
}
}
// TestMetadataVersion covers the JSONB float64 decode path plus int fast path.
func TestMetadataVersion(t *testing.T) {
cases := []struct {
name string
meta map[string]any
want int
}{
{"nil", nil, 0},
{"missing", map[string]any{}, 0},
{"int", map[string]any{"version": 3}, 3},
{"int64", map[string]any{"version": int64(4)}, 4},
{"float64", map[string]any{"version": float64(5)}, 5},
{"string-ignored", map[string]any{"version": "oops"}, 0},
}
for _, tc := range cases {
if got := metadataVersion(tc.meta); got != tc.want {
t.Errorf("%s: got %d want %d", tc.name, got, tc.want)
}
}
}