[BUG] Bash tool silently truncates commands over ~8 KB and reports it as a quoting error

Status Open
Reported on v2.1.226
Maintainer reply None cached
Activity 9 comments · opened Aug 8, 2026

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?

  1. 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.
  2. 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.
  3. 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

  1. On Windows with Git Bash, run any Bash tool command whose text is comfortably under 7 KB. It succeeds.
  2. 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}
  1. Observe exit code 2 and unexpected EOF while looking for matching ''`, with no indication that length was involved and no side effects.
  2. 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.

View original on GitHub ↗

3 Comments

oconnorjoseph · 1 month ago

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 |
| ~10.6 KB (~127 lines),
cat >> FILE <<'XEOF' | bash: line 265: warning: here-document at line 133 delimited by end-of-file (wanted XEOF')`` | 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 local subprocess.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 with FileNotFoundError: [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.

bochen2029-pixel · 1 month ago

Root cause: the MSYS2 runtime silently truncates the bash -c argument at ~8,186 characters (its glob() pattern buffer), and Claude Code puts the whole wrapped script in that argument

Reproduced 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's bash.exe itself, and it reproduces with plain subprocess and 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>" (buildExecCommand in 2.1.255; the prefix is ~337 chars on my machine: source <snapshot> … && export TEMP=… && shopt -u extglob … && { \builtin unalias -- 'unsetenv'; … }). bash.exe is an MSYS2 program spawned by a non-MSYS parent, so the MSYS2 runtime rebuilds argv from the Windows command line and, unless MSYS=noglob, runs every argument that contains any of ?*["'(){} through globify() → glob(pattern, GLOB_TILDE | GLOB_NOCHECK | GLOB_BRACE | GLOB_QUOTE) (winsup/cygwin/dcrt0.cc). The -c string always contains ' and {, so it always takes that path. glob() copies the pattern into a fixed patbuf (winsup/cygwin/glob.cc):

#undef MAXPATHLEN
#define MAXPATHLEN 8192
…
bufnext = patbuf;
bufend = bufnext + MAXPATHLEN - 1;
…
while (bufend - bufnext >= MB_CUR_MAX) { … *bufnext++ = wc | prot; patnext += clen; }
*bufnext = EOS;

No error, no return code: the copy loop just stops. With GLOB_NOCHECK the truncated pattern is handed back as the argument, and bash receives the first 8,186 characters of its -c string (8,192 minus 1 minus the loop's MB_CUR_MAX headroom, measured exactly: a 8,221-char -c string 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, so eval **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 -c string 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 same bash.exe gave the same cut at the same offset, including the same "line 50" / "line 75" positions.

Minimal reproduction (no Claude Code involved):

import os, subprocess
BASH = r"C:\Program Files\Git\bin\bash.exe"   # identical results with usr\bin\bash.exe
def run(n, env=None):
    body = "echo len=${#BASH_EXECUTION_STRING} #" + "x" * n
    s = f"eval '{body}' < /dev/null && echo TAIL-OK"   # same shape as Claude Code's wrapper
    r = subprocess.run([BASH, "-c", s], capture_output=True, text=True, env=env)
    print(f"sent {len(s):5}  rc={r.returncode}  stdout={' '.join(r.stdout.split())!r:24}  stderr={r.stderr.strip()[:72]!r}")
for n in (8000, 8100, 8120, 8150, 9000, 30000):
    run(n)
print("-- with MSYS=noglob --")
for n in (9000, 30000):
    run(n, env={**os.environ, "MSYS": "noglob"})
print("-- but noglob breaks the wrapper's own apostrophe escaping --")
r = subprocess.run([BASH, "-c", "echo it'\"'\"'s fine"], capture_output=True, text=True, env={**os.environ, "MSYS": "noglob"})
print(f"rc={r.returncode} stdout={r.stdout.strip()!r} stderr={r.stderr.strip()[:80]!r}")

Output on this machine:

sent  8071  rc=0  stdout='len=8071 TAIL-OK'        stderr=''
sent  8171  rc=0  stdout='len=8171 TAIL-OK'        stderr=''
sent  8191  rc=0  stdout='len=8186 TA'             stderr=''        <- tail command truncated to `echo TA`, ran anyway, exit 0
sent  8221  rc=2  stdout=''                        stderr="/usr/bin/bash: -c: line 1: unexpected EOF while looking for matching `''"
sent  9071  rc=2  stdout=''                        stderr="/usr/bin/bash: -c: line 1: unexpected EOF while looking for matching `''"
sent 30071  rc=2  stdout=''                        stderr="/usr/bin/bash: -c: line 1: unexpected EOF while looking for matching `''"
-- with MSYS=noglob --
sent  9071  rc=0  stdout='len=9071 TAIL-OK'        stderr=''
sent 30071  rc=0  stdout='len=30071 TAIL-OK'       stderr=''
-- but noglob breaks the wrapper's own apostrophe escaping --
rc=2 stdout='' stderr="fine: -c: line 1: unexpected EOF while looking for matching `''"   <- argument split at the \" ; "fine" became $0

Why MSYS=noglob is not the fix on its own. It removes the truncation (30,744-char -c strings 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:

  1. Don't put the script on the command line on Windows. The spawn already uses stdin: "pipe": write the wrapped script to stdin and run bash -s (or bash <tempfile>, or bash -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.
  2. Until then, measure commandString.length after buildExecCommand and 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.
  3. The prefix counts against the same 8,186. Anything that grows it (the rg shim 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 same line N regardless 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 matching appears 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.

motkoning · 1 month ago

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 -c string, 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_PATH pointed at usr\bin\bash.exe changes nothing (honoured per the debug log, truncates identically), and MSYS=noglob breaks 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 -c script contains spaces, gets quoted, and goes through globify(). 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 \" → " before glob() runs, so \\ written in a Bash-tool command reaches bash as \. Observed in the harness: printf '%s\n' 'p\\q' prints p\q, and a node -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 (no cmd.exe); Git\bin\bash.exe is the git-wrapper and passes the tail of the command line through verbatim (fixup_commandline allocates from wcslen(cmdline)); Node → Node passes a 30,000-character argument intact; bash -s with a 20 KB script on stdin arrives whole.

Minimal reproduction without Claude Code (Node):

const { spawnSync } = require("child_process");
const bash = "C:/Program Files/Git/usr/bin/bash.exe";
let s = "echo len=${#BASH_EXECUTION_STRING}\n";
while (s.length < 12000) s += "echo L " + "x".repeat(90) + " >/dev/null\n";
console.log(spawnSync(bash, ["-c", s + "echo TAIL-OK"], { encoding: "utf8" }).stdout);            // len=8186, no TAIL-OK
console.log(spawnSync(bash, ["-c", "echo ${#1}", "_", "x".repeat(12000)], { encoding: "utf8" }).stdout); // 12000 (bare argument)

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.

Showing cached comments. Read the full discussion on GitHub ↗