PowerShell safety guard false positive: here-string body text with paths like /requirements.txt blocked as 'Remove-Item on system path'

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

Summary

The PowerShell command safety analyzer produces a false positive that blocks legitimate commands: when a command passes a multiline single-quoted here-string (@'...'@) to an external CLI (e.g. gh pr create --body @'...'@), and the here-string text contains root-like paths (e.g. a Markdown list mentioning /app/config.py, /requirements.txt, /passenger_wsgi.py), the tool call is blocked with:

Remove-Item on system path '/requirements.txt,' is blocked. This path is protected from removal.

No Remove-Item/rm/del is present anywhere in the command. Note the trailing comma in the reported path — the analyzer appears to be tokenizing prose from inside the here-string literal instead of treating it as an opaque string argument.

Environment

  • Claude Code on Windows 11 Pro (10.0.26200), PowerShell tool (pwsh 7+)
  • Model: claude-fable-5
  • No user-configured PreToolUse hooks (verified: only a SessionStart hook in settings)

Repro

gh pr create --base develop --title "x" --body @'
Verified today: `/app/config.py`, `/requirements.txt`, `/passenger_wsgi.py` return **403**.
'@

Also reproduced with gh issue comment <n> --body @'...'@ containing the same kind of path list. Same command with the identical text passed via --body-file <file> works fine (that is the workaround we now use).

Expected

The here-string is a single literal string argument; its contents should not be parsed as commands/paths by the protected-path guard.

Actual

The call is blocked before execution with the "Remove-Item on system path ... is blocked" message, with a path fragment (including punctuation from the surrounding prose) extracted from the string literal.

View original on GitHub ↗

3 Comments

TheEdwardOz · 2 months ago

Confirming this on Windows 11 (Claude Code 2.1.119, Windows PowerShell 5.1 — so not specific to pwsh 7+), with two additional data points that show the scope is broader than here-strings:

We hit the same false positive 3 times between 2026-07-24 and 2026-07-28, with three distinct triggers:

| # | Payload that triggered it | Actual command |
|---|---------------------------|----------------|
| 1 | multi-line here-string @'…'@ containing markdown prose (·, →, backticks) | [System.IO.File]::AppendAllText(...) — no Remove-Item anywhere |
| 2 | body of a multi-line array literal (no here-string involved) | legitimate command, no Remove-Item |
| 3 | a regex pattern literal containing \d | legitimate command, no Remove-Item |

Instance 3 produced this message, which is especially diagnostic:

Remove-Item on system path '\d' is blocked

\d is not a filesystem path — it is a regex metacharacter the analyzer resolved as a path being deleted. So the analyzer is not just tokenizing here-string bodies: it appears to parse any multi-line body or literal containing metacharacters as executable command text.

One consequence worth flagging beyond the inconvenience: because the error names a Remove-Item that does not exist in the command, the false positive is indistinguishable from a legitimate block from the agent's point of view. The observed agent reaction is to degrade to plain Add-Content/Set-Content to route around the blocked API call — and on PowerShell 5.1 with UTF-8 (no BOM) non-ASCII content, that silently corrupts the file (mojibake). We paid that cost twice before prohibiting the workaround. A security false positive that induces an unsafe workaround is worse than a plain block — if the message cited which fragment triggered the rule, the false positive would at least be recognizable.

Workaround that avoids the whole class (same spirit as --body-file): write the payload to a temp file with the agent's Write tool, then read it from file (AppendAllText reading the temp file) — never passing the body through the command line.

EstebanR-Hub · 1 month ago

Still reproducible on 2.1.237 (Windows 11 Pro 26200, pwsh 7.6 and powershell.exe 5.1).

Adding the piece that I think explains all three reports above: this is gated on command length, not on here-strings, multi-line bodies, or metacharacters. The same command with the same string content passes when short and is blocked when padded past a threshold. That would explain why the trigger has looked inconsistent — here-strings, array literals and regex literals all just happen to make commands long.

Minimal repro

Both commands are read-only. The only difference is the length of the trailing comment:

# PASSES
$a = "del"; $b = "~"; Write-Output "ok"

# BLOCKED — identical code, padded past the threshold
$a = "del"; $b = "~"; Write-Output "ok"; # xxxxxxxx...(padding to ~850 bytes)

Error: Remove-Item on system path '"~"' is blocked. This path is protected from removal.

Note the reported token is '"~"' — with the surrounding quotes attached. The guard is reporting a fragment it never recognized as a string literal.

Measured

Same command shape, only the padding length varies (UTF-8 bytes of the whole command):

| Size | Result |
|---|---|
| 700 B | passes |
| 850 B | blocked |
| 950 B | blocked |
| 980 B | blocked |

So the cutoff sits somewhere between 700 and 850 bytes on this build. I did not bisect it exactly, and I would expect it to move between builds.

What the fallback appears to do

Below the threshold the command is parsed with the real PowerShell AST, which correctly distinguishes code from quoted text. Above it, parsing is skipped and the command is scanned as flat text: quotes (single, double, and typographic) are stripped, the string is split on separators and whitespace, and each token is checked against two sets. A block needs one token from each set, anywhere in the command, in any order — they do not have to form an actual invocation.

  • Deletion aliases: remove-item, ri, del, rd, rmdir, rm, erase (case-insensitive, also with .exe/.cmd/.bat/.com).
  • Protected paths: *, anything ending in /*, /, a bare drive root (^[A-Za-z]:/?$), ~, and direct children of / or of a drive root.

That last set is what makes this fire on ordinary prose: a single letter followed by a colon matches the drive-root pattern. Option B: triggers; Note: does not (two-plus letters). Combined with del being an extremely common word in Spanish, a paragraph of normal text inside a long command is enough.

This is consistent with the \d case reported above and with /requirements.txt in the original report — both are protected-path matches on text that was never a path.

Notes

  • The Bash tool is unaffected. Same content, same session, no block — it uses a separate checker.
  • bypassPermissions does not bypass it: the denial returns before permission-mode handling.
  • The only escape I found is CLAUDE_CODE_USE_POWERSHELL_TOOL=0, which disables the PowerShell tool entirely rather than this check.

Suggestion

If skipping the AST on long commands is deliberate (a timeout or size guard), the safer failure mode would be to surface that — e.g. "command too long to analyze safely, run it from a script file" — rather than silently downgrading to a text scan that cannot tell code from prose. As noted above, a block that names a Remove-Item which does not exist in the command is indistinguishable from a real one, which is what pushes agents into workarounds.

vrayv81-bot · 19 days ago

Another reproduction of this on Windows Server 2025 (native claude.exe through the Agent SDK, PowerShell tool, Windows PowerShell 5.1), with two variants that may help narrow it down. In both the reported "path" is a string literal from inside a here-string, and the whole multi-statement command is refused, so nothing in it runs.

1. Real Remove-Item on an ordinary user file, '\n' inside a JS here-string

$js = @'
javascript:(()=>{let o=[];/* ... */let b=new Blob([o.join('\n')],{type:'text/plain'});/* ... */})()
'@.Trim()
$steps=@()
foreach($p in 1..4){ $steps += @{tool='type_text';text=$js.Replace('PAGE',"$p")} }
$steps | ConvertTo-Json -Depth 4 | Set-Content "$env:TEMP\vc_steps.json" -Encoding utf8; Remove-Item C:\Users\someone\Downloads\vc_p1.txt -ErrorAction SilentlyContinue; $steps.Count

Refused with Remove-Item on system path ''\n'' is blocked. This path is protected from removal. The same command without the Remove-Item statement runs normally. The real target (C:\Users\someone\Downloads\vc_p1.txt) is not a protected path.

2. No Remove-Item statement at all: the words only appear in the here-string's prose

$h = @{Authorization='Bearer <redacted>'}; $msg = @'
[several lines of prose]
The block `Remove-Item on system path ''\n''` comes from the CLI itself. Workaround: `Remove-Item` as a separate one-line command.
[...]
'@; $r = Invoke-RestMethod -Method Post "http://127.0.0.1:8000/..." -Headers $h -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes((@{text=$msg} | ConvertTo-Json))); $r | ConvertTo-Json -Compress; Start-Sleep 50

Refused with Remove-Item on system path '''\n''' is blocked. Nothing in this command deletes anything; Remove-Item is only text inside the here-string.

Shorter variants (the same here-string followed only by Write-Output $m, or with one statement after '@) were not blocked, so the misparse seems to need the here-string plus further statements, and a quoted \n literal. I could not reduce it further.

Expected: text inside @' ... '@ is data, not arguments; only actual Remove-Item invocations and their real path arguments should be checked, and unrelated statements should not be refused.

Workaround we ship to our agents for now: run Remove-Item as its own one-line command, never in a command that also holds a here-string.