PowerShell tool: destructive-path guard mis-extracts an unrelated quoted token as the delete target (false-positive blocks)
Bug: PowerShell-tool destructive-path guard mis-extracts an unrelated quoted token as the delete target
Summary
Claude Code's built-in protected-path guard (the one that blocks destructive file ops on system/root paths with "Remove-Item on system path 'X' is blocked. This path is protected from removal.") does a crude static scan of the whole command for a path-like/quoted token near Remove-Item, and can grab a token from somewhere other than the actual delete target. When that wrong token happens to look root-like (e.g. a leading-slash string) or is just an unrelated quoted literal, the guard blocks a command that never targeted a protected path. The legitimate Remove-Item in these cases targeted an ordinary repo-relative file (e.g. .git/index.lock).
Environment
- Claude Code on Windows 11, PowerShell tool (
CLAUDE_CODE_USE_POWERSHELL_TOOL=1, pwsh 7). - Guard fires before execution; no user hook is involved (confirmed: the message string exists in no settings file, hook, or managed-settings path — it's the built-in).
Steps to reproduce / observed cases
Two real false-positives, both in compound (;-joined) commands whose only Remove-Item targeted a benign relative path:
- Commit message with a slash-token. A command that deleted
.git/index.lockand then rangit commitwhose message contained the substring/synthesize.
- Expected: lock removed, commit made.
- Actual: blocked — "Remove-Item on system path '/synthesize' is blocked. This path is protected from removal." The guard grabbed the commit-message text, not the
Remove-Itemargument.
-splitregex literal. A command of the shapeRemove-Item <relative-path> ... ; ($line -split '\s+').
- Expected: file removed, split runs.
- Actual: blocked — "Remove-Item on system path '\s+' is blocked. This path is protected from removal." The guard grabbed the regex literal
'\s+'as the "path."
A related, previously-noted variant: when the Remove-Item target is an unresolvable variable (e.g. Remove-Item $lock), the guard assumes / (root) and blocks.
Intermittency
The mis-extraction is inconsistent — minimal synthetic repros (e.g. Remove-Item .\nonexistent.txt -ErrorAction SilentlyContinue; $m = "add /synthesize route") did not trip it, while the real-world compound commands above did. So the trigger depends on command shape (likely the position/quoting of tokens relative to Remove-Item), not merely the presence of a slash-string. This makes it hard for users to predict or avoid.
Impact
- Legitimate commits and cleanup commands are blocked, wasting tool calls and forcing awkward workarounds (
[System.IO.File]::Delete('<literal>'), splitting commands, or falling back to Bashrm -f). - The guard cannot be disabled or scoped by the user, so there is no configuration-level mitigation — only command-rewriting.
Suggested fix
Parse the actual Remove-Item target rather than any quoted substring in the command:
- Bind to the value of
-Path/-LiteralPath, or the first positional argument of theRemove-Iteminvocation specifically. - Restrict protected-path matching to tokens that are genuine filesystem paths (drive-letter
C:\…, UNC\\…, or resolvable repo-relative paths). Ignore regex/option strings, command-argument values of other commands in the pipeline (e.g.git commit -m …), and unrelated quoted text. - For unresolvable-variable targets, prefer "allow + warn" (or resolve the literal assignment when present) over assuming
/.
Keep the genuine protection — Remove-Item C:\Windows\…, Remove-Item /, recursive-force on system roots should still block. The ask is only to stop blocking commands that merely mention a path-like or quoted string elsewhere.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Additional case: the guard also fires on
git rm(not justRemove-Item), and can extract an empty pathFresh instance today on Windows 11 / PowerShell tool (pwsh 7), while extracting a subdirectory into its own repo. Notable for two reasons: (1) the command was
git rm, notRemove-Item— so the scanner keys on the barermtoken, widening the blast radius to git subcommands; and (2)git rm --cachedis non-destructive — it only unstages from the index, it never touches the working tree.Command (interactive — only unstages two dirs from the git index, deletes no files):
Blocked with:
The extracted "path" was empty (
'') — the scanner saw thermsubcommand, couldn't parse a target past the-r --cached --quietflags and the two trailing args, and fell back to an empty//path → blocked.Same session, the unresolved-variable variant also fired (consistent with the note in the original report):
Workarounds that parse cleanly: give
git rman explicit single quoted path after--(git rm -r --cached -- "relative/path"), or use a literal path with[System.IO.File]::Delete('C:\...\literal'). The trip-wires are the multi-flag/multi-arg form and unresolved-variable targets.Net: the guard should (a) not key on a bare
rmtoken insidegit rm— especiallygit rm --cached, which deletes nothing — and (b) never fall back to/or''when it can't confidently extract a target; an unparseable target shouldn't block on a guessed root path.Also reproduces on Windows PowerShell 5.1 (
powershell.exe), and — notably — fires on commands that contain noRemove-Item,rm, orgit rmat all.Two blocks today, neither command containing any delete/destructive cmdlet (only process enumeration, or file create/copy):
Get-CimInstance Win32_Process) and pulling a UUID from each command line with-match "(?:--resume|-r)\s+$uuid":``
``Remove-Item on system path '\s+$uuid' is blocked. This path is protected from removal.
New-Item+Copy-Item+Set-Content— no delete), reusing the same\s+regex literal in single quotes:``
``Remove-Item on system path '\s+' is blocked. This path is protected from removal.
So the
\s+-as-target extraction (case 2 of the original report) does not require an actualRemove-Item/rmin the command — the regex literal alone trips the "Remove-Item on system path" rule. (The onlyrmsubstring present was insideFormat-Table, which was also present in a command that passed, so that substring isn't the trigger on its own.)Intermittency, sharpened: the identical single-quoted literal
'(?:--resume|-r)\s+([0-9a-fA-F-]{36})'passed in one read-only command and was blocked in another that additionally didCopy-Item/Set-Content. Consistent with the "depends on command shape" note — here the co-occurring file-write cmdlets seem to matter.Workaround that avoids it: drop the regex and tokenize with .NET —
"$s".Split([char[]]@(' ', [char]9), [System.StringSplitOptions]::RemoveEmptyEntries)— no\s+, no block.Environment: Claude Code 2.1.210 (PowerShell tool, Windows PowerShell 5.1 /
powershell.exe), Windows 11.Likely the same root cause as #73524.
Pointer, because this thread is where the missing minimal repro was called out ("minimal synthetic repros ... did not trip it").
Here is a deterministic one on 2.1.229, Windows 11, PowerShell tool. The two commands differ by a single echoed string literal.
Control — runs:
Variant — add one line, denied every time:
Two notes on why this one reproduces where the earlier attempts didn't:
^[A-Za-z]:\/?$as well as the/forms discussed above, so"A:"qualifies while/synthesizeand friends depend on other branches. It is also language-independent, unlike the Spanishdelcollision reported on #81378.if(!i.valid){...}. Two statements parse fine and pass, which is exactly the intermittency described here. The four-line form above is enough to fail the parse.The full write-up, with the verbatim 2.1.229 code — the two script-global variables and the
if(K&&j!==void 0)return pat(j)line that combines them across unrelated statements — is on #81378: https://github.com/anthropics/claude-code/issues/81378#issuecomment-5330356143That comment also shows
Remove-Item Q:\Windows -Recurse -Forcestill blocking on 2.1.229, i.e. the statement-scoped inner loop catches genuine attempts and the cross-statement branch can be removed without weakening the guard.