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

@@ -712,6 +712,74 @@ describe('startServer — integration', () => {
},
)
// ── W1: /hook/permission carries a bounded preview + re-sends to late joiners ─
itPty(
'W1 [needs real PTY (sandbox-off)] /hook/permission broadcasts a preview and re-sends it to a late joiner',
async () => {
const ws1 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
headers: { Origin: `http://127.0.0.1:${port}` },
})
await waitForOpen(ws1, 3_000)
ws1.send(JSON.stringify({ type: 'attach', sessionId: null }))
const attached = (await waitForMessage(
ws1,
(m) =>
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'attached',
5_000,
)) as Record<string, unknown>
const sessionId = attached['sessionId'] as string
// Arm the pending-status listener on ws1 before firing (push is synchronous).
const pendingPromise = waitForMessage(
ws1,
(m) =>
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['pending'] === true,
5_000,
)
// Fire a Bash PermissionRequest carrying tool_input — HELD until approve.
const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
body: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'echo hi' } }),
})
const pending = (await pendingPromise) as Record<string, unknown>
expect(pending['status']).toBe('waiting')
const preview = pending['preview'] as Record<string, unknown> | undefined
expect(preview).toBeDefined()
expect(preview!['kind']).toBe('command')
expect(String(preview!['text'])).toContain('echo hi')
// Late joiner: a SECOND ws attaching to the same session while the approval
// is held must receive the preview on attach (re-sent like `gate`).
const ws2 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
headers: { Origin: `http://127.0.0.1:${port}` },
})
await waitForOpen(ws2, 3_000)
const joinPending = waitForMessage(
ws2,
(m) =>
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['pending'] === true,
5_000,
)
ws2.send(JSON.stringify({ type: 'attach', sessionId }))
const joined = (await joinPending) as Record<string, unknown>
const joinedPreview = joined['preview'] as Record<string, unknown> | undefined
expect(joinedPreview).toBeDefined()
expect(joinedPreview!['kind']).toBe('command')
expect(String(joinedPreview!['text'])).toContain('echo hi')
// Approve over ws1 to release the held POST, then clean up.
ws1.send(JSON.stringify({ type: 'approve' }))
await permPromise.then((r) => r.json()).catch(() => undefined)
ws1.close()
ws2.close()
await waitForClose(ws1, 3_000).catch(() => undefined)
await waitForClose(ws2, 3_000).catch(() => undefined)
},
)
// ── H1: tmux keepalive — a shell survives a full server restart ────────────
itTmux(
'H1 [needs tmux + real PTY] shell state survives a server restart',