Skip to content
3 changes: 2 additions & 1 deletion docs/specs/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
10 changes: 10 additions & 0 deletions docs/specs/alert.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<idle>`, 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.
Expand Down
12 changes: 12 additions & 0 deletions lib/src/lib/alert-speech.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

/**
Expand Down
5 changes: 4 additions & 1 deletion lib/src/lib/alert-speech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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, '')
Expand Down
95 changes: 95 additions & 0 deletions lib/src/lib/redact-high-entropy.test.ts
Original file line number Diff line number Diff line change
@@ -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');
Comment thread
dormouse-bot marked this conversation as resolved.
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);
});
});
62 changes: 62 additions & 0 deletions lib/src/lib/redact-high-entropy.ts
Original file line number Diff line number Diff line change
@@ -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-<hex>` is not a key digit.
const HEX_RUNS = new RegExp(`(?<![A-Za-z0-9])${HEX_GROUPS}(?![A-Za-z0-9])`, 'gi');
const HEX_TIER: TokenTier = {
alphabet: new RegExp(`^${HEX_GROUPS}$`, 'i'),
minLength: 16,
minEntropy: 3,
normalize: (value) => value.replace(/[-_]/g, '').toLowerCase(),
};

const TIERS: readonly TokenTier[] = [
HEX_TIER,
{ alphabet: /^[a-z2-7]+$/i, minLength: 16, minEntropy: 3.5, normalize: (value) => value.toLowerCase() },
Comment thread
dormouse-bot marked this conversation as resolved.
{ 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;
});
}
2 changes: 1 addition & 1 deletion scripts/spec-word-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down