Skip to content

plan: fix read-before-edit gate — decouple grant from line-window limit (#728) #729

Description

@btipling

$(cat <<'ENDOFBODY'

Plan header

Field Value
Status IMPLEMENTED
Date 2026-08-20
Type single
Parent N/A
Branch plan/read-file-grant-fix
PR #730
Layers Vercel backend
Reusability impact none
Production mutate? no
Cloud ops path N/A — no Production mutate
Living docs docs/sandbox.md, docs/harness-limits.md

Summary

Fix a catch-22 in the read-before-edit gate: isFullFileReadGrant currently denies edit authorization when the line-window limit clips the display (e.g. a 1085-line file with default limit=1000). The daemon returned the full file content (no byte truncation), but the gate incorrectly treats the line-window clip as a truncated read. After the fix, only offset === 1 + byteTruncated === false gates the grant — line-window limits are a display-only concern and do not block editing.

Goals

# Goal Success signal
1 Files >1000 lines can be edited after a single offset=1 read (no byte truncation) read_file on a 1400-line file with default limit=1000 grants edit; str_replace succeeds
2 Offset ≠ 1 still denies grant (unchanged behavior) offset=40 read still produces (truncated) and blocks edits
3 Byte-truncated reads still deny grant (unchanged behavior) Daemon returns truncated: true → no edit grant
4 Line-window pagination still works for display formatLineWindow unchanged; offset/limit still control the display window

Non-goals / out of scope

  • Raising the READ_FILE_DEFAULT_LIMIT cap (stays at 1000 — generous default, not the bottleneck)
  • Chunked-read grant accumulation (agent uses offset to scroll; only offset=1 is the grant)
  • Daemon-side changes (the daemon returns full content correctly already)
  • Forbidden wiring: none — backend-only, no Wasm, no client-side secrets

Architectural decisions

Decision Options considered Choice Why
Decouple edit grant from line window A) Remove returned >= totalLines check from isFullFileReadGrant / B) Raise default limit to ∞ / C) Accumulate chunked reads A Minimal change; the daemon already returned the full file — the line window is display-only. B would bloat tool results on huge files. C adds state management complexity with no real benefit (if offset=1 and daemon returned full content, the agent has seen the file).
Keep READ_FILE_DEFAULT_LIMIT at 1000 A) Keep / B) Raise / C) Remove A — keep The default is generous; raising would cause huge tool results on large files. The cap is not the cause of the bug — the gate logic is.
Keep formatLineWindow unchanged A) Keep / B) Remove A — keep Display-only function; well-tested; no reason to change

Layer placement

Concern Layer Path(s) Rationale
Grant logic change Vercel backend lib/agent/tools.ts isFullFileReadGrant is the single gatekeeper; lives next to formatLineWindow
Tests Vercel backend lib/agent/tools.test.ts Amend existing unit tests for the new grant behavior
Docs Vercel backend docs/sandbox.md, docs/harness-limits.md Describe the corrected behavior

Current baseline (live code) — verified

Claim Path / symbol Notes
isFullFileReadGrant checks returned >= totalLines (line 137) lib/agent/tools.ts:130-138 verified — BUG IS HERE
READ_FILE_DEFAULT_LIMIT = 1000 (line 101) lib/agent/tools.ts:101 verified — stays unchanged
formatLineWindow splits content by \n, applies offset/limit lib/agent/tools.ts:113-127 verified — stays unchanged
Test asserts isFullFileReadGrant({offset:1, returned:1000, totalLines:1400, byteTruncated:false}) → false lib/agent/tools.test.ts:1510-1517 verified — must flip to true
Daemon readFileTool returns full content (byte-based, no line limit) sandbox/tools.mjs:183-211 verified — correct already
Docs say truncated read "does not authorize edit — including a default 1000-line window on a longer file" docs/sandbox.md:716 verified — must update
Docs say read_file returns "line-numbered window (N→content). Default limit = 1000 lines" docs/harness-limits.md:178 verified — must update

Design

Root cause

isFullFileReadGrant(opts):
  if offset !== 1 || byteTruncated → false  ← correct
  return offset - 1 + returned >= totalLines  ← BUG: line-window clip blocks grant

When the daemon returns 1085 lines (no byte truncation), but the default limit=1000 clips the display window to 1000 lines, returned=1000 < totalLines=1085 → grant denied. The daemon returned the full file — the line window is display-only.

Fix

// Before (line 137):
return opts.offset - 1 + opts.returned >= opts.totalLines;

// After:
return true;
// offset === 1 and byteTruncated === false → full grant, period.

The function becomes a two-gate check:

  1. offset !== 1 → false (reading mid-file is not a full read)
  2. byteTruncated → false (daemon clipped the content)

If offset=1 and not byte-truncated, the daemon returned the full file. The line-window limit is irrelevant to the grant.

What stays the same

Concern Behavior
formatLineWindow Unchanged — still clips to limit lines for display
Tool output format read_file offset=1 limit=1000 lines=1000/1400: — still shows the window stats
(truncated) flag Still appended when fullGrant === false (offset ≠ 1 or byte-truncated)
READ_FILE_DEFAULT_LIMIT Still 1000 — generous display default
Freshness grant recording freshness.recordRead(path, { ...fp, truncated: false }) when fullGrant is true
Gate 2 (stale check) Unchanged — re-stat before mutate still runs

Edge cases

Scenario Before (buggy) After (fixed)
50-line file, offset=1, no limit clip Grant ✅ Grant ✅
1400-line file, offset=1, limit=1000, no byte truncation Grant ❌ Grant ✅
50-line file, offset=40, limit=20 Grant ❌ (offset ≠ 1) Grant ❌ (offset ≠ 1)
Any file, daemon returns truncated: true Grant ❌ Grant ❌

Cloud ops path

N/A — no Production mutate.

Living docs plan

Surface Change Notes
docs/sandbox.md Update §Read-before-edit table: change row from "Full means offset 1 and the window reached the end of the returned content, not clipped by limit or maxBytes" → "Full means offset 1 and the daemon returned the full file (not byte-truncated by maxBytes). Line-window limits (default 1000) are display-only and do NOT block the edit grant." Timeless behavior description
docs/harness-limits.md Update read_file row: clarify that limit=1000 is a display window, not a grant gate Minor clarification
AGENTS.md N/A — no infrastructure/rule change
README.md N/A — visitor-facing entry unchanged
SECURITY.md N/A — no trust boundary change
.env.example N/A — no new env

Implementation order

  1. ✅ Change isFullFileReadGrant in lib/agent/tools.ts (line 137) → return true
  2. ✅ Update the isFullFileReadGrant unit test in lib/agent/tools.test.ts (flip the 1000/1400 assertion)
  3. ✅ Update "1400-line file is truncated" test → "grants edit"
  4. ✅ Update "offset=1 limit=20 does not grant" test → "grants edit"
  5. ✅ Update "trailing-newline 1000-line file is truncated" test → "grants edit"
  6. ✅ Update docs/sandbox.md §Read-before-edit table
  7. ✅ Update docs/harness-limits.md read_file row

Testing

# Case Layer Type Result
1 1400-line file, offset=1, limit=1000, no byte trunc → grant Vercel backend Unit ✅ PASS
2 50-line file, offset=1, limit=20, no byte trunc → grant Vercel backend Unit ✅ PASS
3 1000-line file + trailing \n (1001 lines), default limit → grant Vercel backend Unit ✅ PASS
4 offset=40, limit=20 → no grant (offset≠1) Vercel backend Unit ✅ PASS (unchanged)
5 byte-truncated → no grant Vercel backend Unit ✅ PASS (unchanged)
6 exactly 1000 lines, default limit → grant Vercel backend Unit ✅ PASS (unchanged)
7 offset past EOF → empty + no grant Vercel backend Unit ✅ PASS (unchanged)
ENDOFBODY
)

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