Skip to content

Redact high-entropy tokens from spoken alerts - #582

Merged
nedtwigg merged 9 commits into
mainfrom
redact-speech
Sep 7, 2026
Merged

Redact high-entropy tokens from spoken alerts#582
nedtwigg merged 9 commits into
mainfrom
redact-speech

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Sep 6, 2026

Copy link
Copy Markdown
Member

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 REDACTED locally, 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.

nedtwigg and others added 5 commits September 6, 2026 12:50
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
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 6, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: f4cedae
Status: ✅  Deploy successful!
Preview URL: https://93c856dc.mouseterm.pages.dev
Branch Preview URL: https://redact-speech.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/src/lib/redact-high-entropy.ts Outdated
Comment thread lib/src/lib/redact-high-entropy.ts Outdated

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/specs/alert.md Outdated
@nedtwigg
nedtwigg marked this pull request as ready for review September 6, 2026 20:28

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/src/lib/redact-high-entropy.ts Outdated
Comment thread lib/src/lib/redact-high-entropy.ts

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/src/lib/redact-high-entropy.test.ts
@nedtwigg
nedtwigg merged commit cbb1e9d into main Sep 7, 2026
19 checks passed
@nedtwigg
nedtwigg deleted the redact-speech branch September 7, 2026 05:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants