Holds risky Bash and PowerShell commands (rm -rf, Remove-Item -Recurse, git reset --hard, git clean, force push, migrations) and shows what they would change…


See what a command will touch, before it runs. A Claude Code mod that holds risky shell commands, shows what they would change, and waits for your Proceed or Cancel. It works for Bash and PowerShell, and it is built to run on Windows.
When Claude proposes rm -rf build, Remove-Item -Recurse -Force build or git reset --hard, the command stops. A pane opens with the files it would delete, the count and the size. Cancel is pre-selected, so a stray Enter refuses the command. Claude gets the refusal and the reason.

Based on Blast Radius from
anthropics/claude-code-playground(Apache-2.0). Not an official Anthropic product. See what changed.
In a terminal session of Claude Code:
/plugin install holdpane --marketplace FlyinSteve/holdpane
Answer y to add the marketplace, then pick a scope (user scope loads it in every session). From a shell:
claude plugin install holdpane --marketplace FlyinSteve/holdpane
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with FlyinSteve/holdpane, then Add plugin.
To remove it: claude plugin uninstall holdpane.
Try it: see Examples.
Using the pane: press 2 to Cancel, or click Proceed (or Tab to it and press Enter). Proceed has no hotkey on purpose, so a typed 1 can never run a command. No answer within 10 minutes refuses the command, and so does interrupting the turn (Esc).
In a throwaway folder (a copy of a project is fine), ask Claude:
build folder with rm -rf build." On PowerShell: "Delete the build folder with Remove-Item -Recurse -Force build."git reset --hard to throw away my uncommitted changes." The pane lists the files with uncommitted changes.git clean -fd to remove everything untracked." The pane lists what git clean -n would remove.Press 2 to Cancel in each case, and check that Claude reports the refusal.
| Tool | Commands |
|---|---|
| Bash, PowerShell | git reset --hard, git checkout -- . and -f, git switch -f, git restore ., git clean, git push --force (also -fu, +ref), migrations (manage.py migrate, alembic upgrade, db:migrate, prisma migrate) |
| Bash | rm -r, rm -f, xargs rm -rf, bash -c '...', powershell -Command, cmd /c rd /s, anything after a single & |
| PowerShell | Remove-Item (also rm, ri, del, rd, erase) with -Recurse or -Force, abbreviated (-r -fo) or across lines with backticks, $x = ..., void, Start-Process git -ArgumentList ..., cmd /c rd /s/q, powershell -Command |
| Monitor | the command, read as Bash and as PowerShell |
| all | command lines over 8000 characters, -EncodedCommand and Remove-Item @splat are held without being read |
Every other command runs as normal. If a cd, pushd, Set-Location or git -C comes first on the line, Holdpane measures in that folder.
| Command | What you see |
|---|---|
rm, Remove-Item, rd /s | the files it would delete, with count and size; globs, -LiteralPath, ~ (in Bash) and Windows paths (C:\... and C:/...) are resolved |
git reset --hard, git checkout -- . | the files with uncommitted changes and the diff summary |
git clean | the untracked paths from git clean -n with the same flags |
git push --force | the commits on the remote branch that your branch doesn't have (from your last fetch) |
| migrations | the tool's own status command to check by hand (see below) |
| anything it cannot measure | an honest note instead of a guess: variables, ( ) expressions, %VAR%, network paths and del /s are reported as not measurable, never as "deletes nothing" |
While it measures, Holdpane starts only these programs, and only to read: git (status, diff, log, clean -n, rev-parse), Git's bash, find, du and sleep, and powershell.exe (pwsh on macOS and Linux). It sends nothing anywhere: there is no network access, no telemetry, and it stores nothing. A command you answer Proceed to is run by Claude Code, not by Holdpane.
The original is a good small example, written for macOS and Linux. This version:
-Recurse:$false, -WhatIf, backtick line continuation, script blocks like try{...}, comments, here-strings, Start-Process, nested powershell -Command and cmd /c.bash, find, du and sleep through git --exec-path. On a normal Windows PATH, find is the Windows find and bash can be the WSL launcher, which would measure wrongly.powershell.exe. Paths travel as ASCII JSON on stdin, so umlauts survive and nothing in a path can run as code. Junctions and symbolic links are counted, not followed..catch: if the mod itself throws, the command is refused instead of slipping through. Command lines over 8000 characters are held unread, and the line reader runs in bounded time on hostile input.core.fsmonitor=false. Network paths (\\server\share) are not touched.;, |, &&, ||, & and line breaks, but not inside quotes. Quotes in comments, heredocs and here-strings don't confuse it, and if a quote is left open it reads the line again without regard to quotes.bash, find, du, sleep) and Windows PowerShell 5.1, which is part of Windows. Tested on Windows 11 with Git 2.55.pwsh.This is a safety net, not a permission system. It reads the text of a command, it does not run a shell parser, and it cannot see what a script does. Use permission rules for a hard block.
It has been tested on a real Windows setup, including live sessions with Claude Code, and reviewed twice by an AI reviewer (Claude Opus). That is not an independent security audit.
Known limits:
Invoke-Expression, eval, [IO.Directory]::Delete, scripts and functions that delete by themselves, find -delete and find -exec rm, commands inside $(...) or aliases, a backslash-escaped space in a Bash program path (Program\ Files), -WhatIf:0.rm -rf build⏎git push --force shows only the first part.Remove-Item through a pipeline and del /s are held but not measured. -Include, -Exclude and -Filter are not applied when counting.' or {} may be reported as "not found". The command is still held.filter.<name>.clean) from a changed .git/config could run code during git status. Claude Code protects .git, so this is theoretical.git and powershell.exe are found through PATH, not by fixed paths.claude -p), a held command waits the full 10 minutes.Questions, bugs, and commands that should have been held: open an issue. Security problems: see SECURITY.md. To help, see CONTRIBUTING.md; changes are listed in CHANGELOG.md.
Holdpane hält riskante Befehle an, bevor sie laufen, und zeigt, was sie treffen würden: rm -rf, Remove-Item -Recurse -Force, git reset --hard, git clean, Force-Push und Migrationen. In einer Pane siehst du die betroffenen Dateien mit Anzahl und Größe und entscheidest mit Proceed oder Cancel. Cancel ist vorausgewählt (Taste 2); Proceed hat bewusst keine Taste.
Gegenüber dem Original (macOS/Linux) funktioniert es mit Bash und PowerShell unter Windows: Es findet Git-Bash, find und du selbst, misst PowerShell-Löschungen nativ (Umlaute, Junctions, C:\-Pfade), liest auch mehrzeilige und verschachtelte Befehle und lehnt einen Befehl ab, wenn der Mod selbst einen Fehler hat. Die Messung liest nur: Sie führt keine Werkzeuge deines Projekts aus, bevor du geantwortet hast.
Installation im Claude-Code-Terminal: /plugin install holdpane --marketplace FlyinSteve/holdpane
Es startet nur git, bash, find, du, sleep und powershell.exe, liest nur, sendet nichts und speichert nichts. Es ist ein Sicherheitsnetz, kein Berechtigungssystem: Harte Sperren gehören in die Permission-Regeln. Die Grenzen stehen oben unter Known limits.
Apache License 2.0, see LICENSE and NOTICE. Based on "Blast Radius", Copyright 2026 Anthropic PBC, from anthropics/claude-code-playground. Modifications Copyright 2026 FlyinSteve. Not affiliated with or endorsed by Anthropic. Git is a trademark of Software Freedom Conservancy; PowerShell and Windows are trademarks of Microsoft.
hooks/holdpane.mjs 1291 lines1// Based on Blast Radius from anthropics/claude-code-playground (Apache-2.0,
2// Copyright 2026 Anthropic PBC). Changed for Windows and PowerShell.
3//
4// Holdpane: holds a risky Bash or PowerShell command and shows what it would change.
5//
6// tool.call (Bash, PowerShell): if the command is risky, work out its blast
7// radius, open a pane with Proceed and Cancel, and hold the call until one is
8// pressed. A hook that fails is skipped by the host and the tool then runs, so
9// both registrations carry a `.catch` that refuses in the hook's place.
10// ui.render (Pane): draws the report. If the surface won't place the pane (a
11// narrow terminal), the same report is drawn in the AbovePrompt band instead.
12//
13// Holding: a hook has 10 s of its own time, but time spent inside a `$` call is
14// free. So the hold loop waits on a short `$.process.run` of a sleep until a
15// button's onPress sets the decision. ($.clock.sleep would use up the budget.)
16//
17// Measuring happens BEFORE the user has answered, so it only reads: it never runs
18// the project's own tools (migration status commands run project code), and git
19// runs with fsmonitor off.
20//
21// Windows: the tools are looked up, not assumed. Bash measuring runs in Git's
22// bash (found through `git --exec-path`), and its scripts put /usr/bin first on
23// PATH, so `find` is not the Windows find. PowerShell measuring runs in
24// powershell.exe, with the paths passed as ASCII JSON on stdin, never as source.
25//
26// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
27// spelled literally, and helpers that take `$` are top-level functions.
28
29const PANE_ID = "holdpane";
30const POLL_SECONDS = "0.25";
31const HOLD_LIMIT_MS = 10 * 60 * 1000;
32const STALE_SLACK_MS = 30 * 1000;
33const LIST_MAX = 10;
34// Longer command lines are held without being read: the reading is where a
35// pathological input could cost time, and no real risky command is this long.
36const MAX_COMMAND = 8000;
37// git for the measuring: fsmonitor runs a program named in the repository's config.
38const GIT = ["git", "-c", "core.fsmonitor=false", "-c", "log.showSignature=false"];
39
40// The call being held, or null. One at a time: Bash calls in a turn run in order.
41let held = null;
42// Where bash, sleep and PowerShell live on this machine; looked up on first use.
43let tools = null;
44
45export function register(on) {
46 on("tool.call", { tool: "Bash" }, async ($, e, next) => hold($, e, next, "bash")).catch(($, e, next) => refuse(e, next));
47 on("tool.call", { tool: "PowerShell" }, async ($, e, next) => hold($, e, next, "ps")).catch(($, e, next) => refuse(e, next));
48 // Monitor runs a shell command too; which shell is not known here, so it is read as both.
49 on("tool.call", { tool: "Monitor" }, async ($, e, next) => hold($, e, next, "any")).catch(($, e, next) => refuse(e, next));
50
51 on("ui.render", { component: "Pane" }, ($, e, next) => {
52 if (e.requestId !== PANE_ID || held === null || held.report === null) {
53 return next(e);
54 }
55 return draw($.ui.resolve(e), held);
56 });
57
58 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
59 if (held === null || held.report === null || held.where !== "band") {
60 return next(e);
61 }
62 return draw($.ui.resolve(e), held);
63 });
64}
65
66/** What the `.catch` answers when the hook itself failed: refuse, unless the command was already let through. */
67function refuse(e, next) {
68 return next.called ? next(e) : { deny: "Holdpane hit an error and did not run this command. Do not retry it unless the user asks you to." };
69}
70
71async function hold($, e, next, shell) {
72 const text = String(e.command ?? "");
73 const risk = shell === "any" ? (classify(text, "bash", null) ?? classify(text, "ps", null)) : classify(text, shell, null);
74 if (risk === null) {
75 return next(e);
76 }
77 // One hold at a time. If another risky call is already held (a subagent's,
78 // say), wait until it is answered. `held` is claimed with no await between
79 // the check and the claim, so two waiting calls can't both get through.
80 let since = await $.clock.now();
81 const waitStart = since;
82 while (held !== null) {
83 if (next.signal.aborted) {
84 return { deny: "Holdpane held this command and did not run it: the turn was interrupted. Do not retry it unless the user asks you to." };
85 }
86 if (since - held.since > HOLD_LIMIT_MS + STALE_SLACK_MS) {
87 held = null; // the call that claimed it is gone without releasing it
88 break;
89 }
90 if (since - waitStart > HOLD_LIMIT_MS) {
91 return { deny: "Holdpane did not run this command: another held command was not answered within 10 minutes. Do not retry it unless the user asks you to." };
92 }
93 await pause($);
94 since = await $.clock.now();
95 }
96 const mine = { command: String(e.command), risk, report: null, decision: null, where: "pane", since };
97 held = mine;
98
99 let opened = { isPlaced: false };
100 let decision;
101 let summary = risk.label;
102 try {
103 await findTools($);
104 // Measure where the command will run: the session folder, moved by any
105 // `cd dir &&` or `git -C dir` earlier in the same command line.
106 const sessionCwd = await $.session.cwd();
107 let cwd = sessionCwd;
108 if (risk.dir) {
109 try {
110 cwd = await resolveDir($, sessionCwd, risk.dir, risk.shell);
111 } catch {
112 cwd = null; // no tool to look the folder up with: the pane still opens and says so
113 }
114 }
115 mine.report = cwd === null
116 ? { summary: `${risk.label} in ${risk.dir}`, lines: [], note: `Couldn't find the folder ${risk.dir}, so I couldn't measure what this would change.` }
117 : await measure($, risk, cwd);
118 summary = mine.report.summary;
119
120 opened = await $.ui.open({ id: PANE_ID, title: "Holdpane", focus: true, rows: paneRows(mine.report) });
121 if (!opened.isPlaced) {
122 mine.where = "band";
123 }
124 $.ui.invalidate("ui.render");
125
126 const startedAt = await $.clock.now();
127 mine.since = startedAt; // the 10 minutes run from here, so a waiting call must not take over before that
128 while (mine.decision === null) {
129 if (next.signal.aborted) {
130 mine.decision = "interrupted";
131 break;
132 }
133 if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
134 mine.decision = "timeout";
135 break;
136 }
137 await pause($);
138 }
139 } catch {
140 mine.decision = "error"; // anything unexpected refuses the command
141 } finally {
142 decision = mine.decision;
143 // Close this call's pane before releasing the hold, so the next call's
144 // pane can't be the one that gets closed. A pane that was not placed waits
145 // unseen and could appear later, so it is closed too. If the hold was taken
146 // over as stale, the pane belongs to the new holder and is left alone.
147 if (held === mine) {
148 try {
149 await $.ui.close({ id: PANE_ID });
150 } catch {
151 // the pane is already gone
152 }
153 if (held === mine) {
154 held = null;
155 }
156 }
157 try {
158 $.ui.invalidate("ui.render");
159 } catch {
160 // nothing to redraw
161 }
162 }
163
164 if (decision === "proceed") {
165 $.ui.toast("Holdpane: running it");
166 return next(e);
167 }
168 const why = {
169 cancel: "the user pressed Cancel",
170 timeout: "no answer within 10 minutes",
171 interrupted: "the turn was interrupted",
172 error: "Holdpane hit an error while holding it",
173 }[decision] ?? "no answer was recorded";
174 return {
175 deny: `Holdpane held this command and did not run it: ${why}. It would have: ${summary}. Do not retry it unless the user asks you to.`,
176 };
177}
178
179// ---- Where the tools are ----------------------------------------------------
180
181/**
182 * Looks up bash, sleep and PowerShell. Never throws. On Windows the plain name
183 * `bash` can be the WSL launcher, so without Git's bash there is none (null) and the
184 * result is not kept: the next call looks again.
185 */
186async function findTools($) {
187 if (tools !== null) {
188 return tools;
189 }
190 const found = { win: false, bash: "bash", sleep: ["sleep", POLL_SECONDS], ps: "pwsh" };
191 let complete = true;
192 try {
193 found.win = (await $.env.get("OS")) === "Windows_NT";
194 if (found.win) {
195 found.ps = "powershell.exe";
196 found.bash = null;
197 complete = false;
198 const git = await $.process.run(["git", "--exec-path"], { timeoutMs: 5000 });
199 const exec = git.stdout.trim().replace(/[\\/]+$/, "");
200 const root = exec.replace(/[\\/](?:(?:mingw64|mingw32|clangarm64)[\\/])?libexec[\\/]git-core$/i, "");
201 if (git.exitCode === 0 && root !== exec) {
202 const usr = `${root}/usr/bin`;
203 const probe = await $.process.run([`${usr}/bash.exe`, "-c", "echo ok"], { timeoutMs: 8000 });
204 if (probe.exitCode === 0 && probe.stdout.trim() === "ok") {
205 found.bash = `${usr}/bash.exe`;
206 found.sleep = [`${usr}/sleep.exe`, POLL_SECONDS];
207 complete = true;
208 }
209 }
210 }
211 } catch {
212 // keep what was found
213 }
214 try {
215 await $.process.run(found.sleep, { timeoutMs: 5000 });
216 } catch {
217 found.sleep = [found.ps, "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Milliseconds 250"];
218 }
219 if (complete) {
220 tools = found;
221 }
222 return found;
223}
224
225async function pause($) {
226 const t = await findTools($);
227 await $.process.run(t.sleep, { timeoutMs: 5000 });
228}
229
230/** JSON that is pure ASCII, so no code page can change a path on its way into PowerShell. */
231function asciiJson(value) {
232 return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
233}
234
235async function runBash($, script, args, init) {
236 const t = await findTools($);
237 if (t.bash === null) {
238 throw new Error("Git's bash was not found, so the paths can't be measured with it");
239 }
240 return await $.process.run([t.bash, "-c", script, "holdpane", ...args], init);
241}
242
243// The script has single quotes only and no line breaks that matter, so the command
244// line survives Windows argument quoting. Its input arrives as ASCII JSON on stdin.
245async function runPowerShell($, script, input, init) {
246 const t = await findTools($);
247 return await $.process.run([t.ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { ...init, stdin: asciiJson(input) });
248}
249
250// ---- Cutting a command line apart ---------------------------------------------
251
252/**
253 * Cuts a command line at `;` `|` `&&` `||` and line breaks, in Bash also at a single `&`,
254 * in PowerShell also at the braces of a script block. Nothing is cut inside quotes, and a
255 * brace that sits inside a word (`C:\x\{guid}`, `${env:TEMP}`, `@{a=1}`) is not a block.
256 * Comments, Bash heredocs and PowerShell here-strings are text, not commands: their quotes
257 * are skipped. Returns { parts, open }; `open` is true when a quote was never closed, which
258 * means the scan lost track, and the caller then also reads the line without regard to quotes.
259 */
260function splitCommand(command, shell) {
261 const ps = shell === "ps";
262 const parts = [];
263 const blocks = []; // for each open `{`: whether it was cut at (a block) or part of a word
264 const heredocs = []; // Bash: delimiters whose bodies start after the current line
265 let cur = "";
266 let quote = null;
267 const cut = () => {
268 parts.push(cur);
269 cur = "";
270 };
271 for (let i = 0; i < command.length; i += 1) {
272 const ch = command[i];
273 const after = command[i + 1];
274 const before = command[i - 1];
275 if (quote !== null) {
276 cur += ch;
277 if (ch === "`" && ps && quote === "\"" && after !== undefined) {
278 cur += after;
279 i += 1;
280 } else if (ch === "\\" && !ps && quote === "\"" && after !== undefined) {
281 cur += after;
282 i += 1;
283 } else if (ch === quote) {
284 quote = null;
285 }
286 continue;
287 }
288 if (ch === "#" && (i === 0 || /[\s;|&(]/.test(before))) {
289 const end = command.indexOf("\n", i); // a comment runs to the end of the line
290 i = end < 0 ? command.length : end - 1;
291 } else if (ps && ch === "<" && after === "#") {
292 const end = command.indexOf("#>", i + 2); // <# block comment #>
293 i = end < 0 ? command.length : end + 1;
294 } else if (ps && ch === "@" && (after === "'" || after === "\"") && /^[ \t]*\r?\n/.test(command.slice(i + 2, i + 40))) {
295 const close = command.indexOf(`\n${after}@`, i + 2); // @' here-string '@
296 const stop = close < 0 ? command.length : close + 3;
297 cur += command.slice(i, stop);
298 i = stop - 1;
299 } else if (!ps && ch === "<" && after === "<" && command[i + 2] !== "<") {
300 const m = /^<<(-?)[ \t]*(?:'([^'\n]*)'|"([^"\n]*)"|\\?([A-Za-z0-9_.-]+))/.exec(command.slice(i, i + 200));
301 if (m === null) {
302 cur += ch;
303 } else {
304 heredocs.push({ delim: m[2] ?? m[3] ?? m[4], strip: m[1] === "-" });
305 cur += m[0];
306 i += m[0].length - 1;
307 }
308 } else if (ch === "'" || ch === "\"") {
309 quote = ch;
310 cur += ch;
311 } else if ((ch === "`" && ps) || (ch === "\\" && !ps)) {
312 cur += ch;
313 if (after !== undefined) {
314 cur += after;
315 i += 1;
316 }
317 } else if (ps && ch === "$" && after === "{") {
318 const end = command.indexOf("}", i);
319 const stop = end < 0 ? command.length : end + 1;
320 cur += command.slice(i, stop);
321 i = stop - 1;
322 } else if ((ch === "&" && after === "&") || (ch === "|" && after === "|")) {
323 cut();
324 i += 1;
325 } else if (ch === ";" || ch === "|" || ch === "\n") {
326 cut();
327 if (ch === "\n" && heredocs.length > 0) {
328 let pos = i + 1;
329 for (const h of heredocs) {
330 pos = skipHeredoc(command, pos, h.delim, h.strip);
331 }
332 heredocs.length = 0;
333 i = pos - 1;
334 }
335 } else if (ch === "&" && !ps && before !== ">" && before !== "<" && after !== ">") {
336 cut();
337 } else if (ps && ch === "{") {
338 // A block follows whitespace, ( ; | & { }, a closing ), %, ? or a keyword (try{ else{ ForEach-Object{).
339 const isBlock = before === undefined || /[\s(;|&{})%?]/.test(before) || /(?:^|[\s;|&(){}])(?:try|catch|finally|else|do|begin|process|end|dynamicparam|foreach-object|where-object)$/i.test(cur);
340 blocks.push(isBlock);
341 if (isBlock) {
342 cut();
343 } else {
344 cur += ch;
345 }
346 } else if (ps && ch === "}") {
347 if (blocks.pop() === true) {
348 cut();
349 } else {
350 cur += ch;
351 }
352 } else {
353 cur += ch;
354 }
355 }
356 parts.push(cur);
357 return { parts, open: quote !== null };
358}
359
360/** The index after the line that ends a heredoc body (the line that is just the delimiter). */
361function skipHeredoc(command, from, delim, strip) {
362 let pos = from;
363 while (pos < command.length) {
364 const end = command.indexOf("\n", pos);
365 let line = command.slice(pos, end < 0 ? command.length : end).replace(/\r$/, "");
366 if (strip) {
367 line = line.replace(/^\t+/, "");
368 }
369 pos = end < 0 ? command.length : end + 1;
370 if (line === delim) {
371 return pos;
372 }
373 }
374 return command.length;
375}
376
377/** The line cut at every operator, with no regard to quotes: what the scan falls back on when it lost track of them. */
378function splitNaively(command, shell) {
379 return command.split(shell === "ps" ? /&&|\|\||;|\||\n|[{}]/ : /&&|\|\||;|\||\n|(?<![<>&])&(?![>&])/);
380}
381
382/** The segment without trailing redirects and `&`: `(cd sub && make) > log` still shows its closing ). */
383function stripRedirects(segment) {
384 let text = segment;
385 for (let guard = 0; guard < 50; guard += 1) {
386 // Bounded white space keeps this linear-ish on a line of thousands of spaces.
387 const trimmed = text.replace(/\s{0,64}(?:\d*>>?&?|&>>?|<)\s{0,64}[^\s<>&]+\s{0,64}$/, "").replace(/\s{0,64}&\s{0,64}$/, "");
388 if (trimmed === text) {
389 break;
390 }
391 text = trimmed;
392 }
393 return text;
394}
395
396// ---- What counts as risky -------------------------------------------------
397
398// sudo options that take a value, so the value isn't read as the command.
399const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
400// Commands that only read, so a bare word "migrate" in them isn't a migration.
401const READ_ONLY = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "find", "less", "head", "tail", "cd", "git", "get-content", "select-string", "write-host", "write-output", "get-childitem", "gci", "dir"]);
402
403/** A risk the command line can't be read for: it is held and the pane says so. */
404function opaque(shell, label, note, dir) {
405 return { kind: "opaque", shell, label, note, dir: dir ?? null };
406}
407
408/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
409function joinDir(dir, arg) {
410 if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("\\") || /^[A-Za-z]:/.test(arg) || arg.startsWith("~/") || arg.startsWith("~\\")) {
411 return arg ?? "~";
412 }
413 return dir ? `${dir}/${arg}` : arg;
414}
415
416/** The first risky segment of a shell command, or null. `shell` is "bash" or "ps". */
417function classify(command, shell, startDir) {
418 if (command.length > MAX_COMMAND) {
419 return opaque(shell, "a very long command", `The command line has ${command.length} characters. Commands over ${MAX_COMMAND} are held without being read, so I can't tell what this does.`, startDir);
420 }
421 // PowerShell reads en and em dashes as a hyphen in front of a parameter.
422 const text = shell === "ps" ? command.replace(/[–—―]/g, "-") : command;
423 // A line continuation joins the lines before the command is cut apart.
424 const joined = shell === "ps" ? text.replace(/`\r?\n/g, " ") : text.replace(/\\\r?\n/g, " ");
425 const { parts, open } = splitCommand(joined, shell);
426 const risk = scanSegments(parts, shell, startDir);
427 if (risk !== null || !open) {
428 return risk;
429 }
430 // A quote was never closed: the scan lost track (a quote inside a $( ) heredoc, say).
431 // Read the line again without regard to quotes, so nothing hides behind the lost quote.
432 return scanSegments(splitNaively(joined, shell), shell, startDir);
433}
434
435/** The first risky one of the segments, following cd, pushd and popd; null if none. */
436function scanSegments(segments, shell, startDir) {
437 let dir = startDir; // where a `cd` earlier on the line moved to; null means the session folder
438 const scopes = []; // dir to restore when a ( subshell ) closes
439 const pushed = []; // pushd stack, for popd
440 for (const raw of segments) {
441 const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
442 const closes = (stripRedirects(raw).match(/\)+\s*$/)?.[0].trim().length) ?? 0;
443 for (let k = 0; k < opens; k += 1) {
444 scopes.push(dir);
445 }
446 const risk = shell === "ps" ? classifyPsSegment(raw, dir, pushed) : classifySegment(raw, dir, pushed);
447 if (risk !== null && risk.cd === undefined) {
448 return risk;
449 }
450 if (risk !== null) {
451 dir = risk.cd; // a cd, pushd or popd moved the folder
452 }
453 for (let k = 0; k < closes && scopes.length > 0; k += 1) {
454 dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
455 }
456 }
457 return null;
458}
459
460// Words that can come before the real command without changing what it does.
461const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]);
462
463/** Whether the flags of an `rm` make it recursive, forced, or both. */
464function rmFlags(flags) {
465 return {
466 recursive: flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f))),
467 force: flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f"))),
468 };
469}
470
471/** A git risk for `git args...` run in `dir`, or null. Git's own options come first; -C moves where it runs. */
472function classifyGit(args, dir) {
473 let gitDir = dir;
474 let i = 0;
475 while (i < args.length && args[i].startsWith("-")) {
476 if (args[i] === "-C" && i + 1 < args.length) {
477 gitDir = joinDir(gitDir, args[i + 1]);
478 i += 2;
479 } else if ((args[i] === "-c" || args[i] === "--git-dir" || args[i] === "--work-tree" || args[i] === "--namespace") && i + 1 < args.length) {
480 i += 2;
481 } else {
482 i += 1;
483 }
484 }
485 const sub = args[i];
486 const rest = args.slice(i + 1);
487 if (sub === "reset" && rest.includes("--hard")) {
488 return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
489 }
490 if (sub === "clean") {
491 return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
492 }
493 // -f may sit in a bundle of short flags (-fu, -uf).
494 if (sub === "push" && rest.some((a) => a === "--force" || a.startsWith("--force-with-lease") || /^\+/.test(a) || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(a))) {
495 return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
496 }
497 const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
498 if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
499 return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir };
500 }
501 // checkout -f and switch -f throw away local changes.
502 if ((sub === "checkout" && rest.some((a) => a === "-f" || a === "--force")) || (sub === "switch" && rest.some((a) => a === "-f" || a === "--force" || a === "--discard-changes"))) {
503 return { kind: "git-checkout", label: `git ${sub} --force`, args: rest, dir: gitDir };
504 }
505 return null;
506}
507
508/** A migration risk for the words of one segment, or null. */
509function classifyMigration(cmd, args, joined, dir, shell) {
510 if (/\balembic\s+upgrade\b/.test(joined)) {
511 return { kind: "migrate", shell, tool: "alembic", label: "alembic upgrade", dir };
512 }
513 if (/\bdb:migrate(?!:status\b)/.test(joined)) {
514 return { kind: "migrate", shell, tool: "rails", label: "db:migrate", dir };
515 }
516 if (/\bprisma\s+migrate\b/.test(joined)) {
517 return { kind: "migrate", shell, tool: "prisma", label: "prisma migrate", dir };
518 }
519 if (/\bmanage\.py\s+migrate\b/.test(joined)) {
520 return { kind: "migrate", shell, tool: "django", label: "manage.py migrate", dir };
521 }
522 if (!READ_ONLY.has(cmd) && args.includes("migrate")) {
523 return { kind: "migrate", shell, tool: "unknown", label: "migrate", dir };
524 }
525 return null;
526}
527
528/** `powershell -Command "..."` (args as strings): the text is classified as PowerShell; an encoded command is held unread. */
529function classifyPowerShellCall(args, dir) {
530 const named = (a, full, min) => /^-[A-Za-z]+$/.test(a) && a.length - 1 >= min && full.startsWith(a.slice(1).toLowerCase());
531 const at = args.findIndex((a) => named(a, "command", 1));
532 if (at >= 0) {
533 return classify(args.slice(at + 1).join(" "), "ps", dir);
534 }
535 if (args.some((a) => named(a, "encodedcommand", 1) || a.toLowerCase() === "-ec")) {
536 return opaque("ps", "an encoded PowerShell command", "The command is passed base64-encoded, so I can't read what it does.", dir);
537 }
538 // Without -Command, the first word that is neither an option nor an option's value is the command
539 // (Windows PowerShell's default parameter). A script given with -File is not read.
540 const valued = ["executionpolicy", "ep", "windowstyle", "outputformat", "inputformat", "version", "configurationname", "workingdirectory", "psconsolefile", "custompipename"];
541 for (let i = 0; i < args.length; i += 1) {
542 if (args[i].startsWith("-")) {
543 if (named(args[i], "file", 1)) {
544 return null;
545 }
546 if (valued.some((v) => v.startsWith(args[i].slice(1).toLowerCase()))) {
547 i += 1;
548 }
549 } else {
550 return classify(args.slice(i).join(" "), "ps", dir);
551 }
552 }
553 return null;
554}
555
556/** One segment: a risk, { cd } for a folder change, or null. */
557function classifySegment(segment, dir, pushed) {
558 // Redirects at the end go first, so `(cd sub && rm -rf out) > log 2>&1` leaves `rm -rf out`.
559 // A closing } is only dropped when the segment opened a { group: in `rm -rf {a,b}` it is part of a path.
560 const trimmed = stripRedirects(segment.trim());
561 const words = tokenize(trimmed.replace(/^[({]+\s*/, "").replace(/\s*\)+$/, "").replace(trimmed.startsWith("{") ? /\s*}+$/ : /(?!)/, ""));
562 while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
563 words.shift(); // leading VAR=value
564 }
565 if (words[0] === "sudo") {
566 words.shift();
567 while (words.length > 0 && words[0].startsWith("-")) {
568 const option = words.shift();
569 if (SUDO_VALUE_OPTIONS.has(option)) {
570 words.shift();
571 }
572 }
573 }
574 let prefixed = false;
575 while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) {
576 words.shift();
577 prefixed = true;
578 }
579 while (prefixed && words.length > 0 && words[0].startsWith("-")) {
580 words.shift(); // env -i, time -p
581 }
582 if (words[0] === "timeout") {
583 words.shift();
584 while (words.length > 0 && words[0].startsWith("-")) {
585 const option = words.shift();
586 if (option === "-s" || option === "-k" || option === "--signal" || option === "--kill-after") {
587 words.shift();
588 }
589 }
590 words.shift(); // the duration
591 }
592 if (words[0] === "nice") {
593 words.shift();
594 if (words[0] === "-n") {
595 words.splice(0, 2);
596 } else if (/^-\d+$/.test(words[0] ?? "")) {
597 words.shift();
598 }
599 }
600 const [first, ...args] = words;
601 if (first === undefined) {
602 return null;
603 }
604 const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm
605 if (cmd === "cd") {
606 return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
607 }
608 if (cmd === "pushd") {
609 pushed.push(dir);
610 return { cd: joinDir(dir, args[0]) };
611 }
612 if (cmd === "popd") {
613 return { cd: pushed.length > 0 ? pushed.pop() : "-" };
614 }
615 // The program's name without its folder and .exe, so /c/Program Files/Git/cmd/git.exe is git.
616 const base = cmd.replace(/^.*[\\/]/, "").replace(/\.exe$/i, "");
617 if (base === "rm") {
618 const flags = args.filter((a) => a.startsWith("-"));
619 const { recursive, force } = rmFlags(flags);
620 if (recursive || force) {
621 const targets = args.filter((a) => (!a.startsWith("-") || a === "-") && !/^(?:\d*>|&>|<)/.test(a));
622 return { kind: "rm", shell: "bash", label: `rm ${flags.join(" ")}`.trim(), targets, dir };
623 }
624 }
625 if (base === "xargs") {
626 const at = args.findIndex((a) => a.replace(/^.*[\\/]/, "") === "rm");
627 if (at >= 0) {
628 const flags = args.slice(at + 1).filter((a) => a.startsWith("-"));
629 const { recursive, force } = rmFlags(flags);
630 if (recursive || force) {
631 return { kind: "rm", shell: "bash", label: `xargs rm ${flags.join(" ")}`.trim(), targets: [], piped: true, dir };
632 }
633 }
634 }
635 if (base === "git") {
636 const risk = classifyGit(args, dir);
637 if (risk !== null) {
638 return { ...risk, shell: "bash" };
639 }
640 }
641 if (["bash", "sh", "zsh", "dash"].includes(base)) {
642 const at = args.findIndex((a) => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a));
643 if (at >= 0 && args[at + 1] !== undefined) {
644 const risk = classify(args[at + 1], "bash", dir);
645 if (risk !== null) {
646 return risk;
647 }
648 }
649 }
650 if (base.toLowerCase() === "powershell" || base.toLowerCase() === "pwsh") {
651 const risk = classifyPowerShellCall(args, dir);
652 if (risk !== null) {
653 return risk;
654 }
655 }
656 if (base.toLowerCase() === "cmd") {
657 const at = args.findIndex((a) => /^\/{1,2}[ck]$/i.test(a));
658 if (at >= 0) {
659 const risk = classifyCmd(args.slice(at + 1).join(" "), dir);
660 if (risk !== null) {
661 return risk;
662 }
663 }
664 }
665 return classifyMigration(base, args, words.join(" "), dir, "bash");
666}
667
668// ---- PowerShell -------------------------------------------------------------
669
670const PS_REMOVE = new Set(["remove-item", "ri", "rm", "rmdir", "rd", "del", "erase"]);
671const PS_CD = new Set(["cd", "chdir", "sl", "set-location"]);
672const PS_PUSH = new Set(["pushd", "push-location"]);
673const PS_POP = new Set(["popd", "pop-location"]);
674const PS_START = new Set(["start-process", "saps", "start"]);
675// Parameters that take a value, so the value isn't read as a path. PowerShell accepts
676// any unambiguous start of a name; a start of one of these is taken as theirs.
677const PS_VALUE_PARAMS = ["path", "literalpath", "filter", "include", "exclude", "credential", "stream", "erroraction", "errorvariable", "warningaction", "warningvariable", "informationaction", "informationvariable", "outvariable", "outbuffer", "pipelinevariable", "ea", "ev", "wa", "wv", "infa", "iv", "ov", "ob", "pv"];
678const PS_START_VALUE_PARAMS = ["workingdirectory", "redirectstandardinput", "redirectstandardoutput", "redirectstandarderror", "verb", "windowstyle", "credential", "environment"];
679
680/**
681 * Splits one PowerShell segment into words, honouring quotes ('' and "" inside them,
682 * a backtick escape in double quotes). A word is { raw, parts }: the text as written,
683 * and its comma-separated pieces unquoted (`a,'b c'` is two pieces).
684 */
685function psTokenize(text) {
686 const words = [];
687 let i = 0;
688 while (i < text.length) {
689 if (/\s/.test(text[i])) {
690 i += 1;
691 continue;
692 }
693 const parts = [""];
694 let raw = "";
695 while (i < text.length && !/\s/.test(text[i])) {
696 const ch = text[i];
697 if (ch === "\"" || ch === "'") {
698 let j = i + 1;
699 let value = "";
700 while (j < text.length) {
701 if (ch === "\"" && text[j] === "`" && j + 1 < text.length) {
702 value += text[j + 1];
703 j += 2;
704 } else if (text[j] === ch) {
705 if (text[j + 1] === ch) {
706 value += ch;
707 j += 2;
708 } else {
709 break;
710 }
711 } else {
712 value += text[j];
713 j += 1;
714 }
715 }
716 parts[parts.length - 1] += value;
717 raw += text.slice(i, j + 1);
718 i = j + 1;
719 } else if (ch === ",") {
720 parts.push("");
721 raw += ch;
722 i += 1;
723 } else if (ch === "`" && i + 1 < text.length) {
724 parts[parts.length - 1] += text[i + 1];
725 raw += text.slice(i, i + 2);
726 i += 2;
727 } else {
728 parts[parts.length - 1] += ch;
729 raw += ch;
730 i += 1;
731 }
732 }
733 words.push({ raw, parts });
734 }
735 return words;
736}
737
738/** { name, value } for `-Name` or `-Name:value` (name lower-cased), or null for any other word. */
739function psParam(word) {
740 const m = /^-([A-Za-z]+)(?::(.*))?$/.exec(word.raw);
741 return m === null ? null : { name: m[1].toLowerCase(), value: m[2] };
742}
743
744/** Whether a parameter name is a start of `full` at least `min` letters long. */
745function isParam(name, full, min) {
746 return name.length >= min && full.startsWith(name);
747}
748
749/** Whether a word as written is worked out by PowerShell or cmd at run time: a variable, a ( ) expression, @splat or %VAR%. */
750function isDynamic(raw) {
751 return (!raw.startsWith("'") && raw.includes("$")) || /^[(@]/.test(raw) || /%[A-Za-z_][^%\s]*%/.test(raw);
752}
753
754/** The unquoted pieces of the value in `-Name:value`. */
755function psValueParts(value) {
756 return psTokenize(value)[0]?.parts ?? [];
757}
758
759/** One PowerShell segment: a risk, { cd } for a folder change, or null. */
760function classifyPsSegment(segment, dir, pushed) {
761 const text = stripRedirects(segment.trim())
762 .replace(/^(?:\[[\w.,[\]\s]+\]\s*)?\$[\w:.]+(?:\[[^\]]*\])?\s*[+\-*/%]?=\s*/, "") // $out = cmd, [string]$o = cmd, $x[0] = cmd
763 .replace(/^\$[\w:.]+\s*=\s*/, "") // $a = $b = cmd
764 .replace(/^\[[\w.,[\]\s]+\]\s*/, "") // [void](cmd)
765 .replace(/^[&.](?=["'])/, "") // &"git" with no space
766 .replace(/^[({]+\s*/, "")
767 .replace(/\s*[)}]+$/, "");
768 const words = psTokenize(text);
769 while (words.length > 0 && (words[0].raw === "&" || words[0].raw === ".")) {
770 words.shift(); // call operator and dot-sourcing
771 }
772 if (words.length === 0) {
773 return null;
774 }
775 const cmd = words[0].parts.join(",").replace(/^.*[\\/]/, "").replace(/\.(?:exe|cmd|bat)$/i, "").toLowerCase();
776 const args = words.slice(1);
777
778 if (PS_CD.has(cmd) || PS_PUSH.has(cmd) || PS_POP.has(cmd)) {
779 if (PS_POP.has(cmd)) {
780 return { cd: pushed.length > 0 ? pushed.pop() : "-" };
781 }
782 let target;
783 for (let i = 0; i < args.length && target === undefined; i += 1) {
784 const p = psParam(args[i]);
785 if (p === null && !args[i].raw.startsWith("-")) {
786 target = args[i].parts.join(",");
787 } else if (p !== null && (isParam(p.name, "path", 1) || isParam(p.name, "literalpath", 1))) {
788 target = p.value !== undefined ? psValueParts(p.value).join(",") : args[i + 1]?.parts.join(",");
789 }
790 }
791 if (PS_PUSH.has(cmd)) {
792 pushed.push(dir);
793 }
794 if (target === undefined) {
795 return { cd: dir }; // a bare cd stays where it is
796 }
797 return { cd: target === "-" ? "-" : joinDir(dir, target) };
798 }
799
800 if (PS_REMOVE.has(cmd)) {
801 const name = cmd === "remove-item" ? "Remove-Item" : cmd;
802 if (args.some((w) => w.raw.startsWith("@"))) {
803 return opaque("ps", `${name} with splatted parameters`, "The parameters come from a variable (@name), so I can't tell what this removes.", dir);
804 }
805 let recurse = false;
806 let force = false;
807 let whatIf = false;
808 let filters = false;
809 const flags = [];
810 const targets = [];
811 for (let i = 0; i < args.length; i += 1) {
812 const p = psParam(args[i]);
813 if (p === null) {
814 if (/^\d*>/.test(args[i].raw)) {
815 continue; // a redirect, not a path
816 }
817 for (const part of args[i].parts) {
818 if (part !== "") {
819 targets.push({ p: part, lit: false, dyn: isDynamic(args[i].raw) });
820 }
821 }
822 continue;
823 }
824 const enabled = p.value === undefined ? true : !/^\$?false$/i.test(p.value);
825 if (isParam(p.name, "recurse", 1)) {
826 recurse = enabled;
827 flags.push(args[i].raw);
828 } else if (isParam(p.name, "force", 2)) {
829 force = enabled;
830 flags.push(args[i].raw);
831 } else if (isParam(p.name, "whatif", 2)) {
832 whatIf = enabled;
833 } else if (isParam(p.name, "path", 2) || isParam(p.name, "literalpath", 1)) {
834 const lit = isParam(p.name, "literalpath", 1);
835 const parts = p.value !== undefined ? psValueParts(p.value) : (args[i + 1]?.parts ?? []);
836 const dyn = isDynamic(p.value !== undefined ? p.value : (args[i + 1]?.raw ?? ""));
837 if (p.value === undefined) {
838 i += 1;
839 }
840 for (const part of parts) {
841 if (part !== "") {
842 targets.push({ p: part, lit, dyn });
843 }
844 }
845 } else if (PS_VALUE_PARAMS.some((n) => n.startsWith(p.name))) {
846 filters = filters || isParam(p.name, "filter", 2) || isParam(p.name, "include", 3) || isParam(p.name, "exclude", 2);
847 if (p.value === undefined) {
848 i += 1; // its value
849 }
850 }
851 }
852 if (whatIf || (!recurse && !force)) {
853 return null;
854 }
855 return { kind: "rm", shell: "ps", label: `${name} ${flags.join(" ")}`.trim(), targets, filters, dir };
856 }
857
858 if (cmd === "git") {
859 const risk = classifyGit(args.map((w) => w.parts.join(",")), dir);
860 if (risk !== null) {
861 return { ...risk, shell: "ps" };
862 }
863 }
864
865 if (PS_START.has(cmd)) {
866 // Start-Process git -ArgumentList 'reset','--hard' runs git as well.
867 let file;
868 let list = [];
869 for (let i = 0; i < args.length; i += 1) {
870 const p = psParam(args[i]);
871 if (p === null) {
872 if (file === undefined) {
873 file = args[i].parts.join(",");
874 } else if (list.length === 0) {
875 list = args[i].parts; // the second positional parameter is ArgumentList
876 }
877 } else if (isParam(p.name, "filepath", 2)) {
878 file = p.value !== undefined ? psValueParts(p.value).join(",") : args[i + 1]?.parts.join(",");
879 i += p.value === undefined ? 1 : 0;
880 } else if (isParam(p.name, "argumentlist", 1) || p.name === "args") {
881 list = p.value !== undefined ? psValueParts(p.value) : (args[i + 1]?.parts ?? []);
882 i += p.value === undefined ? 1 : 0;
883 } else if (PS_START_VALUE_PARAMS.some((n) => n.startsWith(p.name)) && p.value === undefined) {
884 i += 1;
885 }
886 }
887 if (file !== undefined) {
888 const risk = classify([file, ...list].join(" "), "ps", dir);
889 if (risk !== null) {
890 return risk;
891 }
892 }
893 }
894
895 // powershell -Command "..." and cmd /c "..." run text of their own.
896 if (cmd === "powershell" || cmd === "pwsh") {
897 const risk = classifyPowerShellCall(args.map((w) => w.parts.join(",")), dir);
898 if (risk !== null) {
899 return risk;
900 }
901 }
902 if (cmd === "cmd") {
903 const at = args.findIndex((w) => /^\/[ck]$/i.test(w.raw));
904 if (at >= 0) {
905 const risk = classifyCmd(args.slice(at + 1).map((w) => w.parts.join(",")).join(" "), dir);
906 if (risk !== null) {
907 return risk;
908 }
909 }
910 }
911
912 return classifyMigration(cmd, args.map((w) => w.parts.join(",")), words.map((w) => w.parts.join(",")).join(" "), dir, "ps");
913}
914
915/** A risk in cmd.exe text: `rd /s` or `rmdir /s`, also `/s/q`; `del /s` too, which is held but not measured. */
916function classifyCmd(text, dir) {
917 for (const part of text.split(/&&|\|\||&|\|/)) {
918 const words = psTokenize(part.trim());
919 const cmd = (words[0]?.parts.join(",") ?? "").toLowerCase();
920 if (!["rd", "rmdir", "del", "erase"].includes(cmd)) {
921 continue;
922 }
923 const args = words.slice(1);
924 const switches = args.filter((w) => w.raw.startsWith("/")).flatMap((w) => w.raw.split("/").filter((s) => s !== ""));
925 if (!switches.some((s) => /^s$/i.test(s))) {
926 continue;
927 }
928 const lit = cmd === "rd" || cmd === "rmdir"; // del matches wildcards, rd does not
929 const targets = args.filter((w) => !w.raw.startsWith("/")).flatMap((w) => w.parts.filter((p) => p !== "").map((p) => ({ p, lit, dyn: isDynamic(w.raw) })));
930 return { kind: "rm", shell: "ps", label: `${cmd} /s`, targets, filters: false, unmeasurable: !lit, dir };
931 }
932 return null;
933}
934
935// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
936// The target is passed as an argument, never as source.
937const CD_SCRIPT = `PATH="/usr/bin:$PATH"; unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && { pwd -W 2>/dev/null || pwd -P; }`;
938const PS_CD_SCRIPT = "$ErrorActionPreference='Stop'; $d=([Console]::In.ReadToEnd()|ConvertFrom-Json).d; if($d -eq '~'){$d=$HOME}elseif($d.StartsWith('~/') -or $d.StartsWith('~\\')){$d=Join-Path $HOME $d.Substring(2)}; try{Set-Location -LiteralPath $d; [Uri]::EscapeDataString((Get-Location).ProviderPath)}catch{exit 1}";
939
940/** A \\server\share or //server/share path: even looking at it opens a network connection. */
941function isNetworkPath(p) {
942 return /^(?:\\\\|\/\/)/.test(p);
943}
944
945async function resolveDir($, sessionCwd, dir, shell) {
946 if (dir === "-" || isNetworkPath(dir)) {
947 return null; // `cd -` depends on the shell's history; a network folder is not touched before the answer
948 }
949 if (shell === "ps") {
950 const run = await runPowerShell($, PS_CD_SCRIPT, { d: dir }, { cwd: sessionCwd, timeoutMs: 15000 });
951 const out = run.stdout.trim();
952 return run.exitCode === 0 && out !== "" ? decodeURIComponent(out) : null;
953 }
954 const run = await runBash($, CD_SCRIPT, [dir], { cwd: sessionCwd, timeoutMs: 5000 });
955 const out = run.stdout.trim();
956 return run.exitCode === 0 && out !== "" ? out : null;
957}
958
959/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
960function tokenize(text) {
961 const words = [];
962 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
963 let m;
964 while ((m = re.exec(text)) !== null) {
965 words.push(m[1] ?? m[2] ?? m[3]);
966 }
967 return words;
968}
969
970// ---- Measuring the blast radius -------------------------------------------
971
972/** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */
973async function measure($, risk, cwd) {
974 try {
975 if (risk.kind === "opaque") {
976 return { summary: `${risk.label}: I can't tell what it does`, lines: [], note: risk.note };
977 }
978 if (risk.kind === "rm") {
979 return risk.shell === "ps" ? await measureRmPs($, risk, cwd) : await measureRm($, risk, cwd);
980 }
981 if (risk.kind === "migrate") {
982 return measureMigrations(risk);
983 }
984 return await measureGit($, risk, cwd);
985 } catch (error) {
986 return { summary: `${risk.label} (could not measure it)`, lines: [], note: `Could not measure: ${String(error?.message ?? error).slice(0, 200)}` };
987 }
988}
989
990/** A report for paths that hold a variable or an expression: they are not expanded, so nothing can be said. */
991function unexpandedReport(risk, targets) {
992 return {
993 summary: `${risk.label}: the target is worked out when it runs`,
994 lines: [],
995 note: `Paths: ${targets.join(" ")}. They use a variable or an expression, which I can't expand, so I can't tell what this would delete.`,
996 };
997}
998
999/** A report for a network path: looking at it would open a connection before you have answered. */
1000function networkReport(risk, targets) {
1001 return {
1002 summary: `${risk.label}: the target is on the network`,
1003 lines: [],
1004 note: `Paths: ${targets.join(" ")}. I don't touch network paths before you answer, so I can't tell what this would delete.`,
1005 };
1006}
1007
1008// The paths are passed to bash as arguments, never as source, so nothing in
1009// them runs. compgen -G expands a glob without command substitution.
1010const RM_SCRIPT = `
1011PATH="/usr/bin:$PATH" # Git's find and du, not the Windows find
1012shopt -s nullglob dotglob
1013paths=()
1014for p in "$@"; do
1015 case "$p" in [A-Za-z]:?*) p=$(cygpath -u "$p");; esac # C:\\x or C:/x
1016 case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
1017 if [[ "$p" == *[*?[]* ]]; then
1018 while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
1019 elif [[ -e "$p" || -L "$p" ]]; then
1020 paths+=("$p")
1021 fi
1022done
1023if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
1024# A relative path gets ./ in front, so find never reads a name like -delete as an action.
1025for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
1026files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
1027kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
1028echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
1029find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
1030`;
1031
1032async function measureRm($, risk, cwd) {
1033 if (risk.targets.length === 0) {
1034 return risk.piped
1035 ? { summary: `${risk.label} on what an earlier command lists`, lines: [], note: "The paths come from an earlier command, so I can't list them." }
1036 : { summary: "rm with no paths", lines: [], note: "No paths to expand." };
1037 }
1038 if (risk.targets.some(isNetworkPath)) {
1039 return networkReport(risk, risk.targets);
1040 }
1041 if (risk.targets.some((t) => /\$|`|[{}]/.test(t))) {
1042 return unexpandedReport(risk, risk.targets);
1043 }
1044 const run = await runBash($, RM_SCRIPT, risk.targets, { cwd, timeoutMs: 15000 });
1045 if (run.exitCode !== 0 && run.stdout.trim() === "") {
1046 return { summary: `${risk.label} (could not measure it)`, lines: [], note: `bash failed: ${run.stderr.trim().slice(0, 200)}` };
1047 }
1048 const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
1049 const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
1050 if (!found) {
1051 return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so rm has nothing to remove." };
1052 }
1053 if (!files) {
1054 return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${risk.targets.join(" ")}` };
1055 }
1056 return {
1057 summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
1058 lines: rest.map((l) => l.replace(/^\.\//, "")),
1059 more: Math.max(0, files - rest.length),
1060 note: `Paths: ${risk.targets.join(" ")}`,
1061 };
1062}
1063
1064// Counts the files below the given paths without following junctions or symbolic links.
1065// Output: "files bytes found links", then up to LIST_MAX percent-encoded file paths.
1066const PS_RM_SCRIPT = [
1067 "$ErrorActionPreference='SilentlyContinue'",
1068 "$in=([Console]::In.ReadToEnd()|ConvertFrom-Json).t",
1069 "$n=0;$bytes=0;$found=0;$links=0",
1070 "$list=New-Object Collections.ArrayList;$stack=New-Object Collections.Stack",
1071 "foreach($t in @($in)){ if($t.lit){$r=@(Get-Item -LiteralPath $t.p -Force)}else{$r=@(Get-Item -Path $t.p -Force)}; foreach($i in $r){ $found++; if($i -is [IO.DirectoryInfo]){ if($i.Attributes -band 1024){$links++}else{$stack.Push($i)} }else{ $n++; $bytes+=$i.Length; if($list.Count -lt " + LIST_MAX + "){[void]$list.Add($i.FullName)} } } }",
1072 "while($stack.Count -gt 0){ $d=$stack.Pop(); try{ foreach($c in $d.EnumerateFileSystemInfos()){ if($c -is [IO.DirectoryInfo]){ if($c.Attributes -band 1024){$links++}else{$stack.Push($c)} }else{ $n++; $bytes+=$c.Length; if($list.Count -lt " + LIST_MAX + "){[void]$list.Add($c.FullName)} } } }catch{} }",
1073 "'{0} {1} {2} {3}' -f $n,$bytes,$found,$links",
1074 "foreach($p in $list){[Uri]::EscapeDataString($p)}",
1075].join("; ");
1076
1077async function measureRmPs($, risk, cwd) {
1078 const paths = risk.targets.map((t) => t.p);
1079 if (risk.unmeasurable) {
1080 return { summary: `${risk.label}: deletes every matching file in the folder and below it`, lines: [], note: `Paths: ${paths.join(" ")}. I don't measure del /s, because it matches names at every level.` };
1081 }
1082 if (paths.length === 0) {
1083 return { summary: `${risk.label} on what the pipeline passes it`, lines: [], note: "The paths come from an earlier command, so I can't list them." };
1084 }
1085 if (paths.some(isNetworkPath)) {
1086 return networkReport(risk, paths);
1087 }
1088 if (risk.targets.some((t) => t.dyn)) {
1089 return unexpandedReport(risk, paths);
1090 }
1091 const run = await runPowerShell($, PS_RM_SCRIPT, { t: risk.targets.map(({ p, lit }) => ({ p, lit })) }, { cwd, timeoutMs: 25000 });
1092 if (run.exitCode !== 0 && run.stdout.trim() === "") {
1093 return { summary: `${risk.label} (could not measure it)`, lines: [], note: `PowerShell failed: ${run.stderr.trim().slice(0, 200)}` };
1094 }
1095 const [head, ...rest] = run.stdout.split("\n").map((l) => l.trim()).filter((l) => l !== "");
1096 const [files, bytes, found, links] = (head ?? "0 0 0 0").split(" ").map(Number);
1097 const base = cwd.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
1098 const lines = rest.map((l) => {
1099 const full = decodeURIComponent(l).replace(/\\/g, "/");
1100 return full.toLowerCase().startsWith(`${base}/`) ? full.slice(base.length + 1) : full;
1101 });
1102 const extra = [
1103 risk.filters ? "Include, Exclude and Filter are not applied here, so the real number may be smaller." : "",
1104 links ? `${links} junction or symbolic link folder${links === 1 ? "" : "s"} not followed.` : "",
1105 ].filter((s) => s !== "").join(" ");
1106 if (!found) {
1107 return { summary: `delete nothing: no file matches ${paths.join(" ")}`, lines: [], note: "The paths don't exist, so there is nothing to remove." };
1108 }
1109 if (!files) {
1110 return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${paths.join(" ")}. ${extra}`.trim() };
1111 }
1112 return {
1113 summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
1114 lines,
1115 more: Math.max(0, files - lines.length),
1116 note: `Paths: ${paths.join(" ")}. ${extra}`.trim(),
1117 };
1118}
1119
1120async function measureGit($, risk, cwd) {
1121 if (risk.kind === "git-push-force") {
1122 return await measurePush($, risk, cwd);
1123 }
1124 if (risk.kind === "git-clean") {
1125 const flags = [];
1126 const paths = [];
1127 for (let i = 0; i < risk.args.length; i += 1) {
1128 const a = risk.args[i];
1129 if (a === "--") {
1130 paths.push(...risk.args.slice(i + 1));
1131 break;
1132 }
1133 if (a === "-e" || a === "--exclude") {
1134 flags.push(a, risk.args[i + 1] ?? "");
1135 i += 1;
1136 } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
1137 flags.push(a);
1138 } else if (/^-[a-zA-Z]+$/.test(a)) {
1139 const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
1140 if (kept !== "-") {
1141 flags.push(kept);
1142 }
1143 if (a.endsWith("e")) {
1144 flags.push(risk.args[i + 1] ?? ""); // -fde 'pattern': the e takes the next word
1145 i += 1;
1146 }
1147 } else if (!a.startsWith("-")) {
1148 paths.push(a);
1149 }
1150 }
1151 const run = await $.process.run([...GIT, "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
1152 if (run.exitCode !== 0) {
1153 return { summary: "git clean (could not dry-run it)", lines: [], note: run.stderr.trim().slice(0, 200) };
1154 }
1155 const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13).trim());
1156 return {
1157 summary: gone.length === 0 ? "remove nothing: no untracked files match" : `remove ${gone.length} untracked ${gone.length === 1 ? "path" : "paths"}`,
1158 lines: gone.slice(0, LIST_MAX),
1159 more: Math.max(0, gone.length - LIST_MAX),
1160 note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
1161 };
1162 }
1163 const status = await $.process.run([...GIT, "-c", "core.quotepath=false", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
1164 if (status.exitCode !== 0) {
1165 return { summary: `${risk.label} (not a git repo here?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
1166 }
1167 const rows = status.stdout.split("\n").map((l) => l.replace(/\r$/, "")).filter((l) => l.length > 3 && !l.startsWith("??"));
1168 // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
1169 const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
1170 const stat = await $.process.run([...GIT, "diff", "--no-ext-diff", "--no-textconv", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
1171 return {
1172 summary: lost.length === 0 ? "discard nothing: no uncommitted changes" : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? "file" : "files"}`,
1173 lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
1174 more: Math.max(0, lost.length - LIST_MAX),
1175 note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : "From git status --porcelain.",
1176 };
1177}
1178
1179async function measurePush($, risk, cwd) {
1180 const positional = risk.args.filter((a) => !a.startsWith("-"));
1181 const remote = positional[0] ?? "origin";
1182 // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
1183 const spec = (positional[1] ?? "").replace(/^\+/, "");
1184 let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
1185 branch = (branch ?? "").replace(/^refs\/heads\//, "");
1186 if (source.startsWith("-") || branch.startsWith("-") || remote.startsWith("-")) {
1187 return { summary: "force-push", lines: [], note: "The branch names start with a dash, so I can't tell which commits the push would drop." };
1188 }
1189 if (!branch) {
1190 const head = await $.process.run([...GIT, "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
1191 branch = head.stdout.trim();
1192 source = "HEAD";
1193 } else if (branch === "HEAD") {
1194 // `git push origin HEAD` pushes the current branch to its namesake.
1195 const head = await $.process.run([...GIT, "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
1196 branch = head.stdout.trim();
1197 source = "HEAD";
1198 }
1199 source = source || "HEAD";
1200 const ref = `${remote}/${branch}`;