diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 812d005f..3ac64d26 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 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. @@ -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/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index 1b1d28db..ec9bb99c 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -64,6 +64,16 @@ ## Spoken alarms +**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 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/alert-speech.test.ts b/lib/src/lib/alert-speech.test.ts index 994bfb6a..ca566689 100644 --- a/lib/src/lib/alert-speech.test.ts +++ b/lib/src/lib/alert-speech.test.ts @@ -148,6 +148,18 @@ 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`); + }); + + 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/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..e6e5db78 --- /dev/null +++ b/lib/src/lib/redact-high-entropy.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { redactHighEntropyTokens } from './redact-high-entropy'; + +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======'], + ['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('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([ + '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'); + expect(redactHighEntropyTokens('8b7d0c4e9f2a610_dop')).toBe('8b7d0c4e9f2a610_dop'); + }); + + it.each([ + ['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); + }); + + // 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], + ['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', () => { + // 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'); + }); + + 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 new file mode 100644 index 00000000..800e097c --- /dev/null +++ b/lib/src/lib/redact-high-entropy.ts @@ -0,0 +1,62 @@ +/** 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. + * Finite samples often fall below their alphabet's maximum entropy. */ +interface TokenTier { + readonly alphabet: RegExp; + readonly minLength: number; + readonly minEntropy: number; + /** Remove encoding separators and fold case before measuring length or entropy. */ + 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[] = [ + 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 }, +]; + +/** 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; +} + +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; + return isHighEntropy(value, tier) ? 'REDACTED' : token; + }); +} diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 63d729b7..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": 6550, + "docs/specs/alert.md": 6600, "docs/specs/auto-update.md": 1000, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4500,