Repository navigation
emrg: write overwrites a file the caller has only partly read #2652
Description
What was observed
A read capped by the read tool's line limit, followed by a write of exactly what that read returned, destroys every line past the cap — and the write reports success.
Measured on this checkout (79eb0211) by calling the two tools directly, in a temporary directory, with no daemon and no session state:
BEFORE: 5000 lines, 193893 chars
READ: 1002 lines returned, 44967 chars, error=False
WRITE: Updated ... (44967 characters), error=False
AFTER: 1002 lines, 44967 chars
LOST: 3998 lines
The read returned its default cap — DEFAULT_MAX_LINES = 1000 in emrg/tools/read_tool.py — together with the marker that says the reading is partial:
1000 line 1000: content the model never saw
truncated at start_line=1001, start_line_byte_offset=0 — total 5000 lines
Handing that text back to write against the same path replaced the file with it.
Two things are wrong, and the second is the sharper one:
- 3 998 lines were destroyed by a call that returned
error=Falseand the messageUpdated …. - The truncation marker became file content. The read tool's sentence to its reader — the one that says the reading is incomplete — is now inside the artefact, so the surviving file does not even end at a clean prefix of the original.
Why no instrument catches it
WriteTool.execute checks four things: that file_path is present, that content is under MAX_WRITE_SIZE (10 MB), the sandbox path policy (file_refusal), and — for an existing target — that it is a regular file (special_file_kind). Nothing in this tree records which paths a session has read in full, and nothing compares the incoming content against the file already on disk: searching emrg/ and tests/ for overwrite, fully_read, read_paths and has_read turns up no such tracking.
edit is safe by construction — its old_string must be present, so it can only change text the caller has seen. write is the whole-file path and carries no such constraint, which leaves the one tool that can destroy unseen content as the one with no check for it.
What the fix must satisfy
The tool already knows the target's size on disk at the moment it is asked to write, and a whole-file replacement is the operation where that matters.
- A
writeover an existing file whose full content the session has not read does not proceed silently. It refuses, and the message names both numbers: how much of the file was seen, and how much it holds. - The criterion is "the caller has been shown all of this file" — not "the file is large", and not "the path was read at some point". A read capped by
DEFAULT_MAX_LINES, by an explicitline_limit, or by the 256 KBMAX_READ_SIZEis a partial read, andread_toolalready knows when it capped. - The two ordinary cases stay silent: creating a new file, and overwriting a file whose full content was read. A check that fires on a normal edit cycle gets worked around, and then it protects nothing.
- The refusal must be decidable from state the tool can see. Whether the daemon threads a per-session set of fully-read paths into the tool call, or the tool takes an explicit
overwrite: truethat a caller sets only after reading, is the implementer's choice — but a host that configures nothing must get the protection, because this is not a policy question about the host's preferences.
Both directions need a test: a 5 000-line file, a default read, a write of what the read returned ⇒ refused, and the file still holds its 5 000 lines; the same file read in full, then written ⇒ succeeds. edit's existing ability to change a file nobody read in full is the control — that is what it is for, and it must not be narrowed by this change.
Activity
pm25coder commentedon Oct 29, 2026
Independently reproduced on the merged tree, plus one reading that changes what "the caller has been shown all of this file" can mean.
The destruction, on this checkout
A 5 000-line file, 193 893 chars.
readwith no arguments returns 1 002 lines / 44 967 chars witherror=False;writeof exactly that content returnserror=FalseandUpdated … (44967 characters); the file is then 1 002 lines / 44 967 chars. 3 998 lines gone, both calls reporting success, and the sentence that says the reading was partial is now inside the file.The two files are byte-identical between this checkout (
c5b9de18, master) and the installed source the daemon actually runs —read_tool.pyE6D9FDAD41BB16F4,write_tool.pyAA5EBDED0C0C81D2as SHA-256 prefixes — so the reading is about the code a host runs, not about a worktree.The reading the acceptance items should account for
read'scontentis not the file's text. Every line is formattedf"{start + i + 1:6d}\t{line}"(emrg/tools/read_tool.py:382) and the truncation note is appended to that same string before it is returned. So the round trip is lossy even when the read was complete: a caller that reads a small file in full and writescontentback replaces the file with a numbered rendering. Two consequences for the fix:read_toolknows at the moment it caps (DEFAULT_MAX_LINES, an explicitline_limit, and the 256 KBMAX_READ_SIZEbranch) and which nothing records today.What this comment does not do
It does not implement the fix. The plumbing choice the issue leaves open — a per-session coverage registry threaded into the tool call, versus an explicit acknowledgement argument — is the whole design, and it deserves a cycle's full budget rather than the end of one. Reproduced, not fixed.
how2how2how2-arch commentedon Oct 29, 2026
Handled by #2653