feat(core): enforce prior read before Edit / WriteFile mutates a file#3774
Conversation
Introduces a session-scoped invariant: the model cannot mutate an existing file without having actually Read it (or its post-write state) earlier in this conversation. Builds on the FileReadCache landed in #3717. Two new ToolErrorType codes: - EDIT_REQUIRES_PRIOR_READ — file has no entry in the session cache. The model is told to use read_file first. - FILE_CHANGED_SINCE_READ — file has an entry but its mtime or size drifted since the recorded fingerprint. The model is told to re-read before retrying. EditTool blocks the existing-file path on cache.check; new-file creation (old_string === '' on a non-existent target) is exempt. WriteFileTool blocks the overwrite path; new-file creation (fileExists === false) is exempt. Both tools route through the existing fileReadCacheDisabled escape hatch on Config — flipping it bypasses enforcement byte-for-byte, matching pre-cache behaviour. Operators can use this as a kill switch if a session falls into a state where the cache cannot be trusted. ReadFile fix on the auto-memory path: PR #3717 had auto-memory reads skip the cache entirely (both lookup and record), but with the new enforcement that means a model that just Read AGENTS.md cannot then Edit it. Decoupled the two: auto-memory reads still skip the file_unchanged fast-path (the per-read freshness <system-reminder> must always reach the model) but DO record into the cache so the follow-up Edit sees `fresh`. New regression test asserts this. Test plan - vitest run (all of @qwen-code/qwen-code-core): 6308 passed, 2 skipped - 9 new enforcement tests across edit.test.ts and write-file.test.ts: unknown rejects, stale rejects, new-file exempt, edit chain stays authorised, escape hatch bypasses, plus the auto-memory record regression in read-file.test.ts. - tsc --noEmit clean. eslint clean. core build succeeds.
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
CI surfaced one Linux-only failure: the prior-read enforcement test 'rejects a write that would overwrite an unread existing file' returned FILE_CHANGED_SINCE_READ instead of EDIT_REQUIRES_PRIOR_READ. Root cause: the FileReadCache instance is declared at module scope (line 41) and shared across every test in write-file.test.ts. State from earlier tests — most recently the 'records a write' integration test that records the same path — leaks forward. On Linux the test ordering puts a record-bearing test before the enforcement test, so the cache reports `stale` (mtime drifted) instead of `unknown`. macOS / Windows happen to order them differently and never hit it. Adding a fileReadCache.clear() to beforeEach gives every test a known-empty cache, matching how edit.test.ts already isolates its per-test cache by re-instantiating it.
wenshao
left a comment
There was a problem hiding this comment.
Three concrete loopholes / regressions that the original PR-B introduction left open. All three are addressed in the same commit because the underlying refactor (move enforcement earlier and tighten the fresh predicate) is shared across them. 1. fresh != "model has seen the bytes". Pre-fix, requirePriorRead() accepted any cache.check === 'fresh'. ReadFileTool records every successful read into the cache, including ranged reads (offset/limit), truncated full reads, and non-cacheable binary/image/audio/video/PDF/notebook reads (lastReadCacheable = false). This let the model peek at a slice or a structured payload of a file and then mutate the whole thing. Tightened the accept predicate to fresh && lastReadAt && lastReadWasFull && lastReadCacheable. 2. Read-less content oracle through calculateEdit error codes. Pre-fix, execute() ran calculateEdit (which reads file bytes and counts matches) before the enforcement check. A model could probe an unread file by attempting Edits with candidate old_strings and observing NO_OCCURRENCE_FOUND vs EXPECTED_OCCURRENCE_MISMATCH vs EDIT_NO_CHANGE — reverse-engineering content without ever calling read_file. Moved enforcement to the top of calculateEdit, before any content read; only a stat is performed up to the rejection point. 3. Confirmation flow regression. Pre-fix, getConfirmationDetails() read the existing file to render a diff for the user, then approval flowed to execute() which would freshly check the cache and reject. The user could approve a diff computed from current bytes the model never saw, and the call would still fail. Moved enforcement before the confirmation read in both EditTool (via the shared calculateEdit path) and WriteFileTool (explicit check at the top of getConfirmationDetails). The user now never sees a confirmation diff for an unread file — the call rejects up front. Public API surface change: requirePriorRead() -> checkPriorRead() that returns a structured decision, so the same predicate can route into a CalculatedEdit.error (calculateEdit), a thrown error (getConfirmationDetails), or a ToolResult (execute) without duplicating the boolean / message / type plumbing in three shapes. Reported by pomelo-nwu (3 inline comments on PR #3774).
wenshao
left a comment
There was a problem hiding this comment.
Code Review
Closes the gap left by #3717's FileReadCache: any mutation of a pre-existing file via Edit or WriteFile now requires the model to have done a prior full-text Read in this session. Two new error codes (EDIT_REQUIRES_PRIOR_READ, FILE_CHANGED_SINCE_READ) carry the diagnostic message back to the model. New-file creation is exempt; the existing fileReadCacheDisabled flag bypasses everything.
I verified the test suites (edit.test.ts 63/63, write-file.test.ts 36/36, read-file.test.ts 49/49) and ran a probe to validate the most subtle behavior I identified.
Strengths
- Genuine threat-model improvement. Running
checkPriorReadbeforecalculateEditneuters theNO_OCCURRENCE_FOUNDcontent-oracle: a model can no longer probe an unread file with candidateold_strings and learn from the response which one matched. The dedicated test (edit.test.ts:238) is a nice regression guard. - Confirmation-flow ordering is right.
WriteFile.getConfirmationDetailsruns enforcement before loading the diff content, so the user can't approve a diff computed from bytes the model never received. - Strict freshness criteria. Requires
state==='fresh'ANDlastReadAt!==undefinedANDlastReadWasFullANDlastReadCacheable. This correctly rejects (a) entries created only byrecordWrite, (b) ranged reads (offset/limit/pages), and (c) binary/PDF/notebook reads where the model only saw a structured proxy. - Auto-memory decoupling is the right fix.
cacheEnabled(records into cache) vsuseFastPath(serves placeholder) is the correct decomposition — auto-memory needs the per-read<system-reminder>re-emitted every time, while still letting follow-up Edits pass enforcement. - Single escape hatch. Reusing
fileReadCacheDisabledkeeps the incident-response surface minimal.
Issues
MEDIUM 1 — Workflow regression: create-then-modify always fails without an intervening Read
Confirmed empirically. I wrote a probe that creates a file via Edit({old_string:'', new_string:'foo'}) and then immediately edits it with Edit({old_string:'foo', new_string:'bar'}). The second call returns EDIT_REQUIRES_PRIOR_READ. Same for WriteFile-create followed by WriteFile-overwrite.
Root cause: FileReadCache.recordWrite only sets lastWriteAt, leaving lastReadAt undefined. checkPriorRead's strict criteria then rejects.
But the model has demonstrably seen the bytes — for new-file creation and full overwrite, the entire content was sent as a tool argument. Forcing it to Read its own freshly-written file before modifying again is friction without safety benefit. Compaction-clears-cache already handles the model-context-lost concern uniformly.
Suggestion: in recordWrite, also stamp lastReadAt = Date.now(), lastReadWasFull = true, lastReadCacheable = true. Add a test for both Edit-create-then-Edit and WriteFile-create-then-WriteFile scenarios.
(For Edit on an existing file the chain already works because the prior recordRead's lastReadAt survives recordWrite.upsert. The bug is specifically the create-from-nothing path.)
MEDIUM 2 — EDIT_REQUIRES_PRIOR_READ is also returned by WriteFile
tool-error.ts:43 defines EDIT_REQUIRES_PRIOR_READ. write-file.ts:455 returns it. WriteFile is not an "edit" in the model's vocabulary — it's a write/overwrite. Naming the code after one of two callers is confusing for any consumer doing per-error-type telemetry or handling.
Suggestion: rename to MUTATION_REQUIRES_PRIOR_READ (or WRITE_REQUIRES_PRIOR_READ), or add a parallel WRITE_REQUIRES_PRIOR_READ and route Edit/WriteFile to their respective codes. Worth doing now while it's only referenced in 6-ish places.
MINOR 3 — WriteFile.execute reads the file before the enforcement check
write-file.ts:189–226 calls readTextFile and populates originalContent, then :235 runs checkPriorRead and rejects on failure. The read was wasted I/O on the rejection path. (No security/correctness issue — originalContent doesn't leak to the model on rejection — but inconsistent with getConfirmationDetails, which checks first.) Move checkPriorRead ahead of the readTextFile block.
MINOR 4 — checkPriorRead duplicated between Edit and WriteFile
Bodies are nearly identical with a few words differing in the message strings. PR description ack's this and defers. I'd extract now — the duplication is small enough to refactor cheaply, and it'll get harder once a third caller (MultiEdit, ApplyPatch, etc.) shows up. A two-arg helper checkPriorRead(config, filePath, action: 'edit' | 'overwrite') is enough.
MINOR 5 — Hardcoded 'read_file' literal in write-file.ts error messages
edit.ts:602 uses ${ReadFileTool.Name}; write-file.ts:440, 451 hardcode 'read_file'. They match today but use the constant for consistency and rename safety.
MINOR 6 — TOCTOU window unmentioned in user-facing error
checkPriorRead does its own stat at edit.ts:586 / write-file.ts:424; the actual write happens later. An external mutation in that window is undetectable. The error message "File ... has been modified since you last read it (mtime or size changed)" sounds stronger than the actual guarantee. Pre-existing behavior, not worse than before — just worth noting in the comment block above checkPriorRead.
Test-coverage gap
The "exempts new-file creation" test (edit.test.ts:300, write-file.test.ts:758) only verifies the create. It does not exercise the create-then-modify chain that fails today (Issue #1). Adding that test would have surfaced the regression.
Verdict
The security improvement and threat model are right; the implementation is careful and well-tested for the targeted attack. The medium-severity findings — workflow regression on create-then-modify, and the cross-tool error-code name — are worth addressing before merge. The minors can be follow-ups.
1. recordWrite now seeds read metadata on brand-new entries (lastReadAt / lastReadWasFull / lastReadCacheable). The strict accept predicate added in the previous round (#3 review) requires all three, but recordWrite only set lastWriteAt — so a model creating a file with Edit (old_string="") or WriteFile and then editing it again was rejected on the second edit. The model authored the bytes it just wrote; for the purposes of prior-read enforcement that counts as having seen them. New regression test in edit.test.ts: "allows a create-then-edit-then-edit chain without an intervening read". 2. Extracted checkPriorRead into src/tools/priorReadEnforcement.ts. The two copies in edit.ts and write-file.ts had already drifted (one used ${ReadFileTool.Name}, the other hardcoded 'read_file'); the boolean guard is security-sensitive and a one-sided fix would silently weaken the boundary. The shared utility takes a verb ('editing' | 'overwriting') so the user-facing prose can differ between callers without duplicating the decision logic. 3. WriteFileTool.execute now runs prior-read enforcement BEFORE readTextFile. Pre-fix, an unread overwrite still slurped the entire file into memory (encoding / BOM / line-ending detection) and only then rejected it: wasted I/O, and momentary in-memory custody of bytes the model never legitimately read. Now matches the order in getConfirmationDetails(). 4. The "rejects a write that would overwrite an unread existing file" test now spies on FileSystemService.readTextFile and asserts not.toHaveBeenCalled() — without that, the test gave false confidence: it passed both pre-fix (read happened, then reject) and post-fix (reject before read), so the ordering regression in (3) was invisible to the assertion. Reported by glm-5.1 via /review on PR #3774.
wenshao
left a comment
There was a problem hiding this comment.
No review findings. Downgraded from Approve to Comment: self-PR. — gpt-5.5 via Qwen Code /review
There was a problem hiding this comment.
Pull request overview
This PR tightens the session-scoped FileReadCache contract in @qwen-code/qwen-code-core so Edit and WriteFile can only mutate pre-existing files that the model has already seen in the current session. It builds on the earlier read-cache work by turning cached read/write fingerprints into an enforcement mechanism and by fixing auto-memory reads so they participate in that enforcement.
Changes:
- Add prior-read enforcement for
EditandWriteFile, with new tool error codes for unread files and files that changed after a read. - Update
ReadFileso auto-memory files still bypass the unchanged fast-path but now record into the cache for later mutation checks. - Extend
FileReadCache.recordWrite()and add regression tests so create→mutate chains and stale-read enforcement are covered.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
packages/core/src/tools/write-file.ts |
Enforces prior-read checks before overwrite confirmation and execution. |
packages/core/src/tools/write-file.test.ts |
Adds overwrite-enforcement regressions and cache setup for existing tests. |
packages/core/src/tools/tool-error.ts |
Introduces new tool error enums for prior-read enforcement outcomes. |
packages/core/src/tools/read-file.ts |
Records auto-memory reads in cache while keeping their fast-path disabled. |
packages/core/src/tools/read-file.test.ts |
Adds regression coverage for auto-memory cache recording. |
packages/core/src/tools/priorReadEnforcement.ts |
New shared helper that decides whether a mutation may proceed. |
packages/core/src/tools/edit.ts |
Enforces prior-read checks during edit calculation before file content is read. |
packages/core/src/tools/edit.test.ts |
Adds edit-side enforcement tests and seeds cache state for existing-file tests. |
packages/core/src/services/fileReadCache.ts |
Seeds read metadata on first write so newly created files can be edited again without an explicit read. |
packages/core/src/services/fileReadCache.test.ts |
Updates cache unit tests for the new recordWrite() semantics. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…Copilot) Five concrete gaps that the previous round of enforcement work left open. Reported by Copilot via /review on PR #3774. 1. Confirmation-time rejections lost their ToolErrorType code. getConfirmationDetails() in both EditTool and WriteFileTool threw a plain Error on prior-read failure, which coreToolScheduler collapsed into UNHANDLED_EXCEPTION — silently breaking the EDIT_REQUIRES_PRIOR_READ / FILE_CHANGED_SINCE_READ contract for any approval-required flow. Fix: introduce PriorReadEnforcementError that carries `errorType: ToolErrorType`. Both confirmation paths now throw it, and coreToolScheduler reads `error.errorType` (falling back to UNHANDLED_EXCEPTION when absent). New regression tests assert the thrown error's `errorType` field for both tools. 2. checkPriorRead's "re-read with read_file" advice was wrong for binary / image / audio / video / PDF / notebook files. Their ReadFile result always sets lastReadCacheable=false, so the message would loop the agent forever on the same rejection. Fix: detect the fresh-but-non-cacheable case explicitly and return a dedicated message that explains the dead end ("Edit / WriteFile cannot mutate that payload safely") instead of asking for another read. Updated the existing non-cacheable regression test to assert the new message and the absence of "use the read_file tool first". 3. checkPriorRead swallowed every stat() failure and returned ok:true. EACCES, EBUSY, NFS hiccups, etc. would silently re-open the blind-write path the helper exists to block. Fix: only ENOENT continues to return ok:true (disappearance race). Any other code is fail-closed: returns EDIT_REQUIRES_PRIOR_READ with a message that names the errno. New regression test in write-file.test.ts spies on fs.promises .stat to inject EACCES and asserts the rejection. 4. The auto-memory record regression test only asserted `state === 'fresh'`. A future change that recorded auto-memory reads as partial / non-cacheable would still satisfy that assertion but would actually fail enforcement on every follow-up Edit. Fix: also assert lastReadAt is defined, lastReadWasFull is true, and lastReadCacheable is true. The full "what enforcement requires" predicate is now explicit in the test. (The 5th item, the WriteFile mirror of (1), is covered by the same PriorReadEnforcementError change.)
wenshao
left a comment
There was a problem hiding this comment.
Additional suggestions from deepseek-v4-pro review:
-
Test gap: No
FILE_CHANGED_SINCE_READtest through the confirmation flow (getConfirmationDetails). Bothedit.test.tsandwrite-file.test.tsonly testFILE_CHANGED_SINCE_READvia theexecutepath. Consider adding: seed prior read → modify file externally → callgetConfirmationDetails→ assert thrownPriorReadEnforcementErrorwitherrorType === FILE_CHANGED_SINCE_READ. -
Test gap: No WriteFile create-then-overwrite chain test.
edit.test.tshas "allows a create-then-edit-then-edit chain without an intervening read" butwrite-file.test.tshas no symmetric test. TherecordWriteseeding logic is unit-tested infileReadCache.test.ts, but the tool-level integration path is unexercised for WriteFile. -
edit.tsgetConfirmationDetailscatch block (line ~330): The catch wraps unexpectedcalculateEditerrors as plainError(noerrorType), causing them to surface asUNHANDLED_EXCEPTION. Theexecutemethod's equivalent catch returnsEDIT_PREPARATION_FAILURE. Consider preservingerrorTypefromPriorReadEnforcementErrorinstances, or attachingEDIT_PREPARATION_FAILUREexplicitly.
Four follow-ups raised by deepseek-v4-pro on PR #3774. None of them change the enforcement boundary; they are all about making the contract clearer and harder to break in future changes. 1. PriorReadEnforcementError -> StructuredToolError. The class now wraps any content-derived ToolErrorType from calculateEdit (EDIT_NO_OCCURRENCE_FOUND, EDIT_EXPECTED_OCCURRENCE_MISMATCH, EDIT_NO_CHANGE, ATTEMPT_TO_CREATE_EXISTING_FILE) on top of the prior-read codes. The old name suggested the class was prior- read-specific, which would mislead any oncall engineer seeing it paired with one of the calculateEdit error codes. 2. EDIT_REQUIRES_PRIOR_READ kept its name (the prefix mentions "edit" but the enum is shared with WriteFileTool) — chose documentation over rename to avoid the churn of a value rename across logs/dashboards already keyed on it. JSDoc now spells out the cross-tool usage explicitly. 3. Stat failures other than ENOENT now map to a new PRIOR_READ_VERIFICATION_FAILED code instead of being conflated with EDIT_REQUIRES_PRIOR_READ. The failure mode is "we cannot verify" rather than "definitely not read" — operators routing on error codes can distinguish the two populations. 4. Added a coreToolScheduler test (`surfaces error.errorType from a confirmation throw instead of UNHANDLED_EXCEPTION`) that constructs a stub tool whose getConfirmationDetails throws StructuredToolError and asserts the surfaced ToolCall response carries the correct ToolErrorType. Without this test the scheduler's explicitErrorType branch would have no coverage at all. Tool tests updated for the new StructuredToolError class name and the PRIOR_READ_VERIFICATION_FAILED code on the EACCES path.
wenshao
left a comment
There was a problem hiding this comment.
Code Review — Incremental (d6ffe80 → 248d179)
This commit renames PriorReadEnforcementError → StructuredToolError, adds PRIOR_READ_VERIFICATION_FAILED for stat-failure cases, and adds a scheduler test for structured error propagation.
tsc / eslint: clean. Tests: 106/106 pass.
Suggestion
[Suggestion] PRIOR_READ_VERIFICATION_FAILED is only tested end-to-end via the WriteFile path (write-file.test.ts). The Edit path's checkPriorRead returns the same new error type but has no corresponding EACCES stat-failure test in edit.test.ts. Consider adding a symmetric test — if the Edit path accidentally regresses to returning EDIT_REQUIRES_PRIOR_READ on stat failure, no test would catch it.
— deepseek-v4-pro via Qwen Code /review
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Six concrete issues that the previous round of enforcement work left open. Reported by Copilot via /review on PR #3774. 1. TOCTOU window between pre-read checkPriorRead and readTextFile. The pre-read stat could pass enforcement, then an external writer could land between that stat and the actual read, leaving currentContent reflecting bytes the model never saw — exactly the stale-write path the PR is supposed to block. Closed by re-running checkPriorRead immediately after every readTextFile that fed currentContent / originalContent: EditTool.calculateEdit and the two WriteFileTool paths (execute + getConfirmationDetails). A `stale` outcome now fails the operation with FILE_CHANGED_SINCE_READ at the correct moment. 2. Directory targets sent the model into an enforcement loop. `fileExists` is a plain access check, so directories also entered the enforcement branch — the model would be told to call `read_file`, but `read_file` rejects directories with TARGET_IS_DIRECTORY, so the loop never terminated. Fixed in checkPriorRead: if `fs.stat` reports the path is not a regular file, return `ok: true` so the downstream readTextFile / write path can surface its own EISDIR / similar error. 3. Confirmation-time error messages used the short `display` form instead of the full `raw` form. Approval-required Edit calls therefore lost the remediation detail (file path, stale-vs-unread distinction, "without offset / limit / pages" hint) that the execute path already surfaced and that the WriteFile confirmation path already preserved. EditTool.getConfirmationDetails now throws StructuredToolError with `editData.error.raw`. 4. Non-text payload displayMessage was grammatically broken: built from the gerund `editing` / `overwriting`, it rendered as "cannot editing via this tool" / "cannot overwriting via this tool". Fixed by deriving a bare-verb form (`edit` / `overwrite`) alongside the gerund and using it in displayMessage. (Items 1, 5 and 6 from Copilot's batch are the same TOCTOU class — EditTool calculateEdit + WriteFile execute + WriteFile confirmation — addressed together in (1) above.) The "bypasses enforcement entirely" test now uses mockReturnValue instead of mockReturnValueOnce because calculateEdit calls getFileReadCacheDisabled twice — once for the pre-read check and once for the post-read TOCTOU re-check. Both must see disabled=true to actually bypass.
wenshao
left a comment
There was a problem hiding this comment.
Incremental review (d6ffe80 → 248d179): all prior Criticals resolved. StructuredToolError rename done, PRIOR_READ_VERIFICATION_FAILED code added, scheduler test coverage added, recordWrite seeding for create-then-mutate chains. 227/227 tests pass, tsc/eslint clean. 2 Suggestion-level findings remain (stale comment, theoretical TOCTOU).
WriteFile gated prior-read enforcement on `fileExists` from `isFilefileExists()`, but a file that sprang into existence between that check and the write would still be overwritten without enforcement — `fileExists === false` skipped the check entirely. Made the gate unconditional on `fileExists`. checkPriorRead's own `fs.stat` decides what to do: - ENOENT → ok:true, fall through to the new-file path as before - file exists right now (whether or not isFilefileExists saw it) → unknown / stale check runs, the race-created file is rejected. Applied to both getConfirmationDetails and execute. The path that actually creates new files is unchanged because checkPriorRead's ENOENT branch is the disappearance-race exit, which is the correct exit for "the file truly does not exist yet". Reported by glm-5.1 via /review on PR #3774.
wenshao
left a comment
There was a problem hiding this comment.
Test coverage note: The three TOCTOU post-read re-checks (edit.ts calculateEdit, write-file.ts getConfirmationDetails/execute) and the !stats.isFile() defense lack dedicated tests simulating the race conditions they protect against. Consider adding tests that modify the file between the pre-check and post-check to verify the TOCTOU window is actually closed.
— deepseek-v4-pro via Qwen Code /review
Code reviewOverviewThis closes a security-relevant gap: previously the model could call Strengths
Concerns / Suggestions1. Unrelated formatting changes pollute the diff. Whitespace-only prettier reformatting in:
Recommend splitting these into a separate PR. They make the diff harder to review and increase merge-conflict risk on cherry-picks. Roughly 100 of the ~1640 added lines are unrelated formatting. 2. Triple-stat pattern adds latency. Each Edit invocation now calls 3. 4. Edit's 5. 6. 7. Naming nit on 8. Risk AssessmentLow-medium. The behavior change is significant (any Edit/Write of an unread existing file now fails), but:
VerdictApprove with recommendations:
Core enforcement logic is well-designed and well-tested. Main blocker is hygiene around bundled-but-unrelated changes. |
tanzhenxin
left a comment
There was a problem hiding this comment.
Review
Re-review after de8ddf530 and 9726c87e0. The SVG fix and the chore commit are clean. Three correctness issues remain.
Issues
1. Sticky-on-true recordRead re-opens the "edit unseen bytes" gap on Read full → external write → Read partial → Edit (severity: high · confidence: very high)
packages/core/src/services/fileReadCache.ts:117-131, 200-219. After T0 full read sets lastReadWasFull=true at fingerprint X, an external write at T1 advances on-disk fingerprint to Y. The partial Read at T2 calls upsert, which silently overwrites mtimeMs/sizeBytes to Y while preserving the sticky lastReadWasFull=true. The Edit at T3 then sees cache.check === 'fresh' against Y plus lastReadWasFull=true and proceeds against bytes the model only saw the first 10 lines of.
The fix targets a real regression (Write→partial-Read→Edit) but goes too broad. Tightening to "sticky-on-true only when upsert sees no drift" closes both directions. The new test at fileReadCache.test.ts:139-155 reuses one stats object across both recordRead calls so it cannot detect the bug; the missing test is a third call with mutated stats.
2. Inherits-model + same-approval-mode subagents share the parent's FileReadCache (severity: medium · confidence: high)
packages/core/src/tools/agent/agent.ts:990-993 falls into the : this.config branch when approval mode matches, and subagents/subagent-manager.ts:694-701 returns base unchanged when the model inherits. AgentHeadless.create() does not wrap. So the common-case subagent's getFileReadCache() returns the parent's cache instance — a parent Read satisfies a subagent Edit on a path the subagent's transcript never contained. The PR description's "subagents inherit an empty cache" claim only holds when an Object.create(parent) actually fires.
Pre-existing infra gap from #3717, but this PR makes the cache load-bearing for safety. One-liner fix at agent.ts:990-993 (always Object.create on the subagent path) plus a regression test — config.test.ts:331-363 only pins the Object.create-was-used case.
3. Race between processSingleFileContent and the post-read stat at read-file.ts:242 records a "fully read" entry against bytes the model never saw (severity: medium · confidence: high)
packages/core/src/tools/read-file.ts:198, 240-247. An external write between the read pipeline call and the post-read stat causes the cache to record the NEW fingerprint as a full cacheable read tied to the OLD bytes the model received. A subsequent Edit passes enforcement (fresh + full + cacheable) and runs against bytes the model never saw. Narrow window but exactly the class of failure this PR exists to close. Either narrow the read-file.ts:240 comment to acknowledge the post-read direction, or store a content hash alongside the fingerprint.
Verdict
REQUEST_CHANGES — prior SVG critical fixed cleanly; the recordRead sticky-on-true fix re-opens a narrow variant of the original blind-write hole; the subagent isolation claim does not hold for the common inherits-model + same-approval-mode spawn path.
All three are tightenings of the prior `de8ddf530` round. 1. **Sticky-on-true narrowed to "no fingerprint drift"**. `fileReadCache.recordRead` previously kept `lastReadWasFull` / `lastReadCacheable` true across drifted recordings, which re-opened a `Read full @x → external write @y → Read partial @y → Edit` hole: the partial recordRead silently advanced the entry's mtime+size to Y while preserving the sticky `full=true` from X, so a follow-up Edit ran against bytes the model only saw the first 10 lines of. Now the sticky branch only fires when `(mtimeMs, sizeBytes)` matches the existing entry; on drift, both flags reset to exactly what this read produced. New regression test in `fileReadCache.test.ts` reproduces the reviewer's reported sequence. 2. **Subagent FileReadCache isolation now covers the inherits-model + same-approval-mode common case**. The own-property machinery from #3717 only triggers when an `Object.create(parent)` actually fires; both `agent.ts:990-993` (same-approval-mode) and `subagent-manager.ts:699-701` (inherits-model) had paths that returned the parent Config directly, so the subagent's `getFileReadCache()` resolved to the parent's instance — a parent Read could satisfy the subagent's Edit on a path the subagent's transcript never contained. Both sites now build a thin `Object.create(base)` override unconditionally; no method changes for the inherits / same-mode cases, but a distinct instance triggers the lazy-init in `Config.getFileReadCache()` so the subagent gets an isolated cache. 3. **Cache records the read pipeline's internal stat instead of a post-read re-stat**. `processSingleFileContent` now surfaces its internal stat via `result.stats`, and read-file uses that for `recordRead` instead of running its own stat after the read returns. Pre-fix, an external write between the pipeline call and the post-read stat let the cache record fingerprint Y for content the model received at X — a subsequent Edit would pass enforcement against bytes the model never legitimately saw. The internal-stat-to-read window is still a few microseconds wide; that residue is the same content-hash territory acknowledged in the Risk section. Reported by tanzhenxin via re-review on PR #3774.
|
@tanzhenxin Thanks for catching all three. Fixed in Issue 1 — sticky-on-true re-opens "edit unseen bytes" on drift Fully accept the framing — the previous fix went too broad. const sameFingerprint =
existing &&
existing.mtimeMs === stats.mtimeMs &&
existing.sizeBytes === stats.size;
const entry = this.upsert(absPath, stats);
entry.lastReadAt = Date.now();
if (sameFingerprint) {
// Sticky: prior `true` survives a follow-up partial read of the *same* bytes.
if (opts.full) entry.lastReadWasFull = true;
if (opts.cacheable) entry.lastReadCacheable = true;
} else {
// Drift: prior flags described different bytes — reset to this read's actual shape.
entry.lastReadWasFull = opts.full;
entry.lastReadCacheable = opts.cacheable;
}The Issue 2 — Inherits-model + same-approval-mode subagent shares parent's FileReadCache You're right, the own-property machinery only fires when an
The Issue 3 — Race between read pipeline and post-read stat Took the "store the right stat" path rather than "narrow the comment": added The internal-stat-to-actual-read window inside the pipeline is still a few microseconds wide (the same OS-level race the PR Risk section already acknowledges for Verification Apologies for needing two passes on the sticky-on-true fix; the drift case was a real hole. |
tanzhenxin
left a comment
There was a problem hiding this comment.
Review
Re-review of 72a1d67c4. Sticky-on-true drift and post-read re-stat fixes both land cleanly. Subagent isolation fix is non-functional, but the gap is pre-existing and the PR doesn't make it worse — flagging for a follow-up rather than blocking.
Issues
1. Object.create wrapper does not isolate the FileReadCache — bound tool instances reach the parent's cache (severity: high · confidence: very high)
agent.ts:998-1001 and subagent-manager.ts:710 build a child Config, but Config.createToolRegistry() (config.ts:2807-2858) registered new EditTool(this) etc. against the parent. The subagent's runtimeContext.getToolRegistry() (agent-core.ts:345) walks the prototype chain back to the parent's registry; tool.build(args) returns an invocation whose this.config is the parent. priorReadEnforcement and recordRead therefore resolve on the parent — the wrapper's lazy-init cache is never consulted.
Effect: a parent Read of /work/secrets.ts still authorizes a subagent's Edit on the same path (and vice versa), exactly the property the PR description says is closed.
config.test.ts:331-363 only pins the cache-getter level; it does not exercise the bound-tool path. InProcessBackend.createPerAgentConfig (InProcessBackend.ts:409-414) already uses the layer that actually isolates — override.createToolRegistry() + copyDiscoveredToolsFrom(base.getToolRegistry()).
This gap pre-dates the PR (it's a property of #3717's per-Config own-property machinery), and pre-PR there was no enforcement on subagent mutations at all, so the PR is strictly an improvement. Worth tightening the PR description's "subagents inherit an empty cache" claim and filing a follow-up.
Verdict
COMMENT — drift and re-stat fixes are good; subagent isolation needs a follow-up that rebuilds the tool registry on the override Config, with a regression test exercising the bound-tool path.
tanzhenxin's third review correctly observed that the `Object.create(parent)` wrappers in `agent.ts:createApprovalModeOverride` and `subagent-manager.ts:maybeOverrideContentGenerator` only isolate the FileReadCache for code that consults `Config.getFileReadCache()` directly. Bound `EditTool` / `WriteFileTool` instances were registered against the parent's tool registry at initialise time, so tool invocations still resolve `this.config` to the parent and reach the parent's cache. `InProcessBackend.createPerAgentConfig` already does the right thing (`override.createToolRegistry()` + `copyDiscoveredToolsFrom(base.getToolRegistry())`); bringing that to these two spawn sites is the real fix. Reviewer's verdict was COMMENT, not REQUEST_CHANGES — the gap pre-dates this PR (it's a property of #3717's per-Config own-property machinery) and pre-PR there was no enforcement on subagent mutations at all, so the PR is strictly an improvement on every spawn path. Documented the partial guarantee explicitly: - Inline comments on both spawn sites note the bound-tool caveat and point at `InProcessBackend.createPerAgentConfig` as the model for the follow-up. - PR description's subagent paragraph (English + Chinese mirror) now splits into "fully isolated" (`InProcessBackend.createPerAgentConfig`) and "partial isolation" (the two sites in this PR) so readers don't walk away with the wrong contract. Filing the registry-rebuild work as a follow-up; not in this PR.
|
@tanzhenxin Accept the framing — the previous round only isolated the path that consults Closing the bound-tool path is the follow-up. For this PR I took your "tighten the description and file as follow-up" path: Inline comments ( // agent.ts:createApprovalModeOverride
// Known partial fix: ... InProcessBackend.createPerAgentConfig
// already does the right thing (override.createToolRegistry() +
// copyDiscoveredToolsFrom); bringing that here is a follow-up.// subagent-manager.ts:maybeOverrideContentGenerator
// Same caveat ... doing that here is the follow-up that closes
// the bound-tool path.PR description (English + Chinese mirror) now splits the claim into two regimes instead of the prior monolithic "subagents inherit an empty cache":
Both sections explicitly call the partial cases out as a follow-up and frame the PR as "strictly an improvement on every spawn path; the partial-isolation cases just don't get the full guarantee yet" — matching your verdict that this is improvement-not-regression. The follow-up will:
Will open it as a separate PR after this one merges. The other two issues from #4233904930 (sticky-on-true drift, post-read re-stat) remain fixed in |
tanzhenxin
left a comment
There was a problem hiding this comment.
Review
Re-review after the docs commit 70f7223e. Every prior critical finding has now been resolved — sticky-on-true drift in fileReadCache and the post-read re-stat race were both fixed earlier; the subagent-isolation framing is now accurately documented as a partial guarantee, with the registry-rebuild follow-up cleanly filed. Parent-flow gating is solid.
One residual worth flagging out loud: the bound-tool gap on agent.ts:createApprovalModeOverride and subagent-manager.ts:maybeOverrideContentGenerator is still in code — a parent's recorded read can authorize a subagent's Edit on the same path, and vice versa, because the subagent's runtimeContext.getToolRegistry() resolves to the parent's registry through the prototype chain. Documented honestly in the description, mitigated by InProcessBackend.createPerAgentConfig being correct on its path, and pre-PR there was zero subagent enforcement at all so this PR is a strict improvement on every spawn path. But the failure mode is the exact thing the PR exists to close, so the registry-rebuild follow-up (port InProcessBackend.createPerAgentConfig's shape to those two sites + add a regression test exercising the bound-tool path) shouldn't drift.
Verdict
APPROVE — partial subagent isolation is now honestly documented and the registry-rebuild fix is cleanly scoped as follow-up. Nice work.
…d tools resolve to the subagent (#3873) * fix(core): rebuild tool registry on subagent Config overrides so bound tools resolve to the subagent PR-B (#3774) added per-Config FileReadCache isolation via Object.create overrides at two subagent spawn sites — agent.ts:createApprovalModeOverride and subagent-manager.ts:maybeOverrideContentGenerator. The override shielded code that read FileReadCache directly through the Config instance, but missed the bound-tool path: Config.createToolRegistry runs once at parent initialise time, so the parent's EditTool / WriteFileTool / ReadFileTool instances are bound with `this.config = parent`. The subagent's Object.create wrapper inherited getToolRegistry via the prototype chain, reaching the parent registry whose bound tools then read FileReadCache and approval mode from the parent. This change closes that gap by rebuilding the tool registry on the override at both sites — the same pattern InProcessBackend.createPerAgentConfig already uses: - override.createToolRegistry(undefined, { skipDiscovery: true }) - registry.copyDiscoveredToolsFrom(base.getToolRegistry()) - override.getToolRegistry = () => registry createApprovalModeOverride becomes async; its single call site already ran inside an async block. maybeOverrideContentGenerator skips the rebuild when the upstream Config already has its own getToolRegistry (real-world case: agent.ts wrapper passed through createAgentHeadless), avoiding wasted work, listener accumulation on shared SubagentManager / SkillManager, and a cache split where the bound tools' registry layer diverges from the runtime context's lazy-init cache. Includes regression tests in agent-override.test.ts and subagent-manager-override.test.ts that exercise the bound-tool path: they instantiate the lazy factories on the override registry and assert that EditTool / WriteFileTool / ReadFileTool resolve this.config to the override Config (and thus to the override's FileReadCache / approval mode), not the parent. * fix(core): close bound-tool gap on resumed background agents too Follow-up audit on PR #3873 surfaced a duplicate, pre-rebuild copy of `createApprovalModeOverride` living in `background-agent-resume.ts` (L142-150). Resumed fork agents go through `createResumedForkSubagent` which bypasses `SubagentManager.maybeOverrideContentGenerator` (where the registry rebuild now lives), so the resumed fork's `EditTool` / `WriteFileTool` / `ReadFileTool` were still resolving `this.config` to the parent and reading the parent's `FileReadCache`. The non-fork resume path went through `subagent-manager` and worked correctly only because `maybeOverrideContentGenerator` saw no upstream own-registry on `bgConfig` and rebuilt one — but with that fallback the fork path could never benefit. This change deletes the local copy and switches `background-agent-resume.ts` to import the now-async exported `createApprovalModeOverride` from `agent.ts`. Drops the previous `?: this.config` short-circuit so the resumed agent ALWAYS gets a wrapper Config — the same behaviour `agent.ts` already enforces; reusing the parent directly defeats the per-Config FileReadCache isolation. Updates `background-agent-resume.test.ts` mock config with the `createToolRegistry` / `getToolRegistry` stubs the rebuild path now exercises. * fix(core): address bound-tool isolation review feedback Three independent fixes from PR #3873 review feedback: 1. Switch the upstream-rebuild guard from `hasOwnProperty(base, 'getToolRegistry')` to a Symbol-keyed marker `TOOL_REGISTRY_REBUILT`. The own-property check missed the case where the override is reached via an Object.create wrapper above the rebuilt Config (e.g. `bgConfig = Object.create(agentConfig)` in the agent.ts background path) — it would falsely report "no upstream rebuild" and cause a redundant third rebuild that wastes work and doubles the listener-leak surface. Symbol property reads walk the prototype chain via normal lookup, so a marker stored on any ancestor is correctly observed. Extracts the shared rebuild logic into `rebuildToolRegistryOnOverride(override, base)` so the three spawn sites (agent.ts:createApprovalModeOverride, the inherits branch, the non-inherit branch) cannot drift apart. 2. Stop the per-subagent ToolRegistry in the lifecycle finally blocks: - agent.ts foreground finally (after the inner try wrapping `runFramed`) - agent.ts background bgBody finally (after `bgSubagent.execute` resolves) - background-agent-resume.ts resume body finally (same shape) Without this, every AgentTool / SkillTool the model instantiates from the per-subagent registry registers a change-listener on shared SubagentManager / SkillManager, and repeated subagent runs accumulate listeners for the rest of the session. Stop is fire-and-forget, matching `InProcessBackend.cleanup` and `stopAgent`. 3. Add bound-tool isolation tests for the non-inherit branch (explicit-model selector). The original PR only covered the inherits branch directly; the non-inherit branch now goes through the same helper, but a dedicated test pins `tool.config === override` and the FileReadCache binding so a regression cannot leave explicit-model subagents reading the parent's cache while existing model-override tests still pass. Tests now exercise: - Symbol marker propagation via Object.create chain (3 cases) - Non-inherit rebuild + bound-tool isolation - Non-inherit skip-rebuild when upstream wrapper has the marker - Pre-existing inherits / chained-override / approval-mode propagation - Mock configs in agent.test.ts / subagent-manager.test.ts / background-agent-resume.test.ts gain `stop` and `tools: Map` stubs to model the registry contract the override path now exercises. `npx vitest run packages/core/src` — 268 files / 6943 passed.
… for Edit The previous commit dropped the `lastReadWasFull` clause from `checkPriorRead` for both Edit and WriteFile. That over-relaxes WriteFile: unlike Edit, the overwrite path replaces the entire file and has no `old_string` matching as a content-derived guard. A model that has only seen a slice via `read_file(offset, limit)` followed by a WriteFile would necessarily hallucinate the rest of the bytes — which is exactly the issue #2499 data-loss scenario PR #3774 was opened to address. Split the policy by operation: - EditTool: partial read counts (`old_string` matching is the floor — a fabricated string that misses the actual bytes is caught by `0 occurrences`). - WriteFileTool overwrite: full read required (no equivalent floor; partial-read-then-overwrite would silently destroy unread bytes). Mechanism: add `requireFullRead?: boolean` to `CheckPriorReadOptions`. WriteFileTool's 5 enforcement call sites pass `true`; EditTool's 3 pass nothing (default `false`). New-file creation paths still hit the ENOENT → `ok: true` branch before the flag is consulted, so brand-new file creation remains exempt regardless. The `fresh + cacheable + partial + requireFullRead` case gets a dedicated rejection branch so the model sees a clear "you have a partial read; do a full read" message rather than the generic "has not been read" wording. The `unknown` branch's message also varies by `requireFullRead` so the read instruction matches the operation's actual requirement. Tests: write-file.test.ts's "rejects when previous read was ranged" test (which the previous commit had inverted) is restored and asserts on the new "partially read / replaces the entire file" message. Edit's partial-OK test is unchanged. 198 / 198 in the prior-read-related suites; tsc clean.
…quired for WriteFile PR #3774 introduced a `lastReadWasFull` requirement to `checkPriorRead` that forced models to re-read multi-thousand-line files just to make a single-line edit. The `0 occurrences` failure mode in `calculateEdit` already catches a fabricated `old_string` that misses the actual bytes, so requiring a full read on top of that is over-defence at a real context cost — but only for Edit, not for WriteFile. WriteFile is asymmetric: it replaces the entire file and has no content-derived guard equivalent to `old_string` matching. A model that has only seen a slice via `read_file(offset, limit)` followed by a `WriteFile` would necessarily hallucinate the rest of the bytes — the issue #2499 data-loss scenario PR #3774 was opened to address. Split the policy along that line. `checkPriorRead` gains a `requireFullRead?: boolean` option. WriteFileTool's 5 enforcement call sites pass `true`; EditTool's 3 leave it unset (default `false`): - EditTool: partial read counts (old_string is the floor) - WriteFileTool overwrite: full read required - Either: new-file creation exempt (ENOENT → ok:true before requireFullRead is consulted); `fileReadCacheDisabled` escape hatch unchanged A dedicated `fresh + cacheable + partial + requireFullRead` rejection branch surfaces a clear "only been partially read … overwriting replaces the entire file" message instead of falling through to the generic "has not been read" wording. The `unknown` branch's wording also varies by `requireFullRead` so the read instruction matches the operation's actual requirement. For comparison, Claude Code's `readFileState` enforcement treats partial and full reads identically for both Edit and WriteFile. This PR is stricter on WriteFile (full read required) and identical on Edit (partial OK). Issue #2499 is empirical evidence that the partial-read-then-overwrite case is real on at least some model populations, so the additional WriteFile constraint is justified. Single-commit shape (versus the earlier afc1b91 / 503fc0b split) to avoid an intermediate state in which Edit's relaxation has landed but WriteFile is still on the relaxed path: cherry-picks or bisect walks crossing such a boundary would re-introduce the issue #2499 data-loss case. Tests: edit.test.ts ranged-read test inverted to "allows after ranged read"; write-file.test.ts ranged-read test asserts the new partial / full-required message. Three error-message regex matchers updated from /fully read/ to /read/. 198 / 198 prior-read-related tests pass; tsc --noEmit clean.
…quired for WriteFile (#3932) PR #3774 introduced a `lastReadWasFull` requirement to `checkPriorRead` that forced models to re-read multi-thousand-line files just to make a single-line edit. The `0 occurrences` failure mode in `calculateEdit` already catches a fabricated `old_string` that misses the actual bytes, so requiring a full read on top of that is over-defence at a real context cost — but only for Edit, not for WriteFile. WriteFile is asymmetric: it replaces the entire file and has no content-derived guard equivalent to `old_string` matching. A model that has only seen a slice via `read_file(offset, limit)` followed by a `WriteFile` would necessarily hallucinate the rest of the bytes — the issue #2499 data-loss scenario PR #3774 was opened to address. Split the policy along that line. `checkPriorRead` gains a `requireFullRead?: boolean` option. WriteFileTool's 5 enforcement call sites pass `true`; EditTool's 3 leave it unset (default `false`): - EditTool: partial read counts (old_string is the floor) - WriteFileTool overwrite: full read required - Either: new-file creation exempt (ENOENT → ok:true before requireFullRead is consulted); `fileReadCacheDisabled` escape hatch unchanged A dedicated `fresh + cacheable + partial + requireFullRead` rejection branch surfaces a clear "only been partially read … overwriting replaces the entire file" message instead of falling through to the generic "has not been read" wording. The `unknown` branch's wording also varies by `requireFullRead` so the read instruction matches the operation's actual requirement. For comparison, Claude Code's `readFileState` enforcement treats partial and full reads identically for both Edit and WriteFile. This PR is stricter on WriteFile (full read required) and identical on Edit (partial OK). Issue #2499 is empirical evidence that the partial-read-then-overwrite case is real on at least some model populations, so the additional WriteFile constraint is justified. Single-commit shape (versus the earlier afc1b91 / 503fc0b split) to avoid an intermediate state in which Edit's relaxation has landed but WriteFile is still on the relaxed path: cherry-picks or bisect walks crossing such a boundary would re-introduce the issue #2499 data-loss case. Tests: edit.test.ts ranged-read test inverted to "allows after ranged read"; write-file.test.ts ranged-read test asserts the new partial / full-required message. Three error-message regex matchers updated from /fully read/ to /read/. 198 / 198 prior-read-related tests pass; tsc --noEmit clean.
…#3774) * feat(core): enforce prior read before Edit / WriteFile mutates a file Introduces a session-scoped invariant: the model cannot mutate an existing file without having actually Read it (or its post-write state) earlier in this conversation. Builds on the FileReadCache landed in #3717. Two new ToolErrorType codes: - EDIT_REQUIRES_PRIOR_READ — file has no entry in the session cache. The model is told to use read_file first. - FILE_CHANGED_SINCE_READ — file has an entry but its mtime or size drifted since the recorded fingerprint. The model is told to re-read before retrying. EditTool blocks the existing-file path on cache.check; new-file creation (old_string === '' on a non-existent target) is exempt. WriteFileTool blocks the overwrite path; new-file creation (fileExists === false) is exempt. Both tools route through the existing fileReadCacheDisabled escape hatch on Config — flipping it bypasses enforcement byte-for-byte, matching pre-cache behaviour. Operators can use this as a kill switch if a session falls into a state where the cache cannot be trusted. ReadFile fix on the auto-memory path: PR #3717 had auto-memory reads skip the cache entirely (both lookup and record), but with the new enforcement that means a model that just Read AGENTS.md cannot then Edit it. Decoupled the two: auto-memory reads still skip the file_unchanged fast-path (the per-read freshness <system-reminder> must always reach the model) but DO record into the cache so the follow-up Edit sees `fresh`. New regression test asserts this. Test plan - vitest run (all of @qwen-code/qwen-code-core): 6308 passed, 2 skipped - 9 new enforcement tests across edit.test.ts and write-file.test.ts: unknown rejects, stale rejects, new-file exempt, edit chain stays authorised, escape hatch bypasses, plus the auto-memory record regression in read-file.test.ts. - tsc --noEmit clean. eslint clean. core build succeeds. * test(core): clear shared fileReadCache between write-file.test.ts cases CI surfaced one Linux-only failure: the prior-read enforcement test 'rejects a write that would overwrite an unread existing file' returned FILE_CHANGED_SINCE_READ instead of EDIT_REQUIRES_PRIOR_READ. Root cause: the FileReadCache instance is declared at module scope (line 41) and shared across every test in write-file.test.ts. State from earlier tests — most recently the 'records a write' integration test that records the same path — leaks forward. On Linux the test ordering puts a record-bearing test before the enforcement test, so the cache reports `stale` (mtime drifted) instead of `unknown`. macOS / Windows happen to order them differently and never hit it. Adding a fileReadCache.clear() to beforeEach gives every test a known-empty cache, matching how edit.test.ts already isolates its per-test cache by re-instantiating it. * fix(core): close prior-read enforcement gaps flagged in 3rd review Three concrete loopholes / regressions that the original PR-B introduction left open. All three are addressed in the same commit because the underlying refactor (move enforcement earlier and tighten the fresh predicate) is shared across them. 1. fresh != "model has seen the bytes". Pre-fix, requirePriorRead() accepted any cache.check === 'fresh'. ReadFileTool records every successful read into the cache, including ranged reads (offset/limit), truncated full reads, and non-cacheable binary/image/audio/video/PDF/notebook reads (lastReadCacheable = false). This let the model peek at a slice or a structured payload of a file and then mutate the whole thing. Tightened the accept predicate to fresh && lastReadAt && lastReadWasFull && lastReadCacheable. 2. Read-less content oracle through calculateEdit error codes. Pre-fix, execute() ran calculateEdit (which reads file bytes and counts matches) before the enforcement check. A model could probe an unread file by attempting Edits with candidate old_strings and observing NO_OCCURRENCE_FOUND vs EXPECTED_OCCURRENCE_MISMATCH vs EDIT_NO_CHANGE — reverse-engineering content without ever calling read_file. Moved enforcement to the top of calculateEdit, before any content read; only a stat is performed up to the rejection point. 3. Confirmation flow regression. Pre-fix, getConfirmationDetails() read the existing file to render a diff for the user, then approval flowed to execute() which would freshly check the cache and reject. The user could approve a diff computed from current bytes the model never saw, and the call would still fail. Moved enforcement before the confirmation read in both EditTool (via the shared calculateEdit path) and WriteFileTool (explicit check at the top of getConfirmationDetails). The user now never sees a confirmation diff for an unread file — the call rejects up front. Public API surface change: requirePriorRead() -> checkPriorRead() that returns a structured decision, so the same predicate can route into a CalculatedEdit.error (calculateEdit), a thrown error (getConfirmationDetails), or a ToolResult (execute) without duplicating the boolean / message / type plumbing in three shapes. Reported by pomelo-nwu (3 inline comments on PR #3774). * refactor(core): close 4 prior-read enforcement gaps from 4th review 1. recordWrite now seeds read metadata on brand-new entries (lastReadAt / lastReadWasFull / lastReadCacheable). The strict accept predicate added in the previous round (#3 review) requires all three, but recordWrite only set lastWriteAt — so a model creating a file with Edit (old_string="") or WriteFile and then editing it again was rejected on the second edit. The model authored the bytes it just wrote; for the purposes of prior-read enforcement that counts as having seen them. New regression test in edit.test.ts: "allows a create-then-edit-then-edit chain without an intervening read". 2. Extracted checkPriorRead into src/tools/priorReadEnforcement.ts. The two copies in edit.ts and write-file.ts had already drifted (one used ${ReadFileTool.Name}, the other hardcoded 'read_file'); the boolean guard is security-sensitive and a one-sided fix would silently weaken the boundary. The shared utility takes a verb ('editing' | 'overwriting') so the user-facing prose can differ between callers without duplicating the decision logic. 3. WriteFileTool.execute now runs prior-read enforcement BEFORE readTextFile. Pre-fix, an unread overwrite still slurped the entire file into memory (encoding / BOM / line-ending detection) and only then rejected it: wasted I/O, and momentary in-memory custody of bytes the model never legitimately read. Now matches the order in getConfirmationDetails(). 4. The "rejects a write that would overwrite an unread existing file" test now spies on FileSystemService.readTextFile and asserts not.toHaveBeenCalled() — without that, the test gave false confidence: it passed both pre-fix (read happened, then reject) and post-fix (reject before read), so the ordering regression in (3) was invisible to the assertion. Reported by glm-5.1 via /review on PR #3774. * refactor(core): close 4 prior-read enforcement gaps from 4th review (Copilot) Five concrete gaps that the previous round of enforcement work left open. Reported by Copilot via /review on PR #3774. 1. Confirmation-time rejections lost their ToolErrorType code. getConfirmationDetails() in both EditTool and WriteFileTool threw a plain Error on prior-read failure, which coreToolScheduler collapsed into UNHANDLED_EXCEPTION — silently breaking the EDIT_REQUIRES_PRIOR_READ / FILE_CHANGED_SINCE_READ contract for any approval-required flow. Fix: introduce PriorReadEnforcementError that carries `errorType: ToolErrorType`. Both confirmation paths now throw it, and coreToolScheduler reads `error.errorType` (falling back to UNHANDLED_EXCEPTION when absent). New regression tests assert the thrown error's `errorType` field for both tools. 2. checkPriorRead's "re-read with read_file" advice was wrong for binary / image / audio / video / PDF / notebook files. Their ReadFile result always sets lastReadCacheable=false, so the message would loop the agent forever on the same rejection. Fix: detect the fresh-but-non-cacheable case explicitly and return a dedicated message that explains the dead end ("Edit / WriteFile cannot mutate that payload safely") instead of asking for another read. Updated the existing non-cacheable regression test to assert the new message and the absence of "use the read_file tool first". 3. checkPriorRead swallowed every stat() failure and returned ok:true. EACCES, EBUSY, NFS hiccups, etc. would silently re-open the blind-write path the helper exists to block. Fix: only ENOENT continues to return ok:true (disappearance race). Any other code is fail-closed: returns EDIT_REQUIRES_PRIOR_READ with a message that names the errno. New regression test in write-file.test.ts spies on fs.promises .stat to inject EACCES and asserts the rejection. 4. The auto-memory record regression test only asserted `state === 'fresh'`. A future change that recorded auto-memory reads as partial / non-cacheable would still satisfy that assertion but would actually fail enforcement on every follow-up Edit. Fix: also assert lastReadAt is defined, lastReadWasFull is true, and lastReadCacheable is true. The full "what enforcement requires" predicate is now explicit in the test. (The 5th item, the WriteFile mirror of (1), is covered by the same PriorReadEnforcementError change.) * refactor(core): tighten StructuredToolError naming + add scheduler test Four follow-ups raised by deepseek-v4-pro on PR #3774. None of them change the enforcement boundary; they are all about making the contract clearer and harder to break in future changes. 1. PriorReadEnforcementError -> StructuredToolError. The class now wraps any content-derived ToolErrorType from calculateEdit (EDIT_NO_OCCURRENCE_FOUND, EDIT_EXPECTED_OCCURRENCE_MISMATCH, EDIT_NO_CHANGE, ATTEMPT_TO_CREATE_EXISTING_FILE) on top of the prior-read codes. The old name suggested the class was prior- read-specific, which would mislead any oncall engineer seeing it paired with one of the calculateEdit error codes. 2. EDIT_REQUIRES_PRIOR_READ kept its name (the prefix mentions "edit" but the enum is shared with WriteFileTool) — chose documentation over rename to avoid the churn of a value rename across logs/dashboards already keyed on it. JSDoc now spells out the cross-tool usage explicitly. 3. Stat failures other than ENOENT now map to a new PRIOR_READ_VERIFICATION_FAILED code instead of being conflated with EDIT_REQUIRES_PRIOR_READ. The failure mode is "we cannot verify" rather than "definitely not read" — operators routing on error codes can distinguish the two populations. 4. Added a coreToolScheduler test (`surfaces error.errorType from a confirmation throw instead of UNHANDLED_EXCEPTION`) that constructs a stub tool whose getConfirmationDetails throws StructuredToolError and asserts the surfaced ToolCall response carries the correct ToolErrorType. Without this test the scheduler's explicitErrorType branch would have no coverage at all. Tool tests updated for the new StructuredToolError class name and the PRIOR_READ_VERIFICATION_FAILED code on the EACCES path. * fix(core): close TOCTOU + grammar + directory regressions in PR-B Six concrete issues that the previous round of enforcement work left open. Reported by Copilot via /review on PR #3774. 1. TOCTOU window between pre-read checkPriorRead and readTextFile. The pre-read stat could pass enforcement, then an external writer could land between that stat and the actual read, leaving currentContent reflecting bytes the model never saw — exactly the stale-write path the PR is supposed to block. Closed by re-running checkPriorRead immediately after every readTextFile that fed currentContent / originalContent: EditTool.calculateEdit and the two WriteFileTool paths (execute + getConfirmationDetails). A `stale` outcome now fails the operation with FILE_CHANGED_SINCE_READ at the correct moment. 2. Directory targets sent the model into an enforcement loop. `fileExists` is a plain access check, so directories also entered the enforcement branch — the model would be told to call `read_file`, but `read_file` rejects directories with TARGET_IS_DIRECTORY, so the loop never terminated. Fixed in checkPriorRead: if `fs.stat` reports the path is not a regular file, return `ok: true` so the downstream readTextFile / write path can surface its own EISDIR / similar error. 3. Confirmation-time error messages used the short `display` form instead of the full `raw` form. Approval-required Edit calls therefore lost the remediation detail (file path, stale-vs-unread distinction, "without offset / limit / pages" hint) that the execute path already surfaced and that the WriteFile confirmation path already preserved. EditTool.getConfirmationDetails now throws StructuredToolError with `editData.error.raw`. 4. Non-text payload displayMessage was grammatically broken: built from the gerund `editing` / `overwriting`, it rendered as "cannot editing via this tool" / "cannot overwriting via this tool". Fixed by deriving a bare-verb form (`edit` / `overwrite`) alongside the gerund and using it in displayMessage. (Items 1, 5 and 6 from Copilot's batch are the same TOCTOU class — EditTool calculateEdit + WriteFile execute + WriteFile confirmation — addressed together in (1) above.) The "bypasses enforcement entirely" test now uses mockReturnValue instead of mockReturnValueOnce because calculateEdit calls getFileReadCacheDisabled twice — once for the pre-read check and once for the post-read TOCTOU re-check. Both must see disabled=true to actually bypass. * fix(core): close fileExists TOCTOU on WriteFile prior-read enforcement WriteFile gated prior-read enforcement on `fileExists` from `isFilefileExists()`, but a file that sprang into existence between that check and the write would still be overwritten without enforcement — `fileExists === false` skipped the check entirely. Made the gate unconditional on `fileExists`. checkPriorRead's own `fs.stat` decides what to do: - ENOENT → ok:true, fall through to the new-file path as before - file exists right now (whether or not isFilefileExists saw it) → unknown / stale check runs, the race-created file is rejected. Applied to both getConfirmationDetails and execute. The path that actually creates new files is unchanged because checkPriorRead's ENOENT branch is the disappearance-race exit, which is the correct exit for "the file truly does not exist yet". Reported by glm-5.1 via /review on PR #3774. * fix(core): close 4 enforcement gaps + 1 critical bug from 5th Copilot review Six issues raised by deepseek-v4-pro / glm-5.1 / qwen3.6-plus on PR #3774. Listed by reviewer-assigned severity. [Critical] (qwen3.6-plus) recordWrite previously only seeded the read metadata for brand-new entries. The reproduction was real: ReadFile(limit=10) → WriteFile(full content) → Edit. The partial read's lastReadWasFull=false would persist through the write, and the Edit would be rejected with EDIT_REQUIRES_PRIOR_READ even though the model just authored every byte. recordWrite now unconditionally refreshes lastReadAt, lastReadWasFull=true, and lastReadCacheable=true. The fileReadCache.test.ts case that previously asserted "preserves lastReadAt" is rewritten to assert the new "refreshes lastReadAt to match the write" contract, and a new "upgrades lastReadWasFull / lastReadCacheable after a full write" regression test pins the reproduction reviewer described. [Suggestion] (deepseek-v4-pro) Narrowed the non-regular-file bypass in priorReadEnforcement from `!stats.isFile()` to `stats.isDirectory()`. The earlier broad form covered FIFOs, sockets, and devices that the model has no legitimate "read first" recourse for and that can block readTextFile (FIFO) or over-allocate (/dev/urandom). Those now flow through to cache.check() and reject with the unread-file path before any I/O. [Suggestion] (glm-5.1) Removed the `fileExists && ...` gate from EditTool.calculateEdit, mirroring the f4ef756 fix on WriteFile. A file that springs into existence between isFilefileExists() and the enforcement check is now caught here as well; ENOENT inside checkPriorRead remains the disappearance-race exit and new-file creation flow is unchanged. [Suggestion] (deepseek-v4-pro) Added debugLogger.warn() at every post-read TOCTOU rejection site (Edit calculateEdit, WriteFile getConfirmationDetails, WriteFile execute). These rejections are rare and self-healing — without a debug record, an operator investigating "why did this Edit fail once?" had nothing to grep. debugLogger uses dedicated 'EDIT_PRIOR_READ' / 'WRITE_FILE' tags. [Suggestion] (qwen3.6-plus) Added a final pre-write checkPriorRead in EditTool.execute() and WriteFileTool.execute(). The earlier post-read check ran inside calculateEdit (Edit) or before mkdirSync (WriteFile), but the actual writeTextFile call could be arbitrarily later — user approval, modify-and-confirm, etc. The window from "post-read check → writeTextFile" is now bounded to "pre-write stat → writeTextFile" (two adjacent syscalls). * fix(core): close new-file race + special-file enforcement loop Three issues from the latest Copilot review on PR #3774. 1. New-file race in pre-write enforcement (write-file.ts:348, edit.ts:487). The earlier pre-write checkPriorRead was gated on `fileExists` (WriteFile) and `!editData.isNewFile` (Edit). If the path was absent at planning time and another process created it while approval was pending, the gated form would skip enforcement and silently overwrite a pre-existing file the model never read. Run unconditionally in both tools — checkPriorRead's own ENOENT branch is the disappearance-race exit, so genuine new-file creation is unaffected, but a race-created file now hits the `unknown` branch and is rejected as unread. 2. FIFO / socket / device sent the model into an enforcement loop (priorReadEnforcement.ts:220). After narrowing the non-regular-file bypass to directories only, FIFOs etc. fell through to cache.check, returned `unknown`, and produced a "use read_file first" message — but read_file rejects those same targets as "not a regular file", so the model would loop on read_file forever. Added a dedicated `!stats.isFile()` branch (after the directory exemption) that returns a "special file; cannot edit/overwrite via this tool — use shell instead" message, matching the shape of the existing non-text-payload guidance. (Tool-error.ts and the non-cacheable policy notes are addressed in the PR description update — not in code.) * fix(core): close 4 enforcement gaps from 6th Copilot review (Plus a doc-only update for the 5th — the mtime+size limitation warning in the Risk section now mentions the silent-overwrite escalation that this PR's mutation paths bring along.) 1. ENOENT after the model has already read the file is no longer silently treated as `ok: true`. Added an `expectExisting` option to `checkPriorRead`; post-read and pre-write callers pass `true`. ENOENT under that flag now rejects with `FILE_CHANGED_SINCE_READ` ("file disappeared after the model read it") rather than falling through to the new-file path with stale bytes. Pre-read callers keep the old default (ENOENT → ok:true → fall through to genuine new-file creation). EditTool's pre-write check derives the flag from `editData.isNewFile`; WriteFile's pre-write check derives it from the post-read `fileExists` value. 2. Directory targets now reject with `TARGET_IS_DIRECTORY` and a structured message instead of returning `ok: true`. The previous form fell through to readTextFile(), which on the WriteFile confirmation path threw a plain Error and was surfaced by the scheduler as `UNHANDLED_EXCEPTION`. Both Edit and WriteFile now emit a structured rejection at enforcement time. (WriteFile's build-time validateToolParamValues already rejects directories, so the change matters most for EditTool.) 3. Non-cacheable rejection's `rawMessage` no longer hard-codes "overwrite" — it now uses the same `verbBare` derivation as the `displayMessage`, so EditTool's path correctly says "if you need to edit it" and WriteFile's path stays "if you need to overwrite it". The previous form was confusing for in-place edits. 4. WriteFile.getConfirmationDetails now mirrors execute()'s ENOENT-to-new-file fallback: a file that disappears between isFilefileExists() and the readTextFile-for-diff call no longer throws a plain Error (which would surface as UNHANDLED_EXCEPTION) — it falls back to the brand-new-file diff so the user sees a clean confirmation rather than an unstructured crash. Tests: - New: `rejects an edit on a directory with TARGET_IS_DIRECTORY` - New: `confirmation falls back to a new-file diff when the file disappears mid-flight` (WriteFile) - Updated: non-cacheable rejection asserts `verbBare` is "edit" on the EditTool path and "overwrite" on the WriteFile path. Reported by Copilot via /review on PR #3774. * docs(core): clarify stat→write race + EDIT_REQUIRES_PRIOR_READ scope Three doc-only follow-ups from Copilot's latest review pass on PR #3774. None change behaviour; the pre-fix code state was already the actual contract — the docs just lagged it. 1. EDIT_REQUIRES_PRIOR_READ enum comment now lists the three cases the code actually returns it for (never-read, partial / ranged / non-cacheable read, structural dead end — non-text payload or special file). The previous one-liner described only the first case and would mislead future maintainers. 2. The Final pre-write freshness check blocks in EditTool.execute and WriteFileTool.execute now spell out that they DO NOT eliminate the stat → writeTextFile race. The window narrows from the previously-unbounded post-read-to-write gap down to two adjacent syscalls, but a concurrent writer landing in that pair can still be clobbered. Closing the residual would require an atomic write (write-to-temp + rename) or a content-hash post-write recheck — both deferred. Operators who need strict protection set `fileReadCacheDisabled: true` and rely on application-level locking. 3. PR description Risk section gains a "Known unmitigated: stat → write race window" subsection (English + Chinese mirror) matching the code comments. * chore(core): minor follow-ups from review #4229917446 Three of the five MINOR items raised in the independent code review on 2026-05-05 — the cheap, isolated ones. The other two (race- simulating integration test, moving StructuredToolError out of priorReadEnforcement.ts) are deferred as the reviewer suggested. 1. EditTool now has a symmetric `PRIOR_READ_VERIFICATION_FAILED` regression test (mocks fs.promises.stat to reject with EACCES, asserts the EditTool path produces the same fail-closed result that the existing WriteFile EACCES test pins). Five-line fix to close the asymmetry that, while harmless today (the helper is shared), would let a future Edit-side change to checkPriorRead slip through without test coverage. 2. ensureParentDirectoriesExist / mkdirSync now run AFTER the pre-write checkPriorRead in both EditTool.execute() and WriteFileTool.execute(). Doing it before would leak intermediate directories on the rejection path — a real (if minor) FS litter the previous order created on every rejected new-file write. 3. EDIT_REQUIRES_PRIOR_READ enum docstring gains a one-line note for operators routing alerts on this code: a single `edit_requires_prior_read` signal can mean any of the three cases (no read / partial read / structural dead-end), and if per-cause monitoring becomes important the enum can be split in a follow-up. The originating tool name and the message text already disambiguate at runtime. * fix(core): close 2 correctness gaps from maintainer review #4232751470 Both tracked back to the cache's "track most recent read shape" model diverging from prior-read enforcement's "model has seen these bytes" model. 1. SVG (and similar string-content fallbacks) recorded as non-cacheable, blocking subsequent Edit / WriteFile. `read-file.ts` derives `cacheable` from `originalLineCount !== undefined && !isTruncated`. The SVG branch in `fileUtils.ts` returned content without `originalLineCount`, so `cacheable` collapsed to false and a follow-up Edit hit the dead-end "non-text payload — use shell" rejection — telling the model to use shell to mutate a file it had just successfully read as text. This was a real regression vs pre-PR behaviour where SVG-as-text editing worked. Fix: SVG-as-text branch now sets `originalLineCount` (split on '\n') and `isTruncated: false`, so ReadFile records it as a full cacheable read. The binary-fallback string and over-1MB SVG branches are deliberately left non-cacheable — they return placeholder strings ("Cannot display content of ...") rather than file content, so blocking edits there is correct. New regression test in `read-file.test.ts`: `records SVG-as-text reads with cacheable=true so a follow-up Edit passes enforcement`. 2. recordRead unconditionally overwriting lastReadWasFull / lastReadCacheable, revoking prior write-author or full-read rights. The `WriteFile(create) → ReadFile(offset/limit) → Edit` sequence rejected the Edit because the partial read clobbered the `lastReadWasFull = true` that `recordWrite` had stamped at create time. Same shape applies to a full text read followed by a partial one of the same inode. Fix: `recordRead` is now sticky-on-true for the read flags — `if (opts.full) entry.lastReadWasFull = true;` and the matching guard for `cacheable`. Prior `true` survives a later partial / non-cacheable read. The fast-path `file_unchanged` check still gates on the incoming request's own `isFullRead` in `read-file.ts`, so a partial read still does not get a placeholder it shouldn't. Updated the existing "overwrites earlier lastReadWasFull" test to assert the new sticky semantics, and added a `lastReadCacheable` symmetric test plus a `Write → partial-Read → Edit` end-to-end test in `edit.test.ts`. Reported by tanzhenxin via independent maintainer review on 2026-05-06. * fix(core): close 3 correctness gaps from re-review #4233904930 All three are tightenings of the prior `de8ddf530` round. 1. **Sticky-on-true narrowed to "no fingerprint drift"**. `fileReadCache.recordRead` previously kept `lastReadWasFull` / `lastReadCacheable` true across drifted recordings, which re-opened a `Read full @x → external write @y → Read partial @y → Edit` hole: the partial recordRead silently advanced the entry's mtime+size to Y while preserving the sticky `full=true` from X, so a follow-up Edit ran against bytes the model only saw the first 10 lines of. Now the sticky branch only fires when `(mtimeMs, sizeBytes)` matches the existing entry; on drift, both flags reset to exactly what this read produced. New regression test in `fileReadCache.test.ts` reproduces the reviewer's reported sequence. 2. **Subagent FileReadCache isolation now covers the inherits-model + same-approval-mode common case**. The own-property machinery from #3717 only triggers when an `Object.create(parent)` actually fires; both `agent.ts:990-993` (same-approval-mode) and `subagent-manager.ts:699-701` (inherits-model) had paths that returned the parent Config directly, so the subagent's `getFileReadCache()` resolved to the parent's instance — a parent Read could satisfy the subagent's Edit on a path the subagent's transcript never contained. Both sites now build a thin `Object.create(base)` override unconditionally; no method changes for the inherits / same-mode cases, but a distinct instance triggers the lazy-init in `Config.getFileReadCache()` so the subagent gets an isolated cache. 3. **Cache records the read pipeline's internal stat instead of a post-read re-stat**. `processSingleFileContent` now surfaces its internal stat via `result.stats`, and read-file uses that for `recordRead` instead of running its own stat after the read returns. Pre-fix, an external write between the pipeline call and the post-read stat let the cache record fingerprint Y for content the model received at X — a subsequent Edit would pass enforcement against bytes the model never legitimately saw. The internal-stat-to-read window is still a few microseconds wide; that residue is the same content-hash territory acknowledged in the Risk section. Reported by tanzhenxin via re-review on PR #3774. * docs(core): clarify partial subagent isolation per review #4234090906 tanzhenxin's third review correctly observed that the `Object.create(parent)` wrappers in `agent.ts:createApprovalModeOverride` and `subagent-manager.ts:maybeOverrideContentGenerator` only isolate the FileReadCache for code that consults `Config.getFileReadCache()` directly. Bound `EditTool` / `WriteFileTool` instances were registered against the parent's tool registry at initialise time, so tool invocations still resolve `this.config` to the parent and reach the parent's cache. `InProcessBackend.createPerAgentConfig` already does the right thing (`override.createToolRegistry()` + `copyDiscoveredToolsFrom(base.getToolRegistry())`); bringing that to these two spawn sites is the real fix. Reviewer's verdict was COMMENT, not REQUEST_CHANGES — the gap pre-dates this PR (it's a property of #3717's per-Config own-property machinery) and pre-PR there was no enforcement on subagent mutations at all, so the PR is strictly an improvement on every spawn path. Documented the partial guarantee explicitly: - Inline comments on both spawn sites note the bound-tool caveat and point at `InProcessBackend.createPerAgentConfig` as the model for the follow-up. - PR description's subagent paragraph (English + Chinese mirror) now splits into "fully isolated" (`InProcessBackend.createPerAgentConfig`) and "partial isolation" (the two sites in this PR) so readers don't walk away with the wrong contract. Filing the registry-rebuild work as a follow-up; not in this PR.
…d tools resolve to the subagent (#3873) * fix(core): rebuild tool registry on subagent Config overrides so bound tools resolve to the subagent PR-B (#3774) added per-Config FileReadCache isolation via Object.create overrides at two subagent spawn sites — agent.ts:createApprovalModeOverride and subagent-manager.ts:maybeOverrideContentGenerator. The override shielded code that read FileReadCache directly through the Config instance, but missed the bound-tool path: Config.createToolRegistry runs once at parent initialise time, so the parent's EditTool / WriteFileTool / ReadFileTool instances are bound with `this.config = parent`. The subagent's Object.create wrapper inherited getToolRegistry via the prototype chain, reaching the parent registry whose bound tools then read FileReadCache and approval mode from the parent. This change closes that gap by rebuilding the tool registry on the override at both sites — the same pattern InProcessBackend.createPerAgentConfig already uses: - override.createToolRegistry(undefined, { skipDiscovery: true }) - registry.copyDiscoveredToolsFrom(base.getToolRegistry()) - override.getToolRegistry = () => registry createApprovalModeOverride becomes async; its single call site already ran inside an async block. maybeOverrideContentGenerator skips the rebuild when the upstream Config already has its own getToolRegistry (real-world case: agent.ts wrapper passed through createAgentHeadless), avoiding wasted work, listener accumulation on shared SubagentManager / SkillManager, and a cache split where the bound tools' registry layer diverges from the runtime context's lazy-init cache. Includes regression tests in agent-override.test.ts and subagent-manager-override.test.ts that exercise the bound-tool path: they instantiate the lazy factories on the override registry and assert that EditTool / WriteFileTool / ReadFileTool resolve this.config to the override Config (and thus to the override's FileReadCache / approval mode), not the parent. * fix(core): close bound-tool gap on resumed background agents too Follow-up audit on PR #3873 surfaced a duplicate, pre-rebuild copy of `createApprovalModeOverride` living in `background-agent-resume.ts` (L142-150). Resumed fork agents go through `createResumedForkSubagent` which bypasses `SubagentManager.maybeOverrideContentGenerator` (where the registry rebuild now lives), so the resumed fork's `EditTool` / `WriteFileTool` / `ReadFileTool` were still resolving `this.config` to the parent and reading the parent's `FileReadCache`. The non-fork resume path went through `subagent-manager` and worked correctly only because `maybeOverrideContentGenerator` saw no upstream own-registry on `bgConfig` and rebuilt one — but with that fallback the fork path could never benefit. This change deletes the local copy and switches `background-agent-resume.ts` to import the now-async exported `createApprovalModeOverride` from `agent.ts`. Drops the previous `?: this.config` short-circuit so the resumed agent ALWAYS gets a wrapper Config — the same behaviour `agent.ts` already enforces; reusing the parent directly defeats the per-Config FileReadCache isolation. Updates `background-agent-resume.test.ts` mock config with the `createToolRegistry` / `getToolRegistry` stubs the rebuild path now exercises. * fix(core): address bound-tool isolation review feedback Three independent fixes from PR #3873 review feedback: 1. Switch the upstream-rebuild guard from `hasOwnProperty(base, 'getToolRegistry')` to a Symbol-keyed marker `TOOL_REGISTRY_REBUILT`. The own-property check missed the case where the override is reached via an Object.create wrapper above the rebuilt Config (e.g. `bgConfig = Object.create(agentConfig)` in the agent.ts background path) — it would falsely report "no upstream rebuild" and cause a redundant third rebuild that wastes work and doubles the listener-leak surface. Symbol property reads walk the prototype chain via normal lookup, so a marker stored on any ancestor is correctly observed. Extracts the shared rebuild logic into `rebuildToolRegistryOnOverride(override, base)` so the three spawn sites (agent.ts:createApprovalModeOverride, the inherits branch, the non-inherit branch) cannot drift apart. 2. Stop the per-subagent ToolRegistry in the lifecycle finally blocks: - agent.ts foreground finally (after the inner try wrapping `runFramed`) - agent.ts background bgBody finally (after `bgSubagent.execute` resolves) - background-agent-resume.ts resume body finally (same shape) Without this, every AgentTool / SkillTool the model instantiates from the per-subagent registry registers a change-listener on shared SubagentManager / SkillManager, and repeated subagent runs accumulate listeners for the rest of the session. Stop is fire-and-forget, matching `InProcessBackend.cleanup` and `stopAgent`. 3. Add bound-tool isolation tests for the non-inherit branch (explicit-model selector). The original PR only covered the inherits branch directly; the non-inherit branch now goes through the same helper, but a dedicated test pins `tool.config === override` and the FileReadCache binding so a regression cannot leave explicit-model subagents reading the parent's cache while existing model-override tests still pass. Tests now exercise: - Symbol marker propagation via Object.create chain (3 cases) - Non-inherit rebuild + bound-tool isolation - Non-inherit skip-rebuild when upstream wrapper has the marker - Pre-existing inherits / chained-override / approval-mode propagation - Mock configs in agent.test.ts / subagent-manager.test.ts / background-agent-resume.test.ts gain `stop` and `tools: Map` stubs to model the registry contract the override path now exercises. `npx vitest run packages/core/src` — 268 files / 6943 passed.
…quired for WriteFile (#3932) PR #3774 introduced a `lastReadWasFull` requirement to `checkPriorRead` that forced models to re-read multi-thousand-line files just to make a single-line edit. The `0 occurrences` failure mode in `calculateEdit` already catches a fabricated `old_string` that misses the actual bytes, so requiring a full read on top of that is over-defence at a real context cost — but only for Edit, not for WriteFile. WriteFile is asymmetric: it replaces the entire file and has no content-derived guard equivalent to `old_string` matching. A model that has only seen a slice via `read_file(offset, limit)` followed by a `WriteFile` would necessarily hallucinate the rest of the bytes — the issue #2499 data-loss scenario PR #3774 was opened to address. Split the policy along that line. `checkPriorRead` gains a `requireFullRead?: boolean` option. WriteFileTool's 5 enforcement call sites pass `true`; EditTool's 3 leave it unset (default `false`): - EditTool: partial read counts (old_string is the floor) - WriteFileTool overwrite: full read required - Either: new-file creation exempt (ENOENT → ok:true before requireFullRead is consulted); `fileReadCacheDisabled` escape hatch unchanged A dedicated `fresh + cacheable + partial + requireFullRead` rejection branch surfaces a clear "only been partially read … overwriting replaces the entire file" message instead of falling through to the generic "has not been read" wording. The `unknown` branch's wording also varies by `requireFullRead` so the read instruction matches the operation's actual requirement. For comparison, Claude Code's `readFileState` enforcement treats partial and full reads identically for both Edit and WriteFile. This PR is stricter on WriteFile (full read required) and identical on Edit (partial OK). Issue #2499 is empirical evidence that the partial-read-then-overwrite case is real on at least some model populations, so the additional WriteFile constraint is justified. Single-commit shape (versus the earlier afc1b91 / 503fc0b split) to avoid an intermediate state in which Edit's relaxation has landed but WriteFile is still on the relaxed path: cherry-picks or bisect walks crossing such a boundary would re-introduce the issue #2499 data-loss case. Tests: edit.test.ts ranged-read test inverted to "allows after ranged read"; write-file.test.ts ranged-read test asserts the new partial / full-required message. Three error-message regex matchers updated from /fully read/ to /read/. 198 / 198 prior-read-related tests pass; tsc --noEmit clean.
…quired for WriteFile (QwenLM#3932) PR QwenLM#3774 introduced a `lastReadWasFull` requirement to `checkPriorRead` that forced models to re-read multi-thousand-line files just to make a single-line edit. The `0 occurrences` failure mode in `calculateEdit` already catches a fabricated `old_string` that misses the actual bytes, so requiring a full read on top of that is over-defence at a real context cost — but only for Edit, not for WriteFile. WriteFile is asymmetric: it replaces the entire file and has no content-derived guard equivalent to `old_string` matching. A model that has only seen a slice via `read_file(offset, limit)` followed by a `WriteFile` would necessarily hallucinate the rest of the bytes — the issue QwenLM#2499 data-loss scenario PR QwenLM#3774 was opened to address. Split the policy along that line. `checkPriorRead` gains a `requireFullRead?: boolean` option. WriteFileTool's 5 enforcement call sites pass `true`; EditTool's 3 leave it unset (default `false`): - EditTool: partial read counts (old_string is the floor) - WriteFileTool overwrite: full read required - Either: new-file creation exempt (ENOENT → ok:true before requireFullRead is consulted); `fileReadCacheDisabled` escape hatch unchanged A dedicated `fresh + cacheable + partial + requireFullRead` rejection branch surfaces a clear "only been partially read … overwriting replaces the entire file" message instead of falling through to the generic "has not been read" wording. The `unknown` branch's wording also varies by `requireFullRead` so the read instruction matches the operation's actual requirement. For comparison, Claude Code's `readFileState` enforcement treats partial and full reads identically for both Edit and WriteFile. This PR is stricter on WriteFile (full read required) and identical on Edit (partial OK). Issue QwenLM#2499 is empirical evidence that the partial-read-then-overwrite case is real on at least some model populations, so the additional WriteFile constraint is justified. Single-commit shape (versus the earlier afc1b91 / 503fc0b split) to avoid an intermediate state in which Edit's relaxation has landed but WriteFile is still on the relaxed path: cherry-picks or bisect walks crossing such a boundary would re-introduce the issue QwenLM#2499 data-loss case. Tests: edit.test.ts ranged-read test inverted to "allows after ranged read"; write-file.test.ts ranged-read test asserts the new partial / full-required message. Three error-message regex matchers updated from /fully read/ to /read/. 198 / 198 prior-read-related tests pass; tsc --noEmit clean.
…quired for WriteFile (QwenLM#3932) PR QwenLM#3774 introduced a `lastReadWasFull` requirement to `checkPriorRead` that forced models to re-read multi-thousand-line files just to make a single-line edit. The `0 occurrences` failure mode in `calculateEdit` already catches a fabricated `old_string` that misses the actual bytes, so requiring a full read on top of that is over-defence at a real context cost — but only for Edit, not for WriteFile. WriteFile is asymmetric: it replaces the entire file and has no content-derived guard equivalent to `old_string` matching. A model that has only seen a slice via `read_file(offset, limit)` followed by a `WriteFile` would necessarily hallucinate the rest of the bytes — the issue QwenLM#2499 data-loss scenario PR QwenLM#3774 was opened to address. Split the policy along that line. `checkPriorRead` gains a `requireFullRead?: boolean` option. WriteFileTool's 5 enforcement call sites pass `true`; EditTool's 3 leave it unset (default `false`): - EditTool: partial read counts (old_string is the floor) - WriteFileTool overwrite: full read required - Either: new-file creation exempt (ENOENT → ok:true before requireFullRead is consulted); `fileReadCacheDisabled` escape hatch unchanged A dedicated `fresh + cacheable + partial + requireFullRead` rejection branch surfaces a clear "only been partially read … overwriting replaces the entire file" message instead of falling through to the generic "has not been read" wording. The `unknown` branch's wording also varies by `requireFullRead` so the read instruction matches the operation's actual requirement. For comparison, Claude Code's `readFileState` enforcement treats partial and full reads identically for both Edit and WriteFile. This PR is stricter on WriteFile (full read required) and identical on Edit (partial OK). Issue QwenLM#2499 is empirical evidence that the partial-read-then-overwrite case is real on at least some model populations, so the additional WriteFile constraint is justified. Single-commit shape (versus the earlier afc1b91 / 503fc0b split) to avoid an intermediate state in which Edit's relaxation has landed but WriteFile is still on the relaxed path: cherry-picks or bisect walks crossing such a boundary would re-introduce the issue QwenLM#2499 data-loss case. Tests: edit.test.ts ranged-read test inverted to "allows after ranged read"; write-file.test.ts ranged-read test asserts the new partial / full-required message. Three error-message regex matchers updated from /fully read/ to /read/. 198 / 198 prior-read-related tests pass; tsc --noEmit clean.
+ #3945 (#4002) * fix(core): decouple cacheable flag from truncation in FileReadCache PR #3774 introduced prior-read enforcement that consults `lastReadCacheable` to discriminate text from binary / image / PDF / notebook payloads. ReadFileToolInvocation derived `cacheable` as `string && originalLineCount && !isTruncated`, conflating two unrelated concerns: "is the content text" and "did we see all the bytes". A partial read (offset/limit) or a truncated full read of a regular `.kt` / `.cpp` / `.py` source file therefore set `cacheable: false`, and priorReadEnforcement.ts mistook that for a non-text payload and rejected the next Edit with the misleading "binary / image / audio / video / PDF / notebook payload" error. PR #3932 split prior-read enforcement so Edit accepts partial reads (`lastReadWasFull`-relaxed for Edit, kept for WriteFile), but the `lastReadCacheable` conflation persisted, so partial / truncated text reads still hit the binary-payload rejection on Edit. Issue #3964 is the resulting field reports: .kt / .cpp / .py / .ts files on both Linux and Windows misclassified as binary across 0.15.7-0.15.9. Decouple the two concerns: - `cacheable` is now purely about content type. A partial or truncated text read records `cacheable: true` because the bytes the model saw were text. - Truncation gating moves to `full`. A request-level full read (no offset/limit/pages) only counts as full at the cache level when the produced content was not truncated; otherwise the model only saw the head of the file. The fast-path `file_unchanged` placeholder still requires both `lastReadWasFull && lastReadCacheable`, so its semantics are unchanged — a truncated full read now fails the AND on the moved flag instead of the original. WriteFile's `requireFullRead` still rejects partial or truncated text reads; it now reports the accurate "partial read" error instead of the wrong "binary payload" message. Also fixes issue #3945 (edit tool unusable for large files) as a side effect: the truncated-full case there hit the same misclassified path before the rejection wording could even surface the truncation question. Tests: 2 regression tests added in read-file.test.ts (partial .kt read and truncated full .cpp read both record `lastReadCacheable: true`). Existing 7386 / 7391 (5 skipped) core tests pass; tsc --noEmit clean. Issue #3964 also reports a separate scenario on Windows encrypted/DRM-protected file systems where .cpp source files are misclassified by `isBinaryFile`'s 4KB content sampling. That path is content-detection-side, not cache-side, and is left to a follow-up (extension- or mime-based override of the content sample for known text types). * fix(core): trust extension/mime over isBinaryFile sampling for known text Issue #3964's first report (Frank-Shaw-FS) describes `.cpp` / `.c` / `.h` source files on a Windows encrypted / DRM-protected file system being misclassified as binary. The OS surfaces encrypted bytes to `fs.open()` random-access reads, so `isBinaryFile`'s 4 KB sample sees nulls / non-printable characters and concludes binary — even though the higher-level `readFile` returns the decrypted text and the extension declares the file as text. Layer-2 fix on top of the cache-side decoupling: change `detectFileType` to trust the registry / curated extension list *before* running the content sample, so a known text extension is not subject to false positives from raw-bytes sampling. - Trust mime types declared as text: `text/*`, `application/*` text-likes (`application/javascript`, `application/json`, `application/toml`, ...), and any mime ending in `+xml` / `+json`. - Trust a curated set of source-code / config / markup extensions whose `mime/lite` registry coverage is patchy (`.py`, `.kt`, `.go`, `.rb`, `.swift`, `.scala`, `.rs`, `.proto`, `.graphql`, `.toml`, `.hcl`, `.tf`, ...). The list is restricted to extensions we have observed to be misclassified by `isBinaryFile` in the field; obscure extensions still go through the content sampler. Order in `detectFileType`: 1. Hardcoded `.ts` / `.svg` / `.ipynb` 2. Mime check (image / audio / video / pdf / declared-text) 3. `BINARY_EXTENSIONS` pre-empt (so `.png` with text-looking content stays binary) 4. Curated text extension override (for mime-less source code) 5. `isBinaryFile` content sampler (final fallback for unrecognised extensions) 6. Default text Tests: 5 new cases in `fileUtils.test.ts` and 1 end-to-end in `read-file.test.ts` covering: text mime override on binary-looking content, application/* text mimes, `+xml` / `+json` suffix match, curated extension override on `.py` / `.kt` / `.go` / `.rb` / `.swift`, and the `BINARY_EXTENSIONS` pre-empt still winning over the new override (a `.png` whose first bytes happen to be ASCII text stays binary). Full core suite passes (7392 / 7397, 5 pre- existing skips); `tsc --noEmit` clean. Together with the earlier commit, this PR closes both arms of issue #3964: the cache-side `cacheable` conflation that affected partial / truncated reads, and the content-detection-side false positive on encrypted file systems. * fix(core): tighten detectFileType after self-review on #4002 Three follow-ups flagged by `/review` on #4002: 1. `KNOWN_TEXT_APPLICATION_MIMES` had 10 dead entries — names like `application/x-sh`, `application/x-perl`, `application/x-yaml`, `application/x-tex`, `application/x-sql`, `application/graphql` are real mimes seen in HTTP `Content-Type` contexts but are not in `mime/lite`'s registry, so `mime.getType()` never returns them and the entries are unreachable. Strip the set to the 6 values the registry actually emits (`javascript`, `ecmascript`, `node`, `json`, `xml`, `toml`); the shells / tex / sql / graphql extensions reach the text fallback through `KNOWN_TEXT_EXTENSIONS` instead. Add a scope rule in the docstring so future additions stay aligned with what mime/lite actually emits. 2. The early-return at the top of `detectFileType` listed `.ts / .mts / .cts / .tsx` in its comment but the array only contained `.ts / .mts / .cts`. `.tsx` was reaching the text verdict via `KNOWN_TEXT_EXTENSIONS`, which works today but would break if a future `mime/lite` update mapped `.tsx` to `video/mp2t` (mirroring `.ts`): the `startsWith('video/')` guard would fire before the text fallback. Move `.tsx` up to the early-return array so the comment matches the code and the defence is consistent across the TypeScript family. Drop the duplicate listing in `KNOWN_TEXT_EXTENSIONS`. 3. `isTextMime()` short-circuits `isBinaryFile` for any `text/*` mime, which is the necessary tradeoff for the encrypted-FS fix but removes the safety net for *corrupted* text files (a binary blob saved with a `.txt` / `.md` extension via redirection). Document the tradeoff explicitly with a concrete counter-example and call out that Edit's `0 occurrences` failure mode is the fallback for the corrupted-text population. Tests: 261 / 262 (1 skipped, pre-existing) on `fileUtils.test.ts` + `read-file.test.ts` + `edit.test.ts` + `write-file.test.ts`. `tsc --noEmit` clean. * fix(core): drop full-read requirement on WriteFile, align with Claude Code PR #3932 deliberately diverged from Claude Code's `readFileState` by keeping `requireFullRead: true` on WriteFile's prior-read enforcement, citing issue #2499 (LLM hallucinates content of an unread file and clobbers user changes) as evidence that the asymmetric stance was justified. In practice that stance leaves a hard deadlock: when a file is larger than `truncate-tool-output- lines`, `read_file` without offset/limit still records `lastReadWasFull: false` (the model only saw the head), and the "only been partially read … re-read without offset / limit / pages" rejection sends the model back to the same truncated read with no escape — the exact deadlock issue #3945 reported. Drop the `requireFullRead` option from `checkPriorRead` and remove all 5 `requireFullRead: true` call sites in WriteFileTool. After this change the contract is identical to Claude Code's: any prior read of an existing file clears enforcement; the mtime/size drift check is the only gate that distinguishes "the model has seen current bytes" from "the model has seen older bytes", and it fires identically for Edit and WriteFile. The residual #2499 risk is acknowledged in the docstring: a model that reads only a slice and then overwrites would necessarily hallucinate the rest of the bytes. Mitigations: - `fileReadCacheDisabled: true` for users who want stricter behaviour (existing escape hatch, unchanged). - The mtime/size drift check still rejects Writes against bytes the model saw at fingerprint X if disk has moved to Y. Cleanup: drops the dedicated "fresh + cacheable + partial + requireFullRead" rejection branch and the `requireFullRead`-aware wording variant in the `unknown` branch — both unreachable now. Tests: - `write-file.test.ts:932` inverted from "rejects a write when the previous read was ranged" to "allows a write after a ranged read", matching the equivalent `edit.test.ts:1077`. - New `write-file.test.ts:961` regression for the issue #3945 deadlock: a `recordRead({ full: false, cacheable: true })` entry (what a truncated full read produces) clears WriteFile enforcement. - 7393 / 7398 (5 skipped, all pre-existing) on the full core suite. `tsc --noEmit` clean. * docs(core): add anti-regression notes locking in the WriteFile relax Three sites a future contributor might naturally try to "tighten up" back into the deadlock-prone shape, now carrying explicit guard comments that name the prior PR (#3932), the issue it broke (#3945), and the residual risk this stance accepts (#2499): - `priorReadEnforcement.ts:CheckPriorReadOptions` — interface-level note: do not re-introduce `requireFullRead` (or any "stricter for WriteFile than Edit") option here. References the function docstring for the full rationale. - `fileReadCache.ts:lastReadWasFull` — field-level note: sole consumer is the Read fast-path; `priorReadEnforcement` does not consult this and must not start. - `write-file.ts` first checkPriorRead call site — anchor comment that explains why no extra option is passed and applies to all 5 call sites in the file. No code changes; test suite unchanged at 7393 / 7398 (5 pre-existing skips); `tsc --noEmit` clean. * fix(core): #4002 review wave — basename allowlist + correct stale comments 3 #4002 review threads addressed: - fileUtils.ts: added KNOWN_TEXT_BASENAMES allowlist for extensionless build / config / lockfiles (Dockerfile, Containerfile, Makefile, GNUmakefile, Jenkinsfile, Vagrantfile, Rakefile, Gemfile, Procfile, BUILD, WORKSPACE, CMakeLists.txt, go.mod, go.sum, go.work, Cargo.lock, Pipfile, Pipfile.lock, poetry.lock, package-lock.json, yarn.lock, pnpm-lock.yaml, requirements.txt, .gitignore, .gitattributes, .dockerignore, .npmignore, .editorconfig, .env, .bashrc, .zshrc, .profile, LICENSE, COPYING, AUTHORS, CHANGELOG, README, NOTICE). `path.extname('Dockerfile')` returns `''`, so the KNOWN_TEXT_EXTENSIONS check above misses these — an encrypted-volume read whose 4 KB sample looks binary would misclassify them as binary. Regression test pinned with fake-encrypted bytes for Dockerfile / Makefile / Jenkinsfile / go.mod / package-lock.json / .gitignore / LICENSE. - priorReadEnforcement.ts: rewrote two misleading comments that pointed users to `fileReadCacheDisabled: true` for "stricter behaviour". That setting actually DISABLES enforcement entirely (skips checkPriorRead). Updated to make the opt-out semantics explicit and clarify that there is no built-in stricter mode — users who want stricter built-in enforcement than the residual #2499 risk accepts have no flag here today and should file a feature request. - read-file.ts: updated the `lastReadWasFull` comment to reflect that PR #4002 removed WriteFile's `requireFullRead`. The flag now gates ONLY the `file_unchanged` fast-path; the stale "and WriteFile's full-read requirement" wording would have confused future readers into thinking WriteFile still consults `lastReadWasFull`. Tests: 89/89 fileUtils.test.ts pass; tsc + ESLint clean. * fix(core): split priorReadEnforcement guidance — partial OK for edit, full for overwrite #4002 review: shared "never read" error said `(a partial read with offset / limit is fine — you only need to have seen the bytes you intend to edit/overwrite)` for BOTH Edit and WriteFile. For Edit this is correct — the model only needs to have seen the `old_string`-bearing bytes; the rest passes through untouched. For WriteFile this is misleading: overwriting replaces EVERY byte, so a partial read leaves any unseen bytes as collateral damage. The mtime/size drift check still catches the worst-case #2499 hallucinated-bytes risk, but recommending a partial read in the WriteFile guidance would actively encourage the footgun. Fix: branch the partial-read guidance on `verb`. Edit keeps the current "partial OK" text. WriteFile gets `(read the full file — overwriting replaces every byte, so any unseen bytes would be discarded)`. 120/120 edit + write-file tests pass; tsc + ESLint clean. * docs(core): finish #4002 review wave — drop two stale "fileReadCacheDisabled is escape hatch" mentions cc30278 + c6e2bde addressed 4 of the 6 #4002 review threads but left two prior occurrences of the misleading "fileReadCacheDisabled: true is the escape hatch for users who want stricter behaviour" wording untouched. The flag actually goes the OPPOSITE way (skips checkPriorRead entirely so application-level locking can take over), so describing it as a "stricter" escape hatch is exactly the guidance the c6e2bde review thread asked us to stop giving. Files updated: - fileReadCache.ts:lastReadWasFull docstring — replaces the "stricter behaviour" sentence with the same opt-out / opt-in distinction c6e2bde used in priorReadEnforcement.ts. - write-file.ts anchor comment for all 5 checkPriorRead call sites — replaces the "fileReadCacheDisabled: true is the escape hatch" sentence with an explicit note on the opt-out direction matching the docstring. Plus a coverage-split comment on the issue #3945 deadlock-free regression test in write-file.test.ts (review thread #6 from glm-5.1: pointed out the test seeds the cache directly rather than driving a full ReadFile→WriteFile pipeline). A real integration test would need ReadFile-side mockConfig plumbing (`getFileService`, `getTruncateToolOutputLines`, etc.) ported into write-file.test.ts; the comment captures the link to read-file.test.ts's matching cache-population assertion so a future cache-entry schema change has to update both halves to keep the end-to-end guarantee. Tests: 295 / 296 (1 pre-existing skip) on the affected files; tsc --noEmit clean. * chore(core): add debug logs to detectFileType text fast-paths #4002 review (DeepSeek): the new text-classification branches returned `'text'` without logging which path fired, leaving future #3964-class troubleshooting unable to tell mime-trust from extension-override from basename-override from the content-sample fallback without re-deriving by code reading. Add `debugLogger.debug` calls on the three new fast-path branches: mime trust (`isTextMime` match), extension override (`KNOWN_TEXT_EXTENSIONS`), and basename override (`KNOWN_TEXT_BASENAMES`). Each log includes the path, the chosen classification, and the looked-up mime when relevant — enough to disambiguate the four classification paths from a single line. Off by default (`debug` level on the `FILE_UTILS` logger). Older branches (image / audio / video / pdf / hardcoded TS / SVG / ipynb / BINARY_EXTENSIONS / isBinaryFile / default text) keep their existing silent behaviour: they predate the issue this is paving for and adding logs there would be scope creep. Tests: 89 / 89 fileUtils.test.ts pass; tsc --noEmit clean.
…QwenLM#3774) * feat(core): enforce prior read before Edit / WriteFile mutates a file Introduces a session-scoped invariant: the model cannot mutate an existing file without having actually Read it (or its post-write state) earlier in this conversation. Builds on the FileReadCache landed in QwenLM#3717. Two new ToolErrorType codes: - EDIT_REQUIRES_PRIOR_READ — file has no entry in the session cache. The model is told to use read_file first. - FILE_CHANGED_SINCE_READ — file has an entry but its mtime or size drifted since the recorded fingerprint. The model is told to re-read before retrying. EditTool blocks the existing-file path on cache.check; new-file creation (old_string === '' on a non-existent target) is exempt. WriteFileTool blocks the overwrite path; new-file creation (fileExists === false) is exempt. Both tools route through the existing fileReadCacheDisabled escape hatch on Config — flipping it bypasses enforcement byte-for-byte, matching pre-cache behaviour. Operators can use this as a kill switch if a session falls into a state where the cache cannot be trusted. ReadFile fix on the auto-memory path: PR QwenLM#3717 had auto-memory reads skip the cache entirely (both lookup and record), but with the new enforcement that means a model that just Read AGENTS.md cannot then Edit it. Decoupled the two: auto-memory reads still skip the file_unchanged fast-path (the per-read freshness <system-reminder> must always reach the model) but DO record into the cache so the follow-up Edit sees `fresh`. New regression test asserts this. Test plan - vitest run (all of @qwen-code/qwen-code-core): 6308 passed, 2 skipped - 9 new enforcement tests across edit.test.ts and write-file.test.ts: unknown rejects, stale rejects, new-file exempt, edit chain stays authorised, escape hatch bypasses, plus the auto-memory record regression in read-file.test.ts. - tsc --noEmit clean. eslint clean. core build succeeds. * test(core): clear shared fileReadCache between write-file.test.ts cases CI surfaced one Linux-only failure: the prior-read enforcement test 'rejects a write that would overwrite an unread existing file' returned FILE_CHANGED_SINCE_READ instead of EDIT_REQUIRES_PRIOR_READ. Root cause: the FileReadCache instance is declared at module scope (line 41) and shared across every test in write-file.test.ts. State from earlier tests — most recently the 'records a write' integration test that records the same path — leaks forward. On Linux the test ordering puts a record-bearing test before the enforcement test, so the cache reports `stale` (mtime drifted) instead of `unknown`. macOS / Windows happen to order them differently and never hit it. Adding a fileReadCache.clear() to beforeEach gives every test a known-empty cache, matching how edit.test.ts already isolates its per-test cache by re-instantiating it. * fix(core): close prior-read enforcement gaps flagged in 3rd review Three concrete loopholes / regressions that the original PR-B introduction left open. All three are addressed in the same commit because the underlying refactor (move enforcement earlier and tighten the fresh predicate) is shared across them. 1. fresh != "model has seen the bytes". Pre-fix, requirePriorRead() accepted any cache.check === 'fresh'. ReadFileTool records every successful read into the cache, including ranged reads (offset/limit), truncated full reads, and non-cacheable binary/image/audio/video/PDF/notebook reads (lastReadCacheable = false). This let the model peek at a slice or a structured payload of a file and then mutate the whole thing. Tightened the accept predicate to fresh && lastReadAt && lastReadWasFull && lastReadCacheable. 2. Read-less content oracle through calculateEdit error codes. Pre-fix, execute() ran calculateEdit (which reads file bytes and counts matches) before the enforcement check. A model could probe an unread file by attempting Edits with candidate old_strings and observing NO_OCCURRENCE_FOUND vs EXPECTED_OCCURRENCE_MISMATCH vs EDIT_NO_CHANGE — reverse-engineering content without ever calling read_file. Moved enforcement to the top of calculateEdit, before any content read; only a stat is performed up to the rejection point. 3. Confirmation flow regression. Pre-fix, getConfirmationDetails() read the existing file to render a diff for the user, then approval flowed to execute() which would freshly check the cache and reject. The user could approve a diff computed from current bytes the model never saw, and the call would still fail. Moved enforcement before the confirmation read in both EditTool (via the shared calculateEdit path) and WriteFileTool (explicit check at the top of getConfirmationDetails). The user now never sees a confirmation diff for an unread file — the call rejects up front. Public API surface change: requirePriorRead() -> checkPriorRead() that returns a structured decision, so the same predicate can route into a CalculatedEdit.error (calculateEdit), a thrown error (getConfirmationDetails), or a ToolResult (execute) without duplicating the boolean / message / type plumbing in three shapes. Reported by pomelo-nwu (3 inline comments on PR QwenLM#3774). * refactor(core): close 4 prior-read enforcement gaps from 4th review 1. recordWrite now seeds read metadata on brand-new entries (lastReadAt / lastReadWasFull / lastReadCacheable). The strict accept predicate added in the previous round (QwenLM#3 review) requires all three, but recordWrite only set lastWriteAt — so a model creating a file with Edit (old_string="") or WriteFile and then editing it again was rejected on the second edit. The model authored the bytes it just wrote; for the purposes of prior-read enforcement that counts as having seen them. New regression test in edit.test.ts: "allows a create-then-edit-then-edit chain without an intervening read". 2. Extracted checkPriorRead into src/tools/priorReadEnforcement.ts. The two copies in edit.ts and write-file.ts had already drifted (one used ${ReadFileTool.Name}, the other hardcoded 'read_file'); the boolean guard is security-sensitive and a one-sided fix would silently weaken the boundary. The shared utility takes a verb ('editing' | 'overwriting') so the user-facing prose can differ between callers without duplicating the decision logic. 3. WriteFileTool.execute now runs prior-read enforcement BEFORE readTextFile. Pre-fix, an unread overwrite still slurped the entire file into memory (encoding / BOM / line-ending detection) and only then rejected it: wasted I/O, and momentary in-memory custody of bytes the model never legitimately read. Now matches the order in getConfirmationDetails(). 4. The "rejects a write that would overwrite an unread existing file" test now spies on FileSystemService.readTextFile and asserts not.toHaveBeenCalled() — without that, the test gave false confidence: it passed both pre-fix (read happened, then reject) and post-fix (reject before read), so the ordering regression in (3) was invisible to the assertion. Reported by glm-5.1 via /review on PR QwenLM#3774. * refactor(core): close 4 prior-read enforcement gaps from 4th review (Copilot) Five concrete gaps that the previous round of enforcement work left open. Reported by Copilot via /review on PR QwenLM#3774. 1. Confirmation-time rejections lost their ToolErrorType code. getConfirmationDetails() in both EditTool and WriteFileTool threw a plain Error on prior-read failure, which coreToolScheduler collapsed into UNHANDLED_EXCEPTION — silently breaking the EDIT_REQUIRES_PRIOR_READ / FILE_CHANGED_SINCE_READ contract for any approval-required flow. Fix: introduce PriorReadEnforcementError that carries `errorType: ToolErrorType`. Both confirmation paths now throw it, and coreToolScheduler reads `error.errorType` (falling back to UNHANDLED_EXCEPTION when absent). New regression tests assert the thrown error's `errorType` field for both tools. 2. checkPriorRead's "re-read with read_file" advice was wrong for binary / image / audio / video / PDF / notebook files. Their ReadFile result always sets lastReadCacheable=false, so the message would loop the agent forever on the same rejection. Fix: detect the fresh-but-non-cacheable case explicitly and return a dedicated message that explains the dead end ("Edit / WriteFile cannot mutate that payload safely") instead of asking for another read. Updated the existing non-cacheable regression test to assert the new message and the absence of "use the read_file tool first". 3. checkPriorRead swallowed every stat() failure and returned ok:true. EACCES, EBUSY, NFS hiccups, etc. would silently re-open the blind-write path the helper exists to block. Fix: only ENOENT continues to return ok:true (disappearance race). Any other code is fail-closed: returns EDIT_REQUIRES_PRIOR_READ with a message that names the errno. New regression test in write-file.test.ts spies on fs.promises .stat to inject EACCES and asserts the rejection. 4. The auto-memory record regression test only asserted `state === 'fresh'`. A future change that recorded auto-memory reads as partial / non-cacheable would still satisfy that assertion but would actually fail enforcement on every follow-up Edit. Fix: also assert lastReadAt is defined, lastReadWasFull is true, and lastReadCacheable is true. The full "what enforcement requires" predicate is now explicit in the test. (The 5th item, the WriteFile mirror of (1), is covered by the same PriorReadEnforcementError change.) * refactor(core): tighten StructuredToolError naming + add scheduler test Four follow-ups raised by deepseek-v4-pro on PR QwenLM#3774. None of them change the enforcement boundary; they are all about making the contract clearer and harder to break in future changes. 1. PriorReadEnforcementError -> StructuredToolError. The class now wraps any content-derived ToolErrorType from calculateEdit (EDIT_NO_OCCURRENCE_FOUND, EDIT_EXPECTED_OCCURRENCE_MISMATCH, EDIT_NO_CHANGE, ATTEMPT_TO_CREATE_EXISTING_FILE) on top of the prior-read codes. The old name suggested the class was prior- read-specific, which would mislead any oncall engineer seeing it paired with one of the calculateEdit error codes. 2. EDIT_REQUIRES_PRIOR_READ kept its name (the prefix mentions "edit" but the enum is shared with WriteFileTool) — chose documentation over rename to avoid the churn of a value rename across logs/dashboards already keyed on it. JSDoc now spells out the cross-tool usage explicitly. 3. Stat failures other than ENOENT now map to a new PRIOR_READ_VERIFICATION_FAILED code instead of being conflated with EDIT_REQUIRES_PRIOR_READ. The failure mode is "we cannot verify" rather than "definitely not read" — operators routing on error codes can distinguish the two populations. 4. Added a coreToolScheduler test (`surfaces error.errorType from a confirmation throw instead of UNHANDLED_EXCEPTION`) that constructs a stub tool whose getConfirmationDetails throws StructuredToolError and asserts the surfaced ToolCall response carries the correct ToolErrorType. Without this test the scheduler's explicitErrorType branch would have no coverage at all. Tool tests updated for the new StructuredToolError class name and the PRIOR_READ_VERIFICATION_FAILED code on the EACCES path. * fix(core): close TOCTOU + grammar + directory regressions in PR-B Six concrete issues that the previous round of enforcement work left open. Reported by Copilot via /review on PR QwenLM#3774. 1. TOCTOU window between pre-read checkPriorRead and readTextFile. The pre-read stat could pass enforcement, then an external writer could land between that stat and the actual read, leaving currentContent reflecting bytes the model never saw — exactly the stale-write path the PR is supposed to block. Closed by re-running checkPriorRead immediately after every readTextFile that fed currentContent / originalContent: EditTool.calculateEdit and the two WriteFileTool paths (execute + getConfirmationDetails). A `stale` outcome now fails the operation with FILE_CHANGED_SINCE_READ at the correct moment. 2. Directory targets sent the model into an enforcement loop. `fileExists` is a plain access check, so directories also entered the enforcement branch — the model would be told to call `read_file`, but `read_file` rejects directories with TARGET_IS_DIRECTORY, so the loop never terminated. Fixed in checkPriorRead: if `fs.stat` reports the path is not a regular file, return `ok: true` so the downstream readTextFile / write path can surface its own EISDIR / similar error. 3. Confirmation-time error messages used the short `display` form instead of the full `raw` form. Approval-required Edit calls therefore lost the remediation detail (file path, stale-vs-unread distinction, "without offset / limit / pages" hint) that the execute path already surfaced and that the WriteFile confirmation path already preserved. EditTool.getConfirmationDetails now throws StructuredToolError with `editData.error.raw`. 4. Non-text payload displayMessage was grammatically broken: built from the gerund `editing` / `overwriting`, it rendered as "cannot editing via this tool" / "cannot overwriting via this tool". Fixed by deriving a bare-verb form (`edit` / `overwrite`) alongside the gerund and using it in displayMessage. (Items 1, 5 and 6 from Copilot's batch are the same TOCTOU class — EditTool calculateEdit + WriteFile execute + WriteFile confirmation — addressed together in (1) above.) The "bypasses enforcement entirely" test now uses mockReturnValue instead of mockReturnValueOnce because calculateEdit calls getFileReadCacheDisabled twice — once for the pre-read check and once for the post-read TOCTOU re-check. Both must see disabled=true to actually bypass. * fix(core): close fileExists TOCTOU on WriteFile prior-read enforcement WriteFile gated prior-read enforcement on `fileExists` from `isFilefileExists()`, but a file that sprang into existence between that check and the write would still be overwritten without enforcement — `fileExists === false` skipped the check entirely. Made the gate unconditional on `fileExists`. checkPriorRead's own `fs.stat` decides what to do: - ENOENT → ok:true, fall through to the new-file path as before - file exists right now (whether or not isFilefileExists saw it) → unknown / stale check runs, the race-created file is rejected. Applied to both getConfirmationDetails and execute. The path that actually creates new files is unchanged because checkPriorRead's ENOENT branch is the disappearance-race exit, which is the correct exit for "the file truly does not exist yet". Reported by glm-5.1 via /review on PR QwenLM#3774. * fix(core): close 4 enforcement gaps + 1 critical bug from 5th Copilot review Six issues raised by deepseek-v4-pro / glm-5.1 / qwen3.6-plus on PR QwenLM#3774. Listed by reviewer-assigned severity. [Critical] (qwen3.6-plus) recordWrite previously only seeded the read metadata for brand-new entries. The reproduction was real: ReadFile(limit=10) → WriteFile(full content) → Edit. The partial read's lastReadWasFull=false would persist through the write, and the Edit would be rejected with EDIT_REQUIRES_PRIOR_READ even though the model just authored every byte. recordWrite now unconditionally refreshes lastReadAt, lastReadWasFull=true, and lastReadCacheable=true. The fileReadCache.test.ts case that previously asserted "preserves lastReadAt" is rewritten to assert the new "refreshes lastReadAt to match the write" contract, and a new "upgrades lastReadWasFull / lastReadCacheable after a full write" regression test pins the reproduction reviewer described. [Suggestion] (deepseek-v4-pro) Narrowed the non-regular-file bypass in priorReadEnforcement from `!stats.isFile()` to `stats.isDirectory()`. The earlier broad form covered FIFOs, sockets, and devices that the model has no legitimate "read first" recourse for and that can block readTextFile (FIFO) or over-allocate (/dev/urandom). Those now flow through to cache.check() and reject with the unread-file path before any I/O. [Suggestion] (glm-5.1) Removed the `fileExists && ...` gate from EditTool.calculateEdit, mirroring the 16a3a5761 fix on WriteFile. A file that springs into existence between isFilefileExists() and the enforcement check is now caught here as well; ENOENT inside checkPriorRead remains the disappearance-race exit and new-file creation flow is unchanged. [Suggestion] (deepseek-v4-pro) Added debugLogger.warn() at every post-read TOCTOU rejection site (Edit calculateEdit, WriteFile getConfirmationDetails, WriteFile execute). These rejections are rare and self-healing — without a debug record, an operator investigating "why did this Edit fail once?" had nothing to grep. debugLogger uses dedicated 'EDIT_PRIOR_READ' / 'WRITE_FILE' tags. [Suggestion] (qwen3.6-plus) Added a final pre-write checkPriorRead in EditTool.execute() and WriteFileTool.execute(). The earlier post-read check ran inside calculateEdit (Edit) or before mkdirSync (WriteFile), but the actual writeTextFile call could be arbitrarily later — user approval, modify-and-confirm, etc. The window from "post-read check → writeTextFile" is now bounded to "pre-write stat → writeTextFile" (two adjacent syscalls). * fix(core): close new-file race + special-file enforcement loop Three issues from the latest Copilot review on PR QwenLM#3774. 1. New-file race in pre-write enforcement (write-file.ts:348, edit.ts:487). The earlier pre-write checkPriorRead was gated on `fileExists` (WriteFile) and `!editData.isNewFile` (Edit). If the path was absent at planning time and another process created it while approval was pending, the gated form would skip enforcement and silently overwrite a pre-existing file the model never read. Run unconditionally in both tools — checkPriorRead's own ENOENT branch is the disappearance-race exit, so genuine new-file creation is unaffected, but a race-created file now hits the `unknown` branch and is rejected as unread. 2. FIFO / socket / device sent the model into an enforcement loop (priorReadEnforcement.ts:220). After narrowing the non-regular-file bypass to directories only, FIFOs etc. fell through to cache.check, returned `unknown`, and produced a "use read_file first" message — but read_file rejects those same targets as "not a regular file", so the model would loop on read_file forever. Added a dedicated `!stats.isFile()` branch (after the directory exemption) that returns a "special file; cannot edit/overwrite via this tool — use shell instead" message, matching the shape of the existing non-text-payload guidance. (Tool-error.ts and the non-cacheable policy notes are addressed in the PR description update — not in code.) * fix(core): close 4 enforcement gaps from 6th Copilot review (Plus a doc-only update for the 5th — the mtime+size limitation warning in the Risk section now mentions the silent-overwrite escalation that this PR's mutation paths bring along.) 1. ENOENT after the model has already read the file is no longer silently treated as `ok: true`. Added an `expectExisting` option to `checkPriorRead`; post-read and pre-write callers pass `true`. ENOENT under that flag now rejects with `FILE_CHANGED_SINCE_READ` ("file disappeared after the model read it") rather than falling through to the new-file path with stale bytes. Pre-read callers keep the old default (ENOENT → ok:true → fall through to genuine new-file creation). EditTool's pre-write check derives the flag from `editData.isNewFile`; WriteFile's pre-write check derives it from the post-read `fileExists` value. 2. Directory targets now reject with `TARGET_IS_DIRECTORY` and a structured message instead of returning `ok: true`. The previous form fell through to readTextFile(), which on the WriteFile confirmation path threw a plain Error and was surfaced by the scheduler as `UNHANDLED_EXCEPTION`. Both Edit and WriteFile now emit a structured rejection at enforcement time. (WriteFile's build-time validateToolParamValues already rejects directories, so the change matters most for EditTool.) 3. Non-cacheable rejection's `rawMessage` no longer hard-codes "overwrite" — it now uses the same `verbBare` derivation as the `displayMessage`, so EditTool's path correctly says "if you need to edit it" and WriteFile's path stays "if you need to overwrite it". The previous form was confusing for in-place edits. 4. WriteFile.getConfirmationDetails now mirrors execute()'s ENOENT-to-new-file fallback: a file that disappears between isFilefileExists() and the readTextFile-for-diff call no longer throws a plain Error (which would surface as UNHANDLED_EXCEPTION) — it falls back to the brand-new-file diff so the user sees a clean confirmation rather than an unstructured crash. Tests: - New: `rejects an edit on a directory with TARGET_IS_DIRECTORY` - New: `confirmation falls back to a new-file diff when the file disappears mid-flight` (WriteFile) - Updated: non-cacheable rejection asserts `verbBare` is "edit" on the EditTool path and "overwrite" on the WriteFile path. Reported by Copilot via /review on PR QwenLM#3774. * docs(core): clarify stat→write race + EDIT_REQUIRES_PRIOR_READ scope Three doc-only follow-ups from Copilot's latest review pass on PR QwenLM#3774. None change behaviour; the pre-fix code state was already the actual contract — the docs just lagged it. 1. EDIT_REQUIRES_PRIOR_READ enum comment now lists the three cases the code actually returns it for (never-read, partial / ranged / non-cacheable read, structural dead end — non-text payload or special file). The previous one-liner described only the first case and would mislead future maintainers. 2. The Final pre-write freshness check blocks in EditTool.execute and WriteFileTool.execute now spell out that they DO NOT eliminate the stat → writeTextFile race. The window narrows from the previously-unbounded post-read-to-write gap down to two adjacent syscalls, but a concurrent writer landing in that pair can still be clobbered. Closing the residual would require an atomic write (write-to-temp + rename) or a content-hash post-write recheck — both deferred. Operators who need strict protection set `fileReadCacheDisabled: true` and rely on application-level locking. 3. PR description Risk section gains a "Known unmitigated: stat → write race window" subsection (English + Chinese mirror) matching the code comments. * chore(core): minor follow-ups from review #4229917446 Three of the five MINOR items raised in the independent code review on 2026-05-05 — the cheap, isolated ones. The other two (race- simulating integration test, moving StructuredToolError out of priorReadEnforcement.ts) are deferred as the reviewer suggested. 1. EditTool now has a symmetric `PRIOR_READ_VERIFICATION_FAILED` regression test (mocks fs.promises.stat to reject with EACCES, asserts the EditTool path produces the same fail-closed result that the existing WriteFile EACCES test pins). Five-line fix to close the asymmetry that, while harmless today (the helper is shared), would let a future Edit-side change to checkPriorRead slip through without test coverage. 2. ensureParentDirectoriesExist / mkdirSync now run AFTER the pre-write checkPriorRead in both EditTool.execute() and WriteFileTool.execute(). Doing it before would leak intermediate directories on the rejection path — a real (if minor) FS litter the previous order created on every rejected new-file write. 3. EDIT_REQUIRES_PRIOR_READ enum docstring gains a one-line note for operators routing alerts on this code: a single `edit_requires_prior_read` signal can mean any of the three cases (no read / partial read / structural dead-end), and if per-cause monitoring becomes important the enum can be split in a follow-up. The originating tool name and the message text already disambiguate at runtime. * fix(core): close 2 correctness gaps from maintainer review #4232751470 Both tracked back to the cache's "track most recent read shape" model diverging from prior-read enforcement's "model has seen these bytes" model. 1. SVG (and similar string-content fallbacks) recorded as non-cacheable, blocking subsequent Edit / WriteFile. `read-file.ts` derives `cacheable` from `originalLineCount !== undefined && !isTruncated`. The SVG branch in `fileUtils.ts` returned content without `originalLineCount`, so `cacheable` collapsed to false and a follow-up Edit hit the dead-end "non-text payload — use shell" rejection — telling the model to use shell to mutate a file it had just successfully read as text. This was a real regression vs pre-PR behaviour where SVG-as-text editing worked. Fix: SVG-as-text branch now sets `originalLineCount` (split on '\n') and `isTruncated: false`, so ReadFile records it as a full cacheable read. The binary-fallback string and over-1MB SVG branches are deliberately left non-cacheable — they return placeholder strings ("Cannot display content of ...") rather than file content, so blocking edits there is correct. New regression test in `read-file.test.ts`: `records SVG-as-text reads with cacheable=true so a follow-up Edit passes enforcement`. 2. recordRead unconditionally overwriting lastReadWasFull / lastReadCacheable, revoking prior write-author or full-read rights. The `WriteFile(create) → ReadFile(offset/limit) → Edit` sequence rejected the Edit because the partial read clobbered the `lastReadWasFull = true` that `recordWrite` had stamped at create time. Same shape applies to a full text read followed by a partial one of the same inode. Fix: `recordRead` is now sticky-on-true for the read flags — `if (opts.full) entry.lastReadWasFull = true;` and the matching guard for `cacheable`. Prior `true` survives a later partial / non-cacheable read. The fast-path `file_unchanged` check still gates on the incoming request's own `isFullRead` in `read-file.ts`, so a partial read still does not get a placeholder it shouldn't. Updated the existing "overwrites earlier lastReadWasFull" test to assert the new sticky semantics, and added a `lastReadCacheable` symmetric test plus a `Write → partial-Read → Edit` end-to-end test in `edit.test.ts`. Reported by tanzhenxin via independent maintainer review on 2026-05-06. * fix(core): close 3 correctness gaps from re-review #4233904930 All three are tightenings of the prior `92218152c` round. 1. **Sticky-on-true narrowed to "no fingerprint drift"**. `fileReadCache.recordRead` previously kept `lastReadWasFull` / `lastReadCacheable` true across drifted recordings, which re-opened a `Read full @x → external write @y → Read partial @y → Edit` hole: the partial recordRead silently advanced the entry's mtime+size to Y while preserving the sticky `full=true` from X, so a follow-up Edit ran against bytes the model only saw the first 10 lines of. Now the sticky branch only fires when `(mtimeMs, sizeBytes)` matches the existing entry; on drift, both flags reset to exactly what this read produced. New regression test in `fileReadCache.test.ts` reproduces the reviewer's reported sequence. 2. **Subagent FileReadCache isolation now covers the inherits-model + same-approval-mode common case**. The own-property machinery from QwenLM#3717 only triggers when an `Object.create(parent)` actually fires; both `agent.ts:990-993` (same-approval-mode) and `subagent-manager.ts:699-701` (inherits-model) had paths that returned the parent Config directly, so the subagent's `getFileReadCache()` resolved to the parent's instance — a parent Read could satisfy the subagent's Edit on a path the subagent's transcript never contained. Both sites now build a thin `Object.create(base)` override unconditionally; no method changes for the inherits / same-mode cases, but a distinct instance triggers the lazy-init in `Config.getFileReadCache()` so the subagent gets an isolated cache. 3. **Cache records the read pipeline's internal stat instead of a post-read re-stat**. `processSingleFileContent` now surfaces its internal stat via `result.stats`, and read-file uses that for `recordRead` instead of running its own stat after the read returns. Pre-fix, an external write between the pipeline call and the post-read stat let the cache record fingerprint Y for content the model received at X — a subsequent Edit would pass enforcement against bytes the model never legitimately saw. The internal-stat-to-read window is still a few microseconds wide; that residue is the same content-hash territory acknowledged in the Risk section. Reported by tanzhenxin via re-review on PR QwenLM#3774. * docs(core): clarify partial subagent isolation per review #4234090906 tanzhenxin's third review correctly observed that the `Object.create(parent)` wrappers in `agent.ts:createApprovalModeOverride` and `subagent-manager.ts:maybeOverrideContentGenerator` only isolate the FileReadCache for code that consults `Config.getFileReadCache()` directly. Bound `EditTool` / `WriteFileTool` instances were registered against the parent's tool registry at initialise time, so tool invocations still resolve `this.config` to the parent and reach the parent's cache. `InProcessBackend.createPerAgentConfig` already does the right thing (`override.createToolRegistry()` + `copyDiscoveredToolsFrom(base.getToolRegistry())`); bringing that to these two spawn sites is the real fix. Reviewer's verdict was COMMENT, not REQUEST_CHANGES — the gap pre-dates this PR (it's a property of QwenLM#3717's per-Config own-property machinery) and pre-PR there was no enforcement on subagent mutations at all, so the PR is strictly an improvement on every spawn path. Documented the partial guarantee explicitly: - Inline comments on both spawn sites note the bound-tool caveat and point at `InProcessBackend.createPerAgentConfig` as the model for the follow-up. - PR description's subagent paragraph (English + Chinese mirror) now splits into "fully isolated" (`InProcessBackend.createPerAgentConfig`) and "partial isolation" (the two sites in this PR) so readers don't walk away with the wrong contract. Filing the registry-rebuild work as a follow-up; not in this PR.
…d tools resolve to the subagent (QwenLM#3873) * fix(core): rebuild tool registry on subagent Config overrides so bound tools resolve to the subagent PR-B (QwenLM#3774) added per-Config FileReadCache isolation via Object.create overrides at two subagent spawn sites — agent.ts:createApprovalModeOverride and subagent-manager.ts:maybeOverrideContentGenerator. The override shielded code that read FileReadCache directly through the Config instance, but missed the bound-tool path: Config.createToolRegistry runs once at parent initialise time, so the parent's EditTool / WriteFileTool / ReadFileTool instances are bound with `this.config = parent`. The subagent's Object.create wrapper inherited getToolRegistry via the prototype chain, reaching the parent registry whose bound tools then read FileReadCache and approval mode from the parent. This change closes that gap by rebuilding the tool registry on the override at both sites — the same pattern InProcessBackend.createPerAgentConfig already uses: - override.createToolRegistry(undefined, { skipDiscovery: true }) - registry.copyDiscoveredToolsFrom(base.getToolRegistry()) - override.getToolRegistry = () => registry createApprovalModeOverride becomes async; its single call site already ran inside an async block. maybeOverrideContentGenerator skips the rebuild when the upstream Config already has its own getToolRegistry (real-world case: agent.ts wrapper passed through createAgentHeadless), avoiding wasted work, listener accumulation on shared SubagentManager / SkillManager, and a cache split where the bound tools' registry layer diverges from the runtime context's lazy-init cache. Includes regression tests in agent-override.test.ts and subagent-manager-override.test.ts that exercise the bound-tool path: they instantiate the lazy factories on the override registry and assert that EditTool / WriteFileTool / ReadFileTool resolve this.config to the override Config (and thus to the override's FileReadCache / approval mode), not the parent. * fix(core): close bound-tool gap on resumed background agents too Follow-up audit on PR QwenLM#3873 surfaced a duplicate, pre-rebuild copy of `createApprovalModeOverride` living in `background-agent-resume.ts` (L142-150). Resumed fork agents go through `createResumedForkSubagent` which bypasses `SubagentManager.maybeOverrideContentGenerator` (where the registry rebuild now lives), so the resumed fork's `EditTool` / `WriteFileTool` / `ReadFileTool` were still resolving `this.config` to the parent and reading the parent's `FileReadCache`. The non-fork resume path went through `subagent-manager` and worked correctly only because `maybeOverrideContentGenerator` saw no upstream own-registry on `bgConfig` and rebuilt one — but with that fallback the fork path could never benefit. This change deletes the local copy and switches `background-agent-resume.ts` to import the now-async exported `createApprovalModeOverride` from `agent.ts`. Drops the previous `?: this.config` short-circuit so the resumed agent ALWAYS gets a wrapper Config — the same behaviour `agent.ts` already enforces; reusing the parent directly defeats the per-Config FileReadCache isolation. Updates `background-agent-resume.test.ts` mock config with the `createToolRegistry` / `getToolRegistry` stubs the rebuild path now exercises. * fix(core): address bound-tool isolation review feedback Three independent fixes from PR QwenLM#3873 review feedback: 1. Switch the upstream-rebuild guard from `hasOwnProperty(base, 'getToolRegistry')` to a Symbol-keyed marker `TOOL_REGISTRY_REBUILT`. The own-property check missed the case where the override is reached via an Object.create wrapper above the rebuilt Config (e.g. `bgConfig = Object.create(agentConfig)` in the agent.ts background path) — it would falsely report "no upstream rebuild" and cause a redundant third rebuild that wastes work and doubles the listener-leak surface. Symbol property reads walk the prototype chain via normal lookup, so a marker stored on any ancestor is correctly observed. Extracts the shared rebuild logic into `rebuildToolRegistryOnOverride(override, base)` so the three spawn sites (agent.ts:createApprovalModeOverride, the inherits branch, the non-inherit branch) cannot drift apart. 2. Stop the per-subagent ToolRegistry in the lifecycle finally blocks: - agent.ts foreground finally (after the inner try wrapping `runFramed`) - agent.ts background bgBody finally (after `bgSubagent.execute` resolves) - background-agent-resume.ts resume body finally (same shape) Without this, every AgentTool / SkillTool the model instantiates from the per-subagent registry registers a change-listener on shared SubagentManager / SkillManager, and repeated subagent runs accumulate listeners for the rest of the session. Stop is fire-and-forget, matching `InProcessBackend.cleanup` and `stopAgent`. 3. Add bound-tool isolation tests for the non-inherit branch (explicit-model selector). The original PR only covered the inherits branch directly; the non-inherit branch now goes through the same helper, but a dedicated test pins `tool.config === override` and the FileReadCache binding so a regression cannot leave explicit-model subagents reading the parent's cache while existing model-override tests still pass. Tests now exercise: - Symbol marker propagation via Object.create chain (3 cases) - Non-inherit rebuild + bound-tool isolation - Non-inherit skip-rebuild when upstream wrapper has the marker - Pre-existing inherits / chained-override / approval-mode propagation - Mock configs in agent.test.ts / subagent-manager.test.ts / background-agent-resume.test.ts gain `stop` and `tools: Map` stubs to model the registry contract the override path now exercises. `npx vitest run packages/core/src` — 268 files / 6943 passed.
…quired for WriteFile (QwenLM#3932) PR QwenLM#3774 introduced a `lastReadWasFull` requirement to `checkPriorRead` that forced models to re-read multi-thousand-line files just to make a single-line edit. The `0 occurrences` failure mode in `calculateEdit` already catches a fabricated `old_string` that misses the actual bytes, so requiring a full read on top of that is over-defence at a real context cost — but only for Edit, not for WriteFile. WriteFile is asymmetric: it replaces the entire file and has no content-derived guard equivalent to `old_string` matching. A model that has only seen a slice via `read_file(offset, limit)` followed by a `WriteFile` would necessarily hallucinate the rest of the bytes — the issue QwenLM#2499 data-loss scenario PR QwenLM#3774 was opened to address. Split the policy along that line. `checkPriorRead` gains a `requireFullRead?: boolean` option. WriteFileTool's 5 enforcement call sites pass `true`; EditTool's 3 leave it unset (default `false`): - EditTool: partial read counts (old_string is the floor) - WriteFileTool overwrite: full read required - Either: new-file creation exempt (ENOENT → ok:true before requireFullRead is consulted); `fileReadCacheDisabled` escape hatch unchanged A dedicated `fresh + cacheable + partial + requireFullRead` rejection branch surfaces a clear "only been partially read … overwriting replaces the entire file" message instead of falling through to the generic "has not been read" wording. The `unknown` branch's wording also varies by `requireFullRead` so the read instruction matches the operation's actual requirement. For comparison, Claude Code's `readFileState` enforcement treats partial and full reads identically for both Edit and WriteFile. This PR is stricter on WriteFile (full read required) and identical on Edit (partial OK). Issue QwenLM#2499 is empirical evidence that the partial-read-then-overwrite case is real on at least some model populations, so the additional WriteFile constraint is justified. Single-commit shape (versus the earlier afc1b91 / 503fc0b split) to avoid an intermediate state in which Edit's relaxation has landed but WriteFile is still on the relaxed path: cherry-picks or bisect walks crossing such a boundary would re-introduce the issue QwenLM#2499 data-loss case. Tests: edit.test.ts ranged-read test inverted to "allows after ranged read"; write-file.test.ts ranged-read test asserts the new partial / full-required message. Three error-message regex matchers updated from /fully read/ to /read/. 198 / 198 prior-read-related tests pass; tsc --noEmit clean.
Motivation
PR #3717 introduced a session-scoped
FileReadCacheand used it to short-circuit repeat ReadFile calls. The cache was always a means to a different end: making it possible to verify, before mutating a file, that the model has actually seen the file's current bytes in this conversation.Before this PR, the model could call EditTool with an
old_stringit never received from any read. If that string happened to occur in the file the edit succeeded — even though the model was working from imagination, not evidence. The closest existing safeguard was the0 occurrencesfailure mode, which only catches the case where the imagined string fails to match anything; it does not catch a plausible-but-stale match against bytes the model has not seen.This PR closes that gap by making prior-read mandatory for any mutation of a pre-existing file. New-file creation is exempt (there is nothing to read first), and the existing
Config.fileReadCacheDisabledescape hatch covers it byte-for-byte for sessions where the cache cannot be trusted.User-visible behaviour
Three new error codes:
EDIT_REQUIRES_PRIOR_READ— the file has no entry in the session cache (or only a partial / non-cacheable entry). The error message tells the model to useread_filefirst. Also surfaced for two structural cases the model cannot recover from by re-reading: non-text payloads (binary / image / audio / video / PDF / notebook — read_file returns these as structured values that Edit / WriteFile cannot mutate safely) and special files (FIFO / socket / character / block device — read_file rejects them as "not a regular file"). For those cases the message points the model at a different mechanism (e.g. shell tool) instead of looping it onread_file.FILE_CHANGED_SINCE_READ— the file was read earlier but its mtime or size has drifted since. The error message tells the model to re-read before retrying.PRIOR_READ_VERIFICATION_FAILED—fs.statitself failed for a reason other than ENOENT (typically EACCES, EBUSY, or an NFS hiccup). Distinct fromEDIT_REQUIRES_PRIOR_READbecause the model may have legitimately read the file — we just cannot verify. Operators monitoring on error codes can route this separately.Examples:
A normal Read → Edit → Edit chain still works without re-reading between edits:
recordWrite(added in #3717) refreshes the cache fingerprint after every successful mutation, so the second edit'scache.check()seesfresh.Behaviour change summary
EDIT_REQUIRES_PRIOR_READ. Previously: succeeded ifold_stringhappened to match.FILE_CHANGED_SINCE_READ. Previously: succeeded against the new bytes (often clobbering an external write the model never saw).EDIT_REQUIRES_PRIOR_READand a "use a different mechanism" message. Re-reading would not change the cacheable bit; these payloads cannot be edited as text via these tools.Config.fileReadCacheDisabled === true→ unchanged from pre-cache behaviour.Risk
The enforcement is gated on the same Config flag (
fileReadCacheDisabled) introduced in #3717, so an incident playbook for "the cache is doing the wrong thing" already exists: flip the flag and Edit / WriteFile go back to their pre-cache behaviour.The most likely false positive is: a model that has been doing Read→Edit chains and the chain spans context compaction. Compaction now clears the cache (see
tryCompressChatin #3717), so the first Edit after compaction will be rejected withEDIT_REQUIRES_PRIOR_READ. The error message tells the model to re-read; one extra Read per compaction event is the cost.Subagents spawned through
InProcessBackend.createPerAgentConfigget fully isolated FileReadCaches: the override rebuilds the tool registry viaoverride.createToolRegistry()+copyDiscoveredToolsFrom(base.getToolRegistry()), so the boundEditTool/WriteFileToolinstances resolvethis.configto the override and the cache lazy-init inConfig.getFileReadCache()produces a fresh per-subagent cache.Subagents spawned through
agent.ts:createApprovalModeOverride(the at-tool-call subagent path) andsubagent-manager.ts:maybeOverrideContentGenerator(the inherits-model path) get only partial isolation in this PR: a thinObject.create(parent)wrapper triggers the lazy-init for code that consultsConfig.getFileReadCache()directly, but the boundEditTool/WriteFileToolinstances were registered on the parent's tool registry at startup and tool invocations resolvethis.configto the parent. Closing the bound-tool path is a follow-up that bringsInProcessBackend.createPerAgentConfig's registry-rebuild approach to those two spawn sites. Pre-PR there was no prior-read enforcement on subagent mutations at all, so this PR is strictly an improvement on every spawn path; the partial-isolation cases just don't get the full guarantee yet.Known unmitigated: same-mtime same-size rewrites
The cache's freshness fingerprint is
(mtimeMs, size), not a content hash. A writer that produces an identical-length payload in the same mtime tick (or that explicitly preserves mtime viautimes) will passcache.check === fresh, and Edit / WriteFile will mutate bytes the model never actually saw. Pre-PR this was a stale-read issue; this PR shares the same fingerprint on the mutation paths, so the residual risk graduates to silent overwrite.The mitigation is straightforward (record a content hash on the read pipeline and compare in
cache.check) but materially increases the cost of every read, so it is deferred to a follow-up PR. Operators who care about this scenario should setfileReadCacheDisabled: true. The risk window is narrow in practice — same-mtime same-size collisions either require an attacker controlling the writer or the rare case of repeated small same-length edits within a single filesystem tick — but the PR description should not pretend the gap closes here.Known unmitigated: stat → write race window
The pre-write enforcement is a stat-then-write pattern:
checkPriorReadcallsfs.stat, the result is fed intocache.check, and only if that approves doeswriteTextFilerun. Between the stat and the write a concurrent process can still mutate the file, and those bytes will be silently overwritten despite the model never having seen them. This is the same OS-level race Unix-like filesystems have surfaced for decades; it is not solvable in user-space without either an atomic write (write to a temp path, thenrename(2)over the target —renameis atomic and closes the window) or a content-hash post-write recheck (re-stat + re-read after the write, compare to the recorded hash, surface a "stale write detected" error after the fact). Both are deferred to a follow-up. This PR narrows the previously-unbounded post-read-to-write gap down to two adjacent syscalls (stat→writeTextFile), but does not eliminate it. Operators who need strict overwrite protection against concurrent writers should setfileReadCacheDisabled: trueand rely on application-level locking.Auto-memory bug fix
PR #3717 had auto-memory reads (
isAutoMemPath) skip the cache entirely — both lookup and record. With the new enforcement that means a model that just ReadAGENTS.mdcould not then Edit it, because the read never registered. Fixed by decoupling: auto-memory reads still skip thefile_unchangedfast-path (the per-read freshness<system-reminder>must always reach the model) but they DO record into the cache so the follow-up Edit seesfresh. New regression test inread-file.test.tsasserts this.Test plan
vitest run(full@qwen-code/qwen-code-coresuite): 6308 passed | 2 skippededit.test.tsandwrite-file.test.ts:fileReadCacheDisabledis trueWriteFileTool's overwrite path (where applicable)read-file.test.ts: asserts that a Read of an auto-memory file records into the cache (so Edit / WriteFile enforcement passes).tsc --noEmitclean.eslinton touched files clean.npm run build --workspace @qwen-code/qwen-code-coresucceeds.Notes for reviewers
requirePriorRead) live onEditToolInvocationandWriteFileToolInvocationseparately. The bodies are nearly identical, but extracting them into a shared utility would either need to take aConfig+ path or live in a new file; deferring that small refactor until we wire a third caller (or settle on whether to addMultiEdit).editData.isNewFilefromcalculateEdit; WriteFile:fileExistsfromisFilefileExists). Both signals are computed on the existing-file pass that already happens, so enforcement does not add a new stat for the creation path.中文版本
动机
PR #3717 引入了会话级
FileReadCache并用它短路重复 ReadFile。但 cache 一直是手段,目的是另一件事:在 mutate 文件之前校验模型确实在本会话看过这文件当前的字节。本 PR 之前,模型可以调 EditTool 传一个从未被任何 read 返回过的
old_string。如果这字符串恰好在文件里出现,edit 就成功——即便模型是在凭想象做事而非证据。最接近的兜底是0 occurrences失败模式,但它只能抓住"想象的字符串完全不匹配"那种,抓不住"看起来合理但实际是过时的"匹配。本 PR 用强制 prior-read 关上这个口子。新建文件豁免(没东西可读),现有的
Config.fileReadCacheDisabled逃生开关在 enforcement 启用时也照样生效,效果与未启用 cache 时逐字节一致。用户可见的变化
三个新错误码:
EDIT_REQUIRES_PRIOR_READ——文件在 session cache 里没记录(或只有 partial/非 cacheable entry)。错误消息告诉模型先用read_file。同样代码也用于两种"重读也救不了"的结构性情况:非文本负载(binary/image/audio/video/PDF/notebook —— read_file 返回结构化值,Edit/WriteFile 无法安全 mutate)和特殊文件(FIFO/socket/character/block device —— read_file 也拒)。这两种 case 错误消息指引模型用 shell 等其他工具而非循环read_file。FILE_CHANGED_SINCE_READ——文件之前读过,但 mtime/size 漂移。消息告诉模型重读后再重试。PRIOR_READ_VERIFICATION_FAILED——fs.stat本身失败了,且不是 ENOENT(典型如 EACCES、EBUSY、NFS hiccup)。与EDIT_REQUIRES_PRIOR_READ区分:模型也许合法读过此文件,只是我们无法验证。运维方可按此码路由独立报警。正常的 Read → Edit → Edit 链不需要中间再 Read:
recordWrite(#3717 引入)在每次成功写后刷新 fingerprint,第二次 Edit 的cache.check()看到fresh。行为变更要点
EDIT_REQUIRES_PRIOR_READ。之前:old_string凑巧匹配则成功。FILE_CHANGED_SINCE_READ。之前:作用在新字节上(经常覆盖模型没看见过的外部写)。EDIT_REQUIRES_PRIOR_READ配 "use a different mechanism" 消息。重读不会改变 cacheable 位;这类负载无法通过 Edit/WriteFile 作为文本编辑。fileReadCacheDisabled === true路径 → 与 pre-cache 行为一致。风险
enforcement 与 #3717 的
fileReadCacheDisabled共用一个 flag,事故应急就是 "cache 行为不对" 的开关:flip 它,Edit / WriteFile 回到 pre-cache 行为。最可能的误拒:模型做 Read→Edit 链时跨过了 compaction。compaction 已经清 cache(见 #3717 的
tryCompressChat),所以 compaction 后的第一次 Edit 会被拒EDIT_REQUIRES_PRIOR_READ,提示模型重读。一次 compaction 多一次 Read 的成本。通过
InProcessBackend.createPerAgentConfigspawn 的 subagent 拿到完全隔离的 FileReadCache:override 通过override.createToolRegistry()+copyDiscoveredToolsFrom(base.getToolRegistry())重建 tool registry,所以 bound 的EditTool/WriteFileTool实例的this.config指向 override,cache lazy-init 给出新的 per-subagent cache。通过
agent.ts:createApprovalModeOverride(at-tool-call subagent 路径)和subagent-manager.ts:maybeOverrideContentGenerator(inherits-model 路径)spawn 的 subagent 在本 PR 只有部分隔离:薄Object.create(parent)wrapper 触发了"直接调Config.getFileReadCache()的代码"的 lazy-init,但 bound 的EditTool/WriteFileTool实例是在启动时注册到父 tool registry 的,tool invocation 的this.config解析到父。关闭 bound-tool 路径是 follow-up——把InProcessBackend.createPerAgentConfig的 registry-rebuild 方法带到这两个 spawn 站点。PR 之前 subagent mutation 完全没有 prior-read enforcement,所以本 PR 在每条 spawn 路径上都是严格改善;部分隔离的两条路径只是还没拿到完整保证。已知未缓解:同 mtime + 同 size 的覆写
cache 的 freshness fingerprint 是
(mtimeMs, size)而非 content hash。同 mtime tick 内产生相同长度的写入(或utimes显式保留 mtime)会让cache.check返回 fresh,Edit/WriteFile 就会动用模型从未看到过的字节。PR 之前这是 stale-read issue;本 PR 把相同 fingerprint 共享到 mutation 路径,残余风险升级为 silent overwrite。兜底方案明确(在 read pipeline 上记录 content hash 并在
cache.check中比对),但显著增加每次 read 的成本,留作 follow-up。在意此场景的运维方应设fileReadCacheDisabled: true。实践中 race window 极窄——同 mtime 同 size collision 要么需要 attacker 控制写入方、要么是单 FS tick 内连续多次同长度小编辑——但 PR 描述不能假装这道口子就此关上。已知未缓解:stat → write race window
pre-write enforcement 是一个 stat-then-write pattern:
checkPriorRead先fs.stat,结果喂给cache.check,通过后才writeTextFile。stat 和 write 之间另一个进程仍可能修改文件,那些字节会被静默覆盖。这是 Unix-like 文件系统经典的 OS-level race;user-space 没法消除,除非用 atomic write(写 temp +rename(2),rename 原子)或 content-hash post-write recheck(write 后再 stat+read,与记录的 hash 比对,事后报 "stale write detected")。两者均留 follow-up。本 PR 把原本"post-read 到 write"的无界 gap 收紧为两个相邻 syscall(stat→writeTextFile),但没消除它。需要严格覆写保护的运维方应设fileReadCacheDisabled: true并用应用层 lock。Auto-memory bug 修复
#3717 让 auto-memory 读完全跳过 cache(lookup + record)。新 enforcement 下,模型 Read 了
AGENTS.md之后无法 Edit 它。修:解耦——auto-memory 读仍然跳过file_unchangedfast-path(per-read freshness<system-reminder>必须每次发到模型),但会 record 进 cache,这样后续 Edit 看到fresh。read-file.test.ts加了回归测试。Test plan
vitest run(全@qwen-code/qwen-code-core套件):6308 通过 / 2 skippedread-file.test.ts:断言 auto-memory read 入 cache 让后续 enforcement 通过。tsc --noEmit/eslint/ build 全绿。Reviewer 注意
requirePriorReadhelper 分别在EditToolInvocation/WriteFileToolInvocation上,函数体几乎相同。要抽到公共 util 需要传 Config + path 或新文件,暂时放着等第三个 caller(或决定是否引入MultiEdit)。editData.isNewFile;WriteFile:fileExists)。两个信号都在已有 existing-file 检查里算出来,enforcement 没新增 stat。