When a Bash command asks for permission, preview its blast radius: rm targets measured (files, size, what git can't restore), plus your own regex -> dry-run…

A Claude Code mod. When a Bash command is about to ask for permission, it shows what the command would destroy, before you answer.

rm is measured: files, size, and how much git can't bring back.git clean -fdx → git clean -n -fdx).One line goes under the dialog. Details (per path, full dry-run output) go to the Blast radius side pane, which opens with the prompt and closes once you answer it. Nothing reaches the model's context, and the permission decision is never changed.
1. Turn on function hooks. The mod is built on them. Add this to your shell rc (~/.zshrc, ~/.bashrc) and open a new terminal:
export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
# inside tmux, herdr or another multiplexer, also:
export CLAUDE_CODE_NO_FLICKER=1
Without CLAUDE_CODE_NO_FLICKER=1, a multiplexer gets the main-screen layout, where the permission dialog draws over the pane.
2. Install.
claude plugin marketplace add aqaurius6666/claude-blast-radius
claude plugin install blast-radius@blast-radius
3. Start Claude Code (or run /reload-plugins in a session already open), and check it loaded:
/blast-radius
The Blast radius pane opens on the right with Nothing yet: opens when a permission prompt has an rm or matches a rule. That's it: you are set up. From now on the pane opens and closes by itself; this one, opened by hand, stays until you close it.
4. See it work. In a throwaway directory, ask Claude to delete something:
mkdir -p /tmp/br-try/build && cd /tmp/br-try && touch build/a.js build/b.js && claude
> delete the build folder
When the permission dialog opens, a line appears under it:
💥 rm -r: ⚠ 2 not in git · 2 files · 4 KB
No line? The mod only speaks when Claude Code asks. If an allow rule or auto mode approves
rmwithout asking, there is no dialog to annotate. To always be asked forrm, add to~/.claude/settings.json:{ "permissions": { "ask": ["Bash(rm *)"] } }Still nothing? See Troubleshooting.
💥 rm -r: ⚠ 1,117 not in git (1,100 gitignored) · 1,204 files · 48 MB
💥 rm -r: ⛔ deletes $HOME · ⚠ 312,004 not in git · ≥240,118 files · ≥12 GB
💥 rm: all in git · 2 files · 4 KB
Worst news first:
| Part | Meaning |
|---|---|
⛔ deletes /, $HOME, the project, .git history | the target is (or contains) one of these |
⚠ N not in git | modified, untracked or gitignored files, or files outside any repo: gone for good |
(M gitignored) | the part of N that is gitignored: build output, but also .env files |
all in git | everything is committed or staged: git checkout brings it back |
outside project | a target outside the session's working directory |
≥ | a probe hit its 3 s limit; the real number is bigger |
🔍 N dry-runs | your rules matched; their output is in the pane |
details: /blast-radius | the pane could not be drawn (narrow terminal): run it to see details |
rm or matches a rule, on a terminal 144+ columns wide (110 once you have opened it yourself with /blast-radius). Narrower, the line ends in details: /blast-radius./blast-radius opens it and shows the last previewed command. Opened this way, it stays until you close it./blast-radius off closes it and stops it opening by itself (kept across sessions). The line under the dialog stays. /blast-radius on undoes it.Rules live in any settings.json (user ~/.claude/settings.json, project .claude/settings.json or local .claude/settings.local.json) under pluginConfigs.blast-radius.options. List rule names in rules, then give each a .match and a .preview:
{
"pluginConfigs": {
"blast-radius": {
"options": {
"rules": ["kdel", "tfd", "gclean"],
"kdel.match": "^kubectl delete (.+)$",
"kdel.preview": "kubectl delete $1 --dry-run=server -o name",
"tfd.match": "^terraform destroy",
"tfd.preview": "terraform plan -destroy -no-color",
"gclean.match": "^git clean (.+)$",
"gclean.preview": "git clean -n $1"
}
}
}
}
Settings are read on every prompt: edits apply without a reload. A broken rule shows as ⚠ config: … at the top of the pane.
match is a JavaScript regex, tested against each command of a chain or pipe on its own: in cd app && kubectl delete pod a | tee log it sees cd app, kubectl delete pod a and tee log. VAR=x and sudo prefixes are dropped first.preview fills $0-$9 from the match, then splits into words the way the shell would (quotes kept). It runs in the directory a cd earlier in the chain left, 10 s at most.Ready-made rules, each covered by a test, are in examples/rules.json. Copy the ones you want:
| Command | Preview |
|---|---|
rm -rf a b | ls -la a b |
kubectl delete … | kubectl delete … --dry-run=server -o name |
kubectl apply … | kubectl diff … |
kubectl drain NODE | pods on NODE |
helm uninstall REL … | helm get manifest REL … |
terraform destroy / apply | terraform plan [-destroy] |
git clean … | git clean -n … |
git reset --hard | git diff --stat HEAD |
git push -f | upstream commits the push drops (HEAD..@{u}, as of the last fetch) |
git branch -D B | commits of B on no remote |
git stash drop / clear | git stash list |
find … -delete | find … -print |
rsync … --delete … | rsync --dry-run --itemize-changes … |
aws s3 rm / mv / sync | same with --dryrun |
docker … prune | docker system df |
⚠ A preview runs without asking, every time its rule matches a prompt. Make it read-only. The mod refuses a preview when, after filling in, it would chain commands (
;|&), still holds$VARor$(..)(so a captured$(curl ..)never runs), or starts with a shell or wrapper (sh,bash,env,sudo,xargs,python, ...). It runs by argv, never through a shell. It cannot know whether your program writes.
The mod passes the permission decision through untouched. It never allows, denies or rewrites a call.
No line does not mean safe. The parser reads the command like a human skimming it. It misses find -delete, bash -c, aliases, functions, heredocs and scripts. $VAR, $(..) and xargs rm targets are reported as unresolved and never expanded, because expanding them would run them.
| Symptom | Fix |
|---|---|
/blast-radius is an unknown command | Function hooks are off: echo $CLAUDE_CODE_ENABLE_FUNCTION_HOOKS must print 1 in the shell that starts claude. Then check claude plugin list shows blast-radius@blast-radius as enabled. Restart Claude Code after either fix. |
| No line under the dialog | The command did not ask (allowed by a rule or auto mode): add Bash(rm *) to permissions.ask, see step 4. For your own rules, check the regex against the single command, not the whole chain. |
Line ends in details: /blast-radius | The terminal is under 144 columns: widen it or run /blast-radius. |
| Dialog drawn over the pane | You are in a multiplexer: export CLAUDE_CODE_NO_FLICKER=1 and restart. |
| Pane never opens by itself | You ran /blast-radius off once: run /blast-radius on. |
⚠ config: … in the pane | A rule is missing .match / .preview or its regex does not compile. |
Update or remove:
claude plugin update blast-radius@blast-radius # restart to apply
claude plugin uninstall blast-radius@blast-radius
sequenceDiagram
participant E as Engine
participant M as blast-radius
participant H as Host (du, find, git)
E->>M: tool.check (Bash)
M->>E: next(e)
E-->>M: verdict
alt ask + command has rm
M-->>E: verdict (unchanged, dialog opens)
M->>E: ui.notice "measuring…"
M->>H: stat / du -sk / find -type f / git status, ls-files
M->>E: ui.notice "💥 rm: …"
else allow / deny / no rm
M-->>E: verdict
end
cd X && rm Y, quotes, escapes, sudo, redirects, one-level globs.Run from a checkout for one session:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir /path/to/claude-blast-radius
Checks:
bun test # tests/*.spec.ts: parser, probes, rules
claude plugin test . # tests/*.test.ts, against the engine
bun x tsc -p . # types
claude plugin validate . # manifests
Types come from /plugin-types into .claude/types/ (git-ignored, regenerate per Claude Code version).
Layout: hooks/parse.ts (command → chain/pipe segments, rm targets), hooks/probe.ts (targets → impact, host injected), hooks/rules.ts (settings → rules → preview argv), hooks/format.ts (impact → line), hooks/register.tsx (the hook and the pane), types/index.d.ts (pane state).
hooks/register.tsx 223 lines1// Blast radius: when a Bash command is about to ask for permission, preview what it would
2// do. The built-in check measures any `rm`; user rules run a dry-run command per matching
3// command in a chain or pipe. One line goes under the dialog, the details to a side pane.
4// Display only, never in the model's context, and the verdict passes through untouched:
5// the parser's blind spots must stay cosmetic misses, never holes in a gate.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { PreviewRun, Report } from '../types'
11import { format, size } from './format'
12import { parse, segments } from './parse'
13import { absolute, probe, type Host } from './probe'
14import { optionsOf, plan, rulesOf, SOURCES } from './rules'
15
16const PANE = 'blast-radius'
17// `/blast-radius off` stores false: the pane then opens only when asked for
18const AUTO_OPEN = 'autoOpen'
19const report = atom({ plugin: 'blast-radius', key: 'report' } as const, null)
20
21const PREVIEW_MS = 10_000
22const MAX_LINES = 200
23
24function hostOf($: EngineInterface, cwd: string, home: string | undefined): Host {
25 return {
26 cwd,
27 home,
28 run: (argv, init) => $.process.run(argv, init),
29 stat: path => $.fs.stat(path).catch(() => undefined),
30 list: dir => $.fs.list(dir),
31 }
32}
33
34function notice($: EngineInterface, id: string, text: string) {
35 try {
36 $.ui.notice(id, text)
37 } catch {
38 // dialog already answered: nothing left to tell
39 }
40}
41
42async function loadRules($: EngineInterface) {
43 const sources = await Promise.all(SOURCES.map(source => $.settings.read({ source }).catch(() => undefined)))
44 return rulesOf(optionsOf(sources))
45}
46
47async function autoOpen($: EngineInterface) {
48 return (await $.store.get(AUTO_OPEN).catch(() => undefined)) !== false
49}
50
51// the pane opened unasked for a prompt: closed once that prompt is answered.
52// Module state: a reload starts over, at worst leaving one pane open.
53const pane = { opened: undefined as string | undefined, answered: new Set<string>() }
54
55async function closeFor($: EngineInterface, id: string) {
56 pane.answered.add(id)
57 if (pane.opened !== id) return
58 pane.opened = undefined
59 await $.ui.close({ id: PANE }).catch(() => {})
60}
61
62// opens the pane for prompt `id`, claimed for closing only when it was not open already
63// (opened with `/blast-radius`, or still up for another prompt)
64async function openFor($: EngineInterface, id: string) {
65 const wasOpen = (await $.ui.panes().catch(() => [])).some(p => p.id === PANE)
66 const placed = await $.ui.open({ id: PANE, title: 'Blast radius' }).then(
67 r => r.isPlaced,
68 () => false,
69 )
70 if (!wasOpen) {
71 pane.opened = id
72 // answered while the pane was opening: close it straight away
73 if (pane.answered.has(id)) await closeFor($, id)
74 }
75 return placed
76}
77
78async function analyze($: EngineInterface, id: string, command: string) {
79 const removal = parse(command)
80 const { rules, errors } = await loadRules($)
81 const plans = segments(command).flatMap(s => rules.flatMap(r => plan(r, s) ?? []))
82 if (!removal && !plans.length) return
83
84 const set = (fn: (r: Report) => Report) => update($, report, cur => (cur?.id === id ? fn(cur) : cur))
85 const previews: PreviewRun[] = plans.map(p =>
86 'skip' in p ? { ...p, state: 'done', lines: [], more: 0 } : { ...p, state: 'running', lines: [], more: 0 },
87 )
88 const fresh: Report = {
89 id,
90 command,
91 rm: removal && { state: 'running', line: 'measuring…', items: [] },
92 previews,
93 errors,
94 }
95 await update($, report, () => fresh)
96 // opened unasked: a narrow terminal (under 144 columns) keeps it undrawn, so the line says how to see it
97 const placed = (await autoOpen($)) && (await openFor($, id))
98 const withHint = (...parts: (string | false | null | undefined)[]) =>
99 [...parts, !placed && 'details: /blast-radius'].filter(Boolean).join(' · ')
100 const dryRuns = previews.length
101 ? `🔍 ${previews.length} dry-run${previews.length === 1 ? '' : 's'}${placed ? ' in the Blast radius pane' : ''}`
102 : ''
103 notice($, id, withHint(removal && '💥 rm: measuring…', dryRuns))
104
105 const [cwd, home] = await Promise.all([$.session.cwd(), $.env.get('HOME')])
106 const host = hostOf($, cwd, home)
107
108 const rmDone = removal
109 ? probe(host, removal).then(async impact => {
110 const line = format(impact)
111 await set(r => ({ ...r, rm: { state: 'done', line, items: impact.items } }))
112 return line
113 })
114 : Promise.resolve('')
115
116 await Promise.all(
117 previews.map(async (p, i) => {
118 if (!p.argv) return
119 const dir = absolute(host, '', p.dir || '.') ?? cwd
120 const done = await host
121 .run(p.argv, { cwd: dir, timeoutMs: PREVIEW_MS })
122 .then(r => {
123 const all = `${r.stdout}\n${r.exitCode === 0 ? '' : (r.stderr ?? '')}`.split('\n').filter(l => l.trim())
124 return { exitCode: r.exitCode, lines: all.slice(0, MAX_LINES), more: Math.max(0, all.length - MAX_LINES) }
125 })
126 .catch((err: unknown) => ({ exitCode: -1, lines: [String(err)], more: 0 }))
127 await set(r => ({ ...r, previews: r.previews.map((q, j) => (j === i ? { ...q, state: 'done', ...done } : q)) }))
128 }),
129 )
130 notice($, id, withHint(await rmDone, dryRuns))
131}
132
133export const register: Register = on => {
134 const seen = new Set<string>()
135
136 on('session.start', async ($, e, next) => {
137 await $.command.register({
138 name: 'blast-radius',
139 description: 'Show the Blast radius pane (last previewed command); `off` / `on`: stop / resume opening it by itself',
140 })
141 return next(e)
142 })
143
144 on('command.run', { command: 'blast-radius' }, async ($, e) => {
145 const arg = e.args.trim().toLowerCase()
146 // asked for: stays open after the prompt it was opened for
147 pane.opened = undefined
148 if (arg === 'off') {
149 await $.store.set(AUTO_OPEN, false)
150 await $.ui.close({ id: PANE }).catch(() => {})
151 return { text: 'Blast radius pane closed; it no longer opens by itself. `/blast-radius on` to undo.' }
152 }
153 if (arg === 'on') await $.store.set(AUTO_OPEN, true)
154 else if (arg) return { text: `Unknown argument "${arg}": /blast-radius [on|off]` }
155 await $.ui.open({ id: PANE, title: 'Blast radius' })
156 return { text: arg === 'on' ? 'Blast radius pane opened; it opens by itself again.' : 'Blast radius pane opened.' }
157 })
158
159 // next(e) runs the permission prompt and the tool: once it settles, the prompt is answered
160 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
161 try {
162 return await next(e)
163 } finally {
164 await closeFor($, e.tool_use_id)
165 }
166 })
167
168 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
169 const verdict = await next(e)
170 const id = e.tool_use_id
171 const command = (e.input as { command?: unknown }).command
172 if (verdict.decision !== 'ask' || !id || seen.has(id) || typeof command !== 'string') return verdict
173 seen.add(id)
174 // not awaited: the dialog opens now, the line and pane fill in as the probes finish
175 analyze($, id, command).catch(err => notice($, id, `💥 could not preview (${String(err).slice(0, 60)})`))
176 return verdict
177 })
178
179 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
180 const { Box, Text } = $.ui.resolve(e)
181 const r = await read($, report)
182 if (!r) return <Text dimColor>Nothing yet: opens when a permission prompt has an rm or matches a rule.</Text>
183
184 const rows: ReturnType<typeof Text>[] = []
185 rows.push(<Text dimColor>$ {r.command}</Text>)
186 for (const err of r.errors) rows.push(<Text color="yellow">⚠ config: {err}</Text>)
187 if (r.rm) {
188 rows.push(<Text bold>{r.rm.line}</Text>)
189 for (const it of r.rm.items)
190 rows.push(
191 <Text>
192 {' '}
193 {it.path}
194 {it.kind === 'dir' ? '/' : ''}
195 <Text dimColor>
196 {' '}
197 {it.files} file{it.files === 1 ? '' : 's'} · {size(it.bytes)}
198 </Text>
199 </Text>,
200 )
201 }
202 for (const p of r.previews) {
203 rows.push(
204 <Text bold>
205 🔍 {p.rule}: {p.argv ? p.argv.join(' ') : p.segment}
206 </Text>,
207 )
208 if (p.skip) rows.push(<Text color="yellow">{' '}skipped: {p.skip}</Text>)
209 else if (p.state === 'running') rows.push(<Text dimColor>{' '}running…</Text>)
210 else {
211 if (p.exitCode !== 0) rows.push(<Text color="red">{' '}exit {p.exitCode}</Text>)
212 if (!p.lines.length) rows.push(<Text dimColor>{' '}(no output)</Text>)
213 for (const l of p.lines) rows.push(<Text>{' '}{l}</Text>)
214 if (p.more) rows.push(<Text dimColor>{' '}… {p.more} more lines</Text>)
215 }
216 }
217
218 const room = Math.max(3, (e.viewport?.rows ?? 30) - 2)
219 const shown = rows.length > room ? [...rows.slice(0, room - 1), <Text dimColor>… {rows.length - room + 1} more rows</Text>] : rows
220 return <Box flexDirection="column">{shown}</Box>
221 })
222}
223hooks/format.ts 46 lines1// One line under the permission dialog: worst news first, so a glance is enough.
2
3import type { Flag, Impact } from './probe'
4
5const DANGER: [Flag, string][] = [
6 ['root', '⛔ deletes /'],
7 ['home', '⛔ deletes $HOME'],
8 ['cwd', '⛔ deletes the project'],
9 ['git-dir', '⛔ deletes .git history'],
10]
11
12const count = (n: number) => n.toLocaleString('en-US')
13
14export function size(bytes: number): string {
15 const units = ['B', 'KB', 'MB', 'GB', 'TB']
16 let v = bytes
17 let u = 0
18 while (v >= 1024 && u < units.length - 1) {
19 v /= 1024
20 u++
21 }
22 return `${v >= 10 ? Math.round(v) : Number(v.toFixed(1))} ${units[u]}`
23}
24
25export function format(i: Impact): string {
26 const parts: string[] = []
27 for (const [flag, text] of DANGER) if (i.flags.includes(flag)) parts.push(text)
28
29 if (i.files > 0 || i.filesCapped) {
30 // gitignored files are lost too (.env, local config): git can't bring them back either
31 const lost = i.unsaved + i.ignored
32 if (i.gitUnknown) parts.push('git status unknown')
33 else if (lost > 0) parts.push(`⚠ ${count(lost)} not in git${i.ignored > 0 ? ` (${count(i.ignored)} gitignored)` : ''}`)
34 else parts.push('all in git')
35 parts.push(`${i.filesCapped ? '≥' : ''}${count(i.files)} file${i.files === 1 ? '' : 's'}`)
36 parts.push(i.sizeUnknown ? `≥${size(i.bytes)}` : size(i.bytes))
37 }
38 if (i.flags.includes('outside-cwd') && !DANGER.some(([f]) => i.flags.includes(f))) parts.push('outside project')
39 if (i.dirsWithoutR > 0) parts.push(`${i.dirsWithoutR} dir${i.dirsWithoutR === 1 ? '' : 's'} skipped (no -r)`)
40 if (i.missing > 0) parts.push(`${i.missing} not found`)
41 if (i.unresolved > 0) parts.push(`${i.unresolved} unresolved ($VAR/xargs)`)
42 if (!parts.length) parts.push('nothing to delete')
43
44 return `💥 rm${i.recursive ? ' -r' : ''}: ${parts.join(' · ')}`
45}
46hooks/parse.ts 159 lines1// Splits a Bash command into simple commands (around ; && || | & newlines) and finds what
2// an `rm` would delete, without running anything. Best effort, like a human skimming the
3// line: no aliases, functions, find -delete, bash -c, heredocs or scripts. Good enough to
4// warn, never to authorize.
5
6export type Target = {
7 text: string
8 // directory the rm runs in, relative to the session cwd (or absolute, or ~); undefined after a `cd` we can't follow
9 dir: string | undefined
10 glob: boolean
11 // $VAR, $(..), `..`, xargs input: only the shell knows; never expanded here, expanding would run it
12 dynamic: boolean
13}
14
15export type Removal = { recursive: boolean; targets: Target[] }
16
17export type Word = { text: string; glob: boolean; dynamic: boolean; start: number; end: number }
18export type Token = Word | { op: string }
19
20// one simple command: its source text from the command name on, its words, the dir it runs in
21export type Segment = { text: string; words: Word[]; dir: string | undefined }
22
23const WRAPPERS = new Set(['sudo', 'command', 'builtin', 'nohup', 'time', 'nice', 'exec'])
24
25export function lex(src: string): Token[] {
26 const out: Token[] = []
27 let word: Word | undefined
28 let skipNext = false
29 const push = (at: number) => {
30 if (word) {
31 word.end = at
32 if (skipNext) skipNext = false
33 else out.push(word)
34 }
35 word = undefined
36 }
37
38 for (let i = 0; i < src.length; i++) {
39 const c = src[i]!
40 const cur = () => (word ??= { text: '', glob: false, dynamic: false, start: i, end: i })
41 if (c === ' ' || c === '\t') {
42 push(i)
43 } else if (c === '\n' || c === ';' || c === '(' || c === ')') {
44 push(i)
45 out.push({ op: ';' })
46 } else if (c === '&' || c === '|') {
47 push(i)
48 if (src[i + 1] === c) i++
49 out.push({ op: c })
50 } else if (c === '>' || c === '<') {
51 // redirection: drop a leading fd number and the redirect target, neither is an argument
52 if (word && /^\d+$/.test(word.text)) word = undefined
53 push(i)
54 if (src[i + 1] === '>' || src[i + 1] === '&') i++
55 if (src[i] === '&') {
56 while (/[\d-]/.test(src[i + 1] ?? '')) i++
57 } else {
58 skipNext = true
59 }
60 } else if (c === '#' && !word) {
61 while (i < src.length && src[i] !== '\n') i++
62 i--
63 } else if (c === "'") {
64 const w = cur()
65 const end = src.indexOf("'", i + 1)
66 const stop = end < 0 ? src.length : end
67 w.text += src.slice(i + 1, stop)
68 i = stop
69 } else if (c === '"') {
70 const w = cur()
71 for (i++; i < src.length && src[i] !== '"'; i++) {
72 if (src[i] === '\\' && /["\\$`]/.test(src[i + 1] ?? '')) i++
73 else if (src[i] === '$' || src[i] === '`') w.dynamic = true
74 w.text += src[i]
75 }
76 } else if (c === '\\') {
77 cur().text += src[++i] ?? ''
78 } else if ((c === '$' && src[i + 1] === '(') || c === '`') {
79 // command substitution stays one opaque word, however many words it holds
80 const w = cur()
81 w.dynamic = true
82 let depth = 0
83 const start = i
84 for (; i < src.length; i++) {
85 const d = src[i]!
86 if (c === '`' ? d === '`' && i > start : d === ')' && --depth === 0) break
87 if (c !== '`' && d === '(') depth++
88 }
89 w.text += src.slice(start, i + 1)
90 } else {
91 const w = cur()
92 if (c === '$') w.dynamic = true
93 if (c === '*' || c === '?' || c === '[') w.glob = true
94 w.text += c
95 }
96 }
97 push(src.length)
98 return out
99}
100
101export const isWord = (t: Token): t is Word => 'text' in t
102
103const basename = (w: Word) => w.text.split('/').pop()
104
105function joinDir(dir: string | undefined, next: Word | undefined): string | undefined {
106 if (!next) return '~'
107 if (dir === undefined || next.dynamic || next.text === '-') return undefined
108 const p = next.text
109 if (p.startsWith('/') || p.startsWith('~')) return p
110 return dir === '' ? p : `${dir}/${p}`
111}
112
113export function segments(command: string): Segment[] {
114 const groups: Word[][] = [[]]
115 for (const t of lex(command)) {
116 if (isWord(t)) groups[groups.length - 1]!.push(t)
117 else groups.push([])
118 }
119
120 let dir: string | undefined = ''
121 const out: Segment[] = []
122 for (const g of groups) {
123 let i = 0
124 while (i < g.length && (WRAPPERS.has(g[i]!.text) || /^[A-Za-z_]\w*=/.test(g[i]!.text) || (i > 0 && g[i]!.text.startsWith('-')))) i++
125 const words = g.slice(i)
126 if (!words.length) continue
127 out.push({ text: command.slice(words[0]!.start, words[words.length - 1]!.end), words, dir })
128 if (basename(words[0]!) === 'cd') dir = joinDir(dir, words[1])
129 }
130 return out
131}
132
133export function parse(command: string): Removal | undefined {
134 if (!/\brm\b/.test(command)) return undefined
135
136 let recursive = false
137 const targets: Target[] = []
138 for (const { words, dir } of segments(command)) {
139 const [head, ...args] = words
140 const name = basename(head!)
141 if (name === 'xargs' && args.some(a => basename(a) === 'rm')) {
142 // targets arrive on stdin from the left side of the pipe
143 targets.push({ text: '<xargs>', dir, glob: false, dynamic: true })
144 } else if (name === 'rm') {
145 let flags = true
146 for (const a of args) {
147 if (flags && a.text === '--') flags = false
148 else if (flags && a.text.startsWith('-') && a.text.length > 1 && !a.dynamic) {
149 if (a.text === '--recursive' || (!a.text.startsWith('--') && /[rR]/.test(a.text))) recursive = true
150 } else {
151 targets.push({ text: a.text, dir, glob: a.glob, dynamic: a.dynamic })
152 }
153 }
154 }
155 }
156
157 return targets.length ? { recursive, targets } : undefined
158}
159hooks/probe.ts 207 lines1// Measures what a Removal would take: files, size, and how much of it git can't bring back.
2// Read-only probes through argv (never a shell: a target like $(curl ..) must not run).
3
4import type { Removal } from './parse'
5
6export type RunResult = { exitCode: number; stdout: string; stderr?: string; isStdoutTruncated: boolean }
7export type Stat = { kind: 'file' | 'dir' | 'other'; size: number; isLink: boolean }
8
9export type Host = {
10 cwd: string
11 home: string | undefined
12 run: (argv: readonly string[], init: { cwd?: string; timeoutMs: number }) => Promise<RunResult>
13 stat: (path: string) => Promise<Stat | undefined>
14 list: (dir: string) => Promise<readonly { name: string }[]>
15}
16
17export type Flag = 'root' | 'home' | 'cwd' | 'git-dir' | 'outside-cwd'
18
19export type Impact = {
20 recursive: boolean
21 paths: number
22 missing: number
23 unresolved: number
24 dirsWithoutR: number
25 files: number
26 filesCapped: boolean
27 bytes: number
28 sizeUnknown: boolean
29 // modified or untracked in git, or outside any repo: gone for good
30 unsaved: number
31 tracked: number
32 ignored: number
33 gitUnknown: boolean
34 flags: Flag[]
35 // per existing path, for the pane
36 items: { path: string; kind: 'file' | 'dir'; files: number; bytes: number }[]
37}
38
39const PROBE_MS = 3000
40const MAX_PATHS = 200
41
42export function normalize(path: string): string {
43 const parts: string[] = []
44 for (const p of path.split('/')) {
45 if (p === '' || p === '.') continue
46 if (p === '..') parts.pop()
47 else parts.push(p)
48 }
49 return `/${parts.join('/')}`
50}
51
52export function absolute(host: Host, dir: string, text: string): string | undefined {
53 const base = text.startsWith('/') || text.startsWith('~') || !dir ? text : `${dir}/${text}`
54 if (base.startsWith('/')) return normalize(base)
55 if (base === '~' || base.startsWith('~/')) return host.home ? normalize(host.home + base.slice(1)) : undefined
56 if (base.startsWith('~')) return undefined // ~user
57 return normalize(`${host.cwd}/${base}`)
58}
59
60function globRegex(pattern: string): RegExp {
61 let re = ''
62 for (let i = 0; i < pattern.length; i++) {
63 const c = pattern[i]!
64 if (c === '*') re += '[^/]*'
65 else if (c === '?') re += '[^/]'
66 else if (c === '[') {
67 const end = pattern.indexOf(']', i + 1)
68 if (end < 0) re += '\\['
69 else {
70 re += `[${pattern.slice(i + 1, end).replace(/^!/, '^').replace(/\\/g, '\\\\')}]`
71 i = end
72 }
73 } else re += c.replace(/[.+^${}()|\\]/g, '\\$&')
74 }
75 return new RegExp(`^${re}$`)
76}
77
78async function expand(host: Host, abs: string): Promise<string[] | undefined> {
79 const cut = abs.lastIndexOf('/')
80 const parent = abs.slice(0, cut) || '/'
81 const pattern = abs.slice(cut + 1)
82 if (/[*?[]/.test(parent)) return undefined
83 const re = globRegex(pattern)
84 const hidden = pattern.startsWith('.')
85 const entries = await host.list(parent).catch(() => [])
86 return entries
87 .filter(e => re.test(e.name) && (hidden || !e.name.startsWith('.')))
88 .map(e => normalize(`${parent}/${e.name}`))
89}
90
91const lines = (s: string) => s.split('\n').filter(Boolean).length
92
93function flagsOf(host: Host, path: string): Flag[] {
94 const within = (a: string, b: string) => b === a || b.startsWith(a === '/' ? '/' : `${a}/`)
95 const flags: Flag[] = []
96 if (path === '/') flags.push('root')
97 else if (host.home && within(path, host.home)) flags.push('home')
98 else if (within(path, host.cwd)) flags.push('cwd')
99 if (path.split('/').includes('.git')) flags.push('git-dir')
100 if (!within(host.cwd, path)) flags.push('outside-cwd')
101 return flags
102}
103
104type GitCount = { repo: boolean; tracked: number; unsaved: number; untracked: number } | undefined
105
106async function gitCount(host: Host, path: string, isDir: boolean): Promise<GitCount> {
107 const at = isDir ? path : path.slice(0, path.lastIndexOf('/')) || '/'
108 const opts = { cwd: at, timeoutMs: PROBE_MS }
109 const top = await host.run(['git', 'rev-parse', '--show-toplevel'], opts)
110 if (top.exitCode !== 0) return { repo: false, tracked: 0, unsaved: 0, untracked: 0 }
111 const [ls, st] = await Promise.all([
112 host.run(['git', 'ls-files', '-z', '--', path], opts),
113 host.run(['git', 'status', '--porcelain', '-z', '--untracked-files=all', '--', path], opts),
114 ])
115 if (ls.exitCode !== 0 || st.exitCode !== 0) return undefined
116 let unsaved = 0
117 let untracked = 0
118 const recs = st.stdout.split('\0').filter(Boolean)
119 for (let i = 0; i < recs.length; i++) {
120 const code = recs[i]!.slice(0, 2)
121 if (code.startsWith('R') || code.startsWith('C')) i++ // rename/copy carry the old path as an extra record
122 if (code === '??') untracked++
123 if (code[1] !== ' ' || code === '??') unsaved++ // worktree side differs: rm loses it
124 }
125 return { repo: true, tracked: ls.stdout.split('\0').filter(Boolean).length, unsaved, untracked }
126}
127
128export async function probe(host: Host, removal: Removal): Promise<Impact> {
129 const impact: Impact = {
130 recursive: removal.recursive,
131 paths: 0,
132 missing: 0,
133 unresolved: 0,
134 dirsWithoutR: 0,
135 files: 0,
136 filesCapped: false,
137 bytes: 0,
138 sizeUnknown: false,
139 unsaved: 0,
140 tracked: 0,
141 ignored: 0,
142 gitUnknown: false,
143 flags: [],
144 items: [],
145 }
146
147 const paths = new Set<string>()
148 for (const t of removal.targets) {
149 const abs = t.dynamic || t.dir === undefined ? undefined : absolute(host, t.dir, t.text)
150 if (!abs) {
151 impact.unresolved++
152 continue
153 }
154 if (!t.glob) {
155 paths.add(abs)
156 continue
157 }
158 const hits = await expand(host, abs)
159 if (!hits) impact.unresolved++
160 else if (!hits.length) impact.missing++
161 else hits.forEach(p => paths.add(p))
162 }
163
164 const flags = new Set<Flag>()
165 await Promise.all(
166 [...paths].slice(0, MAX_PATHS).map(async path => {
167 const st = await host.stat(path).catch(() => undefined)
168 if (!st) return void impact.missing++
169 impact.paths++
170 const isDir = st.kind === 'dir' && !st.isLink
171 if (isDir && !removal.recursive) return void impact.dirsWithoutR++
172 flagsOf(host, path).forEach(f => flags.add(f))
173
174 let files = 1
175 let bytes = st.size
176 if (isDir) {
177 const [du, find] = await Promise.allSettled([
178 host.run(['du', '-sk', path], { timeoutMs: PROBE_MS }),
179 host.run(['find', path, '-type', 'f'], { timeoutMs: PROBE_MS }),
180 ])
181 bytes = du.status === 'fulfilled' && du.value.stdout ? parseInt(du.value.stdout, 10) * 1024 : 0
182 if (!bytes && du.status !== 'fulfilled') impact.sizeUnknown = true
183 if (find.status === 'fulfilled') {
184 files = lines(find.value.stdout)
185 if (find.value.isStdoutTruncated) impact.filesCapped = true
186 } else {
187 files = 0
188 impact.filesCapped = true
189 }
190 }
191 impact.bytes += bytes
192 impact.files += files
193 impact.items.push({ path, kind: isDir ? 'dir' : 'file', files, bytes })
194
195 const git = await gitCount(host, path, isDir).catch(() => undefined)
196 if (!git) return void (impact.gitUnknown = true)
197 if (!git.repo) return void (impact.unsaved += files)
198 impact.tracked += git.tracked
199 impact.unsaved += git.unsaved
200 impact.ignored += Math.max(0, files - git.tracked - git.untracked)
201 }),
202 )
203 if (paths.size > MAX_PATHS) impact.unresolved += paths.size - MAX_PATHS
204 impact.flags = [...flags]
205 return impact
206}
207hooks/rules.ts 75 lines1// User rules: a regex over one simple command, and a read-only preview command to run when
2// it matches. "kdel.match": "^kubectl delete (.+)$", "kdel.preview": "kubectl delete $1 --dry-run=server -o name".
3//
4// The preview runs WITHOUT asking, so it is held to: argv only (never a shell), no ; | &
5// after filling in, no $VAR or $(..) left (a capture like $(curl ..) must not run), and
6// no shell or wrapper as the program. Whether the preview itself is read-only is the
7// rule author's job.
8
9import { isWord, lex, type Segment, type Word } from './parse'
10
11export type Rule = { id: string; match: RegExp; preview: string }
12
13export type Plan =
14 | { rule: string; segment: string; argv: string[]; dir: string }
15 | { rule: string; segment: string; skip: string }
16
17type Options = Readonly<Record<string, unknown>>
18
19export const SOURCES = ['user', 'project', 'local', 'flag', 'policy'] as const
20
21// a program that would run its arguments as code, or hide what runs
22const REFUSED = new Set(['sh', 'bash', 'zsh', 'fish', 'dash', 'ksh', 'eval', 'exec', 'env', 'sudo', 'xargs', 'nohup', 'command', 'builtin', 'time', 'nice', 'python', 'python3', 'node', 'perl', 'ruby'])
23
24// register's `options` only carries keys plugin.json's userConfig declares, so dotted keys
25// never arrive there: read pluginConfigs per source, the last source setting a key wins.
26export function optionsOf(sources: readonly unknown[]): Options {
27 const out: Record<string, unknown> = {}
28 for (const s of sources) {
29 const configs = (s as { pluginConfigs?: Record<string, { options?: unknown }> } | undefined)?.pluginConfigs ?? {}
30 for (const [key, v] of Object.entries(configs)) {
31 if (key !== 'blast-radius' && !key.startsWith('blast-radius@')) continue
32 const opts = v?.options
33 if (opts && typeof opts === 'object' && !Array.isArray(opts)) Object.assign(out, opts)
34 }
35 }
36 return out
37}
38
39export function rulesOf(options: Options): { rules: Rule[]; errors: string[] } {
40 const ids = options.rules
41 const rules: Rule[] = []
42 const errors: string[] = []
43 for (const id of Array.isArray(ids) ? ids.map(String) : []) {
44 const match = options[`${id}.match`]
45 const preview = options[`${id}.preview`]
46 if (typeof match !== 'string' || typeof preview !== 'string') {
47 errors.push(`${id}: needs "${id}.match" and "${id}.preview" strings`)
48 continue
49 }
50 try {
51 rules.push({ id, match: new RegExp(match), preview })
52 } catch (err) {
53 errors.push(`${id}: bad regex: ${String(err)}`)
54 }
55 }
56 return { rules, errors }
57}
58
59export function plan(rule: Rule, seg: Segment): Plan | undefined {
60 const m = rule.match.exec(seg.text)
61 if (!m) return undefined
62 const base = { rule: rule.id, segment: seg.text }
63 if (seg.dir === undefined) return { ...base, skip: 'runs after a cd we cannot follow' }
64
65 const filled = rule.preview.replace(/\$(\d)/g, (_, n: string) => m[Number(n)] ?? '')
66 const tokens = lex(filled)
67 if (!tokens.every(isWord)) return { ...base, skip: 'preview would chain commands (; | &)' }
68 const words = tokens as Word[]
69 if (!words.length) return { ...base, skip: 'preview is empty' }
70 if (words.some(w => w.dynamic)) return { ...base, skip: 'preview holds $VAR or $(..)' }
71 const program = words[0]!.text.split('/').pop()!
72 if (REFUSED.has(program)) return { ...base, skip: `${program} is not allowed as a preview` }
73 return { ...base, argv: words.map(w => w.text), dir: seg.dir }
74}
75types/index.d.ts 31 lines1export type RmItem = { path: string; kind: 'file' | 'dir'; files: number; bytes: number }
2
3export type RmReport = { state: 'running' | 'done'; line: string; items: RmItem[] }
4
5export type PreviewRun = {
6 rule: string
7 segment: string
8 state: 'running' | 'done'
9 argv?: string[]
10 dir?: string
11 skip?: string
12 exitCode?: number
13 lines: string[]
14 more: number
15}
16
17// the last permission prompt that had something to preview
18export type Report = {
19 id: string
20 command: string
21 rm?: RmReport
22 previews: PreviewRun[]
23 errors: string[]
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'blast-radius': { report: Report | null }
29 }
30}
31