SLOPSHOPPER

stack-blast-radius

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

newpanebandguardprocess
v0.1.0no licenseupdated 2026-10-09zainokta/zstack/mods/stack-blast-radius
A shopper browsing a rack in a slop shop
README

stack-blast-radius

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.

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

Source 5 files
hooks/register.tsx 365 lines
1import { 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}
365
hooks/classify.ts 141 lines
1import { 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}
141
hooks/shell.ts 189 lines
1// 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}
189
hooks/sql.ts 130 lines
1// 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}
130
types/index.d.ts 24 lines
1export 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