feat: voice command mapping — context-gated approve/reject over voice

While a tool-permission gate is held on the active tab, spoken confirm
phrases resolve it via the existing approve/reject WS channel — no server
change (byte-shuttle intact). Everything else stays ordinary dictation.

Safety: whole-utterance-exact matching + leading-negation guard + reject
precedence + confidence gate (approve only) + a cancellable 1.5s confirm
window whose commit re-validates gate identity/epoch/connectivity (TOCTOU
guard) + stale-gate epoch bound at PTT-start.

New pure modules public/voice-commands.ts and public/voice-confirm.ts at
100% coverage; wiring in voice/terminal-session/tabs/main. 1307 tests pass,
typecheck clean, build:web OK. Independent code + security reviews flagged a
confirm-window TOCTOU and an unwired cancel path — both fixed and covered.

Plan: docs/PLAN_VOICE_COMMANDS.md.
This commit is contained in:
Yaojia Wang
2026-07-01 10:34:51 +02:00
parent 03612323c0
commit e4c327e25e
13 changed files with 1193 additions and 19 deletions

View File

@@ -95,6 +95,11 @@ export class TerminalSession {
private pendingToolValue: string | undefined = undefined
private telemetryValue: StatusTelemetry | null = null
private pendingGateValue: PermissionGate | null = null
// VC: nonce that increments on every false→true pendingApproval flip — lets a
// caller (e.g. a voice command captured at PTT-start) detect a stale gate: a
// slow transcript must not resolve a NEWER held permission than the one it
// was spoken against (PLAN_VOICE_COMMANDS.md §5, safeguard 5).
private pendingEpochValue = 0
private ws: WebSocket | null = null
private sessionId: string | null
@@ -189,6 +194,11 @@ export class TerminalSession {
return this.pendingGateValue
}
/** Nonce counting false→true pendingApproval flips (VC stale-gate guard, §5). */
get pendingEpoch(): number {
return this.pendingEpochValue
}
private setStatus(s: SessionStatus): void {
this.statusValue = s
this.onStatus?.(s)
@@ -293,9 +303,11 @@ export class TerminalSession {
break
}
case 'status': {
const nextPending = msg.pending === true
if (nextPending && !this.pendingApprovalValue) this.pendingEpochValue++
this.claudeStatusValue = msg.status
this.pendingApprovalValue = msg.pending === true
this.pendingToolValue = msg.pending === true ? msg.detail : undefined
this.pendingApprovalValue = nextPending
this.pendingToolValue = nextPending ? msg.detail : undefined
this.pendingGateValue = msg.gate ?? null
this.onClaudeStatus?.(msg.status, msg.detail)
break