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