From 22d80d2c9583cd249fc188b27bf3c9a46a1e7329 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 6 Sep 2026 12:50:01 -0700 Subject: [PATCH 1/9] Redact high-entropy tokens from spoken alerts --- docs/specs/alert.md | 3 +- lib/src/lib/alert-speech.test.ts | 11 +++++-- lib/src/lib/alert-speech.ts | 5 ++- lib/src/lib/redact-high-entropy.test.ts | 42 +++++++++++++++++++++++++ lib/src/lib/redact-high-entropy.ts | 28 +++++++++++++++++ scripts/spec-word-budgets.json | 2 +- 6 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 lib/src/lib/redact-high-entropy.test.ts create mode 100644 lib/src/lib/redact-high-entropy.ts diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 812d005f..76e54582 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -281,6 +281,7 @@ Source of truth: `AlertSettings` in `lib/src/lib/alert-settings.ts` (renderer mi ### Spoken alarms +- **Must replace high-entropy ASCII tokens with `REDACTED` locally before speech punctuation cleanup or truncation.** Match hex, base32, and base64/base64url candidates using the length and Shannon-entropy cutoffs in `redactHighEntropyTokens`; redact entire matches including padding. This heuristic can redact hashes and identifiers and miss secrets; word-passphrase detection is out of scope. Pinned by `redactHighEntropyTokens` in `lib/src/lib/redact-high-entropy.test.ts` and `redacts whole tokens before punctuation cleanup and truncation` in `lib/src/lib/alert-speech.test.ts`. - **The label must be sanitized before it reaches the engine** (`toSpokenText`): all Unicode punctuation, symbols, and `Other` characters (including controls, bidi controls, and zero-width formats) become spaces, except apostrophes, which are elided so contractions survive; letters, numbers, and their combining marks from every script remain. Whitespace collapses, the result is capped in code points, and an empty result falls back to `terminal`. **Security, not tidiness:** WebKit wedges its synthesizer on angle brackets, and terminal-supplied text reaches Pane labels (rationale). - **Delivery state follows actual engine callbacks, not queue admission.** `AlertSpeechState` is a renderer-local `speaking | spoken` map keyed by Session: `start` publishes `speaking`; `end`, or `error` after a real start, publishes `spoken`; an utterance that never starts publishes neither. **Must check delivery identity before accepting `start` or completion**, including after redispatch, eviction, or teardown. Pinned by `ignores an older ring starting after a newer ring has begun speaking` and `bounds tracked utterances when the engine never calls back` in `lib/src/lib/alert-speech.test.ts`. - **Nothing in the settle path may assume the callback arrives after `speak()` returns** — an engine may dispatch `start` then `end`/`error` *synchronously* inside `speechSynthesis.speak()` (rationale). Handlers therefore close over the utterance itself and registration happens before dispatch. A dispatch the engine refuses outright settles too. @@ -290,7 +291,7 @@ Source of truth: `AlertSettings` in `lib/src/lib/alert-settings.ts` (renderer mi - **In-flight tracking is bounded.** The utterance set and queued index evict their oldest entry past a shared cap. Delivery identities retain one token per ringing Session until the ring resolves. An evicted utterance that still fires settles normally; it is no longer eligible for collateral re-dispatch. - `speaking` / `spoken` remains only while the originating Session is still `ALERT_RINGING`: any action that resolves the ring (Clearing And TODO) clears it, killing the Session included, while visibility, hover, and command-mode selection do not. **Never persist it or send it to the host**, so restore/reconnect cannot recreate it. -Source of truth: `toSpokenText` in `lib/src/lib/alert-speech.ts`, armed by `lib/src/components/wall/use-alert-speech.ts`; label derivation in `lib/src/lib/session-label.ts`; `AlertSpeechState` in `lib/src/lib/alert-speech-state.ts`. +Source of truth: `toSpokenText` in `lib/src/lib/alert-speech.ts`, armed by `lib/src/components/wall/use-alert-speech.ts`; `redactHighEntropyTokens` in `lib/src/lib/redact-high-entropy.ts`; label derivation in `lib/src/lib/session-label.ts`; `AlertSpeechState` in `lib/src/lib/alert-speech-state.ts`. ### Push notifications diff --git a/lib/src/lib/alert-speech.test.ts b/lib/src/lib/alert-speech.test.ts index 994bfb6a..d1d56816 100644 --- a/lib/src/lib/alert-speech.test.ts +++ b/lib/src/lib/alert-speech.test.ts @@ -148,6 +148,13 @@ describe('toSpokenText', () => { it('leaves an ordinary label alone', () => { expect(toSpokenText('pnpm test')).toBe('pnpm test'); }); + + it('redacts whole tokens before punctuation cleanup and truncation', () => { + expect(toSpokenText('key=k8Xq+W2m/P5rZ9vN3aT6yA== done')).toBe('key REDACTED done'); + const prefix = 'build '.repeat(18); + expect(toSpokenText(`${prefix}8b7d0c4e9f2a61035e8c9d1f04a76b23`)) + .toBe(`${prefix}REDACTED`); + }); }); /** @@ -196,7 +203,7 @@ describe('spoken alarms', () => { if (source === 'osc9') { setTerminalActivity(id, { status: 'ALERT_RINGING', - notification: { source: 'OSC 9', title: null, body: 'program title osc9' }, + notification: { source: 'OSC 9', title: null, body: 'program title osc9 key=8b7d0c4e9f2a61035e8c9d1f04a76b23' }, }); } else { setStatus(id, 'ALERT_RINGING'); @@ -207,7 +214,7 @@ describe('spoken alarms', () => { expect(spoken).toEqual([ 'program title osc0', 'program title osc2', - 'program title osc9', + 'program title osc9 key REDACTED', ]); }); diff --git a/lib/src/lib/alert-speech.ts b/lib/src/lib/alert-speech.ts index 15b237af..8752d6e5 100644 --- a/lib/src/lib/alert-speech.ts +++ b/lib/src/lib/alert-speech.ts @@ -8,6 +8,7 @@ import { } from './alert-speech-state'; import { getActivity, getActivitySnapshot, subscribeToActivity } from './session-activity-store'; import { deriveSessionLabel } from './session-label'; +import { redactHighEntropyTokens } from './redact-high-entropy'; // Speech sink and sanitizer; alert-ring-watch owns ring timing/cancellation. // Engine callbacks publish transient renderer-local delivery state. @@ -21,7 +22,9 @@ const MAX_TRACKED_UTTERANCES = 8; /** Sanitize a display label for speech. WebKit wedges on angle brackets; replace * punctuation, symbols, and controls with spaces so adjacent words do not join. */ export function toSpokenText(label: string): string { - const cleaned = label + // Detect whole tokens before punctuation splitting or the speech length cap + // can leave a secret's otherwise unrecognizable fragments in the utterance. + const cleaned = redactHighEntropyTokens(label) // Elide apostrophes so contractions stay intact: spacing `didn't` would // leave a lone `t` for the engine to announce. .replace(/['’]/gu, '') diff --git a/lib/src/lib/redact-high-entropy.test.ts b/lib/src/lib/redact-high-entropy.test.ts new file mode 100644 index 00000000..d06458f9 --- /dev/null +++ b/lib/src/lib/redact-high-entropy.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { redactHighEntropyTokens } from './redact-high-entropy'; + +describe('redactHighEntropyTokens', () => { + it.each([ + ['hex', '8b7d0c4e9f2a61035e8c9d1f04a76b23'], + ['uppercase hex', '8B7D0C4E9F2A61035E8C9D1F04A76B23'], + ['base32', 'K7QW2MXP5RZV3NAT6YHC4BSD'], + ['lowercase base32', 'k7qw2mxp5rzv3nat6yhc4bsd'], + ['padded base32', 'K7QW2MXP5RZV3NAT6YHC4BSD======'], + ['base64', 'k8Xq+W2m/P5rZ9vN3aT6yHc4BsD0EfGj'], + ['padded base64', 'k8Xq+W2m/P5rZ9vN3aT6yA=='], + ['base64url', 'k8Xq-W2m_P5rZ9vN3aT6yHc4BsD0EfGj'], + ['prefixed token', 'ghp_k8XqW2mP5rZ9vN3aT6yHc4BsD0EfGj'], + ])('redacts an entire %s token', (_kind, token) => { + expect(redactHighEntropyTokens(`key="${token}" done`)).toBe('key="REDACTED" done'); + }); + + it('replaces every occurrence while preserving surrounding text', () => { + const token = '8b7d0c4e9f2a61035e8c9d1f04a76b23'; + expect(redactHighEntropyTokens(`first=${token}; second=${token}!`)) + .toBe('first=REDACTED; second=REDACTED!'); + }); + + it.each([ + 'pnpm test: build finished', + 'internationalization configuration', + 'orchard velvet canoe lantern', + '构建完成。終了コード:0', + 'deadbeef 01234567 aB3dE6gH9jK2', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'abababababababababababababababab', + '12312312312312312312312312312312', + ])('preserves ordinary, short, or low-entropy text: %s', (text) => { + expect(redactHighEntropyTokens(text)).toBe(text); + }); + + it('handles long tokens without truncating the entropy calculation', () => { + expect(redactHighEntropyTokens('8b7d0c4e9f2a6103'.repeat(10_000))).toBe('REDACTED'); + expect(redactHighEntropyTokens('x'.repeat(100_000))).toBe('x'.repeat(100_000)); + }); +}); diff --git a/lib/src/lib/redact-high-entropy.ts b/lib/src/lib/redact-high-entropy.ts new file mode 100644 index 00000000..40064b5b --- /dev/null +++ b/lib/src/lib/redact-high-entropy.ts @@ -0,0 +1,28 @@ +/** Replace opaque ASCII tokens; this is a randomness heuristic, not a guarantee + * that all secrets (or only secrets) are removed. Runs in linear time with a + * fixed-size histogram, without dictionaries, network access, or platform APIs. */ +export function redactHighEntropyTokens(text: string): string { + return text.replace(/[A-Za-z0-9+/_-]+=*/g, (token) => { + // Keep padding in the replacement span, but not in the entropy estimate. + const value = token.replace(/=+$/, ''); + if (value.length < 16) return token; + + // Use the narrowest matching alphabet. Hex/base32 are case-insensitive; + // base64/base64url are not. Minimum lengths and bits/character cutoffs: + // hex 16 / 3.0, base32 16 / 3.5, base64 20 / 4.0. These are heuristic + // thresholds: finite samples do not reach their alphabet's maximum entropy. + const hex = /^[0-9a-f]+$/i.test(value); + const base32 = !hex && /^[a-z2-7]+$/i.test(value); + if (!hex && !base32 && value.length < 20) return token; + const normalized = hex || base32 ? value.toLowerCase() : value; + const counts = new Uint32Array(128); + for (let i = 0; i < normalized.length; i++) counts[normalized.charCodeAt(i)]++; + let entropy = 0; + for (const count of counts) { + if (count === 0) continue; + const probability = count / normalized.length; + entropy -= probability * Math.log2(probability); + } + return entropy >= (hex ? 3 : base32 ? 3.5 : 4) ? 'REDACTED' : token; + }); +} diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 63d729b7..8f991b4c 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -2,7 +2,7 @@ "AGENTS.md": 3250, "SECURITY.md": 200, "SELF_HOST.md": 6000, - "docs/specs/alert.md": 6550, + "docs/specs/alert.md": 6650, "docs/specs/auto-update.md": 1000, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4500, From a998f5d6e150020268ae619839929cf2345b4092 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 6 Sep 2026 12:53:33 -0700 Subject: [PATCH 2/9] tests: keep the OSC title-source test scoped to title sources 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) Claude-Session: https://claude.ai/code/session_01NY3gcapRNaDynTKr7cdN7p --- lib/src/lib/alert-speech.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/lib/alert-speech.test.ts b/lib/src/lib/alert-speech.test.ts index d1d56816..484c25ec 100644 --- a/lib/src/lib/alert-speech.test.ts +++ b/lib/src/lib/alert-speech.test.ts @@ -203,7 +203,7 @@ describe('spoken alarms', () => { if (source === 'osc9') { setTerminalActivity(id, { status: 'ALERT_RINGING', - notification: { source: 'OSC 9', title: null, body: 'program title osc9 key=8b7d0c4e9f2a61035e8c9d1f04a76b23' }, + notification: { source: 'OSC 9', title: null, body: 'program title osc9' }, }); } else { setStatus(id, 'ALERT_RINGING'); @@ -214,7 +214,7 @@ describe('spoken alarms', () => { expect(spoken).toEqual([ 'program title osc0', 'program title osc2', - 'program title osc9 key REDACTED', + 'program title osc9', ]); }); From b6b2ca6fa8193170c6428b4d3125e2ed0f717080 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 6 Sep 2026 13:00:49 -0700 Subject: [PATCH 3/9] redact: collapse the alphabet ladder into a tier table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01NY3gcapRNaDynTKr7cdN7p --- lib/src/lib/redact-high-entropy.test.ts | 39 ++++++++++----- lib/src/lib/redact-high-entropy.ts | 65 ++++++++++++++++--------- 2 files changed, 67 insertions(+), 37 deletions(-) diff --git a/lib/src/lib/redact-high-entropy.test.ts b/lib/src/lib/redact-high-entropy.test.ts index d06458f9..345acc9c 100644 --- a/lib/src/lib/redact-high-entropy.test.ts +++ b/lib/src/lib/redact-high-entropy.test.ts @@ -7,7 +7,6 @@ describe('redactHighEntropyTokens', () => { ['uppercase hex', '8B7D0C4E9F2A61035E8C9D1F04A76B23'], ['base32', 'K7QW2MXP5RZV3NAT6YHC4BSD'], ['lowercase base32', 'k7qw2mxp5rzv3nat6yhc4bsd'], - ['padded base32', 'K7QW2MXP5RZV3NAT6YHC4BSD======'], ['base64', 'k8Xq+W2m/P5rZ9vN3aT6yHc4BsD0EfGj'], ['padded base64', 'k8Xq+W2m/P5rZ9vN3aT6yA=='], ['base64url', 'k8Xq-W2m_P5rZ9vN3aT6yHc4BsD0EfGj'], @@ -23,20 +22,34 @@ describe('redactHighEntropyTokens', () => { }); it.each([ - 'pnpm test: build finished', - 'internationalization configuration', - 'orchard velvet canoe lantern', - '构建完成。終了コード:0', - 'deadbeef 01234567 aB3dE6gH9jK2', - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - 'abababababababababababababababab', - '12312312312312312312312312312312', - ])('preserves ordinary, short, or low-entropy text: %s', (text) => { + ['ordinary text', 'pnpm test: build finished'], + ['a long word under every entropy cutoff', 'internationalization configuration'], + ['non-ASCII text', '构建完成。終了コード:0'], + ['a uniform run', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'], + ])('preserves %s', (_kind, text) => { expect(redactHighEntropyTokens(text)).toBe(text); }); - it('handles long tokens without truncating the entropy calculation', () => { - expect(redactHighEntropyTokens('8b7d0c4e9f2a6103'.repeat(10_000))).toBe('REDACTED'); - expect(redactHighEntropyTokens('x'.repeat(100_000))).toBe('x'.repeat(100_000)); + // Each pair below moves one constant in `TIERS` across its boundary; without + // them the narrower tiers are indistinguishable from the base64 tier alone. + it.each([ + ['hex at the 3.0 cutoff', '8b7d0c4e8b7d0c4e', true], + ['hex below it', '8b7d0c4e8b7d0c4d', false], + ['16-char base32, too short for the base64 tier', 'K7QW2MXP5RZV3NAT', true], + ['16-char base32 below the 3.5 cutoff', 'k7qw2mxpk7qw2mxp', false], + ['16-char base64, too short for any tier', 'k8Xq+W2m/P5rZ9vN', false], + ])('%s: redacted=%s', (_kind, token, redacted) => { + expect(redactHighEntropyTokens(token)).toBe(redacted ? 'REDACTED' : token); + }); + + it('folds case on case-insensitive alphabets, so `A` and `a` are one symbol', () => { + // 3.5 bits counted as 16 distinct symbols, 2.75 counted as the 8 hex digits + // it actually carries — only the folded score is below the 3.0 hex cutoff. + expect(redactHighEntropyTokens('aAbBcCdDeEfF0000')).toBe('aAbBcCdDeEfF0000'); + }); + + it('scores the whole token, never a prefix', () => { + const token = `8b7d0c4e9f2a6103${'a'.repeat(10_000)}`; + expect(redactHighEntropyTokens(token)).toBe(token); }); }); diff --git a/lib/src/lib/redact-high-entropy.ts b/lib/src/lib/redact-high-entropy.ts index 40064b5b..09c40b87 100644 --- a/lib/src/lib/redact-high-entropy.ts +++ b/lib/src/lib/redact-high-entropy.ts @@ -1,28 +1,45 @@ +/** One opaque-token shape: the alphabet, the length below which a sample is too + * short to judge, and the bits/character above which it reads as random. Ordered + * narrowest alphabet first, so a token is scored against the tightest one it fits. + * The cutoffs sit below each alphabet's ceiling because a finite sample never + * reaches it (rationale). */ +interface TokenTier { + readonly alphabet: RegExp; + readonly minLength: number; + readonly minEntropy: number; + /** Fold case before counting, so `A` and `a` are one symbol of a + * case-insensitive alphabet rather than two. */ + readonly foldCase: boolean; +} + +const TIERS: readonly TokenTier[] = [ + { alphabet: /^[0-9a-f]+$/i, minLength: 16, minEntropy: 3, foldCase: true }, + { alphabet: /^[a-z2-7]+$/i, minLength: 16, minEntropy: 3.5, foldCase: true }, + { alphabet: /^[A-Za-z0-9+/_-]+$/, minLength: 20, minEntropy: 4, foldCase: false }, +]; + +/** Shannon entropy in bits per character over an ASCII histogram. */ +function entropyOf(value: string): number { + const counts = new Uint32Array(128); + for (let i = 0; i < value.length; i++) counts[value.charCodeAt(i)]++; + let entropy = 0; + for (let code = 0; code < counts.length; code++) { + const count = counts[code]; + if (count === 0) continue; + const probability = count / value.length; + entropy -= probability * Math.log2(probability); + } + return entropy; +} + /** Replace opaque ASCII tokens; this is a randomness heuristic, not a guarantee - * that all secrets (or only secrets) are removed. Runs in linear time with a - * fixed-size histogram, without dictionaries, network access, or platform APIs. */ + * that all secrets (or only secrets) are removed (rationale). Padding joins the + * replaced span but not the entropy estimate. */ export function redactHighEntropyTokens(text: string): string { - return text.replace(/[A-Za-z0-9+/_-]+=*/g, (token) => { - // Keep padding in the replacement span, but not in the entropy estimate. - const value = token.replace(/=+$/, ''); - if (value.length < 16) return token; - - // Use the narrowest matching alphabet. Hex/base32 are case-insensitive; - // base64/base64url are not. Minimum lengths and bits/character cutoffs: - // hex 16 / 3.0, base32 16 / 3.5, base64 20 / 4.0. These are heuristic - // thresholds: finite samples do not reach their alphabet's maximum entropy. - const hex = /^[0-9a-f]+$/i.test(value); - const base32 = !hex && /^[a-z2-7]+$/i.test(value); - if (!hex && !base32 && value.length < 20) return token; - const normalized = hex || base32 ? value.toLowerCase() : value; - const counts = new Uint32Array(128); - for (let i = 0; i < normalized.length; i++) counts[normalized.charCodeAt(i)]++; - let entropy = 0; - for (const count of counts) { - if (count === 0) continue; - const probability = count / normalized.length; - entropy -= probability * Math.log2(probability); - } - return entropy >= (hex ? 3 : base32 ? 3.5 : 4) ? 'REDACTED' : token; + return text.replace(/([A-Za-z0-9+/_-]{16,})=*/g, (token, value: string) => { + const tier = TIERS.find((t) => value.length >= t.minLength && t.alphabet.test(value)); + if (!tier) return token; + const counted = tier.foldCase ? value.toLowerCase() : value; + return entropyOf(counted) >= tier.minEntropy ? 'REDACTED' : token; }); } From 21dc457718f07039dc5f56b20de917b02e26a7f0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 6 Sep 2026 13:02:14 -0700 Subject: [PATCH 4/9] docs: state the redaction rule once, move its cost to the rationale 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) Claude-Session: https://claude.ai/code/session_01NY3gcapRNaDynTKr7cdN7p --- docs/specs/alert.md | 2 +- docs/specs/alert.rationale.md | 2 ++ scripts/spec-word-budgets.json | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 76e54582..ea7cb548 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -281,7 +281,7 @@ Source of truth: `AlertSettings` in `lib/src/lib/alert-settings.ts` (renderer mi ### Spoken alarms -- **Must replace high-entropy ASCII tokens with `REDACTED` locally before speech punctuation cleanup or truncation.** Match hex, base32, and base64/base64url candidates using the length and Shannon-entropy cutoffs in `redactHighEntropyTokens`; redact entire matches including padding. This heuristic can redact hashes and identifiers and miss secrets; word-passphrase detection is out of scope. Pinned by `redactHighEntropyTokens` in `lib/src/lib/redact-high-entropy.test.ts` and `redacts whole tokens before punctuation cleanup and truncation` in `lib/src/lib/alert-speech.test.ts`. +- **Must replace high-entropy ASCII tokens with `REDACTED` before the punctuation pass and the cap**, which would otherwise leave a secret in unrecognizable fragments; padding joins the replaced span (rationale). Pinned by `lib/src/lib/redact-high-entropy.test.ts` and `redacts whole tokens before punctuation cleanup and truncation` in `lib/src/lib/alert-speech.test.ts`. - **The label must be sanitized before it reaches the engine** (`toSpokenText`): all Unicode punctuation, symbols, and `Other` characters (including controls, bidi controls, and zero-width formats) become spaces, except apostrophes, which are elided so contractions survive; letters, numbers, and their combining marks from every script remain. Whitespace collapses, the result is capped in code points, and an empty result falls back to `terminal`. **Security, not tidiness:** WebKit wedges its synthesizer on angle brackets, and terminal-supplied text reaches Pane labels (rationale). - **Delivery state follows actual engine callbacks, not queue admission.** `AlertSpeechState` is a renderer-local `speaking | spoken` map keyed by Session: `start` publishes `speaking`; `end`, or `error` after a real start, publishes `spoken`; an utterance that never starts publishes neither. **Must check delivery identity before accepting `start` or completion**, including after redispatch, eviction, or teardown. Pinned by `ignores an older ring starting after a newer ring has begun speaking` and `bounds tracked utterances when the engine never calls back` in `lib/src/lib/alert-speech.test.ts`. - **Nothing in the settle path may assume the callback arrives after `speak()` returns** — an engine may dispatch `start` then `end`/`error` *synchronously* inside `speechSynthesis.speak()` (rationale). Handlers therefore close over the utterance itself and registration happens before dispatch. A dispatch the engine refuses outright settles too. diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index 1b1d28db..5dced5e9 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -64,6 +64,8 @@ ## Spoken alarms +**Why an entropy heuristic, and what it costs.** No shape-plus-context rule (assignment keys, vendor prefixes) covers a bare token in an `OSC 0` title, which is the case that motivated this. The cost is paid in both directions: the cutoffs sit below each alphabet's ceiling because a finite sample never reaches it, so a random 20-character base64 token is missed about a third of the time, while `/`, `-`, and `_` are token characters, so 135 of this repo's 1102 tracked paths redact (12.3%, measured 2026-09) and `vim lib/src/lib/redact-high-entropy.ts` speaks as `vim REDACTED.ts`. That trade is acceptable at this sink and not at a rendered one: a 40-character SHA read aloud is useless to a listener either way, whereas blanking a Pane header would hide a token the terminal is printing two lines below it. Word passphrases are out of scope — they are indistinguishable from a title by this measure. + **Why the label is sanitized before it reaches the engine.** WebKit silently drops an utterance containing angle brackets **and leaves the synthesizer wedged**, so every later utterance is dropped until the page reloads. Pane labels carry chrome like ``, and terminal-supplied titles reach speech, so any program could permanently disable spoken alarms for the session by putting a `<` in its title. Substituting spaces rather than deleting also keeps adjacent words separate and prevents formatting markers such as `*` from being announced. **Why the settle path cannot assume an async callback.** Chrome dispatches `start` and then `error` with `not-allowed` *synchronously* inside `speechSynthesis.speak()` when speech is invoked without a user gesture — exactly this call site, since an alarm fires on a timer while the user is away. Reading a variable the caller assigns after `speak()` returns would drop the settle and pin the Session at `speaking` for the rest of the ring. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 8f991b4c..2c3ccbe5 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -2,7 +2,7 @@ "AGENTS.md": 3250, "SECURITY.md": 200, "SELF_HOST.md": 6000, - "docs/specs/alert.md": 6650, + "docs/specs/alert.md": 6600, "docs/specs/auto-update.md": 1000, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4500, From d7a1480382a974e92532e68d0066849e61135c2b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 6 Sep 2026 13:05:23 -0700 Subject: [PATCH 5/9] Clarify entropy tradeoffs and complete boundary coverage --- docs/specs/alert.md | 2 +- docs/specs/alert.rationale.md | 2 +- lib/src/lib/redact-high-entropy.test.ts | 17 ++++++++++++----- lib/src/lib/redact-high-entropy.ts | 5 ++--- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index ea7cb548..9e309093 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -281,7 +281,7 @@ Source of truth: `AlertSettings` in `lib/src/lib/alert-settings.ts` (renderer mi ### Spoken alarms -- **Must replace high-entropy ASCII tokens with `REDACTED` before the punctuation pass and the cap**, which would otherwise leave a secret in unrecognizable fragments; padding joins the replaced span (rationale). Pinned by `lib/src/lib/redact-high-entropy.test.ts` and `redacts whole tokens before punctuation cleanup and truncation` in `lib/src/lib/alert-speech.test.ts`. +- **Must replace high-entropy ASCII tokens with `REDACTED` locally before punctuation cleanup and truncation**, including padding in the replaced span (rationale). This heuristic can redact non-secrets and miss secrets; word-passphrase detection is out of scope. Pinned by `lib/src/lib/redact-high-entropy.test.ts` and `redacts whole tokens before punctuation cleanup and truncation` in `lib/src/lib/alert-speech.test.ts`. - **The label must be sanitized before it reaches the engine** (`toSpokenText`): all Unicode punctuation, symbols, and `Other` characters (including controls, bidi controls, and zero-width formats) become spaces, except apostrophes, which are elided so contractions survive; letters, numbers, and their combining marks from every script remain. Whitespace collapses, the result is capped in code points, and an empty result falls back to `terminal`. **Security, not tidiness:** WebKit wedges its synthesizer on angle brackets, and terminal-supplied text reaches Pane labels (rationale). - **Delivery state follows actual engine callbacks, not queue admission.** `AlertSpeechState` is a renderer-local `speaking | spoken` map keyed by Session: `start` publishes `speaking`; `end`, or `error` after a real start, publishes `spoken`; an utterance that never starts publishes neither. **Must check delivery identity before accepting `start` or completion**, including after redispatch, eviction, or teardown. Pinned by `ignores an older ring starting after a newer ring has begun speaking` and `bounds tracked utterances when the engine never calls back` in `lib/src/lib/alert-speech.test.ts`. - **Nothing in the settle path may assume the callback arrives after `speak()` returns** — an engine may dispatch `start` then `end`/`error` *synchronously* inside `speechSynthesis.speak()` (rationale). Handlers therefore close over the utterance itself and registration happens before dispatch. A dispatch the engine refuses outright settles too. diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index 5dced5e9..03c9ca3a 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -64,7 +64,7 @@ ## Spoken alarms -**Why an entropy heuristic, and what it costs.** No shape-plus-context rule (assignment keys, vendor prefixes) covers a bare token in an `OSC 0` title, which is the case that motivated this. The cost is paid in both directions: the cutoffs sit below each alphabet's ceiling because a finite sample never reaches it, so a random 20-character base64 token is missed about a third of the time, while `/`, `-`, and `_` are token characters, so 135 of this repo's 1102 tracked paths redact (12.3%, measured 2026-09) and `vim lib/src/lib/redact-high-entropy.ts` speaks as `vim REDACTED.ts`. That trade is acceptable at this sink and not at a rendered one: a 40-character SHA read aloud is useless to a listener either way, whereas blanking a Pane header would hide a token the terminal is printing two lines below it. Word passphrases are out of scope — they are indistinguishable from a title by this measure. +**Why an entropy heuristic, and what it costs.** A bare token can reach a terminal-supplied title without credential-related wording. Finite samples often fall below their alphabet's maximum entropy, so the cutoffs sit below those maxima and still miss some random tokens. Conversely, `/`, `-`, and `_` are token characters: 135 of this repo's 1102 tracked paths redact (12.3%, measured 2026-09), and `vim lib/src/lib/redact-high-entropy.ts` speaks as `vim REDACTED.ts`. Speech accepts this loss of detail to reduce accidental disclosure. Redacting before punctuation cleanup and truncation prevents those transforms from hiding a token's recognizable shape while leaving its contents speakable. **Why the label is sanitized before it reaches the engine.** WebKit silently drops an utterance containing angle brackets **and leaves the synthesizer wedged**, so every later utterance is dropped until the page reloads. Pane labels carry chrome like ``, and terminal-supplied titles reach speech, so any program could permanently disable spoken alarms for the session by putting a `<` in its title. Substituting spaces rather than deleting also keeps adjacent words separate and prevents formatting markers such as `*` from being announced. diff --git a/lib/src/lib/redact-high-entropy.test.ts b/lib/src/lib/redact-high-entropy.test.ts index 345acc9c..c3aacc8c 100644 --- a/lib/src/lib/redact-high-entropy.test.ts +++ b/lib/src/lib/redact-high-entropy.test.ts @@ -7,6 +7,7 @@ describe('redactHighEntropyTokens', () => { ['uppercase hex', '8B7D0C4E9F2A61035E8C9D1F04A76B23'], ['base32', 'K7QW2MXP5RZV3NAT6YHC4BSD'], ['lowercase base32', 'k7qw2mxp5rzv3nat6yhc4bsd'], + ['padded base32', 'K7QW2MXP5RZV3NAT6YHC4BSD======'], ['base64', 'k8Xq+W2m/P5rZ9vN3aT6yHc4BsD0EfGj'], ['padded base64', 'k8Xq+W2m/P5rZ9vN3aT6yA=='], ['base64url', 'k8Xq-W2m_P5rZ9vN3aT6yHc4BsD0EfGj'], @@ -30,21 +31,27 @@ describe('redactHighEntropyTokens', () => { expect(redactHighEntropyTokens(text)).toBe(text); }); - // Each pair below moves one constant in `TIERS` across its boundary; without - // them the narrower tiers are indistinguishable from the base64 tier alone. + // Pin the narrower alphabets separately from the base64 tier. it.each([ ['hex at the 3.0 cutoff', '8b7d0c4e8b7d0c4e', true], ['hex below it', '8b7d0c4e8b7d0c4d', false], + ['15-char hex, too short', '8b7d0c4e9f2a610', false], ['16-char base32, too short for the base64 tier', 'K7QW2MXP5RZV3NAT', true], + ['15-char base32, too short', 'K7QW2MXP5RZV3NA', false], + ['base32 at the 3.5 cutoff', 'ABCDEFGHJKLMABCD', true], ['16-char base32 below the 3.5 cutoff', 'k7qw2mxpk7qw2mxp', false], ['16-char base64, too short for any tier', 'k8Xq+W2m/P5rZ9vN', false], - ])('%s: redacted=%s', (_kind, token, redacted) => { + ['19-char base64, too short', 'k8Xq+W2m/P5rZ9vN3aT', false], + ['20-char base64', 'k8Xq+W2m/P5rZ9vN3aT6', true], + ['base64 at the 4.0 cutoff', '0123456789ghijkl0123456789ghijkl', true], + ['base64 below it', '0123456789ghijkl0123456789ghijkk', false], + ])('%s', (_kind, token, redacted) => { expect(redactHighEntropyTokens(token)).toBe(redacted ? 'REDACTED' : token); }); it('folds case on case-insensitive alphabets, so `A` and `a` are one symbol', () => { - // 3.5 bits counted as 16 distinct symbols, 2.75 counted as the 8 hex digits - // it actually carries — only the folded score is below the 3.0 hex cutoff. + // Case-sensitive entropy is 3.5 bits; folding gives 2.75 bits, below the + // hex cutoff. Treating case variants as distinct would wrongly redact it. expect(redactHighEntropyTokens('aAbBcCdDeEfF0000')).toBe('aAbBcCdDeEfF0000'); }); diff --git a/lib/src/lib/redact-high-entropy.ts b/lib/src/lib/redact-high-entropy.ts index 09c40b87..8b634581 100644 --- a/lib/src/lib/redact-high-entropy.ts +++ b/lib/src/lib/redact-high-entropy.ts @@ -1,8 +1,7 @@ /** One opaque-token shape: the alphabet, the length below which a sample is too * short to judge, and the bits/character above which it reads as random. Ordered * narrowest alphabet first, so a token is scored against the tightest one it fits. - * The cutoffs sit below each alphabet's ceiling because a finite sample never - * reaches it (rationale). */ + * Finite samples often fall below their alphabet's maximum entropy. */ interface TokenTier { readonly alphabet: RegExp; readonly minLength: number; @@ -33,7 +32,7 @@ function entropyOf(value: string): number { } /** Replace opaque ASCII tokens; this is a randomness heuristic, not a guarantee - * that all secrets (or only secrets) are removed (rationale). Padding joins the + * that all secrets (or only secrets) are removed. Padding joins the * replaced span but not the entropy estimate. */ export function redactHighEntropyTokens(text: string): string { return text.replace(/([A-Za-z0-9+/_-]{16,})=*/g, (token, value: string) => { From 82d952f96e07e07db5379cbf1ff0c6281e6d4bcd Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 6 Sep 2026 13:16:12 -0700 Subject: [PATCH 6/9] Handle grouped hex and preserve separators during redaction --- docs/specs/alert.md | 2 +- lib/src/lib/alert-speech.test.ts | 5 +++++ lib/src/lib/redact-high-entropy.test.ts | 17 +++++++++++++++++ lib/src/lib/redact-high-entropy.ts | 25 +++++++++++++++---------- 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 9e309093..7b4d2876 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -281,7 +281,7 @@ Source of truth: `AlertSettings` in `lib/src/lib/alert-settings.ts` (renderer mi ### Spoken alarms -- **Must replace high-entropy ASCII tokens with `REDACTED` locally before punctuation cleanup and truncation**, including padding in the replaced span (rationale). This heuristic can redact non-secrets and miss secrets; word-passphrase detection is out of scope. Pinned by `lib/src/lib/redact-high-entropy.test.ts` and `redacts whole tokens before punctuation cleanup and truncation` in `lib/src/lib/alert-speech.test.ts`. +- **Must replace high-entropy ASCII tokens with `REDACTED` locally before punctuation cleanup and truncation**, including trailing padding but preserving `=` separators (rationale). Hex candidates include hyphen/underscore groups. False positives and negatives remain possible; word-passphrase detection is excluded. Pinned by `lib/src/lib/redact-high-entropy.test.ts` and `redacts whole tokens before punctuation cleanup and truncation` in `lib/src/lib/alert-speech.test.ts`. - **The label must be sanitized before it reaches the engine** (`toSpokenText`): all Unicode punctuation, symbols, and `Other` characters (including controls, bidi controls, and zero-width formats) become spaces, except apostrophes, which are elided so contractions survive; letters, numbers, and their combining marks from every script remain. Whitespace collapses, the result is capped in code points, and an empty result falls back to `terminal`. **Security, not tidiness:** WebKit wedges its synthesizer on angle brackets, and terminal-supplied text reaches Pane labels (rationale). - **Delivery state follows actual engine callbacks, not queue admission.** `AlertSpeechState` is a renderer-local `speaking | spoken` map keyed by Session: `start` publishes `speaking`; `end`, or `error` after a real start, publishes `spoken`; an utterance that never starts publishes neither. **Must check delivery identity before accepting `start` or completion**, including after redispatch, eviction, or teardown. Pinned by `ignores an older ring starting after a newer ring has begun speaking` and `bounds tracked utterances when the engine never calls back` in `lib/src/lib/alert-speech.test.ts`. - **Nothing in the settle path may assume the callback arrives after `speak()` returns** — an engine may dispatch `start` then `end`/`error` *synchronously* inside `speechSynthesis.speak()` (rationale). Handlers therefore close over the utterance itself and registration happens before dispatch. A dispatch the engine refuses outright settles too. diff --git a/lib/src/lib/alert-speech.test.ts b/lib/src/lib/alert-speech.test.ts index 484c25ec..ca566689 100644 --- a/lib/src/lib/alert-speech.test.ts +++ b/lib/src/lib/alert-speech.test.ts @@ -155,6 +155,11 @@ describe('toSpokenText', () => { expect(toSpokenText(`${prefix}8b7d0c4e9f2a61035e8c9d1f04a76b23`)) .toBe(`${prefix}REDACTED`); }); + + it('keeps words separate when a redacted token precedes an equals sign', () => { + expect(toSpokenText('CargoBuildFinished=ok BackgroundTaskScheduler==finished')) + .toBe('REDACTED ok REDACTED finished'); + }); }); /** diff --git a/lib/src/lib/redact-high-entropy.test.ts b/lib/src/lib/redact-high-entropy.test.ts index c3aacc8c..f01cc9d4 100644 --- a/lib/src/lib/redact-high-entropy.test.ts +++ b/lib/src/lib/redact-high-entropy.test.ts @@ -5,6 +5,9 @@ describe('redactHighEntropyTokens', () => { it.each([ ['hex', '8b7d0c4e9f2a61035e8c9d1f04a76b23'], ['uppercase hex', '8B7D0C4E9F2A61035E8C9D1F04A76B23'], + ['UUID', '3f2504e0-4f89-11d3-9a0c-0305e82c3301'], + ['grouped hex', '8b7d-0c4e-9f2a-6103'], + ['underscore-grouped hex', '8B7D_0C4E_9F2A_6103'], ['base32', 'K7QW2MXP5RZV3NAT6YHC4BSD'], ['lowercase base32', 'k7qw2mxp5rzv3nat6yhc4bsd'], ['padded base32', 'K7QW2MXP5RZV3NAT6YHC4BSD======'], @@ -22,6 +25,20 @@ describe('redactHighEntropyTokens', () => { .toBe('first=REDACTED; second=REDACTED!'); }); + it('preserves equals separators while removing trailing padding', () => { + expect(redactHighEntropyTokens('CargoBuildFinished=ok BackgroundTaskScheduler==finished')) + .toBe('REDACTED=ok REDACTED==finished'); + expect(redactHighEntropyTokens('CargoBuildFinished== next')) + .toBe('REDACTED next'); + }); + + it('normalizes separators only for grouped hex, counting only its digits', () => { + expect(redactHighEntropyTokens('PostgreSQL_Connection_Manager implementation_details_v2')) + .toBe('PostgreSQL_Connection_Manager implementation_details_v2'); + expect(redactHighEntropyTokens('8-b-7-d-0-c-4-e-9-f-2-a-6-1-0')) + .toBe('8-b-7-d-0-c-4-e-9-f-2-a-6-1-0'); + }); + it.each([ ['ordinary text', 'pnpm test: build finished'], ['a long word under every entropy cutoff', 'internationalization configuration'], diff --git a/lib/src/lib/redact-high-entropy.ts b/lib/src/lib/redact-high-entropy.ts index 8b634581..cf21018b 100644 --- a/lib/src/lib/redact-high-entropy.ts +++ b/lib/src/lib/redact-high-entropy.ts @@ -6,15 +6,19 @@ interface TokenTier { readonly alphabet: RegExp; readonly minLength: number; readonly minEntropy: number; - /** Fold case before counting, so `A` and `a` are one symbol of a - * case-insensitive alphabet rather than two. */ - readonly foldCase: boolean; + /** Remove encoding separators and fold case before measuring length or entropy. */ + readonly normalize: (value: string) => string; } const TIERS: readonly TokenTier[] = [ - { alphabet: /^[0-9a-f]+$/i, minLength: 16, minEntropy: 3, foldCase: true }, - { alphabet: /^[a-z2-7]+$/i, minLength: 16, minEntropy: 3.5, foldCase: true }, - { alphabet: /^[A-Za-z0-9+/_-]+$/, minLength: 20, minEntropy: 4, foldCase: false }, + { + alphabet: /^[0-9a-f]+(?:[-_][0-9a-f]+)*$/i, + minLength: 16, + minEntropy: 3, + normalize: (value) => value.replace(/[-_]/g, '').toLowerCase(), + }, + { alphabet: /^[a-z2-7]+$/i, minLength: 16, minEntropy: 3.5, normalize: (value) => value.toLowerCase() }, + { alphabet: /^[A-Za-z0-9+/_-]+$/, minLength: 20, minEntropy: 4, normalize: (value) => value }, ]; /** Shannon entropy in bits per character over an ASCII histogram. */ @@ -32,13 +36,14 @@ function entropyOf(value: string): number { } /** Replace opaque ASCII tokens; this is a randomness heuristic, not a guarantee - * that all secrets (or only secrets) are removed. Padding joins the + * that all secrets (or only secrets) are removed. Trailing padding joins the * replaced span but not the entropy estimate. */ export function redactHighEntropyTokens(text: string): string { - return text.replace(/([A-Za-z0-9+/_-]{16,})=*/g, (token, value: string) => { - const tier = TIERS.find((t) => value.length >= t.minLength && t.alphabet.test(value)); + return text.replace(/([A-Za-z0-9+/_-]{16,})(?:=+(?![A-Za-z0-9+/_=-]))?/g, (token, value: string) => { + const tier = TIERS.find((t) => t.alphabet.test(value)); if (!tier) return token; - const counted = tier.foldCase ? value.toLowerCase() : value; + const counted = tier.normalize(value); + if (counted.length < tier.minLength) return token; return entropyOf(counted) >= tier.minEntropy ? 'REDACTED' : token; }); } From ef481b381f8df9922c67e2a83513fd4a507fe441 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 6 Sep 2026 13:24:42 -0700 Subject: [PATCH 7/9] Document grouped-hex and padding redaction tradeoffs --- docs/specs/alert.rationale.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index 03c9ca3a..54854507 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -66,6 +66,10 @@ **Why an entropy heuristic, and what it costs.** A bare token can reach a terminal-supplied title without credential-related wording. Finite samples often fall below their alphabet's maximum entropy, so the cutoffs sit below those maxima and still miss some random tokens. Conversely, `/`, `-`, and `_` are token characters: 135 of this repo's 1102 tracked paths redact (12.3%, measured 2026-09), and `vim lib/src/lib/redact-high-entropy.ts` speaks as `vim REDACTED.ts`. Speech accepts this loss of detail to reduce accidental disclosure. Redacting before punctuation cleanup and truncation prevents those transforms from hiding a token's recognizable shape while leaving its contents speakable. +**Why only hex grouping is normalized.** Grouped hex otherwise falls into the base64 tier and almost always misses its higher cutoff. In review samples of 20,000 random UUIDs, removing hex separators reduced misses from 100% to 0.01%, with no additional matches among the 1102 tracked paths (measured 2026-09). Applying separator removal to other alphabets would also redact `PostgreSQL_Connection_Manager` and `implementation_details_v2`; limiting normalization to hex keeps those identifiers unchanged. + +**Why padding must end the candidate.** Absorbing an `=` separator turns `CargoBuildFinished=ok` into `REDACTEDok`. Leaving it for punctuation cleanup yields `REDACTED ok`, preserving the word boundary. Trailing padding belongs to the token and carries no useful speech content. + **Why the label is sanitized before it reaches the engine.** WebKit silently drops an utterance containing angle brackets **and leaves the synthesizer wedged**, so every later utterance is dropped until the page reloads. Pane labels carry chrome like ``, and terminal-supplied titles reach speech, so any program could permanently disable spoken alarms for the session by putting a `<` in its title. Substituting spaces rather than deleting also keeps adjacent words separate and prevents formatting markers such as `*` from being announced. **Why the settle path cannot assume an async callback.** Chrome dispatches `start` and then `error` with `not-allowed` *synchronously* inside `speechSynthesis.speak()` when speech is invoked without a user gesture — exactly this call site, since an alarm fires on a timer while the user is away. Reading a variable the caller assigns after `speak()` returns would drop the settle and pin the Session at `speaking` for the rest of the ring. From 6f500c510b0d26584e08ad2760a58d3b7153a63d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 6 Sep 2026 13:41:12 -0700 Subject: [PATCH 8/9] Detect embedded hex keys and document base32 false positives --- docs/specs/alert.md | 2 +- docs/specs/alert.rationale.md | 4 ++++ lib/src/lib/redact-high-entropy.test.ts | 15 ++++++++++++ lib/src/lib/redact-high-entropy.ts | 31 ++++++++++++++++++------- 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 7b4d2876..3ac64d26 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -281,7 +281,7 @@ Source of truth: `AlertSettings` in `lib/src/lib/alert-settings.ts` (renderer mi ### Spoken alarms -- **Must replace high-entropy ASCII tokens with `REDACTED` locally before punctuation cleanup and truncation**, including trailing padding but preserving `=` separators (rationale). Hex candidates include hyphen/underscore groups. False positives and negatives remain possible; word-passphrase detection is excluded. Pinned by `lib/src/lib/redact-high-entropy.test.ts` and `redacts whole tokens before punctuation cleanup and truncation` in `lib/src/lib/alert-speech.test.ts`. +- **Must replace high-entropy ASCII tokens with `REDACTED` locally before punctuation cleanup and truncation**, including trailing padding but preserving `=` separators (rationale). Hex candidates include embedded hyphen/underscore groups. False positives and negatives remain possible; word-passphrase detection is excluded. Pinned by `lib/src/lib/redact-high-entropy.test.ts` and `redacts whole tokens before punctuation cleanup and truncation` in `lib/src/lib/alert-speech.test.ts`. - **The label must be sanitized before it reaches the engine** (`toSpokenText`): all Unicode punctuation, symbols, and `Other` characters (including controls, bidi controls, and zero-width formats) become spaces, except apostrophes, which are elided so contractions survive; letters, numbers, and their combining marks from every script remain. Whitespace collapses, the result is capped in code points, and an empty result falls back to `terminal`. **Security, not tidiness:** WebKit wedges its synthesizer on angle brackets, and terminal-supplied text reaches Pane labels (rationale). - **Delivery state follows actual engine callbacks, not queue admission.** `AlertSpeechState` is a renderer-local `speaking | spoken` map keyed by Session: `start` publishes `speaking`; `end`, or `error` after a real start, publishes `spoken`; an utterance that never starts publishes neither. **Must check delivery identity before accepting `start` or completion**, including after redispatch, eviction, or teardown. Pinned by `ignores an older ring starting after a newer ring has begun speaking` and `bounds tracked utterances when the engine never calls back` in `lib/src/lib/alert-speech.test.ts`. - **Nothing in the settle path may assume the callback arrives after `speak()` returns** — an engine may dispatch `start` then `end`/`error` *synchronously* inside `speechSynthesis.speak()` (rationale). Handlers therefore close over the utterance itself and registration happens before dispatch. A dispatch the engine refuses outright settles too. diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index 54854507..ec9bb99c 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -70,6 +70,10 @@ **Why padding must end the candidate.** Absorbing an `=` separator turns `CargoBuildFinished=ok` into `REDACTEDok`. Leaving it for punctuation cleanup yields `REDACTED ok`, preserving the word boundary. Trailing padding belongs to the token and carries no useful speech content. +**Why embedded hex runs are checked.** A non-hex prefix or suffix such as `pod-` or `-log` otherwise moves an entire UUID candidate into the base64 tier, whose higher cutoff misses most such values. Checking every contiguous hex-group run preserves the hex threshold inside those candidates; replacing the enclosing token also avoids speaking credential prefixes or suffixes. + +**Why base32 does not require a digit.** Letter-only values are valid base32, including short high-entropy strings that fall below the base64 tier's minimum length. A digit requirement would reduce false positives but intentionally miss those values. The accepted cost extends beyond paths: review measured 663 of 2048 distinct ASCII-letter identifiers of at least 16 characters in `lib/src/` redacting (32.4%, measured 2026-09), including `PostgreSQLConnectionManager` and `CargoBuildFinished`. The cutoff test for `ABCDEFGHJKLMABCD` records that choice. + **Why the label is sanitized before it reaches the engine.** WebKit silently drops an utterance containing angle brackets **and leaves the synthesizer wedged**, so every later utterance is dropped until the page reloads. Pane labels carry chrome like ``, and terminal-supplied titles reach speech, so any program could permanently disable spoken alarms for the session by putting a `<` in its title. Substituting spaces rather than deleting also keeps adjacent words separate and prevents formatting markers such as `*` from being announced. **Why the settle path cannot assume an async callback.** Chrome dispatches `start` and then `error` with `not-allowed` *synchronously* inside `speechSynthesis.speak()` when speech is invoked without a user gesture — exactly this call site, since an alarm fires on a timer while the user is away. Reading a variable the caller assigns after `speak()` returns would drop the settle and pin the Session at `speaking` for the rest of the ring. diff --git a/lib/src/lib/redact-high-entropy.test.ts b/lib/src/lib/redact-high-entropy.test.ts index f01cc9d4..2432d865 100644 --- a/lib/src/lib/redact-high-entropy.test.ts +++ b/lib/src/lib/redact-high-entropy.test.ts @@ -39,6 +39,21 @@ describe('redactHighEntropyTokens', () => { .toBe('8-b-7-d-0-c-4-e-9-f-2-a-6-1-0'); }); + it.each([ + 'pod-3f2504e0-4f89-11d3-9a0c-0305e82c3301', + '3f2504e0-4f89-11d3-9a0c-0305e82c3301-log', + 'session_8b7d0c4e9f2a61035e8c9d1f04a76b23', + 'job_8b7d-0c4e-9f2a-6103_output', + ])('redacts a whole candidate containing an embedded hex key: %s', (token) => { + expect(redactHighEntropyTokens(token)).toBe('REDACTED'); + }); + + it('checks every hex run without exempting the entire enclosing token', () => { + expect(redactHighEntropyTokens(`8b7d0c4e9f2a6103_${'x'.repeat(100)}`)).toBe('REDACTED'); + expect(redactHighEntropyTokens(`${'0'.repeat(100)}_job_8b7d0c4e9f2a6103`)).toBe('REDACTED'); + expect(redactHighEntropyTokens('pod_8b7d0c4e9f2a610')).toBe('pod_8b7d0c4e9f2a610'); + }); + it.each([ ['ordinary text', 'pnpm test: build finished'], ['a long word under every entropy cutoff', 'internationalization configuration'], diff --git a/lib/src/lib/redact-high-entropy.ts b/lib/src/lib/redact-high-entropy.ts index cf21018b..800e097c 100644 --- a/lib/src/lib/redact-high-entropy.ts +++ b/lib/src/lib/redact-high-entropy.ts @@ -10,13 +10,18 @@ interface TokenTier { readonly normalize: (value: string) => string; } +const HEX_GROUPS = '[0-9a-f]+(?:[-_][0-9a-f]+)*'; +// Match whole groups: the final `d` in `pod-` is not a key digit. +const HEX_RUNS = new RegExp(`(? value.replace(/[-_]/g, '').toLowerCase(), +}; + const TIERS: readonly TokenTier[] = [ - { - alphabet: /^[0-9a-f]+(?:[-_][0-9a-f]+)*$/i, - minLength: 16, - minEntropy: 3, - normalize: (value) => value.replace(/[-_]/g, '').toLowerCase(), - }, + HEX_TIER, { alphabet: /^[a-z2-7]+$/i, minLength: 16, minEntropy: 3.5, normalize: (value) => value.toLowerCase() }, { alphabet: /^[A-Za-z0-9+/_-]+$/, minLength: 20, minEntropy: 4, normalize: (value) => value }, ]; @@ -35,15 +40,23 @@ function entropyOf(value: string): number { return entropy; } +function isHighEntropy(value: string, tier: TokenTier): boolean { + const counted = tier.normalize(value); + return counted.length >= tier.minLength && entropyOf(counted) >= tier.minEntropy; +} + /** Replace opaque ASCII tokens; this is a randomness heuristic, not a guarantee * that all secrets (or only secrets) are removed. Trailing padding joins the * replaced span but not the entropy estimate. */ export function redactHighEntropyTokens(text: string): string { return text.replace(/([A-Za-z0-9+/_-]{16,})(?:=+(?![A-Za-z0-9+/_=-]))?/g, (token, value: string) => { + // A non-hex prefix/suffix must not force an embedded key to use the higher + // base64 cutoff. Replace the whole candidate when any hex run qualifies. + for (const [hexRun] of value.matchAll(HEX_RUNS)) { + if (isHighEntropy(hexRun, HEX_TIER)) return 'REDACTED'; + } const tier = TIERS.find((t) => t.alphabet.test(value)); if (!tier) return token; - const counted = tier.normalize(value); - if (counted.length < tier.minLength) return token; - return entropyOf(counted) >= tier.minEntropy ? 'REDACTED' : token; + return isHighEntropy(value, tier) ? 'REDACTED' : token; }); } From f4cedae1507432e0037ae32da7c1dc538e253495 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 6 Sep 2026 13:52:38 -0700 Subject: [PATCH 9/9] Pin the trailing boundary of embedded hex runs --- lib/src/lib/redact-high-entropy.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/lib/redact-high-entropy.test.ts b/lib/src/lib/redact-high-entropy.test.ts index 2432d865..e6e5db78 100644 --- a/lib/src/lib/redact-high-entropy.test.ts +++ b/lib/src/lib/redact-high-entropy.test.ts @@ -52,6 +52,7 @@ describe('redactHighEntropyTokens', () => { expect(redactHighEntropyTokens(`8b7d0c4e9f2a6103_${'x'.repeat(100)}`)).toBe('REDACTED'); expect(redactHighEntropyTokens(`${'0'.repeat(100)}_job_8b7d0c4e9f2a6103`)).toBe('REDACTED'); expect(redactHighEntropyTokens('pod_8b7d0c4e9f2a610')).toBe('pod_8b7d0c4e9f2a610'); + expect(redactHighEntropyTokens('8b7d0c4e9f2a610_dop')).toBe('8b7d0c4e9f2a610_dop'); }); it.each([