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:
@@ -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;
|
||||
|
||||
@@ -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) — <script>, ANSI, & etc. appear as literal characters. */
|
||||
private renderApprovalPreview(p: ApprovalPreview): HTMLElement {
|
||||
const container = document.createElement('div')
|
||||
container.className = 'approval-preview'
|
||||
if (p.kind === 'command') {
|
||||
const pre = document.createElement('pre')
|
||||
pre.className = 'approval-cmd'
|
||||
pre.textContent = p.text // textContent — attacker-influenced command bytes
|
||||
container.append(pre)
|
||||
} else {
|
||||
container.append(renderDiffFile(p.file)) // diff.ts is innerHTML-free (SEC-H4)
|
||||
}
|
||||
if (p.truncated) {
|
||||
const note = document.createElement('div')
|
||||
note.className = 'approval-truncated'
|
||||
note.textContent = '… truncated'
|
||||
container.append(note)
|
||||
}
|
||||
return container
|
||||
}
|
||||
|
||||
/** Ordinary tool gate: Approve / Reject (two buttons, unchanged). */
|
||||
private toolGateButtons(session: TerminalSession): HTMLButtonElement[] {
|
||||
return [
|
||||
|
||||
@@ -14,6 +14,7 @@ import { FitAddon } from '@xterm/addon-fit'
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import type {
|
||||
ApprovalPreview,
|
||||
ClaudeStatus,
|
||||
ClientMessage,
|
||||
PermissionGate,
|
||||
@@ -147,6 +148,9 @@ export class TerminalSession {
|
||||
private pendingToolValue: string | undefined = undefined
|
||||
private telemetryValue: StatusTelemetry | null = null
|
||||
private pendingGateValue: PermissionGate | null = null
|
||||
// W1: bounded command/diff preview of the held tool, from the last pending
|
||||
// status frame. Null when no approval is held or the tool wasn't previewable.
|
||||
private pendingPreviewValue: ApprovalPreview | null = null
|
||||
// VC: nonce that increments on every false→true pendingApproval flip — lets a
|
||||
// caller (e.g. a voice command captured at PTT-start) detect a stale gate: a
|
||||
// slow transcript must not resolve a NEWER held permission than the one it
|
||||
@@ -257,6 +261,12 @@ export class TerminalSession {
|
||||
return this.pendingGateValue
|
||||
}
|
||||
|
||||
/** W1: bounded command/diff preview of the held tool (from the last pending
|
||||
* status frame), or null when nothing is held / the tool wasn't previewable. */
|
||||
get pendingPreview(): ApprovalPreview | null {
|
||||
return this.pendingPreviewValue
|
||||
}
|
||||
|
||||
/** Nonce counting false→true pendingApproval flips (VC stale-gate guard, §5). */
|
||||
get pendingEpoch(): number {
|
||||
return this.pendingEpochValue
|
||||
@@ -372,6 +382,9 @@ export class TerminalSession {
|
||||
this.pendingApprovalValue = nextPending
|
||||
this.pendingToolValue = nextPending ? msg.detail : undefined
|
||||
this.pendingGateValue = msg.gate ?? null
|
||||
// W1: keep the preview only while an approval is held; a non-pending
|
||||
// status (approve/reject resolved) clears it so the bar hides cleanly.
|
||||
this.pendingPreviewValue = nextPending ? (msg.preview ?? null) : null
|
||||
this.onClaudeStatus?.(msg.status, msg.detail)
|
||||
break
|
||||
}
|
||||
@@ -501,10 +514,12 @@ export class TerminalSession {
|
||||
* can distinguish "approve with default" from "approve, keep existing mode". */
|
||||
approve(mode?: PermissionMode): void {
|
||||
this.pendingApprovalValue = false
|
||||
this.pendingPreviewValue = null // W1: resolved → drop the stale preview
|
||||
this.sendMsg({ type: 'approve', ...(mode !== undefined ? { mode } : {}) })
|
||||
}
|
||||
reject(): void {
|
||||
this.pendingApprovalValue = false
|
||||
this.pendingPreviewValue = null // W1: resolved → drop the stale preview
|
||||
this.sendMsg({ type: 'reject' })
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
20
src/types.ts
20
src/types.ts
@@ -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. */
|
||||
|
||||
189
test/http/approval-preview.test.ts
Normal file
189
test/http/approval-preview.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* test/http/approval-preview.test.ts (W1) — deriveApprovalPreview unit tests.
|
||||
*
|
||||
* Pure, never-throws derivation of a bounded, sanitized command/diff preview
|
||||
* from an untrusted hook tool_input. Node env (no DOM).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
deriveApprovalPreview,
|
||||
PREVIEW_MAX_LINES,
|
||||
PREVIEW_MAX_BYTES,
|
||||
} from '../../src/http/approval-preview.js'
|
||||
import type { ApprovalPreview, DiffFile } from '../../src/types.js'
|
||||
|
||||
/** Narrow a preview to its 'diff' variant (throws in-test if wrong shape). */
|
||||
function asDiff(p: ApprovalPreview | null): DiffFile {
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.kind).toBe('diff')
|
||||
return (p as { kind: 'diff'; file: DiffFile }).file
|
||||
}
|
||||
|
||||
describe('deriveApprovalPreview — Bash → command', () => {
|
||||
it('extracts the command string', () => {
|
||||
const p = deriveApprovalPreview('Bash', { command: 'ls -la', description: 'x' })
|
||||
expect(p).toEqual({ kind: 'command', text: 'ls -la' })
|
||||
})
|
||||
|
||||
it('preserves newlines in a multi-line command', () => {
|
||||
const p = deriveApprovalPreview('Bash', { command: 'a\nb' })
|
||||
expect(p).toMatchObject({ kind: 'command' })
|
||||
expect((p as { text: string }).text).toContain('\n')
|
||||
expect((p as { text: string }).text).toBe('a\nb')
|
||||
})
|
||||
|
||||
it('strips ANSI/control injection (ESC, BEL) from the command', () => {
|
||||
const p = deriveApprovalPreview('Bash', { command: '\x1b[31mrm\x1b[0m\x07' })
|
||||
const text = (p as { text: string }).text
|
||||
expect(text).not.toContain('\x1b')
|
||||
expect(text).not.toContain('\x07')
|
||||
expect(text).toBe('[31mrm[0m') // control bytes gone, printable payload remains
|
||||
})
|
||||
|
||||
it('returns null when command is missing / not a string', () => {
|
||||
expect(deriveApprovalPreview('Bash', { description: 'x' })).toBeNull()
|
||||
expect(deriveApprovalPreview('Bash', { command: 42 })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveApprovalPreview — Edit → diff', () => {
|
||||
it('builds one hunk with removed + added lines and correct counts', () => {
|
||||
const file = asDiff(
|
||||
deriveApprovalPreview('Edit', {
|
||||
file_path: '/p/f.ts',
|
||||
old_string: 'a\nb',
|
||||
new_string: 'c',
|
||||
}),
|
||||
)
|
||||
expect(file.newPath).toBe('/p/f.ts')
|
||||
expect(file.removed).toBe(2)
|
||||
expect(file.added).toBe(1)
|
||||
expect(file.hunks).toHaveLength(1)
|
||||
const kinds = file.hunks[0]!.lines.map((l) => `${l.kind}:${l.text}`)
|
||||
expect(kinds).toEqual(['removed:a', 'removed:b', 'added:c'])
|
||||
})
|
||||
|
||||
it('sanitizes the file path (strips control chars)', () => {
|
||||
const file = asDiff(
|
||||
deriveApprovalPreview('Edit', {
|
||||
file_path: '/p/\x1b[2Jevil.ts',
|
||||
old_string: 'x',
|
||||
new_string: 'y',
|
||||
}),
|
||||
)
|
||||
expect(file.newPath).not.toContain('\x1b')
|
||||
expect(file.newPath).toBe('/p/[2Jevil.ts')
|
||||
})
|
||||
|
||||
it('returns null for an Edit missing old_string', () => {
|
||||
expect(deriveApprovalPreview('Edit', { file_path: '/p/f', new_string: 'c' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveApprovalPreview — Write → all-added diff', () => {
|
||||
it('emits an all-added hunk with removed===0', () => {
|
||||
const file = asDiff(deriveApprovalPreview('Write', { file_path: '/p/f', content: 'x\ny' }))
|
||||
expect(file.removed).toBe(0)
|
||||
expect(file.added).toBe(2)
|
||||
expect(file.status).toBe('added')
|
||||
expect(file.hunks[0]!.lines.map((l) => l.kind)).toEqual(['added', 'added'])
|
||||
})
|
||||
|
||||
it('returns null when content is not a string', () => {
|
||||
expect(deriveApprovalPreview('Write', { file_path: '/p/f' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveApprovalPreview — MultiEdit → one hunk per edit', () => {
|
||||
it('produces a hunk for each edit', () => {
|
||||
const file = asDiff(
|
||||
deriveApprovalPreview('MultiEdit', {
|
||||
file_path: '/p/f',
|
||||
edits: [
|
||||
{ old_string: 'a', new_string: 'b' },
|
||||
{ old_string: 'c', new_string: 'd' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(file.hunks).toHaveLength(2)
|
||||
expect(file.removed).toBe(2)
|
||||
expect(file.added).toBe(2)
|
||||
})
|
||||
|
||||
it('drops malformed edit entries; null when all are invalid', () => {
|
||||
expect(
|
||||
deriveApprovalPreview('MultiEdit', { file_path: '/p/f', edits: [{ old_string: 'a' }] }),
|
||||
).toBeNull()
|
||||
const file = asDiff(
|
||||
deriveApprovalPreview('MultiEdit', {
|
||||
file_path: '/p/f',
|
||||
edits: [{ old_string: 'a' }, { old_string: 'x', new_string: 'y' }],
|
||||
}),
|
||||
)
|
||||
expect(file.hunks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns null when edits is not an array', () => {
|
||||
expect(deriveApprovalPreview('MultiEdit', { file_path: '/p/f', edits: 'nope' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveApprovalPreview — NotebookEdit', () => {
|
||||
it('previews new_source as an all-added diff', () => {
|
||||
const file = asDiff(
|
||||
deriveApprovalPreview('NotebookEdit', { notebook_path: '/n.ipynb', new_source: 'print(1)' }),
|
||||
)
|
||||
expect(file.newPath).toBe('/n.ipynb')
|
||||
expect(file.added).toBe(1)
|
||||
expect(file.removed).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveApprovalPreview — truncation (DoS containment)', () => {
|
||||
it('caps line count and flags truncated for a huge command', () => {
|
||||
const command = Array.from({ length: PREVIEW_MAX_LINES + 20 }, (_, i) => `line${i}`).join('\n')
|
||||
const p = deriveApprovalPreview('Bash', { command })
|
||||
expect(p).toMatchObject({ kind: 'command', truncated: true })
|
||||
const text = (p as { text: string }).text
|
||||
expect(text.split('\n').length).toBeLessThanOrEqual(PREVIEW_MAX_LINES)
|
||||
})
|
||||
|
||||
it('caps total bytes for a huge single-line blob', () => {
|
||||
const command = 'x'.repeat(PREVIEW_MAX_BYTES * 4)
|
||||
const p = deriveApprovalPreview('Bash', { command })
|
||||
expect(p).toMatchObject({ truncated: true })
|
||||
const text = (p as { text: string }).text
|
||||
expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(PREVIEW_MAX_BYTES)
|
||||
})
|
||||
|
||||
it('caps a giant Write and keeps the serialized diff within the byte cap', () => {
|
||||
const content = Array.from({ length: 500 }, (_, i) => `row-${i}-`.repeat(50)).join('\n')
|
||||
const p = deriveApprovalPreview('Write', { file_path: '/p/f', content })
|
||||
expect(p).toMatchObject({ kind: 'diff', truncated: true })
|
||||
const file = asDiff(p)
|
||||
const emitted = file.hunks.flatMap((h) => h.lines)
|
||||
expect(emitted.length).toBeLessThanOrEqual(PREVIEW_MAX_LINES)
|
||||
const bytes = emitted.reduce((n, l) => n + Buffer.byteLength(l.text, 'utf8'), 0)
|
||||
expect(bytes).toBeLessThanOrEqual(PREVIEW_MAX_BYTES)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveApprovalPreview — unknown tools & malformed input (never throws)', () => {
|
||||
it('returns null for a non-previewable tool', () => {
|
||||
expect(deriveApprovalPreview('WebFetch', { url: 'http://x' })).toBeNull()
|
||||
expect(deriveApprovalPreview('ExitPlanMode', {})).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for undefined tool name', () => {
|
||||
expect(deriveApprovalPreview(undefined, { command: 'ls' })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null (never throws) for null / array / number / string tool_input', () => {
|
||||
expect(() => deriveApprovalPreview('Bash', null)).not.toThrow()
|
||||
expect(deriveApprovalPreview('Bash', null)).toBeNull()
|
||||
expect(deriveApprovalPreview('Bash', [1, 2])).toBeNull()
|
||||
expect(deriveApprovalPreview('Edit', 42)).toBeNull()
|
||||
expect(deriveApprovalPreview('Bash', 'ls -la')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -799,6 +799,39 @@ describe('handleHookEvent — gate broadcast (B4)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleHookEvent — W1 preview broadcast ────────────────────────────────────
|
||||
describe('handleHookEvent — preview broadcast (W1)', () => {
|
||||
it('puts the preview arg on the broadcast status frame (deep equal)', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const a = createMockWs();
|
||||
const b = createMockWs();
|
||||
const s = mgr.handleAttach(a, null, DIMS, 1_000);
|
||||
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
|
||||
a.sent.length = 0;
|
||||
b.sent.length = 0;
|
||||
|
||||
const preview = { kind: 'command', text: 'ls -la' } as const;
|
||||
mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true, 'tool', undefined, undefined, preview);
|
||||
|
||||
for (const ws of [a, b]) {
|
||||
const status = parseSent(ws).find((m) => m.type === 'status');
|
||||
expect(status.preview).toEqual(preview);
|
||||
}
|
||||
});
|
||||
|
||||
it('omits the preview key when no preview is supplied', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
ws.sent.length = 0;
|
||||
|
||||
mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true, 'tool');
|
||||
|
||||
const status = parseSent(ws).find((m) => m.type === 'status');
|
||||
expect(status).not.toHaveProperty('preview');
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleStatusLine (B2 telemetry) ───────────────────────────────────────────
|
||||
describe('handleStatusLine', () => {
|
||||
it('stores telemetry on the session and broadcasts it to all clients', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { ApprovalPreview } from '../src/types.js'
|
||||
|
||||
// ── Mock TerminalSession ──────────────────────────────────────────────────────
|
||||
let constructed = 0
|
||||
@@ -22,6 +23,8 @@ class FakeTerminalSession {
|
||||
pendingTool: string | undefined = undefined
|
||||
/** B4: which gate the held approval is — 'tool' (two buttons) or 'plan' (three). */
|
||||
pendingGate: 'tool' | 'plan' | null = null
|
||||
/** W1: bounded command/diff preview of the held tool (or null). */
|
||||
pendingPreview: ApprovalPreview | null = null
|
||||
/** VC: nonce that counts false→true pendingApproval flips (stale-gate guard). */
|
||||
pendingEpoch = 0
|
||||
/** B2: latest statusLine telemetry (single source of truth). */
|
||||
@@ -693,6 +696,75 @@ describe('TabApp — B4 plan-gate approval bar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — W1 approval preview on the bar', () => {
|
||||
/** Open one tab, hold a tool-gate approval carrying `preview`, render the bar. */
|
||||
function openWithPreview(preview: ApprovalPreview | null, tool = 'Bash'): FakeTerminalSession {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
const session = FakeTerminalSession.instances.at(-1)!
|
||||
session.pendingApproval = true
|
||||
session.pendingGate = 'tool'
|
||||
session.pendingTool = tool
|
||||
session.pendingPreview = preview
|
||||
session.cbs.onClaudeStatus?.('waiting') // triggers updateApprovalBar
|
||||
return session
|
||||
}
|
||||
|
||||
it('renders a command preview as a .approval-cmd whose textContent is the command', () => {
|
||||
openWithPreview({ kind: 'command', text: 'rm -rf /tmp/x' })
|
||||
const bar = document.getElementById('approvalbar')!
|
||||
const cmd = bar.querySelector('.approval-cmd')
|
||||
expect(cmd).not.toBeNull()
|
||||
expect(cmd!.textContent).toBe('rm -rf /tmp/x')
|
||||
// Preview sits before the buttons; buttons still present (two for a tool gate).
|
||||
expect(bar.querySelectorAll('button')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders a diff preview via renderDiffFile (.df-file present, path shown)', () => {
|
||||
const preview: ApprovalPreview = {
|
||||
kind: 'diff',
|
||||
file: {
|
||||
oldPath: '/p/f.ts',
|
||||
newPath: '/p/f.ts',
|
||||
status: 'modified',
|
||||
added: 1,
|
||||
removed: 1,
|
||||
binary: false,
|
||||
hunks: [{ header: '', lines: [{ kind: 'removed', text: 'a' }, { kind: 'added', text: 'b' }] }],
|
||||
},
|
||||
}
|
||||
openWithPreview(preview)
|
||||
const bar = document.getElementById('approvalbar')!
|
||||
expect(bar.querySelector('.approval-preview .df-file')).not.toBeNull()
|
||||
expect(bar.querySelector('.df-path')!.textContent).toBe('/p/f.ts')
|
||||
})
|
||||
|
||||
it('shows the "… truncated" note when preview.truncated is set', () => {
|
||||
openWithPreview({ kind: 'command', text: 'x', truncated: true })
|
||||
const bar = document.getElementById('approvalbar')!
|
||||
expect(bar.querySelector('.approval-truncated')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('no preview → today\'s name-only bar (regression: label + 2 buttons, no preview)', () => {
|
||||
openWithPreview(null, 'Bash')
|
||||
const bar = document.getElementById('approvalbar')!
|
||||
expect(bar.style.display).toBe('flex')
|
||||
expect(bar.querySelector('.approval-preview')).toBeNull()
|
||||
expect(bar.querySelectorAll('button')).toHaveLength(2)
|
||||
expect(bar.querySelector('.approval-label')!.textContent).toBe('Claude wants to use Bash')
|
||||
})
|
||||
|
||||
it('renders attacker-influenced content via textContent only (no HTML injection)', () => {
|
||||
openWithPreview({ kind: 'command', text: '<img src=x onerror=alert(1)>' })
|
||||
const bar = document.getElementById('approvalbar')!
|
||||
const cmd = bar.querySelector('.approval-cmd')!
|
||||
expect(cmd.textContent).toBe('<img src=x onerror=alert(1)>') // literal text
|
||||
expect(cmd.querySelector('img')).toBeNull() // NOT parsed into an element
|
||||
expect(bar.querySelector('img')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — B4 permission-mode relay', () => {
|
||||
it('openProject with a non-default mode upgrades the launch command (AC-B4.1)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
|
||||
@@ -220,6 +220,59 @@ describe('status frame (H3 pending approval)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('W1 approval preview on the status frame', () => {
|
||||
beforeEach(() => setLocation('http:', 'lan:3000'))
|
||||
|
||||
function openSession(): { s: InstanceType<typeof TerminalSession>; ws: ReturnType<typeof MockWebSocket.last> } {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
const ws = MockWebSocket.last()
|
||||
ws.openIt()
|
||||
return { s, ws }
|
||||
}
|
||||
|
||||
it('sets pendingPreview from a pending status frame', () => {
|
||||
const { s, ws } = openSession()
|
||||
ws.message(
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
status: 'waiting',
|
||||
detail: 'Bash',
|
||||
pending: true,
|
||||
gate: 'tool',
|
||||
preview: { kind: 'command', text: 'ls -la' },
|
||||
}),
|
||||
)
|
||||
expect(s.pendingPreview).toEqual({ kind: 'command', text: 'ls -la' })
|
||||
})
|
||||
|
||||
it('clears pendingPreview when a follow-up status is not pending', () => {
|
||||
const { s, ws } = openSession()
|
||||
ws.message(
|
||||
JSON.stringify({ type: 'status', status: 'waiting', pending: true, preview: { kind: 'command', text: 'ls' } }),
|
||||
)
|
||||
expect(s.pendingPreview).not.toBeNull()
|
||||
ws.message(JSON.stringify({ type: 'status', status: 'working', pending: false }))
|
||||
expect(s.pendingPreview).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves pendingPreview null when a pending status carries no preview', () => {
|
||||
const { s, ws } = openSession()
|
||||
ws.message(JSON.stringify({ type: 'status', status: 'waiting', detail: 'WebFetch', pending: true, gate: 'tool' }))
|
||||
expect(s.pendingApproval).toBe(true)
|
||||
expect(s.pendingPreview).toBeNull()
|
||||
})
|
||||
|
||||
it('approve() clears the held preview locally (resolved)', () => {
|
||||
const { s, ws } = openSession()
|
||||
ws.message(
|
||||
JSON.stringify({ type: 'status', status: 'waiting', pending: true, preview: { kind: 'command', text: 'ls' } }),
|
||||
)
|
||||
s.approve()
|
||||
expect(s.pendingPreview).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconnect backoff', () => {
|
||||
beforeEach(() => {
|
||||
setLocation('http:', 'lan:3000')
|
||||
|
||||
Reference in New Issue
Block a user