Repository navigation
fix(tools): refuse to edit a file that isn't valid UTF-8 (#1854) - #1866
diyaavirmani wants to merge 1 commit into
Conversation
string_replace and diff_edit read the file through the file cache, which decodes it as UTF-8, and write the whole file back as UTF-8. In a file that isn't valid UTF-8 (Latin-1, Windows-1252, Shift-JIS) every byte that doesn't decode becomes U+FFFD, so an edit rewrote bytes on lines it never touched (a Latin-1 E9 became EF BF BD) and still reported success. The cache now records whether the bytes it read were valid UTF-8 (buffer.isUtf8), and both tools refuse such a file, in their execute path and in their validator, before any content matching. The refusal is a new validateEditableEncoding() next to validateEditableFormat(), which refuses PDF and DOCX edits the same way, and its message tells the model not to retry. UTF-8 files, including ones with non-ASCII text or a BOM, edit as before. Closes Nano-Collective#1854.
nc-review: comments — 1 important, 3 nits@diyaavirmani — a few things worth a look, none blocking. The change records a 🟠 important ·
const original = await readFile(absPath, 'utf-8');
const formatted = applyTextEdits(original, edits);
await writeFile(absPath, formatted, 'utf-8');Formatting a Latin-1 or Shift-JIS source file rewrites every undecodable byte as U+FFFD and reports "Formatted path (N edits applied)". This is the same data loss as #1854, and arguably worse in intent terms: the user asked for formatting, not to rewrite the file's encoding, so "the write is an approved full overwrite" does not cover it. Now that ⚪ nit · The PR argues ⚪ nit · The metadata path reports ⚪ nit ·
🔴 blocking · 🟠 a reviewer would ask for a change · ⚪ optional Automated code review — correctness, security, design, tests, plus duplicates and scope. A human still decides; this is not a substitute for review and is not exhaustive. The required status checks separately cover lint, formatting, types, unused dependencies, the test suite and the build. This bot never merges. Maintainers can rerun with |
Description
Closes #1854.
string_replaceanddiff_editread the file through the file cache, which decodes it withbuffer.toString('utf-8'), then write the whole file back as UTF-8. In a file that isn't valid UTF-8, such as Latin-1, Windows-1252 or Shift-JIS, every byte that doesn't decode becomes U+FFFD. So an edit rewrote bytes on lines it never touched (a Latin-1E9becameEF BF BD) and still reported success.As agreed in the issue, both tools now refuse these files with a clear error, the same way they already refuse PDF and DOCX files.
Change:
source/utils/file-cache.ts: the cache records whether the bytes it read are valid UTF-8 (notUtf8, frombuffer.isUtf8). It already holds the raw buffer, so this costs no extra read.source/utils/path-validators.ts: a newvalidateEditableEncoding(), next tovalidateEditableFormat()(the PDF/DOCX refusal), with a message in the same style:string_replaceanddiff_edit: both call it in their execute path and in their validator, right after reading the cached file and before any content matching. So a non-UTF-8 file gets this error, not a confusing "content not found".UTF-8 files edit exactly as before, including ones with non-ASCII text or a BOM.
write_fileisn't changed. It only reads the cache to show the previous content, and its write is a full overwrite the user approves.No docs change: the docs don't describe the edit tools' existing PDF/DOCX refusal either.
Type of Change
Changeset
pnpm changeset) describing this change for the changelogTesting
Automated Tests
.spec.ts/tsxfilespnpm test:allcompletes successfully): see below; the only failures also happen without this changeNew tests, covering the cases asked for on the issue, for both tools:
string_replace.spec.tsx:café ✓ 日本) still edits, and the untouched line keeps its bytesdiff-edit.spec.tsx:file-cache.spec.ts:notUtf8is set for a Latin-1 file and not for UTF-8 with non-ASCII text and a BOM.path-validators.spec.ts:validateEditableEncodingaccepts and refuses as expected.With the four source files reverted, every refusal test fails, as do the two cache-flag tests. The UTF-8 and BOM tests pass either way: they guard against refusing good files.
Format, lint, types and knip pass. On macOS the full AVA suite passed 9,729 tests (20 skipped), with three failures that this PR doesn't cause:
gh-pr-worktreetests fail the same way on upstream main (/varvs/private/vartmpdir paths on macOS)bash-executorcancel timing test ("SIGKILL fallback terminates processes that ignore SIGTERM") failed while the machine was under heavy load (load average 13).bash-executor.spec.tspasses 51/51 when run on its own, twice, and this PR doesn't touchsource/services.Manual Testing
End to end through the real CLI on macOS with Node 22.23.3:
--plain run, auto-accept,read_fileonlegacy.txt, then the edit from the issue (old→new).The source of truth is the file's bytes before and after (
xxd -p) and the tool result the model was sent.c72945d3e4c0df94)string_replace, Latin-1:63 61 66 e9 0a 6f 6c 64 0a63 61 66 ef bf bd 0a 6e 65 77 0a, "Successfully replaced"string_replace, Shift-JIS日本+old:93 fa 96 7b 0a 6f 6c 64 0aef bf bd ef bf bd ef bf bd 7b 0a 6e 65 77 0a, "Successfully replaced"diff_edit, Latin-1… ef bf bd …, "Successfully applied"string_replace, UTF-8 control:63 61 66 c3 a9 0a 6f 6c 64 0a63 61 66 c3 a9 0a 6e 65 77 0adiff_edit, UTF-8 control63 61 66 c3 a9 0a 6e 65 77 0aWhen it refuses, the model is sent
⚒ Validation failed: ⚒ Cannot edit "legacy.txt": it is not valid UTF-8, …. The doubled⚒is the existing prefix every validator error gets, the same as the PDF refusal. The fix cherry-picks cleanly onto the v1.32.0 release PR.Checklist