From 3aa1a264de90374bbccccdebae4fb11a35aab09f Mon Sep 17 00:00:00 2001 From: Marc Seguin Date: Sat, 10 Oct 2026 14:19:08 -0400 Subject: [PATCH 1/4] feat(blast-radius): hold risky PowerShell commands and add tests Blast Radius only watched the Bash tool, so on Windows, where shell commands run through the PowerShell tool, risky commands were never held. - Register a second tool.call hook for PowerShell, sharing the hold, pane and decision logic with Bash through guard(). - Add a PowerShell classifier: Remove-Item and its aliases with -Recurse/-Force (abbreviated parameter names allowed), plus Set-Location, Push-Location and Pop-Location for folder tracking. - Share git and migration detection between both shells. - Measure Remove-Item through pwsh, falling back to powershell, with paths passed as arguments so nothing in them runs as script. - Add tests: classifier coverage for both shells, and hold-queue tests for Cancel, Proceed and overlapping calls. - Update the README for PowerShell support and its limits. --- claude-code/mods/blast-radius/README.md | 13 +- .../mods/blast-radius/hooks/blast-radius.mjs | 577 ++++++++++++------ .../mods/blast-radius/tests/classify.test.ts | 186 ++++++ .../mods/blast-radius/tests/guard.test.ts | 112 ++++ 4 files changed, 698 insertions(+), 190 deletions(-) create mode 100644 claude-code/mods/blast-radius/tests/classify.test.ts create mode 100644 claude-code/mods/blast-radius/tests/guard.test.ts diff --git a/claude-code/mods/blast-radius/README.md b/claude-code/mods/blast-radius/README.md index 0d59e5d..7d0c56f 100644 --- a/claude-code/mods/blast-radius/README.md +++ b/claude-code/mods/blast-radius/README.md @@ -4,11 +4,12 @@ A Claude Code mod that holds a risky shell command and shows you what it would c ## 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. +When Claude calls Bash or PowerShell with one of the commands below, Blast Radius stops the call, works out what the command would touch, and opens a pane with two buttons: **Proceed** runs the command, **Cancel** refuses it. Claude sees the refusal and the reason. | Command | What the pane shows | |---|---| | `rm -r`, `rm -f`, `rm -rf` | The files it would delete, with the count and total size. Globs and `~` are expanded. | +| PowerShell `Remove-Item` (also `ri`, `rd`, `erase`, `del`, `rmdir`) with `-Recurse` or `-Force` | The same report as `rm`. Parameter names can be abbreviated (`-r`, `-rec`, `-f`), as PowerShell allows. | | `git reset --hard` | The files with uncommitted changes, from `git status --porcelain`, and `git diff --shortstat`. | | `git checkout -- .`, `git restore .` | The files with unstaged changes. | | `git clean` | The untracked paths it would remove, from `git clean -n` with the same flags. | @@ -16,7 +17,7 @@ When Claude calls Bash with one of the commands below, Blast Radius stops the ca | `manage.py migrate`, `db:migrate`, `alembic upgrade`, `prisma migrate` | The pending migrations, from the tool's own status command. | | any other `migrate` | A note that it can't list the pending migrations for that tool. | -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 `git` and migration rows work the same from either shell. Every other command runs as normal. If the command line moves first, with `cd dir &&`, `pushd`/`popd` or `git -C dir` (in PowerShell also `Set-Location`, `Push-Location` and `Pop-Location`), Blast Radius measures in that folder. A `cd` inside `( ... )` only applies inside the parentheses, as in the shell. The patterns it demonstrates: @@ -66,7 +67,8 @@ The second try, after Proceed: **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 Bash calls: `bash`, `git`, `find` and `du` on your `PATH`. +- For PowerShell calls: `git`, and `pwsh` (preferred) or `powershell` on your `PATH`, to measure `Remove-Item`. 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. @@ -117,7 +119,10 @@ No environment variables or configuration. - 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. +- It watches the Bash and PowerShell tools. File edits and other tools aren't held. +- PowerShell is read more simply than Bash. A command is split on `;`, `&&`, `||` and new lines, but not on `|`, so a risky cmdlet that only appears after a pipe (`Get-ChildItem build | Remove-Item -Force`) isn't caught. `$(...)`, script blocks, splatting, here-strings, `Invoke-Expression`, `& $command` and aliases you define yourself aren't read either. +- `-WhatIf` and `-Confirm:$false` aren't special-cased: a `Remove-Item -Recurse -WhatIf` is still held. +- `cmd.exe` commands such as `rd /s /q` aren't held, since they don't run through the PowerShell tool. - 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. diff --git a/claude-code/mods/blast-radius/hooks/blast-radius.mjs b/claude-code/mods/blast-radius/hooks/blast-radius.mjs index c1fe7a6..a4dedac 100644 --- a/claude-code/mods/blast-radius/hooks/blast-radius.mjs +++ b/claude-code/mods/blast-radius/hooks/blast-radius.mjs @@ -1,9 +1,9 @@ // Copyright 2026 Anthropic PBC // SPDX-License-Identifier: Apache-2.0 // -// Blast Radius: holds a risky Bash command and shows what it would change. +// Blast Radius: holds a risky Bash or PowerShell command and shows what it would change. // -// tool.call (Bash): if the command is risky, work out its blast radius, open a +// tool.call (Bash, PowerShell): if the command is risky, work out its blast radius, open a // pane with Proceed and Cancel, and hold the call until one is pressed. // ui.render (Pane): draws the report. If the surface won't place the pane (a // narrow terminal), the same report is drawn in the AbovePrompt band instead. @@ -20,91 +20,13 @@ const POLL_SECONDS = "0.25"; const HOLD_LIMIT_MS = 10 * 60 * 1000; const LIST_MAX = 10; -// The call being held, or null. One at a time: Bash calls in a turn run in order. +// The call being held, or null. One at a time: Bash and PowerShell calls in a +// turn run in order, and share this one slot. let held = null; export function register(on) { - on("tool.call", { tool: "Bash" }, async ($, e, next) => { - const risk = classify(String(e.command ?? "")); - if (risk === null) { - return next(e); - } - // One hold at a time. If another risky call is already held (a subagent's, - // say), wait until it is answered. `held` is claimed with no await between - // the check and the claim, so two waiting calls can't both get through. - while (held !== null) { - if (next.signal.aborted) { - 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." }; - } - await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 }); - } - const mine = { command: String(e.command), risk, report: null, decision: null, where: "pane" }; - held = mine; - - let opened = { isPlaced: false }; - let decision; - let summary = risk.label; - try { - // Measure where the command will run: the session folder, moved by any - // `cd dir &&` or `git -C dir` earlier in the same command line. - const sessionCwd = await $.session.cwd(); - const cwd = risk.dir ? await resolveDir($, sessionCwd, risk.dir) : sessionCwd; - mine.report = cwd === null - ? { summary: `${risk.label} in ${risk.dir}`, lines: [], note: `Couldn't find the folder ${risk.dir}, so I couldn't measure what this would change.` } - : await measure($, risk, cwd); - summary = mine.report.summary; - - opened = await $.ui.open({ id: PANE_ID, title: "Blast Radius", focus: true, rows: paneRows(mine.report) }); - if (!opened.isPlaced) { - mine.where = "band"; - } - $.ui.invalidate("ui.render"); - - const startedAt = await $.clock.now(); - while (mine.decision === null) { - if (next.signal.aborted) { - mine.decision = "interrupted"; - break; - } - if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) { - mine.decision = "timeout"; - break; - } - await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 }); - } - } catch { - mine.decision = "error"; // anything unexpected refuses the command - } finally { - decision = mine.decision; - // Close this call's pane before releasing the hold, so the next call's - // pane can't be the one that gets closed. - try { - if (opened.isPlaced) { - await $.ui.close({ id: PANE_ID }); - } - } catch { - // the pane is already gone - } - if (held === mine) { - held = null; - } - $.ui.invalidate("ui.render"); - } - - if (decision === "proceed") { - $.ui.toast("Blast Radius: running it"); - return next(e); - } - const why = { - cancel: "the user pressed Cancel", - timeout: "no answer within 10 minutes", - interrupted: "the turn was interrupted", - error: "Blast Radius hit an error while holding it", - }[decision] ?? "no answer was recorded"; - return { - 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.`, - }; - }); + on("tool.call", { tool: "Bash" }, async ($, e, next) => guard($, e, next, "bash")); + on("tool.call", { tool: "PowerShell" }, async ($, e, next) => guard($, e, next, "powershell")); on("ui.render", { component: "Pane" }, ($, e, next) => { if (e.requestId !== PANE_ID || held === null || held.report === null) { @@ -121,10 +43,95 @@ export function register(on) { }); } -// ---- What counts as risky ------------------------------------------------- +// ---- Holding a risky call -------------------------------------------------- + +/** Holds one risky call. shellKind is "bash" or "powershell": each reads that shell's syntax. */ +async function guard($, e, next, shellKind) { + const risk = shellKind === "bash" ? classify(String(e.command ?? "")) : classifyPowerShell(String(e.command ?? "")); + if (risk === null) { + return next(e); + } + // One hold at a time. If another risky call is already held (a subagent's, + // say), wait until it is answered. `held` is claimed with no await between + // the check and the claim, so two waiting calls can't both get through. + while (held !== null) { + if (next.signal.aborted) { + 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." }; + } + await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 }); + } + const mine = { command: String(e.command), risk, report: null, decision: null, where: "pane" }; + held = mine; + + let opened = { isPlaced: false }; + let decision; + let summary = risk.label; + try { + // Measure where the command will run: the session folder, moved by any + // `cd dir &&` or `git -C dir` earlier in the same command line. + const sessionCwd = await $.session.cwd(); + const cwd = risk.dir + ? await (shellKind === "bash" ? resolveDir($, sessionCwd, risk.dir) : resolveDirPowerShell($, sessionCwd, risk.dir)) + : sessionCwd; + mine.report = cwd === null + ? { summary: `${risk.label} in ${risk.dir}`, lines: [], note: `Couldn't find the folder ${risk.dir}, so I couldn't measure what this would change.` } + : await measure($, risk, cwd, shellKind); + summary = mine.report.summary; + + opened = await $.ui.open({ id: PANE_ID, title: "Blast Radius", focus: true, rows: paneRows(mine.report) }); + if (!opened.isPlaced) { + mine.where = "band"; + } + $.ui.invalidate("ui.render"); + + const startedAt = await $.clock.now(); + while (mine.decision === null) { + if (next.signal.aborted) { + mine.decision = "interrupted"; + break; + } + if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) { + mine.decision = "timeout"; + break; + } + await $.process.run(["sleep", POLL_SECONDS], { timeoutMs: 5000 }); + } + } catch { + mine.decision = "error"; // anything unexpected refuses the command + } finally { + decision = mine.decision; + // Close this call's pane before releasing the hold, so the next call's + // pane can't be the one that gets closed. + try { + if (opened.isPlaced) { + await $.ui.close({ id: PANE_ID }); + } + } catch { + // the pane is already gone + } + if (held === mine) { + held = null; + } + $.ui.invalidate("ui.render"); + } + + if (decision === "proceed") { + $.ui.toast("Blast Radius: running it"); + return next(e); + } + const why = { + cancel: "the user pressed Cancel", + timeout: "no answer within 10 minutes", + interrupted: "the turn was interrupted", + error: "Blast Radius hit an error while holding it", + }[decision] ?? "no answer was recorded"; + return { + 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.`, + }; +} + +// ---- What counts as risky: shared, shell-agnostic detection --------------- -// sudo options that take a value, so the value isn't read as the command. -const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]); // Commands that only read, so a bare word "migrate" in them isn't a migration. const READ_ONLY = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "find", "less", "head", "tail", "cd", "git"]); @@ -136,6 +143,68 @@ function joinDir(dir, arg) { return dir ? `${dir}/${arg}` : arg; } +/** git's own CLI is identical regardless of shell: args are already tokenized words. */ +function classifyGitArgs(args, dir) { + // Git's own options come before the subcommand; -C moves where it runs. + let gitDir = dir; + let i = 0; + while (i < args.length && args[i].startsWith("-")) { + if (args[i] === "-C" && i + 1 < args.length) { + gitDir = joinDir(gitDir, args[i + 1]); + i += 2; + } else if (args[i] === "-c" && i + 1 < args.length) { + i += 2; + } else { + i += 1; + } + } + const sub = args[i]; + const rest = args.slice(i + 1); + if (sub === "reset" && rest.includes("--hard")) { + return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir }; + } + if (sub === "clean") { + return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir }; + } + if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) { + return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir }; + } + const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W"); + if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) { + return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir }; + } + return null; +} + +/** A migration-tool invocation is identical regardless of shell: words are already tokenized. */ +function classifyMigrationWords(words, args, cmd, dir) { + const joined = words.join(" "); + if (/\balembic\s+upgrade\b/.test(joined)) { + return { kind: "migrate", tool: "alembic", label: "alembic upgrade", dir }; + } + if (/\bdb:migrate(?!:status\b)/.test(joined)) { + return { kind: "migrate", tool: "rails", label: "db:migrate", dir }; + } + if (/\bprisma\s+migrate\b/.test(joined)) { + return { kind: "migrate", tool: "prisma", label: "prisma migrate", dir }; + } + if (/\bmanage\.py\s+migrate\b/.test(joined)) { + return { kind: "migrate", tool: "django", label: "manage.py migrate", dir }; + } + if (!READ_ONLY.has(cmd) && args.includes("migrate")) { + return { kind: "migrate", tool: "unknown", label: "migrate", dir }; + } + return null; +} + +// ---- Bash: what counts as risky -------------------------------------------- + +// sudo options that take a value, so the value isn't read as the command. +const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]); + +// Words that can come before the real command without changing what it does. +const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]); + /** The first risky segment of a shell command, or null. */ function classify(command) { let dir = null; // where a `cd` earlier on the line moved to; null means the session folder @@ -163,108 +232,74 @@ function classify(command) { return null; } -// Words that can come before the real command without changing what it does. -const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]); - /** One segment: a risk, { cd } for a folder change, or null. */ function classifySegment(segment, dir, pushed) { - { - const words = tokenize(segment.trim().replace(/^[({]+\s*/, "").replace(/\s*[)}]+$/, "")); - while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) { - words.shift(); // leading VAR=value - } - if (words[0] === "sudo") { - words.shift(); - while (words.length > 0 && words[0].startsWith("-")) { - const option = words.shift(); - if (SUDO_VALUE_OPTIONS.has(option)) { - words.shift(); - } - } - } - while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) { - words.shift(); - } - if (words[0] === "nice") { - words.shift(); - if (words[0] === "-n") { - words.splice(0, 2); - } else if (/^-\d+$/.test(words[0] ?? "")) { + const words = tokenize(segment.trim().replace(/^[({]+\s*/, "").replace(/\s*[)}]+$/, "")); + while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) { + words.shift(); // leading VAR=value + } + if (words[0] === "sudo") { + words.shift(); + while (words.length > 0 && words[0].startsWith("-")) { + const option = words.shift(); + if (SUDO_VALUE_OPTIONS.has(option)) { words.shift(); } } - const [first, ...args] = words; - if (first === undefined) { - return null; - } - const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm - if (cmd === "cd") { - return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) }; - } - if (cmd === "pushd") { - pushed.push(dir); - return { cd: joinDir(dir, args[0]) }; - } - if (cmd === "popd") { - return { cd: pushed.length > 0 ? pushed.pop() : "-" }; - } - if (cmd === "rm" || cmd.endsWith("/rm")) { - const flags = args.filter((a) => a.startsWith("-")); - const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f))); - const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f"))); - if (recursive || force) { - const targets = args.filter((a) => !a.startsWith("-") || a === "-"); - return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir }; - } - } - if (cmd === "git") { - // Git's own options come before the subcommand; -C moves where it runs. - let gitDir = dir; - let i = 0; - while (i < args.length && args[i].startsWith("-")) { - if (args[i] === "-C" && i + 1 < args.length) { - gitDir = joinDir(gitDir, args[i + 1]); - i += 2; - } else if (args[i] === "-c" && i + 1 < args.length) { - i += 2; - } else { - i += 1; - } - } - const sub = args[i]; - const rest = args.slice(i + 1); - if (sub === "reset" && rest.includes("--hard")) { - return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir }; - } - if (sub === "clean") { - return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir }; - } - if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) { - return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir }; - } - const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W"); - if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) { - return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir }; - } - } - const joined = words.join(" "); - if (/\balembic\s+upgrade\b/.test(joined)) { - return { kind: "migrate", tool: "alembic", label: "alembic upgrade", dir }; - } - if (/\bdb:migrate(?!:status\b)/.test(joined)) { - return { kind: "migrate", tool: "rails", label: "db:migrate", dir }; - } - if (/\bprisma\s+migrate\b/.test(joined)) { - return { kind: "migrate", tool: "prisma", label: "prisma migrate", dir }; + } + while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) { + words.shift(); + } + if (words[0] === "nice") { + words.shift(); + if (words[0] === "-n") { + words.splice(0, 2); + } else if (/^-\d+$/.test(words[0] ?? "")) { + words.shift(); } - if (/\bmanage\.py\s+migrate\b/.test(joined)) { - return { kind: "migrate", tool: "django", label: "manage.py migrate", dir }; + } + const [first, ...args] = words; + if (first === undefined) { + return null; + } + const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm + if (cmd === "cd") { + return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) }; + } + if (cmd === "pushd") { + pushed.push(dir); + return { cd: joinDir(dir, args[0]) }; + } + if (cmd === "popd") { + return { cd: pushed.length > 0 ? pushed.pop() : "-" }; + } + if (cmd === "rm" || cmd.endsWith("/rm")) { + const flags = args.filter((a) => a.startsWith("-")); + const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f))); + const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f"))); + if (recursive || force) { + const targets = args.filter((a) => !a.startsWith("-") || a === "-"); + return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir }; } - if (!READ_ONLY.has(cmd) && args.includes("migrate")) { - return { kind: "migrate", tool: "unknown", label: "migrate", dir }; + } + if (cmd === "git") { + const risk = classifyGitArgs(args, dir); + if (risk !== null) { + return risk; } } - return null; + return classifyMigrationWords(words, args, cmd, dir); +} + +/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */ +function tokenize(text) { + const words = []; + const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g; + let m; + while ((m = re.exec(text)) !== null) { + words.push(m[1] ?? m[2] ?? m[3]); + } + return words; } // Resolves a `cd` target to an absolute folder, or null if it doesn't exist. @@ -280,24 +315,180 @@ async function resolveDir($, sessionCwd, dir) { return run.exitCode === 0 && out !== "" ? out : null; } -/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */ -function tokenize(text) { +// ---- PowerShell: what counts as risky -------------------------------------- + +// PowerShell's default aliases for the cmdlets we track. Matching is case-insensitive. +const SET_LOCATION = new Set(["set-location", "cd", "chdir", "sl"]); +const PUSH_LOCATION = new Set(["push-location", "pushd"]); +const POP_LOCATION = new Set(["pop-location", "popd"]); +const REMOVE_ITEM = new Set(["remove-item", "ri", "rd", "erase", "del", "rmdir"]); + +// `|` is excluded on purpose: a pipeline stage doesn't carry its own positional +// args (`Get-ChildItem ... | Remove-Item -Force` has no target in that segment), +// so splitting on it would read a risk that isn't really there. +const PS_SEPARATOR = /&&|\|\||;|\r?\n/; + +/** The first risky segment of a PowerShell command, or null. */ +function classifyPowerShell(command) { + let dir = null; + const pushed = []; + for (const raw of command.split(PS_SEPARATOR)) { + const risk = classifySegmentPowerShell(raw, dir, pushed); + if (risk !== null && risk.cd === undefined) { + return risk; + } + if (risk !== null) { + dir = risk.cd; + } + } + return null; +} + +/** A PowerShell parameter name, matched by any unambiguous prefix (-r, -rec, -recurse all bind to Recurse). */ +function matchesPsFlag(token, fullName) { + if (!token.startsWith("-")) { + return false; + } + const body = token.slice(1).toLowerCase(); + return body.length > 0 && fullName.toLowerCase().startsWith(body); +} + +/** One segment: a risk, { cd } for a folder change, or null. */ +function classifySegmentPowerShell(segment, dir, pushed) { + const words = tokenizePowerShell(segment.trim()); + const [first, ...args] = words; + if (first === undefined) { + return null; + } + const cmd = first.toLowerCase(); + + if (SET_LOCATION.has(cmd)) { + return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) }; + } + if (PUSH_LOCATION.has(cmd)) { + pushed.push(dir); + return { cd: joinDir(dir, args[0]) }; + } + if (POP_LOCATION.has(cmd)) { + return { cd: pushed.length > 0 ? pushed.pop() : "-" }; + } + if (REMOVE_ITEM.has(cmd)) { + const recursive = args.some((a) => matchesPsFlag(a, "Recurse")); + const force = args.some((a) => matchesPsFlag(a, "Force")); + if (recursive || force) { + const flags = [recursive ? "-Recurse" : null, force ? "-Force" : null].filter(Boolean); + const targets = args.filter((a) => !a.startsWith("-")); + return { kind: "rm", label: `Remove-Item ${flags.join(" ")}`.trim(), targets, dir }; + } + } + if (cmd === "git") { + const risk = classifyGitArgs(args, dir); + if (risk !== null) { + return risk; + } + } + return classifyMigrationWords(words, args, cmd, dir); +} + +/** Splits one segment into words. Single quotes are literal; double quotes allow a backtick escape. */ +function tokenizePowerShell(text) { const words = []; - const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g; + const re = /"((?:[^"`]|`.)*)"|'((?:[^']|'')*)'|(\S+)/g; let m; while ((m = re.exec(text)) !== null) { - words.push(m[1] ?? m[2] ?? m[3]); + if (m[1] !== undefined) { + words.push(m[1].replace(/`(.)/g, "$1")); + } else if (m[2] !== undefined) { + words.push(m[2].replace(/''/g, "'")); + } else { + words.push(m[3]); + } } return words; } +/** Runs a PowerShell script via argv, preferring pwsh (the tool's own PS7+ edition), falling + back to Windows PowerShell if pwsh isn't on PATH. Script args reach the script only via + $args, never interpolated into the script text. */ +async function runPowerShell($, scriptAndArgs, opts) { + try { + return await $.process.run(["pwsh", "-NoProfile", "-NonInteractive", "-Command", ...scriptAndArgs], opts); + } catch { + return await $.process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", ...scriptAndArgs], opts); + } +} + +// $args[0] holds `dir`; never interpolated into the script text, the same security +// property as the Bash CD_SCRIPT, which reads $1 the same way. -LiteralPath avoids +// reading *, ? or [ in the folder name as a wildcard. +const CD_SCRIPT_PS = ` +$d = $args[0] +if ($d -eq "~") { $d = $HOME } +elseif ($d.StartsWith("~/") -or $d.StartsWith("~\\")) { $d = Join-Path $HOME $d.Substring(2) } +try { (Resolve-Path -LiteralPath $d -ErrorAction Stop).ProviderPath } catch { exit 1 } +`; + +async function resolveDirPowerShell($, sessionCwd, dir) { + if (dir === "-") { + return null; // no `cd -` history stack in PowerShell by default + } + const run = await runPowerShell($, [CD_SCRIPT_PS, dir], { cwd: sessionCwd, timeoutMs: 5000 }); + const out = run.stdout.trim(); + return run.exitCode === 0 && out !== "" ? out : null; +} + +// Each target reaches the script only via $args, never interpolated into script +// text, the same security property as Bash's RM_SCRIPT. A target containing *, ? +// or [ is expanded as a wildcard; otherwise matched literally, so a path +// containing those characters isn't misread as a glob. +const RM_SCRIPT_PS = ` +$targets = @() +foreach ($p in $args) { + $pp = $p + if ($pp -eq "~") { $pp = $HOME } + elseif ($pp.StartsWith("~/") -or $pp.StartsWith("~\\")) { $pp = Join-Path $HOME $pp.Substring(2) } + if ($pp -match '[*?\\[]') { $targets += @(Get-Item -Path $pp -Force -ErrorAction SilentlyContinue) } + else { $targets += @(Get-Item -LiteralPath $pp -Force -ErrorAction SilentlyContinue) } +} +if ($targets.Count -eq 0) { Write-Output "0 0 0"; exit 0 } +$files = @() +foreach ($t in $targets) { + if ($t.PSIsContainer) { $files += @(Get-ChildItem -LiteralPath $t.FullName -Recurse -Force -File -ErrorAction SilentlyContinue) } + else { $files += $t } +} +$bytes = ($files | Measure-Object -Property Length -Sum).Sum +Write-Output "$($files.Count) $([int64]($bytes)) $($targets.Count)" +$files | Select-Object -First ${LIST_MAX} -ExpandProperty FullName +`; + +async function measureRmPowerShell($, risk, cwd) { + if (risk.targets.length === 0) { + return { summary: "Remove-Item with no paths", lines: [], note: "No paths to expand." }; + } + const run = await runPowerShell($, [RM_SCRIPT_PS, ...risk.targets], { cwd, timeoutMs: 15000 }); + const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== ""); + const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number); + if (!found) { + return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so Remove-Item has nothing to remove." }; + } + if (!files) { + return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${risk.targets.join(" ")}` }; + } + return { + summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`, + lines: rest, + more: Math.max(0, files - rest.length), + note: `Paths: ${risk.targets.join(" ")}`, + }; +} + // ---- Measuring the blast radius ------------------------------------------- /** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */ -async function measure($, risk, cwd) { +async function measure($, risk, cwd, shellKind) { try { if (risk.kind === "rm") { - return await measureRm($, risk, cwd); + return await (shellKind === "bash" ? measureRm($, risk, cwd) : measureRmPowerShell($, risk, cwd)); } if (risk.kind === "migrate") { return await measureMigrations($, risk, cwd); @@ -526,3 +717,17 @@ function draw(t, state) { ], }); } + +// ---- Exported for tests ---------------------------------------------------- + +export { + classify, + classifyPowerShell, + classifyGitArgs, + classifyMigrationWords, + tokenize, + tokenizePowerShell, + matchesPsFlag, + joinDir, + size, +}; diff --git a/claude-code/mods/blast-radius/tests/classify.test.ts b/claude-code/mods/blast-radius/tests/classify.test.ts new file mode 100644 index 0000000..13cbe4f --- /dev/null +++ b/claude-code/mods/blast-radius/tests/classify.test.ts @@ -0,0 +1,186 @@ +import { expect, test } from 'claude-code/testing' + +import { + classify, + classifyPowerShell, + joinDir, + matchesPsFlag, + tokenize, + tokenizePowerShell, +} from '../hooks/blast-radius.mjs' + +// ---- Bash: rm --------------------------------------------------------------- + +test('rm needs -r or -f to be risky, and bare rm is not', async () => { + expect(classify('rm notes.txt')).toBeNull() + expect(classify('rm -r build')?.kind).toBe('rm') + expect(classify('rm -f build')?.kind).toBe('rm') + expect(classify('rm -rf build')?.kind).toBe('rm') + expect(classify('rm --recursive build')?.kind).toBe('rm') + expect(classify('rm --force build')?.kind).toBe('rm') +}) + +test('rm is still caught through a path or an escaped alias', async () => { + expect(classify('/usr/bin/rm -rf build')?.kind).toBe('rm') + expect(classify('\\rm -rf build')?.kind).toBe('rm') +}) + +test('rm targets are the non-flag args', async () => { + const risk = classify('rm -rf build dist') + expect(risk?.targets).toEqual(['build', 'dist']) +}) + +// ---- Bash: cd / pushd / popd / subshell scoping ----------------------------- + +test('cd, pushd and popd move the folder a later risk is measured in', async () => { + expect(classify('cd build && rm -rf .')?.dir).toBe('build') + expect(classify('pushd build && rm -rf .')?.dir).toBe('build') + expect(classify('cd a && cd b && rm -rf .')?.dir).toBe('a/b') +}) + +test('a cd inside parentheses does not outlive them', async () => { + const risk = classify('(cd build) && rm -rf .') + expect(risk?.dir).toBeNull() +}) + +// ---- Bash: git --------------------------------------------------------------- + +test('git reset --hard is risky, a soft reset is not', async () => { + expect(classify('git reset --hard')?.kind).toBe('git-reset') + expect(classify('git reset --soft HEAD~1')).toBeNull() +}) + +test('git clean is risky and keeps its flags for the dry run', async () => { + const risk = classify('git clean -fdx -e dist') + expect(risk?.kind).toBe('git-clean') +}) + +test('git push --force variants are risky, a plain push is not', async () => { + expect(classify('git push --force origin main')?.kind).toBe('git-push-force') + expect(classify('git push -f origin main')?.kind).toBe('git-push-force') + expect(classify('git push --force-with-lease origin main')?.kind).toBe('git-push-force') + expect(classify('git push origin +main')?.kind).toBe('git-push-force') + expect(classify('git push origin main')).toBeNull() +}) + +test('git checkout -- . and git restore . are risky, a staged-only restore is not', async () => { + expect(classify('git checkout -- .')?.kind).toBe('git-checkout') + expect(classify('git restore .')?.kind).toBe('git-checkout') + expect(classify('git restore --staged .')).toBeNull() +}) + +test('git -C moves where a later risk is measured', async () => { + expect(classify('git -C sub reset --hard')?.dir).toBe('sub') +}) + +// ---- Bash: migrations --------------------------------------------------------- + +test('known migration tools are recognised by their own command', async () => { + expect(classify('alembic upgrade head')?.tool).toBe('alembic') + expect(classify('bin/rails db:migrate')?.tool).toBe('rails') + expect(classify('db:migrate:status')).toBeNull() + expect(classify('npx prisma migrate deploy')?.tool).toBe('prisma') + expect(classify('python3 manage.py migrate')?.tool).toBe('django') +}) + +test('a bare migrate argument is caught for unknown tools, but not for read-only ones', async () => { + expect(classify('./my-tool migrate')?.tool).toBe('unknown') + expect(classify('echo migrate')).toBeNull() + expect(classify('grep migrate schema.rb')).toBeNull() +}) + +// ---- Bash: sudo / prefixes / nice ------------------------------------------- + +test('sudo, env prefixes and nice are stripped before classifying', async () => { + expect(classify('sudo rm -rf build')?.kind).toBe('rm') + expect(classify('sudo -u root rm -rf build')?.kind).toBe('rm') + expect(classify('env FOO=1 rm -rf build')?.kind).toBe('rm') + expect(classify('nice -n 10 rm -rf build')?.kind).toBe('rm') + expect(classify('VAR=1 rm -rf build')?.kind).toBe('rm') +}) + +test('tokenize honours single and double quotes', async () => { + expect(tokenize('rm -rf "my build"')).toEqual(['rm', '-rf', 'my build']) + expect(tokenize("rm -rf 'my build'")).toEqual(['rm', '-rf', 'my build']) +}) + +// ---- PowerShell: Remove-Item -------------------------------------------------- + +test('Remove-Item needs -Recurse or -Force to be risky, and bare Remove-Item is not', async () => { + expect(classifyPowerShell('Remove-Item notes.txt')).toBeNull() + expect(classifyPowerShell('Remove-Item -Recurse build')?.kind).toBe('rm') + expect(classifyPowerShell('Remove-Item -Force build')?.kind).toBe('rm') + expect(classifyPowerShell('Remove-Item -Recurse -Force build')?.kind).toBe('rm') +}) + +test('Remove-Item is case-insensitive and recognises its default aliases', async () => { + expect(classifyPowerShell('REMOVE-ITEM -Recurse build')?.kind).toBe('rm') + expect(classifyPowerShell('ri -Recurse build')?.kind).toBe('rm') + expect(classifyPowerShell('rd -Recurse build')?.kind).toBe('rm') + expect(classifyPowerShell('erase -Force build')?.kind).toBe('rm') + expect(classifyPowerShell('del -Force build')?.kind).toBe('rm') + expect(classifyPowerShell('rmdir -Recurse build')?.kind).toBe('rm') +}) + +test('an unambiguous flag prefix still matches, but an unrelated flag does not', async () => { + expect(classifyPowerShell('Remove-Item -r build')?.kind).toBe('rm') + expect(classifyPowerShell('Remove-Item -rec build')?.kind).toBe('rm') + expect(classifyPowerShell('Remove-Item -f build')?.kind).toBe('rm') + expect(classifyPowerShell('Remove-Item -for build')?.kind).toBe('rm') + expect(classifyPowerShell('Remove-Item -Confirm build')).toBeNull() +}) + +test('Remove-Item targets are the non-flag args', async () => { + const risk = classifyPowerShell('Remove-Item -Recurse -Force build dist') + expect(risk?.targets).toEqual(['build', 'dist']) +}) + +test('a pipeline stage after Remove-Item is not matched: it is a documented v1 gap', async () => { + expect(classifyPowerShell('Get-ChildItem build | Remove-Item -Force')).toBeNull() +}) + +// ---- PowerShell: Set-Location / Push-Location / Pop-Location ------------------ + +test('Set-Location, Push-Location and Pop-Location move the folder a later risk is measured in', async () => { + expect(classifyPowerShell('Set-Location build; Remove-Item -Recurse .')?.dir).toBe('build') + expect(classifyPowerShell('cd build; Remove-Item -Recurse .')?.dir).toBe('build') + expect(classifyPowerShell('Push-Location build; Remove-Item -Recurse .')?.dir).toBe('build') + expect(classifyPowerShell('pushd a; pushd b; Remove-Item -Recurse .')?.dir).toBe('a/b') +}) + +// ---- PowerShell: git and migrations, shared with Bash ------------------------- + +test('git detection behaves the same from PowerShell as from Bash', async () => { + expect(classifyPowerShell('git reset --hard')?.kind).toBe('git-reset') + expect(classifyPowerShell('git push --force origin main')?.kind).toBe('git-push-force') + expect(classifyPowerShell('git push origin main')).toBeNull() +}) + +test('migration detection behaves the same from PowerShell as from Bash', async () => { + expect(classifyPowerShell('alembic upgrade head')?.tool).toBe('alembic') + expect(classifyPowerShell('echo migrate')).toBeNull() +}) + +// ---- PowerShell: quoting ------------------------------------------------------ + +test('tokenizePowerShell honours single quotes (literal) and double quotes (backtick escape)', async () => { + expect(tokenizePowerShell("Remove-Item 'my build'")).toEqual(['Remove-Item', 'my build']) + expect(tokenizePowerShell('Remove-Item "a `"quoted`" word"')).toEqual(['Remove-Item', 'a "quoted" word']) +}) + +// ---- Shared helpers ------------------------------------------------------------ + +test('matchesPsFlag matches any unambiguous prefix of the full name', async () => { + expect(matchesPsFlag('-r', 'Recurse')).toBe(true) + expect(matchesPsFlag('-recurse', 'Recurse')).toBe(true) + expect(matchesPsFlag('-x', 'Recurse')).toBe(false) + expect(matchesPsFlag('recurse', 'Recurse')).toBe(false) +}) + +test('joinDir resolves ~, absolute and relative targets the same for both shells', async () => { + expect(joinDir(null, 'sub')).toBe('sub') + expect(joinDir('a', 'b')).toBe('a/b') + expect(joinDir('a', '/root')).toBe('/root') + expect(joinDir('a', '~')).toBe('~') + expect(joinDir('a', undefined)).toBe('~') +}) diff --git a/claude-code/mods/blast-radius/tests/guard.test.ts b/claude-code/mods/blast-radius/tests/guard.test.ts new file mode 100644 index 0000000..2608d43 --- /dev/null +++ b/claude-code/mods/blast-radius/tests/guard.test.ts @@ -0,0 +1,112 @@ +import { expect, mock, test } from 'claude-code/testing' + +const PANE_PROPS = { + plugin: 'blast-radius', + component: 'Pane', + requestId: 'blast-radius', + surface: 'terminal', + viewport: { columns: 100, rows: 30 }, + props: { + title: 'Blast Radius', + isFocused: true, + bodyColumns: 60, + placement: 'inline', + scroll: { offset: 0, bodyRows: 10 }, + view: {}, + }, +} as const + +type On = (name: string, handler: (...args: any[]) => any) => void + +// The hold loop polls with `sleep`. Answering it instantly makes the loop spin, and the +// kit's find() and press() wait for the mod to go idle, which a spinning loop never does. +// clock.sleep makes each poll wait on the mock clock instead, so the mod is idle between +// polls and the test moves time on by hand. +function stubEngine(on: On, rmStdout: string) { + const clock = mock.clock(on) + on('process.run', async ($: unknown, e: { argv: string[] }) => { + if (e.argv[0] === 'sleep') { + await clock.sleep(250) + return { value: { exitCode: 0, stdout: '', stderr: '' } } + } + return { value: { exitCode: 0, stdout: rmStdout, stderr: '' } } + }) + on('session.cwd', () => ({ value: '/work' })) + on('ui.open', () => ({ value: { isPlaced: true } })) + on('ui.close', () => ({ value: undefined })) + on('ui.toast', () => ({ value: undefined })) + // What Claude Code draws when the mod passes the site on: before a hold, or after one ends. + on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] })) + return clock +} + +test('a non-risky Bash command runs normally', async ($, on) => { + on('tool.call', () => ({ result: 'ran it' })) + + const out = await $.tool.call({ tool: 'Bash', command: 'ls' }) + expect(out).toEqual({ result: 'ran it' }) +}) + +test('a non-risky PowerShell command runs normally', async ($, on) => { + on('tool.call', () => ({ result: 'ran it' })) + + const out = await $.tool.call({ tool: 'PowerShell', command: 'Get-ChildItem' }) + expect(out).toEqual({ result: 'ran it' }) +}) + +test('a risky Bash command opens a pane, and Cancel refuses it', { timeoutMs: 3000 }, async ($, on) => { + const clock = stubEngine(on, '2 2048 1\nbuild/a.txt\nbuild/b.txt\n') + + const call = $.tool.call({ tool: 'Bash', command: 'rm -rf build' }) + await clock.settle() + + const pane = await $.ui.mount(PANE_PROPS) + expect(await pane.find({ type: 'Text', text: /rm -rf build/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /delete 2 files/ })).toBeDefined() + await pane.press({ key: 'cancel' }) + await clock.advance(250) + + const result = (await call) as { deny?: string } + expect(result.deny).toContain('the user pressed Cancel') +}) + +test('a risky PowerShell command opens a pane, and Proceed runs it', { timeoutMs: 3000 }, async ($, on) => { + const clock = stubEngine(on, '1 512 1\nC:\\work\\build\\a.txt\n') + on('tool.call', () => ({ result: 'ran it' })) + + const call = $.tool.call({ tool: 'PowerShell', command: 'Remove-Item -Recurse -Force build' }) + await clock.settle() + + const pane = await $.ui.mount(PANE_PROPS) + expect(await pane.find({ type: 'Text', text: /Remove-Item -Recurse -Force build/ })).toBeDefined() + await pane.press({ key: 'proceed' }) + await clock.advance(250) + + expect(await call).toEqual({ result: 'ran it' }) +}) + +test('a second risky call waits until the first is answered', { timeoutMs: 3000 }, async ($, on) => { + const clock = stubEngine(on, '0 0 0\n') + + const first = $.tool.call({ tool: 'Bash', command: 'rm -rf build' }) + const second = $.tool.call({ tool: 'PowerShell', command: 'Remove-Item -Recurse build2' }) + await clock.settle() + + const firstPane = await $.ui.mount(PANE_PROPS) + // While the first call is held, the pane shows the first command, not the second. + expect(await firstPane.find({ type: 'Text', text: /rm -rf build/ })).toBeDefined() + await firstPane.press({ key: 'cancel' }) + await clock.advance(250) + await first + await firstPane.unmount() + + // The second call was waiting on the same slot; it takes the hold on its next poll. + await clock.advance(250) + const secondPane = await $.ui.mount(PANE_PROPS) + expect(await secondPane.find({ type: 'Text', text: /Remove-Item -Recurse build2/ })).toBeDefined() + await secondPane.press({ key: 'cancel' }) + await clock.advance(250) + + const result = (await second) as { deny?: string } + expect(result.deny).toContain('the user pressed Cancel') +}) From 60121fb786c75d5393a3a8b4ead49c9bf6524aba Mon Sep 17 00:00:00 2001 From: Marc Seguin Date: Sat, 10 Oct 2026 14:26:37 -0400 Subject: [PATCH 2/4] chore(blast-radius): add copyright and SPDX headers to the new test files --- claude-code/mods/blast-radius/tests/classify.test.ts | 3 +++ claude-code/mods/blast-radius/tests/guard.test.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/claude-code/mods/blast-radius/tests/classify.test.ts b/claude-code/mods/blast-radius/tests/classify.test.ts index 13cbe4f..2acd57b 100644 --- a/claude-code/mods/blast-radius/tests/classify.test.ts +++ b/claude-code/mods/blast-radius/tests/classify.test.ts @@ -1,3 +1,6 @@ +// Copyright 2026 Anthropic PBC +// SPDX-License-Identifier: Apache-2.0 + import { expect, test } from 'claude-code/testing' import { diff --git a/claude-code/mods/blast-radius/tests/guard.test.ts b/claude-code/mods/blast-radius/tests/guard.test.ts index 2608d43..e66ddc5 100644 --- a/claude-code/mods/blast-radius/tests/guard.test.ts +++ b/claude-code/mods/blast-radius/tests/guard.test.ts @@ -1,3 +1,6 @@ +// Copyright 2026 Anthropic PBC +// SPDX-License-Identifier: Apache-2.0 + import { expect, mock, test } from 'claude-code/testing' const PANE_PROPS = { From 790c852e8dceb0bde96f3f4b1f612ace998ddf1b Mon Sep 17 00:00:00 2001 From: Marc Seguin Date: Wed, 14 Oct 2026 07:41:21 -0400 Subject: [PATCH 3/4] fix(blast-radius): pass paths to PowerShell without -Command arg joining pwsh -Command does not fill $args from trailing arguments; it joins them onto the script text. The measuring and folder-resolving scripts therefore failed to parse and reported "delete nothing" for real paths, and a path containing a space or a semicolon would have been read as script. Send the values as percent-encoded JSON instead. encodeURIComponent plus an escaped quote leaves nothing in a path that can end the string literal. The scripts read them from $inputs. Add a test that records what the mod sends and checks a hostile path arrives only in encoded form. The earlier tests stubbed process.run and could not see this. --- .../mods/blast-radius/hooks/blast-radius.mjs | 37 ++++++++++--------- .../mods/blast-radius/tests/guard.test.ts | 36 ++++++++++++++++++ 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/claude-code/mods/blast-radius/hooks/blast-radius.mjs b/claude-code/mods/blast-radius/hooks/blast-radius.mjs index a4dedac..e7ed414 100644 --- a/claude-code/mods/blast-radius/hooks/blast-radius.mjs +++ b/claude-code/mods/blast-radius/hooks/blast-radius.mjs @@ -407,22 +407,26 @@ function tokenizePowerShell(text) { return words; } -/** Runs a PowerShell script via argv, preferring pwsh (the tool's own PS7+ edition), falling - back to Windows PowerShell if pwsh isn't on PATH. Script args reach the script only via - $args, never interpolated into the script text. */ -async function runPowerShell($, scriptAndArgs, opts) { +/** Runs a PowerShell script, preferring pwsh (the tool's own PS7+ edition) and falling back to + Windows PowerShell if pwsh isn't on PATH. `pwsh -Command` does not fill $args from trailing + arguments (it joins them onto the script text), so the values travel as percent-encoded JSON. + encodeURIComponent leaves only letters, digits and - _ . ! ~ * ( ) alone, and the quote is + escaped by hand, so nothing in a path can end the string literal or run. The script reads + them from $inputs. */ +async function runPowerShell($, script, values, opts) { + const encoded = encodeURIComponent(JSON.stringify(values)).replace(/'/g, "%27"); + const full = `$inputs = @(ConvertFrom-Json ([Uri]::UnescapeDataString('${encoded}')))\n${script}`; try { - return await $.process.run(["pwsh", "-NoProfile", "-NonInteractive", "-Command", ...scriptAndArgs], opts); + return await $.process.run(["pwsh", "-NoProfile", "-NonInteractive", "-Command", full], opts); } catch { - return await $.process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", ...scriptAndArgs], opts); + return await $.process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", full], opts); } } -// $args[0] holds `dir`; never interpolated into the script text, the same security -// property as the Bash CD_SCRIPT, which reads $1 the same way. -LiteralPath avoids -// reading *, ? or [ in the folder name as a wildcard. +// $inputs[0] holds `dir`, the same security property as the Bash CD_SCRIPT, which reads $1. +// -LiteralPath avoids reading *, ? or [ in the folder name as a wildcard. const CD_SCRIPT_PS = ` -$d = $args[0] +$d = $inputs[0] if ($d -eq "~") { $d = $HOME } elseif ($d.StartsWith("~/") -or $d.StartsWith("~\\")) { $d = Join-Path $HOME $d.Substring(2) } try { (Resolve-Path -LiteralPath $d -ErrorAction Stop).ProviderPath } catch { exit 1 } @@ -432,18 +436,17 @@ async function resolveDirPowerShell($, sessionCwd, dir) { if (dir === "-") { return null; // no `cd -` history stack in PowerShell by default } - const run = await runPowerShell($, [CD_SCRIPT_PS, dir], { cwd: sessionCwd, timeoutMs: 5000 }); + const run = await runPowerShell($, CD_SCRIPT_PS, [dir], { cwd: sessionCwd, timeoutMs: 5000 }); const out = run.stdout.trim(); return run.exitCode === 0 && out !== "" ? out : null; } -// Each target reaches the script only via $args, never interpolated into script -// text, the same security property as Bash's RM_SCRIPT. A target containing *, ? -// or [ is expanded as a wildcard; otherwise matched literally, so a path -// containing those characters isn't misread as a glob. +// Each target reaches the script only through $inputs, the same security property as +// Bash's RM_SCRIPT. A target containing *, ? or [ is expanded as a wildcard; otherwise +// matched literally, so a path containing those characters isn't misread as a glob. const RM_SCRIPT_PS = ` $targets = @() -foreach ($p in $args) { +foreach ($p in $inputs) { $pp = $p if ($pp -eq "~") { $pp = $HOME } elseif ($pp.StartsWith("~/") -or $pp.StartsWith("~\\")) { $pp = Join-Path $HOME $pp.Substring(2) } @@ -465,7 +468,7 @@ async function measureRmPowerShell($, risk, cwd) { if (risk.targets.length === 0) { return { summary: "Remove-Item with no paths", lines: [], note: "No paths to expand." }; } - const run = await runPowerShell($, [RM_SCRIPT_PS, ...risk.targets], { cwd, timeoutMs: 15000 }); + const run = await runPowerShell($, RM_SCRIPT_PS, risk.targets, { cwd, timeoutMs: 15000 }); const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== ""); const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number); if (!found) { diff --git a/claude-code/mods/blast-radius/tests/guard.test.ts b/claude-code/mods/blast-radius/tests/guard.test.ts index e66ddc5..9b7bdb1 100644 --- a/claude-code/mods/blast-radius/tests/guard.test.ts +++ b/claude-code/mods/blast-radius/tests/guard.test.ts @@ -43,6 +43,42 @@ function stubEngine(on: On, rmStdout: string) { return clock } +test('PowerShell paths reach the script only percent-encoded, never as script text', { timeoutMs: 3000 }, async ($, on) => { + const clock = mock.clock(on) + const seen: string[][] = [] + on('process.run', async ($: unknown, e: { argv: string[] }) => { + if (e.argv[0] === 'sleep') { + await clock.sleep(250) + return { value: { exitCode: 0, stdout: '', stderr: '' } } + } + seen.push(e.argv) + return { value: { exitCode: 0, stdout: '0 0 0\n', stderr: '' } } + }) + on('session.cwd', () => ({ value: 'C:\\work' })) + on('ui.open', () => ({ value: { isPlaced: true } })) + on('ui.close', () => ({ value: undefined })) + on('ui.toast', () => ({ value: undefined })) + on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] })) + + const hostile = "has space' $(calc) Write-Host PWNED" + const call = $.tool.call({ tool: 'PowerShell', command: `Remove-Item -Recurse "${hostile}"` }) + await clock.settle() + const pane = await $.ui.mount(PANE_PROPS) + await pane.press({ key: 'cancel' }) + await clock.advance(250) + await call + + const run = seen.find((argv) => argv[0] === 'pwsh' || argv[0] === 'powershell') + expect(run).toBeDefined() + const script = run![run!.length - 1] + const encoded = /UnescapeDataString\('([A-Za-z0-9%\-_.!~*()]+)'\)/.exec(script) + expect(encoded).not.toBeNull() + expect(JSON.parse(decodeURIComponent(encoded![1]))).toEqual([hostile]) + expect(script).not.toContain('Write-Host PWNED') + // Trailing argv elements would be glued onto the script text by -Command. + expect(run!.length).toBe(5) +}) + test('a non-risky Bash command runs normally', async ($, on) => { on('tool.call', () => ({ result: 'ran it' })) From ba136b7ae42a1cc3e91ca5b85daee9ce53619ba1 Mon Sep 17 00:00:00 2001 From: Marc Seguin Date: Wed, 14 Oct 2026 08:05:07 -0400 Subject: [PATCH 4/4] fix(blast-radius): report a failed PowerShell measurement instead of "nothing to delete" When the measuring script failed it printed nothing, and the empty output was read as "no file matches". The pane then told the user a command that deletes files had nothing to delete. Check the exit code and empty output, and say the paths could not be measured, with the first line of stderr. Also split output on \r\n, so Windows line endings do not leave stray carriage returns in the pane, and add a test for the failure path. --- .../mods/blast-radius/hooks/blast-radius.mjs | 10 +++++-- .../mods/blast-radius/tests/guard.test.ts | 26 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/claude-code/mods/blast-radius/hooks/blast-radius.mjs b/claude-code/mods/blast-radius/hooks/blast-radius.mjs index e7ed414..8b06455 100644 --- a/claude-code/mods/blast-radius/hooks/blast-radius.mjs +++ b/claude-code/mods/blast-radius/hooks/blast-radius.mjs @@ -469,8 +469,14 @@ async function measureRmPowerShell($, risk, cwd) { return { summary: "Remove-Item with no paths", lines: [], note: "No paths to expand." }; } const run = await runPowerShell($, RM_SCRIPT_PS, risk.targets, { cwd, timeoutMs: 15000 }); - const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== ""); - const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number); + const [head, ...rest] = run.stdout.split(/\r?\n/).filter((l) => l !== ""); + // A failed script prints nothing, which must not read as "no file matches": that would + // tell the user a command that deletes files has nothing to delete. + if (run.exitCode !== 0 || head === undefined) { + const why = run.stderr.trim().slice(0, 200); + return { summary: `${risk.label} (could not measure it)`, lines: [], note: why ? `PowerShell could not measure these paths: ${why}` : "PowerShell could not measure these paths." }; + } + const [files, bytes, found] = head.split(" ").map(Number); if (!found) { return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so Remove-Item has nothing to remove." }; } diff --git a/claude-code/mods/blast-radius/tests/guard.test.ts b/claude-code/mods/blast-radius/tests/guard.test.ts index 9b7bdb1..3b7c253 100644 --- a/claude-code/mods/blast-radius/tests/guard.test.ts +++ b/claude-code/mods/blast-radius/tests/guard.test.ts @@ -79,6 +79,32 @@ test('PowerShell paths reach the script only percent-encoded, never as script te expect(run!.length).toBe(5) }) +test('a PowerShell measuring failure is reported, not read as nothing to delete', { timeoutMs: 3000 }, async ($, on) => { + const clock = mock.clock(on) + on('process.run', async ($: unknown, e: { argv: string[] }) => { + if (e.argv[0] === 'sleep') { + await clock.sleep(250) + return { value: { exitCode: 0, stdout: '', stderr: '' } } + } + return { value: { exitCode: 1, stdout: '', stderr: 'ParserError: unexpected token' } } + }) + on('session.cwd', () => ({ value: 'C:\\work' })) + on('ui.open', () => ({ value: { isPlaced: true } })) + on('ui.close', () => ({ value: undefined })) + on('ui.toast', () => ({ value: undefined })) + on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] })) + + const call = $.tool.call({ tool: 'PowerShell', command: 'Remove-Item -Recurse -Force build' }) + await clock.settle() + const pane = await $.ui.mount(PANE_PROPS) + expect(await pane.find({ type: 'Text', text: /could not measure it/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /ParserError/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /delete nothing/ })).toBeUndefined() + await pane.press({ key: 'cancel' }) + await clock.advance(250) + await call +}) + test('a non-risky Bash command runs normally', async ($, on) => { on('tool.call', () => ({ result: 'ran it' }))