[BUG] Bash tool silently truncates commands over ~8 KB and reports it as a quoting error
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
The Bash tool silently truncates commands above a size threshold, and the resulting failure is reported as a bash quoting error that points at innocent content. Nothing in the command runs.
The novel part is that this is size-dependent and deterministic. There are several open issues reporting this same error string on Windows (#83243, #82376, #81732, #71591, and the auto-closed #68337), but all describe it as happening on trivial commands or as a session that breaks permanently. None identify command length as the variable. Below the threshold everything works perfectly, including heredocs full of apostrophes and backticks; above it, everything fails.
Measurements. Each probe self-reports the length it received on success, so passing sizes are exact. Failing sizes are approximate, because a truncated command produces no output.
| Tool | Command bytes | Result |
|---|---|---|
| Bash | 7,238 | pass |
| Bash | ~8,300 | fail |
| Bash | ~8,400 | fail |
| Bash | 9,431 | fail (original real-world case) |
| Bash | 10,875 | fail (contained no quote characters at all) |
| PowerShell tool | 9,045 | pass |
| Write tool (content param) | 9,293 | pass |
It is not the transport. The PowerShell tool carried 9,045 bytes and the Write tool a 9,293-byte parameter in the same session, well past where Bash fails. The limit is specific to the Bash tool.
Why the error misleads. The wrapper appears to be <inlined shell snapshot> && ... && eval '<user command>'. Truncation cuts inside that quoting, leaving the wrapper's own ' unclosed, so bash blames whichever quote ends up unmatched:
- With no apostrophes in the command, it blames the wrapper's quote at line 1.
- With prose in the command, it blames an ordinary apostrophe in that prose. In my original case it cited line 76 of 95, which is neither the truncation point nor a defect.
Individually tested and ruled out as causes, all fine below the threshold: apostrophes, backticks, fenced code blocks, && before a heredoc, markdown headings, markdown blockquotes, heredocs generally.
A hypothesis that may connect this to the other open reports
The wrapper fragment leaked in #82376 shows shell-snapshot content (builtin unalias, builtin unset -f) inlined directly into the bash -c string, followed by eval '<user command>'. My snapshot is 2,952 bytes / 44 lines, most of it one very long exported PATH.
If the inlined snapshot consumes the same byte budget as the user command, then the effective limit is not fixed. It shrinks as the snapshot grows, and the snapshot grows with PATH length and accumulated shell state. That would predict exactly what the other issues report:
- #83243, #82376: eventually every command fails, including
echo hi, once the prefix alone approaches the budget. - #82376 specifically: a heredoc with contraction-heavy prose failed first, before trivial commands did. Escaping apostrophes inflates the string, so that call would cross the threshold earliest.
- #82376: a new session fixes it while restarting the app or the machine does not, which fits a fresh, smaller snapshot rather than corrupted state.
- The varying line numbers across reports (86, 87, 117, 120, 123) look like positions inside inlined wrappers of differing sizes, not anything in the user's command.
- #81732 audited 322 snapshot files, found them all syntactically valid, and concluded the fault is in "the unseen command string assembled around snapshot sourcing." A size limit on that assembled string is consistent with that conclusion.
I want to be clear this is a hypothesis. What I have measured directly is the threshold and the tool-specificity; the connection to the other issues is inference from #82376's leaked fragment.
What Should Happen?
- Reject rather than truncate. Return the byte count and the limit. Claude Code already does exactly this elsewhere: the PowerShell permission pre-parser returns
PowerShell parser: command too long (${bytes} bytes, max ${max}). Applying that existing pattern to the Bash path would turn a multi-session mystery into one line of output. - Do not let the wrapper prefix silently consume the user's budget. If the snapshot is inlined, either source it by path or subtract its size when validating.
- Document the limit in the Bash tool description, which currently documents the 30,000-character cap on output and says nothing about a cap on input.
Error Messages/Logs
Exit code 2
/usr/bin/bash: -c: line 1: unexpected EOF while looking for matching `''
The command that produced this contained no ' character anywhere.
Steps to Reproduce
- On Windows with Git Bash, run any Bash tool command whose text is comfortably under 7 KB. It succeeds.
- Run a Bash tool command of roughly 8.5 KB. Content is irrelevant; the minimal case has no quote characters at all:
X=<about 8300 digits>; echo len=${#X}
- Observe exit code 2 and
unexpected EOF while looking for matching''`, with no indication that length was involved and no side effects. - For contrast, send the same payload size through the PowerShell tool or the Write tool. Both succeed.
To self-measure the boundary, X=<digits>; echo len=${#X} reports the exact byte count on any run that survives.
Claude Model
Opus
Is this a regression?
I don't know
Claude Code Version
2.1.226 (Claude Code)
Platform
Anthropic API
Operating System
Windows
Terminal/Shell
Git Bash (Git for Windows) via the Bash tool; PowerShell 7 is the environment's primary shell
Additional Information
Related open issues reporting the same error string on Windows, which this may underlie: #83243, #82376, #81732, #71591, #73701, and the auto-closed #68337.
The practical workaround, once the cause is known, is to stop putting content in the shell: write files with the Write tool and pass the path (gh ... --body-file, git commit -F). That is better practice anyway, which is part of why this stays undiagnosed. Each encounter looks like your own quoting mistake, so you fix it by other means and never file it.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
A failure shape past the threshold that does NOT abort: truncated body, redirect RUNS, exit 0
Your report says "Nothing in the command runs," and that matches the shape we see most often. But a second shape exists past the same threshold, and it is the expensive one because it produces no failure at all. Two measured occurrences in one session (Windows 11 / Git Bash), same call shape, differing only in size:
| body | shell response | file state | exit |
|---|---|---|---|
| ~14.5 KB (179 lines),
cat > FILE <<'NOTEBOOK'| `bash: -c: line 159: unexpected EOF while looking for matching''`| **not created** | 2 |cat >> FILE <<'XEOF'| ~10.6 KB (~127 lines),
|bash: line 265: warning: here-document at line 133 delimited by end-of-file (wantedXEOF')`` | written, silently truncated mid-word | 0 |A 10-line body through the identical call shape works. In the second row bash reaches end of input with the heredoc still open, treats what it has as the body, and executes the redirect: 58 of ~127 lines landed. That fits your wrapper hypothesis cleanly — where the cut falls decides the shape. Cut inside the wrapper's own quoting and you get the unbalanced-quote abort; cut past the redirect but inside the heredoc body and the command is still syntactically runnable, so it runs on a partial document.
The consequence worth designing around: the truncation eats its own self-check
The command ended
…\nXEOF\necho OK; grep -c '' FILE. Because the heredoc never terminated, those verification lines were consumed as body. So the self-check never ran, and its absence emitted nothing — the natural defensive idiom (append, then immediately count or tail in the same call) is disarmed by exactly the failure it was written to catch. Combined with exit 0, nothing anywhere in the chain distinguishes this from a complete write except reading the file's tail in a separate call.The tail is also the worst part to lose: in a report or notebook it is the conclusion and the evidence table, while the intact setup leaves the artifact looking substantial.
Same conclusion as yours on the transport, from a different control
Not the
CreateProcess~32 KB ceiling. A localsubprocess.run(["bash","-c",cmd])on that same machine comfortably passed 30,646-byte commands (requested 600 lines | cmd bytes 30646 | wrote 600) and only died near 40 KB withFileNotFoundError: [WinError 206] The filename or extension is too long. The failures above were at 10–15 KB, well inside that — so the cut is above the shell, consistent with your measurement that the PowerShell and file-write paths carry far larger payloads in the same session.Supporting your recommendation #1 (reject rather than truncate) specifically because of the exit-0 row: a rejection is recoverable in one turn, whereas a silent partial write is discovered — if ever — hours later by someone re-reading the artifact.
Root cause: the MSYS2 runtime silently truncates the
bash -cargument at ~8,186 characters (itsglob()pattern buffer), and Claude Code puts the whole wrapped script in that argumentReproduced today on Windows 11 with Claude Code 2.1.255 (Claude desktop 1.40609) and Git for Windows 2.48.1 (msys-2.0.dll 3.5.7). It is not in Claude Code's wrapper code and not in
CreateProcess(a 20,000-char argv reaches a Python child intact). It is Git'sbash.exeitself, and it reproduces with plainsubprocessand no Claude Code in the loop.Mechanism. On Windows the Bash tool runs
bash.exe -c "<prefix> && eval '<command, apostrophes escaped as '"'"'>' < /dev/null && pwd -P >| <cwd-file>"(buildExecCommandin 2.1.255; the prefix is ~337 chars on my machine:source <snapshot> … && export TEMP=… && shopt -u extglob … && { \builtin unalias -- 'unsetenv'; … }).bash.exeis an MSYS2 program spawned by a non-MSYS parent, so the MSYS2 runtime rebuilds argv from the Windows command line and, unlessMSYS=noglob, runs every argument that contains any of?*["'(){}throughglobify()→glob(pattern, GLOB_TILDE | GLOB_NOCHECK | GLOB_BRACE | GLOB_QUOTE)(winsup/cygwin/dcrt0.cc). The-cstring always contains'and{, so it always takes that path.glob()copies the pattern into a fixedpatbuf(winsup/cygwin/glob.cc):No error, no return code: the copy loop just stops. With
GLOB_NOCHECKthe truncated pattern is handed back as the argument, and bash receives the first 8,186 characters of its-cstring (8,192 minus 1 minus the loop'sMB_CUR_MAXheadroom, measured exactly: a 8,221-char-cstring arrived as 8,186).What the model sees. Where the cut lands decides the shape. With the harness prefix, the budget for the command itself is about 7,770 characters, minus 4 per apostrophe (each
'becomes'"'"'):| cut lands … | result |
|---|---|
| inside the command body | the wrapper's closing
'is gone →bash: -c: line N: unexpected EOF while looking for matching'`, where N is the line of the **last apostrophe before the cut** (bash reports the line where the unterminated quote opened). A random apostrophe in the body gets blamed, the model "fixes" quoting, and it fails again. |'"'"'| exactly on an apostrophe escape (
) | the outer string ends balanced, soeval**runs the truncated script**: a heredoc loses its terminator (warning: here-document at line … delimited by end-of-file), the partial body is written, the self-check that followed the heredoc is swallowed as body text, exit 0. This is the exit-0 shape @oconnorjoseph measured above. |'| after the closing
, inside&& pwd -P >| …| the command runs, then bash executes a mangled tail (/c/Users/user: Is a directory`, exit 1). |Measured in the harness with 19 calibrated commands (every one printed
${#BASH_EXECUTION_STRING}when it survived): every-cstring of 8,117 characters or less ran intact; every one of 8,221 or more was cut, regardless of content (quote-free, comment lines, heredocs, inline Python), deterministically. Replaying the identical strings from Python into the samebash.exegave the same cut at the same offset, including the same "line 50" / "line 75" positions.Minimal reproduction (no Claude Code involved):
Output on this machine:
Why
MSYS=noglobis not the fix on its own. It removes the truncation (30,744-char-cstrings arrive intact), but in noglob mode the runtime no longer honours the\"escapes the spawner emits, so the'"'"'sequences the wrapper uses for apostrophes split the argument (last line above). Any command with an apostrophe would break instead.Fixes that work, from the transport up:
stdin: "pipe": write the wrapped script to stdin and runbash -s(orbash <tempfile>, orbash -c 'eval "$CLAUDE_CMD"'with the script in an environment variable). Pipes, files and the environment block are not globbed. A 30 KB script then runs like a 3 KB one.commandString.lengthafterbuildExecCommandand reject anything over ~8,100 characters with a clear error that names the limit and points the model at the Write tool — a rejection is recoverable in one turn; the silent partial write is discovered hours later, if ever.rgshim function, credential exports,CLAUDE_CODE_SHELL_PREFIX, a long snapshot path) shrinks the budget for every command, and if the prefix alone approached the limit every command would fail with the sameline Nregardless of content. That is the shape reported in #83243, #82376 and #68337; worth checking against this mechanism.Cost on one machine:
unexpected EOF while looking for matchingappears in 20 session transcripts in the last 72 hours (35 hits), several of them multi-hour investigations that concluded "shell quoting is flaky". Related earlier reports: #32563, #48317.Environment: Windows 11 Pro 10.0.26100, Claude Code 2.1.255 (build 2026-08-30, via Claude desktop 1.40609.1.0), Git for Windows 2.48.1.windows.1, msys-2.0.dll 3.5.7-882031da, bash 5.2.37.
Confirmed on Git for Windows 2.55.0 / msys 3.6.10 / Claude Code 2.1.255 (desktop), with two additions
Independent reproduction of @bochen2029-pixel's root cause on newer components: same 8,186-character cut of the
-cstring, no error at the cut. On this machine the wrapper (source <snapshot> … && eval '<command>' < /dev/null && pwd -P >| <cwd-file>) is 432 characters, so the command budget is about 7,750 characters, minus 4 per apostrophe ('→'"'"'). Calibrated in the harness with${#BASH_EXECUTION_STRING}: a 7,737-character command (total 8,169) ran; 8,545 (total 8,977) was cut.CLAUDE_CODE_GIT_BASH_PATHpointed atusr\bin\bash.exechanges nothing (honoured per the debug log, truncates identically), andMSYS=noglobbreaks the'"'"'escaping exactly as described above.1. The trigger is the quoting the spawner applies, not any particular character in the prefix. Measured with a 12 KB single argument (
bash -c 'echo ${#1}' _ <arg>), Node as the native parent:| argument content | Node child receives | MSYS bash receives |
|---|---|---|
| letters only (passed bare) | 12000 | 12000 |
| contains spaces or tabs (spawner wraps it in
"…") | 12000 | 8186 ||
;>$=#but no space (bare) | 12000 | 12000 || newlines, no spaces (bare) | 12000 | 99 (split at the newline) |
So stripping
'or{from the prefix would not help: any-cscript contains spaces, gets quoted, and goes throughglobify(). Only a transport that is not the command line (stdin, file, environment block) avoids it.2. The same glob step halves backslashes. Inside a quoted word
globify()un-escapes\\→\and\"→"beforeglob()runs, so\\written in a Bash-tool command reaches bash as\. Observed in the harness:printf '%s\n' 'p\\q'printsp\q, and anode -e '…"C:\\Program Files\\…"…'snippet reached node with single backslashes and died on\u. Moving the script to stdin fixes this as well.3. Nothing else in the chain is involved. bash is spawned directly by
claude.exe(nocmd.exe);Git\bin\bash.exeis the git-wrapper and passes the tail of the command line through verbatim (fixup_commandlineallocates fromwcslen(cmdline)); Node → Node passes a 30,000-character argument intact;bash -swith a 20 KB script on stdin arrives whole.Minimal reproduction without Claude Code (Node):
Environment: Windows 11 Pro 10.0.26200, Claude Code 2.1.255 via Claude desktop, Git for Windows 2.55.0.windows.5, msys-2.0.dll 3.6.10-710e5275, bash 5.3.15.