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

57 lines
1.7 KiB
JavaScript

// 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 };
}