Skip to content

edit tool: BlockAnchorReplacer threshold=0.0 causes silent overwrite of user manual edits (TOCTOU) #29726

Closed as not planned

Description

@HaleTom

Description

The edit tool's BlockAnchorReplacer (replacer #3 in the 9-step chain at packages/opencode/src/tool/edit.ts) silently accepts files that have changed between the agent's last Read and the Edit call. It uses first-line/last-line anchors with SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0 for single-candidate blocks.

When the user manually inserts a comment or line inside a multi-line block (between the agent's Read and Edit), the edit succeeds silently and overwrites the user's change instead of failing with oldString not found.

Root cause — two gaps in packages/opencode/src/tool/edit.ts:

  1. BlockAnchorReplacer threshold 0.0 for single candidates — when there's only one block with matching first/last line anchors, any middle content passes the match. A Levenshtein distance of 0.0 threshold is effectively "match everything between the anchors." User-added lines in the middle don't block the match.

  2. No TOCTOU protection — the file is read fresh at edit.ts#L122 and written at edit.ts#L151 with zero guards between:

    • stat() is called but mtimeMs is never read or compared
    • No content-vs-prior-read hash check
    • The inotify watcher (@parcel/watcher, configured in file/watcher.ts) fires events on change but the edit tool never subscribes to them
    • The semaphore lock (line 88) only serializes concurrent edit tool calls within the same process — it does NOT guard against external (user-editor) mutations

Steps to reproduce

  1. Agent reads a file containing a multi-line block with unique first/last lines (e.g. dirs=() to done)
  2. User manually inserts a comment (e.g. # Exclude comments and blank lines) inside that block via their editor
  3. Agent calls edit with oldString matching the pre-change block

Expected: Edit fails with oldString not found in content because the file has changed.

Actual: Edit succeeds — BlockAnchorReplacer matches the block by first/last line anchors (single candidate, threshold 0.0), replaces the entire block, and silently drops the user's inserted line.

Suggested fix

Option A — Raise the single-candidate threshold from 0.0 to a stricter value (e.g. ≥ 0.9) so that user-added lines in the middle cause the replacer to fall through to the next replacer instead of silently matching.

Option B — Add an mtime guard: after reading the file, compare info.mtimeMs against a stored last-read timestamp. If the mtime changed, fail with a clear error.

Option C — Content snapshot: track the agent's last-read hash per file, verify current content matches before applying the edit.

Recommended: A + B together.

Operating System

Linux (inotify)

Terminal

Ghostty

Activity

  1. github-actions commented on Jul 28, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions