Windows/Git Bash: Bash tool silently halves backslashes in commands (MSVCRT vs MSYS2 command-line encoding mismatch)

Status Open
Reported on v2.1.222
Maintainer reply None cached
Activity 4 comments · opened Aug 11, 2026

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 via
Get-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.

View original on GitHub ↗

4 Comments

borisbat · 11 days ago

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

Sylvain13bdr · 10 days ago

Independent confirmation on a second environment, plus your test corpus run here.

Repro A, byte-exact (Bash tool command): printf '%s\n' 'a\\b' prints a\b.

Runs 1–6, single-quoted payload, printf '%s' vehicle — immune to echo escape interpretation by construction:

sent     : B1:\  B2:\\  B3:\\\  B4:\\\\  B5:\\\\\  B6:\\\\\\
received : B1:\  B2:\   B3:\\   B4:\\    B5:\\\    B6:\\\

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 with echo gave the same six values, with the escape-interpretation confound ruled out separately (echo 'x\ny' prints literally on this box; xpg_echo unset).

Corpus: cloned the repo and ran tests/test_bash_mangling.py on this environment — 57/57 pass.

Environment:

Claude Code      2.1.234 (version of the live runtime process that ran the probes)
bash             GNU bash 5.2.37(1)-release
Git for Windows  git 2.52.0.windows.1
OS               Windows 11, 10.0.26200
Python           3.14.3

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.py wired behind a PreToolUse deny and a PostToolUse detector (registered for both result events — a nonzero Bash exit routes through PostToolUseFailure, verified live on 2.1.234, as the repo README documents). The PROBE_MODE toggle is what let the measurements above run through the very gate that now denies such commands.

emel-purely · 2 days ago

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 would
explain the bug. Two controls cleared it:

  • A command shape that makes the hook bail (pass through with no updatedInput at all) collapses

backslashes identically.

  • Feeding the hook a payload directly returns two backslashes in, two backslashes out.

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 SyntaxWarning and runs the
mangled 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 SyntaxError with exit 1 and nothing written. Python 3.14 does
this 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.

Sylvain13bdr · 1 day ago

@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 PYTHONWARNINGS note, measured today on Python 3.14.3
(Windows 11, same box as my earlier comment):

Python 3.14 does this by default.

It does not. An invalid escape sequence — the post-collapse state of a
doubled backslash — is still a plain SyntaxWarning by 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:

$ python probe.py
probe.py:15: SyntaxWarning: "\d" is an invalid escape sequence. Such
sequences will not work in the future. Did you mean "\\d"? A raw string is
also an option.
FICHIER_ECRIT                # exit 0 — mangled bytes written, no error

$ PYTHONWARNINGS=error::SyntaxWarning python probe.py
SyntaxError: "\d" is an invalid escape sequence. Did you mean "\\d"? A raw
string is also an option.    # exit 1 — nothing runs, nothing written

(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::SyntaxWarning filters the whole process, so
setting 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 in
Bash commands (discussed above) still catches the class one step earlier,
before any file is written.