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 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 changes 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.

This is option 2 from #2591, refuse the edit. surrogateescape on both ends needs the errors type in kaos.path widened and is an exception to the convention in tests_ai/test_encoding_error_handling.md; byte-level splicing is a rewrite of how the tool applies edits. Refusing is small, cannot panic, and turns silent corruption into an error the user can see. The cost is that the tool now declines edits it currently performs by corrupting the file. If you would rather have option 1 or 3, say so and I will rewrite this.

Two design notes:

  • U+FFFD in the decoded text is only a symptom, since the file may legitimately contain one. When U+FFFD is present, the raw bytes are re-read and strictly decoded to tell an original U+FFFD from a failed decode. A file without one still costs exactly one read. The strict decode is caught, not propagated, so it cannot panic on malformed UTF-8, and the read that produces the content the tool uses still passes errors="replace".
  • Detection cannot be content.encode() != raw_bytes. kaos.local.readtext opens without newline="", so reads translate CRLF to LF, while writetext passes newline="". That comparison would reject every CRLF file. test_replace_allows_crlf_file locks this down. The write normalizing those endings to LF is the separate bug in [Windows] StrReplaceFile silently converts entire file from CRLF to LF, forcing Agent to abandon native tools for Python workarounds #2191 and is untouched here.

Not in this PR: replace.py:170 writes with errors="replace", which the project's own rule says is not needed on writes and which write.py:158 does not do. After this guard it is a no-op, since the content is known to round-trip. Happy to drop it in a follow-up.

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 on this branch vs 2930 on cbc15c07, with the same 26 pre-existing failures (tests/ui/test_usage.py, tests/utils/test_editor.py) before and after. ruff check, ruff format --check, and pyright are 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.

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.

@shoemoney

Copy link
Copy Markdown
Author

This has been open since Aug 6 and no CI has ever run on the head commit. commits/e318cc06/status returns total_count: 0 and the check-runs list is empty, so no workflow was triggered rather than one failing. Other outside PRs in this repo do have check runs on their head commits, so it looks specific to this PR.

The change makes StrReplaceFile refuse to edit files that are not valid UTF-8, with tests. Happy to rebase onto current main if that helps it pick up a run.

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