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

hooks/blast-radius.tsx 148 lines1import { 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}
148hooks/classify.ts 52 lines1import 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}
52types/index.d.ts 20 lines1export 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