Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Only write entries that are worth mentioning to users.
## Unreleased

- Kosong: Stop sending an empty `anthropic-beta` header when no beta features are declared — adaptive thinking removes the interleaved-thinking beta, which previously left an empty header value that some backends reject
- Tools: `StrReplaceFile` now refuses to edit a file that is not valid UTF-8 instead of silently corrupting it — the whole-file round trip replaced every undecodable byte with U+FFFD, anywhere in the file, including bytes far from the edit and invisible in the approval diff

## 1.49.0 (2026-07-16)

Expand Down
23 changes: 23 additions & 0 deletions src/kimi_cli/tools/file/replace.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,29 @@ async def __call__(self, params: Params) -> ToolReturnValue:
# Read the file content
content = await p.read_text(errors="replace")

# This tool reads the whole file, edits the string, and writes the whole
# string back, so every undecodable byte in the file — including bytes
# nowhere near the edit — would come back as U+FFFD and be written out as
# EF BF BD. Refuse rather than silently rewrite bytes the edit never asked
# to touch. A U+FFFD present in the decoded text is only a symptom: it may
# equally be a real U+FFFD stored in the file, so confirm against the raw
# bytes before rejecting. The strict decode below is deliberate and is
# caught, not propagated, so it cannot panic on malformed UTF-8.
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",
)

original_content = content
edits = [params.edit] if isinstance(params.edit, Edit) else params.edit

Expand Down
56 changes: 56 additions & 0 deletions tests/tools/test_str_replace_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,59 @@ async def test_replace_empty_strings(
assert not result.is_error
assert "successfully edited" in result.message
assert await file_path.read_text() == "Hello !"


async def test_replace_refuses_file_with_undecodable_bytes(
str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath
):
"""A file that is not valid UTF-8 is left byte-for-byte alone."""
file_path = temp_work_dir / "invalid.txt"
# The undecodable byte is nowhere near the edit, on a line the edit never mentions.
original_bytes = b"alpha\nbeta \xff gamma\ndelta\n"
await file_path.write_bytes(original_bytes)

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

assert result.is_error
assert "not valid UTF-8" in result.message
# Without the guard the file is rewritten with \xff replaced by \xef\xbf\xbd,
# growing by two bytes on an edit that only asked to touch "alpha".
assert await file_path.read_bytes() == original_bytes


async def test_replace_allows_file_containing_real_replacement_character(
str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath
):
"""U+FFFD stored in the file is legitimate content, not a failed decode."""
file_path = temp_work_dir / "fffd.txt"
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"


async def test_replace_allows_crlf_file(
str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath
):
"""CRLF files must not be mistaken for undecodable ones.

Reads translate CRLF to LF, so any detection that compares the decoded text
against the raw bytes would reject every Windows-line-ending file. (That the
write then normalizes the endings to LF is a separate bug, #2191.)
"""
file_path = temp_work_dir / "crlf.txt"
await file_path.write_bytes(b"alpha\r\nbeta\r\n")

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

assert not result.is_error
assert b"ALPHA" in await file_path.read_bytes()
Loading