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:
@@ -1086,6 +1086,7 @@ body {
|
||||
inset: auto 0 calc(var(--keybar-h) + var(--safe-b)) 0;
|
||||
z-index: 1050;
|
||||
display: flex;
|
||||
flex-wrap: wrap; /* W1: a preview row wraps below the label + buttons */
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
@@ -1097,6 +1098,31 @@ body {
|
||||
.approval-label {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
/* W1: approval preview (command/diff) — its own full-width row, scrollable. */
|
||||
.approval-preview {
|
||||
flex: 1 1 100%;
|
||||
order: 3; /* below the label + buttons regardless of insertion order */
|
||||
max-height: 30vh;
|
||||
overflow: auto;
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgba(245, 177, 76, 0.35);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.approval-cmd {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
color: #f4f5f7;
|
||||
}
|
||||
.approval-truncated {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
#approvalbar button {
|
||||
flex: none;
|
||||
border: none;
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user