Holds terraform/pulumi apply and destroy, gcloud/aws deletes, kubectl delete/apply, docker system prune, rm -rf outside build dirs, and destructive SQL behind…

This mod pauses a risky infra or database command until you approve it. Each held command opens a "Blast Radius" pane that shows the command, where it would run, and a dry-run preview. Press 1 Proceed to run it or 2 Cancel to refuse it. Claude then gets the refusal with a reason. It is adapted from the Blast Radius mod in the Claude Code mods article, for this stack.
| Held | What the pane shows | |||
|---|---|---|---|---|
terraform/`tofu apply\ | destroy` | The workspace, plus the Plan: line and resources from terraform plan -no-color -lock=false (or terraform show for a plan file) | ||
| `pulumi up\ | destroy` | The stack, plus a summary from pulumi preview (or destroy --preview-only) | ||
| `gcloud … deploy\ | delete, gcloud sql … patch; aws … delete*\ | terminate*, aws s3 rm --recursive\ | rb` | The gcloud project and account, or the AWS profile and region (plus the object count from aws s3 ls --summarize) |
| `kubectl delete\ | apply` | The context and namespace, plus kubectl get -o name (for delete) or kubectl diff (for apply) | ||
docker system prune; rm -rf outside /tmp and build folders (dist, node_modules, .next, …) | docker system df; the file count and size | |||
DELETE/UPDATE/DROP/TRUNCATE/ALTER through DB MCP tools (tool name contains mysql, postgres or sql) or mysql/psql (including -e, -c, heredocs, < file.sql) | Each statement, plus a warning when it has no WHERE or a range WHERE. For MCP tools only, it also shows a live row count from a derived SELECT COUNT(*) … WHERE <same where>. That count runs only when the tool is already allowed without a prompt. |
The pane times out after 5 minutes and refuses the command. Pressing Esc on the turn or closing the pane by hand also refuses it. In a claude -p session (or any session with no screen), the command is refused at once because nobody can press Proceed. When the pane can't be placed (a narrow terminal), the same view appears above the prompt. Passwords on the command line are masked in the pane.
This is a safety net, not a permission system. It reads the command text, so aliases, scripts, bash -c, $(…) and xargs get past it. Use permission rules for a hard block. Equality-WHERE statements are held too, because the brief asked for every destructive statement. The mod fails closed: if it breaks while holding a risky call, that call is refused.
hooks/register.tsx 365 lines1import { atom, read } from 'claude-code'
2import type { EngineInterface, Register, ToolCallInput } from 'claude-code'
3
4import type { BlastHeld } from '../types'
5import { classifyBash, mcpSql } from './classify'
6import type { Risk } from './classify'
7import { joinDir } from './shell'
8import { DESTRUCTIVE, analyze, scopeLine } from './sql'
9
10const PANE = 'stack-blast-radius'
11const HOLD_MS = 5 * 60_000
12const MAX_LINES = 12
13const RISKY_WORD = /\b(terraform|tofu|pulumi|gcloud|aws|kubectl|docker|rm|mysql|mariadb|psql)\b/
14
15const held = atom({ plugin: 'stack-blast-radius', key: 'held' } as const, null)
16
17// The call being held lives here; the pane draws the copy published to $.state.
18// A $.state read inside the long tool.call dispatch doesn't see a button's
19// later write, so the hold loop watches these instead (a reload ends the hold
20// with them: its dispatch goes too).
21let current: BlastHeld | null = null
22const decisions = new Map<string, 'proceed' | 'cancel'>()
23
24async function publish($: EngineInterface, change: (h: BlastHeld) => BlastHeld) {
25 if (current === null) return
26 current = change(current)
27 await $.state.set({ plugin: 'stack-blast-radius', key: 'held' }, current)
28}
29
30type Ran = { exitCode: number; stdout: string; stderr: string }
31type Call = { risk: Risk; tool: string; sqlKey?: string; command: string }
32
33/** Passwords and tokens out of anything the pane or a deny shows. */
34export function redact(text: string): string {
35 return (/\b(mysql|mariadb)\b/.test(text) ? text.replace(/(\s-p)(?!\s)\S+/g, '$1***') : text)
36 .replace(/(--password[= ])\S+/gi, '$1***')
37 .replace(/\b([A-Z_]*(PASSWORD|PASS|PWD|TOKEN|SECRET|KEY)[A-Z_]*=)\S+/g, '$1***')
38 .replace(/(\/\/[^:/\s@]+:)[^@\s]+@/g, '$1***@')
39}
40
41const firstLines = (text: string, n = MAX_LINES) => text.split('\n').map(l => l.trimEnd()).filter(l => l.trim() !== '').slice(0, n)
42
43/** The risky part of a tool call, judged from the call alone (no `$`), or null. */
44function riskOf(e: ToolCallInput, root: string): Call | null {
45 if (e.tool === 'Bash') {
46 const risk = classifyBash(e.command, root)
47 return risk && { risk, tool: 'Bash', command: e.command }
48 }
49 const tool = String(e.tool)
50 const found = mcpSql(tool, e as unknown as Record<string, unknown>)
51 if (found === null) return null
52 const sql = analyze(found.sql)
53 const verbs = [...new Set(sql.map(s => s.verb))].join(', ')
54 return sql.length === 0 ? null : {
55 risk: { kind: 'sql', label: `${tool.replace(/^mcp__/, '').replace(/__/, ' ')} ${verbs}`, words: [], dir: null, sql, warnings: [] },
56 tool,
57 sqlKey: found.key,
58 command: found.sql,
59 }
60}
61
62async function run($: EngineInterface, argv: string[], cwd: string | undefined, timeoutMs = 20_000): Promise<Ran> {
63 try {
64 return await $.process.run(argv, { cwd, timeoutMs })
65 } catch (error) {
66 return { exitCode: -1, stdout: '', stderr: String(error instanceof Error ? error.message : error) }
67 }
68}
69
70async function where($: EngineInterface, dir: string | null): Promise<string | undefined> {
71 if (dir === null) return undefined
72 if (dir === '~' || dir.startsWith('~/')) return `${(await $.env.get('HOME')) ?? ''}${dir.slice(1)}`
73 return dir
74}
75
76/** A `sql-file` risk read and judged: null when the file holds nothing destructive. */
77async function readSqlFile($: EngineInterface, risk: Risk): Promise<Risk | null> {
78 const cwd = await $.session.cwd()
79 const dir = await where($, risk.dir)
80 const base = dir === undefined ? cwd : dir.startsWith('/') ? dir : `${cwd}/${dir}`
81 const path = risk.file!.startsWith('/') ? risk.file! : joinDir(base, risk.file)
82 try {
83 const sql = analyze(await $.fs.read(path))
84 return sql.length === 0 ? null : { ...risk, kind: 'sql', sql }
85 } catch {
86 return { ...risk, kind: 'sql', warnings: [`Couldn't read ${risk.file}; it may hold anything.`] }
87 }
88}
89
90const flagValue = (words: readonly string[], ...names: string[]) => {
91 for (let i = 0; i < words.length; i += 1) {
92 const w = words[i]!
93 for (const n of names) {
94 if (w === n) return words[i + 1]
95 if (w.startsWith(`${n}=`)) return w.slice(n.length + 1)
96 }
97 }
98 return undefined
99}
100
101/** What the command would land on, and a dry run where one is cheap and only reads. */
102async function measure($: EngineInterface, call: Call, cwd: string | undefined): Promise<{ context: string[]; lines: string[] }> {
103 const { risk } = call
104 const w = risk.words
105 const context: string[] = []
106 let lines: string[] = []
107
108 if (risk.kind === 'terraform') {
109 const subAt = w.findIndex((x, i) => i > 0 && !x.startsWith('-'))
110 const globals = w.slice(1, subAt)
111 const rest = w.slice(subAt + 1)
112 const ws = await run($, [w[0]!, ...globals, 'workspace', 'show'], cwd, 10_000)
113 if (ws.exitCode === 0) context.push(`workspace: ${ws.stdout.trim()}`)
114 const VALUED = new Set(['-var', '-var-file', '-target', '-replace', '-parallelism', '-lock-timeout', '-state', '-state-out', '-backup'])
115 const planFile = rest.find((x, i) => !x.startsWith('-') && !VALUED.has(rest[i - 1] ?? ''))
116 const pass = rest.flatMap((x, i) => (/^-(var|var-file|target|replace)(=|$)/.test(x) ? (x.includes('=') ? [x] : [x, rest[i + 1] ?? '']) : []))
117 const isDestroy = w[subAt] === 'destroy' || rest.includes('-destroy')
118 const argv = w[subAt] === 'apply' && planFile
119 ? [w[0]!, ...globals, 'show', '-no-color', planFile]
120 : [w[0]!, ...globals, 'plan', '-no-color', '-input=false', '-lock=false', ...(isDestroy ? ['-destroy'] : []), ...pass]
121 const plan = await run($, argv, cwd, 180_000)
122 const out = plan.stdout.split('\n')
123 const summary = out.filter(l => /^(Plan:|No changes\.|Changes to Outputs)/.test(l.trim()))
124 const changes = out.filter(l => /^\s*# .+ (will be|must be)/.test(l)).map(l => l.trim().replace(/^# /, ''))
125 lines = [...summary, ...changes].slice(0, MAX_LINES)
126 if (lines.length === 0) lines = [`${argv.slice(0, 4).join(' ')} gave no summary${plan.exitCode !== 0 ? `: ${firstLines(plan.stderr, 1)[0] ?? `exit ${plan.exitCode}`}` : ''}`]
127 } else if (risk.kind === 'pulumi') {
128 const stackArgs = flagValue(w, '-s', '--stack') ? ['--stack', flagValue(w, '-s', '--stack')!] : []
129 const cwdArgs = flagValue(w, '-C', '--cwd') ? ['--cwd', flagValue(w, '-C', '--cwd')!] : []
130 const stack = stackArgs[1] ?? (await run($, ['pulumi', 'stack', '--show-name', ...cwdArgs], cwd, 15_000)).stdout.trim()
131 if (stack) context.push(`stack: ${stack}`)
132 const argv = risk.label.endsWith('destroy')
133 ? ['pulumi', 'destroy', '--preview-only', '--non-interactive', ...stackArgs, ...cwdArgs]
134 : ['pulumi', 'preview', '--non-interactive', ...stackArgs, ...cwdArgs]
135 const preview = await run($, argv, cwd, 180_000)
136 lines = preview.stdout.split('\n')
137 .filter(l => /^\s*([-+~]{1,2}|\+-|-\+)\s+\S|Resources:|\d+ to (create|update|delete|replace)|unchanged/.test(l))
138 .map(l => l.trim())
139 .slice(0, MAX_LINES)
140 if (lines.length === 0) lines = [`${argv.slice(0, 2).join(' ')} gave no summary${preview.exitCode !== 0 ? `: ${firstLines(preview.stderr, 1)[0] ?? ''}` : ''}`]
141 } else if (risk.kind === 'gcloud') {
142 const project = flagValue(w, '--project') ?? (await run($, ['gcloud', 'config', 'get-value', 'project'], cwd, 10_000)).stdout.trim()
143 const account = flagValue(w, '--account') ?? (await run($, ['gcloud', 'config', 'get-value', 'account'], cwd, 10_000)).stdout.trim()
144 if (project) context.push(`project: ${project}`)
145 if (account) context.push(`account: ${account}`)
146 lines = ['gcloud has no dry run for this; check the project above.']
147 } else if (risk.kind === 'aws') {
148 const profile = flagValue(w, '--profile') ?? (await $.env.get('AWS_PROFILE'))
149 const region = flagValue(w, '--region') ?? (await $.env.get('AWS_REGION')) ?? (await $.env.get('AWS_DEFAULT_REGION'))
150 context.push(`profile: ${profile ?? 'default'}`, ...(region ? [`region: ${region}`] : []))
151 const url = w.find(x => x.startsWith('s3://'))
152 if (url) {
153 const extra = [...(profile ? ['--profile', profile] : []), ...(region ? ['--region', region] : [])]
154 const ls = await run($, ['aws', 's3', 'ls', '--recursive', '--summarize', url, ...extra], cwd, 30_000)
155 lines = ls.stdout.split('\n').filter(l => /Total (Objects|Size)/.test(l)).map(l => l.trim())
156 if (lines.length === 0) lines = [`Couldn't list ${url}.`]
157 } else {
158 lines = ['aws has no dry run for this; check the profile and region above.']
159 }
160 } else if (risk.kind === 'kubectl') {
161 const ctx = flagValue(w, '--context') ?? (await run($, ['kubectl', 'config', 'current-context'], cwd, 10_000)).stdout.trim()
162 const ns = flagValue(w, '-n', '--namespace')
163 ?? ((await run($, ['kubectl', 'config', 'view', '--minify', '-o', 'jsonpath={..namespace}'], cwd, 10_000)).stdout.trim() || 'default')
164 context.push(`context: ${ctx || '(none)'}`, `namespace: ${ns}`)
165 const subAt = w.findIndex(x => x === 'delete' || x === 'apply')
166 if (w[subAt] === 'delete') {
167 const keep = w.slice(1).filter((x, i) => i + 1 !== subAt && !/^--(grace-period|force|now|wait|cascade|all$|timeout)/.test(x))
168 const out = keep.flatMap((x, i) => (x === '-o' || x === '--output' ? [] : keep[i - 1] === '-o' || keep[i - 1] === '--output' ? [] : [x]))
169 const get = await run($, ['kubectl', 'get', ...out, '-o', 'name'], cwd, 20_000)
170 const names = firstLines(get.stdout, 200)
171 lines = get.exitCode === 0 ? [`deletes ${names.length} object(s)`, ...names.slice(0, MAX_LINES - 1)] : [`kubectl get failed: ${firstLines(get.stderr, 1)[0] ?? ''}`]
172 } else {
173 const args: string[] = []
174 w.forEach((x, i) => {
175 if (/^(-f|--filename|-k|--kustomize|-n|--namespace|--context|-l|--selector)$/.test(x)) args.push(x, w[i + 1] ?? '')
176 else if (/^(-R|--recursive|--server-side)$|^(-f|--filename|-k|--kustomize|-n|--namespace|--context|-l|--selector)=/.test(x)) args.push(x)
177 })
178 const diff = await run($, ['kubectl', 'diff', ...args], cwd, 30_000)
179 lines = diff.exitCode === 0 ? ['kubectl diff: no changes'] : firstLines(diff.stdout || diff.stderr)
180 }
181 } else if (risk.kind === 'docker') {
182 lines = firstLines((await run($, ['docker', 'system', 'df'], cwd, 15_000)).stdout)
183 } else if (risk.kind === 'rm') {
184 const targets = w.slice(1).filter(a => !/^-/.test(a))
185 // Paths go in as arguments, never as source; IFS= keeps a glob from splitting.
186 const script = 'IFS=; shopt -s nullglob dotglob; paths=(); for g in "$@"; do case "$g" in /*) ;; *) g="./$g";; esac; for p in $g; do [ -e "$p" ] && paths+=("$p"); done; done; [ ${#paths[@]} -eq 0 ] && { echo 0; echo 0; exit 0; }; find "${paths[@]}" 2>/dev/null | wc -l; du -shc -- "${paths[@]}" 2>/dev/null | tail -n1 | cut -f1'
187 const counted = await run($, ['bash', '-c', script, 'blast-radius', ...targets], cwd, 15_000)
188 const [n, size] = firstLines(counted.stdout, 2)
189 lines = n === '0' ? ['Nothing there to delete.'] : [`${n ?? '?'} files and folders, ${size ?? '?'} in all`]
190 } else if (risk.kind === 'sql') {
191 for (const s of risk.sql) {
192 lines.push(s.text.replace(/\s+/g, ' ').slice(0, 160), ` ${scopeLine(s)}${s.note && s.scope !== 'schema' ? ` ${s.note}` : ''}`)
193 if (s.count !== undefined && call.sqlKey !== undefined && risk.sql.length === 1) {
194 lines.push(` ${await countRows($, call.tool, call.sqlKey, s.count)}`)
195 }
196 }
197 if (call.tool === 'Bash') lines.push('Row counts are not run for the mysql/psql CLI.')
198 }
199 return { context, lines }
200}
201
202/** Runs the derived COUNT through the same DB tool, only if that call needs no prompt. */
203async function countRows($: EngineInterface, tool: string, key: string, count: string): Promise<string> {
204 const input = { [key]: count }
205 const check = await $.tool.check({ tool, input })
206 if (check.decision !== 'allow') return `Not counted: running ${count.slice(0, 60)}... would need your permission.`
207 const res = await $.tool.call({ tool, ...input } as never)
208 const text = res.deny ?? res.text ?? ''
209 const n = /"n"\s*:\s*"?(\d+)/.exec(text)?.[1] ?? /(\d+)/.exec(text)?.[1]
210 return n === undefined ? `Count failed: ${text.slice(0, 80)}` : `${n} row(s) match now.`
211}
212
213/** Fills in the held call's preview, as long as that call is still the one held. */
214async function preview($: EngineInterface, call: Call, id: string) {
215 let found: { context?: string[]; lines: string[] }
216 try {
217 found = await measure($, call, await where($, call.risk.dir))
218 } catch (error) {
219 found = { lines: [`Preview failed: ${String(error instanceof Error ? error.message : error).slice(0, 120)}`] }
220 }
221 if (current?.id === id) await publish($, h => ({ ...h, ...found, isMeasuring: false })).catch(() => {})
222}
223
224const deny = (label: string, why: string) => ({
225 deny: `stack-blast-radius held \`${label}\` and did not run it: ${why}. Don't retry it unless the user asks you to.`,
226})
227
228export const register: Register = on => {
229 on('tool.call', async ($, e, next) => {
230 if (e.tool !== 'Bash' && !String(e.tool).startsWith('mcp__')) return next(e)
231 let call = riskOf(e, e.tool === 'Bash' ? await $.session.root() : '/')
232 if (call === null) return next(e)
233 if (call.risk.kind === 'sql-file') {
234 const risk = await readSqlFile($, call.risk)
235 if (risk === null) return next(e)
236 call = { ...call, risk }
237 }
238 const { label } = call.risk
239 if ((await $.session.surfaces()).length === 0) {
240 return deny(label, 'nobody can press Proceed in a session with no screen (claude -p); ask the user to run it themselves')
241 }
242
243 const id = e.tool_use_id
244 const startedAt = await $.clock.now()
245 const mine: BlastHeld = {
246 id,
247 label,
248 command: redact(call.command),
249 context: [],
250 lines: [],
251 warnings: [...call.risk.warnings, ...(call.risk.sql.some(s => s.scope === 'none' && s.verb !== 'TRUNCATE') ? ['No WHERE: it touches every row.'] : [])],
252 isMeasuring: true,
253 decision: null,
254 where: 'pane',
255 startedAt,
256 }
257 // One hold at a time: a second risky call (a subagent's) waits its turn.
258 // No await between the check and the claim, so two can't both get in.
259 while (current !== null) {
260 if (next.signal.aborted) return deny(label, 'the turn was interrupted')
261 if ((await $.clock.now()) - startedAt > HOLD_MS) return deny(label, 'another held command was still waiting for an answer')
262 await $.process.run(['sleep', '0.25'], { timeoutMs: 5000 })
263 }
264 current = mine
265
266 let placed = false
267 let decision: string
268 try {
269 await publish($, h => h)
270 const opened = await $.ui.open({ id: PANE, title: 'Blast Radius', focus: true, rows: 20 })
271 placed = opened.isPlaced
272 if (!placed) await publish($, h => ({ ...h, where: 'band' }))
273 void preview($, call, id)
274 for (;;) {
275 const chosen = decisions.get(id)
276 if (chosen !== undefined) { decision = chosen; break }
277 if (next.signal.aborted) { decision = 'interrupted'; break }
278 if ((await $.clock.now()) - startedAt > HOLD_MS) { decision = 'timeout'; break }
279 await $.process.run(['sleep', '0.25'], { timeoutMs: 5000 })
280 }
281 } catch {
282 decision = 'error'
283 } finally {
284 // Close this call's pane before letting the next hold in.
285 if (placed) await $.ui.close({ id: PANE }).catch(() => {})
286 decisions.delete(id)
287 if (current?.id === id) current = null
288 await $.state.set({ plugin: 'stack-blast-radius', key: 'held' }, null).catch(() => {})
289 }
290
291 if (decision === 'proceed') return next(e)
292 const why: Record<string, string> = {
293 cancel: 'the user pressed Cancel',
294 timeout: `no answer within ${HOLD_MS / 60_000} minutes`,
295 interrupted: 'the turn was interrupted',
296 }
297 return deny(label, why[decision] ?? 'the hold failed')
298 }).catch(($, e, next) => {
299 // Fail closed (Jev: fail_closed 0.98): a risky call is refused when the
300 // hold breaks; anything else goes on. Re-entry (our own COUNT) is judged
301 // the same way, from the call alone.
302 if (next.called) return next(e)
303 let risky: boolean
304 try {
305 risky = riskOf(e, '') !== null
306 } catch {
307 risky = e.tool !== 'Bash' || DESTRUCTIVE.test(e.command) || RISKY_WORD.test(e.command)
308 }
309 return !risky ? next(e) : { deny: 'stack-blast-radius: its hold failed, so this risky command was not run. Ask the user to run it, or retry.' }
310 })
311
312 // Closing the pane by hand is a Cancel.
313 on('ui.close', ($, e, next) => {
314 if (e.id === PANE && e.origin.kind === 'person' && current !== null && !decisions.has(current.id)) {
315 decisions.set(current.id, 'cancel')
316 }
317 return next(e)
318 })
319
320 // A copy left in $.state by a reload mid-hold names no live call: not drawn.
321 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
322 const h = await read($, held)
323 return h === null || h.id !== current?.id ? next(e) : draw($, e, h)
324 })
325
326 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
327 const h = await read($, held)
328 return h === null || h.id !== current?.id || h.where !== 'band' ? next(e) : draw($, e, h)
329 })
330}
331
332// `h` is the JSX factory, so the held call is `item` here.
333function draw($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], item: BlastHeld) {
334 const { Box, Button, Text } = $.ui.resolve(e)
335 // The buttons answer the call this pane was drawn for, never a later one.
336 const decide = (decision: 'proceed' | 'cancel') => async () => {
337 if (decisions.has(item.id) || current?.id !== item.id) return
338 decisions.set(item.id, decision)
339 await publish($, h => ({ ...h, decision }))
340 }
341 const commandLines = item.command.split('\n')
342 return (
343 <Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
344 <Text key="title" bold color="yellow">⚠ Blast Radius · {item.label}</Text>
345 <Box key="cmd" flexDirection="column" marginTop={1}>
346 {commandLines.slice(0, 6).map((line, i) => (
347 <Text key={`c${i}`} wrap="truncate-end">{i === 0 ? '$ ' : ' '}{line}</Text>
348 ))}
349 {commandLines.length > 6 ? <Text key="cmore" dimColor> … {commandLines.length - 6} more lines</Text> : null}
350 </Box>
351 {item.context.length > 0 ? <Text key="ctx" color="cyan">{item.context.join(' · ')}</Text> : null}
352 <Box key="preview" flexDirection="column" marginTop={1}>
353 {item.isMeasuring && item.lines.length === 0 ? <Text key="wait" dimColor>Running the dry run…</Text> : null}
354 {item.lines.map((line, i) => <Text key={`l${i}`} wrap="truncate-end">{line}</Text>)}
355 </Box>
356 {item.warnings.map((line, i) => <Text key={`w${i}`} color="red" bold>! {line}</Text>)}
357 <Box key="buttons" marginTop={1} gap={2}>
358 <Button key="proceed" label="Proceed" hotkey="1" plain onPress={decide('proceed')} />
359 <Button key="cancel" label="Cancel" hotkey="2" plain autoFocus onPress={decide('cancel')} />
360 <Text key="hint" dimColor>Claude is waiting. No answer in {HOLD_MS / 60_000} min = Cancel.</Text>
361 </Box>
362 </Box>
363 )
364}
365hooks/classify.ts 141 lines1import { commands, program, stripHeredocs } from './shell'
2import { DESTRUCTIVE, analyze } from './sql'
3import type { SqlStatement } from './sql'
4
5export type Kind = 'terraform' | 'pulumi' | 'gcloud' | 'aws' | 'kubectl' | 'docker' | 'rm' | 'sql' | 'sql-file'
6
7export type Risk = {
8 kind: Kind
9 label: string
10 /** The risky command's words (after sudo, env and the like). */
11 words: string[]
12 /** Where it runs: a `cd` earlier on the line; null = the session folder. */
13 dir: string | null
14 sql: SqlStatement[]
15 /** For `sql-file`: the file the client reads. */
16 file?: string
17 warnings: string[]
18}
19
20/** Folders a build makes and `rm -rf` may clear without asking. */
21const BUILD_DIRS = new Set([
22 'node_modules', 'dist', 'build', 'out', 'target', '.next', '.nuxt', '.turbo', '.cache', 'coverage',
23 '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache', '.parcel-cache', '.svelte-kit', '.output', 'tmp', '.tmp',
24])
25
26/** The words that aren't flags, skipping the values of the flags named in `valued`. */
27function positional(args: readonly string[], valued: ReadonlySet<string>): string[] {
28 const out: string[] = []
29 for (let i = 0; i < args.length; i += 1) {
30 const a = args[i]!
31 if (valued.has(a)) i += 1
32 else if (!a.startsWith('-')) out.push(a)
33 }
34 return out
35}
36
37const AWS_VALUED = new Set(['--profile', '--region', '--output', '--endpoint-url', '--query', '--color', '--ca-bundle', '--cli-read-timeout', '--cli-connect-timeout'])
38const KUBECTL_VALUED = new Set(['--context', '-n', '--namespace', '--kubeconfig', '--cluster', '--user', '-s', '--server', '--as', '-l', '--selector', '-f', '--filename', '-k', '--kustomize', '-o', '--output'])
39const GCLOUD_VALUED = new Set(['--project', '--account', '--region', '--zone', '--format', '--configuration', '--impersonate-service-account'])
40
41/** True when every target is under /tmp or inside a build folder of the repo. */
42export function isSafeRm(targets: readonly string[], dir: string | null, root: string): boolean {
43 if (targets.length === 0) return true
44 return targets.every(t => {
45 if (/[$`~]/.test(t) || t.split('/').includes('..')) return false
46 if (t.startsWith('/tmp/') && t.length > 5) return true
47 let rel: string
48 if (t.startsWith('/')) {
49 if (root === '' || root === '/') return false
50 if (!t.startsWith(`${root.replace(/\/$/, '')}/`)) return false
51 rel = t.slice(root.replace(/\/$/, '').length + 1)
52 } else {
53 if (dir !== null && (dir.startsWith('/') || dir.startsWith('~') || dir === '-' || dir.split('/').includes('..'))) return false
54 rel = t
55 }
56 const parts = rel.split('/').filter(p => p !== '' && p !== '.')
57 const at = parts.findIndex(p => BUILD_DIRS.has(p))
58 // Globs only below the build folder: `dist/*` yes, `*/dist` no.
59 return at >= 0 && parts.slice(0, at + 1).every(p => !/[*?[]/.test(p))
60 })
61}
62
63function sqlClient(words: readonly string[]): { sources: string[]; file?: string } {
64 const sources: string[] = []
65 let file: string | undefined
66 for (let i = 1; i < words.length; i += 1) {
67 const a = words[i]!
68 if (a === '-e' || a === '--execute' || a === '-c' || a === '--command') sources.push(words[(i += 1)] ?? '')
69 else if (/^--(execute|command)=/.test(a)) sources.push(a.replace(/^--\w+=/, ''))
70 else if (/^-[ec].+/.test(a)) sources.push(a.slice(2))
71 else if (a === '-f' || a === '--file') file = words[(i += 1)]
72 else if (a.startsWith('--file=')) file = a.slice(7)
73 }
74 return { sources, file }
75}
76
77/** The first risky command on a Bash line, or null. `root` is the session's project root. */
78export function classifyBash(line: string, root: string): Risk | null {
79 const { bodies } = stripHeredocs(line)
80 const cmds = commands(line)
81 const risk = (kind: Kind, label: string, words: string[], dir: string | null, extra: Partial<Risk> = {}): Risk =>
82 ({ kind, label, words, dir, sql: [], warnings: [], ...extra })
83
84 for (const { words, dir } of cmds) {
85 const cmd = program(words[0])
86 const args = words.slice(1)
87 const pos = (valued: ReadonlySet<string> = new Set()) => positional(args, valued)
88
89 if (cmd === 'terraform' || cmd === 'tofu') {
90 const sub = pos()[0]
91 if (sub === 'apply' || sub === 'destroy') return risk('terraform', `${cmd} ${sub}`, words, dir)
92 } else if (cmd === 'pulumi') {
93 const sub = pos(new Set(['-s', '--stack', '-C', '--cwd']))[0]
94 if (sub === 'up' || sub === 'update' || sub === 'destroy') return risk('pulumi', `pulumi ${sub}`, words, dir)
95 } else if (cmd === 'gcloud') {
96 const p = pos(GCLOUD_VALUED)
97 const verb = p.find(w => w === 'deploy' || w === 'delete') ?? (p[0] === 'sql' && p.includes('patch') ? 'patch' : undefined)
98 if (verb) return risk('gcloud', `gcloud ${p.slice(0, p.indexOf(verb) + 1).join(' ')}`, words, dir)
99 } else if (cmd === 'aws') {
100 const [service, op] = pos(AWS_VALUED)
101 const isS3 = service === 's3' && (op === 'rb' || (op === 'rm' && args.includes('--recursive')))
102 if (isS3 || (op !== undefined && /^(delete|terminate)/.test(op))) return risk('aws', `aws ${service} ${op}`, words, dir)
103 } else if (cmd === 'kubectl') {
104 const sub = pos(KUBECTL_VALUED)[0]
105 if (sub === 'delete' || sub === 'apply') return risk('kubectl', `kubectl ${sub}`, words, dir)
106 } else if (cmd === 'docker') {
107 if (args[0] === 'system' && args[1] === 'prune') return risk('docker', 'docker system prune', words, dir)
108 } else if (cmd === 'rm') {
109 const flags = args.filter(a => /^-/.test(a) && a !== '-' && a !== '--')
110 const recursive = flags.some(f => f === '--recursive' || (/^-[^-]/.test(f) && /[rR]/.test(f)))
111 const force = flags.some(f => f === '--force' || (/^-[^-]/.test(f) && f.includes('f')))
112 const targets = args.filter(a => !/^-/.test(a) || a === '-')
113 if (recursive && force && !isSafeRm(targets, dir, root)) {
114 return risk('rm', `rm ${flags.join(' ')}`, words, dir, { warnings: [`Deletes ${targets.join(' ')} for good: rm has no undo.`] })
115 }
116 } else if (cmd === 'mysql' || cmd === 'mariadb' || cmd === 'psql') {
117 const client = sqlClient(words)
118 const echoed = cmds.filter(c => ['echo', 'printf'].includes(program(c.words[0]))).flatMap(c => c.words.slice(1))
119 const sql = analyze([...client.sources, ...bodies, ...echoed].join(';\n'))
120 if (sql.length > 0) return risk('sql', `${cmd} ${sql.map(s => s.verb).join(', ')}`, words, dir, { sql })
121 const redirect = new RegExp(`\\b${cmd}\\b[^|;&\\n]*<\\s*([^\\s|;&<>]+)`).exec(line)?.[1]
122 const file = client.file ?? redirect
123 if (file !== undefined) return risk('sql-file', `${cmd} < ${file}`, words, dir, { file })
124 if (DESTRUCTIVE.test(line)) {
125 return risk('sql', `${cmd} (SQL)`, words, dir, { warnings: ["Couldn't pick the SQL out of the command line; read the command."] })
126 }
127 }
128 }
129 return null
130}
131
132/** The SQL a DB MCP tool call carries, by tool name and argument, or null. */
133export function mcpSql(tool: string, input: Record<string, unknown>): { key: string; sql: string } | null {
134 if (!tool.startsWith('mcp__') || !/mysql|postgres|sql/i.test(tool)) return null
135 for (const key of ['sql', 'query', 'statement', 'command']) {
136 const v = input[key]
137 if (typeof v === 'string') return { key, sql: v }
138 }
139 return null
140}
141hooks/shell.ts 189 lines1// A shell command line read well enough to find the commands in it: quotes,
2// escapes, separators (&& || ; | & newline), ( ) subshells and `cd`. Heredoc
3// bodies are cut out first; $( ... ) stays inside its word. Not a full shell.
4
5export type Command = {
6 /** The words, unquoted, with redirects, VAR=value, sudo and the like removed. */
7 words: string[]
8 /** Where a `cd`/`pushd` earlier on the line moved to; null = the session folder. */
9 dir: string | null
10}
11
12const HEREDOC = /<<-?[ \t]*(['"]?)([A-Za-z_]\w*)\1([^\n]*)\n([\s\S]*?)\n[ \t]*\2[ \t]*(?=\n|$)/g
13
14/** The command with heredoc bodies removed, and the bodies. */
15export function stripHeredocs(input: string): { text: string; bodies: string[] } {
16 const bodies: string[] = []
17 const text = input.replace(HEREDOC, (_m, q: string, tag: string, rest: string, body: string) => {
18 bodies.push(body)
19 return `<<${q}${tag}${q}${rest}`
20 })
21 return { text, bodies }
22}
23
24type Raw = { words: string[]; depth: number }
25
26function split(input: string): Raw[] {
27 const out: Raw[] = []
28 let words: string[] = []
29 let word = ''
30 let inWord = false
31 let depth = 0
32 let segDepth = 0
33 const endWord = () => {
34 if (inWord) words.push(word)
35 word = ''
36 inWord = false
37 }
38 const endSeg = () => {
39 endWord()
40 if (words.length > 0) out.push({ words, depth: segDepth })
41 words = []
42 segDepth = depth
43 }
44 for (let i = 0; i < input.length; i += 1) {
45 const c = input[i]!
46 if (c === '\\') {
47 if (i + 1 < input.length && input[i + 1] !== '\n') {
48 word += input[i + 1]
49 inWord = true
50 }
51 i += 1
52 } else if (c === "'") {
53 const j = input.indexOf("'", i + 1)
54 const end = j < 0 ? input.length : j
55 word += input.slice(i + 1, end)
56 inWord = true
57 i = end
58 } else if (c === '"') {
59 let j = i + 1
60 while (j < input.length && input[j] !== '"') {
61 if (input[j] === '\\' && j + 1 < input.length && '"\\$`'.includes(input[j + 1]!)) {
62 word += input[j + 1]
63 j += 2
64 } else {
65 word += input[j]
66 j += 1
67 }
68 }
69 inWord = true
70 i = j
71 } else if (c === '$' && input[i + 1] === '(') {
72 let j = i + 2
73 let d = 1
74 while (j < input.length && d > 0) {
75 if (input[j] === '(') d += 1
76 else if (input[j] === ')') d -= 1
77 j += 1
78 }
79 word += input.slice(i, j)
80 inWord = true
81 i = j - 1
82 } else if (c === '#' && !inWord) {
83 while (i + 1 < input.length && input[i + 1] !== '\n') i += 1
84 } else if (c === ' ' || c === '\t') {
85 endWord()
86 } else if (c === '\n' || c === ';') {
87 endSeg()
88 } else if (c === '&' && (input[i - 1] === '>' || input[i + 1] === '>')) {
89 word += c
90 inWord = true
91 } else if (c === '&' || c === '|') {
92 if (input[i + 1] === c) i += 1
93 endSeg()
94 } else if (c === '(' && !inWord) {
95 endSeg()
96 depth += 1
97 segDepth = depth
98 } else if (c === ')') {
99 endSeg()
100 depth = Math.max(0, depth - 1)
101 segDepth = depth
102 } else {
103 word += c
104 inWord = true
105 }
106 }
107 endSeg()
108 return out
109}
110
111const PREFIXES = new Set(['command', 'exec', 'env', 'nohup', 'time', 'then', 'do', 'else', '!', 'builtin'])
112const SUDO_VALUE = new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U'])
113const REDIRECT = /^(\d*|&)(>>?|<<?-?|<)/
114
115function normalize(input: readonly string[]): string[] {
116 const words: string[] = []
117 for (let i = 0; i < input.length; i += 1) {
118 const w = input[i]!
119 const m = REDIRECT.exec(w)
120 if (m) {
121 if (m[0] === w) i += 1 // `> file`: drop the target too
122 continue
123 }
124 words.push(w)
125 }
126 for (;;) {
127 const first = words[0]
128 if (first === undefined) break
129 if (/^[A-Za-z_]\w*=/.test(first) || PREFIXES.has(first)) {
130 words.shift()
131 } else if (first === 'sudo' || first === 'doas') {
132 words.shift()
133 while (words[0]?.startsWith('-')) {
134 const opt = words.shift()!
135 if (SUDO_VALUE.has(opt)) words.shift()
136 }
137 } else if (first === 'nice') {
138 words.shift()
139 if (words[0] === '-n') words.splice(0, 2)
140 else if (/^-\d+$/.test(words[0] ?? '')) words.shift()
141 } else {
142 break
143 }
144 }
145 return words
146}
147
148export function joinDir(dir: string | null, arg: string | undefined): string {
149 if (arg === undefined || arg === '~' || arg.startsWith('/') || arg.startsWith('~/')) return arg ?? '~'
150 return dir === null ? arg : `${dir}/${arg}`
151}
152
153/** Every simple command on the line, in order, each with the folder it runs in. */
154export function commands(line: string): Command[] {
155 const raws = split(stripHeredocs(line).text)
156 const out: Command[] = []
157 let dir: string | null = null
158 let depth = 0
159 const scopes: (string | null)[] = []
160 const pushed: (string | null)[] = []
161 for (const raw of raws) {
162 while (depth < raw.depth) {
163 scopes.push(dir)
164 depth += 1
165 }
166 while (depth > raw.depth) {
167 dir = scopes.pop() ?? null
168 depth -= 1
169 }
170 const words = normalize(raw.words)
171 const [cmd, arg] = words
172 if (cmd === 'cd') {
173 dir = arg === '-' ? '-' : joinDir(dir, arg)
174 } else if (cmd === 'pushd') {
175 pushed.push(dir)
176 dir = joinDir(dir, arg)
177 } else if (cmd === 'popd') {
178 dir = pushed.length > 0 ? (pushed.pop() ?? null) : '-'
179 }
180 if (words.length > 0) out.push({ words, dir })
181 }
182 return out
183}
184
185/** The program's bare name: `/usr/bin/git` and `\git` are `git`. */
186export function program(word: string | undefined): string {
187 return (word ?? '').replace(/^\\/, '').replace(/^.*\//, '')
188}
189hooks/sql.ts 130 lines1// Reads destructive SQL closely enough to say what it touches: the verb, the
2// table, the WHERE clause and how wide it is. 'String' literals and comments
3// are masked first so a keyword inside them is not read as SQL.
4
5export type Scope = 'none' | 'range' | 'equality' | 'schema'
6
7export type SqlStatement = {
8 verb: 'DELETE' | 'UPDATE' | 'DROP' | 'TRUNCATE' | 'ALTER'
9 text: string
10 table?: string
11 where?: string
12 scope: Scope
13 /** A read-only `SELECT COUNT(*) AS n ...` over the same rows, when it can be derived. */
14 count?: string
15 note?: string
16}
17
18export const DESTRUCTIVE = /\b(DELETE\s+FROM|UPDATE\s+\S+\s+SET|DROP\s+(TABLE|DATABASE|SCHEMA|VIEW|INDEX|COLUMN)|TRUNCATE|ALTER\s+TABLE)\b/i
19
20/** Same length as `sql`, with quoted text and comments blanked. */
21function mask(sql: string): string {
22 let out = ''
23 for (let i = 0; i < sql.length; i += 1) {
24 const c = sql[i]!
25 if (c === "'") {
26 let j = i + 1
27 while (j < sql.length && !(sql[j] === c && sql[j + 1] !== c)) j += sql[j] === c || sql[j] === '\\' ? 2 : 1
28 out += c + ' '.repeat(Math.max(0, Math.min(j, sql.length) - i - 1)) + (j < sql.length ? c : '')
29 i = j
30 } else if (c === '-' && sql[i + 1] === '-') {
31 const j = sql.indexOf('\n', i)
32 const end = j < 0 ? sql.length : j
33 out += ' '.repeat(end - i)
34 i = end - 1
35 } else if (c === '/' && sql[i + 1] === '*') {
36 const j = sql.indexOf('*/', i + 2)
37 const end = j < 0 ? sql.length : j + 2
38 out += ' '.repeat(end - i)
39 i = end - 1
40 } else {
41 out += c
42 }
43 }
44 return out
45}
46
47/** Index of the first match of `re` outside parentheses, or -1. */
48function topLevel(masked: string, re: RegExp, from = 0): number {
49 const global = new RegExp(re.source, 'gi')
50 global.lastIndex = from
51 for (let m = global.exec(masked); m !== null; m = global.exec(masked)) {
52 let depth = 0
53 for (let k = 0; k < m.index; k += 1) {
54 if (masked[k] === '(') depth += 1
55 else if (masked[k] === ')') depth -= 1
56 }
57 if (depth === 0) return m.index
58 }
59 return -1
60}
61
62export function statements(sql: string): string[] {
63 const masked = mask(sql)
64 const out: string[] = []
65 let start = 0
66 for (let i = 0; i <= masked.length; i += 1) {
67 if (i === masked.length || masked[i] === ';') {
68 const one = sql.slice(start, i).trim()
69 if (one !== '') out.push(one)
70 start = i + 1
71 }
72 }
73 return out
74}
75
76const RANGE = /(<|>|\bBETWEEN\b|\bLIKE\b|\bNOT\b|\bIS\s+(NOT\s+)?NULL\b|\bIN\s*\(\s*SELECT\b|\bOR\b)/i
77const ALL_ROWS = /^\s*(1\s*=\s*1|true|1)\s*$/i
78const NAME = '([\\w.$]+|`[^`]+`|"[^"]+")'
79
80function rowsOf(verb: 'DELETE' | 'UPDATE', stmt: string, masked: string): SqlStatement {
81 const head = verb === 'DELETE'
82 ? new RegExp(`^\\s*DELETE\\s+(?:(?:LOW_PRIORITY|QUICK|IGNORE)\\s+)*FROM\\s+(?:ONLY\\s+)?${NAME}(?:\\s+(?:AS\\s+)?(?!WHERE\\b|ORDER\\b|LIMIT\\b|RETURNING\\b|USING\\b)(\\w+))?\\s*(?=WHERE\\b|ORDER\\b|LIMIT\\b|RETURNING\\b|$)`, 'i')
83 : new RegExp(`^\\s*UPDATE\\s+(?:(?:LOW_PRIORITY|IGNORE|ONLY)\\s+)*${NAME}(?:\\s+(?:AS\\s+)?(?!SET\\b)(\\w+))?\\s+SET\\b`, 'i')
84 const m = head.exec(masked)
85 const table = m?.[1]
86 const alias = m?.[2]
87 const whereAt = topLevel(masked, /\bWHERE\b/)
88 const endAt = whereAt < 0 ? -1 : topLevel(masked, /\b(ORDER\s+BY|LIMIT|RETURNING)\b/, whereAt)
89 const cut = (s: string) => s.slice(whereAt + 5, endAt < 0 ? undefined : endAt).trim()
90 const where = whereAt < 0 ? undefined : cut(stmt)
91 const scope: Scope = where === undefined || ALL_ROWS.test(where) ? 'none' : RANGE.test(cut(masked)) ? 'range' : 'equality'
92 const limited = topLevel(masked, /\bLIMIT\b/) >= 0
93 const count = table
94 ? `SELECT COUNT(*) AS n FROM ${table}${alias ? ` ${alias}` : ''}${where ? ` WHERE ${where}` : ''}`
95 : undefined
96 return { verb, text: stmt, table, where, scope, count, note: limited ? 'LIMIT caps how many rows it touches.' : undefined }
97}
98
99/** The destructive statements in `sql`; reads and inserts are left out. */
100export function analyze(sql: string): SqlStatement[] {
101 const out: SqlStatement[] = []
102 for (const stmt of statements(sql)) {
103 const masked = mask(stmt)
104 const verb = /^\s*(DELETE|UPDATE|DROP|TRUNCATE|ALTER)\b/i.exec(masked)?.[1]?.toUpperCase()
105 if (verb === 'DELETE' || verb === 'UPDATE') {
106 out.push(rowsOf(verb, stmt, masked))
107 } else if (verb === 'TRUNCATE') {
108 const table = new RegExp(`^\\s*TRUNCATE\\s+(TABLE\\s+)?(ONLY\\s+)?${NAME}\\s*$`, 'i').exec(masked)?.[3]
109 out.push({ verb, text: stmt, table, scope: 'none', count: table ? `SELECT COUNT(*) AS n FROM ${table}` : undefined, note: 'Removes every row.' })
110 } else if (verb === 'DROP') {
111 const m = new RegExp(`^\\s*DROP\\s+(TEMPORARY\\s+)?(\\w+)\\s+(IF\\s+EXISTS\\s+)?${NAME}\\s*(CASCADE)?\\s*$`, 'i').exec(masked)
112 const kind = m?.[2]?.toUpperCase()
113 const table = kind === 'TABLE' ? m?.[4] : undefined
114 out.push({ verb, text: stmt, table, scope: 'schema', count: table ? `SELECT COUNT(*) AS n FROM ${table}` : undefined, note: `Drops ${kind?.toLowerCase() ?? 'an object'}${m?.[4] ? ` ${m[4]}` : ''}${m?.[5] ? ' and everything that depends on it' : ''}.` })
115 } else if (verb === 'ALTER') {
116 const drops = /\bDROP\s+(COLUMN|INDEX|CONSTRAINT|PRIMARY|FOREIGN)\b/i.test(masked)
117 out.push({ verb, text: stmt, scope: 'schema', note: drops ? 'Drops part of the schema.' : 'Changes the schema; a big table may lock while it runs.' })
118 }
119 }
120 return out
121}
122
123/** One line on how wide the statement is. */
124export function scopeLine(s: SqlStatement): string {
125 if (s.scope === 'none') return `${s.verb} with no WHERE: every row${s.table ? ` of ${s.table}` : ''}.`
126 if (s.scope === 'range') return `${s.verb} with a range WHERE (${(s.where ?? '').slice(0, 80)}): may match many rows.`
127 if (s.scope === 'equality') return `${s.verb} WHERE ${(s.where ?? '').slice(0, 80)}.`
128 return s.note ?? s.verb
129}
130types/index.d.ts 24 lines1export type BlastHeld = {
2 /** The held call's tool_use_id. */
3 id: string
4 label: string
5 /** The command or SQL, secrets masked. */
6 command: string
7 /** Where it would land: workspace, stack, project, kube context. */
8 context: string[]
9 /** The dry-run preview's lines. */
10 lines: string[]
11 warnings: string[]
12 isMeasuring: boolean
13 decision: 'proceed' | 'cancel' | null
14 /** Drawn in its pane, or in the band above the prompt when no pane is placed. */
15 where: 'pane' | 'band'
16 startedAt: number
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'stack-blast-radius': { held: BlastHeld | null }
22 }
23}
24