PowerShell command blocked citing a path unrelated to the command; here-string text content is scanned as code
Summary
When the built-in PowerShell safety check cannot statically parse a command (multi-line / compound scripts), it falls back to a string-splitting scanner. In that fallback path, "the first token that looks like a protected system path" and "whether a delete cmdlet appears anywhere in the script" are tracked as two independent variables spanning the entire multi-statement script. They are then combined at the end to produce a deny message.
Consequences:
- The path shown in the error message can be completely unrelated to what the command actually deletes.
- A command that deletes nothing at all can be blocked, if its text content happens to contain a delete-cmdlet name plus a token that normalizes to
/. - Because the check is fail-closed and blocks the whole invocation, unrelated operations earlier in the same command (e.g. file writes) also never execute.
Observed behaviour
Case A — wrong path in message
A multi-line PowerShell command that (a) read a file, (b) did a string replace, (c) wrote it back via [System.IO.File]::WriteAllText, (d) printed byte counts, (e) ran a Select-String probe, and (f) as its last statement deleted a lock file at a normal user path, was blocked with:
Remove-Item on system path '/' is blocked. This path is protected from removal.
The command contained no reference to /. The actual delete target was an ordinary file under a data directory. Verified that nothing executed (file byte count unchanged), confirming the block happens before execution.
Case B — text content triggers it, with no delete operation present
A later command performed a pure write — no delete of any kind — inserting a block of prose into a Markdown file using a single-quoted here-string (@'...'@). The prose was documentation about Case A, so it contained the cmdlet name as literal text, plus a quoted '/' inside a sentence. Result:
Remove-Item on system path ''/'」=**CLI' is blocked. This path is protected from removal.
The string in the message ('/'」=**CLI) is a fragment of the prose being written — the surrounding text is Chinese, hence the CJK characters. It is not a path, not an argument, and not part of any command. This demonstrates that here-string content is scanned as if it were code.
This case is the more actionable of the two: it means writing documentation that merely mentions a delete cmdlet can be blocked, even when the command performs no deletion at all.
Expected behaviour
- The path reported in the deny message should be the path the delete cmdlet actually targets.
- A command with no delete operation should not be blocked by a delete-path rule.
- String literals / here-string contents should not be scanned as command tokens; or if the scanner cannot distinguish them, it should report "cannot statically verify — needs manual approval" rather than asserting a specific protected-path claim.
Root cause
The fallback scanner splits the script on [;|\n\r{}()&]+ (note: parentheses are split points), then per token:
- sets a "candidate protected path" variable on the first token that passes the protected-path predicate, guarded by an is-undefined check so it is never overwritten;
- sets a boolean when any token normalizes to a delete cmdlet (aliases are normalized correctly —
rm,del,rd,rmdir,ri,eraseall map through); - after both loops, if the boolean is set and the candidate path is defined, it denies using that candidate — even though the two were discovered in unrelated statements.
The protected-path predicate returns true for a bare /: after backslash-to-slash normalization, path.win32.isAbsolute('/') is true and the normalized value equals /.
Caveat on this section: this was derived by extracting strings from the shipped bundle, so identifier names are not meaningful and may change between releases. The mechanism is the claim being made, and it is independently corroborated by Case B, which is reproducible from observable behaviour alone without any inspection of the bundle.
Suggested fix
Scope the candidate-path search to the same statement in which the delete cmdlet was matched, instead of using a script-global variable. If no path token is found within that statement, return "cannot statically verify — requires manual approval" rather than borrowing a token from elsewhere.
This preserves the protection: a genuine Remove-Item C:\Windows still has its path token in the same statement and is still caught. It only removes the cross-statement false positives.
Additionally, string literals and here-string bodies should be excluded from token scanning in the fallback path.
Notes
[System.IO.File]::Delete(...)is not covered by this check at all (member invocation rather than a command); that is how the original operation was eventually completed. Raising this as context rather than as a security report: the check reads as a guardrail against accidental high-risk cmdlet use, not a security boundary. It may be worth being explicit about that in the docs so users do not over-rely on it.- Environment: Windows 11, Claude Code CLI installed globally via npm (
%APPDATA%\npm\node_modules\@anthropic-ai\claude-code), PowerShell 7.
Workarounds (for others hitting this)
- Keep delete operations as their own single-line invocation, without nested parentheses, separate from read/write/verify logic.
- When writing text that mentions delete cmdlet names, write it with a file-editing tool rather than passing it through a PowerShell here-string.
3 Comments
Confirming this on v2.1.220, with an angle that narrows the trigger further and a minimal A/B that isolates it to a single character.
Natural-language words collide with the alias table
The alias normalization you describe (
rm,del,rd,rmdir,ri,erase) means the delete-cmdlet flag can be set by ordinary prose in a non-English language.delis a common Spanish preposition ("of the"). I work in Spanish, so it appears in the echoed section headings of most of my diagnostic scripts.Minimal A/B
Two runs of the same 14-line read-only script —
git diff,git status, and twoSelect-Stringcalls, no deletion of any kind — byte-identical except for one character in an echoed heading:The reported "path"
'\[Herramienta:'is the-Patternargument of aSelect-Stringon a different line.This is your Case B extended: not only is string content scanned as code, a word in a natural language can supply the delete-cmdlet token. Any language with a short function word colliding with the alias table will hit this, and the user has no way to suspect the cause — the message names a token from an unrelated statement, and the word doing the damage does not look like code at all.
Frequency, for triage
Across 145 PowerShell invocations in my session transcripts, 4 were blocked this way. All 4 were read-only, and all 4 contained Spanish
delinside an echoed heading. The reported "paths" wereC:",/c,'\[Herramienta:', and\.exe$'— respectively a fragment of a heading, the switch of acmd /ccall, aSelect-Stringpattern, and part of a-matchregex.A further 16 commands containing
delran without incident. They were short enough to parse as an AST — consistent with your "fallback only on parse failure" analysis.Two notes that may help scope the fix
"copias del disco"; Test-Path '\[Herramienta:'runs fine, presumably because the AST parse succeeds. The failure therefore looks intermittent from the user's side: the same idiom is harmless in a short command and fatal in a long one.One addition to your workarounds list
For other non-English users landing here: avoid
del/rm/ri/rd/rmdir/eraseas standalone words in echoed headings and comments of multi-statement scripts. That is hygiene, not a fix — it only avoids setting the flag.Environment: Windows 11, PowerShell 7, Claude Code v2.1.220 (build 2026-07-24), PowerShell tool enabled.
Confirming on 2.1.229 (Windows 11 Home 10.0.26200, WinGet install, PowerShell tool). Two things to add: the verbatim code from the shipped binary, which removes the caveat on your root-cause section, and a deterministic three-line repro — #69461 notes that minimal synthetic repros wouldn't trigger it, so this may be useful for a regression test.
Deterministic repro: the two commands differ by one string literal
Control — runs normally:
Variant — identical plus one echoed literal, denied:
"A:"is a string literal being echoed. It is not a path, and it is not an argument toRemove-Item— the actual target is an ordinary file under%LOCALAPPDATA%.Note the message quotes
'"A:"'with the double-quote characters still attached. Only the fallback token splitter yields a token with its quotes intact, so the message itself identifies which code path produced the denial.This is language-independent — a bare drive letter, not a word that collides with the alias table. I first hit it on the literal
"=== TEST A: 2.1.224 inside the WinGet Packages dir ===", and separately on/cfrom& cmd /c type $f 2>&1, matching the/cin the frequency table above.Verbatim code
Your root-cause section is correct. Here is the code, read from the 2.1.229 binary at
...\WinGet\Packages\Anthropic.ClaudeCode_Microsoft.Winget.Source_8wekyb3d8bbwe\claude.exe, so the mechanism no longer rests on inference:Kis your delete-cmdlet boolean,jyour never-overwritten candidate path (j===void 0is the is-undefined guard), andif(K&&j!==void 0)return pat(j)is the cross-statement combination.The root-path predicate
bRnrests on these two regexes, which is why a bareA:qualifies alongside/:And the message builder that reports
jverbatim:Identifiers are minified and build-specific. The stable anchors for re-verifying against a later build are the two regex literals above and the string
is blocked. This path is protected from removal.Evidence your suggested fix preserves the protection
Only the command-wide
jcapture is defective. The inner loop is correct — it walks tokens after aremove-itemtoken within the same statement, which is a genuine argument position. That loop is what catches real attempts, and it still fires on 2.1.229:So deleting the
if(K&&j!==void 0)return pat(j)branch and thejcapture removes the false positives without weakening the real check. If a command-wide backstop is still wanted, the function already has a correct next step a few lines later — anaskcarryingCommand contains malformed syntax that cannot be parsed— and falling through to that is strictly better than a deny that names a bystander token as a path it never confirmed.One impact note
Because a
PreToolUsedenial discards the whole tool call, statements earlier in the same command are skipped with no separate notice. In the repro above, theSet-Contentnever runs. Your Case A observed the same thing from the other direction (byte count unchanged). Worth flagging because the deny message reads as "your delete was blocked", not "nothing in your command executed".The same PowerShell rejection persists after upgrading Claude Code from 2.1.231 to 2.1.263.
Environment: Windows 11, native WinGet installation, Windows PowerShell 5.1.
Error:
Remove-Item on system path '"\r"' is blocked. This path is protected from removal.The intended operation was exclusive creation of a Python source file using .NET
FileMode.CreateNew. The complete creation command reviewed before the upgrade contained Python source inside a single-quoted PowerShell here-string. The outer PowerShell performed no deletion. The embedded text contained a Pythondelstatement and a quoted\rliteral.After the rejection under 2.1.263, read-only checks confirmed that the destination directory remained empty and the requested file was absent. No Python test was executed.
This appears consistent with the issue described here, but the internal cause in 2.1.263 has not been independently verified.
Is a fix available or planned that preserves the existing safety checks?