Edit/Write decode typed escape sequences into raw control bytes — silently, and un-repairably with the same tools

Status Open
Maintainer reply None cached
Activity 0 comments · opened Jul 30, 2026

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:

  1. Write side: text intended to land as the 6 characters \^@ lands as a raw 0x00 byte instead.
  2. Match side: a file that correctly contains the literal escape text cannot be targeted by old_string — because old_string decodes 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=true repos 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 + Buffer splice), 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 --eol command 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.

View original on GitHub ↗