feat(cockpit): approval preview — command/diff above Approve/Reject (W1)

Remote one-tap approval was blind (you'd tap Approve without seeing Claude wants
to run `rm -rf` or rewrite a config). The pending tool's actual command / diff now
renders above the approval bar, on every attached device, riding the same broadcast
+ late-joiner rails as the existing `gate` field.

- src/http/approval-preview.ts (new, pure, never-throws): deriveApprovalPreview —
  Bash → command; Edit/Write/MultiEdit/NotebookEdit → a synthetic DiffFile; else null.
  Every line sanitized via sanitizeField (strips control/ANSI); caps 40 lines /
  200 chars/line / 4KB (security limits, UTF-8-safe byte clamp).
- src/types.ts: additive optional `preview?: ApprovalPreview` on the status
  ServerMessage + handleHookEvent (older clients ignore it).
- src/server.ts /hook/permission: derive preview from tool_input, store on the
  PendingApproval entry, re-send to late joiners exactly like `gate`.
- manager.ts threads it; public/tabs.ts renderApprovalPreview (command → <pre>
  textContent; diff → reused innerHTML-free renderDiffFile). Unknown tools /
  plan gates fall back to today's name-only bar.

Attacker-influenced tool input → rendered via textContent/diff-renderer only,
never innerHTML. Verified independently: typecheck + build:web clean, 1692 pass.
This commit is contained in:
Yaojia Wang
2026-07-12 20:04:18 +02:00
parent debf47d99e
commit e062065cd3
12 changed files with 808 additions and 8 deletions

View File

@@ -14,6 +14,7 @@ import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
import { WebLinksAddon } from '@xterm/addon-web-links'
import type {
ApprovalPreview,
ClaudeStatus,
ClientMessage,
PermissionGate,
@@ -147,6 +148,9 @@ export class TerminalSession {
private pendingToolValue: string | undefined = undefined
private telemetryValue: StatusTelemetry | null = null
private pendingGateValue: PermissionGate | null = null
// W1: bounded command/diff preview of the held tool, from the last pending
// status frame. Null when no approval is held or the tool wasn't previewable.
private pendingPreviewValue: ApprovalPreview | 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
@@ -257,6 +261,12 @@ export class TerminalSession {
return this.pendingGateValue
}
/** W1: bounded command/diff preview of the held tool (from the last pending
* status frame), or null when nothing is held / the tool wasn't previewable. */
get pendingPreview(): ApprovalPreview | null {
return this.pendingPreviewValue
}
/** Nonce counting false→true pendingApproval flips (VC stale-gate guard, §5). */
get pendingEpoch(): number {
return this.pendingEpochValue
@@ -372,6 +382,9 @@ export class TerminalSession {
this.pendingApprovalValue = nextPending
this.pendingToolValue = nextPending ? msg.detail : undefined
this.pendingGateValue = msg.gate ?? null
// W1: keep the preview only while an approval is held; a non-pending
// status (approve/reject resolved) clears it so the bar hides cleanly.
this.pendingPreviewValue = nextPending ? (msg.preview ?? null) : null
this.onClaudeStatus?.(msg.status, msg.detail)
break
}
@@ -501,10 +514,12 @@ export class TerminalSession {
* can distinguish "approve with default" from "approve, keep existing mode". */
approve(mode?: PermissionMode): void {
this.pendingApprovalValue = false
this.pendingPreviewValue = null // W1: resolved → drop the stale preview
this.sendMsg({ type: 'approve', ...(mode !== undefined ? { mode } : {}) })
}
reject(): void {
this.pendingApprovalValue = false
this.pendingPreviewValue = null // W1: resolved → drop the stale preview
this.sendMsg({ type: 'reject' })
}