eleven / messenger Public

tools: a /regex/ personal-redaction line also matches itself literally (#1360)

A literal that happens to start and end with a slash (a path, say) was
silently reinterpreted as a pattern — and a pattern that can't match its
own text leaks it, invisibly, because the fail-closed verifier shares
the rules. Slash-wrapped lines now emit two rules: the escaped full
literal first (so whole-literal occurrences redact in one piece), then
the regex. Caught by literal grep in the 2026-07-27 accrual (3 leaked
occurrences, since republished redacted).


Claude-Session: https://claude.ai/code/session_01VonpkALPw4ryZ6DXGy6tsS
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell c29dc2d6de10800ca98733117135369453e24512 parent 37e6a11
2 files changed, +20 −7
  • docs/status.md +3 −1
  • tools/prompt-registry.mjs +17 −6
diff --git a/docs/status.md b/docs/status.md
index 4ff3bf7..7551a54 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -166,7 +166,9 @@ aggregation boundary lives in the generator and holds by construction:
Publishing a machine's registry is **that contributor's choice** — the tool
only reads the machine it runs on, and nothing here auto-collects anyone's
transcripts. Each contributor also keeps a **small personal redaction
- budget** (`--personal <file>`: literal strings or `/regex/`, one per line):
+ budget** (`--personal <file>`: literal strings or `/regex/`, one per line —
+ a `/regex/` line also matches itself literally, so a path-shaped literal is
+ never silently reinterpreted as a pattern):
things removed for personal reasons, no justification owed. They publish as
`⟦redacted: personal⟧` and are **counted per session like every other
category** — the public count is the budget's honesty mechanism; a row with
diff --git a/tools/prompt-registry.mjs b/tools/prompt-registry.mjs
index 809775e..38a50c8 100644
--- a/tools/prompt-registry.mjs
+++ b/tools/prompt-registry.mjs
@@ -22,10 +22,13 @@
// your name is the same choice as publishing at all
// --personal <file> contributor's personal redaction list: one literal
// string (or /regex/) per line, removed as
-// ⟦redacted: personal⟧. The policy allows a SMALL
-// budget of these for personal reasons; the count
-// publishes per session like every other category,
-// so the budget is kept honest in the open.
+// ⟦redacted: personal⟧. A /regex/ line also matches
+// itself literally, so a path-shaped literal is
+// never silently reinterpreted as a pattern. The
+// policy allows a SMALL budget of these for personal
+// reasons; the count publishes per session like
+// every other category, so the budget is kept
+// honest in the open.
// --sessions <file> session-level controls. Sessions are in the
// ledger by default (opting in is what contributing
// means), but per line a contributor may:
@@ -201,10 +204,18 @@ const LOCAL_MD_SENTINELS = ["sync-claude-local.sh", "CLAUDE.local.md (deploy run
// Personal redactions run FIRST so a personal phrase wins over (and isn't
// split by) the mechanical rules.
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+// A slash-wrapped line is a /regex/ — but it ALSO gets a literal rule (first,
+// so a whole-literal occurrence is redacted in one piece before the pattern
+// nibbles at substrings). A literal that merely LOOKS slash-wrapped (a path,
+// say) would otherwise be silently reinterpreted as a pattern that can miss
+// its own text — and the fail-closed verifier shares these rules, so it can't
+// catch what a rule can't match (this leaked a personal string three times
+// before the 2026-07-27 accrual caught it by literal grep).
const personalRules = argAll("--personal").flatMap((f) =>
readFileSync(f, "utf8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"))
- .map((l) => ["personal", l.length > 2 && l.startsWith("/") && l.endsWith("/")
- ? new RegExp(l.slice(1, -1), "gi") : new RegExp(escapeRe(l), "gi")]));
+ .flatMap((l) => l.length > 2 && l.startsWith("/") && l.endsWith("/")
+ ? [["personal", new RegExp(escapeRe(l), "gi")], ["personal", new RegExp(l.slice(1, -1), "gi")]]
+ : [["personal", new RegExp(escapeRe(l), "gi")]]));
const RULES = [
...personalRules,