Holds destructive shell commands, shows what they would delete or overwrite, and asks Proceed or Cancel.

hooks/register.tsx 390 lines1// Blast Radius: before Claude runs a destructive shell command, hold it,
2// work out what it would delete or overwrite, show that, and ask
3// Proceed or Cancel. Cancel refuses the call and tells Claude why.
4//
5// A safety net, not a permission system: it reads the command text, so a
6// script that deletes files, an alias or $(...) gets past it. Use permission
7// rules in settings for a hard block. Set BLAST_RADIUS=off to switch it off.
8
9import { atom, read, update } from 'claude-code'
10import type { Elements, EngineInterface, Register } from 'claude-code'
11
12import type { BlastReport } from '../types'
13
14const PANE = 'blast-radius'
15const MAX_LINES = 40
16const WALK_LIMIT = 20_000
17
18const held = atom({ plugin: 'blast-radius', key: 'held' } as const, null)
19const interactive = atom({ plugin: 'blast-radius', key: 'isInteractive' } as const, true)
20
21export const register: Register = on => {
22 on('session.start', async ($, e, next) => {
23 await update($, interactive, () => e.isInteractive)
24 return next(e)
25 })
26
27 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
28 const command = String((e as unknown as { command?: unknown }).command ?? '')
29 const risks = classify(command)
30 if (risks.length === 0) return next(e)
31 if ((await $.env.get('BLAST_RADIUS')) === 'off') return next(e)
32 // Nobody to ask (claude -p, the SDK): stay out of the way of automation.
33 if (!(await read($, interactive))) return next(e)
34
35 let report: BlastReport
36 try {
37 report = await measure($, command, risks)
38 } catch (error) {
39 report = {
40 command,
41 title: risks.map(r => r.title).join(' + '),
42 summary: 'could not be previewed',
43 lines: [],
44 warnings: [`The preview failed: ${String(error)}`],
45 }
46 }
47
48 await update($, held, () => ({ report, where: 'pane' }))
49 try {
50 const opened = await $.ui.open({ id: PANE, title: 'Blast Radius' })
51 if (!opened.isPlaced) await update($, held, h => (h === null ? h : { ...h, where: 'band' as const }))
52 } catch {
53 await update($, held, h => (h === null ? h : { ...h, where: 'band' as const }))
54 }
55
56 let answer = 'Cancel'
57 try {
58 answer = await $.ui.ask(`Blast Radius: this ${report.summary}. Run it anyway?`, {
59 header: 'Blast Radius',
60 options: ['Proceed', 'Cancel'],
61 })
62 } catch {
63 answer = 'Cancel' // dismissed (Esc) counts as Cancel
64 } finally {
65 await update($, held, () => null)
66 try {
67 await $.ui.close({ id: PANE })
68 } catch {
69 // the pane may already be gone
70 }
71 }
72
73 if (answer === 'Proceed') return next(e)
74 return {
75 deny:
76 `Blast Radius held this command and the user chose Cancel. It ${report.summary}.` +
77 ' Do not retry it as written: ask the user how they want to proceed, or find a narrower command.',
78 }
79 })
80
81 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
82 const h = await read($, held)
83 const { Box, Text } = $.ui.resolve(e)
84 if (h === null) return <Text dimColor>Nothing held.</Text>
85 return report({ Box, Text }, h.report)
86 })
87
88 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
89 const h = await read($, held)
90 if (h === null || h.where !== 'band') return next(e)
91 const { Box, Text } = $.ui.resolve(e)
92 return (
93 <Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
94 <Text color="yellow" bold>
95 Blast Radius
96 </Text>
97 {report({ Box, Text }, h.report)}
98 </Box>
99 )
100 })
101}
102
103type Parts = Pick<Elements['terminal'], 'Box' | 'Text'>
104
105function report({ Box, Text }: Parts, r: BlastReport) {
106 return (
107 <Box flexDirection="column">
108 <Text bold>$ {r.command}</Text>
109 <Text color="red">
110 {r.title}: {r.summary}
111 </Text>
112 {r.warnings.map((w, i) => (
113 <Text key={`w${i}`} color="yellow">
114 ⚠ {w}
115 </Text>
116 ))}
117 {r.lines.map((l, i) => (
118 <Text key={`l${i}`} dimColor wrap="truncate-end">
119 {l}
120 </Text>
121 ))}
122 </Box>
123 )
124}
125
126// ---------- what counts as risky ----------
127
128export type Risk =
129 | { kind: 'rm'; title: string; targets: string[] }
130 | { kind: 'reset'; title: string; ref: string }
131 | { kind: 'clean'; title: string; flags: string[] }
132 | { kind: 'push'; title: string; remote?: string; branch?: string }
133 | { kind: 'discard'; title: string; paths: string[] }
134 | { kind: 'branch'; title: string; branch: string }
135 | { kind: 'data'; title: string }
136
137export function classify(command: string): Risk[] {
138 const risks: Risk[] = []
139 for (const segment of command.split(/&&|\|\||;|\||\n/)) {
140 const words = tokenize(segment.trim())
141 while (words[0] === 'sudo' || words[0] === 'command' || words[0] === 'exec') words.shift()
142 const risk = classifySegment(words, segment)
143 if (risk) risks.push(risk)
144 }
145 return risks
146}
147
148function classifySegment(words: string[], raw: string): Risk | undefined {
149 const [cmd, ...rest] = words
150 if (cmd === undefined) return undefined
151 const name = cmd.replace(/^.*[\\/]/, '').toLowerCase()
152 const flags = rest.filter(w => w.startsWith('-'))
153 const args = rest.filter(w => !w.startsWith('-'))
154
155 if (name === 'rm') {
156 const recursive = flags.some(f => /^-[a-zA-Z]*[rR]/.test(f) || f === '--recursive')
157 const force = flags.some(f => /^-[a-zA-Z]*f/.test(f) || f === '--force')
158 if (recursive || (force && args.length > 0)) {
159 return { kind: 'rm', title: recursive ? 'rm -r' : 'rm -f', targets: args }
160 }
161 }
162 // PowerShell and cmd.exe
163 if (/^(remove-item|ri)$/.test(name) && flags.some(f => /^-r(ecurse)?$/i.test(f))) {
164 return { kind: 'rm', title: 'Remove-Item -Recurse', targets: args }
165 }
166 if (/^(rmdir|rd|del|erase)$/.test(name) && rest.some(w => /^\/s$/i.test(w))) {
167 return { kind: 'rm', title: `${name} /s`, targets: rest.filter(w => !w.startsWith('/')) }
168 }
169
170 if (name === 'git') {
171 const sub = args[0]
172 if (sub === 'reset' && flags.includes('--hard')) {
173 return { kind: 'reset', title: 'git reset --hard', ref: args[1] ?? 'HEAD' }
174 }
175 if (sub === 'clean' && flags.some(f => /^-[a-zA-Z]*f/.test(f) || f === '--force')) {
176 return { kind: 'clean', title: 'git clean', flags }
177 }
178 if (sub === 'push' && flags.some(f => f === '-f' || f === '--force' || f.startsWith('--force-with-lease') || /^-[a-zA-Z]*f/.test(f))) {
179 const risk: Risk = { kind: 'push', title: 'git push --force' }
180 if (args[1] !== undefined) risk.remote = args[1]
181 if (args[2] !== undefined) risk.branch = args[2].replace(/^\+/, '').split(':').pop()
182 return risk
183 }
184 if (sub === 'checkout' && (rest.includes('--') || args[1] === '.')) {
185 const after = rest.includes('--') ? rest.slice(rest.indexOf('--') + 1) : ['.']
186 return { kind: 'discard', title: 'git checkout --', paths: after }
187 }
188 if (sub === 'restore' && !flags.includes('--staged') && !flags.includes('-S')) {
189 return { kind: 'discard', title: 'git restore', paths: args.slice(1) }
190 }
191 if (sub === 'branch' && flags.some(f => f === '-D' || f === '--delete') && flags.some(f => f === '-D' || f === '--force' || f === '-f')) {
192 if (args[1] !== undefined) return { kind: 'branch', title: 'git branch -D', branch: args[1] }
193 }
194 if (sub === 'stash' && (args[1] === 'clear' || args[1] === 'drop')) {
195 return { kind: 'data', title: `git stash ${args[1]}` }
196 }
197 }
198
199 if (/\b(drop\s+(table|database|schema)|truncate\s+table)\b/i.test(raw)) {
200 return { kind: 'data', title: 'SQL that drops data' }
201 }
202 if (/\b(migrate|db:migrate|db:rollback|migrate:fresh|migrate:reset|db:drop|db:reset)\b/.test(raw) && /\b(manage\.py|rails|rake|artisan|prisma|alembic|knex|sequelize|flyway|dotnet ef|npx|bunx|pnpm|yarn|npm)\b/.test(raw)) {
203 return { kind: 'data', title: 'database migration' }
204 }
205 if (name === 'docker' && (/\b(system|volume|image|container)\s+prune\b/.test(raw) || /\bvolume\s+rm\b/.test(raw) || /\bcompose\s+down\b.*\s(-v|--volumes)\b/.test(raw))) {
206 return { kind: 'data', title: 'docker prune / volume removal' }
207 }
208 return undefined
209}
210
211export function tokenize(text: string): string[] {
212 const out: string[] = []
213 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g
214 let m: RegExpExecArray | null
215 while ((m = re.exec(text)) !== null) out.push(m[1] ?? m[2] ?? m[3] ?? '')
216 return out
217}
218
219// ---------- the dry run ----------
220
221async function measure($: EngineInterface, command: string, risks: Risk[]): Promise<BlastReport> {
222 const cwd = await $.session.cwd()
223 const parts: { summary: string; lines: string[]; warnings: string[] }[] = []
224 for (const risk of risks) parts.push(await measureOne($, cwd, risk))
225 const lines = parts.flatMap(p => p.lines)
226 return {
227 command,
228 title: risks.map(r => r.title).join(' + '),
229 summary: parts.map(p => p.summary).join('; '),
230 lines: lines.length > MAX_LINES ? [...lines.slice(0, MAX_LINES - 1), `… ${lines.length - MAX_LINES + 1} more`] : lines,
231 warnings: parts.flatMap(p => p.warnings),
232 }
233}
234
235async function git($: EngineInterface, args: string[]): Promise<string[]> {
236 const run = await $.process.run(['git', ...args], { timeoutMs: 15_000 })
237 if (run.exitCode !== 0) return []
238 return run.stdout.split('\n').map(l => l.trimEnd()).filter(l => l !== '')
239}
240
241async function measureOne($: EngineInterface, cwd: string, risk: Risk) {
242 const warnings: string[] = []
243 switch (risk.kind) {
244 case 'rm': {
245 let files = 0
246 let bytes = 0
247 const lines: string[] = []
248 if (risk.targets.length === 0) return { summary: 'names nothing to delete', lines, warnings }
249 for (const target of risk.targets) {
250 if (isWholeTree(target, cwd)) warnings.push(`"${target}" is the project folder, its parent, your home folder or the root`)
251 if (/[*?[]/.test(target)) {
252 lines.push(`${target} (a pattern: the shell expands it, not previewed)`)
253 continue
254 }
255 const found = await sizeOf($, absolute(target, cwd))
256 if (found === undefined) {
257 lines.push(`${target} (not found)`)
258 continue
259 }
260 files += found.files
261 bytes += found.bytes
262 lines.push(`${target}${found.isDir ? '/' : ''} ${found.files} ${plural(found.files, 'file')}, ${human(found.bytes)}${found.isPartial ? ' (stopped counting)' : ''}`)
263 }
264 return { summary: `would delete ${files} ${plural(files, 'file')} (${human(bytes)})`, lines, warnings }
265 }
266 case 'reset': {
267 const changed = (await git($, ['status', '--porcelain'])).filter(l => !l.startsWith('??'))
268 const stat = (await git($, ['diff', '--shortstat', 'HEAD']))[0]
269 const lines = changed.map(l => `uncommitted: ${l.slice(3)}`)
270 let lost: string[] = []
271 if (risk.ref !== 'HEAD') {
272 lost = await git($, ['log', '--oneline', '-n', '30', `${risk.ref}..HEAD`])
273 lines.push(...lost.map(c => `commit dropped from branch: ${c}`))
274 }
275 const bits = [
276 `would discard uncommitted changes in ${changed.length} ${plural(changed.length, 'file')}${stat ? ` (${stat.trim()})` : ''}`,
277 ]
278 if (lost.length > 0) bits.push(`and drop ${lost.length} ${plural(lost.length, 'commit')} from the branch`)
279 return { summary: bits.join(' '), lines, warnings }
280 }
281 case 'clean': {
282 const keep = risk.flags.filter(f => /^-[a-zA-Z]+$/.test(f)).map(f => f.replace(/[fniq]/g, '')).filter(f => f !== '-')
283 const removed = (await git($, ['clean', '-n', ...keep])).map(l => l.replace(/^Would remove /, ''))
284 return {
285 summary: `would delete ${removed.length} untracked ${plural(removed.length, 'path')}`,
286 lines: removed.map(p => `untracked: ${p}`),
287 warnings,
288 }
289 }
290 case 'push': {
291 const upstream =
292 risk.remote !== undefined && risk.branch !== undefined
293 ? `${risk.remote}/${risk.branch}`
294 : (await git($, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']))[0]
295 if (upstream === undefined) {
296 return { summary: 'would force-push (no upstream found to compare with)', lines: [], warnings }
297 }
298 const gone = await git($, ['log', '--oneline', '-n', '30', `HEAD..${upstream}`])
299 warnings.push('Compared with the last fetch: run git fetch first for a fresh picture. --force-with-lease is the safer flag.')
300 return {
301 summary:
302 gone.length === 0
303 ? `would force-push to ${upstream} (no remote commits would be lost, as of the last fetch)`
304 : `would overwrite ${gone.length} ${plural(gone.length, 'commit')} on ${upstream}`,
305 lines: gone.map(c => `remote commit lost: ${c}`),
306 warnings,
307 }
308 }
309 case 'discard': {
310 const paths = risk.paths.length === 0 ? ['.'] : risk.paths
311 const stat = await git($, ['diff', '--stat', '--', ...paths])
312 const total = stat.length > 0 ? stat[stat.length - 1]! : ''
313 const files = stat.slice(0, -1)
314 return {
315 summary: files.length === 0 ? 'would discard no unstaged changes' : `would discard unstaged changes (${total.trim()})`,
316 lines: files.map(l => `unstaged: ${l.trim()}`),
317 warnings,
318 }
319 }
320 case 'branch': {
321 const only = await git($, ['log', '--oneline', '-n', '30', risk.branch, '--not', 'HEAD', '--remotes'])
322 return {
323 summary:
324 only.length === 0
325 ? `would delete branch ${risk.branch} (its commits are on HEAD or a remote)`
326 : `would delete branch ${risk.branch} and ${only.length} ${plural(only.length, 'commit')} found nowhere else`,
327 lines: only.map(c => `only on ${risk.branch}: ${c}`),
328 warnings,
329 }
330 }
331 case 'data':
332 warnings.push('This changes data outside your files (a database, Docker volumes or the stash): there is no preview, and Waypoint cannot undo it.')
333 return { summary: `runs a ${risk.title}`, lines: [], warnings }
334 }
335}
336
337async function sizeOf($: EngineInterface, target: string) {
338 let stat
339 try {
340 stat = await $.fs.stat(target)
341 } catch {
342 return undefined
343 }
344 if (stat.kind !== 'dir') return { files: 1, bytes: stat.size, isDir: false, isPartial: false }
345 let files = 0
346 let bytes = 0
347 let seen = 0
348 const queue = [target]
349 while (queue.length > 0 && seen < WALK_LIMIT) {
350 const dir = queue.shift()!
351 let entries
352 try {
353 entries = await $.fs.list(dir)
354 } catch {
355 continue
356 }
357 for (const entry of entries) {
358 seen += 1
359 if (entry.kind === 'dir') queue.push(`${dir}/${entry.name}`)
360 else if (entry.kind === 'file') {
361 files += 1
362 bytes += entry.size
363 }
364 }
365 }
366 return { files, bytes, isDir: true, isPartial: queue.length > 0 }
367}
368
369function absolute(path: string, cwd: string): string {
370 if (/^([a-zA-Z]:[\\/]|[\\/]|~)/.test(path)) return path
371 return `${cwd.replace(/[\\/]+$/, '')}/${path.replace(/^\.[\\/]/, '')}`
372}
373
374function isWholeTree(target: string, cwd: string): boolean {
375 const t = target.replace(/\\/g, '/').replace(/\/+$/, '')
376 const c = cwd.replace(/\\/g, '/').replace(/\/+$/, '')
377 return ['', '.', '..', '~', '/', '$HOME', '${HOME}', '%USERPROFILE%', '*', './*'].includes(t) || t === c || /^[a-zA-Z]:$/.test(t)
378}
379
380function human(bytes: number): string {
381 if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`
382 if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`
383 if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`
384 return `${bytes} B`
385}
386
387function plural(n: number, word: string): string {
388 return n === 1 ? word : `${word}s`
389}
390types/index.d.ts 28 lines1export type BlastReport = {
2 /** The command as Claude wrote it. */
3 command: string
4 /** What kind of risk, in a few words: "rm -r", "git reset --hard". */
5 title: string
6 /** One line for the question: "would delete 9 files (1.1 MB)". */
7 summary: string
8 /** The details: files, commits, changes. */
9 lines: string[]
10 /** Things worth shouting about: the whole project, the home folder, no preview. */
11 warnings: string[]
12}
13
14export type BlastHeld = {
15 report: BlastReport
16 /** Where the report is drawn while the question is up. */
17 where: 'pane' | 'band'
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'blast-radius': {
23 held: BlastHeld | null
24 isInteractive: boolean
25 }
26 }
27}
28