Hält gefährliche Bash-Befehle an (rm -rf, git reset --hard, git clean, force-push …), zeigt was sie anrichten würden, und fragt vor dem Ausführen

Ein Wetterbericht für das Kontextfenster, als Zeile über dem Claude-Code-Prompt. Er aktualisiert sich nach jeder Runde.
☂ Regenschauer (showers) 67% · 134,4k / 200k ▂▆ ▲ +98,3k letzte Runde (last turn)
| Füllstand | Anzeige |
|---|---|
| unter 25 % | ☀ Heiter (clear), gelb |
| 25–49 % | ☁ Bewölkt (cloudy), cyan |
| 50–74 % | ☂ Regenschauer (showers), blau |
| 75–89 % | ☇ Gewitter (storm), magenta |
| ab 90 % | ↯ Bald komprimieren (compact soon), rot |
Danach folgen der Füllstand in Prozent, die belegten Tokens von der Fenstergröße, ein Verlauf der letzten 12 Runden (jeder Balken gemessen am ganzen Fenster) und der Zuwachs der letzten Runde. Nach einem Komprimieren zeigt er ▼ und den Rückgang.
Benötigt eine Claude-Code-Version mit Mod-Unterstützung (Function Hooks).
/plugin marketplace add reifen01/claude-mods
/plugin install token-weather@reifen01-mods
session.measure: Die Engine misst nach jeder Runde; ändert sich der Kontext, speichert der Mod den neuen Stand. Kostet keine Tokens.$.state: Die letzten 12 Stände, das Fenster und der Zuwachs. Ein Schreiben zeichnet die Zeile neu.ui.render auf AbovePrompt: zeichnet die Zeile unter dem, was andere Plugins dort zeichnen.claude plugin test plugins/token-weather
claude plugin test plugins/blast-radius
claude plugin test plugins/replay
Hält gefährliche Bash-Befehle von Claude an, bevor sie laufen, misst was sie anrichten würden, und fragt dich.
⚠ Rekursives Löschen angehalten
$ rm -rf build
Würde 1.204 Dateien und Ordner (48,3 MB) unwiderruflich löschen
build: 1.204 Einträge, 48,3 MB
[ Abbrechen ] [ Trotzdem ausführen ]
| Erkannt | Gemessen mit |
|---|---|
rm -r / rm -rf | du, find (Anzahl und Größe) |
git reset --hard [ref] | git status, git log ref..HEAD |
git clean -f… | git clean -n (Probelauf) |
git checkout -- …, git restore … | git diff |
git push --force / -f | git log HEAD..@{u} (Stand letzter fetch) |
git branch -D | Commits, die nur auf dem Branch liegen |
find … -delete | derselbe find mit -print |
n, auch ✕ oder Esc): Claude bekommt eine Absage mit dem, was der Befehl angerichtet hätte.j): der Befehl läuft normal.claude -p) läuft der Befehl wie ohne Mod.*.log oder $VAR werden genannt, nicht ausgewertet.Installieren:
/plugin install blast-radius@reifen01-mods
Nach jeder Runde merkt sich der Mod, welche Dateien Claude geändert hat. /replay öffnet ein Pane und blättert Schritt für Schritt durch die Diffs. Die Beschriftungen sind deutsch, mit dem englischen Begriff in Klammern.
Schritt (step) 2/5 …/src/app.ts (geändert (changed)) +12 −3
@@ -40,7 +40,16 @@
…
[ ◀ Zurück (back) ] [ Weiter (next) ▶ ] [ Schließen (close) ]
Edit, Write und NotebookEdit, auch die von Subagenten derselben Runde.w weiter, z zurück, q oder Esc schließen.Installieren:
/plugin install replay@reifen01-mods
MIT
hooks/register.tsx 129 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput } from 'claude-code'
3
4import type { Held } from '../types'
5import { classify } from './classify'
6import { measure } from './measure'
7import type { Run } from './measure'
8
9const PANE = 'blast-radius'
10const held = atom({ plugin: 'blast-radius', key: 'held' } as const, null)
11
12// a $ call's wait is free of the hook's 10 s budget (a clock.sleep is not): this is how the hook waits for a press
13const pause = ($: EngineInterface) => $.process.run(['sleep', '0.25']).catch(() => undefined)
14
15// ten minutes of pauses with no answer is a Cancel: a held command never waits forever
16const MAX_PAUSES = 2400
17
18type Answer = 'proceed' | 'cancel'
19
20// The answer lives in the module, not in $.state: a hook that is still running reads $.state as it was when its
21// dispatch began, so it would never see a press. `waiting` is the id of the held call, `answer` what was pressed.
22let waiting: string | null = null
23let answer: Answer | null = null
24
25// the press writes the module's answer for the hook, and $.state for the drawing (the card shows it was answered)
26const decide = ($: EngineInterface, decision: Answer) => {
27 if (waiting !== null && answer === null) answer = decision
28 return update($, held, cur => (cur && cur.decision === null ? { ...cur, decision } : cur))
29}
30
31export const register: Register = on => {
32 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
33 const command = String(e.command ?? '')
34 const risk = classify(command)
35 if (risk === null) return next(e) // everything else runs as normal
36
37 // nobody to press a button (claude -p): say so and let it run, as without the mod
38 if ((await $.session.surfaces()).length === 0) {
39 $.ui.log(`blast-radius: ${risk.label} ohne Rückfrage ausgeführt (keine Oberfläche)`, { to: 'debug' })
40 return next(e)
41 }
42
43 // one held command at a time: a second waits until the first is answered
44 for (let i = 0; waiting !== null && i < MAX_PAUSES && !next.signal.aborted; i++) await pause($)
45 if (waiting !== null) return { deny: 'Blast Radius: ein anderer Befehl wartet noch auf eine Antwort.' }
46 waiting = e.tool_use_id
47 answer = null
48
49 const cwd = await $.session.cwd()
50 // a failed or slow measurement is a line in the report, never a reason to let the command through
51 const run: Run = argv =>
52 $.process
53 .run(argv, { cwd, timeoutMs: 5000 })
54 .then(r => (r.exitCode === 0 ? r.stdout : null))
55 .catch(() => null)
56 const report = await measure(run, risk, cwd)
57 const opened = await $.ui.open({ id: PANE, title: 'Blast Radius', focus: true, closeOnEscape: true, holdToasts: true })
58 const where: Held['where'] = opened.isPlaced ? 'pane' : 'band' // too narrow for a pane: draw above the prompt
59 await update($, held, () => ({ id: e.tool_use_id, command, risk, report, where, decision: null }))
60
61 for (let i = 0; answer === null && i < MAX_PAUSES && !next.signal.aborted; i++) await pause($)
62 const decision = answer // null when time ran out or the turn was interrupted: a Cancel
63 waiting = null
64 answer = null
65 await update($, held, () => null)
66 await $.ui.close({ id: PANE }).catch(() => undefined)
67
68 if (decision === 'proceed') return next(e) // let it run
69 return {
70 deny:
71 `Blast Radius hat diesen Befehl angehalten, die Person hat „Abbrechen“ gewählt. ` +
72 `Er ${report.summary}. Nicht auf anderem Weg wiederholen, ohne vorher nachzufragen.`,
73 }
74 }).catch(($, e, next) => {
75 // a guard that broke before asking holds the command rather than letting it through unseen
76 if (waiting === e.tool_use_id) {
77 waiting = null
78 answer = null
79 }
80 return next.called ? next(e) : { deny: 'Blast Radius konnte diesen Befehl nicht prüfen und hat ihn angehalten.' }
81 })
82
83 // the person closing the pane (✕ or Esc) is a Cancel
84 on('ui.close', async ($, e, next) => {
85 if (e.id === PANE && e.origin.kind === 'person') await decide($, 'cancel')
86 return next(e)
87 }).catch(($, e, next) => next(e))
88
89 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
90 const cur = await read($, held)
91 const { Text } = $.ui.resolve(e)
92 return cur ? card($, e, cur) : <Text dimColor>Kein Befehl angehalten.</Text>
93 })
94
95 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
96 const cur = await read($, held)
97 if (!cur || cur.where !== 'band' || cur.decision !== null) return next(e)
98 const mine = card($, e, cur)
99 const below = await next(e)
100 const { Box } = $.ui.resolve(e)
101 return (
102 <Box flexDirection="column">
103 {below}
104 {mine}
105 </Box>
106 )
107 })
108}
109
110function card($: EngineInterface, e: RenderInput, it: Held) {
111 const { Box, Text, Button } = $.ui.resolve(e)
112 const color = it.report.severity === 'critical' ? 'red' : 'yellow'
113 const summary = it.report.summary.charAt(0).toUpperCase() + it.report.summary.slice(1)
114 return (
115 <Box key="blast-radius" flexDirection="column">
116 <Text color={color} bold>{`⚠ ${it.risk.label} angehalten`}</Text>
117 <Text wrap="truncate-end">{`$ ${it.command}`}</Text>
118 <Text bold>{summary}</Text>
119 {it.report.lines.map(line => (
120 <Text dimColor wrap="truncate-end">{` ${line}`}</Text>
121 ))}
122 <Box flexDirection="row" gap={2} marginTop={1}>
123 <Button key="cancel" label="Abbrechen" hotkey="n" variant="primary" autoFocus onPress={() => decide($, 'cancel')} />
124 <Button key="proceed" label="Trotzdem ausführen" hotkey="j" onPress={() => decide($, 'proceed')} />
125 </Box>
126 </Box>
127 )
128}
129hooks/classify.ts 80 lines1import type { Risk } from '../types'
2
3// a shell word list good enough to read a command, never to run one: quotes are stripped, nothing expands
4export function wordsOf(segment: string): string[] {
5 return (segment.match(/'[^']*'|"[^"]*"|\S+/g) ?? []).map(w => w.replace(/^(['"])(.*)\1$/, '$2'))
6}
7
8const isFlag = (w: string) => w.startsWith('-') && w !== '-'
9
10// `sudo`, `env X=1`, `X=1` and `command` in front change nothing about what the command destroys
11function stripPrefix(words: string[]): string[] {
12 let i = 0
13 while (i < words.length) {
14 const w = words[i] ?? ''
15 if (w === 'sudo' || w === 'env' || w === 'command' || w === 'nohup' || w === 'time') i++
16 else if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) i++
17 else break
18 }
19 return words.slice(i)
20}
21
22function classifySegment(segment: string): Risk | null {
23 const words = stripPrefix(wordsOf(segment))
24 const [cmd, ...rest] = words
25
26 if (cmd === 'rm' || cmd?.endsWith('/rm')) {
27 const ddash = rest.indexOf('--')
28 const flags = (ddash < 0 ? rest : rest.slice(0, ddash)).filter(isFlag)
29 const isRecursive = flags.some(f => f === '--recursive' || /^-[a-zA-Z]*[rR]/.test(f))
30 if (!isRecursive) return null
31 const args = ddash < 0 ? rest.filter(w => !isFlag(w)) : [...rest.slice(0, ddash).filter(w => !isFlag(w)), ...rest.slice(ddash + 1)]
32 return { kind: 'rm', label: 'Rekursives Löschen', segment, args }
33 }
34
35 if (cmd === 'find' && rest.includes('-delete')) {
36 return { kind: 'find-delete', label: 'find -delete', segment, args: rest }
37 }
38
39 if (cmd === 'git') {
40 // skip git's own options (-C <dir>, -c <k=v>) to reach the subcommand
41 let i = 0
42 while (i < rest.length && isFlag(rest[i] ?? '')) i += rest[i] === '-C' || rest[i] === '-c' ? 2 : 1
43 const sub = rest[i]
44 const tail = rest.slice(i + 1)
45 const operands = tail.filter(w => !isFlag(w))
46
47 if (sub === 'reset' && tail.includes('--hard')) {
48 return { kind: 'git-reset-hard', label: 'git reset --hard', segment, args: operands }
49 }
50 if (sub === 'clean' && tail.some(f => f === '--force' || /^-[a-zA-Z]*f/.test(f))) {
51 return { kind: 'git-clean', label: 'git clean', segment, args: tail.filter(isFlag) }
52 }
53 if (sub === 'checkout' && (tail.includes('--') || operands.includes('.'))) {
54 const ddash = tail.indexOf('--')
55 const paths = ddash < 0 ? operands : tail.slice(ddash + 1)
56 return { kind: 'git-discard', label: 'Änderungen verwerfen', segment, args: paths }
57 }
58 if (sub === 'restore' && !tail.includes('--staged') && !tail.includes('-S')) {
59 return { kind: 'git-discard', label: 'Änderungen verwerfen', segment, args: operands }
60 }
61 if (sub === 'push' && tail.some(f => f === '--force' || f.startsWith('--force-with-lease') || /^-[a-zA-Z]*f/.test(f) || /^\+/.test(f))) {
62 return { kind: 'git-push-force', label: 'Force-Push', segment, args: operands }
63 }
64 if (sub === 'branch' && tail.some(f => f === '-D' || /^-[a-zA-Z]*D/.test(f))) {
65 return { kind: 'git-branch-delete', label: 'Branch löschen (-D)', segment, args: operands }
66 }
67 }
68
69 return null
70}
71
72/** The first part of `command` that would destroy something, or null when nothing would. */
73export function classify(command: string): Risk | null {
74 for (const segment of command.split(/&&|\|\||;|\||\n/)) {
75 const risk = classifySegment(segment.trim())
76 if (risk) return risk
77 }
78 return null
79}
80hooks/measure.ts 155 lines1import type { Report, Risk } from '../types'
2import { wordsOf } from './classify'
3
4const SHOWN = 6
5
6// German grouping: 12345 → "12.345"
7export const countOf = (n: number) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, '.')
8
9// du's kilobytes → "2,3 GB"
10export function sizeOf(kb: number): string {
11 const [value, unit] = kb >= 1024 ** 2 ? [kb / 1024 ** 2, 'GB'] : kb >= 1024 ? [kb / 1024, 'MB'] : [kb, 'KB']
12 return `${value.toFixed(unit === 'KB' ? 0 : 1).replace(/\.0$/, '').replace('.', ',')} ${unit}`
13}
14
15const linesOf = (text: string) => text.split('\n').filter(l => l.trim() !== '')
16
17// paths whose loss is the whole machine, the home folder or the whole project
18const CRITICAL = new Set(['/', '/*', '~', '~/', '~/*', '$HOME', '.', './', './*', '*', '..', '../'])
19
20/** Runs a read-only command, resolving its stdout, or null when it failed or could not run. */
21export type Run = (argv: string[]) => Promise<string | null>
22
23/** Measures what `risk` would destroy in `cwd`, through `run`, which only reads. */
24export async function measure(run: Run, risk: Risk, cwd: string): Promise<Report> {
25 switch (risk.kind) {
26 case 'rm':
27 return measureRm(run, risk, cwd)
28 case 'git-reset-hard':
29 return measureReset(run, risk)
30 case 'git-clean':
31 return measureClean(run, risk)
32 case 'git-discard':
33 return measureDiscard(run, risk)
34 case 'git-push-force':
35 return measurePush(run)
36 case 'git-branch-delete':
37 return measureBranch(run, risk)
38 case 'find-delete':
39 return measureFind(run, risk)
40 }
41}
42
43async function measureRm(run: Run, risk: Risk, cwd: string): Promise<Report> {
44 const isCritical = risk.args.some(t => CRITICAL.has(t) || t === cwd || t === `${cwd}/`)
45 let files = 0
46 let kb = 0
47 const lines: string[] = []
48 for (const target of risk.args) {
49 // a pattern or a variable expands only in the shell; it is named, not measured
50 if (/[*?[\]{}$`~]/.test(target)) {
51 lines.push(`${target}: Muster, nicht ausgewertet`)
52 continue
53 }
54 const du = await run(['du', '-sk', '--', target])
55 if (du === null) {
56 lines.push(`${target}: existiert nicht`)
57 continue
58 }
59 const size = Number(du.split(/\s/)[0]) || 0
60 const count = linesOf((await run(['find', target])) ?? '').length
61 files += count
62 kb += size
63 lines.push(`${target}: ${countOf(count)} Einträge, ${sizeOf(size)}`)
64 }
65 return {
66 summary: `würde ${countOf(files)} Dateien und Ordner (${sizeOf(kb)}) unwiderruflich löschen`,
67 lines: lines.slice(0, SHOWN),
68 severity: isCritical ? 'critical' : 'high',
69 }
70}
71
72async function measureReset(run: Run, risk: Risk): Promise<Report> {
73 const changed = linesOf((await run(['git', 'status', '--porcelain'])) ?? '').filter(l => !l.startsWith('??'))
74 const ref = risk.args[0]
75 const dropped = ref ? linesOf((await run(['git', 'log', '--oneline', `${ref}..HEAD`])) ?? '') : []
76 const parts = [`ungespeicherte Änderungen in ${countOf(changed.length)} Dateien verwerfen`]
77 if (dropped.length > 0) parts.push(`${countOf(dropped.length)} Commits vom Branch entfernen`)
78 return {
79 summary: `würde ${parts.join(' und ')}`,
80 lines: [...changed.map(l => l.slice(3)), ...dropped.map(c => `Commit ${c}`)].slice(0, SHOWN),
81 severity: changed.length + dropped.length > 0 ? 'critical' : 'high',
82 }
83}
84
85async function measureClean(run: Run, risk: Risk): Promise<Report> {
86 // the same flags as a dry run: -fdx becomes -n -dx
87 const kept = risk.args.map(f => (f.startsWith('--') ? '' : f.replace(/[^dxX]/g, ''))).join('')
88 const removed = linesOf((await run(['git', 'clean', '-n', ...(kept ? [`-${kept}`] : [])])) ?? '').map(l =>
89 l.replace(/^Would remove /, ''),
90 )
91 return {
92 summary: `würde ${countOf(removed.length)} ungetrackte Dateien und Ordner löschen`,
93 lines: removed.slice(0, SHOWN),
94 severity: 'high',
95 }
96}
97
98async function measureDiscard(run: Run, risk: Risk): Promise<Report> {
99 const paths = risk.args.length > 0 ? risk.args : ['.']
100 const files = linesOf((await run(['git', 'diff', '--name-only', '--', ...paths])) ?? '')
101 const stat = linesOf((await run(['git', 'diff', '--shortstat', '--', ...paths])) ?? '')[0]?.trim()
102 return {
103 summary: `würde ungespeicherte Änderungen in ${countOf(files.length)} Dateien verwerfen`,
104 lines: [...(stat ? [stat] : []), ...files].slice(0, SHOWN),
105 severity: files.length > 0 ? 'critical' : 'high',
106 }
107}
108
109async function measurePush(run: Run): Promise<Report> {
110 const upstream = (await run(['git', 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']))?.trim()
111 if (!upstream) {
112 return { summary: 'würde einen Remote-Branch überschreiben (kein Upstream bekannt)', lines: [], severity: 'high' }
113 }
114 const lost = linesOf((await run(['git', 'log', '--oneline', 'HEAD..@{u}'])) ?? '')
115 return {
116 summary:
117 lost.length > 0
118 ? `würde ${countOf(lost.length)} Commits auf ${upstream} überschreiben (Stand letzter fetch)`
119 : `überschreibt ${upstream}; laut letztem fetch geht kein Commit verloren`,
120 lines: lost.slice(0, SHOWN).map(c => `Commit ${c}`),
121 severity: lost.length > 0 ? 'critical' : 'high',
122 }
123}
124
125async function measureBranch(run: Run, risk: Risk): Promise<Report> {
126 const lines: string[] = []
127 let total = 0
128 for (const name of risk.args) {
129 const only = linesOf((await run(['git', 'log', '--oneline', name, '--not', 'HEAD', '--remotes'])) ?? '')
130 total += only.length
131 lines.push(`${name}: ${countOf(only.length)} Commits, die sonst nirgends liegen`)
132 }
133 return {
134 summary: `würde ${risk.args.length === 1 ? 'einen Branch' : `${risk.args.length} Branches`} löschen, mit ${countOf(total)} Commits nur dort`,
135 lines: lines.slice(0, SHOWN),
136 severity: total > 0 ? 'critical' : 'high',
137 }
138}
139
140async function measureFind(run: Run, risk: Risk): Promise<Report> {
141 const all = wordsOf(risk.segment)
142 const words = all.slice(Math.max(0, all.indexOf('find')))
143 // only a plain find is re-run as a dry run: one that runs other programs or reads the shell is named, not run
144 const isPlain = !words.some(w => /^-(exec|execdir|ok|okdir|fprint|fls|fprintf)$/.test(w) || /[$`<>]/.test(w))
145 if (!isPlain) {
146 return { summary: 'würde Dateien löschen (find mit -exec, nicht ausgewertet)', lines: [], severity: 'high' }
147 }
148 const found = linesOf((await run(words.map(w => (w === '-delete' ? '-print' : w)))) ?? '')
149 return {
150 summary: `würde ${countOf(found.length)} Dateien und Ordner löschen`,
151 lines: found.slice(0, SHOWN),
152 severity: 'high',
153 }
154}
155types/index.d.ts 38 lines1export type RiskKind =
2 | 'rm'
3 | 'git-reset-hard'
4 | 'git-clean'
5 | 'git-discard'
6 | 'git-push-force'
7 | 'git-branch-delete'
8 | 'find-delete'
9
10/** A command segment that would destroy something, and what it names. */
11export type Risk = { kind: RiskKind; label: string; segment: string; args: string[] }
12
13/** What the command would do, measured before it runs. */
14export type Report = {
15 /** One line: "würde 1.234 Dateien (2,3 GB) löschen". */
16 summary: string
17 /** Up to a few lines of detail: file names, commits, sizes. */
18 lines: string[]
19 /** `critical` for targets like /, ~ or the whole working tree. */
20 severity: 'critical' | 'high'
21}
22
23/** The command waiting for the person's answer. */
24export type Held = {
25 id: string
26 command: string
27 risk: Risk
28 report: Report
29 where: 'pane' | 'band'
30 decision: 'proceed' | 'cancel' | null
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'blast-radius': { held: Held | null }
36 }
37}
38