Holds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations), also inside a script run by path, and shows what they would change…

A Claude Code mod that holds a risky shell command and shows you what it would change before it runs. We're sharing it as a small, complete example of a mod that pauses a tool call and asks you to decide.
When Claude calls Bash with one of the commands below, Blast Radius stops the call, works out what the command would touch, and opens a pane with two buttons: Proceed runs the command, Cancel refuses it. Claude sees the refusal and the reason.
| Command | What the pane shows |
|---|---|
rm -r, rm -f, rm -rf | The files it would delete, with the count and total size. Globs and ~ are expanded. |
git reset --hard | The files with uncommitted changes, from git status --porcelain, and git diff --shortstat. |
git checkout -- ., git restore . | The files with unstaged changes. |
git clean | The untracked paths it would remove, from git clean -n with the same flags. |
git push --force (also -f, --force-with-lease, +ref) | The commits on the remote branch that your HEAD doesn't have, which the push would drop. |
manage.py migrate, db:migrate, alembic upgrade, prisma migrate | The pending migrations, from the tool's own status command. |
any other migrate | A note that it can't list the pending migrations for that tool. |
bash x.sh (also sh, zsh, dash, ksh), . x.sh, source x.sh, ./x.sh | Local change: the script is read from disk and each risky line in it is held and measured as if it had been typed inline, named by file and line. |
Every other command runs as normal. If the command line moves first, with cd dir &&, pushd/popd or git -C dir, Blast Radius measures in that folder. A cd inside ( ... ) only applies inside the parentheses, as in the shell.
The patterns it demonstrates:
tool.call until the user answers, and returning { deny } with a reason Claude can act on.Pane, or in the AbovePrompt band when the terminal is too narrow for a pane.$.process.run, passing paths as arguments so nothing in them runs as shell.rm -rf build held, with the files it would delete:

After Cancel, Claude reports that nothing was deleted:

The second try, after Proceed:

git reset --hard in a 120-column terminal, where the report is drawn in the band above the prompt:

Blast Radius. See what a command will touch, before it runs. When Claude runs a risky shell command, like
rm,git reset, or a migration, the mod opens a pane. The pane lists the files and branches the command would change. The developer presses Proceed, or Cancel. It usestool.callto hold the call, andui.renderonPane, withButtonelements. It adds a safety check, and it doesn't remove any, so it's safe to show. A bigger project, because each command needs its own dry run.
The build prompt, which picked this idea and two others by number:
implement 1,2,7. give me zips for them. test them in claude code and get me screenshots of what they look like when used.
$ call doesn't count, so the hold is a loop that waits on $.process.run(["sleep", "0.25"]) until a button's onPress sets the decision.isPlaced and draws the report in the AbovePrompt band instead.find action (for example -delete) that a glob matched could be read as an action while measuring, so relative paths now go to find with ./ in front. And a cd earlier on the command line was ignored, so the wrong folder was measured; the mod now follows cd and git -C. The same review led to smaller fixes: overlapping holds are queued, and the buttons always answer the command shown; an error while holding refuses the command; git clean -e, src:dst and HEAD force pushes are measured correctly; and sizes use du -k, which works on macOS as well as Linux.rm -rf build was held for more than 30 seconds, Cancel kept the files and Proceed deleted them; git reset --hard listed the two changed files and Cancel kept them; git clean -fdx was held. Force pushes and migrations were checked against the command classifier only, not run. The fixes are covered by tests of the classifier, the measuring step and the hold queue, run with Node against a stand-in for Claude Code; they haven't been re-run in a live session.Requirements:
claude plugin validate passes on 2.1.285.bash, git, find and du on your PATH. For migrations, the project's own tool (for example python3 manage.py showmigrations).No environment variables or configuration.
Steps:
git clone https://github.com/anthropics/claude-code-playground.git
cd claude-code-playground/claude-code/mods
claude plugin validate ./blast-radius
claude --plugin-dir ./blast-radius
Or install it, with the other mods here, from the local marketplace in this folder (see the mods README):
claude plugin marketplace add ./
claude plugin install blast-radius@claude-code-playground-mods --scope user
Remove it with claude plugin uninstall blast-radius@claude-code-playground-mods --scope user.
Using the pane:
1 for Proceed or 2 for Cancel. You can also click a button, or Tab to it and press Enter.1 or 2 works while the input is empty.$(...), aliases, eval, bash -c "...", xargs rm, find -delete, and wrappers such as timeout 5 rm, doas rm, time -p rm or env -i rm aren't caught. A script that a script calls is not read, nor is a program in another language (python x.py and a shutil.rmtree inside it are out of scope).cd, pushd, popd and git -C on the same line. cd -, and a folder that doesn't exist, can't be measured; the pane says so and still holds the command. Otherwise it starts from the session's working folder.rm count is approximate: a path matched twice is counted twice, and a file name with a line break is not counted. The list shows the first 10 files. Counts come from find and sizes from du -k, so a size is the space on disk, to the nearest kilobyte. A very large tree can take a few seconds to measure.origin.python3 manage.py showmigrations), which loads your project's code before you choose Proceed or Cancel. If that fails, the pane says it couldn't list them.; rm -rf.| Name | Version | License (SPDX) | Source |
|---|---|---|---|
| None |
The mod has no packages to install. It calls tools that are already on the machine (listed under Requirements).
Git is a trademark of Software Freedom Conservancy. Python is a registered trademark of the Python Software Foundation. npm is a trademark of npm, Inc. Django is a registered trademark of the Django Software Foundation. Rails and Ruby on Rails are trademarks of David Heinemeier Hansson. Prisma is a trademark of Prisma Data, Inc. Alembic is an open-source project of the SQLAlchemy authors. Use of these names here is descriptive and implies no endorsement.
This copy is vendored at upstream commit e9ab132 and tested with Claude Code CLI 2.1.288, and 2.1.294 for the question-dialog hold and the script read. See UPSTREAM.md for the full list.
isInteractive=false, and the list of drawing surfaces can be empty at the moment a command arrives. When it lists desktop, you get the pane, which the desktop draws. When it is empty, the command is put to you as a question with Proceed and Cancel. It includes the command, what it would delete, and the first few paths. Proceed runs it. Cancel, dismissing the question, or typing your own answer refuses it, and a typed answer is passed back to Claude.claude -p there is no question dialog, so the question fails straight away and the command is refused. The refusal names the signals that decided: isInteractive, the drawing surfaces, and why the question failed. To let such runs delete, start Claude Code with BLAST_RADIUS_HEADLESS=allow in its own environment; one line is logged. A VAR=value prefix on the Bash command does not reach the mod. The setting applies only where no question dialog exists. It never skips a question, and never runs a command whose question was refused.BLAST_RADIUS_HOLD_SECONDS (default 600, minimum 5) sets how long a hold waits before refusing. This replaces the fixed 10 minutes above. A question nobody answers is refused at the limit; it may stay open, and answering it then does nothing.rm paths are read the way the shell reads them. The pieces of one word stay together, so "$DIR"/* is one path, not $DIR and the filesystem root /*. A variable set to a plain value earlier on the same line is filled in (export, local and readonly count), and $HOME at the start of a word or of an assignment's value becomes ~. Anything else is not measured, and the summary says why instead of "delete nothing". That covers a variable from the shell's environment, $(...), and an unquoted value with spaces. Nothing is run to find a value.bash local-delete.sh ran at once while the same rm -rf lines typed inline were held (observed 2026-10-08). Now bash x.sh, sh, zsh, dash, ksh with options before the file, . x.sh, source x.sh, and a bare path such as ./x.sh or ~/bin/x make the mod read the file and check its lines the way it checks a command line, so a cd above a line moves where that line is measured, and a plain assignment above it fills in a variable; $1, a value from a command, and the rest are said to be unmeasured. The hold names the file and the lines: "rm -rf in local-delete.sh line 28 and 1 more risky line", and the summary says what each line would do. The first five risky lines are measured; the rest are named. Comments are not removed. A comment line starts with #, which is not a command, so # rm -rf old is not held, but # done; rm -rf old is, because the line is split at ; first. A bare path is read only when it has a shell shebang or a .sh, .bash, .zsh or .ksh name; #!/usr/bin/env node passes with a debug line./tmp/claude-<uid>/<project>/<session id>/scratchpad/; another session's is not read). Anything else is not read, and a transcript line says what was skipped and why: too big, outside those folders, missing (for a bare name, from the folder and from PATH), a folder, a path from a variable the line does not set, a relative path after a cd that needs a command to expand, or bash -c. For a bare path without a shell name (/usr/bin/git status, .venv/bin/python x.py), which is usually a program, that line goes to the debug log instead.x.sh is looked up as the shell does (measured with bash and zsh on macOS). After source or ., the first match on PATH and the copy in the folder are both read, since bash tries PATH first and zsh's source tries the folder first. After bash and the other shells, the folder's copy is read, or the first match on PATH when the folder has none. PATH is Claude Code's own, which may lack a folder your shell profile adds.;, | or & inside quotes still splits the line, as upstream: bash "a;b.sh" is read as bash a, and rm -rf "a;b" is measured as a. The cut name is skipped and said, unless a file by that name exists.cd inside an if that would not run is still used, and the shell text inside a heredoc body is read as commands.\ joined to the next, and it is held if either reading finds a risk. So rm -rf \ followed by build is held and measured as rm -rf build, and rm \ followed by -rf build, which upstream missed, is held. The join removes the \ and the line break, as the shell does, so foo\ followed by bar is the one path foobar. With no space, rm\ followed by -rf build is the command rm-rf, which deletes nothing, and passes. The joined reading can only add a hold, never remove one; evals/no-weaker/run.sh checks that against main.CLAUDE_CODE_PLUGIN_DIRS.Shared as-is as part of claude-code-playground. Not an official Anthropic product; no support or maintenance is implied. See the root README and LICENSE.
hooks/blast-radius.mjs 1224 lines1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Blast Radius: holds a risky Bash command and shows what it would change.
5//
6// tool.call (Bash): if the command is risky, work out its blast radius, open a
7// pane with Proceed and Cancel, and hold the call until one is pressed.
8// ui.render (Pane): draws the report. If the surface won't place the pane (a
9// narrow terminal), the same report is drawn in the AbovePrompt band instead.
10//
11// Holding: a hook has 10 s of its own time, but time spent inside a `$` call is
12// free. So the hold loop waits on a short `$.process.run(["sleep", ...])` until
13// a button's onPress sets the decision.
14//
15// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
16// spelled literally, and helpers that take `$` are top-level functions.
17//
18// Local change: a script run by path (`bash x.sh`, `./x.sh`, `source x.sh`) is
19// read from disk and its lines are checked as if its text had been the command.
20
21const PANE_ID = "blast-radius";
22const POLL_SECONDS = "0.25";
23const HOLD_SECONDS_DEFAULT = 600;
24const HOLD_SECONDS_MIN = 5;
25const LIST_MAX = 10;
26// Local change: the engine's question dialog, the hold where no surface draws the pane.
27const PROCEED = "Proceed";
28const CANCEL = "Cancel";
29const QUESTION_LIST_MAX = 3;
30// Local change: scripts run by path. A local file up to this size is read; its
31// first few risky lines are measured.
32const SCRIPT_BYTES_MAX = 256 * 1024;
33const SCRIPT_MEASURE_MAX = 5;
34const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
35const SHELL_OPTIONS_WITH_VALUE = new Set(["--rcfile", "--init-file"]);
36// A group of short options whose last letter takes a value: -o, -O, -euo, +o.
37const SHELL_OPTION_GROUP_WITH_VALUE = /^[-+][^-]*[oO]$/;
38const SHELL_NAME = /\.(?:sh|bash|zsh|ksh)$/;
39// This session's scratchpad: /tmp/claude-<uid>/<project>/<session id>/scratchpad/.
40// Measured 2026-10-08: $.session.id() is that folder's name under `claude -p`, and
41// the desktop's CLAUDE_CODE_SESSION_ID is its own scratchpad's.
42const SCRATCHPAD = (id) => new RegExp(`^(?:/private)?/tmp/claude-[^/]+/[^/]+/${id.replace(/[^A-Za-z0-9-]/g, "\\$&")}/scratchpad/`);
43
44// The call being held, or null. One at a time: Bash calls in a turn run in order.
45let held = null;
46
47// Local change: whether a person is at the prompt, from session.start. null until
48// that event is seen (a mod loaded mid-session). Reported in a deny, not used to
49// decide: the desktop app's Code tab reports false with a person present.
50let sessionInteractive = null;
51
52export function register(on) {
53 // Local change: record whether a person is present, for the hold below.
54 on("session.start", async ($, e, next) => {
55 sessionInteractive = e.isInteractive === true;
56 return next(e);
57 });
58
59 // Local change: `.catch` refuses the command when the hook itself fails (a
60 // throw outside the hold's own try, or an overrun budget). Upstream failed open.
61 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
62 let risk = classifyCommand(String(e.command ?? ""));
63 // Local change: a line that runs scripts by path and is not risky itself is
64 // checked by reading each script in order; the first risky one holds. Any
65 // risk written on the line itself wins, as before.
66 if (risk !== null && risk.kind === "scripts") {
67 let found = null;
68 const skipped = [];
69 for (let i = 0; i < risk.list.length && found === null; i += 1) {
70 const read = await scriptRisk($, risk.list[i]);
71 if (read?.skipped === true) {
72 skipped.push(risk.list[i].path);
73 } else if (read !== null) {
74 found = read;
75 // not read: those skipped before this one, and every one after it
76 found.alsoRuns = [...new Set([...skipped, ...risk.list.slice(i + 1).map((m) => m.path)])];
77 }
78 }
79 risk = found;
80 }
81 if (risk === null) {
82 return next(e);
83 }
84 // One hold at a time. If another risky call is already held (a subagent's,
85 // say), wait until it is answered. `held` is claimed with no await between
86 // the check and the claim, so two waiting calls can't both get through.
87 while (held !== null) {
88 if (next.signal.aborted) {
89 return { deny: "Blast Radius held this command and did not run it: the turn was interrupted. Do not retry it unless the user asks you to." };
90 }
91 await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 });
92 }
93 const mine = { command: String(e.command), risk, report: null, decision: null, where: "pane", polls: 0, answer: null, askError: null, askRejectedAtPoll: null };
94 held = mine;
95
96 let paneOpened = false;
97 let decision;
98 let holdSeconds = HOLD_SECONDS_DEFAULT;
99 let summary = risk.label;
100 let surfaces = [];
101 try {
102 // Measure where the command will run: the session folder, moved by any
103 // `cd dir &&` or `git -C dir` earlier in the same command line.
104 const sessionCwd = await $.session.cwd();
105 const cwd = risk.dir ? await resolveDir($, sessionCwd, risk.dir) : sessionCwd;
106 mine.report = cwd === null
107 ? { summary: `${risk.label} in ${risk.dir}`, lines: [], note: `Couldn't find the folder ${risk.dir}, so I couldn't measure what this would change.` }
108 : await measure($, risk, cwd);
109 // Local change: scripts on the line that were not read are named, so Proceed
110 // is not taken as approving only what the report shows.
111 if (risk.alsoRuns !== undefined && risk.alsoRuns.length > 0) {
112 const names = risk.alsoRuns.map((p) => p.slice(p.lastIndexOf("/") + 1)).join(", ");
113 mine.report.summary += `; Proceed also runs ${names}, which ${risk.alsoRuns.length === 1 ? "was" : "were"} not read`;
114 }
115 summary = mine.report.summary;
116
117 // Local change: the limit is BLAST_RADIUS_HOLD_SECONDS (default 600, minimum 5);
118 // upstream hard-coded 10 minutes.
119 const asked = Number(await $.env.get("BLAST_RADIUS_HOLD_SECONDS"));
120 if (Number.isFinite(asked) && asked > 0) {
121 holdSeconds = Math.max(HOLD_SECONDS_MIN, asked);
122 }
123
124 // Local change: where the person answers. A surface that draws gets the pane,
125 // as upstream. With none, the engine's own question dialog holds the call.
126 // Measured 2026-10-08 in a stream-json SDK host shaped like the desktop app's
127 // Code tab (--permission-prompt-tool stdio): isInteractive was false and
128 // surfaces() empty, yet $.ui.ask reached the host as a can_use_tool request
129 // for AskUserQuestion. Under `claude -p` the ask rejects at once ("no tool
130 // named AskUserQuestion"), which is what marks a session with no one to ask.
131 // In the real desktop (a probe mod, same day) surfaces() listed `desktop` at
132 // load, was empty when a Bash call arrived, and listed `desktop` again after;
133 // the desktop drew a mod pane the person saw and answered the question.
134 surfaces = await $.session.surfaces();
135 if (surfaces.length > 0) {
136 const opened = await $.ui.open({ id: PANE_ID, title: "Blast Radius", focus: true, rows: paneRows(mine.report) });
137 paneOpened = true;
138 if (opened.isPlaced) {
139 mine.where = "pane";
140 } else if (surfaces.includes("terminal")) {
141 mine.where = "band"; // a narrow terminal: the report is drawn above the prompt
142 } else {
143 mine.where = "ask"; // a remote surface that places no panes
144 }
145 } else {
146 mine.where = "ask";
147 }
148 if (mine.where === "ask") {
149 // Not awaited: the poll loop below keeps the interrupt and the hold limit.
150 $.ui.ask(question(mine), { header: "Blast Radius", options: [PROCEED, CANCEL] }).then(
151 (answer) => {
152 if (mine.decision === null) {
153 mine.answer = String(answer);
154 mine.decision = mine.answer === PROCEED ? "proceed" : "answered";
155 }
156 },
157 (error) => {
158 if (mine.decision === null) {
159 mine.askError = String(error?.message ?? error).slice(0, 200);
160 mine.askRejectedAtPoll = mine.polls;
161 mine.decision = "ask-rejected";
162 }
163 },
164 );
165 }
166 $.ui.invalidate("ui.render");
167
168 const startedAt = await $.clock.now();
169 while (mine.decision === null) {
170 if (next.signal.aborted) {
171 mine.decision = "interrupted";
172 break;
173 }
174 if ((await $.clock.now()) - startedAt > holdSeconds * 1000) {
175 mine.decision = "timeout";
176 break;
177 }
178 await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 });
179 mine.polls += 1;
180 }
181 } catch {
182 mine.decision = "error"; // anything unexpected refuses the command
183 } finally {
184 decision = mine.decision;
185 // Close this call's pane before releasing the hold, so the next call's
186 // pane can't be the one that gets closed. Local change: an opened pane that
187 // waits unplaced is closed too, so it is not seated later with nothing held.
188 try {
189 if (paneOpened) {
190 await $.ui.close({ id: PANE_ID });
191 }
192 } catch {
193 // the pane is already gone
194 }
195 if (held === mine) {
196 held = null;
197 }
198 $.ui.invalidate("ui.render");
199 }
200
201 if (decision === "proceed") {
202 $.ui.toast("Blast Radius: running it");
203 return next(e);
204 }
205 // Local change: what a rejected question means. Only a session with no question
206 // dialog at all (`claude -p`: "no tool named AskUserQuestion") is one nobody can
207 // be asked in, and only that one honours BLAST_RADIUS_HEADLESS=allow. A rejection
208 // before the first poll ended is a dismissal straight away, or a host answering
209 // without a person (one that refuses questions, or approves every request with no
210 // answer); a person may be there, so it is refused, never run. Later, it was dismissed.
211 if (decision === "ask-rejected") {
212 if (/no tool named/i.test(mine.askError ?? "")) {
213 decision = "nobody";
214 } else {
215 decision = mine.askRejectedAtPoll === 0 ? "refused" : "dismissed";
216 }
217 }
218 // Local change: the signals that decided, so Claude can report them accurately.
219 const signals = `isInteractive=${sessionInteractive ?? "not seen"}, drawing surfaces: ${surfaces.length > 0 ? surfaces.join(", ") : "none"}, question rejected at once: ${mine.askError ?? "no reason given"}`;
220 if (decision === "nobody") {
221 if ((await $.env.get("BLAST_RADIUS_HEADLESS")) === "allow") {
222 $.ui.log(`Blast Radius: no one could be asked, BLAST_RADIUS_HEADLESS=allow, running: ${summary}`);
223 return next(e);
224 }
225 return {
226 deny: `Blast Radius held this command and did not run it: no one could be asked to approve it (${signals}). It would have: ${summary}. Do not retry it. Ask the user to run it themselves, or to start Claude Code with BLAST_RADIUS_HEADLESS=allow in Claude Code's own environment when an unattended run may delete; the mod reads it from that environment, so a VAR=value prefix on the Bash command does not reach it.`,
227 };
228 }
229 if (decision === "refused") {
230 return {
231 deny: `Blast Radius held this command and did not run it: its question was refused at once (${signals}). Either the user dismissed it straight away, or this host answers Claude Code's questions without a person. It would have: ${summary}. Do not retry it unless the user asks you to.`,
232 };
233 }
234 if (decision === "timeout") {
235 const where = mine.where === "ask" ? "the question; it may still be open, and answering it now does nothing" : "the pane";
236 return {
237 deny: `Blast Radius held this command for ${holdSeconds} s and nobody answered ${where}. It did not run. It would have: ${summary}. Do not retry it unless the user asks you to.`,
238 };
239 }
240 if (decision === "answered") {
241 const said = mine.answer === CANCEL ? "the user chose Cancel" : `the user answered "${mine.answer.slice(0, 300)}" instead of choosing Proceed`;
242 return {
243 deny: `Blast Radius held this command and did not run it: ${said}. It would have: ${summary}. Do not retry it unless the user asks you to; act on what they said.`,
244 };
245 }
246 const why = {
247 cancel: "the user pressed Cancel",
248 dismissed: "the user dismissed the Blast Radius question",
249 interrupted: "the turn was interrupted",
250 error: "Blast Radius hit an error while holding it",
251 }[decision] ?? "no answer was recorded";
252 return {
253 deny: `Blast Radius held this command and did not run it: ${why}. It would have: ${summary}. Do not retry it unless the user asks you to.`,
254 };
255 }).catch(($, e, next) => (next.called ? next(e) : { deny: "Blast Radius failed while checking this command, so it did not run. Do not retry it unless the user asks you to." }));
256
257 on("ui.render", { component: "Pane" }, ($, e, next) => {
258 if (e.requestId !== PANE_ID || held === null || held.report === null) {
259 return next(e);
260 }
261 return draw($.ui.resolve(e), held);
262 });
263
264 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
265 if (held === null || held.report === null || held.where !== "band") {
266 return next(e);
267 }
268 return draw($.ui.resolve(e), held);
269 });
270}
271
272// ---- What counts as risky -------------------------------------------------
273
274// sudo options that take a value, so the value isn't read as the command.
275const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
276// Commands that only read, so a bare word "migrate" in them isn't a migration.
277const READ_ONLY = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "find", "less", "head", "tail", "cd", "git"]);
278
279/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
280function joinDir(dir, arg) {
281 if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("~/")) {
282 return arg ?? "~";
283 }
284 return dir ? `${dir}/${arg}` : arg;
285}
286
287/** The first risky segment of a shell command, or null. */
288function classify(command) {
289 return scan(command, false).first;
290}
291
292/**
293 * Local change: the text with each `\`-continued line joined, the pair replaced
294 * by U+2028, which the splitter does not count as a new line and `tokenize`
295 * drops, as the shell drops the pair. So `rm -rf \⏎ build` is one rm,
296 * `foo\⏎bar` is the one word `foobar`, and the lines after it keep their
297 * numbers. It does not know quotes, so it is never read alone (see classifyCommand).
298 */
299function joinLines(text) {
300 return text.replace(/\\\n/g, "\u2028");
301}
302
303/**
304 * Local change: what the hook holds for a command line. The line is read as
305 * written and with its continued lines joined; a risk in either holds, so the
306 * joined reading can add a hold and never remove one. The joined reading's risk
307 * is the one reported, since it measures `rm -rf \⏎ build` as written. With no
308 * risk, the scripts both readings run by path come back as `{ kind: "scripts",
309 * list }`; with one, their paths ride on it as `alsoRuns`, since they are not
310 * read then.
311 */
312function classifyCommand(command) {
313 const joined = scan(joinLines(command), true);
314 const raw = scan(command, true);
315 const seen = new Set();
316 const scripts = [...joined.scripts, ...raw.scripts].filter((m) => {
317 const key = `${m.dir ?? ""}\0${m.path}`;
318 return seen.has(key) ? false : seen.add(key);
319 });
320 const first = joined.first ?? raw.first;
321 if (first !== null) {
322 first.alsoRuns = [...new Set(scripts.map((m) => m.path))];
323 return first;
324 }
325 return scripts.length > 0 ? { kind: "scripts", list: scripts } : null;
326}
327
328/**
329 * Local change: every risky segment of a script's text as `[{ line, risk }]`,
330 * by line. Read both ways, as classifyCommand does: the joined reading's lines,
331 * plus any line only the reading as written finds. It looks for no scripts, so
332 * a script is read one level deep.
333 */
334function riskyLines(text) {
335 const joined = scan(joinLines(text), false).found;
336 const raw = scan(text, false).found.filter((r) => !joined.some((j) => j.line === r.line));
337 return [...joined, ...raw].sort((x, y) => x.line - y.line);
338}
339
340/**
341 * The segments of a command: `first`, the first risky one or null; `found`,
342 * every risky one with its line number; and with `withScripts`, `scripts`, each
343 * script the line runs by path (Local change: upstream returned the first risk).
344 */
345function scan(command, withScripts) {
346 const found = [];
347 const scripts = [];
348 let first = null;
349 let line = 1;
350 let dir = null; // where a `cd` earlier on the line moved to; null means the session folder
351 const scopes = []; // dir to restore when a ( subshell ) closes
352 const pushed = []; // pushd stack, for popd
353 // Local change: NAME -> a value set earlier on the line, or null when it can't be
354 // known without running something. Saved and restored around ( subshells ).
355 const vars = new Map();
356 const varScopes = [];
357 // Local change: the separators are kept, so an assignment can be told apart by
358 // what runs it. Only one that follows `;` or a line start and feeds no pipe
359 // surely ran in this shell.
360 const parts = command.split(/(&&|\|\||;|\||\n)/);
361 for (let p = 0; p < parts.length; p += 2) {
362 if (p > 0 && parts[p - 1] === "\n") {
363 line += 1;
364 }
365 const raw = parts[p];
366 const certain = (p === 0 || parts[p - 1] === ";" || parts[p - 1] === "\n") && parts[p + 1] !== "|";
367 const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
368 // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
369 const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, "");
370 const closes = (tail.match(/\)+\s*$/)?.[0].trim().length) ?? 0;
371 for (let k = 0; k < opens; k += 1) {
372 scopes.push(dir);
373 varScopes.push(new Map(vars));
374 }
375 const risk = classifySegment(raw, dir, pushed, vars, certain, withScripts);
376 if (risk !== null && risk.cd !== undefined) {
377 dir = risk.cd; // a cd, pushd or popd moved the folder
378 } else if (risk !== null && risk.kind === "script") {
379 scripts.push(risk); // read later, in this order, if nothing on the line is risky itself
380 } else if (risk !== null) {
381 first ??= risk; // the scan goes on, for every risky line and every script
382 found.push({ line, risk });
383 }
384 line += (raw.match(/\u2028/g) ?? []).length; // continued lines inside this segment
385 for (let k = 0; k < closes && scopes.length > 0; k += 1) {
386 dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
387 const outer = varScopes.pop(); // nor does an assignment
388 vars.clear();
389 outer.forEach((value, name) => vars.set(name, value));
390 }
391 }
392 return { first, found, scripts };
393}
394
395// Words that can come before the real command without changing what it does.
396const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]);
397
398/** One segment: a risk, { cd } for a folder change, a script run by path when asked, or null. */
399function classifySegment(segment, dir, pushed, vars, certain, scripts = false) {
400 {
401 const notes = new Map(); // word -> what it left unexpanded (Local change)
402 const words = tokenize(segment.trim().replace(/^[({]+\s*/, "").replace(/\s*[)}]+$/, ""), vars, notes);
403 // Local change: a segment of plain assignments (`X=/path`, `export X=...`) sets
404 // a value later words expand. One that needs a command or another unknown
405 // variable is recorded as unknowable (null), never run.
406 const assigns = words[0] === "export" || words[0] === "local" || words[0] === "readonly" ? words.slice(1) : words;
407 if (assigns.length > 0 && assigns.every((w) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(w))) {
408 for (const w of assigns) {
409 const eq = w.indexOf("=");
410 vars.set(w.slice(0, eq), certain && !notes.has(w) ? w.slice(eq + 1) : null);
411 }
412 return null;
413 }
414 while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
415 words.shift(); // leading VAR=value
416 }
417 if (words[0] === "sudo") {
418 words.shift();
419 while (words.length > 0 && words[0].startsWith("-")) {
420 const option = words.shift();
421 if (SUDO_VALUE_OPTIONS.has(option)) {
422 words.shift();
423 }
424 }
425 }
426 while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) {
427 words.shift();
428 }
429 if (words[0] === "nice") {
430 words.shift();
431 if (words[0] === "-n") {
432 words.splice(0, 2);
433 } else if (/^-\d+$/.test(words[0] ?? "")) {
434 words.shift();
435 }
436 }
437 const [first, ...args] = words;
438 if (first === undefined) {
439 return null;
440 }
441 const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm
442 if (cmd === "cd") {
443 return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
444 }
445 if (cmd === "pushd") {
446 pushed.push(dir);
447 return { cd: joinDir(dir, args[0]) };
448 }
449 if (cmd === "popd") {
450 return { cd: pushed.length > 0 ? pushed.pop() : "-" };
451 }
452 if (cmd === "rm" || cmd.endsWith("/rm")) {
453 const flags = args.filter((a) => a.startsWith("-"));
454 const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f)));
455 const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f")));
456 if (recursive || force) {
457 const targets = args.filter((a) => !a.startsWith("-") || a === "-");
458 // Local change: targets the tokenizer could not expand are named, with why.
459 return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir, unexpanded: unexpandedWhy(targets, notes) };
460 }
461 }
462 if (cmd === "git") {
463 // Git's own options come before the subcommand; -C moves where it runs.
464 let gitDir = dir;
465 let i = 0;
466 while (i < args.length && args[i].startsWith("-")) {
467 if (args[i] === "-C" && i + 1 < args.length) {
468 gitDir = joinDir(gitDir, args[i + 1]);
469 i += 2;
470 } else if (args[i] === "-c" && i + 1 < args.length) {
471 i += 2;
472 } else {
473 i += 1;
474 }
475 }
476 const sub = args[i];
477 const rest = args.slice(i + 1);
478 if (sub === "reset" && rest.includes("--hard")) {
479 return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
480 }
481 if (sub === "clean") {
482 return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
483 }
484 if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) {
485 return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
486 }
487 const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
488 if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
489 return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir };
490 }
491 }
492 const joined = words.join(" ");
493 if (/\balembic\s+upgrade\b/.test(joined)) {
494 return { kind: "migrate", tool: "alembic", label: "alembic upgrade", dir };
495 }
496 if (/\bdb:migrate(?!:status\b)/.test(joined)) {
497 return { kind: "migrate", tool: "rails", label: "db:migrate", dir };
498 }
499 if (/\bprisma\s+migrate\b/.test(joined)) {
500 return { kind: "migrate", tool: "prisma", label: "prisma migrate", dir };
501 }
502 if (/\bmanage\.py\s+migrate\b/.test(joined)) {
503 return { kind: "migrate", tool: "django", label: "manage.py migrate", dir };
504 }
505 if (!READ_ONLY.has(cmd) && args.includes("migrate")) {
506 return { kind: "migrate", tool: "unknown", label: "migrate", dir };
507 }
508 if (scripts) {
509 return scriptInvocation(cmd, args, dir, notes);
510 }
511 }
512 return null;
513}
514
515/**
516 * Local change: a script the segment runs by path, or null. `bash x.sh` (also
517 * sh, zsh, dash, ksh, with options before the file), `. x.sh`, `source x.sh`,
518 * and a bare path such as `./x.sh` or `~/bin/x`. `bash -c "..."` and `bash -s`
519 * run no file, so they stay out of scope. A bare path is only a candidate here;
520 * whether it is a shell script is decided after reading it.
521 */
522function scriptInvocation(cmd, args, dir, notes) {
523 const base = cmd.slice(cmd.lastIndexOf("/") + 1);
524 let path;
525 let via;
526 if (cmd === "." || cmd === "source") {
527 via = cmd;
528 path = args[0];
529 } else if (SHELLS.has(base)) {
530 via = base;
531 for (let i = 0; i < args.length; i += 1) {
532 const a = args[i];
533 if (a === "--") {
534 path = args[i + 1];
535 break;
536 }
537 if (/^-[^-]*[cs]/.test(a)) {
538 return null; // -c runs a string, -s reads stdin
539 }
540 if (SHELL_OPTIONS_WITH_VALUE.has(a) || SHELL_OPTION_GROUP_WITH_VALUE.test(a)) {
541 i += 1; // `-euo pipefail`: the value is not the file
542 } else if (!a.startsWith("-") && !a.startsWith("+")) {
543 path = a;
544 break;
545 }
546 }
547 } else if (cmd.includes("/") && !cmd.startsWith("-")) {
548 via = "path";
549 path = cmd;
550 }
551 if (path === undefined || path === "" || /^(?:\d*[<>]|[&|])/.test(path)) {
552 return null;
553 }
554 return { kind: "script", path, via, dir, unexpanded: unexpandedWhy([path], notes) };
555}
556
557// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
558// The target is passed as an argument, never as source.
559const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && pwd -P`;
560
561async function resolveDir($, sessionCwd, dir) {
562 if (dir === "-") {
563 return null; // `cd -` depends on the shell's history
564 }
565 const run = await $.process.run(["bash", "-c", CD_SCRIPT, "blast-radius", dir], { cwd: sessionCwd, timeoutMs: 5000 });
566 const out = run.stdout.trim();
567 return run.exitCode === 0 && out !== "" ? out : null;
568}
569
570/**
571 * Splits one segment into words the way the shell does, honouring quotes and
572 * backslashes. Good enough to read flags and paths.
573 *
574 * Local change. Upstream split at every quote, so `rm -rf "$DIR"/*` read as the
575 * two targets `$DIR` and `/*`, the second one the filesystem root. Here the pieces
576 * of one word stay together. Outside single quotes, `$NAME` and `${NAME}` take a
577 * value set earlier on the line (`vars`), and `$HOME` at the start of a word,
578 * or of an assignment's value, becomes `~`. Every other expansion stays as written and is recorded in `notes`
579 * (word -> what it left, and why), so the caller can say it was not measured:
580 * an unset variable, one whose value is unknown, an unquoted value with spaces
581 * (the shell would split it), `$1` or `${X:-y}`, and `$(...)` or backticks, which
582 * are kept whole and never run. A `$` from single quotes or `\$` is a plain `$`.
583 */
584function tokenize(text, vars = new Map(), notes = new Map()) {
585 const words = [];
586 let word = null; // null between words
587 let left = []; // what this word left unexpanded
588 let i = 0;
589 const push = () => {
590 if (left.length > 0) {
591 notes.set(word, left);
592 }
593 words.push(word);
594 word = null;
595 left = [];
596 };
597 // Reads the expansion at text[i] (a `$` or a backtick): its value, or the text as written.
598 const expansion = (quoted) => {
599 if (text[i] === "`") {
600 const end = text.indexOf("`", i + 1);
601 const raw = end === -1 ? text.slice(i) : text.slice(i, end + 1);
602 i += raw.length;
603 left.push({ kind: "command", raw });
604 return raw;
605 }
606 if (text[i + 1] === "(") {
607 const raw = text.slice(i, closing(text, i + 1));
608 i += raw.length;
609 left.push({ kind: "command", raw });
610 return raw;
611 }
612 const m = /^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/.exec(text.slice(i));
613 if (m === null) {
614 const special = /^\$(?:[0-9?@*#$!-]|\{[^}]*\}?)/.exec(text.slice(i));
615 if (special === null) {
616 i += 1;
617 return "$"; // a lone $, as the shell leaves it
618 }
619 i += special[0].length;
620 left.push({ kind: "special", raw: special[0] });
621 return special[0];
622 }
623 i += m[0].length;
624 const name = m[1] ?? m[2];
625 if (!vars.has(name)) {
626 // Local change: also at the start of an assignment's value (`OUT="$HOME/x"`).
627 if (name === "HOME" && (word === "" || /^[A-Za-z_][A-Za-z0-9_]*=$/.test(word))) {
628 return "~";
629 }
630 left.push({ kind: "unset", name });
631 return m[0];
632 }
633 const value = vars.get(name);
634 if (value === null) {
635 left.push({ kind: "unknown", name });
636 return m[0];
637 }
638 if (!quoted && /\s/.test(value)) {
639 left.push({ kind: "split", name });
640 return m[0];
641 }
642 return value;
643 };
644 while (i < text.length) {
645 const c = text[i];
646 if (c === "\u2028") {
647 i += 1; // Local change: a joined `\`-newline, which the shell removes: `foo\⏎bar` is `foobar`
648 continue;
649 }
650 if (/\s/.test(c)) {
651 if (word !== null) {
652 push();
653 }
654 i += 1;
655 continue;
656 }
657 if (word === null) {
658 word = "";
659 }
660 if (c === "'") {
661 const end = text.indexOf("'", i + 1);
662 // Local change: single quotes keep a `\`-newline as written.
663 word += (end === -1 ? text.slice(i + 1) : text.slice(i + 1, end)).replace(/\u2028/g, "\\\n");
664 i = end === -1 ? text.length : end + 1;
665 } else if (c === '"') {
666 i += 1;
667 while (i < text.length && text[i] !== '"') {
668 if (text[i] === "\u2028") {
669 i += 1; // Local change: removed inside double quotes too
670 } else if (text[i] === "\\" && i + 1 < text.length && '"\\$`'.includes(text[i + 1])) {
671 word += text[i + 1];
672 i += 2;
673 } else if (text[i] === "$" || text[i] === "`") {
674 word += expansion(true);
675 } else {
676 word += text[i];
677 i += 1;
678 }
679 }
680 i += 1;
681 } else if (c === "\\") {
682 word += text[i + 1] ?? "";
683 i += 2;
684 } else if (c === "$" || c === "`") {
685 word += expansion(false);
686 } else {
687 word += c;
688 i += 1;
689 }
690 }
691 if (word !== null) {
692 push();
693 }
694 return words;
695}
696
697/** Local change: the index just past the `)` that closes the `(` at `open`, or the end. */
698function closing(text, open) {
699 let depth = 0;
700 for (let k = open; k < text.length; k += 1) {
701 const c = text[k];
702 if (c === "\\") {
703 k += 1;
704 } else if (c === "'") {
705 const end = text.indexOf("'", k + 1);
706 if (end === -1) {
707 return text.length;
708 }
709 k = end;
710 } else if (c === "(") {
711 depth += 1;
712 } else if (c === ")") {
713 depth -= 1;
714 if (depth === 0) {
715 return k + 1;
716 }
717 }
718 }
719 return text.length;
720}
721
722/**
723 * Local change: the rm targets the tokenizer could not expand, and why, or null
724 * when every target was expanded.
725 */
726function unexpandedWhy(targets, notes) {
727 const left = targets.filter((t) => notes.has(t));
728 if (left.length === 0) {
729 return null;
730 }
731 const reasons = new Set();
732 for (const t of left) {
733 for (const n of notes.get(t)) {
734 reasons.add({
735 command: () => `${n.raw} runs a command, which Blast Radius does not run`,
736 special: () => `${n.raw} is not a plain variable`,
737 unset: () => `$${n.name} is not set on this line, and Blast Radius does not read the shell's variables`,
738 unknown: () => `$${n.name} is set on this line from a command or another variable, or where it may not have run`,
739 split: () => `$${n.name} is unquoted and holds spaces, so the shell splits it into several paths`,
740 }[n.kind]());
741 }
742 }
743 return { targets: left, why: [...reasons].join("; ") };
744}
745
746// ---- Reading a script run by path (Local change) --------------------------
747
748/**
749 * Reads the script `marker` names and returns a risk of kind "script" with its
750 * risky lines, null when it was read and there is nothing to hold, or
751 * `{ skipped: true }` when it was not read, so a later hold can name it. A bare
752 * path read and found not to be a shell script was read, so it returns null.
753 * Which file a bare name runs is found by scriptFiles. A file is read only when
754 * it is a regular file, under SCRIPT_BYTES_MAX, and lands (every link followed)
755 * inside the session folder, the home folder or this session's scratchpad. A
756 * skip is logged with its reason, since no hold means no summary to say it in:
757 * to the transcript for a shell invocation or a path with a shell name, to the
758 * debug sink for any other bare path, which is usually a program (`/usr/bin/git`,
759 * `.venv/bin/python`) and would otherwise say "ran unchecked" on every call.
760 * One level: a script the script runs is not read. The file is read now and may
761 * differ by the time Proceed runs it.
762 */
763async function scriptRisk($, marker) {
764 const invoked = marker.via === "path" ? marker.path : `${marker.via} ${marker.path}`;
765 const loud = marker.via !== "path" || SHELL_NAME.test(marker.path);
766 const skip = (why) => {
767 $.ui.log(`Blast Radius did not read ${marker.path} (${why}), so \`${invoked}\` ran unchecked.`, { to: loud ? "transcript" : "debug" });
768 return { skipped: true };
769 };
770 if (marker.unexpanded !== null) {
771 return skip(`its path was not expanded: ${marker.unexpanded.why}`);
772 }
773 const sessionCwd = await $.session.cwd();
774 const home = (await $.env.get("HOME")) ?? "";
775 // A relative path needs the folder the line moved to; an absolute or ~ path does not.
776 let cwd = sessionCwd;
777 if (marker.dir && !marker.path.startsWith("/") && !marker.path.startsWith("~")) {
778 cwd = await resolveDir($, sessionCwd, marker.dir);
779 if (cwd === null) {
780 return skip(`the folder it is relative to, ${marker.dir}, was not found or could not be expanded without running a command`);
781 }
782 }
783 const found = await scriptFiles($, marker, cwd, home);
784 if (found.length === 0) {
785 return skip(marker.path.includes("/") || marker.path.startsWith("~") ? "no such file" : "no such file in the folder it runs in or on PATH");
786 }
787 const roots = await localRoots($, [sessionCwd, home]);
788 const id = await $.session.id().catch(() => "");
789 const check = async (stat) => {
790 const real = stat.realPath;
791 if (stat.kind !== "file") {
792 return skip(`${real} is not a regular file`);
793 }
794 if (!roots.some((root) => real.startsWith(`${root}/`)) && !(id !== "" && SCRATCHPAD(id).test(real))) {
795 return skip(`${real} is outside the session folder, your home folder and this session's scratchpad`);
796 }
797 if (stat.size > SCRIPT_BYTES_MAX) {
798 return skip(`${kib(stat.size)} is over the ${kib(SCRIPT_BYTES_MAX)} limit`);
799 }
800 const text = await $.fs.read(real).catch(() => undefined);
801 if (typeof text !== "string") {
802 return skip("it could not be read"); // no permission, or not text
803 }
804 const name = real.slice(real.lastIndexOf("/") + 1);
805 if (marker.via === "path" && !looksLikeShell(name, text)) {
806 // Read, so not a skip: a program in another language is out of scope, as `python x.py` is.
807 const first = text.split("\n", 1)[0];
808 const why = first.startsWith("#!") ? `${first.slice(0, 60)} is not a shell` : "it has no shell shebang and no .sh name";
809 $.ui.log(`Blast Radius read ${real} and did not check it (${why}), so \`${invoked}\` ran unchecked.`, { to: "debug" });
810 return null;
811 }
812 const lines = riskyLines(text);
813 const lineCount = text.split("\n").filter((l, i, arr) => i < arr.length - 1 || l !== "").length;
814 if (lines.length === 0) {
815 $.ui.log(`Blast Radius read ${real} (${lineCount} lines): nothing risky in it.`, { to: "debug" });
816 return null;
817 }
818 const more = lines.length - 1;
819 const label = `${lines[0].risk.label} in ${name} line ${lines[0].line}${more > 0 ? ` and ${more} more risky ${more === 1 ? "line" : "lines"}` : ""}`;
820 return { kind: "script", label, path: real, name, dir: marker.dir, invoked, lines, lineCount };
821 };
822 // Two copies can be found for a sourced name: the first risky one holds, and a
823 // skip of either is kept so a later hold can name it.
824 let result = null;
825 for (const stat of found) {
826 const read = await check(stat);
827 if (read !== null && read.skipped !== true) {
828 return read;
829 }
830 result = read ?? result;
831 }
832 return result;
833}
834
835/**
836 * Local change: the files `marker` may run, as stats that found something. A
837 * path with a `/` or a `~` names one file. A bare name is looked up as the shell
838 * does it. Measured 2026-10-08 on macOS, bash 3.2 and zsh 5.9: `source` and `.`
839 * try PATH first in bash and sh and with zsh's `.`, and zsh's `source` tries the
840 * folder first, so the first match on PATH and the folder's copy are both
841 * returned, in that order. `bash x.sh` and the other shells take the folder's
842 * copy, and only without one the first match on PATH (bash and sh look there,
843 * zsh does not). PATH is Claude Code's own, which may lack a folder that the Bash
844 * tool's shell profile adds.
845 */
846async function scriptFiles($, marker, cwd, home) {
847 const at = async (path) => {
848 const stat = await $.fs.stat(path, { resolve: true }).catch(() => undefined);
849 return stat?.realPath === undefined ? null : stat;
850 };
851 const here = await at(absolutePath(marker.path, cwd, home));
852 const bare = !marker.path.includes("/") && !marker.path.startsWith("~");
853 const sourced = marker.via === "." || marker.via === "source";
854 if (!bare || (here !== null && !sourced)) {
855 return here === null ? [] : [here];
856 }
857 let onPath = null;
858 for (const dir of ((await $.env.get("PATH")) ?? "").split(":")) {
859 if (dir.startsWith("/")) {
860 const stat = await at(`${dir.replace(/\/+$/, "")}/${marker.path}`);
861 if (stat?.kind === "file") {
862 onPath = stat;
863 break;
864 }
865 }
866 }
867 return [onPath, here].filter((s, i, all) => s !== null && all.findIndex((t) => t?.realPath === s.realPath) === i);
868}
869
870/** `path` as an absolute path: `~` from `home`, a relative path under `cwd`. */
871function absolutePath(path, cwd, home) {
872 if (path === "~") {
873 return home;
874 }
875 if (path.startsWith("~/")) {
876 return `${home}/${path.slice(2)}`;
877 }
878 return path.startsWith("/") ? path : `${cwd}/${path.replace(/^(?:\.\/)+/, "")}`;
879}
880
881/** Where each of `dirs` lands, every link followed, for an allow-list on real paths. */
882async function localRoots($, dirs) {
883 const roots = [];
884 for (const dir of dirs) {
885 if (!dir) {
886 continue;
887 }
888 const stat = await $.fs.stat(dir, { resolve: true }).catch(() => undefined);
889 const root = (stat?.realPath ?? dir).replace(/\/+$/, "");
890 if (root !== "") {
891 roots.push(root);
892 }
893 }
894 return roots;
895}
896
897/** Whether a file run by its bare path is a shell script: by its shebang, or its name. */
898function looksLikeShell(name, text) {
899 if (SHELL_NAME.test(name)) {
900 return true;
901 }
902 const first = text.split("\n", 1)[0];
903 if (!first.startsWith("#!")) {
904 return false;
905 }
906 const words = first.slice(2).trim().split(/\s+/);
907 let program = words[0] ?? "";
908 if (program.endsWith("/env") || program === "env") {
909 program = words.find((w, i) => i > 0 && !w.startsWith("-")) ?? "";
910 }
911 return SHELLS.has(program.slice(program.lastIndexOf("/") + 1));
912}
913
914function kib(bytes) {
915 return `${Math.round(bytes / 1024)} KiB`;
916}
917
918// ---- Measuring the blast radius -------------------------------------------
919
920/** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */
921async function measure($, risk, cwd) {
922 try {
923 if (risk.kind === "rm") {
924 return await measureRm($, risk, cwd);
925 }
926 if (risk.kind === "migrate") {
927 return await measureMigrations($, risk, cwd);
928 }
929 if (risk.kind === "script") {
930 return await measureScript($, risk, cwd);
931 }
932 return await measureGit($, risk, cwd);
933 } catch (error) {
934 return { summary: `${risk.label} (could not measure it)`, lines: [], note: `Could not measure: ${String(error?.message ?? error).slice(0, 200)}` };
935 }
936}
937
938// The paths are passed to bash as arguments, never as source, so nothing in
939// them runs. compgen -G expands a glob without command substitution.
940const RM_SCRIPT = `
941shopt -s nullglob dotglob
942paths=()
943for p in "$@"; do
944 case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
945 if [[ "$p" == *[*?[]* ]]; then
946 while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
947 elif [[ -e "$p" || -L "$p" ]]; then
948 paths+=("$p")
949 fi
950done
951if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
952# A relative path gets ./ in front, so find never reads a name like -delete as an action.
953for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
954files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
955kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
956echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
957find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
958`;
959
960
961async function measureRm($, risk, cwd) {
962 if (risk.targets.length === 0) {
963 return { summary: "rm with no paths", lines: [], note: "No paths to expand." };
964 }
965 // Local change: targets Blast Radius could not expand are not measured, and the
966 // summary says so instead of "delete nothing". Upstream measured the literal `$X`.
967 const skipped = risk.unexpanded;
968 const measurable = skipped === null ? risk.targets : risk.targets.filter((t) => !skipped.targets.includes(t));
969 if (measurable.length === 0) {
970 return {
971 summary: `delete what ${skipped.targets.join(" ")} matches: not measured, because ${skipped.why}`,
972 lines: [],
973 note: "Blast Radius does not run commands or read the shell's variables to expand a path, so it could not count what this would delete.",
974 };
975 }
976 const report = await measureRmPaths($, { ...risk, targets: measurable }, cwd);
977 if (skipped !== null) {
978 report.summary += `; and what ${skipped.targets.join(" ")} matches, not measured, because ${skipped.why}`;
979 }
980 return report;
981}
982
983async function measureRmPaths($, risk, cwd) {
984 const run = await $.process.run(["bash", "-c", RM_SCRIPT, "blast-radius", ...risk.targets], { cwd, timeoutMs: 15000 });
985 const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
986 const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
987 if (!found) {
988 return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so rm has nothing to remove." };
989 }
990 if (!files) {
991 return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${risk.targets.join(" ")}` };
992 }
993 return {
994 summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
995 lines: rest.map((l) => l.replace(/^\.\//, "")),
996 more: Math.max(0, files - rest.length),
997 note: `Paths: ${risk.targets.join(" ")}`,
998 };
999}
1000
1001/**
1002 * Local change: each risky line of a script, measured as the same text inline
1003 * would be, from `cwd` (where the script runs) moved by any `cd` above the line
1004 * in the script. The first SCRIPT_MEASURE_MAX lines are measured; the rest are
1005 * named. A line's variables are filled in only from plain assignments above it
1006 * and $HOME; the rest is said to be unmeasured, as for a command line.
1007 */
1008async function measureScript($, risk, cwd) {
1009 const parts = [];
1010 const lines = [];
1011 const notes = [`Read from ${risk.path} (${risk.lineCount} lines); risky ${risk.lines.length === 1 ? "line" : "lines"}: ${risk.lines.map((l) => l.line).join(", ")}.`];
1012 let total = 0;
1013 for (const { line, risk: r } of risk.lines.slice(0, SCRIPT_MEASURE_MAX)) {
1014 const where = r.dir ? await resolveDir($, cwd, r.dir) : cwd;
1015 if (where === null) {
1016 parts.push(`line ${line} (${r.label}) is not measured: the folder ${r.dir} was not found`);
1017 continue;
1018 }
1019 const report = await measure($, r, where);
1020 parts.push(`line ${line} (${r.label}) would ${report.summary}`);
1021 total += report.lines.length + (report.more ?? 0);
1022 lines.push(...report.lines.map((l) => `line ${line}: ${l}`));
1023 if (report.note) {
1024 notes.push(`Line ${line}: ${report.note}`);
1025 }
1026 }
1027 const rest = risk.lines.slice(SCRIPT_MEASURE_MAX);
1028 if (rest.length > 0) {
1029 parts.push(`${rest.length} more risky ${rest.length === 1 ? "line" : "lines"} (${rest.map((l) => l.line).join(", ")}) ${rest.length === 1 ? "is" : "are"} not measured`);
1030 }
1031 const shown = lines.slice(0, LIST_MAX);
1032 return { summary: `run ${risk.name}, where ${parts.join("; ")}`, lines: shown, more: Math.max(0, total - shown.length), note: notes.join(" ") };
1033}
1034
1035async function measureGit($, risk, cwd) {
1036 if (risk.kind === "git-push-force") {
1037 return await measurePush($, risk, cwd);
1038 }
1039 if (risk.kind === "git-clean") {
1040 const flags = [];
1041 const paths = [];
1042 for (let i = 0; i < risk.args.length; i += 1) {
1043 const a = risk.args[i];
1044 if (a === "--") {
1045 paths.push(...risk.args.slice(i + 1));
1046 break;
1047 }
1048 if (a === "-e" || a === "--exclude") {
1049 flags.push(a, risk.args[i + 1] ?? "");
1050 i += 1;
1051 } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
1052 flags.push(a);
1053 } else if (/^-[a-zA-Z]+$/.test(a)) {
1054 const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
1055 if (kept !== "-") {
1056 flags.push(kept);
1057 }
1058 } else if (!a.startsWith("-")) {
1059 paths.push(a);
1060 }
1061 }
1062 const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
1063 if (run.exitCode !== 0) {
1064 return { summary: "git clean (could not dry-run it)", lines: [], note: run.stderr.trim().slice(0, 200) };
1065 }
1066 const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
1067 return {
1068 summary: gone.length === 0 ? "remove nothing: no untracked files match" : `remove ${gone.length} untracked ${gone.length === 1 ? "path" : "paths"}`,
1069 lines: gone.slice(0, LIST_MAX),
1070 more: Math.max(0, gone.length - LIST_MAX),
1071 note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
1072 };
1073 }
1074 const status = await $.process.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
1075 if (status.exitCode !== 0) {
1076 return { summary: `${risk.label} (not a git repo here?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
1077 }
1078 const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
1079 // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
1080 const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
1081 const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
1082 return {
1083 summary: lost.length === 0 ? "discard nothing: no uncommitted changes" : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? "file" : "files"}`,
1084 lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
1085 more: Math.max(0, lost.length - LIST_MAX),
1086 note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : "From git status --porcelain.",
1087 };
1088}
1089
1090async function measurePush($, risk, cwd) {
1091 const positional = risk.args.filter((a) => !a.startsWith("-"));
1092 const remote = positional[0] ?? "origin";
1093 // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
1094 const spec = (positional[1] ?? "").replace(/^\+/, "");
1095 let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
1096 branch = (branch ?? "").replace(/^refs\/heads\//, "");
1097 if (!branch) {
1098 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
1099 branch = head.stdout.trim();
1100 source = "HEAD";
1101 } else if (branch === "HEAD") {
1102 // `git push origin HEAD` pushes the current branch to its namesake.
1103 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
1104 branch = head.stdout.trim();
1105 source = "HEAD";
1106 }
1107 source = source || "HEAD";
1108 const ref = `${remote}/${branch}`;
1109 const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
1110 if (known.exitCode !== 0) {
1111 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.` };
1112 }
1113 const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
1114 const dropped = log.stdout.split("\n").filter((l) => l !== "");
1115 return {
1116 summary: dropped.length === 0 ? `force-push to ${ref}: drops no commits` : `force-push to ${ref}: drops ${dropped.length} ${dropped.length === 1 ? "commit" : "commits"}`,
1117 lines: dropped.slice(0, LIST_MAX),
1118 more: Math.max(0, dropped.length - LIST_MAX),
1119 note: `Commits on ${ref} that ${source} doesn't have, as of the last fetch.`,
1120 };
1121}
1122
1123const MIGRATION_LISTERS = {
1124 django: { argv: ["python3", "manage.py", "showmigrations", "--plan"], pending: (l) => l.startsWith("[ ]"), strip: (l) => l.slice(4) },
1125 alembic: { argv: ["alembic", "history", "-r", "current:head"], pending: (l) => l.includes("->"), strip: (l) => l },
1126 rails: { argv: ["bin/rails", "db:migrate:status"], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
1127 prisma: { argv: ["npx", "--no-install", "prisma", "migrate", "status"], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
1128};
1129
1130async function measureMigrations($, risk, cwd) {
1131 const lister = MIGRATION_LISTERS[risk.tool];
1132 if (lister === undefined) {
1133 return { summary: "run migrations", lines: [], note: "I can't list the pending migrations for this tool, so the list is not shown." };
1134 }
1135 let run;
1136 try {
1137 run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 });
1138 } catch (error) {
1139 run = { exitCode: -1, stdout: "", stderr: String(error?.message ?? error) };
1140 }
1141 if (run.exitCode !== 0) {
1142 return { summary: `run ${risk.label}`, lines: [], note: `Couldn't list pending migrations (${lister.argv.join(" ")} failed).` };
1143 }
1144 const pending = run.stdout.split("\n").filter(lister.pending).map(lister.strip);
1145 return {
1146 summary: pending.length === 0 ? `run ${risk.label}: nothing pending` : `apply ${pending.length} pending ${pending.length === 1 ? "migration" : "migrations"}`,
1147 lines: pending.slice(0, LIST_MAX),
1148 more: Math.max(0, pending.length - LIST_MAX),
1149 note: `From ${lister.argv.join(" ")}.`,
1150 };
1151}
1152
1153function size(bytes) {
1154 if (!Number.isFinite(bytes) || bytes < 1024) {
1155 return `${bytes || 0} B`;
1156 }
1157 const units = ["KB", "MB", "GB", "TB"];
1158 let n = bytes;
1159 let i = -1;
1160 while (n >= 1024 && i < units.length - 1) {
1161 n /= 1024;
1162 i += 1;
1163 }
1164 return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
1165}
1166
1167// ---- Drawing --------------------------------------------------------------
1168
1169/**
1170 * Local change: the text of the engine's question dialog, for a session where no
1171 * surface draws the pane. The command, what it would do, and the first few paths.
1172 */
1173function question(state) {
1174 const { report } = state;
1175 const command = state.command.length > 300 ? `${state.command.slice(0, 300)}…` : state.command;
1176 const shown = report.lines.slice(0, QUESTION_LIST_MAX);
1177 const rest = report.lines.length - shown.length + (report.more ?? 0);
1178 const list = shown.length > 0 ? ` That includes ${shown.join(", ")}${rest > 0 ? ` and ${rest} more` : ""}.` : "";
1179 const note = report.note ? ` ${/[.?!]$/.test(report.note) ? report.note : `${report.note}.`}` : "";
1180 return `Blast Radius held \`${command}\`. It would ${report.summary}.${list}${note} Run it?`;
1181}
1182
1183function paneRows(report) {
1184 return Math.min(24, 9 + report.lines.length + (report.more ? 1 : 0));
1185}
1186
1187function draw(t, state) {
1188 const { Box, Text, Button } = t;
1189 const { report } = state;
1190 const list = report.lines.map((line, i) => Text({ key: `l${i}`, children: ` ${line}`, wrap: "truncate-end" }));
1191 if (report.more) {
1192 list.push(Text({ key: "more", dimColor: true, children: ` + ${report.more} more` }));
1193 }
1194 // The buttons answer the call this pane was drawn for, never whichever one is held now.
1195 const decide = (choice) => () => {
1196 if (state.decision === null) {
1197 state.decision = choice;
1198 }
1199 };
1200 return Box({