Holds risky Bash commands and shows what they would change before they run.

A private reference collection of public Claude Code mods, with a security-oriented analysis.
mods/Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.
hooks/register.tsx 280 lines1// Blast Radius: holds risky Bash commands and shows what they would change.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register } from 'claude-code'
4
5import type { HeldCommand } from '../types'
6import { bandsFile, parseOrder, stackBands } from './bands'
7
8const PANE = 'blast-radius'
9const MAX_LISTED = 200
10const DEFAULT_TIMEOUT_SECONDS = 60
11
12// Held by the host, so the drawing redraws when a command is held or released.
13const held = atom({ plugin: 'blast-radius', key: 'held' } as const, null as HeldCommand | null)
14
15const MIGRATION =
16 /\b(prisma\s+(migrate|db\s+push)|supabase\s+(db\s+(reset|push)|migration\s+up)|drizzle-kit\s+(push|migrate)|knex\s+migrate|sequelize(-cli)?\s+db:migrate|rails\s+db:(migrate|reset|drop)|alembic\s+(upgrade|downgrade)|typeorm\s+migration:run)\b/
17
18type Risk = { risk: HeldCommand['risk']; cwd?: string; targets?: string[]; hasQuotes?: boolean }
19
20let bandOrder: Promise<string[]> | undefined // read once per session: restart to apply
21
22export const register: Register = (on, options) => {
23 // How long a held command waits for a press before it is cancelled; 0 waits forever.
24 const timeoutSeconds = timeoutFrom(options.timeoutSeconds)
25 // The press of a Button, by held command id.
26 const decisions = new Map<string, 'proceed' | 'cancel'>()
27 // The call holding the pane now. Checked and claimed in one step, so two waiting calls can't both take it.
28 let holder: string | null = null
29
30 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
31 const found = classify(e.command)
32 if (!found) return next(e)
33
34 // Nothing draws the session (a plain `claude -p` run): nobody can see the buttons, so refuse now.
35 if ((await $.session.surfaces()).length === 0) {
36 const { summary } = await measure($, found)
37 return {
38 deny:
39 `blast-radius: cancelled this command because this session has no screen, so nobody can answer. ` +
40 `It would have: ${summary}. Ask the user to run it themselves.`,
41 }
42 }
43
44 // One command is held at a time; a second waits its turn (the first one's timeout bounds the wait).
45 for (;;) {
46 // The call ended while it waited its turn: leave the held one alone.
47 if (next.signal.aborted) return { deny: 'blast-radius: held this command, but the call was stopped before it was shown.' }
48 if (holder === null && (await read($, held)) === null && holder === null) break
49 await $.process.run(['sleep', '0.25'])
50 }
51 holder = e.tool_use_id
52 try {
53 const measured = await measure($, found)
54 const one: HeldCommand = {
55 id: e.tool_use_id,
56 command: e.command,
57 risk: found.risk,
58 ...measured,
59 where: 'pane',
60 secondsLeft: timeoutSeconds > 0 ? timeoutSeconds : null,
61 }
62 await update($, held, () => one)
63
64 const opened = await $.ui.open({ id: PANE, title: 'Blast Radius', focus: true })
65 if (!opened.isPlaced) {
66 await update($, held, h => (h ? { ...h, where: 'band' as const } : h)) // too narrow for a pane: draw above the prompt
67 }
68
69 // Time spent inside $ calls doesn't count against the hook's time limit.
70 // Nobody pressing within the timeout counts as Cancel, so an unattended session never stalls.
71 const deadline = timeoutSeconds > 0 ? (await $.clock.now()) + timeoutSeconds * 1000 : null
72 let isTimedOut = false
73 while (!decisions.has(one.id) && !next.signal.aborted) {
74 if (deadline !== null) {
75 const left = Math.ceil((deadline - (await $.clock.now())) / 1000)
76 if (left <= 0) {
77 isTimedOut = !decisions.has(one.id) // a press that landed at the deadline still counts
78 break
79 }
80 if (left !== one.secondsLeft) {
81 one.secondsLeft = left
82 await update($, held, h => (h && h.id === one.id ? { ...h, secondsLeft: left } : h))
83 }
84 }
85 await $.process.run(['sleep', '0.25'])
86 }
87 const decision = isTimedOut ? 'cancel' : (decisions.get(one.id) ?? 'cancel')
88 decisions.delete(one.id)
89 await update($, held, () => null)
90 await $.ui.close({ id: PANE })
91
92 if (decision === 'proceed') return next(e) // runs as written
93 if (isTimedOut) {
94 return {
95 deny:
96 `blast-radius: held this command and nobody answered within ${timeoutSeconds} s, so it was cancelled. ` +
97 `It would have: ${one.summary}. Don't retry it on your own: ask the user to run it, or run it again once they're back.`,
98 }
99 }
100 return {
101 deny: `blast-radius: the user pressed Cancel on this command. It would have: ${one.summary}.`,
102 }
103 } finally {
104 holder = null
105 // If the hold failed partway, don't leave its command blocking the next call.
106 await update($, held, h => (h && h.id === e.tool_use_id ? null : h)).catch(() => {})
107 }
108 })
109
110 // The hook's loop picks the press up.
111 const decide = (id: string, decision: 'proceed' | 'cancel') => {
112 decisions.set(id, decision)
113 }
114
115 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
116 const one = await read($, held)
117 const { Text } = $.ui.resolve(e)
118 if (!one) return <Text dimColor>Nothing held.</Text>
119 return report($, e, one, decide)
120 })
121
122 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
123 const one = await read($, held)
124 if (!one || one.where !== 'band' || e.props.hasSurvey) return next(e)
125 // a held command takes the whole band until answered; the key lets the band order place it
126 return stackBands(undefined, 'blast-radius', report($, e, one, decide), 'below', await (bandOrder ??= readBandOrder($).catch(() => [])))
127 })
128}
129
130// The band order file, as the claude-code-mods checklist saves it; [] on any error.
131async function readBandOrder($: EngineInterface): Promise<string[]> {
132 const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
133 return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
134}
135
136// Forgets the cached band order, for tests.
137export function resetBandOrder() {
138 bandOrder = undefined
139}
140
141function report(
142 $: EngineInterface,
143 e: Parameters<EngineInterface['ui']['resolve']>[0],
144 one: HeldCommand,
145 decide: (id: string, d: 'proceed' | 'cancel') => void,
146) {
147 const { Box, Text, Button } = $.ui.resolve(e)
148 const label = { delete: 'Deletes files', 'force-push': 'Force push', migration: 'Database migration' }[one.risk]
149 const shown = one.where === 'band' ? one.details.slice(0, 5) : one.details
150 return (
151 <Box flexDirection="column" paddingX={1}>
152 <Text>
153 <Text color="red" bold>{'⚠ Blast Radius held a command '}</Text>
154 <Text dimColor>{`(${label})`}</Text>
155 </Text>
156 <Text color="yellow" wrap="truncate-end">{`$ ${one.command}`}</Text>
157 <Text bold>{one.summary}</Text>
158 {shown.map(line => (
159 <Text dimColor wrap="truncate-end">{` ${line}`}</Text>
160 ))}
161 {one.details.length > shown.length && <Text dimColor>{` … ${one.details.length - shown.length} more`}</Text>}
162 <Box flexDirection="row" gap={1}>
163 <Button key="cancel" label="Cancel" hotkey="c" variant="primary" autoFocus onPress={() => decide(one.id, 'cancel')} />
164 <Button key="proceed" label="Proceed" hotkey="y" onPress={() => decide(one.id, 'proceed')} />
165 {one.secondsLeft != null && (
166 <Text key="countdown" color={one.secondsLeft <= 10 ? 'yellow' : undefined} dimColor={one.secondsLeft > 10}>
167 {`auto-cancels in ${one.secondsLeft} s`}
168 </Text>
169 )}
170 </Box>
171 </Box>
172 )
173}
174
175// The timeoutSeconds option as whole seconds: a number or a numeric string, 0 to wait forever.
176export function timeoutFrom(value: unknown): number {
177 const n = typeof value === 'string' && value.trim() !== '' ? Number(value) : value
178 if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return DEFAULT_TIMEOUT_SECONDS
179 return Math.ceil(n) // a positive fraction stays a timeout, never 0 (wait forever)
180}
181
182// Which risky kind a command is, if any, looking at each part of a compound command.
183export function classify(command: string): Risk | null {
184 let cwd: string | undefined
185 for (const part of command.split(/&&|\|\||;|\n/)) {
186 const words = split(part.trim())
187 if (words[0] === 'cd' && words[1]) cwd = words[1]
188 const at = words.findIndex(w => w === 'rm')
189 if (at !== -1 && (at === 0 || words[at - 1] === 'sudo')) {
190 const args = words.slice(at + 1)
191 const flags = args.filter(a => a.startsWith('-'))
192 const isRecursive = flags.some(f => f === '--recursive' || (/^-[a-zA-Z]+$/.test(f) && /[rR]/.test(f)))
193 // Quotes are gone after split: remember they were there, so '~' or '$HOME' are not expanded as if bare.
194 if (isRecursive) return { risk: 'delete', cwd, targets: args.filter(a => !a.startsWith('-')), ...(/['"\\]/.test(command) ? { hasQuotes: true } : {}) }
195 }
196 if (words[0] === 'git' && words.includes('push') && words.some(w => w === '-f' || w.startsWith('--force') || /^\+/.test(w))) {
197 return { risk: 'force-push', cwd }
198 }
199 if (MIGRATION.test(part)) return { risk: 'migration', cwd }
200 }
201 return null
202}
203
204// What the command would touch, from the tools' own commands.
205// Paths the preview can't resolve without running the command: shell variables, command substitution, ~user.
206const UNRESOLVED = /[$`]|^~[^/]/
207const UNRESOLVED_QUOTED = /[$`~]/ // with quotes around, even ~ and $HOME are unknown
208
209async function measure($: EngineInterface, found: Risk): Promise<Pick<HeldCommand, 'summary' | 'details'>> {
210 // ~ and $HOME are expanded here; anything else with a variable is reported as unknown, never as "nothing".
211 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
212 // With quotes in the command we can't tell '~' (literal) from ~ (home): don't expand, report it instead.
213 const expand = (p: string) => (home && !found.hasQuotes ? p.replace(/^~(?=\/|$)/, home).replace(/\$\{HOME\}|\$HOME\b/g, home) : p)
214 const cwd = found.cwd ? expand(found.cwd) : undefined
215 const unresolved = found.hasQuotes ? UNRESOLVED_QUOTED : UNRESOLVED
216 const init = cwd && !unresolved.test(cwd) ? { cwd, timeoutMs: 10_000 } : { timeoutMs: 10_000 }
217 try {
218 if (found.risk === 'delete') {
219 const targets = (found.targets ?? []).map(expand)
220 const unknown = targets.filter(t => unresolved.test(t))
221 if (unknown.length > 0 || (cwd !== undefined && unresolved.test(cwd))) {
222 const list = unknown.length > 0 ? unknown : [`cd ${found.cwd}`]
223 return { summary: `can't preview: ${list.length === 1 ? 'a path uses' : `${list.length} paths use`} a shell variable, check by hand`, details: list.slice(0, MAX_LISTED) }
224 }
225 // Unquoted $t expands globs, and nothing else, without running the command.
226 const script =
227 'shopt -s nullglob; for t in "$@"; do for p in $t; do [ -e "$p" ] || continue; ' +
228 'echo "S $(du -sk "$p" | cut -f1)"; find "$p" -type f | head -n 5000 | sed "s/^/F /"; done; done'
229 const r = await $.process.run(['bash', '-c', script, 'blast-radius', ...targets], init)
230 const lines = r.stdout.split('\n')
231 const files = lines.filter(l => l.startsWith('F ')).map(l => short(l.slice(2), targets))
232 const kb = lines.filter(l => l.startsWith('S ')).reduce((sum, l) => sum + Number(l.slice(2)), 0)
233 if (files.length === 0 && kb === 0) return { summary: 'delete nothing that exists right now', details: [] }
234 return {
235 summary: `delete ${files.length >= 5000 ? '5000+' : files.length} file${files.length === 1 ? '' : 's'} (${size(kb)})`,
236 details: files.slice(0, MAX_LISTED),
237 }
238 }
239 if (found.risk === 'force-push') {
240 const lost = await $.process.run(['git', 'log', '--oneline', 'HEAD..@{u}'], init)
241 const ahead = await $.process.run(['git', 'log', '--oneline', '@{u}..HEAD'], init)
242 const gone = lost.stdout.split('\n').filter(Boolean)
243 const added = ahead.stdout.split('\n').filter(Boolean)
244 return {
245 summary: `overwrite the remote branch: ${gone.length} remote commit${gone.length === 1 ? '' : 's'} lost, ${added.length} pushed (as of the last fetch)`,
246 details: [...gone.map(c => `lost ${c}`), ...added.map(c => `pushed ${c}`)].slice(0, MAX_LISTED),
247 }
248 }
249 const status = await $.process.run(['git', 'status', '--porcelain'], init)
250 const touched = status.stdout.split('\n').filter(l => /migrat|schema|prisma|supabase|drizzle/i.test(l))
251 return {
252 summary: 'change the database schema (not previewable here)',
253 details: touched.map(l => `uncommitted ${l.trim()}`).slice(0, MAX_LISTED),
254 }
255 } catch {
256 return { summary: `run a risky ${found.risk} command (could not measure it)`, details: [] }
257 }
258}
259
260// Shell-like words: quotes kept together, the quotes themselves dropped.
261function split(text: string) {
262 return (text.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(w => w.replace(/^["']|["']$/g, ''))
263}
264
265// A path from the folder being deleted (build/chunk-1.js), not the whole path.
266function short(file: string, targets: string[]) {
267 for (const t of targets) {
268 const base = t.replace(/\/+$/, '')
269 const parent = base.includes('/') ? base.slice(0, base.lastIndexOf('/') + 1) : ''
270 if (parent && file.startsWith(parent)) return file.slice(parent.length)
271 }
272 return file
273}
274
275function size(kb: number) {
276 if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)} GB`
277 if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`
278 return `${kb} KB`
279}
280hooks/bands.ts 73 lines1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2// The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3// (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4// `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5// column, adds its own band and sorts again, so the last one to draw sorts them all.
6// Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7// Reading the file needs $, which Claude Code follows only within one file, so each mod
8// reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16 const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17 return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22 try {
23 const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24 return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25 } catch {
26 return []
27 }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38 const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39 const others = unpack(rest)
40 const items = at === 'above' ? [own, ...others] : [...others, own]
41 return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46 if (!rest) return []
47 if (!isColumn(rest)) return [rest]
48 const kids = rest.children ?? []
49 const owner = rest.part?.plugin
50 // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51 return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55 return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59 if (typeof node === 'string' || node.type !== 'Box') return undefined
60 const key = node.props?.key
61 return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65 const rank = (n: RenderNode) => {
66 const name = bandName(n)
67 if (name === undefined) return -2 // not a band: first
68 const i = order.indexOf(name)
69 return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70 }
71 return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73types/index.d.ts 17 lines1export type HeldCommand = {
2 id: string
3 command: string
4 risk: 'delete' | 'force-push' | 'migration'
5 summary: string
6 details: string[]
7 where: 'pane' | 'band'
8 /** Seconds until the hold auto-cancels; null when it waits for a press forever. */
9 secondsLeft: number | null
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'blast-radius': { held: HeldCommand | null }
15 }
16}
17