Edit/Write decode typed escape sequences into raw control bytes — silently, and un-repairably with the same tools
Summary
Edit and Write decode typed escape sequences into the raw control byte they denote — on both the write side (new_string / content) and the match side (old_string). Two consequences, the second sharper than the first:
- Write side: text intended to land as the 6 characters
\^@lands as a raw0x00byte instead. - Match side: a file that correctly contains the literal escape text cannot be targeted by
old_string— becauseold_stringdecodes too, the tool searches for a raw byte that isn't in the file. So the tools cannot repair the damage they cause, and cannot edit any line that legitimately holds escape text.
The failure is silent. Nothing surfaces at write time. The file reveals itself only later, via git ls-files --eol reporting w/-text, an opaque binary diff, or ripgrep quietly skipping the file. That silence is what makes it a hazard rather than an annoyance.
Not NUL-specific — observed with 0x00 and 0x1F.
Repro
A — write side
Use Edit on any .ts file with new_string:
const SEP = '\^@';
Expected on disk: the 6 characters \^@ between the quotes.
Actual: a raw 0x00 byte.
Verify (do not trust an editor's rendering):
node -e "const b=require('fs').readFileSync('path/to/file');console.log(b.indexOf(0))"
# prints a byte offset instead of -1
B — match side (the sharper demonstration)
Create a file that correctly contains the literal 6-character escape text — write it with node, not with the tools:
node -e "require('fs').writeFileSync('t.ts', `const SEP = '\\^@';`)"
Now try to edit that line with Edit, old_string:
const SEP = '\^@';
Result: String to replace not found — although the text is plainly present and visible in the file.
Net effect: the tool can create the corruption, and then cannot address it. Repair requires stepping outside the tools entirely (a byte-level script).
Why this compounds
- One raw control byte flips git's text/binary classification for the file. Diffs become opaque, and on
core.autocrlf=truerepos CRLF normalization stops — the file reads as perpetually modified. - ripgrep skips binary files, so any lint/audit/codemod tooling built on ripgrep silently stops covering that file. It reports success over a file it never read.
- In our repo this bit twice over: nine tracked source files were affected, and two repo-wide drift-detection suites had been reporting green across all nine because they could not see them. The defect hides behind the exact symptom it causes.
- It reproduces on itself in practice. We hit it three times in one day — including in a code comment describing the hazard, and in the ledger entry documenting it.
Detection
# repo-wide, cheap:
git ls-files --eol | grep 'w/-text'
(grep -c with a NUL pattern does not work from bash — the shell cannot hold a NUL, so the pattern is empty and matches every line.)
Workarounds currently in use
- Never type an escape sequence into
Edit/Write. Write control-char-adjacent code via a byte-level script (node+Buffersplice), or build regexes from string sources at runtime. - In comments and prose, refer to bytes as
U+0000— a form with no decodable representation. - Detect with the
git ls-files --eolcommand above; a CI check that fails on raw C0 bytes (excluding tab/LF/CR) in tracked text-extension files.
Suggested fix
Treat tool input as literal text: do not interpret escape sequences in new_string, content, or old_string. If interpretation is desirable somewhere, make it opt-in and symmetric across write and match, so a file written by the tool remains addressable by it.
Environment
Claude Code in the VS Code extension, Windows 11, git core.autocrlf=true, pnpm monorepo. Also reproduced against \x1b and \^_ in regex character-class literals.