fix(memory): refuse mutations when on-disk file changed since last read (#26045) - #30993
fix(memory): refuse mutations when on-disk file changed since last read (#26045)#30993bradhallett wants to merge 1 commit into
Conversation
…e last read (NousResearch#26045) Add a content-hash guard to the replace, add, and remove mutation paths. Before flushing, the tool re-reads the on-disk file and compares its SHA-256 hash to the snapshot taken at lock-acquire time. If the file was modified externally (concurrent session, patch tool edit, shell append) between those two points, the mutation is refused with a clear error message directing the caller to re-read and retry. This is the same contract the patch tool already enforces ('file was modified since you last read it on disk'). It supplements the existing drift guard (round-trip mismatch / oversized-entry detection) and would alone have prevented the original ~8KB data-loss incident. New tests: - test_replace_refuses_when_file_changed_between_read_and_write - test_replace_succeeds_when_file_unchanged - test_remove_refuses_when_file_changed_between_read_and_write - test_add_refuses_when_file_changed_between_read_and_write - test_on_disk_guard_does_not_trigger_when_file_is_new - test_on_disk_guard_and_drift_guard_are_independent
jsboige
left a comment
There was a problem hiding this comment.
Reviewed this carefully. The on-disk change guard is well-designed:
What it does: SHA256 snapshot of file content before lock acquisition, then verification before write in add/replace/remove. Refuses mutation if content changed between read and write — prevents data loss from concurrent sessions (#26045).
Strengths:
- Static methods (
_snapshot_on_disk,_check_on_disk_unchanged) — clean separation, testable - Guard fires AFTER drift check — correct ordering (drift is structural, on-disk is temporal)
- 7 tests covering all 3 mutation paths + edge cases (new file, drift vs on-disk independence)
- Error messages reference issue #26045 and instruct user to re-read + retry
One observation (non-blocking): The pre_hash is taken just before _reload_target() which re-reads from disk. If the file changes between _snapshot_on_disk() and _reload_target(), the drift guard would likely catch it (unparseable content → backup). If it changes between _reload_target() and the check, the on-disk guard catches it. This covers the full window — good.
LGTM.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for pursuing the concurrent-write protection from #26045. The underlying race remains on current main: replace reloads at tools/memory_tool.py:403 and saves at :453 without an on-disk version check.
Problems
- The proposed
pre_hashis captured before_reload_target(tools/memory_tool.py:294in this diff). A valid write between those reads is incorporated by the reload but still causes a refusal. Snapshot after the authoritative reload instead. - The add path appends to live entries before checking (
tools/memory_tool.py:328), so a refused add leaves in-memory state changed even though disk was not written. - Current main added
apply_batchin38c8a9c10; it also reloads then saves (tools/memory_tool.py:524,:600) and needs the same protection. - The new tests mock the check helper, rather than modifying the backing file during the real reload-to-write window.
Suggested changes
- Centralize a post-reload snapshot plus immediately-pre-write validation for every persistence path, including
apply_batch. - Add real temp-file mutation tests and assert both disk and live state remain unchanged on refusal.
This is an automated hermes-sweeper review.
| return {"success": False, "error": scan_error} | ||
|
|
||
| with self._file_lock(self._path_for(target)): | ||
| path = self._path_for(target) |
There was a problem hiding this comment.
_reload_target() reads the file again immediately after this snapshot. If a valid external write lands between these reads, the reload already incorporates it, but the later check still rejects the mutation. Capture the snapshot after the authoritative reload so the guard covers only changes after the state used for this mutation.
| @@ -307,6 +328,19 @@ def add(self, target: str, content: str) -> Dict[str, Any]: | |||
|
|
|||
There was a problem hiding this comment.
This refusal happens after entries.append(content), so a failed add leaves memory_entries/user_entries changed in memory although disk was not written. Validate before mutating live entries, or restore the previous list before returning the error.
Summary
Follow-up to #26045 (commit 6855d17). The drift guard detects structurally un-roundtrippable content, but jrhouston-trilogy recommended an additional defense: refuse mutations when the on-disk file has changed since last read — the same contract the
patchtool already enforces.This alone would have prevented the original ~8KB data-loss incident.
Changes
tools/memory_tool.py: Added SHA-256 content-hash snapshot at lock acquisition. Beforesave_to_disk(), re-reads the file and refuses if the hash differs. Applied to all three mutation paths (add,replace,remove).tests/tools/test_memory_tool.py: 6 new tests inTestOnDiskChangeGuardRelationship to existing drift guard
Both defenses are active and independent:
Test plan
Refs #26045