PowerShell safety guard false positive: here-string body text with paths like /requirements.txt blocked as 'Remove-Item on system path'
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.
3 Comments
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:
\dis 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-Itemthat 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 plainAdd-Content/Set-Contentto 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 (AppendAllTextreading the temp file) — never passing the body through the command line.Still reproducible on 2.1.237 (Windows 11 Pro 26200,
pwsh7.6 andpowershell.exe5.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:
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.
remove-item,ri,del,rd,rmdir,rm,erase(case-insensitive, also with.exe/.cmd/.bat/.com).*, 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 withdelbeing an extremely common word in Spanish, a paragraph of normal text inside a long command is enough.This is consistent with the
\dcase reported above and with/requirements.txtin the original report — both are protected-path matches on text that was never a path.Notes
bypassPermissionsdoes not bypass it: the denial returns before permission-mode handling.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-Itemwhich does not exist in the command is indistinguishable from a real one, which is what pushes agents into workarounds.Another reproduction of this on Windows Server 2025 (native
claude.exethrough 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-Itemon an ordinary user file,'\n'inside a JS here-stringRefused with
Remove-Item on system path ''\n'' is blocked. This path is protected from removal.The same command without theRemove-Itemstatement runs normally. The real target (C:\Users\someone\Downloads\vc_p1.txt) is not a protected path.2. No
Remove-Itemstatement at all: the words only appear in the here-string's proseRefused with
Remove-Item on system path '''\n''' is blocked.Nothing in this command deletes anything;Remove-Itemis 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\nliteral. I could not reduce it further.Expected: text inside
@' ... '@is data, not arguments; only actualRemove-Iteminvocations 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-Itemas its own one-line command, never in a command that also holds a here-string.