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

@@ -0,0 +1,263 @@
/**
* src/http/approval-preview.ts (W1) — derive a bounded, sanitized preview of a
* held tool approval so a remote one-tap approve is no longer blind.
*
* Pure + never throws (SEC-M7 discipline, mirroring parseHookEvent). The input
* is the Claude Code hook `tool_input` — attacker-influenced content arriving on
* the loopback `/hook/permission` route. It is treated as `unknown`, every field
* narrowed before use, and every emitted string passed through sanitizeField
* (strips ASCII control chars incl. ANSI ESC / BEL) and line/byte-capped so a
* hostile or huge tool_input can never bloat the broadcast / pendingApprovals.
*
* Mapping:
* Bash → { kind:'command', text }
* Edit / Write / MultiEdit / NotebookEdit → { kind:'diff', file }
* anything else / malformed input → null (FE falls back to a name-only bar)
*
* SECURITY: this module NEVER runs a command, touches fs, or interprets a path —
* it only produces display data. Rendering is textContent / renderDiffFile only
* (public/*), never innerHTML.
*/
import type { ApprovalPreview, DiffFile, DiffHunk, DiffLine } from '../types.js';
import { sanitizeField } from '../session/timeline.js';
// ── bounds (security limits, not user knobs — see plan §"New env vars") ────────
/** Max diff/command lines emitted across the whole preview. */
export const PREVIEW_MAX_LINES = 40;
/** Per-line char cap (matches sanitizeField's default). */
export const PREVIEW_MAX_LINE_LEN = 200;
/** Hard total-byte cap on the emitted preview text (all lines combined). */
export const PREVIEW_MAX_BYTES = 4096;
/** Tools that map to a diff preview (mirrors timeline.ts EDIT_TOOLS semantics). */
const EDIT_TOOLS: ReadonlySet<string> = new Set([
'Edit',
'Write',
'MultiEdit',
'NotebookEdit',
]);
// ── helpers ────────────────────────────────────────────────────────────────────
/** Narrow an unknown value to a plain object record, or null. */
function asRecord(v: unknown): Record<string, unknown> | null {
if (v === null || typeof v !== 'object' || Array.isArray(v)) return null;
return v as Record<string, unknown>;
}
/** Sanitize a single line: strip control/ANSI chars (via sanitizeField), cap to
* PREVIEW_MAX_LINE_LEN, and report whether the cap actually clipped content (so
* a hidden over-long line still flags the preview as truncated). Newlines never
* reach here — callers split on '\n' first so structure survives. */
function sanitizeLine(s: string): { text: string; clipped: boolean } {
// sanitizeField with an effectively-unbounded max = control-strip only (DRY:
// reuse the SEC-H6 regex), then apply OUR length cap + clip detection here.
const stripped = sanitizeField(s, Number.MAX_SAFE_INTEGER);
const text = stripped.slice(0, PREVIEW_MAX_LINE_LEN);
return { text, clipped: stripped.length > PREVIEW_MAX_LINE_LEN };
}
/** Split a raw multi-line blob into individual (still-raw) lines. */
function splitLines(s: string): string[] {
return s.split('\n');
}
/** UTF-8 byte length of a string (whole-string). */
function byteLen(s: string): number {
return Buffer.byteLength(s, 'utf8');
}
/** A budget shared across all emitted lines of one preview. */
interface Budget {
linesLeft: number;
bytesLeft: number;
truncated: boolean;
}
function newBudget(): Budget {
return { linesLeft: PREVIEW_MAX_LINES, bytesLeft: PREVIEW_MAX_BYTES, truncated: false };
}
/**
* Truncate `s` to whole characters that fit within `maxBytes` UTF-8 bytes
* (never splitting a multi-byte codepoint). Returns whether it was clamped.
*/
function clampBytes(s: string, maxBytes: number): { text: string; clamped: boolean } {
if (byteLen(s) <= maxBytes) return { text: s, clamped: false };
let out = '';
let used = 0;
for (const ch of s) {
const b = byteLen(ch);
if (used + b > maxBytes) break;
out += ch;
used += b;
}
return { text: out, clamped: true };
}
// ── Bash → command preview ───────────────────────────────────────────────────────
function deriveCommand(input: Record<string, unknown>): ApprovalPreview | null {
const command = input['command'];
if (typeof command !== 'string') return null;
const rawLines = splitLines(command);
const lineCountTruncated = rawLines.length > PREVIEW_MAX_LINES;
let anyClipped = false;
const joined = rawLines
.slice(0, PREVIEW_MAX_LINES)
.map((l) => {
const r = sanitizeLine(l);
if (r.clipped) anyClipped = true;
return r.text;
})
.join('\n');
const { text, clamped } = clampBytes(joined, PREVIEW_MAX_BYTES);
const truncated = lineCountTruncated || anyClipped || clamped;
return { kind: 'command', text, ...(truncated ? { truncated: true } : {}) };
}
// ── Edit-family → diff preview ───────────────────────────────────────────────────
/** One hunk's worth of source lines (still raw, unsanitized). */
interface HunkSpec {
removed: string[];
added: string[];
}
/** Pick a display path from the tool_input, or '' when none is present. */
function pickPath(input: Record<string, unknown>): string {
const fp = input['file_path'];
if (typeof fp === 'string') return fp;
const np = input['notebook_path'];
if (typeof np === 'string') return np;
return '';
}
/** Turn an {old_string,new_string} object into a HunkSpec. Requires BOTH fields
* to be strings (an Edit missing old_string / new_string → null → drop). */
function toHunkSpec(v: unknown): HunkSpec | null {
const rec = asRecord(v);
if (rec === null) return null;
const oldStr = rec['old_string'];
const newStr = rec['new_string'];
if (typeof oldStr !== 'string' || typeof newStr !== 'string') return null;
return { removed: splitLines(oldStr), added: splitLines(newStr) };
}
/** Emit a bounded array of DiffLines from raw source lines, honouring the budget. */
function emitLines(
kind: DiffLine['kind'],
rawLines: readonly string[],
budget: Budget,
): DiffLine[] {
const out: DiffLine[] = [];
for (const raw of rawLines) {
if (budget.linesLeft <= 0) {
budget.truncated = true;
break;
}
const { text, clipped } = sanitizeLine(raw);
if (clipped) budget.truncated = true;
const b = byteLen(text);
if (b > budget.bytesLeft) {
budget.truncated = true;
break;
}
out.push({ kind, text });
budget.linesLeft -= 1;
budget.bytesLeft -= b;
}
return out;
}
/** Build a synthetic DiffFile preview from one-or-more hunk specs. */
function buildDiffPreview(rawPath: string, specs: readonly HunkSpec[]): ApprovalPreview {
const path = sanitizeLine(rawPath).text;
let addedTotal = 0;
let removedTotal = 0;
for (const s of specs) {
addedTotal += s.added.length;
removedTotal += s.removed.length;
}
const budget = newBudget();
const hunks: DiffHunk[] = [];
for (let i = 0; i < specs.length; i++) {
const spec = specs[i]!;
const lines = [
...emitLines('removed', spec.removed, budget),
...emitLines('added', spec.added, budget),
];
if (lines.length > 0) {
hunks.push({ header: specs.length > 1 ? `edit ${i + 1}` : '', lines });
}
if (budget.truncated) break;
}
const file: DiffFile = {
oldPath: path,
newPath: path,
status: removedTotal === 0 ? 'added' : 'modified',
added: addedTotal,
removed: removedTotal,
binary: false,
hunks,
};
return { kind: 'diff', file, ...(budget.truncated ? { truncated: true } : {}) };
}
function deriveDiff(toolName: string, input: Record<string, unknown>): ApprovalPreview | null {
const path = pickPath(input);
if (toolName === 'MultiEdit') {
const edits = input['edits'];
if (!Array.isArray(edits)) return null;
const specs = edits.map(toHunkSpec).filter((s): s is HunkSpec => s !== null);
if (specs.length === 0) return null;
return buildDiffPreview(path, specs);
}
if (toolName === 'Write') {
const content = input['content'];
if (typeof content !== 'string') return null;
return buildDiffPreview(path, [{ removed: [], added: splitLines(content) }]);
}
if (toolName === 'NotebookEdit') {
const source = input['new_source'];
if (typeof source !== 'string') return null;
return buildDiffPreview(path, [{ removed: [], added: splitLines(source) }]);
}
// Edit: a single {old_string,new_string} hunk.
const spec = toHunkSpec(input);
if (spec === null) return null;
return buildDiffPreview(path, [spec]);
}
// ── public entry point ───────────────────────────────────────────────────────────
/**
* Derive a bounded approval preview from a hook tool_input, or null when the
* tool is not previewable or the input is malformed. NEVER throws (SEC-M7).
*/
export function deriveApprovalPreview(
toolName: string | undefined,
toolInput: unknown,
): ApprovalPreview | null {
try {
if (toolName === undefined) return null;
const input = asRecord(toolInput);
if (input === null) return null;
if (toolName === 'Bash') return deriveCommand(input);
if (EDIT_TOOLS.has(toolName)) return deriveDiff(toolName, input);
return null;
} catch {
// SEC-M7: any unexpected shape must degrade to "no preview", never crash.
return null;
}
}

View File

@@ -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

View File

@@ -26,6 +26,7 @@
*/
import type {
ApprovalPreview,
ClaudeStatus,
Config,
Dims,
@@ -218,7 +219,9 @@ export function createSessionManager(
* H2/H3: a Claude Code hook reported activity for `sessionId`. Update the
* status, append a timeline event (A4) when `eventClass` is supplied and the
* timeline is enabled, and broadcast a `status` frame — carrying `gate` (B4)
* when the held approval is a 'tool'/'plan' gate. No-op for an unknown session.
* when the held approval is a 'tool'/'plan' gate, and `preview` (W1) when a
* bounded command/diff preview of the held tool was derived. No-op for an
* unknown session.
*/
function handleHookEvent(
sessionId: string,
@@ -228,6 +231,7 @@ export function createSessionManager(
gate?: PermissionGate,
eventClass?: string,
toolName?: string,
preview?: ApprovalPreview,
): void {
const session = sessions.get(sessionId);
if (session === undefined) return;
@@ -246,6 +250,7 @@ export function createSessionManager(
if (detail !== undefined) msg.detail = detail;
if (pending) msg.pending = true;
if (gate !== undefined) msg.gate = gate;
if (preview !== undefined) msg.preview = preview;
broadcast(session, msg);
}

View File

@@ -100,6 +100,21 @@ export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | 'stuck';
* gate (three-way approve/auto/keep-planning); 'tool' = an ordinary tool gate. */
export type PermissionGate = 'tool' | 'plan';
/** W1: a compact, BOUNDED preview of what a held tool approval would run, so a
* remote one-tap approval is no longer blind. Derived server-side from the hook
* `tool_input` (attacker-influenced) — sanitized (control/ANSI chars stripped),
* line-capped and byte-capped in src/http/approval-preview.ts. Discriminated on
* `kind`:
* - 'command' → a shell command string (Bash). Newlines are PRESERVED; every
* other control/ANSI char is stripped. The FE renders it in a <pre> via
* textContent only (never innerHTML).
* - 'diff' → ONE synthetic DiffFile (Edit/Write/MultiEdit/NotebookEdit),
* rendered by public/diff.ts renderDiffFile (textContent-only, SEC-H4).
* `truncated` = the source exceeded the line/byte cap and was clipped. */
export type ApprovalPreview =
| { kind: 'command'; text: string; truncated?: boolean }
| { kind: 'diff'; file: DiffFile; truncated?: boolean };
/** server → client. exit.code = shell code; -1 when spawn never succeeded (M4).
* exit.reason optional normally, REQUIRED on spawn failure / abnormal exit.
* status (H2/H3) = Claude Code activity; `pending` true when a tool approval
@@ -116,6 +131,10 @@ export type ServerMessage =
detail?: string;
pending?: boolean;
gate?: PermissionGate;
/** W1: bounded preview of the held tool's command/diff; present only on a
* held (pending) waiting status for a Bash/Edit-family tool. Older clients
* ignore it (additive + optional). */
preview?: ApprovalPreview;
}
| { type: 'telemetry'; telemetry: StatusTelemetry };
@@ -336,6 +355,7 @@ export interface SessionManager {
gate?: PermissionGate,
eventClass?: string,
toolName?: string,
preview?: ApprovalPreview,
): void;
/** B2: store the latest statusLine telemetry for a session and broadcast a
* `telemetry` message to all attached clients. */