SLOPSHOPPER

blast-radius

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…

newpanebandguardtoastprocess
★ 2v0.1.0+aww.3MITupdated 2026-10-08nino-chavez/agentic-ways-of-working/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 │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ⚠ Blast Radius · rm -rf ⏺ Read(src/auth.ts) │ ┃ │ Command rm -rf build && git push --for… ⎿ Read 6 lines │ ┃ │ Would delete nothing: no file matches ⏺ Update(src/auth.ts) │ ┃ │ build ⎿ Added 2 lines, removed 1 line │ ┃ │ ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ │ The paths don't exist, so rm has nothing ⎿ Denied by blast-radius: Blast Radius held this command fo │ ┃ │ to remove. │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 1: Proceed 2: Cancel Claude is waiting o │ ┃ ╰─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Blast Radius · while holding a tool call
╭──────────────────────────────────────────────────────────╮ │ ⚠ Blast Radius · rm -rf │ │ Command rm -rf build && git push --force origin main │ │ Would delete nothing: no file matches build │ │ │ │ The paths don't exist, so rm has nothing to remove. │ │ │ │ 1: Proceed 2: Cancel Claude is waiting on your answer │ ╰──────────────────────────────────────────────────────────╯
README

Blast Radius

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.

What this shows

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.

CommandWhat the pane shows
rm -r, rm -f, rm -rfThe files it would delete, with the count and total size. Globs and ~ are expanded.
git reset --hardThe files with uncommitted changes, from git status --porcelain, and git diff --shortstat.
git checkout -- ., git restore .The files with unstaged changes.
git cleanThe 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 migrateThe pending migrations, from the tool's own status command.
any other migrateA 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.shLocal 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:

  • Holding a tool.call until the user answers, and returning { deny } with a reason Claude can act on.
  • Drawing the same report in a Pane, or in the AbovePrompt band when the terminal is too narrow for a pane.
  • Measuring with $.process.run, passing paths as arguments so nothing in them runs as shell.

Demo

rm -rf build held, with the files it would delete:

Blast Radius holding rm -rf build in a pane

After Cancel, Claude reports that nothing was deleted:

Claude reports the command was cancelled

The second try, after Proceed:

Claude reports the folder was deleted after Proceed

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

Blast Radius showing git reset --hard in the band above the prompt

How it was built

  • Model: built with Claude in Claude Code. The test runs and screenshots used Claude Sonnet 5. The mod itself doesn't call a model.
  • Prompt(s): the mod started as one of ten ideas Claude wrote for mods. This is the idea as written:

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 uses tool.call to hold the call, and ui.render on Pane, with Button elements. 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.

  • Transcript: not shared. The build ran in an internal workspace.
  • Iterations:
  • A hook has a 10-second budget for its own code, which is far too short to wait for a person. Time spent inside a $ 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.
  • A pane needs room. In a narrow terminal Claude Code doesn't place it, so the mod checks isPlaced and draws the report in the AbovePrompt band instead.
  • Cancel has the focus when the pane opens, so pressing Enter refuses the command rather than running it.
  • An independent review before release found two problems in the measuring step, both fixed. A file named like a 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.
  • Tested in Claude Code, before those fixes: 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.

Run it

Requirements:

  • Claude Code 2.1.287 or later, where mods load by default. The mod was built and tested on 2.1.280, and 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).
  • For the side pane, a terminal about 144 columns wide or more. Narrower terminals get the band above the prompt.

No environment variables or configuration.

Steps:

  1. Clone this repository and go to this folder's parent:
   git clone https://github.com/anthropics/claude-code-playground.git
   cd claude-code-playground/claude-code/mods
  1. Check the plugin:
   claude plugin validate ./blast-radius
  1. Try it for one session:
   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.

  1. Ask Claude to run something risky in a throwaway repo, for example "delete the build folder with rm -rf build".

Using the pane:

  • Press 1 for Proceed or 2 for Cancel. You can also click a button, or Tab to it and press Enter.
  • In the band above the prompt, 1 or 2 works while the input is empty.
  • If nobody answers within 10 minutes, the command is refused.
  • If you interrupt the turn (Esc), the command is refused.

Notes / limitations

  • It reads the command text, and (a local change) a script the command runs by path. It doesn't parse shell fully: $(...), 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).
  • Only the first risky part of a command line is measured, and the pane shows the command on one line, cut off if it's long. Proceed runs the whole line as written.
  • It follows 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.
  • In a narrow terminal the report is drawn in the band above the prompt, which only one mod can use at a time. If another mod that draws there (such as Replay Theater or Token Weather in this folder) takes the band, the Proceed and Cancel buttons may not show, and the command is refused after 10 minutes. Use a wider terminal, or turn the other mod off, when you rely on Blast Radius.
  • One command is held at a time. A second risky call, from a subagent for example, waits until the first is answered.
  • It only watches the Bash tool. File edits and other tools aren't held.
  • The 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.
  • The force-push list uses your last fetch of the remote branch. Without one, it can't list the dropped commits, and it says so. When the push names no remote, it assumes origin.
  • To list pending migrations, it runs the tool's own status command (for example 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.
  • A few harmless commands are held too, such as a commit whose message contains ; rm -rf.
  • It checks one command at a time. It holds a call, but it doesn't sandbox it: after you press Proceed, the command runs as written.
  • This is a safety net, not a permission system. Use permission rules for a hard block.

Dependencies

NameVersionLicense (SPDX)Source
None

The mod has no packages to install. It calls tools that are already on the machine (listed under Requirements).

Third-party notices

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.

Local changes

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.

  • Where no surface is listed, Claude Code's own question dialog holds the command. In the desktop app's Code tab the mod sees 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.
  • Sessions with no one to ask still deny at once. Under 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.
  • The hold has a configurable limit. 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.
  • A script run by path is read, and its risky lines hold the command. Upstream read only the command text, so 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.
  • The file must be a regular file of 256 KiB or less, and must land, every link followed, inside the session folder, your home folder, or this session's scratchpad (/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.
  • A bare name such as 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.
  • A ;, | 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.
  • Every script on the line is read, in order, until one is risky; a clean or skipped one does not end the check. One level: a script that the script runs is not read. A risk written on the command line itself wins, and no script is then read. The summary names each script on the line that was not read: skipped before the held one, after it, or beside an inline risk. It reads "Proceed also runs wipe, wipe-db.sh, which were not read", so Proceed is not taken as approving only what was shown. A script that was read, clean or not a shell script, is not named. A file that cannot be read is skipped with that reason, never refused as a failure. The script is read when the command is held; it may differ by the time Proceed runs it. Control flow is not modelled: an assignment or cd inside an if that would not run is still used, and the shell text inside a heredoc body is read as commands.
  • A continued line is read both ways. A command or script is read as written and again with each line ending in \ 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.
  • A hook failure refuses the command. Upstream let it run.
  • To load it for every session, add its folder to 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.

Source 1 files
hooks/blast-radius.mjs 1224 lines
1// 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({