fix(brainfs): refuse a file behind a symlinked directory for reads and writes (#173) #187

Merged
mathias merged 2 commits from fix/write-dir-redirect-173 into main 2026-10-05 20:17:37 +00:00
Owner

Fixes #173. risk/high: control code in brainfs (every reader and writer), adversarial review required.

Problem

os.Root follows an intermediate directory symlink as long as it stays inside the brain:

  • A dir symlink inside wiki/ that points elsewhere in the brain let brain_update and brain_tunnel rewrite an existing .md there (for example under raw/).
  • session_log followed a symlinked sessions/ dir.

Change

  • WriteRegular, ReadRegular and ReadRegularMax require every directory above the file to be real (realDir(path.Dir(rel))).
  • Why reads too:
    • brain_tunnel read the redirected target, then rewrote the source before the target's write was refused (the new tunnel test caught this).
    • brain_read and brain_get could read another part of the brain through such a path.
  • session.Append refuses a sessions dir that is not a real directory.
  • Remaining: a directory swapped in after the check is the #167 residual.

Tests (each red before the fix)

  • TestUpdateNoteRefusesANoteBehindASymlinkedDirectory
  • TestWriteTunnelRefusesATargetBehindASymlinkedDirectory: the target is refused and the source is not rewritten.
  • TestAppendRefusesASymlinkedSessionsDir
  • TestReadAndWriteRegularRefuseAFileBehindASymlinkedDirectory: positive control included.

Flips (red)

  • realDir removed from WriteRegular
  • realDir removed from ReadRegular
  • the session dir check off

Cost: one Lstat per path component on every read and write.

🤖 Generated with Claude Code


Created via git-mcp on behalf of @mathias

Fixes #173. risk/high: control code in brainfs (every reader and writer), adversarial review required. ## Problem `os.Root` follows an intermediate directory symlink as long as it stays inside the brain: - A dir symlink inside `wiki/` that points elsewhere in the brain let **brain_update** and **brain_tunnel** rewrite an existing `.md` there (for example under `raw/`). - **session_log** followed a symlinked `sessions/` dir. ## Change - `WriteRegular`, `ReadRegular` and `ReadRegularMax` require **every directory above the file** to be real (`realDir(path.Dir(rel))`). - **Why reads too:** - brain_tunnel read the redirected target, then rewrote the source before the target's write was refused (the new tunnel test caught this). - brain_read and brain_get could read another part of the brain through such a path. - `session.Append` refuses a sessions dir that is not a real directory. - **Remaining:** a directory swapped in after the check is the #167 residual. ## Tests (each red before the fix) - `TestUpdateNoteRefusesANoteBehindASymlinkedDirectory` - `TestWriteTunnelRefusesATargetBehindASymlinkedDirectory`: the target is refused and the source is not rewritten. - `TestAppendRefusesASymlinkedSessionsDir` - `TestReadAndWriteRegularRefuseAFileBehindASymlinkedDirectory`: positive control included. ## Flips (red) - realDir removed from WriteRegular - realDir removed from ReadRegular - the session dir check off **Cost:** one Lstat per path component on every read and write. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Created via git-mcp on behalf of @mathias_
mathias added 1 commit 2026-10-05 18:54:42 +00:00
fix(brainfs): refuse a file behind a symlinked directory for reads and writes (#173)
CI / Lint / Test / Vet (pull_request) Successful in 55s
CI / Mirror to GitHub (pull_request) Skipped
87dace37b1
os.Root follows an intermediate directory symlink that stays inside the
brain. A dir symlink inside wiki/ pointing elsewhere in the brain let
brain_update and brain_tunnel rewrite an existing .md there (e.g. under
raw/), and session_log followed a symlinked sessions dir.

- WriteRegular, ReadRegular and ReadRegularMax require every directory
  above the file to be a real directory (realDir). Reads too: brain_tunnel
  read the redirected target, then rewrote the source before the target's
  write was refused; and brain_read/brain_get could read another part of
  the brain through such a path.
- session.Append refuses a sessions dir that is not a real directory.
- A directory swapped in after the check remains the #167 residual.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mathias added the risk/high label 2026-10-05 18:54:57 +00:00
mathias reviewed 2026-10-05 19:04:51 +00:00
mathias left a comment
Author
Owner

pr-gatekeeper: BLOCKED (head 87dace3)

I ran everything below in a scratch copy of the branch. The tests were run through the real MCP handlers (NewServer + tools/call) and checked against origin/main.

Blocking

B1. brainfs.ReadRegular, ReadRegularMax, WriteRegular: the new check is lexical, but the open is not.

  • File: internal/brainfs/brainfs.go:131, :160, :274.
  • realDir(path.Dir(rel)) checks the cleaned parent directory. The Lstat and the open then use the raw rel through os.Root.
  • os.Root resolves a .. component after it follows a symlink. So suppose a non-clean rel has a .. that comes after a symlinked directory. The check passes on the lexical parent, and the read or write goes through the symlink.
  • I ran this as a brainfs unit probe. WriteRegular replaced a file in raw/ through a symlinked directory under wiki/. ReadRegular and ReadRegularMax returned that file's bytes. The new doc comment says "every directory above the file must be a real directory", and that is false for this input.
  • No current entry point reaches it. notes.Resolve and resolveWithin clean the path, and checkNotePath and graphsync refuse non-clean paths. I confirmed that brain_get, brain_read, brain_edit, brain_tunnel and brain_update all refuse such a path. The hydrate and excerpt paths come from stores that only receive clean or walked paths.
  • That means the property is carried by every caller's upstream filter, not by the primitive this PR hardens. Any later caller that passes a store-sourced or constructed path gets the false guarantee. That includes #158 moving the raw/ writes onto WriteRegular.
  • Fix: in all three functions, refuse a rel that is not path.Clean-clean and filepath.IsLocal, with ErrNotRegular. Add a brainfs test that uses a ..-after-symlink path for all three. It must be red without the guard.

Non-blocking (please record)

N1. session.Append check-then-open race. This is not the #167 residual.

  • File: internal/session/session.go:73-79.
  • #167 is bounded by os.Root: a swap stays inside the brain. Append opens by plain path, so if a symlink is swapped in for sessions/ after the Lstat, the .jsonl is created wherever that link points, including outside the brain.
  • I measured it with a local atomic-exchange swapper. Most of the appends that passed the check landed in the outside directory.
  • A local writer can hit this; a Syncthing peer probably cannot. Main has the same write statically, so this PR is still a strict improvement.
  • The code comment records no residual. Either do the open through an os.Root on the brain dir (that bounds it to #167), or file the residual with its true bound.

N2. The api and brain endpoint tests pin neither layer individually.

  • Flipping only the read-side realDir, or only the write-side one, leaves TestUpdateNoteRefusesANoteBehindASymlinkedDirectory green. The other layer still refuses.
  • The brainfs unit test does pin each layer, so the coverage exists.
  • Both endpoint tests assert assert.Error with any error and have no in-test positive control. If an unrelated earlier refusal appeared later, they would stay green vacuously. Prefer ErrorIs(..., brainfs.ErrNotRegular) plus the same call on a real path succeeding.

N3. Two behaviour changes are shipped without assertions.

  • Search hydrate and brain_context's readExcerpt now refuse a stored path behind a symlinked directory. On main both returned the redirected file's content; I verified both on main and on the PR.
  • No test in the PR pins this. #171 tracks these per-caller tests.

N4. brain_write creates notes with os.WriteFile and is not covered by this PR. I found no open issue that covers its directory handling. Reported separately; no details here.

N5. Cosmetic.

  • The realDir error text says "walk root component" for file reads and writes.
  • The ReadRegular doc does not mention the new directory rule.

Flip tests (each one made a test go red)

Flip Red
drop the WriteRegular realDir brainfs test only
drop the ReadRegular realDir brainfs test + tunnel test (the source got rewritten)
drop the ReadRegularMax realDir brainfs test
drop all three brainfs + UpdateNote + tunnel tests
Append: Lstat changed to Stat / dir check dropped TestAppendRefusesASymlinkedSessionsDir
realDir checking only the first component, or skipping the last red (several)
Append without MkdirAll the existing session tests (a missing dir is still created)

Static redirect, end to end (MCP handlers, PR vs main)

  • I set up a directory symlink to raw/facts at each of wiki/<link>, wiki/w/<link>, wiki/w/<hall-name>, knowledge/<link> and knowledge/deep/<link>, with the brain dir both direct and behind a mount symlink. Through it I called brain_update, brain_get, brain_read, brain_edit (with .git present so the edit is reachable) and brain_tunnel, as both source and target.
  • PR: every call was refused, nothing leaked, and the victim was intact.
  • main: update, get, read and edit succeeded at every depth. Tunnel succeeded at every depth where its wing/hall shape applies.
  • session_log: a symlinked sessions/ is refused, and a missing one is still created, both with and without a mount symlink.
  • Positive controls: legitimate get, read, update, edit and tunnel calls all succeed, including through the mount symlink.

Regression check against a copy of the real brain

  • Over 1670 regular .md files, opened through a mount symlink:
    • ReadRegular and ReadRegularMax: 1670/1670 succeed on main and 1670/1670 on the PR.
    • WriteRegular (identical content, on a copy): 1670/1670 on the PR.
  • The copy has no symlinks, so this only shows that nothing legitimate is refused.

Gates

  • go test -race -count=5 over brainfs, api, brain and session passed.
  • CI=1 task check exited 0: 0 FAIL, golangci-lint reported 0 issues for both modules, gitleaks was clean.
  • CI run 9451: Lint/Test/Vet passed.
  • Green, and green covers none of B1 or N1.

The PR is labelled risk/high, so it needs Mathias's approval to merge regardless of this review.

## pr-gatekeeper: BLOCKED (head 87dace3) I ran everything below in a scratch copy of the branch. The tests were run through the real MCP handlers (`NewServer` + `tools/call`) and checked against `origin/main`. ### Blocking **B1. `brainfs.ReadRegular`, `ReadRegularMax`, `WriteRegular`: the new check is lexical, but the open is not.** - File: `internal/brainfs/brainfs.go:131`, `:160`, `:274`. - `realDir(path.Dir(rel))` checks the *cleaned* parent directory. The `Lstat` and the open then use the *raw* `rel` through `os.Root`. - `os.Root` resolves a `..` component *after* it follows a symlink. So suppose a non-clean `rel` has a `..` that comes after a symlinked directory. The check passes on the lexical parent, and the read or write goes through the symlink. - I ran this as a brainfs unit probe. `WriteRegular` replaced a file in `raw/` through a symlinked directory under `wiki/`. `ReadRegular` and `ReadRegularMax` returned that file's bytes. The new doc comment says "every directory above the file must be a real directory", and that is false for this input. - **No current entry point reaches it.** `notes.Resolve` and `resolveWithin` clean the path, and `checkNotePath` and graphsync refuse non-clean paths. I confirmed that brain_get, brain_read, brain_edit, brain_tunnel and brain_update all refuse such a path. The hydrate and excerpt paths come from stores that only receive clean or walked paths. - That means the property is carried by every caller's upstream filter, not by the primitive this PR hardens. Any later caller that passes a store-sourced or constructed path gets the false guarantee. That includes #158 moving the raw/ writes onto `WriteRegular`. - **Fix:** in all three functions, refuse a `rel` that is not `path.Clean`-clean and `filepath.IsLocal`, with `ErrNotRegular`. Add a brainfs test that uses a `..`-after-symlink path for all three. It must be red without the guard. ### Non-blocking (please record) **N1. `session.Append` check-then-open race. This is not the #167 residual.** - File: `internal/session/session.go:73-79`. - #167 is bounded by `os.Root`: a swap stays inside the brain. `Append` opens by plain path, so if a symlink is swapped in for `sessions/` after the `Lstat`, the `.jsonl` is created wherever that link points, including outside the brain. - I measured it with a local atomic-exchange swapper. Most of the appends that passed the check landed in the outside directory. - A local writer can hit this; a Syncthing peer probably cannot. Main has the same write *statically*, so this PR is still a strict improvement. - The code comment records no residual. Either do the open through an `os.Root` on the brain dir (that bounds it to #167), or file the residual with its true bound. **N2. The api and brain endpoint tests pin neither layer individually.** - Flipping only the read-side `realDir`, or only the write-side one, leaves `TestUpdateNoteRefusesANoteBehindASymlinkedDirectory` green. The other layer still refuses. - The brainfs unit test does pin each layer, so the coverage exists. - Both endpoint tests assert `assert.Error` with any error and have no in-test positive control. If an unrelated earlier refusal appeared later, they would stay green vacuously. Prefer `ErrorIs(..., brainfs.ErrNotRegular)` plus the same call on a real path succeeding. **N3. Two behaviour changes are shipped without assertions.** - Search `hydrate` and `brain_context`'s `readExcerpt` now refuse a stored path behind a symlinked directory. On main both returned the redirected file's content; I verified both on main and on the PR. - No test in the PR pins this. #171 tracks these per-caller tests. **N4.** `brain_write` creates notes with `os.WriteFile` and is not covered by this PR. I found no open issue that covers its directory handling. Reported separately; no details here. **N5. Cosmetic.** - The `realDir` error text says "walk root component" for file reads and writes. - The `ReadRegular` doc does not mention the new directory rule. ### Flip tests (each one made a test go red) | Flip | Red | |---|---| | drop the `WriteRegular` realDir | brainfs test only | | drop the `ReadRegular` realDir | brainfs test + tunnel test (the source got rewritten) | | drop the `ReadRegularMax` realDir | brainfs test | | drop all three | brainfs + UpdateNote + tunnel tests | | `Append`: `Lstat` changed to `Stat` / dir check dropped | `TestAppendRefusesASymlinkedSessionsDir` | | realDir checking only the first component, or skipping the last | red (several) | | `Append` without `MkdirAll` | the existing session tests (a missing dir is still created) | ### Static redirect, end to end (MCP handlers, PR vs main) - I set up a directory symlink to `raw/facts` at each of `wiki/<link>`, `wiki/w/<link>`, `wiki/w/<hall-name>`, `knowledge/<link>` and `knowledge/deep/<link>`, with the brain dir both direct and behind a mount symlink. Through it I called brain_update, brain_get, brain_read, brain_edit (with `.git` present so the edit is reachable) and brain_tunnel, as both source and target. - **PR:** every call was refused, nothing leaked, and the victim was intact. - **main:** update, get, read and edit succeeded at every depth. Tunnel succeeded at every depth where its wing/hall shape applies. - **session_log:** a symlinked `sessions/` is refused, and a missing one is still created, both with and without a mount symlink. - **Positive controls:** legitimate get, read, update, edit and tunnel calls all succeed, including through the mount symlink. ### Regression check against a copy of the real brain - Over 1670 regular `.md` files, opened through a mount symlink: - `ReadRegular` and `ReadRegularMax`: 1670/1670 succeed on main and 1670/1670 on the PR. - `WriteRegular` (identical content, on a copy): 1670/1670 on the PR. - The copy has no symlinks, so this only shows that nothing legitimate is refused. ### Gates - `go test -race -count=5` over brainfs, api, brain and session passed. - `CI=1 task check` exited 0: 0 FAIL, golangci-lint reported 0 issues for both modules, gitleaks was clean. - CI run 9451: Lint/Test/Vet passed. - Green, and green covers none of B1 or N1. The PR is labelled `risk/high`, so it needs Mathias's approval to merge regardless of this review.
mathias added 1 commit 2026-10-05 19:08:49 +00:00
fix(brainfs,session): clean paths only; session logs open through the brain root (#173)
CI / Lint / Test / Vet (pull_request) Successful in 1m1s
CI / Mirror to GitHub (pull_request) Skipped
9a288bea6f
Review round 1 on PR #187:

- realDir checked the cleaned parent while Lstat/OpenFile used the raw
  path; os.Root resolves ".." after following a symlink, so a ".." placed
  after a symlinked directory passed the check and redirected the read or
  write. ReadRegular, ReadRegularMax and WriteRegular now refuse a path
  that is not clean and local (ErrNotRegular). No current entry point
  reached it (callers clean first); the primitive no longer relies on that.
- session.Append checked the sessions dir with Lstat, then opened by plain
  path: a symlink swapped in between created logs outside the brain
  (29k in the reviewer's RENAME_EXCHANGE race). It now opens through an
  os.Root on the sessions dir's parent, so a swap can only redirect inside
  the brain (#167), and, since os.Root follows an in-root final symlink
  even with O_NOFOLLOW, writes only after confirming the path is the
  regular file it opened (Lstat + os.SameFile).
- Endpoint tests assert ErrorIs(brainfs.ErrNotRegular) and have positive
  controls.

golang.org/x/sys moves from indirect to direct (unix.Renameat2 in the
session race test); it was already in the module graph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mathias reviewed 2026-10-05 19:13:05 +00:00
mathias left a comment
Author
Owner

pr-gatekeeper round 2: PASS (head 9a288be). No merge: risk/high, so Mathias approves.

I ran everything in a fresh scratch copy of 9a288be.

Round-1 findings

  • B1: closed.
    • cleanRel runs first in ReadRegular, ReadRegularMax and WriteRegular.
    • My round-1 brainfs probe is now refused by all three, and the target is untouched.
    • The same probe through brain_get, brain_read, brain_edit, brain_tunnel and brain_update is still refused.
  • N1: closed to the #167 bound.
    • A sessions/ symlink that points outside the brain is refused, and nothing is created outside.
    • A dangling final log symlink that points outside creates nothing.
    • A missing sessions dir is still created, including under a mount-symlinked brain dir, and a second append adds a second line.
  • N2: closed. Both endpoint tests now use ErrorIs(brainfs.ErrNotRegular) with real positive controls.
  • N4: filed as #188.
  • N3: stays with #171.

Flip tests (each run separately; all red unless noted)

Flip Red
cleanRel made a no-op ...RefuseAPathThatIsNotClean
cleanRel dropped from ReadRegular / ReadRegularMax / WriteRegular, one at a time the same test, each time
IsLocal clause dropped the same test
plain-path open instead of root.OpenFile (3 runs) ...UnderADirSwapRace, 3/3
SameFile off on its own (3 runs) ...LogSwappedForASymlink, 3/3
IsDir check off TestAppendRefusesASymlinkedSessionsDir
root.Mkdir dropped existing session tests
backslash clause dropped stays green (N-b)
empty-string clause dropped stays green; redundant, because path.Clean("") is .
IsRegular on the log dropped stays green; redundant, because SameFile already refuses a symlink

End-to-end and regression re-run

  • The round-1 MCP probe: directory symlinks at five depths under wiki/ and knowledge/, direct and behind a mount symlink, through update, get, read, edit and tunnel (source and target).
    • All refused, nothing leaked, the victim was intact.
    • Positive controls pass. session_log refuses a symlinked dir and creates a missing one.
  • hydrate and readExcerpt refuse the redirected paths and read the real ones.
  • Copy of the real brain, opened through a mount symlink (1670 .md files): ReadRegular, ReadRegularMax and WriteRegular each succeed on 1670/1670.

Non-blocking

  • N-a. Dangling in-brain log symlink. Append creates the log with O_CREATE before its SameFile check. A dangling in-brain final symlink at sessions/<id>.jsonl therefore makes Append create an empty file at the link's in-brain target before it refuses. I measured this: 0 bytes, with an error returned.
    • There is no privilege gain, because planting the link already needs brain write access.
    • Still, that file then gets indexed. An Lstat of the log before the open, or an O_EXCL create, would close it.
  • N-b. Untested backslash clause. The backslash clause in cleanRel has no test. On Linux it is a policy choice, not a traversal guard, so either test it or say why it is there.
  • N-c. Race tests have no floor. The two new race tests have no floor showing the swapper actually ran (a swap count above zero, or at least one refusal observed). That is the #177 pattern: on a slow runner they could pass without proving anything. Each one is red under its flip locally (3/3), so this is hardening, not a hole.
  • N5: the error text is unchanged and cosmetic.

Gates

  • go test -race -count=5 over brainfs, api, brain and session passed.
  • CI=1 task check exited 0: 0 FAIL, golangci-lint reported 0 issues in both modules, gitleaks was clean.
  • go mod tidy is clean with x/sys as a direct dependency.
  • CI run 9459: Lint/Test/Vet passed.
## pr-gatekeeper round 2: PASS (head 9a288be). No merge: `risk/high`, so Mathias approves. I ran everything in a fresh scratch copy of 9a288be. ### Round-1 findings - **B1: closed.** - `cleanRel` runs first in `ReadRegular`, `ReadRegularMax` and `WriteRegular`. - My round-1 brainfs probe is now refused by all three, and the target is untouched. - The same probe through brain_get, brain_read, brain_edit, brain_tunnel and brain_update is still refused. - **N1: closed to the #167 bound.** - A `sessions/` symlink that points outside the brain is refused, and nothing is created outside. - A dangling final log symlink that points outside creates nothing. - A missing sessions dir is still created, including under a mount-symlinked brain dir, and a second append adds a second line. - **N2: closed.** Both endpoint tests now use `ErrorIs(brainfs.ErrNotRegular)` with real positive controls. - **N4:** filed as #188. - **N3:** stays with #171. ### Flip tests (each run separately; all red unless noted) | Flip | Red | |---|---| | `cleanRel` made a no-op | `...RefuseAPathThatIsNotClean` | | `cleanRel` dropped from ReadRegular / ReadRegularMax / WriteRegular, one at a time | the same test, each time | | `IsLocal` clause dropped | the same test | | plain-path open instead of `root.OpenFile` (3 runs) | `...UnderADirSwapRace`, 3/3 | | `SameFile` off on its own (3 runs) | `...LogSwappedForASymlink`, 3/3 | | `IsDir` check off | `TestAppendRefusesASymlinkedSessionsDir` | | `root.Mkdir` dropped | existing session tests | | backslash clause dropped | **stays green** (N-b) | | empty-string clause dropped | stays green; redundant, because `path.Clean("")` is `.` | | `IsRegular` on the log dropped | stays green; redundant, because `SameFile` already refuses a symlink | ### End-to-end and regression re-run - The round-1 MCP probe: directory symlinks at five depths under wiki/ and knowledge/, direct and behind a mount symlink, through update, get, read, edit and tunnel (source and target). - All refused, nothing leaked, the victim was intact. - Positive controls pass. `session_log` refuses a symlinked dir and creates a missing one. - hydrate and readExcerpt refuse the redirected paths and read the real ones. - Copy of the real brain, opened through a mount symlink (1670 `.md` files): ReadRegular, ReadRegularMax and WriteRegular each succeed on 1670/1670. ### Non-blocking - **N-a. Dangling in-brain log symlink.** `Append` creates the log with `O_CREATE` before its `SameFile` check. A dangling *in-brain* final symlink at `sessions/<id>.jsonl` therefore makes `Append` create an empty file at the link's in-brain target before it refuses. I measured this: 0 bytes, with an error returned. - There is no privilege gain, because planting the link already needs brain write access. - Still, that file then gets indexed. An `Lstat` of the log before the open, or an `O_EXCL` create, would close it. - **N-b. Untested backslash clause.** The backslash clause in `cleanRel` has no test. On Linux it is a policy choice, not a traversal guard, so either test it or say why it is there. - **N-c. Race tests have no floor.** The two new race tests have no floor showing the swapper actually ran (a swap count above zero, or at least one refusal observed). That is the #177 pattern: on a slow runner they could pass without proving anything. Each one is red under its flip locally (3/3), so this is hardening, not a hole. - **N5:** the error text is unchanged and cosmetic. ### Gates - `go test -race -count=5` over brainfs, api, brain and session passed. - `CI=1 task check` exited 0: 0 FAIL, golangci-lint reported 0 issues in both modules, gitleaks was clean. - `go mod tidy` is clean with x/sys as a direct dependency. - CI run 9459: Lint/Test/Vet passed.
mathias merged commit ddd4bdc2ac into main 2026-10-05 20:17:37 +00:00
Sign in to join this conversation.