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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user