PowerShell command blocked citing a path unrelated to the command; here-string text content is scanned as code

Status Open
Maintainer reply None cached
Activity 3 comments · opened Jul 26, 2026

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:

  1. The path shown in the error message can be completely unrelated to what the command actually deletes.
  2. 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 /.
  3. 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, erase all 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)

  1. Keep delete operations as their own single-line invocation, without nested parentheses, separate from read/write/verify logic.
  2. When writing text that mentions delete cmdlet names, write it with a file-editing tool rather than passing it through a PowerShell here-string.

View original on GitHub ↗

3 Comments

vizi0uz · 2 months ago

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. del is 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 two Select-String calls, no deletion of any kind — byte-identical except for one character in an echoed heading:

"=== longitud del bloque de herramienta mas largo conservado"
# -> Remove-Item on system path ''\[Herramienta:'' is blocked. This path is protected from removal.

"=== longitud de bloque de herramienta mas largo conservado"
# -> runs normally

The reported "path" '\[Herramienta:' is the -Pattern argument of a Select-String on 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 del inside an echoed heading. The reported "paths" were C:", /c, '\[Herramienta:', and \.exe$' — respectively a fragment of a heading, the switch of a cmd /c call, a Select-String pattern, and part of a -match regex.

A further 16 commands containing del ran 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

  • Two-statement reproductions do not trigger it. "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.
  • Your suggested fix would resolve all four of my cases. In every one, the flag-setting token and the reported path came from different statements.

One addition to your workarounds list

For other non-English users landing here: avoid del/rm/ri/rd/rmdir/erase as 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.

mattc517 · 1 month ago

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:

Set-Content -Path "C:\Users\mcamp\AppData\Local\claude-guard-probe.txt" -Value "x"
Remove-Item "C:\Users\mcamp\AppData\Local\claude-guard-probe.txt" -Force
"F-A control ran"

Variant — identical plus one echoed literal, denied:

Set-Content -Path "C:\Users\mcamp\AppData\Local\claude-guard-probe.txt" -Value "x"
"A:"
Remove-Item "C:\Users\mcamp\AppData\Local\claude-guard-probe.txt" -Force
"F-B variant ran"
Remove-Item on system path '"A:"' is blocked. This path is protected from removal.

"A:" is a string literal being echoed. It is not a path, and it is not an argument to Remove-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 /c from & cmd /c type $f 2>&1, matching the /c in 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:

if(!i.valid){let U=ZU(n.replace(/<#[\s\S]*?#>/g," ").replace(/`[\r\n]+\s*/g,"")),K=!1,j;
for(let ce of U.split(/[;|\n\r{}()&]+/)){let ie=ce.trim();if(!ie)continue;
let se=ie.split(rCt);
for(let ee=0;ee<se.length;ee++){let le=se[ee],de=CG(le);if(!de)continue;
  if(j===void 0&&!oce.has(le[0]??"")&&bRn(le))j=le;
  if(kE(de)==="remove-item"){K=!0;
    for(let J of se.slice(ee+1)){if(oce.has(J[0]??""))continue;if(bRn(J))return pat(J)}}
  ...}}
if(K&&j!==void 0)return pat(j);

K is your delete-cmdlet boolean, j your never-overwritten candidate path (j===void 0 is the is-undefined guard), and if(K&&j!==void 0)return pat(j) is the cross-statement combination.

The root-path predicate bRn rests on these two regexes, which is why a bare A: qualifies alongside /:

bk_=/^[A-Za-z]:\/?$/,vk_=/^[A-Za-z]:\/[^/]+$/

And the message builder that reports j verbatim:

function pat(e){return{behavior:"deny",message:`Remove-Item on system path '${e}' is blocked. This path is protected from removal.`,decisionReason:{type:"safetyCheck",reason:"Removal targets a protected system path",classifierApprovable:!1}}}

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 j capture is defective. The inner loop is correct — it walks tokens after a remove-item token 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:

Remove-Item "Q:\" -Force
Remove-Item Q:\Windows -Recurse -Force
Remove-Item on system path '"Q:\"' is blocked. This path is protected from removal.
Remove-Item on system path 'Q:\Windows' is blocked. This path is protected from removal.

So deleting the if(K&&j!==void 0)return pat(j) branch and the j capture 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 — an ask carrying Command 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 PreToolUse denial discards the whole tool call, statements earlier in the same command are skipped with no separate notice. In the repro above, the Set-Content never 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".

laurent-bezier · 1 month ago

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 Python del statement and a quoted \r literal.

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?