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

@@ -100,6 +100,21 @@ export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | 'stuck';
* gate (three-way approve/auto/keep-planning); 'tool' = an ordinary tool gate. */
export type PermissionGate = 'tool' | 'plan';
/** W1: a compact, BOUNDED preview of what a held tool approval would run, so a
* remote one-tap approval is no longer blind. Derived server-side from the hook
* `tool_input` (attacker-influenced) — sanitized (control/ANSI chars stripped),
* line-capped and byte-capped in src/http/approval-preview.ts. Discriminated on
* `kind`:
* - 'command' → a shell command string (Bash). Newlines are PRESERVED; every
* other control/ANSI char is stripped. The FE renders it in a <pre> via
* textContent only (never innerHTML).
* - 'diff' → ONE synthetic DiffFile (Edit/Write/MultiEdit/NotebookEdit),
* rendered by public/diff.ts renderDiffFile (textContent-only, SEC-H4).
* `truncated` = the source exceeded the line/byte cap and was clipped. */
export type ApprovalPreview =
| { kind: 'command'; text: string; truncated?: boolean }
| { kind: 'diff'; file: DiffFile; truncated?: boolean };
/** server → client. exit.code = shell code; -1 when spawn never succeeded (M4).
* exit.reason optional normally, REQUIRED on spawn failure / abnormal exit.
* status (H2/H3) = Claude Code activity; `pending` true when a tool approval
@@ -116,6 +131,10 @@ export type ServerMessage =
detail?: string;
pending?: boolean;
gate?: PermissionGate;
/** W1: bounded preview of the held tool's command/diff; present only on a
* held (pending) waiting status for a Bash/Edit-family tool. Older clients
* ignore it (additive + optional). */
preview?: ApprovalPreview;
}
| { type: 'telemetry'; telemetry: StatusTelemetry };
@@ -336,6 +355,7 @@ export interface SessionManager {
gate?: PermissionGate,
eventClass?: string,
toolName?: string,
preview?: ApprovalPreview,
): void;
/** B2: store the latest statusLine telemetry for a session and broadcast a
* `telemetry` message to all attached clients. */