fix(brain_edit): snapshot, rollback and lock file go through the brain root (#192) #194
Open
mathias
wants to merge 3 commits from
fix/edit-path-io-192 into main
pull from: fix/edit-path-io-192
merge into: :main
:main
:fix/pipeline-writes-158
:fix/edit-path-io-192
:fix/create-regular-188
:fix/guard-special-179
:fix/linkcheck-keeps-write-178
:fix/write-dir-redirect-173
:fix/tunnel-detect-174
:fix/pipeline-root-157
:w3/154-mcp-auth-wiring
:fix/tunnel-traversal
:test/in-brain-targets-171
:fix/brain-scan-root-157
:fix/graph-wiki-root-157
:fix/vectorstore-root-157
:fix/notes-root-157
:fix/search-root-157
:fix/brainwide-root-157
:fix/raw-symlinks-153
:feat/symlink-tripwire-157
:fix/rest-auth-149
:fix/123-brain-edit-envelope-check
:feat/106-page-aware-extraction
:fix/105-source-drift-on-main
:fix/104-surface-merge-conflicts-on-main
:agent/b1-compile-on-ingest
:agent/104-surface-merge-conflicts
:agent/105-source-drift
:agent/107-loud-unconfigured-alerting
:feat/brain-read-edit
:fix/capture-i1-refusal-names-target
:renovate/configure
:feat/consolidate-single-harness
:icebox/cmd-routing
:feat/brain-pending-promote
:fix/close-session-classification-refresh
:fix/capture-summarywriter
Labels
Clear labels
agent/ready
blocked/client
blocked/supplier
blocked/upstream
risk/high
risk/low
risk/medium
size/l
size/m
size/s
size/xs
status:dispatch
status:dispatched
status:review
status:reviewing
type/chore
type/decision
type/docs
type/feat
type/fix
type/ops
type/research
type/test
Meets the agent-ready contract; authorizes unattended execution
Waiting on the client
Waiting on a supplier
Waiting on an upstream project or vendor
Irreversible, security-relevant, or control plane
Reversible, local, test-covered
Touches shared state, deploys or data
Too big - split it
Under two days
Under half a day
Under an hour
Maintenance, no behaviour change
Human decision required; never agent/ready
Documentation only
New capability
Corrects broken behaviour
Infrastructure, deploy, runtime
Investigate; output is a finding, not code
Test coverage only
No labels
risk/high
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: mathias/hyperguild#194
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Fixes #192. risk/high: control code, adversarial review required.
Problem
brain_editdid its own I/O by path aroundnotes.Edit:os.ReadFile, which hung on a FIFO note held open by a writer.os.WriteFile, by path.<note>.lockwas opened withO_CREATEby path. A planted dangling.locksymlink made a successful edit create a file at the link's target, outside the brain or elsewhere in it.Change
brainfs.ReadRegular, and the rollback usesbrainfs.WriteRegular.brainfs.OpenLock/RemoveLock:O_NOFOLLOW|O_NONBLOCK, then confirmed withSameFile. os.Root follows an in-root final symlink even withO_NOFOLLOW.Tests (red before the fix)
TestBrainEditReturnsPromptlyOnAFIFONoteWithAWriter, with a positive control.TestBrainEditDoesNotCreateAFileThroughADanglingLockSymlink: outside and in-brain targets.Flips (red)
os.ReadFile🤖 Generated with Claude Code
Created via git-mcp on behalf of @mathias
Adversarial review (pr-gatekeeper) — BLOCKED
Head
47354dc. Reviewed by execution against a scratch copy; tests mutated one guard at a time. Issue #192 is labelledrisk/high, so this goes to Mathias for the merge regardless.Blocking
The rollback hardening has no assertion. If
bfs.WriteRegular(target.Rel, prior)ininternal/mcp/tools_notes.gogoes back toos.WriteFile(target.Abs, ...),go test ./internal/mcp ./internal/notes ./internal/brainfsstays green. I put a seam before the stamp, swapped the note for a symlink, and forced a stamp failure. Under the PR the rollback refuses the swap, which is correct. With the revert it writes the prior note bytes through the link, to a file outside the brain or another note in it.TestBrainEditRollsBackWhenTheStampCannotBeVerifiedonly covers the case with no swap. Add a test that swaps a link in and goes red on the revert.The "in-brain" dangling
.locksubtest never reaches the new guard.TestBrainEditDoesNotCreateAFileThroughADanglingLockSymlink/in-brainplants an absolute symlink. os.Root refuses every absolute symlink on its own ("path escapes from parent"), so that subtest passes even if the guards are taken out. A relative in-brain dangling link is the shape Syncthing would carry, and os.Root follows it, O_NOFOLLOW or not. Only the Lstat pre-check inOpenLockstops it. Flip: remove that pre-check and the whole suite stays green, while a relative dangling<note>.lockmakes brain_edit create a file at the link's target, which is the #192 defect. Make the in-brain case a relative link, or add a case for one.OpenLock/RemoveLockhave no unit tests. Each of these mutations survives the three packages: drop the pre-check, drop the post-open SameFile check, drop the pre-check and SameFile together, droprealDir, dropcleanRel, drop O_NOFOLLOW, drop O_NONBLOCK, droprealDirin RemoveLock. The PR's tests detect only two flips: snapshot back to os.ReadFile (the FIFO test, which has a real positive control) and the lock opened by path again.Non-blocking (some predate this PR; please file them)
notes.Editreads inside the lock and renames, so two pods can lose an update and both report success. Removing the flock call entirely leaves the suite green, because no test covers cross-process locking.OpenLockcalls failed withchanged while opening: not a regular file. That is a peer's RemoveLock and recreate racing the SameFile check. The message is misleading and the edit fails. One fix covers both 4 and 5: after flock, compare the fd's inode with an Lstat of the path and retry if they differ, or stop unlinking the lock.OpenLocksays "nothing is created at its target". In the window between the pre-check and the open, a relative dangling link swapped in still gets an empty in-brain file created at its target (the open is refused only afterwards). Name it as the same accepted residual as #167.brainfs.goafter #189, and Gitea reports it not mergeable. Main now hasBrain.RemoveandRealParent, which do the same job asRemoveLockand thecleanRel+realDirpreamble. Review the rebased result again.What held (executed end to end through the MCP handler with a real
.git)go test -race -count=3passes on mcp, notes and brainfs.CI=1 task checkexits 0. CI run #597 (Lint / Test / Vet) is green, but that green does not test any of the findings above.47354dc4fcto07ea5b01ecAdversarial review round 2 (pr-gatekeeper) — BLOCKED, one finding
Head
07ea5b0, rebased on main and mergeable. Tested by execution against a scratch copy. Issue #192 is labelledrisk/high, so Mathias decides the merge.Blocking
TestBrainEditRollbackDoesNotWriteThroughASymlinkSwappedInForTheNotepasses when the swap never happens.beforeStampHook()call intools_notes.gowith a no-op. The suite stays green.resp["error"]is non-nil.What now holds
os.WriteFileRealParentos.ReadFileLockIsCurrentre-checkLockIsCurrentalways trueErrLockChangedtreated as fatal.git:go test -race -count=3passes on mcp, notes and brainfs.CI=1 task checkexits 0. CI run #604 (task 12728) is green.Non-blocking
notespackage comment says brain-fix and brain-enrich "write the same tree". Nothing else takes this lock:notes.Editis the only flock user. That predates this PR and is outside #192.Adversarial review round 3 (pr-gatekeeper): PASS
Head
cdcff12. The only change since07ea5b0is six lines inedit_pathio_test.go; no production code changed. I tested by execution against a scratch copy.Round-2 blocker is fixed. The rollback test now asserts that the error says the edit could NOT be rolled back, and that the note path is still the symlink planted before the stamp.
beforeStampHook()call into a no-op now fails this test, and only this test.Flips re-run at this head, all red:
os.WriteFileTestBrainEditRollsBackWhenTheStampCannotBeVerifiedTestOpenLock...LockIsCurrentre-check: the cross-process testGates:
go test -race -count=3passes on mcp, notes and brainfs.CI=1 task checkexits 0, with 0--- FAILlines.Carried over from round 2. The production code is identical to
07ea5b0, so I did not re-run these:Tracked elsewhere:
risk/high: merging deploys ingestion, so Mathias decides the merge.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.