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:
263
src/http/approval-preview.ts
Normal file
263
src/http/approval-preview.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user