SLOPSHOPPER

blast-radius

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…

newpaneguardcommandprocess
v0.3.0no licenseupdated 2026-10-02aqaurius6666/claude-blast-radius
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius
│ ┃ Blast radius ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing yet: opens when a permission prompt │ ┃ has an rm or matches a rule. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /blast-radius │ ⎿ blast-radius: Blast radius pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Blast radius
Nothing yet: opens when a permission prompt has an rm or matches a rule.
README

blast-radius

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

blast-radius demo: the side pane opens with an rm permission prompt and closes once it is declined, then opens again for a git clean dry-run and closes once approved

  • Built in: every rm is measured: files, size, and how much git can't bring back.
  • Your rules: a regex per command and a read-only dry-run to run when it matches (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.

Quick start

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 rm without asking, there is no dialog to annotate. To always be asked for rm, add to ~/.claude/settings.json:

{ "permissions": { "ask": ["Bash(rm *)"] } }

Still nothing? See Troubleshooting.

Reading the line

💥 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:

PartMeaning
⛔ deletes /, $HOME, the project, .git historythe target is (or contains) one of these
⚠ N not in gitmodified, 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 giteverything is committed or staged: git checkout brings it back
outside projecta target outside the session's working directory
≥a probe hit its 3 s limit; the real number is bigger
🔍 N dry-runsyour rules matched; their output is in the pane
details: /blast-radiusthe pane could not be drawn (narrow terminal): run it to see details

The pane

  • Opens by itself only while it has something to show: when a permission prompt has an 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.
  • Closes by itself once that prompt is answered (after the command runs, or when you say no).
  • /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.

Add your own dry-runs

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:

CommandPreview
rm -rf a bls -la a b
kubectl delete …kubectl delete … --dry-run=server -o name
kubectl apply …kubectl diff …
kubectl drain NODEpods on NODE
helm uninstall REL …helm get manifest REL …
terraform destroy / applyterraform plan [-destroy]
git clean …git clean -n …
git reset --hardgit diff --stat HEAD
git push -fupstream commits the push drops (HEAD..@{u}, as of the last fetch)
git branch -D Bcommits of B on no remote
git stash drop / cleargit stash list
find … -deletefind … -print
rsync … --delete …rsync --dry-run --itemize-changes …
aws s3 rm / mv / syncsame with --dryrun
docker … prunedocker 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 $VAR or $(..) (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.

Display only, never a gate

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.

Troubleshooting

SymptomFix
/blast-radius is an unknown commandFunction 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 dialogThe 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-radiusThe terminal is under 144 columns: widen it or run /blast-radius.
Dialog drawn over the paneYou are in a multiplexer: export CLAUDE_CODE_NO_FLICKER=1 and restart.
Pane never opens by itselfYou ran /blast-radius off once: run /blast-radius on.
⚠ config: … in the paneA 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

How it works

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
  • Only commands that will ask are measured: rules that already allow or deny cost nothing.
  • Follows cd X && rm Y, quotes, escapes, sudo, redirects, one-level globs.
  • Probes run by argv, never through a shell.

Develop

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).

Source 6 files
hooks/register.tsx 223 lines
1// 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}
223
hooks/format.ts 46 lines
1// 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}
46
hooks/parse.ts 159 lines
1// 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}
159
hooks/probe.ts 207 lines
1// 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}
207
hooks/rules.ts 75 lines
1// 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}
75
types/index.d.ts 31 lines
1export 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