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.
190 lines
7.0 KiB
TypeScript
190 lines
7.0 KiB
TypeScript
/**
|
|
* 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()
|
|
})
|
|
})
|