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

@@ -35,6 +35,7 @@ import { loadConfig } from './config.js'
import { parseClientMessage, serialize } from './protocol.js'
import { isOriginAllowed } from './http/origin.js'
import { parseHookEvent } from './http/hook.js'
import { deriveApprovalPreview } from './http/approval-preview.js'
import { listSessions } from './http/history.js'
import { buildProjects, buildProjectDetail } from './http/projects.js'
import { openInEditor, openFileInEditor } from './http/editor.js'
@@ -49,6 +50,7 @@ import { createPushService } from './push/push-service.js'
import { combineNotifyServices, initApns, normalizeApnsToken } from './push/apns.js'
import { initFcm, normalizeFcmToken } from './push/fcm.js'
import type {
ApprovalPreview,
Config,
NotifyService,
PermissionGate,
@@ -228,6 +230,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
token: string
expiresAt: number
gate: PermissionGate
/** W1: bounded command/diff preview of the held tool, re-sent to late joiners
* exactly like `gate`. Undefined for non-previewable tools (e.g. ExitPlanMode). */
preview?: ApprovalPreview
}
const pendingApprovals = new Map<string, PendingApproval>()
@@ -460,6 +465,11 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// B4: an ExitPlanMode request is a 'plan' gate (three-way), else a 'tool' gate.
const gate: PermissionGate = tool === 'ExitPlanMode' ? 'plan' : 'tool'
// W1: derive a bounded, sanitized command/diff preview from the untrusted
// tool_input so the approve/reject bar shows WHAT will run (never blind).
// Non-previewable tools / malformed input → undefined (name-only bar).
const preview = deriveApprovalPreview(tool, body['tool_input']) ?? undefined
resolvePending(sessionId, {}) // clear any stale hold for this session
const token = randomUUID()
const expiresAt = Date.now() + cfg.decisionTokenTtlMs
@@ -468,14 +478,21 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
res.json({}) // timeout → fall back to Claude's interactive prompt
manager.handleHookEvent(sessionId, 'idle')
}, cfg.permTimeoutMs)
pendingApprovals.set(sessionId, { res, timer, token, expiresAt, gate })
pendingApprovals.set(sessionId, {
res,
timer,
token,
expiresAt,
gate,
...(preview !== undefined ? { preview } : {}),
})
// A1: push the lock-screen approval (carrying the capability token) to every
// subscribed device. Best-effort — failures are logged inside push-service.
void pushService.notify(session, 'needs-input', token)
// Show the approve/reject affordance on every attached client.
manager.handleHookEvent(sessionId, 'waiting', tool, true, gate)
// Show the approve/reject affordance (+ W1 preview) on every attached client.
manager.handleHookEvent(sessionId, 'waiting', tool, true, gate, undefined, undefined, preview)
})
// ── A1 push subscription + lock-screen decision routes ────────────────────
@@ -864,9 +881,18 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// portion lives here because the server owns pendingApprovals).
const heldApproval = pendingApprovals.get(boundSessionId)
if (heldApproval !== undefined) {
// W1: re-send the bounded preview alongside gate so a late-joining
// device sees WHAT is held, not just that something is. JSON.stringify
// drops `preview` when undefined (non-previewable tools).
safeSend(
ws,
serialize({ type: 'status', status: 'waiting', pending: true, gate: heldApproval.gate }),
serialize({
type: 'status',
status: 'waiting',
pending: true,
gate: heldApproval.gate,
...(heldApproval.preview !== undefined ? { preview: heldApproval.preview } : {}),
}),
)
}
return