SLOPSHOPPER

blast-radius

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

newpanebandguardprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius
│ ┃ blast-radius ✕ › fix the failing auth test and add an audit log call │ ┃ Kein Befehl angehalten. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(rm -rf build && git push --force origin main) │ ⎿ Denied by blast-radius: Blast Radius hat diesen Befehl an │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · blast-radius
Kein Befehl angehalten.
README

Claude-Code-Mods

token-weather

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üllstandAnzeige
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.

Installieren

Benötigt eine Claude-Code-Version mit Mod-Unterstützung (Function Hooks).

/plugin marketplace add reifen01/claude-mods
/plugin install token-weather@reifen01-mods

Wie es funktioniert

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

Tests

claude plugin test plugins/token-weather
claude plugin test plugins/blast-radius
claude plugin test plugins/replay

blast-radius

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 ]
ErkanntGemessen mit
rm -r / rm -rfdu, 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 / -fgit log HEAD..@{u} (Stand letzter fetch)
git branch -DCommits, die nur auf dem Branch liegen
find … -deletederselbe find mit -print
  • Die Karte erscheint als Pane, oder über dem Prompt, wenn kein Platz für ein Pane ist.
  • Abbrechen (Taste n, auch ✕ oder Esc): Claude bekommt eine Absage mit dem, was der Befehl angerichtet hätte.
  • Trotzdem ausführen (Taste j): der Befehl läuft normal.
  • Ohne Antwort nach 10 Minuten wird abgebrochen. Ohne Oberfläche (claude -p) läuft der Befehl wie ohne Mod.
  • Gemessen wird nur lesend; Muster wie *.log oder $VAR werden genannt, nicht ausgewertet.

Installieren:

/plugin install blast-radius@reifen01-mods

replay

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) ]
  • Erfasst werden Edit, Write und NotebookEdit, auch die von Subagenten derselben Runde.
  • Der Diff kommt aus dem Patch, den die Engine zum Edit liefert; nichts wird neu berechnet oder erneut gelesen.
  • Tasten im Pane: w weiter, z zurück, q oder Esc schließen.
  • Eine neue Runde mit Änderungen ersetzt die Wiedergabe; eine Runde ohne Änderungen lässt sie stehen.

Installieren:

/plugin install replay@reifen01-mods

Lizenz

MIT

Source 4 files
hooks/register.tsx 129 lines
1import { 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}
129
hooks/classify.ts 80 lines
1import 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}
80
hooks/measure.ts 155 lines
1import 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}
155
types/index.d.ts 38 lines
1export 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