Session dies permanently with `400 ... unexpected end of data` after a Bash result containing NUL bytes

Status Open
Reported on v2.1.270
Maintainer reply None cached
Activity 0 comments · opened Sep 13, 2026

Summary

If a Bash tool result contains NUL characters (U+0000), the next API request is sent with a
truncated body. The server cannot parse it and returns:

400 invalid_request_error
The request body is not valid JSON: unexpected end of data: line 1 column N (char N-1)

The session never recovers. Every following turn rebuilds the same body, so the conversation is
dead from that moment. Automatic model fallback retries the same body against a second model,
which cannot help, because the request is rejected before any model sees it.

Reading any binary as text is enough to trigger it.

Reproduction

Run this in an empty directory (no CLAUDE.md, no MCP servers, no hooks, no plugins). The file is
the published release artifact, renamed to make clear it is used only as a source of bytes. Its
contents are pinned, so the reproduction is identical on every machine.

Reproduced 10/10, running the whole sequence from a fresh directory each time.

rm -rf /tmp/nul-repro && mkdir -p /tmp/nul-repro && cd /tmp/nul-repro

claude -v
# 2.1.270 (Claude Code)

gh release download v2.1.270 --repo anthropics/claude-code \
  --pattern 'claude-linux-arm64.tar.gz'
tar xzf claude-linux-arm64.tar.gz
mv claude poison

claude -p 'Run exactly this with the Bash tool:
grep -ao ".\{80\}\.lsp\.json.\{80\}" ./poison | head -20
Then, as a second step, tell me in one short sentence what that output looks like.' \
  --model haiku \
  --allowedTools Bash \
  --strict-mcp-config \
  --dangerously-skip-permissions

Result, every time:

API Error: 400 The request body is not valid JSON: unexpected end of data: line 1 column 88253

The grep succeeds and returns 80 byte windows of the file's string table, which contain NUL
padding (37 NUL bytes sit within 80 bytes of the .lsp.json string). The first turn completes
normally. The second request fails, and so does every request after it.

The two step prompt matters only because a second request is needed to carry the poisoned tool
result. Any follow up turn fails.

Across the 10 runs the truncation column ranged from 87,869 to 88,442. The variation comes from
the model's own first turn wording, which changes how much content precedes the NUL. The cut point
tracks the position of the NUL, not the size of the body.

Substitute the matching artifact for another platform (claude-darwin-arm64.tar.gz,
claude-linux-x64.tar.gz) as needed.

The file being a claude build is incidental

Nothing here is specific to the claude executable, which is why the reproduction renames it to
poison. It is used only because it is a pinned, published artifact, so everyone running the
reproduction gets the same bytes. I hit this originally while inspecting that binary for unrelated
reasons, which is a coincidence of what I happened to be looking at.

Any binary with NUL padding reproduces it. The shortest form, confirmed 3/3, needs no download:

claude -p 'Run exactly this with the Bash tool:
head -c 300 /bin/bash
Then, as a second step, tell me in one short sentence what that output looks like.' \
  --model haiku --allowedTools Bash --strict-mcp-config --dangerously-skip-permissions

The first 300 bytes of /bin/bash are the ELF header, 225 of them NUL. This is not the primary
reproduction because /bin/bash differs between machines and distributions, so its NUL layout is
not guaranteed.

Expected

Control characters that cannot survive the request path are stripped or escaped before the body is
built, or the tool result is rejected at capture time with a clear error.

Actual

The NUL bytes are stored verbatim in the session transcript and reach the request body, which is
truncated at the first NUL. The session is unrecoverable and no diagnostic points at the cause.

The guard exists on two other paths, but not this one

The Bash tool already rejects control characters in the command it is given:

InputValidationError: command contains control characters that would be hidden in the approval dialog

The Read tool detects a binary and never emits raw bytes: pointing it at the same file returns a
description of the file rather than its contents.

Bash tool output has no equivalent check. A 4 KB result containing 76 NUL characters is accepted
with is_error: false.

Why the error is misleading

The message names no field path, because the body never reaches content validation. It is a parse
failure, so nothing indicates a tool result is responsible. Three plausible explanations are all
wrong:

  • Not a context window problem. One affected session failed at 13,049 tokens in a 1M window,

8 seconds after an auto compaction freed 954,270 tokens. A genuine context error reads
prompt is too long: N tokens > M maximum.

  • Not payload size. The truncation column tracks the position of the NUL, not the body length.

Requests of 400 KB with no NUL succeed. The reproduction above fails at 88 KB.

  • Not the model. Reproduced on Haiku, Sonnet and Opus.

Impact

Any command that reads a binary as text can kill a session with no warning: head, cat,
grep -a, strings. On one machine, 9 of 301 transcripts carry this, each from a single command.
One of them was just head -1 $(which pnpm) against a compiled shim, with no intent to read binary
at all.

Sessions cannot be rescued from inside the product. Resuming replays the stored transcript, so it
fails the same way.

Workaround

Strip control characters from the offending line in the session transcript
(~/.claude/projects/<project>/<session-id>.jsonl):

[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]

Only the message.content[].content field of the affected tool_result needs changing. After that
the session resumes normally. I verified this by A/B on a broken session: the poisoned copy returns
400, and the same file with NULs removed resumes cleanly.

Suggested fix

Sanitise control characters where the request body is built, not only where tool output is
captured. That covers replayed transcripts as well as live tool results, and it makes existing
broken sessions recoverable after upgrade.

Environment

  • Claude Code 2.1.270 (also seen on 2.1.266 and 2.1.218)
  • Linux aarch64
  • Reproduced through the CLI directly, with no SDK host or wrapper involved
  • Reproduced in an empty directory with --strict-mcp-config and no project configuration

View original on GitHub ↗