[BUG] Windows: Bash tool silently corrupts written file content via command substitution and reports exit code 0
Preflight
- [x] I have searched existing issues. Closest matches: #76208 (same root mechanism, different failure mode — see below), #56361 (closed; slash-command preprocessor), #54222 (closed/not planned; backticks in a Skill glob). None covers silent corruption of written file content with a success status.
- [x] This is a single bug report.
- [ ] Version: reproduced on 2.1.221. Latest is 2.1.223 (2026-08-06). I checked the 2.1.222 and 2.1.223 changelogs: neither contains a fix for shell metacharacter handling in Bash tool arguments (the two Bash entries in 2.1.223 concern permission-prompt display, not command-string expansion), so this should still apply on latest.
---
What's Wrong?
On Windows, using the Bash tool to write file content — via python -c "...", heredocs, or cat >> file — lets the shell expand metacharacters in the command string before the payload reaches its target. Backtick-quoted segments, which are extremely common in Markdown and in code documentation, are consumed as command substitution and disappear from the file that gets written.
The expansion itself is correct POSIX behaviour and I am not asking for it to change. The problem is that the corruption is silent and the tool reports success: exit code 0, no warning, no diff. The agent concludes the write succeeded and moves on, leaving a corrupted file in the user's repository.
In my case this wrote a mangled line into a project tracking document, and also created a stray file named = at the repository root — a > inside the substituted text was parsed as a redirection. Neither was signalled. I found the stray file myself, several minutes later; the agent never noticed.
Relationship to #76208: that issue reports the same root mechanism (bash evaluating $(...) / backticks inside an agent-constructed double-quoted string) but a different failure mode: destructive command execution on macOS. It does not cover silent corruption of written content, and does not mention success reporting. This report is about the reporting/guardrail gap.
---
Steps to Reproduce
Run each of these through the Bash tool on Windows, and observe the output, the exit code, and the working directory afterwards.
1. Backticked text is silently dropped
python -c "print('avant `X.md` apres')"
Observed:
/usr/bin/bash: line 1: X.md: command not found
avant apres
exit code: 0
Intended payload: `avant X.md apresavant apres` — the backticked segment is gone.
Actually produced:
2. Command substitution creates a stray file in the repository
python -c "print('critere: `ts_run >= 2026-08-06`')"
Observed:
/usr/bin/bash: line 1: ts_run: command not found
critere:
exit code: 0
and a file literally named = appears in the current working directory: bash executed the backticked text as a command, in which > was parsed as a redirection. Nothing in the tool result indicates that a file was created.
3. Command substitution executes repository content
If the backticked segment names an existing file — very common when documenting paths, e.g. ` logs/run.csv — bash attempts to **execute that file**. With a CSV, every line is dispatched as a command: I got ~25 lines of command not found` followed by the script's own success message, which reads like a successful run.
---
Expected Behavior
Either the write should not be silently corrupted, or the tool result should make the corruption visible. Any one of these would have prevented the incident:
- Warn when a Bash/PowerShell command string contains unescaped backticks, a heredoc, or an output redirection targeting a file, and point to the Write tool. Cheap, no behaviour change.
- Report files created in the working directory as part of the tool result. The stray
=would have been caught immediately. - Do not present as a clean success a run whose stderr contains
command not foundlines originating from command substitution rather than from the top-level command. - Document it. The Bash tool description already warns about
/dev/nullvsNULand about PowerShell here-strings, but says nothing about command strings being metacharacter-expanded, nor that they must not be used to compose file content.
---
Environment
OS: Microsoft Windows 11, version 10.0.26200.8973
Claude Code: 2.1.221 (latest at time of filing: 2.1.223)
Bash tool shell: GNU bash 5.2.37(1)-release (x86_64-pc-msys) — Git Bash
PowerShell tool: PowerShell 7.6.4 (exposed as a separate tool in the same session)
Python: 3.14.3
---
Additional Context
Frequency and impact, measured over one working session — the same defect hit four times in one night:
| attempt | outcome |
|---|---|
| cat >> report.md <<'EOF' heredoc | unexpected EOF while looking for matching — whole block lost |
| python -c string replacement (×2) | \e and \b consumed → pattern no longer matched → AssertionError |
| python -c editing a Markdown file | line written to disk with segments missing, no error |
| same, second occurrence | corrupted line plus a stray file named = at the repository root |
Every equivalent operation performed by writing a .py file first, with the native Write tool, and then executing it by path succeeded on the first attempt.
Why this matters specifically for an agentic product:
- The tool result is the agent's only feedback channel. Exit code
0plus plausible stdout is indistinguishable from a real success, so self-verification never triggers. - The writes land in user-owned files, not in a sandbox.
- On Windows the session exposes two shell tools with different quoting rules (Git Bash and PowerShell 7). Choosing the right escaping per call is a high-frequency, low-salience decision — exactly the kind that fails.
3 Comments
Version update, since the report carries an explicit reserve on this point (filed on 2.1.221, changelogs checked through 2.1.223).
Re-measured today on 2.1.229 (the running install here, published 2026-08-12). Latest at the time of writing is 2.1.233; I have not run it, but none of the Bash/shell-related changelog entries for 2.1.224 through 2.1.233 touch command-string expansion, substitution warnings, created-file reporting, or success-status handling. Consistent with that, the behaviour is unchanged where I measured it:
| probe (Bash tool) | observed on 2.1.229 |
|---|---|
| `
echo "avantX.mdapres"|X.md: command not foundon stderr, stdoutavant apres— backticked segment gone; reported as success ||
echo "critere:ts_run >= 2026-08-06"| stray file=(0 bytes) silently created in the working directory; reported as success ||
echo "voir./sonde.csv",sonde.csvpresent (3 lines) | the file is dispatched line by line — sixcommand not found— stdoutvoir`; reported as success |Same shell as originally reported (GNU bash 5.2.37), Windows 11 10.0.26200.
Why
echorather than the filedpython -cforms: this machine now carries a local PreToolUse hook that denies inlinepython -c— a guardrail built in response to this very defect, after the writes landed in user files. Command substitution is performed by bash during word expansion, before the target command runs, so the vehicle is irrelevant to the mechanism; the observed outputs match the filed reproductions.Three data points that were not in the original report:
rw-r--r--perls -la).command not foundlines do appear in stderr, but nothing elevates them — exit 0, no warning, no mention of the created file. The stray=was found only by listing the directory afterwards.Two of the rows here are a deterministic harness defect, not shell expansion: the
python -c\e/\blosses are the Windows spawn path halving backslash runs (N → ⌈N/2⌉) — see #85856; it still reproduces on Git for Windows 2.55.0.4. The backtick/$()rows are genuine bash semantics.Your requested fixes 1 and 3 exist as hooks (PreToolUse deny that redirects the model to write-a-file-and-run-the-path; PostToolUse fingerprint detector for "clean exit, mangled run"): https://github.com/borisbat/hooks-and-memes
Thanks @borisbat — the decomposition is correct, and both halves are now measured from here.
The backslash halving reproduces on this environment byte-exactly (
printf '%s\n' 'a\\b'printsa\b; runs of 1–6 arrive asceil(n/2)); measurement details posted on #85856 to keep each thread to its own mechanism.Two precisions for this thread's record:
\e/\brows of the original incident table, the attribution to #85856 is plausible (those commands composed doubled backslashes for Python string literals) but the original command lines were not preserved, so plausible is where I leave it.On the guardrail side: I reviewed hooks-and-memes line by line and adopted
bash_mangling_rules.pyas the engine behind the local net — a policy PreToolUse deny (inlinepython -c, heredocs, backticks,\\runs) plus a two-stage PostToolUse detector. Your corpus passes 57/57 on this box. The README's routing note holds here as well: registering the detector onPostToolUseFailurewas verified live on 2.1.234 (a bare exit-127 command triggered it there), so a detector listening only onPostToolUsenever sees failing commands. And thePROBE_MODEtoggle is what let the halving measurements run through the very gate that now denies them.All of which keeps making the case for Expected Behavior items 1 and 3 shipping in the tool rather than being rebuilt user by user — though the rebuild is at least becoming a shared artifact now.