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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user