SLOPSHOPPER

bash-guard

Blocks catastrophic Bash commands; holds risky ones (rm -r, git reset --hard, git clean, force push, migrations) and shows what they would change, with Proceed…

newpanebandguardtoastprocess
v0.2.0Apache-2.0updated 2026-10-09hugo88/claude-code-bash-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bash-guard
│ ┃ bash-guard ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ⚠ bash-guard · rm -rf ⏺ Read(src/auth.ts) │ ┃ │ Command rm -rf build && git push --for… ⎿ Read 6 lines │ ┃ │ Would delete nothing: no file matches ⏺ Update(src/auth.ts) │ ┃ │ build ⎿ Added 2 lines, removed 1 line │ ┃ │ ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ │ The paths don't exist, so rm has nothing ⎿ Denied by bash-guard: bash-guard held this command and di │ ┃ │ to remove. │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 1: Proceed 2: Cancel Claude is waiting o │ ┃ ╰─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · bash-guard · while holding a tool call
╭──────────────────────────────────────────────────────────╮ │ ⚠ bash-guard · rm -rf │ │ Command rm -rf build && git push --force origin main │ │ Would delete nothing: no file matches build │ │ │ │ The paths don't exist, so rm has nothing to remove. │ │ │ │ 1: Proceed 2: Cancel Claude is waiting on your answer │ ╰──────────────────────────────────────────────────────────╯
README

bash-guard

Mod do Claude Code, który pilnuje komend Bash uruchamianych przez Claude'a. Katastrofalne blokuje od razu. Ryzykowne wstrzymuje i pokazuje, co by zmieniły: pliki do usunięcia, zmiany do wyrzucenia, commity, które przepadną. Decydujesz przyciskiem Proceed albo Cancel.

English summary below.

Co robi

Blokada (odmowa bez pytania, toast z powodem):

  • rm -rf /, rm -rf ~, rm -rf $HOME
  • mkfs, dd … of=/dev/…
  • chmod 777 /…
  • curl … | sh, wget … | bash
  • fork bomb

Wstrzymanie (panel z podglądem i Proceed / Cancel):

KomendaCo pokazuje panel
rm -r, rm -f, rm -rfpliki do usunięcia, ich liczbę i łączny rozmiar; globy i ~ są rozwijane
git reset --hardpliki z niezacommitowanymi zmianami i git diff --shortstat
git checkout -- ., git restore .pliki ze zmianami unstaged
git cleannieśledzone ścieżki, z git clean -n z tymi samymi flagami
git push --force (też -f, --force-with-lease, +ref)commity na zdalnej gałęzi, których nie masz lokalnie i które push wyrzuci
manage.py migrate, db:migrate, alembic upgrade, prisma migrateoczekujące migracje, z komendy statusu danego narzędzia

Każda inna komenda przechodzi bez zmian. cd dir &&, pushd/popd i git -C dir przesuwają folder, w którym liczony jest podgląd. cd w ( … ) działa tylko w nawiasie, jak w powłoce.

Przycisk Cancel ma domyślny fokus, więc przypadkowy Enter niczego nie uruchomi. Claude dostaje odmowę z powodem i tym, co komenda by zrobiła. Bez odpowiedzi przez 10 minut komenda jest odrzucana. W wąskim terminalu (panel się nie mieści) podgląd jest rysowany w pasku nad polem wpisywania.

Instalacja

Wymagania: Claude Code w wersji obsługującej mody, testowane na 2.1.295; sprawdzisz przez claude --version.

Najprościej, wpisane w Claude Code (od 2.1.275):

/plugin install bash-guard --marketplace hugo88/claude-code-bash-guard

potwierdź dodanie marketplace (y) i wybierz zakres (Enter = użytkownik). Mod działa od razu. Aktualizacja: claude plugin update bash-guard.

Instalacja z klonu repozytorium

Linux, macOS albo WSL:

git clone https://github.com/hugo88/claude-code-bash-guard
cd claude-code-bash-guard
./install.sh            # testy, potem kopia do ~/.claude/mods/bash-guard

Aktualizacja: git pull && ./install.sh. Przy pierwszej instalacji dopisz folder do CLAUDE_CODE_PLUGIN_DIRS w ~/.claude/settings.json (kilka folderów oddzielasz :) i uruchom Claude Code ponownie:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/bash-guard"
  }
}

Sprawdzenie: w testowym folderze poproś Claude'a o rm -rf build. Powinien pojawić się panel z listą plików i przyciskami Proceed / Cancel.

Odinstalowanie

Zainstalowany przez /plugin install: claude plugin uninstall bash-guard. Z klonu: usuń folder z CLAUDE_CODE_PLUGIN_DIRS i rm -rf ~/.claude/mods/bash-guard.

Ograniczenia

To pomoc, nie zabezpieczenie. Reguły czytają tekst komendy, więc skrypt, alias czy eval je obejdą. Twarda ochrona to permissions.deny w ustawieniach Claude Code.

  • Pilnowane są tylko komendy Claude'a (narzędzie Bash), nie te, które wpisujesz sam przez !.
  • Podgląd migracji uruchamia komendę statusu projektu (np. python3 manage.py showmigrations, bin/rails db:migrate:status), zanim klikniesz Proceed.
  • Podgląd git push --force opiera się na ostatnim git fetch.
  • Naraz wstrzymywana jest jedna komenda; kolejne (np. subagenta) czekają na odpowiedź.

Rozwój

claude plugin validate .
claude plugin test .
tsc -p .   # typy generuje Claude Code przy pierwszym załadowaniu moda

Pochodzenie

Wstrzymywanie z podglądem skutków jest przeniesione z moda Blast Radius (Copyright 2026 Anthropic PBC, Apache-2.0), przepisane na TypeScript i połączone z blokadą bash-guard.


English

A Claude Code mod that guards the Bash commands Claude runs. Catastrophic ones (rm -rf /, mkfs, dd of=/dev/…, curl | sh, fork bomb) are denied outright. Risky ones (rm -r, git reset --hard, git clean, force push, migrations) are held while a pane shows what they would change, with Proceed and Cancel (Cancel has focus). Based on Anthropic's Blast Radius example mod.

Install: /plugin install bash-guard --marketplace hugo88/claude-code-bash-guard in Claude Code, or clone the repo and run ./install.sh. Messages in the pane are in English, docs in Polish.

Licencja

Apache-2.0

Source 2 files
hooks/register.tsx 319 lines
1// bash-guard: dwa poziomy ochrony komend Bash.
2// - BLOCK: komendy bez sensownego "tak" (rm -rf /, mkfs, curl | sh...): odmowa od razu.
3// - HOLD: komendy, które coś nieodwracalnie zmieniają (rm -r, git reset --hard, git clean,
4//   git push --force, migracje): panel z tym, co by się zmieniło, i przyciski Proceed / Cancel.
5//
6// Hook ma 10 s własnego czasu, ale czas wewnątrz wywołań `$` się nie liczy, więc pętla
7// czekania śpi na `$.process.run(['sleep', ...])`, aż przycisk ustawi decyzję.
8
9import type { ElementTable, EngineInterface, Register } from 'claude-code'
10
11import { LIST_MAX, classify } from './radius'
12import type { Report, Risk } from './radius'
13
14const BLOCK: { pattern: RegExp; reason: string }[] = [
15  { pattern: /\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)+(\/|~|\$HOME)(\s|\/?$|\/\*)/, reason: 'rm -rf on / or home' },
16  { pattern: /\bmkfs(\.\w+)?\b|\bdd\b.*\bof=\/dev\//, reason: 'writes to a block device' },
17  { pattern: /\bchmod\s+(-R\s+)?777\s+\//, reason: 'chmod 777 on a system path' },
18  { pattern: /(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z)?sh\b/, reason: 'pipes a download into a shell' },
19  { pattern: /:\(\)\s*\{\s*:\|:&\s*\};:/, reason: 'fork bomb' },
20]
21
22const PANE_ID = 'bash-guard'
23const POLL_SECONDS = '0.25'
24const HOLD_LIMIT_MS = 10 * 60 * 1000
25
26type Decision = 'proceed' | 'cancel' | 'timeout' | 'interrupted' | 'error'
27type Held = { command: string; risk: Risk; report: Report | null; decision: Decision | null; where: 'pane' | 'band' }
28
29// Wstrzymane wywołanie albo null. Jedno naraz: wywołania Bash w turze idą po kolei.
30let held: Held | null = null
31
32const WHY: Record<Decision, string> = {
33  proceed: '',
34  cancel: 'the user pressed Cancel',
35  timeout: 'no answer within 10 minutes',
36  interrupted: 'the turn was interrupted',
37  error: 'bash-guard hit an error while holding it',
38}
39
40export const register: Register = (on) => {
41  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
42    const command = String(e.command ?? '')
43    const hit = BLOCK.find((rule) => rule.pattern.test(command))
44    if (hit !== undefined) {
45      $.ui.toast(`🛡 bash-guard blocked: ${hit.reason}`, { timeoutMs: 8000 })
46      return { deny: `bash-guard: blocked (${hit.reason}). Ask the user to run it themselves if it is really intended.` }
47    }
48    const risk = classify(command)
49    if (risk === null) return next(e)
50
51    // Inne wstrzymane wywołanie (np. subagenta): czekamy na jego odpowiedź.
52    // `held` jest zajmowany bez await między sprawdzeniem a zajęciem.
53    while (held !== null) {
54      if (next.signal.aborted) return { deny: 'bash-guard held this command and did not run it: the turn was interrupted. Do not retry it unless the user asks you to.' }
55      await $.process.run(['sleep', POLL_SECONDS], { timeoutMs: 5000 })
56    }
57    const mine: Held = { command, risk, report: null, decision: null, where: 'pane' }
58    held = mine
59
60    let placed = false
61    let summary = risk.label
62    try {
63      const sessionCwd = await $.session.cwd()
64      const cwd = risk.dir ? await resolveDir($, sessionCwd, risk.dir) : sessionCwd
65      mine.report =
66        cwd === null
67          ? { summary: `${risk.label} in ${risk.dir}`, lines: [], note: `Couldn't find the folder ${risk.dir}, so I couldn't measure what this would change.` }
68          : await measure($, risk, cwd)
69      summary = mine.report.summary
70
71      const rows = Math.min(24, 9 + mine.report.lines.length + (mine.report.more ? 1 : 0))
72      placed = (await $.ui.open({ id: PANE_ID, title: 'bash-guard', focus: true, rows })).isPlaced
73      if (!placed) mine.where = 'band' // wąski terminal: raport w pasku nad promptem
74      $.ui.invalidate('ui.render')
75
76      const startedAt = await $.clock.now()
77      while (mine.decision === null) {
78        if (next.signal.aborted) mine.decision = 'interrupted'
79        else if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) mine.decision = 'timeout'
80        else await $.process.run(['sleep', POLL_SECONDS], { timeoutMs: 5000 })
81      }
82    } catch {
83      mine.decision = 'error' // coś nieoczekiwanego: komenda nie rusza
84    } finally {
85      // Zamykamy swój panel przed zwolnieniem, żeby nie zamknąć panelu następnego wywołania.
86      if (placed) await $.ui.close({ id: PANE_ID }).catch(() => undefined)
87      if (held === mine) held = null
88      $.ui.invalidate('ui.render')
89    }
90
91    if (mine.decision === 'proceed') {
92      $.ui.toast('bash-guard: running it')
93      return next(e)
94    }
95    return {
96      deny: `bash-guard held this command and did not run it: ${WHY[mine.decision ?? 'error']}. It would have: ${summary}. Do not retry it unless the user asks you to.`,
97    }
98  }).catch(($, e, next) =>
99    // hook padł przed decyzją: komenda nie rusza
100    next.called ? next(e) : { deny: 'bash-guard could not check this command, so it did not run. Ask the user to run it themselves.' },
101  )
102
103  on('ui.render', { component: 'Pane' }, ($, e, next) => {
104    if (e.requestId !== PANE_ID || held === null || held.report === null) return next(e)
105    return draw($.ui.resolve(e), held)
106  })
107
108  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
109    if (held === null || held.report === null || held.where !== 'band') return next(e)
110    return draw($.ui.resolve(e), held)
111  })
112}
113
114function draw({ Box, Text, Button }: ElementTable, state: Held) {
115  const report = state.report as Report
116  // Przyciski odpowiadają wywołaniu, dla którego panel narysowano, nie temu, które czeka teraz.
117  const decide = (choice: Decision) => () => {
118    if (state.decision === null) state.decision = choice
119  }
120  return (
121    <Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
122      <Text bold color="yellow">{`⚠ bash-guard · ${state.risk.label}`}</Text>
123      <Text wrap="truncate-end">
124        <Text dimColor>{'Command  '}</Text>
125        <Text bold>{state.command}</Text>
126      </Text>
127      <Text>
128        <Text dimColor>{'Would    '}</Text>
129        <Text color="red" bold>{report.summary}</Text>
130      </Text>
131      <Box flexDirection="column" marginTop={1}>
132        {report.lines.map((line, i) => (
133          <Text key={`l${i}`} wrap="truncate-end">{`  ${line}`}</Text>
134        ))}
135        {report.more ? <Text dimColor>{`  + ${report.more} more`}</Text> : null}
136      </Box>
137      {report.note ? (
138        <Text dimColor italic wrap="wrap">
139          {report.note}
140        </Text>
141      ) : null}
142      <Box marginTop={1} gap={2}>
143        <Button label="Proceed" hotkey="1" plain onPress={decide('proceed')} />
144        {/* Cancel ma fokus: Enter odmawia, nie uruchamia */}
145        <Button label="Cancel" hotkey="2" plain autoFocus onPress={decide('cancel')} />
146        <Text dimColor>Claude is waiting on your answer</Text>
147      </Box>
148    </Box>
149  )
150}
151
152// ---- Pomiar (przeniesiony z Blast Radius, Apache-2.0) ----------------------
153// Ścieżki trafiają do bash jako argumenty, nigdy jako kod. Komendy sprawdzające są tylko do odczytu.
154
155const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && pwd -P`
156
157/** Folder po `cd` jako ścieżka bezwzględna albo null, gdy nie istnieje. */
158async function resolveDir($: EngineInterface, sessionCwd: string, dir: string): Promise<string | null> {
159  if (dir === '-') return null // `cd -` zależy od historii powłoki
160  const run = await $.process.run(['bash', '-c', CD_SCRIPT, 'bash-guard', dir], { cwd: sessionCwd, timeoutMs: 5000 })
161  const out = run.stdout.trim()
162  return run.exitCode === 0 && out !== '' ? out : null
163}
164
165/** Raport do panelu. Nie rzuca: błąd odczytu jest pokazany, nie ukryty. */
166async function measure($: EngineInterface, risk: Risk, cwd: string): Promise<Report> {
167  try {
168    if (risk.kind === 'rm') return await measureRm($, risk, cwd)
169    if (risk.kind === 'migrate') return await measureMigrations($, risk, cwd)
170    if (risk.kind === 'git-push-force') return await measurePush($, risk, cwd)
171    if (risk.kind === 'git-clean') return await measureClean($, risk, cwd)
172    return await measureDiscard($, risk, cwd)
173  } catch (error) {
174    return { summary: `${risk.label} (could not measure it)`, lines: [], note: `Could not measure: ${String((error as Error)?.message ?? error).slice(0, 200)}` }
175  }
176}
177
178// compgen -G rozwija glob bez podstawiania komend.
179const RM_SCRIPT = `
180shopt -s nullglob dotglob
181paths=()
182for p in "$@"; do
183  case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
184  if [[ "$p" == *[*?[]* ]]; then
185    while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
186  elif [[ -e "$p" || -L "$p" ]]; then
187    paths+=("$p")
188  fi
189done
190if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
191# Względna ścieżka dostaje ./ z przodu, żeby find nie czytał nazwy typu -delete jako akcji.
192for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
193files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
194kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
195echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
196find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
197`
198
199async function measureRm($: EngineInterface, risk: Risk, cwd: string): Promise<Report> {
200  const targets = risk.targets ?? []
201  if (targets.length === 0) return { summary: 'rm with no paths', lines: [], note: 'No paths to expand.' }
202  const run = await $.process.run(['bash', '-c', RM_SCRIPT, 'bash-guard', ...targets], { cwd, timeoutMs: 15000 })
203  const [head, ...rest] = run.stdout.split('\n').filter((l) => l !== '')
204  const [files = 0, bytes = 0, found = 0] = (head ?? '0 0 0').split(' ').map(Number)
205  if (!found) return { summary: `delete nothing: no file matches ${targets.join(' ')}`, lines: [], note: "The paths don't exist, so rm has nothing to remove." }
206  if (!files) return { summary: `delete ${found} ${found === 1 ? 'path' : 'paths'} with no files in ${found === 1 ? 'it' : 'them'}`, lines: [], note: `Paths: ${targets.join(' ')}` }
207  return {
208    summary: `delete ${files} ${files === 1 ? 'file' : 'files'} (about ${size(bytes)})`,
209    lines: rest.map((l) => l.replace(/^\.\//, '')),
210    more: Math.max(0, files - rest.length),
211    note: `Paths: ${targets.join(' ')}`,
212  }
213}
214
215async function measureClean($: EngineInterface, risk: Risk, cwd: string): Promise<Report> {
216  const args = risk.args ?? []
217  const flags: string[] = []
218  const paths: string[] = []
219  for (let i = 0; i < args.length; i += 1) {
220    const a = args[i] ?? ''
221    if (a === '--') {
222      paths.push(...args.slice(i + 1))
223      break
224    }
225    if (a === '-e' || a === '--exclude') {
226      flags.push(a, args[i + 1] ?? '')
227      i += 1
228    } else if (a.startsWith('--exclude=') || /^-e./.test(a)) flags.push(a)
229    else if (/^-[a-zA-Z]+$/.test(a)) {
230      const kept = a.replace(/[finq]/g, '') // -n dodajemy sami; -f, -i i -q zmieniłyby próbę
231      if (kept !== '-') flags.push(kept)
232    } else if (!a.startsWith('-')) paths.push(a)
233  }
234  const run = await $.process.run(['git', 'clean', '-n', ...flags, '--', ...paths], { cwd, timeoutMs: 15000 })
235  if (run.exitCode !== 0) return { summary: 'git clean (could not dry-run it)', lines: [], note: run.stderr.trim().slice(0, 200) }
236  const gone = run.stdout.split('\n').filter((l) => l.startsWith('Would remove ')).map((l) => l.slice(13))
237  return {
238    summary: gone.length === 0 ? 'remove nothing: no untracked files match' : `remove ${gone.length} untracked ${gone.length === 1 ? 'path' : 'paths'}`,
239    lines: gone.slice(0, LIST_MAX),
240    more: Math.max(0, gone.length - LIST_MAX),
241    note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
242  }
243}
244
245async function measureDiscard($: EngineInterface, risk: Risk, cwd: string): Promise<Report> {
246  const status = await $.process.run(['git', 'status', '--porcelain'], { cwd, timeoutMs: 15000 })
247  if (status.exitCode !== 0) return { summary: `${risk.label} (not a git repo here?)`, lines: [], note: status.stderr.trim().slice(0, 200) }
248  const rows = status.stdout.split('\n').filter((l) => l.length > 3 && !l.startsWith('??'))
249  // reset --hard wyrzuca zmiany staged i unstaged; checkout -- . tylko unstaged.
250  const lost = risk.kind === 'git-reset' ? rows : rows.filter((l) => l[1] !== ' ')
251  const stat = await $.process.run(['git', 'diff', '--shortstat', risk.kind === 'git-reset' ? 'HEAD' : '--'], { cwd, timeoutMs: 15000 })
252  return {
253    summary: lost.length === 0 ? 'discard nothing: no uncommitted changes' : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? 'file' : 'files'}`,
254    lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
255    more: Math.max(0, lost.length - LIST_MAX),
256    note: stat.stdout.trim() !== '' ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : 'From git status --porcelain.',
257  }
258}
259
260async function measurePush($: EngineInterface, risk: Risk, cwd: string): Promise<Report> {
261  const positional = (risk.args ?? []).filter((a) => !a.startsWith('-'))
262  const remote = positional[0] ?? 'origin'
263  // Refspec to src:dst; bez dwukropka wypychana jest lokalna gałąź o tej samej nazwie.
264  const spec = (positional[1] ?? '').replace(/^\+/, '')
265  let [source = '', branch = ''] = spec.includes(':') ? spec.split(':') : [spec, spec]
266  branch = branch.replace(/^refs\/heads\//, '')
267  if (!branch || branch === 'HEAD') {
268    const head = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeoutMs: 10000 })
269    branch = head.stdout.trim()
270    source = 'HEAD'
271  }
272  source = source || 'HEAD'
273  const ref = `${remote}/${branch}`
274  const known = await $.process.run(['git', 'rev-parse', '--verify', '--quiet', ref], { cwd, timeoutMs: 10000 })
275  if (known.exitCode !== 0) return { summary: `force-push to ${ref}`, lines: [], note: `No local copy of ${ref}, so I can't tell which commits the push would drop. Run git fetch first.` }
276  const log = await $.process.run(['git', 'log', '--oneline', '--no-decorate', `${source}..${ref}`], { cwd, timeoutMs: 15000 })
277  const dropped = log.stdout.split('\n').filter((l) => l !== '')
278  return {
279    summary: dropped.length === 0 ? `force-push to ${ref}: drops no commits` : `force-push to ${ref}: drops ${dropped.length} ${dropped.length === 1 ? 'commit' : 'commits'}`,
280    lines: dropped.slice(0, LIST_MAX),
281    more: Math.max(0, dropped.length - LIST_MAX),
282    note: `Commits on ${ref} that ${source} doesn't have, as of the last fetch.`,
283  }
284}
285
286// Uwaga: te komendy uruchamiają kod projektu (manage.py, bin/rails), zanim padnie decyzja.
287const MIGRATION_LISTERS: Record<string, { argv: string[]; pending: (l: string) => boolean; strip: (l: string) => string }> = {
288  django: { argv: ['python3', 'manage.py', 'showmigrations', '--plan'], pending: (l) => l.startsWith('[ ]'), strip: (l) => l.slice(4) },
289  alembic: { argv: ['alembic', 'history', '-r', 'current:head'], pending: (l) => l.includes('->'), strip: (l) => l },
290  rails: { argv: ['bin/rails', 'db:migrate:status'], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
291  prisma: { argv: ['npx', '--no-install', 'prisma', 'migrate', 'status'], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
292}
293
294async function measureMigrations($: EngineInterface, risk: Risk, cwd: string): Promise<Report> {
295  const lister = MIGRATION_LISTERS[risk.tool ?? '']
296  if (lister === undefined) return { summary: 'run migrations', lines: [], note: "I can't list the pending migrations for this tool, so the list is not shown." }
297  const run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 }).catch((error: unknown) => ({ exitCode: -1, stdout: '', stderr: String(error) }))
298  if (run.exitCode !== 0) return { summary: `run ${risk.label}`, lines: [], note: `Couldn't list pending migrations (${lister.argv.join(' ')} failed).` }
299  const pending = run.stdout.split('\n').filter(lister.pending).map(lister.strip)
300  return {
301    summary: pending.length === 0 ? `run ${risk.label}: nothing pending` : `apply ${pending.length} pending ${pending.length === 1 ? 'migration' : 'migrations'}`,
302    lines: pending.slice(0, LIST_MAX),
303    more: Math.max(0, pending.length - LIST_MAX),
304    note: `From ${lister.argv.join(' ')}.`,
305  }
306}
307
308function size(bytes: number): string {
309  if (!Number.isFinite(bytes) || bytes < 1024) return `${bytes || 0} B`
310  const units = ['KB', 'MB', 'GB', 'TB']
311  let n = bytes
312  let i = -1
313  while (n >= 1024 && i < units.length - 1) {
314    n /= 1024
315    i += 1
316  }
317  return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`
318}
319
hooks/radius.ts 127 lines
1// Rozpoznawanie ryzykownych komend i pomiar ich skutków ("blast radius").
2// Przeniesione z moda Blast Radius, Copyright 2026 Anthropic PBC, Apache-2.0:
3// https://github.com/anthropics/claude-code-playground/tree/main/claude-code/mods/blast-radius
4//
5//
6// Pomiar (funkcje z `$`) jest w register.tsx: silnik śledzi `$` tylko w obrębie jednego pliku.
7
8export const LIST_MAX = 10
9
10export type Risk = {
11  kind: 'rm' | 'git-reset' | 'git-clean' | 'git-push-force' | 'git-checkout' | 'migrate'
12  label: string
13  dir: string | null // dokąd przeniósł wcześniejszy cd; null = folder sesji
14  targets?: string[]
15  args?: string[]
16  tool?: 'alembic' | 'rails' | 'prisma' | 'django' | 'unknown'
17}
18
19export type Report = { summary: string; lines: string[]; more?: number; note?: string }
20
21type Cd = { kind: 'cd'; cd: string | null }
22
23// ---- Co jest ryzykowne -----------------------------------------------------
24
25const SUDO_VALUE_OPTIONS = new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U'])
26// Komendy tylko do odczytu: samo słowo "migrate" w nich to nie migracja.
27const READ_ONLY = new Set(['ls', 'cat', 'echo', 'printf', 'grep', 'rg', 'find', 'less', 'head', 'tail', 'cd', 'git'])
28const PREFIXES = new Set(['command', 'exec', 'env', 'nohup', 'time', 'then', 'do', 'else', '!'])
29
30function joinDir(dir: string | null, arg: string | undefined): string {
31  if (arg === undefined || arg === '~' || arg.startsWith('/') || arg.startsWith('~/')) return arg ?? '~'
32  return dir ? `${dir}/${arg}` : arg
33}
34
35export function tokenize(text: string): string[] {
36  const words: string[] = []
37  const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g
38  let m: RegExpExecArray | null
39  while ((m = re.exec(text)) !== null) words.push(m[1] ?? m[2] ?? m[3] ?? '')
40  return words
41}
42
43/** Pierwszy ryzykowny segment komendy albo null. */
44export function classify(command: string): Risk | null {
45  let dir: string | null = null
46  const scopes: Array<string | null> = [] // dir do przywrócenia, gdy zamyka się ( subshell )
47  const pushed: Array<string | null> = [] // stos pushd
48  for (const raw of command.split(/&&|\|\||;|\||\n/)) {
49    const opens = raw.match(/^\s*\(+/)?.[0].trim().length ?? 0
50    // Przekierowania i & na końcu nie zasłaniają nawiasu: `(cd sub && make) > log`.
51    const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, '')
52    const closes = tail.match(/\)+\s*$/)?.[0].trim().length ?? 0
53    for (let k = 0; k < opens; k += 1) scopes.push(dir)
54    const r = classifySegment(raw, dir, pushed)
55    if (r !== null && r.kind !== 'cd') return r
56    if (r !== null) dir = r.cd
57    for (let k = 0; k < closes && scopes.length > 0; k += 1) dir = scopes.pop() ?? null // cd w ( ... ) nie wychodzi poza nawias
58  }
59  return null
60}
61
62function classifySegment(segment: string, dir: string | null, pushed: Array<string | null>): Risk | Cd | null {
63  const words = tokenize(segment.trim().replace(/^[({]+\s*/, '').replace(/\s*[)}]+$/, ''))
64  while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] ?? '')) words.shift()
65  if (words[0] === 'sudo') {
66    words.shift()
67    while (words.length > 0 && (words[0] ?? '').startsWith('-')) {
68      const option = words.shift() ?? ''
69      if (SUDO_VALUE_OPTIONS.has(option)) words.shift()
70    }
71  }
72  while (words.length > 0 && (PREFIXES.has(words[0] ?? '') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] ?? ''))) words.shift()
73  if (words[0] === 'nice') {
74    words.shift()
75    if ((words[0] as string | undefined) === '-n') words.splice(0, 2) // TS zawęża words[0] do 'nice' mimo shift()
76    else if (/^-\d+$/.test(words[0] ?? '')) words.shift()
77  }
78  const [first, ...args] = words
79  if (first === undefined) return null
80  const cmd = first.replace(/^\\/, '') // \rm omija aliasy, ale to dalej rm
81  if (cmd === 'cd') return { kind: 'cd', cd: args[0] === '-' ? '-' : joinDir(dir, args[0]) }
82  if (cmd === 'pushd') {
83    pushed.push(dir)
84    return { kind: 'cd', cd: joinDir(dir, args[0]) }
85  }
86  if (cmd === 'popd') return { kind: 'cd', cd: pushed.length > 0 ? (pushed.pop() ?? null) : '-' }
87  if (cmd === 'rm' || cmd.endsWith('/rm')) {
88    const flags = args.filter((a) => a.startsWith('-'))
89    const recursive = flags.some((f) => f === '--recursive' || (/^-[^-]/.test(f) && /[rR]/.test(f)))
90    const force = flags.some((f) => f === '--force' || (/^-[^-]/.test(f) && f.includes('f')))
91    if (recursive || force) {
92      const targets = args.filter((a) => !a.startsWith('-') || a === '-')
93      return { kind: 'rm', label: `rm ${flags.join(' ')}`.trim(), targets, dir }
94    }
95  }
96  if (cmd === 'git') {
97    // Opcje gita są przed podkomendą; -C zmienia folder.
98    let gitDir: string | null = dir
99    let i = 0
100    while (i < args.length && (args[i] ?? '').startsWith('-')) {
101      if (args[i] === '-C' && i + 1 < args.length) {
102        gitDir = joinDir(gitDir, args[i + 1])
103        i += 2
104      } else if (args[i] === '-c' && i + 1 < args.length) i += 2
105      else i += 1
106    }
107    const sub = args[i]
108    const rest = args.slice(i + 1)
109    if (sub === 'reset' && rest.includes('--hard')) return { kind: 'git-reset', label: 'git reset --hard', args: rest, dir: gitDir }
110    if (sub === 'clean') return { kind: 'git-clean', label: 'git clean', args: rest, dir: gitDir }
111    if (sub === 'push' && rest.some((a) => a === '--force' || a === '-f' || a.startsWith('--force-with-lease') || /^\+/.test(a))) {
112      return { kind: 'git-push-force', label: 'git push --force', args: rest, dir: gitDir }
113    }
114    const stagedOnly = sub === 'restore' && rest.includes('--staged') && !rest.includes('--worktree') && !rest.includes('-W')
115    if ((sub === 'checkout' || sub === 'restore') && rest.includes('.') && !stagedOnly) {
116      return { kind: 'git-checkout', label: `git ${sub} -- .`, args: rest, dir: gitDir }
117    }
118  }
119  const joined = words.join(' ')
120  if (/\balembic\s+upgrade\b/.test(joined)) return { kind: 'migrate', tool: 'alembic', label: 'alembic upgrade', dir }
121  if (/\bdb:migrate(?!:status\b)/.test(joined)) return { kind: 'migrate', tool: 'rails', label: 'db:migrate', dir }
122  if (/\bprisma\s+migrate\b/.test(joined)) return { kind: 'migrate', tool: 'prisma', label: 'prisma migrate', dir }
123  if (/\bmanage\.py\s+migrate\b/.test(joined)) return { kind: 'migrate', tool: 'django', label: 'manage.py migrate', dir }
124  if (!READ_ONLY.has(cmd) && args.includes('migrate')) return { kind: 'migrate', tool: 'unknown', label: 'migrate', dir }
125  return null
126}
127