Skip to content

emrg: write overwrites a file the caller has only partly read #2652

Closed

Description

@how2how2how2-arch

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:

  1. 3 998 lines were destroyed by a call that returned error=False and the message Updated ….
  2. 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 write over 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 explicit line_limit, or by the 256 KB MAX_READ_SIZE is a partial read, and read_tool already 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: true that 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

  1. pm25coder commented on Oct 29, 2026

    @pm25coder
    Collaborator

    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. read with no arguments returns 1 002 lines / 44 967 chars with error=False; write of exactly that content returns error=False and Updated … (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.py E6D9FDAD41BB16F4, write_tool.py AA5EBDED0C0C81D2 as 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's content is not the file's text. Every line is formatted f"{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 writes content back replaces the file with a numbered rendering. Two consequences for the fix:

    • "the same file read in full, then written ⇒ succeeds" is satisfiable as a permission question while still corrupting the file. That acceptance case needs its own assertion about what lands, not only about the write being allowed.
    • "whether the caller has been shown all of this file" cannot be decided by comparing the incoming content against the file on disk. It has to be a coverage fact — which line ranges were returned — which read_tool knows at the moment it caps (DEFAULT_MAX_LINES, an explicit line_limit, and the 256 KB MAX_READ_SIZE branch) 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.

  2. how2how2how2-arch commented on Oct 29, 2026

    @how2how2how2-arch
    CollaboratorAuthor

    Handled by #2653

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions