SLOPSHOPPER

blast-radius

Holds risky Bash commands, shows what they would hit, and waits for Proceed or Cancel.

newpanebandguardprocess
v0.1.0no licenseupdated 2026-10-06soenkenils/claude-code-mods/blast-radius
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 │ ┃ Nothing held. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · blast-radius
Nothing held.
Source 3 files
hooks/blast-radius.tsx 148 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { BlastRadiusHeld, BlastRadiusRisk } from "../types";
5import { classify } from "./classify";
6
7const PANE = "blast-radius";
8const SHOWN = 8;
9const held = atom({ plugin: "blast-radius", key: "held" } as const, null);
10
11// The decision lives in the module, not in $.state: every $.state.get of one
12// dispatch reads one moment, so the waiting hook would never see a press.
13type Decision = "proceed" | "cancel";
14let isHolding = false;
15let decision: Decision | null = null;
16
17export const register: Register = (on) => {
18  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
19    const command = String(e.command ?? "");
20    const risk = classify(command);
21    if (risk === null) return next(e);
22
23    if (isHolding) {
24      return { deny: "Blast Radius is already holding another command; run this one after it." };
25    }
26
27    isHolding = true;
28    decision = null;
29    try {
30      const report = await measure($, risk, await $.session.cwd());
31      await update($, held, (): BlastRadiusHeld => ({ command, ...report, where: "pane" }));
32      const opened = await $.ui.open({ id: PANE, title: "Blast Radius", focus: true });
33      if (!opened.isPlaced) await update($, held, (cur) => (cur ? { ...cur, where: "band" as const } : cur));
34
35      // A `$` call in flight does not count against the hook's budget, so
36      // short sleeps let us wait on the person for as long as they need.
37      while (decision === null && !next.signal.aborted) {
38        await $.process.run(["sleep", "0.25"]);
39      }
40    } finally {
41      isHolding = false;
42      await update($, held, () => null);
43      await $.ui.close({ id: PANE });
44    }
45
46    if (decision === "proceed") return next(e);
47    return {
48      deny: `Blast Radius held this command and the user chose not to run it: ${command}. Ask them how to proceed instead of retrying.`,
49    };
50  });
51
52  // Closing the pane by hand (Escape, its close mark) counts as Cancel.
53  on("ui.close", { id: PANE }, async ($, e, next) => {
54    if (e.origin.kind === "person") decide("cancel");
55    return next(e);
56  });
57
58  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
59    const { Box, Text } = $.ui.resolve(e);
60    const current = await read($, held);
61    if (current === null) return <Box><Text dimColor>Nothing held.</Text></Box>;
62    return warning($, e, current);
63  });
64
65  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
66    const current = await read($, held);
67    if (current === null || current.where !== "band" || e.props.hasSurvey) return next(e);
68    return warning($, e, current);
69  });
70};
71
72function warning($: EngineInterface, e: Parameters<EngineInterface["ui"]["resolve"]>[0], shown: BlastRadiusHeld) {
73  const { Box, Text, Button } = $.ui.resolve(e);
74  return (
75    <Box flexDirection="column" paddingX={1}>
76      <Text color="red" bold>⚠ {shown.title}</Text>
77      <Text dimColor>$ {shown.command}</Text>
78      {shown.lines.slice(0, SHOWN).map((line) => <Text>  {line}</Text>)}
79      {shown.lines.length > SHOWN && <Text dimColor>  … and {shown.lines.length - SHOWN} more</Text>}
80      <Box flexDirection="row" gap={2} marginTop={1}>
81        <Button key="proceed" label="Proceed" hotkey="1" plain onPress={() => decide("proceed")} />
82        <Button key="cancel" label="Cancel" hotkey="2" plain onPress={() => decide("cancel")} />
83      </Box>
84    </Box>
85  );
86}
87
88function decide(choice: Decision) {
89  if (isHolding && decision === null) decision = choice;
90}
91
92type Report = { title: string; lines: string[] };
93
94async function measure($: EngineInterface, risk: BlastRadiusRisk, cwd: string): Promise<Report> {
95  const run = async (argv: string[]) => {
96    try {
97      const r = await $.process.run(argv, { cwd });
98      return r.exitCode === 0 ? r.stdout.split("\n").filter(Boolean) : null;
99    } catch {
100      return null;
101    }
102  };
103
104  switch (risk.kind) {
105    case "rm": {
106      if (risk.targets.length === 0) return { title: "Recursive force delete", lines: [] };
107      const entries = (await run(["find", ...risk.targets])) ?? [];
108      const sizes = (await run(["du", "-sh", ...risk.targets])) ?? [];
109      const dirty = (await run(["git", "status", "--porcelain", "--", ...risk.targets])) ?? [];
110      return {
111        title: `Deletes ${entries.length} files and folders`,
112        lines: [
113          ...sizes.map((s) => s.replace(/\s+/, "  ")),
114          ...(dirty.length > 0 ? [`${dirty.length} of them have uncommitted changes:`, ...dirty] : []),
115        ],
116      };
117    }
118    case "git-reset-hard": {
119      const dirty = (await run(["git", "status", "--porcelain", "--untracked-files=no"])) ?? [];
120      return {
121        title: dirty.length > 0 ? `Discards uncommitted changes in ${dirty.length} files` : "Resets the branch (working tree is clean)",
122        lines: dirty,
123      };
124    }
125    case "git-clean": {
126      const extra = risk.flags.filter((f) => /^-[a-zA-Z]*[dxX]/.test(f)).flatMap((f) => f.slice(1).replace(/[^dxX]/g, "").split("").map((c) => `-${c}`));
127      const gone = (await run(["git", "clean", "-n", ...extra])) ?? [];
128      return { title: `Removes ${gone.length} untracked files`, lines: gone.map((l) => l.replace(/^Would remove /, "")) };
129    }
130    case "force-push": {
131      const lost = await run(["git", "log", "--oneline", "HEAD..@{u}"]);
132      if (lost === null) return { title: "Force push (no upstream to compare)", lines: [] };
133      return {
134        title: lost.length > 0 ? `Overwrites ${lost.length} commits on the remote` : "Force push (remote has nothing that would be lost)",
135        lines: lost,
136      };
137    }
138    case "migration": {
139      if (risk.tool === "django") {
140        const plan = (await run(["python", "manage.py", "showmigrations", "--plan"])) ?? [];
141        const pending = plan.filter((l) => l.startsWith("[ ]")).map((l) => l.slice(4));
142        return { title: `Applies ${pending.length} pending migrations`, lines: pending };
143      }
144      return { title: "Runs database migrations", lines: ["Check which database this targets before going on."] };
145    }
146  }
147}
148
hooks/classify.ts 52 lines
1import type { BlastRadiusRisk } from "../types";
2
3// Reads the command text only: `$(…)`, aliases and scripts that call rm
4// themselves slip past. A reminder, not a security boundary.
5export function classify(command: string): BlastRadiusRisk | null {
6  for (const segment of command.split(/&&|\|\||[;|\n]/)) {
7    const words = segment.trim().split(/\s+/).map(unquote).filter(Boolean);
8    while (words[0] === "sudo" || /^\w+=/.test(words[0] ?? "")) words.shift();
9    const risk = classifyWords(words);
10    if (risk) return risk;
11  }
12  return null;
13}
14
15function classifyWords(words: string[]): BlastRadiusRisk | null {
16  const [cmd, sub, ...rest] = words;
17  const flags = words.filter((w) => w.startsWith("-"));
18
19  if (cmd === "rm") {
20    const isRecursive = flags.some((f) => f === "--recursive" || /^-[a-zA-Z]*[rR]/.test(f));
21    const isForced = flags.some((f) => f === "--force" || /^-[a-zA-Z]*f/.test(f));
22    if (!isRecursive || !isForced) return null;
23    return { kind: "rm", targets: words.slice(1).filter((w) => !w.startsWith("-")) };
24  }
25
26  if (cmd === "git") {
27    if (sub === "reset" && rest.includes("--hard")) return { kind: "git-reset-hard" };
28    if (sub === "clean" && rest.some((f) => /^-[a-zA-Z]*f/.test(f) || f === "--force")) {
29      return { kind: "git-clean", flags: rest.filter((f) => f.startsWith("-")) };
30    }
31    if (sub === "push" && rest.some((f) => f === "-f" || f.startsWith("--force") || /^\+/.test(f))) {
32      return { kind: "force-push" };
33    }
34    return null;
35  }
36
37  if (words.includes("manage.py") && words.includes("migrate")) return { kind: "migration", tool: "django" };
38  if (/^(rails|rake|bin\/rails)$/.test(cmd ?? "") && /^db:(migrate|rollback|reset|drop)/.test(sub ?? "")) {
39    return { kind: "migration", tool: "other" };
40  }
41  if (words.some((w) => w === "migrate" || w.startsWith("migrate:")) && /^(npx|bunx|prisma|knex|flyway|alembic|sequelize)$/.test(cmd ?? "")) {
42    return { kind: "migration", tool: "other" };
43  }
44  if (cmd === "alembic" && (sub === "upgrade" || sub === "downgrade")) return { kind: "migration", tool: "other" };
45
46  return null;
47}
48
49function unquote(word: string) {
50  return word.replace(/^['"]|['"]$/g, "");
51}
52
types/index.d.ts 20 lines
1export type BlastRadiusRisk =
2  | { kind: "rm"; targets: string[] }
3  | { kind: "git-reset-hard" }
4  | { kind: "git-clean"; flags: string[] }
5  | { kind: "force-push" }
6  | { kind: "migration"; tool: "django" | "other" };
7
8export type BlastRadiusHeld = {
9  command: string;
10  title: string;
11  lines: string[];
12  where: "pane" | "band";
13};
14
15declare module "claude-code" {
16  interface PluginState {
17    "blast-radius": { held: BlastRadiusHeld | null };
18  }
19}
20