Redact high-entropy tokens from spoken alerts - #582
Conversation
The redaction rule is already pinned by redact-high-entropy.test.ts and by `redacts whole tokens before punctuation cleanup and truncation`, the two tests docs/specs/alert.md names. Carrying a hex token through the OSC 0/2/9 title-precedence fixture added no coverage and gave that test a second, unrelated reason to fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NY3gcapRNaDynTKr7cdN7p
The hex/base32/base64 tiers were two booleans, an `!hex &&` chain, two nested ternaries, a second length constant, and a comment restating all six constants from three other lines. They are one ordered table now, scanned narrowest alphabet first, with the entropy loop extracted. Verified behavior-preserving over 400k fuzzed inputs. The tests could not distinguish the base32 tier from the base64 tier alone: every base32 row also cleared the base64 cutoff, no row sat near any cutoff, and no mixed-case token pinned the case fold. Added the cases that move each constant across its boundary — deleting the base32 tier, or moving any of the five constants, now fails a test — and replaced the four duplicate negative rows and the 260,000-character allocation with a token whose prefix scores high and whose whole does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NY3gcapRNaDynTKr7cdN7p
The bullet paraphrased the alphabet list and cutoffs the code owns, re-pointed at a symbol the section's Source of truth line already names, and restated the module doc comment's caveat. AGENTS.md house form: bolded imperative, one why clause, a (rationale) marker in place of the rest. The rationale now carries the evidence, including the measured false-positive cost the caveat only gestured at (12.3% of this repo's tracked paths redact, because `/`, `-`, and `_` are token characters) and why that trade is acceptable at a speech sink but not at a rendered one. Budget ratchets to 6600 rather than the 6650 the longer bullet needed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NY3gcapRNaDynTKr7cdN7p
Deploying mouseterm with
|
| Latest commit: |
f4cedae
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://93c856dc.mouseterm.pages.dev |
| Branch Preview URL: | https://redact-speech.mouseterm.pages.dev |
dormouse-bot
left a comment
There was a problem hiding this comment.
Reviewing as a draft — flagging anything that looks worth a quick fix. Mark ready for a full review.
Two findings, both reproduced by running redactHighEntropyTokens standalone against the tiers as written. The rationale's own numbers check out: 135/1102 tracked paths redact (12.3%) and vim lib/src/lib/redact-high-entropy.ts speaks as vim REDACTED.ts, exactly as documented.
Both are inline. The bigger one is that hyphenated hex — UUIDs above all — never redacts, because - is in the outer character class, so the whole UUID becomes one candidate and gets scored against the base64 tier's 4.0 cutoff instead of hex's 3.0. Measured over 20,000 crypto.randomUUID() values: 0 redacted. This isn't the sampling loss the rationale already accepts ("finite samples often fall below their alphabet's maximum entropy") — a v4 UUID draws from 17 effective symbols, so it caps at log2(17) ≈ 4.09 and lands under 4.0 essentially always. UUID-shaped session tokens and API keys are common enough that it's worth a decision either way; if it's deliberately out of scope, the rationale is the place to say so alongside the passphrase exclusion.
For reference, at the minimum lengths the miss rates on genuinely random tokens are also high enough to be worth knowing: 16-char hex 13.8%, 16-char base32 29.4%, 20-char base64 27.7% (20k samples each). They fall off fast — 32-char hex and 32-char base64 are both ~0.01%.
Scope observation, not a request: toPushText in lib/src/remote/burrow/push-delivery.ts carries the same terminal-supplied title to a phone's lock screen and gets no redaction. Speech is local and transient; a push notification persists in a notification shade. If the answer is "speech first, push later", worth a line in ## Future so the asymmetry is deliberate on the record.
dormouse-bot
left a comment
There was a problem hiding this comment.
Reviewing as a draft — flagging anything that looks worth a quick fix. Mark ready for a full review.
Both prior findings check out. Re-measured against the new code: 20,000 crypto.randomUUID() values now redact at a 0.01% miss rate (was 100% miss), and the separator-stripping trade costs nothing on this repo — still 135 of 1102 tracked paths, with zero paths newly redacting. CargoBuildFinished=ok speaks as REDACTED ok. The grouped-hex alphabet's nested quantifier is unambiguous (the separator class is disjoint from [0-9a-f]), so it stays linear — 180 KB of deadbeef- repeats redacts in ~5 ms, which matters because redaction runs on an uncapped terminal-supplied label before SPEECH_LIMIT applies.
I also mutation-tested the three guards this commit adds — dropping the counted.length < tier.minLength check, measuring raw length instead of normalized, and dropping the = lookahead each turn a new test red. They're really pinned, not just accompanied.
One finding, inline, and it's docs-only.
dormouse-bot
left a comment
There was a problem hiding this comment.
Two findings from a full pass, both measured by running redactHighEntropyTokens standalone at ef481b38. Neither is a defect in what the code claims — the spec says false positives and negatives remain possible — but both are large enough that I think they're worth a decision rather than absorbing into that sentence.
The first is a structural miss: hex content can only ever redact through the hex tier, because a hex-dominated string can't reach the base64 tier's 4.0-bit cutoff. That tier requires the whole candidate to be hex groups, so a bare UUID redacts ~100% of the time while pod-3f2504e0-4f89-11d3-9a0c-0305e82c3301 misses 93% of the time. It's the same shape as the grouped-hex finding you fixed in 82d952f, one level out.
The second is the false-positive side: the base32 tier's alphabet is [a-z2-7] case-insensitively, which is a superset of the English alphabet, so long CamelCase identifiers are scored against a 3.5-bit cutoff and 32.4% of this repo's 2,048 distinct ≥16-letter identifiers redact — PostgreSQLConnectionManager, BackgroundTaskScheduler, CargoBuildFinished. That's a bigger class than the 12.3% path cost the rationale documents, and it hits the labels the alarm exists to announce.
Everything else checked out. The nested quantifier in the hex alphabet stays linear (the separator class is disjoint from [0-9a-f]); the tier alphabet regexes carry no g flag, so .test has no lastIndex state to leak across calls; entropyOf's 128-entry histogram can't overflow because the outer candidate class is ASCII-only; redaction runs ahead of both the punctuation pass and SPEECH_LIMIT, as the spec bullet says, and the whole-token-not-prefix test pins it. pnpm lint:specs passes on the merged tree, the (rationale) marker resolves, and the two new rationale paragraphs carry their measured 2026-09 dates.
dormouse-bot
left a comment
There was a problem hiding this comment.
The embedded-hex fix does what it claims. Re-measured at 6f500c51 against ef481b38, 20,000 samples each: pod-<uuid> goes from 8.7% redacted to 100%, w-<uuid> from 0.7% to 99.98%, <uuid>-log from 25.0% to 100%, sess_<32hex>_v1 from 24.3% to 99.98%. The cost side stayed at zero on this repo — still 135 of 1102 tracked paths, and scanning every [A-Za-z0-9+/_-]{16,} token in every tracked file turns up exactly three tokens that newly redact, all of them the new test cases themselves. The HEX_RUNS nested quantifier stays linear because the lookbehind kills every interior start position: 188 KB of 8-b-7-d-0-c-4-e- redacts in ~12 ms, and the deliberately adversarial ('_' + 'a'×1000 + 'Z')×200 shape in ~6 ms. matchAll on the shared g-flagged HEX_RUNS can't leak lastIndex — it iterates a clone.
The rationale's new base32 numbers hold: re-measured at this head, 670 of 2077 distinct [A-Za-z]{16,} identifiers in lib/src/ redact (32.3%, against the 663/2048 = 32.4% the paragraph cites), and all three of the strings it names behave as described. pnpm lint:specs passes, though alert.md is now exactly at its 6600-word budget with no headroom.
One finding, inline: the trailing boundary guard in HEX_RUNS is the only piece of this commit no test pins.
Separately, and not a request — the increment made HEX_TIER's entry in TIERS unreachable. Any candidate matching ^HEX_GROUPS$ produces exactly one HEX_RUNS match equal to itself (the lookbehind is satisfied at offset 0 and the lookahead at end-of-string), and the loop scores it with the identical isHighEntropy(_, HEX_TIER) predicate, so the tier lookup can only re-derive an answer the loop already gave. Deleting HEX_TIER, from the array changes nothing: all 77 cases still pass, and a 400,000-string fuzz over a hex/separator-rich alphabet found zero behavioral differences. Keeping it as documentation of the tier ladder is a defensible call — worth making deliberately rather than by accident, since a reader tracing "how does a bare UUID redact?" now finds two answers.
Spoken alerts can read terminal-supplied labels containing opaque keys. Replace high-entropy hex (including prefixed UUID-style groups), base32, and base64/base64url tokens with
REDACTEDlocally, before punctuation cleanup and the speech length cap, so those transforms cannot expose token fragments.The detector is dependency-free TypeScript with alphabet-specific length and Shannon-entropy cutoffs. This is a speech-only heuristic: it can redact hashes, paths, and identifiers, and can miss secrets. Passphrase detection is out of scope.
Validation: 77 targeted redaction/speech tests, library TypeScript check, and spec lint pass. The simplify pass also ran the full library suite successfully (1,513 tests) before the final boundary-test and documentation refinements.