Windows/Git Bash: Bash tool silently halves backslashes in commands (MSVCRT vs MSYS2 command-line encoding mismatch)
Bug report: Bash tool silently halves backslashes on Windows (Git Bash / MSYS2)
Summary
On Windows with Git Bash, every run of backslashes in a Bash tool command is
halved before bash parses anything: n backslashes arrive as ceil(n/2).
Quoting cannot prevent it — single quotes, double quotes and quoted heredocs
(<<'EOF') are all affected identically, because the corruption happens at
process-spawn time, before bash lexes a single character.
Cause: Claude Code encodes its command line with MSVCRT quoting rules, but
the target (bash.exe) is an MSYS2 binary that decodes with a different
convention. MSYS2 performs an un-escaping pass that Claude Code never escaped
for, so one backslash is eaten from every pair.
Severity
Two failure modes, and the second is the concerning one:
# loud - fails immediately
"C:\\Users\\x" -> "C:\Users\x" -> SyntaxError: (unicode error)
truncated \UXXXXXXXX escape
# SILENT - exit code 0, wrong result
print("a\\nb") -> print("a\nb") -> prints a real newline
Single backslashes survive, so raw-string regexes like r"\s+" keep working.
That masks the bug until a Windows path or a literal escape appears, and it
means agents can produce silently wrong output with no diagnostic.
This affects any heredoc'd or inlined content containing backslashes — Python,
sed, awk, jq, LaTeX — not just Python.
Environment
- Windows 11 Home 10.0.26200
- Claude Code 2.1.222 (
AppData\Roaming\Claude\claude-code\2.1.222\claude.exe) - Git for Windows bash 5.3.15(1)-release, MSYS2 runtime 3.6.9
- Python 3.13.14
Reproduction A — through Claude Code
Run as a Bash tool command:
printf '%s\n' 'a\\b'
- Expected:
a\\b(bash single quotes are literal; nothing should change) - Actual:
a\b
Reproduction B — standalone, isolates the layer
Does not involve Claude Code. Sends a byte-exact Windows command line from a
native process, so nothing but MSYS2 can alter it:
import subprocess, re
BASH = r"C:\Program Files\Git\bin\bash.exe"
inner = r"printf '%s' 'A:\ B:\\ C:\\\ D:\\\\'" # runs of 1,2,3,4
cmdline = '"' + BASH + '" -c "' + inner + '"'
out = subprocess.run(cmdline, capture_output=True).stdout.decode()
print("sent :", [len(s) for s in re.findall(r"\\+", inner)])
print("received:", [len(s) for s in re.findall(r"\\+", out)])
sent : [1, 2, 3, 4]
received: [1, 1, 2, 2]
The payload is inside bash single quotes, where bash performs no
processing whatsoever. The loss is therefore not bash's.
Root cause
Windows passes a command string, not an argv array, so spawner and spawnee
must agree on how to parse it. They don't:
| | convention for a double-quoted argument |
|---|---|
| Claude Code (and every native spawner: Node, PowerShell, CPython) | MSVCRT: backslashes literal, except doubled immediately before a quote |
| bash.exe (MSYS2 runtime) | \\ → \, \" → " |
MSYS2 runs an extra un-escaping pass the sender never escaped for.
Confirmed by reading the live command line of the spawned bash viaGet-CimInstance Win32_Process. Claude Code's encoding is correct MSVCRT — a
run of 4 backslashes before a quote was correctly emitted as 8. The mismatch is
purely in which convention the receiver applies.
The trigger is specifically double-quoted arguments. Bare arguments are
untouched:
| command line | MSYS2 bash | native MSVCRT receiver |
|---|---|---|
| bare a\b a\\b a\\\b a\\\\b | 1,2,3,4 ✓ | 1,2,3,4 ✓ |
| quoted "a\b" "a\\b" … | 1,1,2,2 ✗ | 1,2,3,4 ✓ |
Since the Bash tool's command contains spaces, it is always passed as one
double-quoted -c argument — i.e. always in the failing cell.
Proposed fix
When the spawn target is an MSYS2/Cygwin binary, encode arguments for its
convention rather than MSVCRT's:
// inside the double-quoted argument
const msys2Encode = (arg) => arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
Must be conditional on the target. Applying this to a native Windows
executable breaks it in the opposite direction: MSVCRT treats backslashes as
literal, so C:\Users would arrive as C:\\Users.
Verification of the proposed encoder
Measured against the current MSVCRT encoding, same inputs, same invocation
shape (including the eval '...' wrapper Claude Code uses):
| test | current encoding | proposed encoding |
|---|---|---|
| 11-case behaviour battery | 4 failures | 0 failures |
| 800 fuzzed payloads (quotes, spaces, $, backtick, glob metachars) | 21 mismatches | 0 mismatches |
| non-ASCII (accents, CJK, Greek, emoji) | correct | correct |
| quoted args with spaces, python3 -c, globbing | correct | correct |
It is a strict improvement: every case that works today still works, and the
backslash failures are fixed.
What does not work
MSYS=noglob restores backslashes but disables the entire quote-unescaping
pass, including \". Since a native spawner must encode inner quotes as \",
every command containing a double quote then breaks:
echo "result: ok" -> result:
python3 -c "print('hi there')" -> bash: syntax error near unexpected token `('
Please don't ship that as a workaround.
Workaround for users
Write the script to a file (the Write tool is byte-faithful) and run the file.
The Write tool, the PowerShell tool, script files and bash→bash spawns are all
unaffected; only the Windows→MSYS2 command line corrupts.
15 Comments
Independently confirmed, and one datapoint for the root-cause analysis: the collapse reproduces byte-for-byte on Git for Windows 2.55.0.4 (bash 5.3.15, msys 3.6.9) with Claude Code 2.1.236 — runs of 2/4/8 backslashes arrive as 1/2/4, in single quotes and quoted heredocs alike. Upgrading Git changes nothing, so this is the harness's spawn encoding, as you diagnosed. (For contrast: the sibling non-ASCII → exit 127 failure on old Git is cured by the Git upgrade — different layer.)
Until the spawn path is fixed I published hooks that guard the class: PreToolUse denies commands carrying a collapsing run and points the model at write-a-file-and-pass-the-path; PostToolUse/PostToolUseFailure flags mangled output so the model doesn't trust the side effects. Corpus of real mangled commands as the test suite: https://github.com/borisbat/hooks-and-memes
Independent confirmation on a second environment, plus your test corpus run here.
Repro A, byte-exact (Bash tool command):
printf '%s\n' 'a\\b'printsa\b.Runs 1–6, single-quoted payload,
printf '%s'vehicle — immune to echo escape interpretation by construction:1,2,3,4,5,6 → 1,1,2,2,3,3 — exactly
ceil(n/2), extending the issue's 1–4 table to 6. An earlier pass withechogave the same six values, with the escape-interpretation confound ruled out separately (echo 'x\ny'prints literally on this box;xpg_echounset).Corpus: cloned the repo and ran
tests/test_bash_mangling.pyon this environment — 57/57 pass.Environment:
Version spread on the collapse so far: your report (Claude Code 2.1.222, bash 5.3.15 / msys 3.6.9), your re-probe (Git for Windows 2.55.0.4, Claude Code 2.1.236, per the hooks-and-memes README), and this box (Git for Windows 2.52.0, Claude Code 2.1.234) — byte-identical in all three, consistent with the harness spawn encoding rather than anything on Git's side.
Mitigation running locally since today, for anyone landing here meanwhile:
bash_mangling_rules.pywired behind a PreToolUse deny and a PostToolUse detector (registered for both result events — a nonzero Bash exit routes throughPostToolUseFailure, verified live on 2.1.234, as the repo README documents). ThePROBE_MODEtoggle is what let the measurements above run through the very gate that now denies such commands.Two additional controls from a Windows/Git Bash repro, in case they help triage. Both point the
same way as the MSVCRT/MSYS2 diagnosis in the issue body.
1. PreToolUse hooks are not involved. Worth stating because this environment had a PreToolUse
hook that rewrites every Bash command via
updatedInput, which is exactly the shape that wouldexplain the bug. Two controls cleared it:
updatedInputat all) collapsesbackslashes identically.
2. The loss happens inside single quotes, which bash is not permitted to alter. That alone
places the corruption before bash parses the line, independent of any shell-level explanation.
Chain measured end to end, reading the emitted command back out of the session transcript:
| Stage | Backslashes |
|---|---|
| Emitted by the model (from transcript JSONL) | 2 |
| PreToolUse hook, in / out | 2 / 2 |
| Received by bash | 1 |
Mitigation that makes it non-silent, which is the worst property of this bug. When a doubled
backslash collapses in a generated Python file, Python 3.12 emits a
SyntaxWarningand runs themangled script anyway with exit 0, so a corrupted file is written and looks fine. Setting
PYTHONWARNINGS=error::SyntaxWarning
turns the same input into a hard
SyntaxErrorwith exit 1 and nothing written. Python 3.14 doesthis by default. It does not fix the corruption, but it converts a silent wrong-output failure into
a loud one, which was the difference between catching this in seconds and not catching it at all.
Separately, authoring file content with the Write tool rather than through a heredoc avoids the
class entirely, since Write takes a path and bytes with no shell parsing between.
@emel-purely — the end-to-end chain you measured (2 at emission, 2/2 through
the hook, 1 at bash) matches what I measured on my environment on 2026-08-20,
and ruling out PreToolUse hooks is a useful control to have on record.
One correction on the
PYTHONWARNINGSnote, measured today on Python 3.14.3(Windows 11, same box as my earlier comment):
It does not. An invalid escape sequence — the post-collapse state of a
doubled backslash — is still a plain
SyntaxWarningby default on 3.14.3:the script runs anyway and exits 0, so the corrupted output is still written
silently. The warning text itself says these sequences "will not work in the
future", i.e. not yet:
(Verified with a file-writing probe: in the default case the mangled output
file is really on disk afterwards; with the filter it is absent. A control
script with a correctly doubled escape passes silently in both modes, so the
filter discriminates rather than breaking everything.)
So the mitigation is real and worth having, but it has to be opted into on
3.14 as well.
One scoping caution:
error::SyntaxWarningfilters the whole process, sosetting it machine-wide turns an invalid escape inside any third-party
library into an import-time failure. Safer to set it per harness/launcher
process than in the user environment — and a PreToolUse deny on
\\runs inBash commands (discussed above) still catches the class one step earlier,
before any file is written.
Still present on the current desktop build, plus one finding that bears on mitigation design.
Environment (2026-09-05): Windows 11 Home 10.0.26200; Claude desktop app 1.44121.2.0 running Claude Code 2.1.258; standalone CLI 2.1.251 reproduces identically; Git for Windows bash 5.3.15(1)-release (MSYS2 runtime). Repro A from the issue body prints
a\bunchanged from what was reported for 2.1.222.Confirming the mechanism from the harness side. From inside a Bash tool call I read this bash process's own raw Windows command line (
cat /proc/$$/winpid, thenWin32_Process.CommandLinefor that pid). It is onebash.exe -c "..."string: a shell-snapshot preamble, theneval '<command>'with the command's"escaped as\"and its'as'"'"'. So the harness relies on the MSYS2 un-escaping pass for its own quoting, and that same pass halves the user's\\. Consistent with the body's diagnosis and with theMSYS=noglobresult already noted here (I re-measured it:\\survives,\"handling stops, the wrapper dies withunexpected EOF while looking for matching '"'). A script file passed tobash.exeis untouched.The user-side mitigation is blocked by a hook contract detail. The natural workaround is a
PreToolUsehook that doubles backslashes intool_input.commandviaupdatedInput, so the transport delivers the command as written. But per the hooks reference,updatedInputtakes effect only together withpermissionDecision: "allow"— and this build's strings confirm it ("Hook satisfied user interaction ... via updatedInput ..., bypassing permission prompt"). So a rewriting hook necessarily auto-approves the call. That is fine in abypassPermissionssession and unacceptable in any other mode, where it would remove the permission prompt from every Bash command containing\\. Measured today with such a hook gated onpermission_mode == "bypassPermissions", in two fresh headless sessions (claude -p --dangerously-skip-permissions, CLI 2.1.251): with the hook, Repro A printsa\\b; with the hook disabled,a\b. So the rewrite works and is exact — it is only the permission coupling that keeps it from being a general user-side fix.Two possible resolutions from the maintainers' side, either of which would close this class:
-cstring), or pass the script via a temp file / stdin, which the script-file control shows is untouched.updatedInputapply withpermissionDecision: "ask"(prompt shows the rewritten input) so users can ship a safe local fix without grantingallow.Same defect, for cross-reference: #88561, #89392, #90630, and #88578 (hook commands).
Still present on 2.1.282 (Windows 11 Home 10.0.26200, Claude desktop app, Code tab, Git Bash).
prints
a\btwice. The same text written with the Write tool and printed withcatkeeps both backslashes, so each pair is halved before bash reads the command. In practice it silently changed asedregex ([/\\]arrived as[/\]) and Windows paths inside string literals. The workaround here is to put any text with a backslash pair in a file written by the Write tool.Two more reports of this bug aren't linked here yet:
bash.exe -ccuts off at 8,203 characters, and the result surfaces as a bogus "unexpected EOF" syntax error. Its author found that the truncation and the backslash halving both disappear when the identical command goes to bash on stdin (bash -s), byte-identical up to 259 KB. That supports the stdin option suggested above: one change fixes both bugs.Until there's a fix, a PreToolUse hook that refuses works as a guard. Ours denies any Bash command on Windows that contains two backslashes in a row. A single backslash survives the transport, so it passes. The deny reason tells the model what to do instead: write the text to a file with the Write tool and run it by path, use the PowerShell tool, or use forward slashes in paths. Doubling the backslashes in the hook would give the right bytes, but it runs into the
updatedInputlimitation described above, so a deny is the simpler option. It ships on by default in token-goat if anyone wants a working copy.I've built a hook-based plugin(io-guard) that checks each call before and after it runs. It got much worse with release of Opus 5.5 and i've noticed the agent often circling or solving non-existing issues due to bad edits.
It fixes what it safely can on the fly, moves long heredoc and python -c bodies into a file so the Bash tool can't cut them or halve their backslashes, and rejects the rest with a structured error that names the fix.
It also contains a local mcp server with a few file tools, such as atomic writes, several edits to one file in one call and more
For triage: the same halving is also reported in #92543, #93618, #98622 (2.1.285) and #98733 (2.1.286), which are not linked from this thread yet. Still present here on 2.1.284 in the desktop app (Windows 11, Git for Windows 2.55.0, MSYS2 runtime 3.6.9).
Thanks for the root-cause write-up; it saved us a lot of digging. Three things that may help triage, measured today (2026-10-08):
1. Still present on 2.1.294, which is the newest entry in the changelog; no entry from 2.1.286 to 2.1.294 mentions it. Environment: Windows 11 Pro 10.0.26300, Git for Windows 2.56.0.windows.2 (bash 5.3.15, MSYS runtime 3.6.10), Claude Code 2.1.294 (entrypoint
claude-desktop).printf 'two:[%s] four:[%s]\n' 'a\\b' 'a\\\\b'printstwo:[a\b] four:[a\\b]. Inside the same command,$BASH_EXECUTION_STRINGalready holdsa\banda\\b(one and two backslashes) inside theeval '...'wrapper, while the single backslash in\narrives intact, so each pair is halved before bash parses anything.spawnSyncofbash.exewith positional arguments:a\\barrives intact,a\\b carrives asa\b c,q\\x yarrives asq\x y. A pair is halved only in an argument that holds a space, i.e. one Node wraps in quotes.2. The truncation in #85111 is the same transport, and the two threads do not link each other yet. In the same standalone test, one 10,000-character argument reaches a Node child whole, but reaches
bash.exeas 8,186 characters, and MSYSprintf.exeprints 8,186 of it, both with exit 0; two 5,000-character arguments arrive whole. So the cut is per argument, and the Bash tool puts the whole wrapped command in one argument. That matches the 8,186 measured in #85111. Further reports of one or both symptoms not linked from here yet: #97692, #99410, #100063.3. No setting seems to avoid it. Per the environment-variables page (read today),
CLAUDE_CODE_SHELLandCLAUDE_CODE_GIT_BASH_PATHonly choose whichbashbinary runs; the command still travels as one-cargument. (Documentation reading; we did not test each variable.)Suggestion, for whatever it is worth: delivering the command to Git Bash without the MSYS command-line parser, for example on stdin or in a temporary script file passed by path, would end both symptoms at once. Happy to run a test build or a specific probe on this setup if that would help.
Still reproduces on 2.1.295 (Claude desktop app, Code tab), Windows 11 Pro 10.0.26300, Git for Windows 2.53.0.windows.2, GNU bash 5.2.37.
Minimal repro in the Bash tool:
printf '%s\n' 'a\\b'printsa\binstead ofa\\b. The session transcript (.jsonl) shows the tool input still holds both backslashes, so they are lost between the tool and bash. Same in a quoted heredoc (<<'EOF'). Only runs of two or more are affected (3 → 2, 4 → 2; inside double quotes bash then halves again, 4 → 1). A single backslash,\$,\", a backslash before a backtick and a trailing backslash pass intact. The PowerShell and Write tools are not affected.Workaround in use here: a PreToolUse hook (matcher
Bash) that denies any command containing two consecutive backslashes. Replayed over ~15,000 past Bash commands, it would have denied 1.8% of them, and those commands produced 40 of the 46 Python "invalid escape sequence" warnings in our transcripts.Another repro: Windows 11 Pro 10.0.26200, Claude Code desktop app (Code tab), Bash tool via Git Bash. Hit on 2026-09-30, 2026-10-07 and 2026-10-12.
Single quotes don't protect it:
printf '%s\n' 'Q\\s' | od -c
prints
Q \ s(expectedQ \ \ s).A quoted heredoc gives the same result:
cat <<'EOF' > out.txt
A\\s B\s C:\\Users
EOF
lands on disk as
A\s B\s C:\Users. A lone\sis left alone; only doubled backslashes are halved.Control, PowerShell tool in the same session:
[System.Text.Encoding]::ASCII.GetBytes('P\\s') -join ','
prints
80,92,92,115, so both backslashes leave the model and the loss is on the Bash tool's path to bash.exe. No PreToolUse hook rewrote these commands.Workaround: write scripts with the Write tool and run the file, or use the PowerShell tool.
Thank you all for the detailed reproductions in this thread. They make the cause very clear.
We built Check (
npx @golproductions/check) to address exactly this. It is a PreToolUse hook that readsnode -eandpython -ccode as Git Bash will actually deliver it. If halving has broken the code, Check blocks the command before it runs and instructs Claude to write the script to a file and execute that instead. The broken code never runs, and Claude corrects course on its own.In the interest of transparency: Check currently catches code that no longer parses after halving. It does not yet catch
sed,greporprintfpatterns, or the silent"a\\nb"case. A 14-day free trial is available.For anyone who prefers not to install anything, the workaround remains reliable: have Claude write the script with the Write tool and run the file. Write preserves every backslash.
<img width="1149" height="336" alt="Image" src="https://github.com/user-attachments/assets/6317f55a-304d-4e4d-9f61-796b6ebb689c" />
!Kermit shaking his head
Still present on 2.1.300 (latest). Windows 11 Enterprise 10.0.26200, Claude Code CLI 2.1.300, Git for Windows 2.55.0.windows.3, GNU bash 5.3.15(1)-release, MSYS 3.6.9.
Bash tool command:
Expected (single quotes are literal):
A:\\ B:\\\\ D:\\"x E:\\$x H:a\\\bActual:
A:\ B:\\ D:\\"x E:\$x H:a\\b. Each run of n backslashes not directly followed by"arrives as ceil(n/2). A quoted heredoc (<<'EOF') behaves the same.One point not yet in this thread: Claude Code already ships a transport that is immune. The PowerShell tool starts
pwsh -Command "$__claudeCodeScript = $env:CLAUDE_CODE_SHELL_LAUNCHER_SCRIPT; … Invoke-Expression",so its script never crosses the Windows command line. In the same session,
'A:\\','B:\\\\','H:a\\\b'printed exactly as written, and scripts of 20,668 characters ran intact. Handing the Git Bash wrapper over the same way (an environment variable plusbash -c 'eval "$VAR"', or stdin or a temp file) would fix this issue and the 8,186-character cut in #85111 together.For scale, one machine over about 7 weeks: 85,766 recorded tool calls, with 92 failures (79 incidents in 75 separate agent contexts) attributable to the halving. That is a lower bound, because the silent cases leave no error behind.