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

@@ -26,7 +26,8 @@ import { ICON_TIMELINE } from './icons.js'
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
import { mountLauncher, type Launcher } from './launcher.js'
import { mountProjects, type ProjectsPanel } from './projects.js'
import type { ClaudeStatus, PermissionMode, UiConfig } from '../src/types.js'
import type { ApprovalPreview, ClaudeStatus, PermissionMode, UiConfig } from '../src/types.js'
import { renderDiffFile } from './diff.js'
import { renderTelemetryGauge } from './preview-grid.js'
import { mountPushToggle } from './push.js'
import { mountQuickReply } from './quick-reply.js'
@@ -366,16 +367,45 @@ export class TabApp {
this.approvalBar.replaceChildren()
const label = document.createElement('span')
label.className = 'approval-label'
// W1: show WHAT will run (command / diff) between the label and the buttons,
// so a one-tap remote approval is no longer blind. Absent for plan gates and
// unknown tools (no reviewable command/diff) → today's name-only bar.
const preview = session.pendingPreview
const previewNode = preview ? this.renderApprovalPreview(preview) : null
const middle = previewNode ? [previewNode] : []
if (session.pendingGate === 'plan') {
label.textContent = 'Claude finished planning — how should it proceed?'
this.approvalBar.append(label, ...this.planGateButtons(session))
this.approvalBar.append(label, ...middle, ...this.planGateButtons(session))
} else {
label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}`
this.approvalBar.append(label, ...this.toolGateButtons(session))
this.approvalBar.append(label, ...middle, ...this.toolGateButtons(session))
}
this.approvalBar.style.display = 'flex'
}
/** W1: build the command/diff preview node for the approval bar. Untrusted,
* server-sanitized content is rendered via textContent / renderDiffFile ONLY
* (never innerHTML) — <script>, ANSI, & etc. appear as literal characters. */
private renderApprovalPreview(p: ApprovalPreview): HTMLElement {
const container = document.createElement('div')
container.className = 'approval-preview'
if (p.kind === 'command') {
const pre = document.createElement('pre')
pre.className = 'approval-cmd'
pre.textContent = p.text // textContent — attacker-influenced command bytes
container.append(pre)
} else {
container.append(renderDiffFile(p.file)) // diff.ts is innerHTML-free (SEC-H4)
}
if (p.truncated) {
const note = document.createElement('div')
note.className = 'approval-truncated'
note.textContent = '… truncated'
container.append(note)
}
return container
}
/** Ordinary tool gate: Approve / Reject (two buttons, unchanged). */
private toolGateButtons(session: TerminalSession): HTMLButtonElement[] {
return [