[BUG] Write emits raw U+0000 where the model intended the six-character NUL escape sequence - committed TypeScript source file classified as binary by git/GitHub
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?
While writing a TypeScript file, the model intended to produce a template literal that joins strings with the six-character escape sequence \^@ as a collision-proof separator:
const key = `${a}\^@${b}\^@${c}`;
What actually landed on disk contained raw U+0000 (NUL) bytes at those two positions instead of the escape-sequence text. The runtime string is identical, so nothing downstream complained:
- TypeScript compiled it fine
- the linter/formatter passed it untouched
- tests passed
- the file looked completely normal in every editor and in
Readoutput (the NULs are invisible)
The corruption only surfaced after the commit was pushed: git's binary heuristic (NUL byte in the first ~8 KB) kicked in, so git diff showed - - (numstat) and GitHub rendered the file as "Binary file not shown" in the PR — no diff, no review possible on that file.
Two aggravating factors made it hard to diagnose and fix from inside the session:
- When the agent then tried to repair the file, it reproduced the same failure twice more in other tools — a
bun -eBash command and agit commit -F -heredoc both got rejected by the harness guardInputValidationError: command contains control characters that would be hidden in the approval dialog. So the raw control character is being produced on the model/tool-call side (the Write tool faithfully wrote what it received) — this is not only Edit-tool escape processing (related: #66058). The guard exists for Bash but not for Write/Edit content, which is exactly where it silently corrupted a source file. - Grep silently returns "no matches" on NUL-containing files (#66120), so the standard way of hunting for the bad content hides it.
The fix ultimately required writing a small script to a file (where \\^@ could be spelled safely in code) and running it to rewrite the raw bytes as escape text.
What Should Happen?
Whichever layer is dropping the escaping level (model emitting one level too few in the JSON tool arguments, or tool-side processing), the practical ask is defense in depth:
- Write/Edit should flag or reject raw C0 control characters (other than
\t,\n,\r) appearing incontent/new_stringfor text files — the same class of guard the Bash tool already applies to commands. At minimum a warning in the tool result ("wrote N raw control characters at offsets …") would let the agent notice and self-correct immediately instead of shipping an invisible binary-classified source file. - Ideally the tool result for a successful Write/Edit that produced NUL bytes should say so, since every other feedback channel (compiler, linter, tests, Read, Grep) reports the file as healthy.
Error Messages/Logs
# git side (the only signal, post-commit):
$ git diff --numstat -- path/to/file.ts
- - path/to/file.ts # git classifies the source file as binary
# GitHub PR view: "Binary file not shown."
# harness guard that caught the *later* reproductions in Bash (but nothing guards Write/Edit):
InputValidationError: command contains control characters that would be hidden in the approval dialog
Byte-level inspection of the written file showed exactly two 0x00 bytes at the template-literal separator positions where the \^@ escape text was intended.
Steps to Reproduce
- Ask Claude Code to write a TypeScript file containing a Map grouping key built as a template literal with
\^@separators, e.g. "join the id parts with NUL escape separators so the compound key can't collide". - Inspect the written file byte-by-byte (e.g.
od -cor a small script counting0x00bytes) — intermittently the file contains raw NUL bytes instead of the 6-character escape sequences. (grep/Grepwill NOT find them, per #66120.) - Commit the file:
git diff --numstatreports it as binary; pushed to GitHub, the file shows "Binary file not shown".
Intermittent (model-generation dependent), but it reproduced five times in one session across three different tools — twice via Write (the second time while the agent was drafting this issue body, which mentions the escape sequence several times: all five mentions came out as raw NULs and had to be repaired by script), once in a Bash command, once in a commit-message heredoc, and once more in this issue's own title when first passed to gh (caught by the same Bash-tool guard). The rate is not negligible when escape-dense content is involved.
Claude Model
claude-fable-5
Is this a regression?
Unknown
Last Working Version
_No response_
Claude Code Version
2.1.198 (Claude Code)
Platform
Anthropic API
Operating System
Windows 11
Terminal/Shell
PowerShell / Git Bash (Bash tool)
Additional Information
Related issues, for triage context:
- #66058 — Edit tool corrupts literal escape sequences (closest open report; this report adds evidence the raw control characters originate on the model/tool-call side, and that Write is affected, not just Edit)
- #60033 — Write tool: escape-dense content intermittently yields malformed tool-input JSON (same boundary)
- #53153 — control characters injected into written HTML, agent failed to diagnose
- #66120 — Grep silently hides NUL-containing files, which masks this corruption
- #57197 — the mirror-image failure (literal
\ntext where real newlines were intended), suggesting a general one-escaping-level-off failure mode around JSON tool arguments