From e062065cd3c751da8692fd783d8688ae4e3346af Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sun, 12 Jul 2026 20:04:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(cockpit):=20approval=20preview=20=E2=80=94?= =?UTF-8?q?=20command/diff=20above=20Approve/Reject=20(W1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 →
  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.
---
 public/style.css                   |  26 +++
 public/tabs.ts                     |  36 +++-
 public/terminal-session.ts         |  15 ++
 src/http/approval-preview.ts       | 263 +++++++++++++++++++++++++++++
 src/server.ts                      |  34 +++-
 src/session/manager.ts             |   7 +-
 src/types.ts                       |  20 +++
 test/http/approval-preview.test.ts | 189 +++++++++++++++++++++
 test/integration/server.test.ts    |  68 ++++++++
 test/manager.test.ts               |  33 ++++
 test/tabs.test.ts                  |  72 ++++++++
 test/terminal-session.test.ts      |  53 ++++++
 12 files changed, 808 insertions(+), 8 deletions(-)
 create mode 100644 src/http/approval-preview.ts
 create mode 100644 test/http/approval-preview.test.ts

diff --git a/public/style.css b/public/style.css
index 2933ae2..ca25da5 100644
--- a/public/style.css
+++ b/public/style.css
@@ -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;
diff --git a/public/tabs.ts b/public/tabs.ts
index fb2890b..1c41178 100644
--- a/public/tabs.ts
+++ b/public/tabs.ts
@@ -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) —