$(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:
offset !== 1 → false (reading mid-file is not a full read)
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
- ✅ Change
isFullFileReadGrant in lib/agent/tools.ts (line 137) → return true
- ✅ Update the
isFullFileReadGrant unit test in lib/agent/tools.test.ts (flip the 1000/1400 assertion)
- ✅ Update "1400-line file is truncated" test → "grants edit"
- ✅ Update "offset=1 limit=20 does not grant" test → "grants edit"
- ✅ Update "trailing-newline 1000-line file is truncated" test → "grants edit"
- ✅ Update
docs/sandbox.md §Read-before-edit table
- ✅ 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 |
|
|
|
|
| ) |
|
|
|
|
$(cat <<'ENDOFBODY'
Plan header
docs/sandbox.md,docs/harness-limits.mdSummary
Fix a catch-22 in the read-before-edit gate:
isFullFileReadGrantcurrently denies edit authorization when the line-windowlimitclips the display (e.g. a 1085-line file with defaultlimit=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, onlyoffset === 1+byteTruncated === falsegates the grant — line-window limits are a display-only concern and do not block editing.Goals
read_fileon a 1400-line file with default limit=1000 grants edit;str_replacesucceedsoffset=40read still produces(truncated)and blocks editstruncated: true→ no edit grantformatLineWindowunchanged;offset/limitstill control the display windowNon-goals / out of scope
READ_FILE_DEFAULT_LIMITcap (stays at 1000 — generous default, not the bottleneck)Architectural decisions
returned >= totalLinescheck fromisFullFileReadGrant/ B) Raise default limit to ∞ / C) Accumulate chunked readsREAD_FILE_DEFAULT_LIMITat 1000formatLineWindowunchangedLayer placement
lib/agent/tools.tsisFullFileReadGrantis the single gatekeeper; lives next toformatLineWindowlib/agent/tools.test.tsdocs/sandbox.md,docs/harness-limits.mdCurrent baseline (live code) — verified
isFullFileReadGrantchecksreturned >= totalLines(line 137)lib/agent/tools.ts:130-138READ_FILE_DEFAULT_LIMIT = 1000(line 101)lib/agent/tools.ts:101formatLineWindowsplits content by\n, applies offset/limitlib/agent/tools.ts:113-127isFullFileReadGrant({offset:1, returned:1000, totalLines:1400, byteTruncated:false}) → falselib/agent/tools.test.ts:1510-1517truereadFileToolreturns full content (byte-based, no line limit)sandbox/tools.mjs:183-211docs/sandbox.md:716docs/harness-limits.md:178Design
Root cause
When the daemon returns 1085 lines (no byte truncation), but the default
limit=1000clips 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
The function becomes a two-gate check:
offset !== 1→ false (reading mid-file is not a full read)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
formatLineWindowlimitlines for displayread_file offset=1 limit=1000 lines=1000/1400:— still shows the window stats(truncated)flagfullGrant === false(offset ≠ 1 or byte-truncated)READ_FILE_DEFAULT_LIMITfreshness.recordRead(path, { ...fp, truncated: false })when fullGrant is trueEdge cases
truncated: trueCloud ops path
N/A — no Production mutate.
Living docs plan
docs/sandbox.mddocs/harness-limits.mdAGENTS.mdREADME.mdSECURITY.md.env.exampleImplementation order
isFullFileReadGrantinlib/agent/tools.ts(line 137) → returntrueisFullFileReadGrantunit test inlib/agent/tools.test.ts(flip the 1000/1400 assertion)docs/sandbox.md§Read-before-edit tabledocs/harness-limits.mdread_file rowTesting