Skip to content

fix(StrReplaceFile): refuse to edit files that are not valid UTF-8 - #2595

Open
shoemoney wants to merge 3 commits into
MoonshotAI:mainfrom
shoemoney:fix/str-replace-undecodable-bytes
Open

fix(StrReplaceFile): refuse to edit files that are not valid UTF-8#2595
shoemoney wants to merge 3 commits into
MoonshotAI:mainfrom
shoemoney:fix/str-replace-undecodable-bytes

Conversation

@shoemoney

@shoemoney shoemoney commented Aug 6, 2026

Copy link
Copy Markdown

Related Issue

Resolve #2591

Description

StrReplaceFile decodes the whole file with errors="replace", applies the edit to the string, and writes the whole string back. Any byte in the file that is not valid UTF-8 — including bytes nowhere near the edit — comes back as U+FFFD and is written out as EF BF BD. The file's length and contents change outside the requested edit, and the approval diff cannot show it, because the diff is built from the already-lossy string.

before: b'alpha\nbeta \xff gamma\ndelta\n'          25 bytes
after:  b'ALPHA\nbeta \xef\xbf\xbd gamma\ndelta\n'  27 bytes   # edit only asked to touch "alpha"

This PR detects the lossy decode and returns a ToolError instead of writing. The check runs before the approval request, so a corrupting edit is never offered for approval in the first place.

Why this direction

#2591 laid out three options. This is option 2 — refuse the edit. @ayaangazali independently confirmed the bug at cbc15c07 and narrowed it to the same two candidates, so to be explicit about why this one:

If you would rather have option 1 or 3, say so and I will rewrite this.

Two things I checked so a later change does not undo them

U+FFFD in the decoded text is only a symptom. The file may legitimately contain one. The raw bytes are re-read and strictly decoded to tell an original U+FFFD from a failed decode. That second read only happens when a U+FFFD is present, so a file without one — the overwhelming majority — still costs exactly one read, as before.

Detection cannot be a byte comparison against the decoded text. kaos.local.readtext opens without newline="", so reads translate CRLF to LF, while writetext passes newline="". Any check of the form content.encode() != raw_bytes therefore rejects every Windows-line-ending file. test_replace_allows_crlf_file locks that down. (That the write then normalizes those endings to LF is the separate bug in #2191, and is deliberately untouched here.)

The strict decode is deliberate and is caught, not propagated, so it cannot panic on malformed UTF-8 — which is what the errors="replace" convention exists to prevent. The read that produces the content the tool actually uses still specifies errors="replace", so the convention holds as written.

Not in this PR

replace.py:170 writes with errors="replace", which is inconsistent with the project's own rule ("Writing files and encoding Python strings to bytes do not require errors="replace"") and with the sibling tool at write.py:158. After this guard it is provably a no-op, since the content is now known to round-trip. I left it alone to keep this to one concern — happy to drop it in a follow-up if you want it gone.

Testing

Three tests added to tests/tools/test_str_replace_file.py:

Test Guards
test_replace_refuses_file_with_undecodable_bytes The bug. Asserts the file is byte-for-byte unchanged. Fails on main (the edit succeeds and the file grows by two bytes).
test_replace_allows_file_containing_real_replacement_character No false positive on a file that genuinely contains U+FFFD.
test_replace_allows_crlf_file No false positive on CRLF files.

Full suite: 2933 passed with this branch vs 2930 passed on cbc15c07 — the same 26 pre-existing failures (tests/ui/test_usage.py, tests/utils/test_editor.py) before and after, exactly +3 from this PR. ruff check, ruff format --check, and pyright are all clean.

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked the related issue, if any.
  • I have added tests that prove my fix is effective or that my feature works.
  • I have updated the changelog (added the entry by hand under ## Unreleased; make gen-changelog shells out to kimi itself, which I did not want to run against your repo).
  • I have run make gen-docs to update the user documentation — no user-facing docs describe this behavior, so there was nothing to regenerate.

Open in Devin Review

StrReplaceFile decodes the whole file with errors="replace", applies the
edit to the string, and writes the whole string back. Any byte in the file
that is not valid UTF-8 — including bytes nowhere near the edit — comes
back as U+FFFD and is written out as EF BF BD, so the file changes outside
the requested edit and the approval diff cannot show it, because the diff
is built from the already-lossy string.

Detect the lossy decode and return a ToolError instead of writing. The
check runs before the approval request, so a corrupting edit is never
offered for approval in the first place.

U+FFFD in the decoded text is only a symptom: the file may legitimately
contain one. The raw bytes are re-read and strictly decoded to tell the
two apart, and only when a U+FFFD is present, so a file with no U+FFFD —
the overwhelming majority — still costs exactly one read as before.

The strict decode is deliberate and is caught rather than propagated, so
it cannot panic on malformed UTF-8, which is what the errors="replace"
convention in tests_ai/test_encoding_error_handling.md exists to prevent.

Fixes MoonshotAI#2591

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens StrReplaceFile to avoid silent file corruption by refusing to edit files that are not valid UTF-8, preventing lossy errors="replace" round-trips from rewriting undecodable bytes outside the requested edit.

Changes:

  • Add a pre-approval guard in StrReplaceFile that detects lossy UTF-8 decoding and returns a ToolError instead of writing.
  • Add tests covering: refusal on invalid UTF-8 bytes, allowing a real U+FFFD character, and avoiding false positives on CRLF files.
  • Add a changelog entry describing the behavior change.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/kimi_cli/tools/file/replace.py Detects undecodable UTF-8 bytes before requesting approval; refuses the edit to prevent corruption.
tests/tools/test_str_replace_file.py Adds regression tests for undecodable bytes, legitimate U+FFFD, and CRLF files.
CHANGELOG.md Documents the new refusal behavior for StrReplaceFile on non-UTF-8 files.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/kimi_cli/tools/file/replace.py Outdated
Comment on lines +142 to +155
if "�" in content:
try:
(await p.read_bytes()).decode("utf-8")
except UnicodeDecodeError as decode_error:
return ToolError(
message=(
f"`{params.path}` is not valid UTF-8 "
f"(byte 0x{decode_error.object[decode_error.start]:02x} at offset "
f"{decode_error.start}). Editing it with StrReplaceFile would "
"replace that byte, and every other undecodable byte in the file, "
"with U+FFFD. No changes were made."
),
brief="File is not valid UTF-8",
)
Comment thread tests/tools/test_str_replace_file.py Outdated
Comment on lines +276 to +284
original_content = "alpha\nbeta � gamma\ndelta\n"
await file_path.write_text(original_content)

result = await str_replace_file_tool(
Params(path=str(file_path), edit=Edit(old="alpha", new="ALPHA"))
)

assert not result.is_error
assert await file_path.read_text() == "ALPHA\nbeta � gamma\ndelta\n"
Matches the existing convention in tests/ui_and_conv/test_prompt_history.py:67
and test_prompt_placeholders.py:153, which both write the sentinel as an escape
rather than a literal glyph. Binding read_bytes() to a name also makes it obvious
the strict decode and the error message read the same buffer.
@shoemoney

Copy link
Copy Markdown
Author

Thanks — both Copilot comments checked against the source. Adopted one, and the other does not reproduce.

Adopted: "�" over the literal glyph. Correct, and the precedent is the repo's own — tests/ui_and_conv/test_prompt_history.py:67 and tests/ui_and_conv/test_prompt_placeholders.py:153 both write the sentinel as an escape. Mine were the only three literal glyphs in the diff. Fixed in e318cc0, along with binding read_bytes() to a name so it is obvious the strict decode and the error message read the same buffer.

Not adopted: the IndexError on decode_error.start. I could not construct one. start is always a valid index into object for a UnicodeDecodeError from the UTF-8 codec, including the truncated-sequence case the comment names:

b"abc\xe2\x82".decode("utf-8")
# UnicodeDecodeError: ... invalid continuation byte / unexpected end of data
# e.start == 3, len(e.object) == 5  ->  e.object[3] is fine

I brute-forced it rather than argue from the docs: 47,883 distinct UnicodeDecodeErrors — every 1- and 2-byte sequence, a sampled 3-byte space over the interesting lead bytes, plus targeted truncations, overlongs, surrogates and out-of-range scalars (\xed\xa0\x80, \xc0\x80, \xf5\x80\x80\x80, \xf4\x90\x80\x80). Zero cases where 0 <= start < len(object) did not hold. The decoder points start at a real byte by construction; there is no truncation shape that moves it past the end.

Happy to add a guard anyway if you would rather not depend on that, but it would be unreachable code and I would rather not add it silently.

@shoemoney

Copy link
Copy Markdown
Author

Worth recording here since it came up on #2591: this PR does not use a content.encode() != raw round-trip check, precisely because of the newline asymmetry @ayaangazali traced (readtext uses universal newlines, writetext passes newline=""). That check would reject every CRLF file.

The gate here is narrower: it only looks at the raw bytes when U+FFFD is already present in the decoded text, and then rejects solely on bytes.decode("utf-8") raising — so a CRLF file with no undecodable bytes never reaches the byte comparison at all, and a file containing a genuine U+FFFD is still editable (covered by test_replace_allows_file_containing_real_replacement_character). Line endings are untouched either way.

Still happy to rewrite as direction 1 or the byte-level splice if a maintainer prefers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

StrReplaceFile corrupts undecodable bytes outside the edited region

2 participants