SECURITY: symlinks anywhere in the Syncthing-shared brain dir are followed by search, vectorstore, context and graph readers #157

Closed
opened 2026-10-03 14:57:13 +00:00 by mathias · 26 comments
Owner

Found by the PR #156 review, 2026-10-03. Out of #153's raw/ scope.

/var/lib/supervisor/brain is a Syncthing sendreceive folder shared with flamingo, and Syncthing syncs symlinks. .stignore lists only sessions, training-data, .git and log.md. So whoever controls a Syncthing peer can plant wiki/<wing>/<hall>/leak.md -> /any/pod-readable/file.

  • Reproduced: search.Query (brain_search / brain_query) returned the target's contents in the excerpt (sentinel count 1).
  • Read but not run: vectorstore/sync.go (embeds it), search.hydrate, mcp/tools_context.go readExcerpt, graphsync, frontmatterlint (--fix writes through symlinks) all follow such a symlink.

Impact: a file outside the brain gets indexed and returned to any MCP/REST caller. Since #149 that means token holders, plus whoever controls flamingo or koala.

Options (decision needed)

  1. Code: every brain reader walks with os.Root on the brain dir and skips non-regular files (the #153 pattern, brain-wide). Thorough, but touches many packages.
  2. Sweep: a startup check plus periodic job that refuses to serve if a symlink exists under the brain dir, or deletes it. Cheap and blunt.
  3. Syncthing: stop the peer from creating symlinks in that folder. Syncthing has no per-folder "ignore symlinks" option; check whether ignore patterns or the receive side can do it.

Recommended: 2 now as a tripwire, then 1.


Created via git-mcp on behalf of @mathias

Found by the PR #156 review, 2026-10-03. Out of #153's raw/ scope. `/var/lib/supervisor/brain` is a Syncthing `sendreceive` folder shared with flamingo, and Syncthing syncs symlinks. `.stignore` lists only sessions, training-data, .git and log.md. So whoever controls a Syncthing peer can plant `wiki/<wing>/<hall>/leak.md -> /any/pod-readable/file`. - **Reproduced:** `search.Query` (brain_search / brain_query) returned the target's contents in the excerpt (sentinel count 1). - **Read but not run:** `vectorstore/sync.go` (embeds it), `search.hydrate`, `mcp/tools_context.go readExcerpt`, `graphsync`, `frontmatterlint` (`--fix` writes through symlinks) all follow such a symlink. Impact: a file outside the brain gets indexed and returned to any MCP/REST caller. Since #149 that means token holders, plus whoever controls flamingo or koala. ## Options (decision needed) 1. **Code:** every brain reader walks with `os.Root` on the brain dir and skips non-regular files (the #153 pattern, brain-wide). Thorough, but touches many packages. 2. **Sweep:** a startup check plus periodic job that refuses to serve if a symlink exists under the brain dir, or deletes it. Cheap and blunt. 3. **Syncthing:** stop the peer from creating symlinks in that folder. Syncthing has no per-folder "ignore symlinks" option; check whether ignore patterns or the receive side can do it. Recommended: 2 now as a tripwire, then 1. --- _Created via git-mcp on behalf of @mathias_
mathias added the risk/highsize/mtype/fix labels 2026-10-03 14:57:13 +00:00
Author
Owner

Decision (Mathias, 2026-10-03): option 2 now as a tripwire, then option 1 (brain-wide readers through os.Root, skipping non-regular files). Option 3 (Syncthing) is not pursued.


Created via git-mcp on behalf of @mathias

**Decision (Mathias, 2026-10-03):** option 2 now as a tripwire, then option 1 (brain-wide readers through os.Root, skipping non-regular files). Option 3 (Syncthing) is not pursued. --- _Created via git-mcp on behalf of @mathias_
Author
Owner

pr-gatekeeper verdict: BLOCK (PR #159 @ 796d226)

Not merged. CI run 8893 is green, CI=1 task check EXIT=0 locally, and neither covers any of the findings below.

Findings (most severe first)

  1. cmd/server/symlink_check.go:16-18: the tripwire fails open on any scan error, not only a missing brain dir. filepath.WalkDir aborts on the first error and Scan throws away everything it had already found, so a single unreadable directory turns off the startup refusal.
    Repro (executed): brain with planted -> /tmp/x plus an empty dir aaa/ at mode 000. startupSymlinkCheck returns err=<nil>. In the same tree, search.Query still returns the sentinel from a symlinked wiki/w/facts/leak.md (hits=1), because search logs walk errors and carries on (search.go:128-131).
    This is live-relevant: the pod runs as uid 1000 (runAsUser: 1000, no root DAC override), and Syncthing on koala also runs as uid 1000, so a dir a peer syncs with mode 000 is unreadable to the pod. I did not test this against a real Syncthing peer. Even with no attacker, Syncthing deleting a dir in the middle of a walk produces the same silent pass.
    The fix is a correction, not a judgement call: return nil only when the root does not exist (errors.Is(err, fs.ErrNotExist) from EvalSymlinks), refuse on every other error, or make Scan record per-entry errors and keep walking.

  2. internal/symlinkguard/symlinkguard.go:68-70: Watch goes permanently dark on a persistent scan error. Each tick logs slog.Warn and skips the notifier, so the same unreadable dir silences the runtime alert too. Nothing pages anyone. Because this control's whole value is that someone finds out, a scan that fails is itself something to alert on.

  3. symlinkguard.go:75-84: a failed alert is never retried. last = key is set before Notify, so if ntfy is down at that moment the alert is lost until the symlink set changes. Executed: a failing notifier was called 1 time over ~30 ticks with the symlink still in place. Only move last forward after Notify succeeds (or n is nil).

  4. symlinkguard.go:41: .git is skipped at every depth, not only at the root. Executed: wiki/w/facts/.git/leak.md -> outside gives Scan found=0, while search sentinel hits=1. Syncthing is probably not a route for this, since the live .stignore entry .git (no leading slash) matches at any depth. That leaves it reachable only by a writer local to koala. The real problem is that the PR's stated guarantee ("refuses to start while any symlink exists") is false. Skip only filepath.Join(root, ".git"). I flipped it to root-only and the suite stayed green, so the fix costs nothing.

  5. cmd/server/main.go:369: BRAIN_SYMLINK_SCAN_INTERVAL<=0 panics. time.NewTicker panics with "non-positive interval for NewTicker" (executed), and because it happens in a goroutine it takes the whole server down into a crashloop. Clamp the value or reject it at startup.

  6. Minor: the dedupe key strings.Join(found, "\n") collides for names that contain a newline ({"a\nb"} vs {"a","b"}). alertingWarnings doesn't mention that the tripwire alert goes dark when BRAIN_NTFY_URL is unset (it is set live, so there's no effect today).

  7. Risk label: issue #157 is risk/high, PR is risk/medium. A security control on the brain should carry the issue's label.

Rule 9 (test names)

  • TestScanFollowsTheRootButNotEntries: the test has no symlinked entries, so "ButNotEntries" is never asserted. Rename it (e.g. ...ResolvesASymlinkedRoot) or add an entry symlink.
  • TestScanFindsSymlinksAtAnyDepthAndSkipsGit: "any depth" is contradicted by finding 4, and "SkipsGit" passes for a skip at any depth as well as a root-only skip.
  • TestWatchNotifiesOncePerNewSymlinkSet: uses only one set. Moving last= after the empty-check, which suppresses the alert when a set disappears and reappears, stayed green.

Flip tests

Mutation Suite
drop .git skip red
.git skip root-only green (no test pins depth)
drop root EvalSymlinks red
drop dedupe red
never call Notify red
suppress reappear notification green
startup: scan error refuses instead of nil green (fail-open path is untested)
remove both startupSymlinkCheck and go Watch from main.go green (wiring unasserted)

What holds

  • The startup check runs before every brain reader (handler, MCP, graph backfill, vectorstore, claudewatcher).
  • Dangling symlinks, directory symlinks, and unicode/newline names are all reported.
  • A symlink inside a symlinked dir doesn't matter, because the dir link is reported.
  • The nil notifier is a true nil interface.
  • context.Background() is never cancelled, but it is one goroutine for the life of the process, so there is no leak.
  • Live deploy safety: INGEST_BRAIN_DIR=/app/brain is a hostPath of /var/lib/supervisor/brain, which has 0 symlinks, 0 find errors, 0 nested .git, and 0 dirs unreadable by uid 1000 (read-only check, 2026-10-03).

Not checked

  • Behaviour against a real Syncthing peer (mode-000 dir propagation, symlink sync).
  • Hard links (out of scope; Syncthing syncs content, not links).
  • A TOCTOU plant during a walk.
  • Readers other than search.Query, which I did not execute (vectorstore, context, graph).
## pr-gatekeeper verdict: **BLOCK** (PR #159 @ 796d226) Not merged. CI run 8893 is green, `CI=1 task check` EXIT=0 locally, and **neither covers any of the findings below**. ### Findings (most severe first) 1. **`cmd/server/symlink_check.go:16-18`: the tripwire fails open on *any* scan error, not only a missing brain dir.** `filepath.WalkDir` aborts on the first error and `Scan` throws away everything it had already found, so a single unreadable directory turns off the startup refusal. Repro (executed): brain with `planted -> /tmp/x` plus an empty dir `aaa/` at mode 000. `startupSymlinkCheck` returns `err=<nil>`. In the same tree, `search.Query` still returns the sentinel from a symlinked `wiki/w/facts/leak.md` (hits=1), because search logs walk errors and carries on (`search.go:128-131`). This is live-relevant: the pod runs as uid 1000 (`runAsUser: 1000`, no root DAC override), and Syncthing on koala also runs as uid 1000, so a dir a peer syncs with mode 000 is unreadable to the pod. *I did not test this against a real Syncthing peer.* Even with no attacker, Syncthing deleting a dir in the middle of a walk produces the same silent pass. The fix is a correction, not a judgement call: return nil only when the **root** does not exist (`errors.Is(err, fs.ErrNotExist)` from `EvalSymlinks`), refuse on every other error, or make Scan record per-entry errors and keep walking. 2. **`internal/symlinkguard/symlinkguard.go:68-70`: Watch goes permanently dark on a persistent scan error.** Each tick logs `slog.Warn` and skips the notifier, so the same unreadable dir silences the runtime alert too. Nothing pages anyone. Because this control's whole value is that someone finds out, a scan that fails is itself something to alert on. 3. **`symlinkguard.go:75-84`: a failed alert is never retried.** `last = key` is set *before* `Notify`, so if ntfy is down at that moment the alert is lost until the symlink set changes. Executed: a failing notifier was called **1 time over ~30 ticks** with the symlink still in place. Only move `last` forward after Notify succeeds (or n is nil). 4. **`symlinkguard.go:41`: `.git` is skipped at every depth, not only at the root.** Executed: `wiki/w/facts/.git/leak.md -> outside` gives Scan found=0, while search sentinel hits=1. Syncthing is probably *not* a route for this, since the live `.stignore` entry `.git` (no leading slash) matches at any depth. That leaves it reachable only by a writer local to koala. The real problem is that the PR's stated guarantee ("refuses to start while any symlink exists") is false. Skip only `filepath.Join(root, ".git")`. I flipped it to root-only and the suite stayed green, so the fix costs nothing. 5. **`cmd/server/main.go:369`: `BRAIN_SYMLINK_SCAN_INTERVAL<=0` panics.** `time.NewTicker` panics with "non-positive interval for NewTicker" (executed), and because it happens in a goroutine it takes the whole server down into a crashloop. Clamp the value or reject it at startup. 6. Minor: the dedupe key `strings.Join(found, "\n")` collides for names that contain a newline (`{"a\nb"}` vs `{"a","b"}`). `alertingWarnings` doesn't mention that the tripwire alert goes dark when `BRAIN_NTFY_URL` is unset (it is set live, so there's no effect today). 7. **Risk label:** issue #157 is `risk/high`, PR is `risk/medium`. A security control on the brain should carry the issue's label. ### Rule 9 (test names) - `TestScanFollowsTheRootButNotEntries`: the test has no symlinked entries, so "ButNotEntries" is never asserted. Rename it (e.g. `...ResolvesASymlinkedRoot`) or add an entry symlink. - `TestScanFindsSymlinksAtAnyDepthAndSkipsGit`: "any depth" is contradicted by finding 4, and "SkipsGit" passes for a skip at any depth as well as a root-only skip. - `TestWatchNotifiesOncePerNewSymlinkSet`: uses only one set. Moving `last=` after the empty-check, which suppresses the alert when a set disappears and reappears, stayed **green**. ### Flip tests | Mutation | Suite | |---|---| | drop `.git` skip | red | | `.git` skip root-only | green (no test pins depth) | | drop root EvalSymlinks | red | | drop dedupe | red | | never call Notify | red | | suppress reappear notification | **green** | | startup: scan error refuses instead of nil | **green** (fail-open path is untested) | | remove both `startupSymlinkCheck` and `go Watch` from main.go | **green** (wiring unasserted) | ### What holds - The startup check runs before every brain reader (handler, MCP, graph backfill, vectorstore, claudewatcher). - Dangling symlinks, directory symlinks, and unicode/newline names are all reported. - A symlink inside a symlinked dir doesn't matter, because the dir link is reported. - The nil notifier is a true nil interface. - `context.Background()` is never cancelled, but it is one goroutine for the life of the process, so there is no leak. - Live deploy safety: `INGEST_BRAIN_DIR=/app/brain` is a hostPath of `/var/lib/supervisor/brain`, which has 0 symlinks, 0 find errors, 0 nested `.git`, and 0 dirs unreadable by uid 1000 (read-only check, 2026-10-03). ### Not checked - Behaviour against a real Syncthing peer (mode-000 dir propagation, symlink sync). - Hard links (out of scope; Syncthing syncs content, not links). - A TOCTOU plant during a walk. - Readers other than `search.Query`, which I did not execute (vectorstore, context, graph).
Author
Owner

pr-gatekeeper verdict, round 2: BLOCK (PR #159 @ 956ad8b)

Not merged. CI run 8895 is green and CI=1 task check EXIT=0. -race -count=5 is also green. All of the round-1 findings are fixed, and I verified each one by running it. One fail-open remains, and it is the iron-law pattern itself.

Finding (blocking)

ingestion/internal/symlinkguard/symlinkguard.go:74: _ = filepath.WalkDir(...) throws away the error when the brain root can't be listed, and Scan reports Clean. When ReadDir fails on the root, the callback returns that error (if p == root { return err }). WalkDir passes it back up, and _ = drops it, so Scan returns an empty Result with a nil error.

Repro (executed at 956ad8b):

  • Set up a brain whose root is mode 0100 (traversable but not listable), with a readable wiki/w/facts/leak.md -> outside.
  • startupSymlinkCheck returns err=<nil>, and search.Query returns the sentinel (hits=1).
  • The reason: search never lists the root. It joins knowledge/wiki and walks from there.
  • A root at mode 000 also passes startup (err=<nil>), though readers then see nothing.

Reachability: I don't think a Syncthing peer can change the folder root's mode (I have not verified that against Syncthing), so this most likely needs a writer local to koala. It is still a control reporting "clean" for a tree it never read. The fix is one line: return the WalkDir error from Scan, and let startup refuse on anything that isn't ErrNotExist.

Flipping startupSymlinkCheck to pass on any scan error stays green. Today the only error Scan can return is ErrNotExist, so the refusal branch is never exercised. The fix above plus a test with the root at 0100 closes both gaps.

Non-blocking

  • The suite does not check that the same set alerts again after it disappears and reappears. Removing reported = "" on a clean scan stays green. ...AgainForANewSet is honestly named (it tests a different set), so this is a gap, not a rule-9 issue. In round 1 I confirmed by running it that the behaviour works.
  • main.go wiring is untested (removing both calls stayed green in round 1). I don't consider this blocking here, provided hyperguild#154 covers this wiring class. It's worth a line on #154.
  • Both risk/high and risk/medium labels are on the PR (gitea-mcp#61).

Round-1 fixes: re-verified by execution

Round-1 finding Probe Result
1. A subdir at mode 000 let startup pass mode-000 dir plus a symlink Scan reports syml=1 unread=1, not Clean. Startup refuses.
2. Watch stayed silent on scan errors Watch run on an unreadable dir, and on a missing root 1 alert each
3. A failed alert was never retried notifier fails, then recovers 9 attempts while failing, then exactly 1 more after recovery, then quiet
4. .git was skipped at every depth nested wiki/w/facts/.git/leak.md reported. The root .git is still skipped.
5. An interval of 0 or less panicked Watch with an interval of 0 and of -5 no panic
6. Newline names name containing a newline quoted "wiki/a\nb.md"
new symlink planted while Watch's scans are running caught on the next tick (alerts=1). The window between scans is inherent and documented.

Flip tests

Mutation Suite
unreadable dir not recorded red (2 tests)
.git skipped at any depth red
no retry after a failed Notify red
Watch doesn't clamp the interval red
no reset on a clean scan (reappear suppressed) green
startup passes on any scan error green (see the blocking finding)

Deploy safety (read-only, re-checked now)

  • INGEST_BRAIN_DIR=/app/brain is a hostPath mount of /var/lib/supervisor/brain.
  • That dir has 0 symlinks, 0 dirs unreadable by uid 1000, and 0 find errors. The root is mode 755. The pod runs as uid 1000.

Not checked

  • A real Syncthing peer.
  • Running readers other than search (vectorstore, context, graph).
  • Hard links (out of scope).
## pr-gatekeeper verdict, round 2: **BLOCK** (PR #159 @ 956ad8b) Not merged. CI run 8895 is green and `CI=1 task check` EXIT=0. `-race -count=5` is also green. All of the round-1 findings are fixed, and I verified each one by running it. One fail-open remains, and it is the iron-law pattern itself. ### Finding (blocking) **`ingestion/internal/symlinkguard/symlinkguard.go:74`: `_ = filepath.WalkDir(...)` throws away the error when the brain root can't be listed, and Scan reports Clean.** When `ReadDir` fails on the root, the callback returns that error (`if p == root { return err }`). WalkDir passes it back up, and `_ =` drops it, so Scan returns an empty Result with a nil error. Repro (executed at 956ad8b): - Set up a brain whose root is mode 0100 (traversable but not listable), with a readable `wiki/w/facts/leak.md -> outside`. - `startupSymlinkCheck` returns `err=<nil>`, and `search.Query` returns the sentinel (hits=1). - The reason: search never lists the root. It joins `knowledge`/`wiki` and walks from there. - A root at mode 000 also passes startup (`err=<nil>`), though readers then see nothing. Reachability: I don't think a Syncthing peer can change the folder root's mode (I have not verified that against Syncthing), so this most likely needs a writer local to koala. It is still a control reporting "clean" for a tree it never read. The fix is one line: return the WalkDir error from Scan, and let startup refuse on anything that isn't `ErrNotExist`. Flipping `startupSymlinkCheck` to pass on *any* scan error stays **green**. Today the only error Scan can return is ErrNotExist, so the refusal branch is never exercised. The fix above plus a test with the root at 0100 closes both gaps. ### Non-blocking - The suite does not check that the *same* set alerts again after it disappears and reappears. Removing `reported = ""` on a clean scan stays green. `...AgainForANewSet` is honestly named (it tests a different set), so this is a gap, not a rule-9 issue. In round 1 I confirmed by running it that the behaviour works. - main.go wiring is untested (removing both calls stayed green in round 1). I don't consider this blocking here, provided hyperguild#154 covers this wiring class. It's worth a line on #154. - Both `risk/high` and `risk/medium` labels are on the PR (gitea-mcp#61). ### Round-1 fixes: re-verified by execution | Round-1 finding | Probe | Result | |---|---|---| | 1. A subdir at mode 000 let startup pass | mode-000 dir plus a symlink | Scan reports syml=1 unread=1, not Clean. Startup refuses. | | 2. Watch stayed silent on scan errors | Watch run on an unreadable dir, and on a missing root | 1 alert each | | 3. A failed alert was never retried | notifier fails, then recovers | 9 attempts while failing, then exactly 1 more after recovery, then quiet | | 4. `.git` was skipped at every depth | nested `wiki/w/facts/.git/leak.md` | reported. The root `.git` is still skipped. | | 5. An interval of 0 or less panicked | Watch with an interval of 0 and of -5 | no panic | | 6. Newline names | name containing a newline | quoted `"wiki/a\nb.md"` | | new | symlink planted while Watch's scans are running | caught on the next tick (alerts=1). The window between scans is inherent and documented. | ### Flip tests | Mutation | Suite | |---|---| | unreadable dir not recorded | red (2 tests) | | `.git` skipped at any depth | red | | no retry after a failed Notify | red | | Watch doesn't clamp the interval | red | | no reset on a clean scan (reappear suppressed) | **green** | | startup passes on any scan error | **green** (see the blocking finding) | ### Deploy safety (read-only, re-checked now) - `INGEST_BRAIN_DIR=/app/brain` is a hostPath mount of `/var/lib/supervisor/brain`. - That dir has 0 symlinks, 0 dirs unreadable by uid 1000, and 0 find errors. The root is mode 755. The pod runs as uid 1000. ### Not checked - A real Syncthing peer. - Running readers other than search (vectorstore, context, graph). - Hard links (out of scope).
Author
Owner

pr-gatekeeper verdict, round 3: PASS (PR #159 @ cda18e6). Not merged.

This is a verdict only. The PR is risk/high, so the merge is Mathias's call (CD deploys on merge).

Blocking finding from round 2: closed, verified by execution

Scan now returns the root's WalkDir error. Deeper errors are still recorded as Unreadable. Startup refuses on any error except ErrNotExist.

Probe (run at cda18e6, uid 1000) startup refuses?
root at mode 0100 plus a symlinked wiki/w/facts/leak.md (my round-2 repro; search would still serve it, hits=1) yes
root at mode 000 yes
subdir at mode 000 plus a symlink (search hits=1) yes
wiki/ at mode 0100 (can be entered, not listed) yes
symlink under a nested .git yes
missing brain dir no (passes, by design)
clean brain no (passes)

Flip tests: all red

Mutation Suite
discard the walk error again red (TestScanOfAnUnlistableRootIsAnError, TestStartupSymlinkCheckRefusesAnUnlistableBrainRoot)
record the root error as Unreadable instead of returning it red
startup passes on any scan error red
no reset after a clean scan red (TestWatchReportsASetAgainAfterItWasCleared)
.git skipped at any depth red

The round-2 flips (no unreadable recording, no retry, no interval clamp) were red at 956ad8b. The code they cover hasn't changed since.

Rule 9: I read each new test name against its assertions, and they match.

The Geteuid()==0 skips do not hide the tests in CI. The gitea-runner pod runs as uid 1000 with runAsNonRoot: true, so the permission tests actually run there.

CI and gate

  • Run 8896 on cda18e6: success.
  • Local CI=1 task check: EXIT=0, 0 FAILs.
  • -race -count=3 on both packages: green.

Deploy safety (read-only, re-checked now)

  • INGEST_BRAIN_DIR=/app/brain is a hostPath mount of /var/lib/supervisor/brain. The pod runs as uid 1000.
  • That dir has 0 symlinks, 0 dirs unreadable by uid 1000, and 0 find errors. The root is mode 755 and listable.
  • The rollout will not trip the check.

Residual, accepted or tracked

  • Window between scans: a symlink planted after a scan is live for up to the scan interval. Watch reports it but does not remove it. This is documented; the fix is #157 option 1.
  • main.go wiring is untested. This is now tracked on hyperguild#154 (comment 2026-10-03T15:15).
  • Stale risk/medium label is still on the PR (gitea-mcp#61).

Not checked

  • A real Syncthing peer.
  • Running readers other than search (vectorstore, context, graph).
  • Hard links (out of scope).
## pr-gatekeeper verdict, round 3: **PASS** (PR #159 @ cda18e6). Not merged. This is a verdict only. The PR is `risk/high`, so the merge is Mathias's call (CD deploys on merge). ### Blocking finding from round 2: closed, verified by execution `Scan` now returns the root's WalkDir error. Deeper errors are still recorded as Unreadable. Startup refuses on any error except ErrNotExist. | Probe (run at cda18e6, uid 1000) | startup refuses? | |---|---| | root at mode 0100 plus a symlinked `wiki/w/facts/leak.md` (my round-2 repro; search would still serve it, hits=1) | **yes** | | root at mode 000 | yes | | subdir at mode 000 plus a symlink (search hits=1) | yes | | `wiki/` at mode 0100 (can be entered, not listed) | yes | | symlink under a nested `.git` | yes | | missing brain dir | no (passes, by design) | | clean brain | no (passes) | ### Flip tests: all red | Mutation | Suite | |---|---| | discard the walk error again | red (`TestScanOfAnUnlistableRootIsAnError`, `TestStartupSymlinkCheckRefusesAnUnlistableBrainRoot`) | | record the root error as Unreadable instead of returning it | red | | startup passes on any scan error | red | | no reset after a clean scan | red (`TestWatchReportsASetAgainAfterItWasCleared`) | | `.git` skipped at any depth | red | The round-2 flips (no unreadable recording, no retry, no interval clamp) were red at 956ad8b. The code they cover hasn't changed since. **Rule 9:** I read each new test name against its assertions, and they match. **The `Geteuid()==0` skips do not hide the tests in CI.** The gitea-runner pod runs as uid 1000 with `runAsNonRoot: true`, so the permission tests actually run there. ### CI and gate - Run 8896 on cda18e6: success. - Local `CI=1 task check`: EXIT=0, 0 FAILs. - `-race -count=3` on both packages: green. ### Deploy safety (read-only, re-checked now) - `INGEST_BRAIN_DIR=/app/brain` is a hostPath mount of `/var/lib/supervisor/brain`. The pod runs as uid 1000. - That dir has 0 symlinks, 0 dirs unreadable by uid 1000, and 0 find errors. The root is mode 755 and listable. - The rollout will not trip the check. ### Residual, accepted or tracked - **Window between scans:** a symlink planted after a scan is live for up to the scan interval. Watch reports it but does not remove it. This is documented; the fix is #157 option 1. - **main.go wiring is untested.** This is now tracked on hyperguild#154 (comment 2026-10-03T15:15). - **Stale `risk/medium` label** is still on the PR (gitea-mcp#61). ### Not checked - A real Syncthing peer. - Running readers other than search (vectorstore, context, graph). - Hard links (out of scope).
Author
Owner

Tripwire half (option 2) shipped and verified live. PR #159 squash-merged as cad389f after 3 adversarial review rounds; Mathias approved the merge. CD rolled out ingestion:cad389f, the pod started (startup check passed: 0 symlinks, 0 unreadable dirs), the tripwire logged nothing, and the service answers.

What runs now:

  • At startup: ingestion refuses to start if any symlink or unreadable dir exists in the brain dir, or if the root can't be listed.
  • At runtime: symlinkguard.Watch rescans every BRAIN_SYMLINK_SCAN_INTERVAL seconds (default 300) and sends one ntfy alert per new finding, retried until it sends.

Still open here: option 1, brain-wide readers through os.Root (search, vectorstore, context, graph, frontmatterlint). Until then, a symlink planted between scans is live for up to 5 minutes; it's reported, not removed. The wiring test is tracked in #154.


Created via git-mcp on behalf of @mathias

**Tripwire half (option 2) shipped and verified live.** PR #159 squash-merged as `cad389f` after 3 adversarial review rounds; Mathias approved the merge. CD rolled out `ingestion:cad389f`, the pod started (startup check passed: 0 symlinks, 0 unreadable dirs), the tripwire logged nothing, and the service answers. What runs now: - **At startup:** ingestion refuses to start if any symlink or unreadable dir exists in the brain dir, or if the root can't be listed. - **At runtime:** `symlinkguard.Watch` rescans every `BRAIN_SYMLINK_SCAN_INTERVAL` seconds (default 300) and sends one ntfy alert per new finding, retried until it sends. **Still open here:** option 1, brain-wide readers through `os.Root` (search, vectorstore, context, graph, frontmatterlint). Until then, a symlink planted between scans is live for up to 5 minutes; it's reported, not removed. The wiring test is tracked in #154. --- _Created via git-mcp on behalf of @mathias_
Author
Owner

Decision (Mathias, 2026-10-03): both, auto-remove first.

  1. Now: the runtime tripwire unlinks any symlink it finds (the link only, never the target), scans every 30s by default instead of 5 min, and its alert names what it removed. This cuts the exposure window from "until a human removes it" to at most one scan interval.
  2. Then: option 1, brain-wide readers through os.Root, done incrementally as defense in depth. About 12 model-reachable readers: search, hydrate, vectorstore sync, graphsync, the context excerpt, ReadNote/brain_get, the wiki inventory, wing indexes, the ingest merge's reviseOrCreate, and the provenance re-stamp.

Created via git-mcp on behalf of @mathias

**Decision (Mathias, 2026-10-03):** both, auto-remove first. 1. **Now:** the runtime tripwire unlinks any symlink it finds (the link only, never the target), scans every 30s by default instead of 5 min, and its alert names what it removed. This cuts the exposure window from "until a human removes it" to at most one scan interval. 2. **Then:** option 1, brain-wide readers through `os.Root`, done incrementally as defense in depth. About 12 model-reachable readers: search, hydrate, vectorstore sync, graphsync, the context excerpt, ReadNote/brain_get, the wiki inventory, wing indexes, the ingest merge's reviseOrCreate, and the provenance re-stamp. --- _Created via git-mcp on behalf of @mathias_
Author
Owner

pr-gatekeeper verdict: BLOCK (PR #160 @ 504feb3). Not merged.

The removal itself is correct, and I verified that by execution (table below). Two things block. CI is red, and the alert path can be wedged by the very party it is meant to report. One claimed guard also has no assertion.

Blocking

  1. CI run 8912 is red.

    • The failure is TestRawReadersLeakNothingUnderASymlinkSwapRace, from #156, not this PR.
    • Its assertion passed (0 leaks). The t.TempDir cleanup failed with "directory not empty" because the test never waits for its swapper goroutine.
    • Red is still an automatic block. Filed as #162 with the cause and the fix.
    • Locally CI=1 task check was EXIT=0, so this is a flake, but it is the estate's gate.
  2. symlinkguard.go Watch/pending: the alert queue is unbounded, and one rejected message wedges it permanently.

    • The paths in the alert are chosen by whoever plants the links, and nothing bounds them.
    • The message is a single string with no limit on path count or length.
    • Notify is retried head-first with break. A message the server will never accept therefore blocks every later alert forever, while pending keeps growing by one per removal event.
    • Repro (executed): plant 2000 symlinks with 200-byte names, using a notifier that rejects bodies over a size limit, as a stand-in for ntfy's limits. All links were removed, but delivered=0, including 20 later, ordinary single-link incidents; rejected attempts=70.
    • Live ntfy (v2.11) has attachments enabled. Bodies over 4 KB become attachments (an alert of about 30 paths already arrives as "attachment.txt"), and bodies over the attachment size limit are refused.
    • Separately, an ntfy outage with re-planting queued 300 messages, then flushed all 300 at once on recovery. At 30 s that is 2880 messages per outage-day, inside a pod limited to 128Mi.
    • Fix (a correction):
      • Cap the paths per message (for example, the first 10 plus "and N more").
      • Bound the queue by coalescing it into a single "N removal events, M paths since " summary when it exceeds a small cap.
      • Optionally, truncate or drop a message the server rejects with a 4xx, rather than retrying it forever.
  3. symlinkguard.go removeSymlinks: the "still a symlink" Lstat re-check has no test.

    • The PR body names this re-check as the guard against deleting a regular file.
    • Replacing the check with fi == nil keeps the suite green.
    • Routing removal through os.RemoveAll(filepath.Join(resolved, rel)) instead of root.Remove also keeps it green, so the os.Root confinement is unasserted too.
    • A direct removeSymlinks test fixes both. It should pass a regular file, a path that goes through an in-root dir symlink, and a ../ path, and assert that each is reported failed and nothing was deleted.

Verified by execution (holds)

Case Result
File target outside the brain link removed, target intact
Dir target outside the brain, plus a symlink inside that dir link removed; dir contents and the inner link (outside the brain) intact
In-brain targets: file, dir, a link-to-link chain, ../../.. all links removed, every regular file intact; 0 symlinks remain
Dangling link removed
removeSymlinks called directly on a regular file, a path through an in-root dir link, a ../ escape, a missing path all reported failed, nothing deleted
Link under an unreadable (0100) dir not reachable; alerted once as an unreadable dir
Re-planting every tick (ntfy up) 98 alerts over about 100 ticks, one per removal event; no duplicate alert for a state that persists

Swap race (Lstat to Remove): I accept it. os.Root confines removal to inside the brain, and the only file that can be hit is the one at the very path the peer just replaced. A sendreceive peer can already delete any brain file outright by syncing a deletion. The race gives a remote party nothing it doesn't already have.

Non-blocking, for Mathias's attention

  • The removal propagates. The brain is a sendreceive folder, so unlinking on koala syncs the deletion to flamingo. A deliberate symlink there (an Obsidian or iCloud link) gets deleted on flamingo too. It is link only, never the target. That follows from the auto-remove decision, but the decision record does not state it.
  • Risk label: risk/medium, while #157 is risk/high and this code deletes files on a shared folder. #159 ended up risk/high.
  • The ntfy Title header is hard-coded to "brain-capture audit degraded", so symlink alerts arrive under that title (pre-existing in audit/ntfy.go).
  • A link that disappears between Scan and Lstat is reported as "failed", which gives one spurious persistent alert.
  • Rule 9: TestWatchRemovesSymlinksButNeverTheirTargetsOrRegularFiles checks one outside file target. Dir targets and in-brain targets are not asserted. They hold by execution (above), but the name is broader than what the test proves.

Flip tests

Mutation Suite
no removal alert queued red (4 tests)
drop a message on notify failure red
drop the Lstat symlink re-check green
os.RemoveAll outside os.Root green
drop the rest of the queue after the first send green

Deploy safety (read-only, re-checked)

  • Live /var/lib/supervisor/brain: 0 symlinks, 0 dirs unreadable by uid 1000, 0 find errors. Rollout removes nothing.
  • No BRAIN_SYMLINK_SCAN_INTERVAL is set on the deployment, so the new 30 s default applies.

Not checked

  • A real Syncthing peer, including the deletion propagating to flamingo.
  • Live ntfy's actual reject threshold (inferred from config; I did not post to it).
  • Readers other than search.
## pr-gatekeeper verdict: **BLOCK** (PR #160 @ 504feb3). Not merged. The removal itself is correct, and I verified that by execution (table below). Two things block. CI is red, and the alert path can be wedged by the very party it is meant to report. One claimed guard also has no assertion. ### Blocking 1. **CI run 8912 is red.** - The failure is `TestRawReadersLeakNothingUnderASymlinkSwapRace`, from #156, not this PR. - Its assertion passed (0 leaks). The `t.TempDir` cleanup failed with "directory not empty" because the test never waits for its swapper goroutine. - Red is still an automatic block. Filed as **#162** with the cause and the fix. - Locally `CI=1 task check` was EXIT=0, so this is a flake, but it is the estate's gate. 2. **`symlinkguard.go` Watch/`pending`: the alert queue is unbounded, and one rejected message wedges it permanently.** - The paths in the alert are chosen by whoever plants the links, and nothing bounds them. - The message is a single string with no limit on path count or length. - Notify is retried head-first with `break`. A message the server will never accept therefore blocks every later alert forever, while `pending` keeps growing by one per removal event. - Repro (executed): plant 2000 symlinks with 200-byte names, using a notifier that rejects bodies over a size limit, as a stand-in for ntfy's limits. All links were removed, but **delivered=0**, including 20 later, ordinary single-link incidents; rejected attempts=70. - Live ntfy (v2.11) has attachments enabled. Bodies over 4 KB become attachments (an alert of about 30 paths already arrives as "attachment.txt"), and bodies over the attachment size limit are refused. - Separately, an ntfy outage with re-planting queued 300 messages, then flushed all 300 at once on recovery. At 30 s that is 2880 messages per outage-day, inside a pod limited to 128Mi. - Fix (a correction): - Cap the paths per message (for example, the first 10 plus "and N more"). - Bound the queue by coalescing it into a single "N removal events, M paths since <t>" summary when it exceeds a small cap. - Optionally, truncate or drop a message the server rejects with a 4xx, rather than retrying it forever. 3. **`symlinkguard.go` removeSymlinks: the "still a symlink" Lstat re-check has no test.** - The PR body names this re-check as the guard against deleting a regular file. - Replacing the check with `fi == nil` keeps the suite green. - Routing removal through `os.RemoveAll(filepath.Join(resolved, rel))` instead of `root.Remove` also keeps it green, so the `os.Root` confinement is unasserted too. - A direct `removeSymlinks` test fixes both. It should pass a regular file, a path that goes through an in-root dir symlink, and a `../` path, and assert that each is reported failed and nothing was deleted. ### Verified by execution (holds) | Case | Result | |---|---| | File target outside the brain | link removed, target intact | | Dir target outside the brain, plus a symlink inside that dir | link removed; dir contents and the inner link (outside the brain) intact | | In-brain targets: file, dir, a link-to-link chain, `../../..` | all links removed, every regular file intact; 0 symlinks remain | | Dangling link | removed | | `removeSymlinks` called directly on a regular file, a path through an in-root dir link, a `../` escape, a missing path | all reported failed, nothing deleted | | Link under an unreadable (0100) dir | not reachable; alerted once as an unreadable dir | | Re-planting every tick (ntfy up) | 98 alerts over about 100 ticks, one per removal event; no duplicate alert for a state that persists | **Swap race (Lstat to Remove):** I accept it. `os.Root` confines removal to inside the brain, and the only file that can be hit is the one at the very path the peer just replaced. A sendreceive peer can already delete any brain file outright by syncing a deletion. The race gives a remote party nothing it doesn't already have. ### Non-blocking, for Mathias's attention - **The removal propagates.** The brain is a sendreceive folder, so unlinking on koala syncs the deletion to flamingo. A deliberate symlink there (an Obsidian or iCloud link) gets deleted on flamingo too. It is link only, never the target. That follows from the auto-remove decision, but the decision record does not state it. - **Risk label:** risk/medium, while #157 is risk/high and this code deletes files on a shared folder. #159 ended up risk/high. - The ntfy `Title` header is hard-coded to "brain-capture audit degraded", so symlink alerts arrive under that title (pre-existing in `audit/ntfy.go`). - A link that disappears between Scan and Lstat is reported as "failed", which gives one spurious persistent alert. - Rule 9: `TestWatchRemovesSymlinksButNeverTheirTargetsOrRegularFiles` checks one outside file target. Dir targets and in-brain targets are not asserted. They hold by execution (above), but the name is broader than what the test proves. ### Flip tests | Mutation | Suite | |---|---| | no removal alert queued | red (4 tests) | | drop a message on notify failure | red | | drop the Lstat symlink re-check | **green** | | `os.RemoveAll` outside `os.Root` | **green** | | drop the rest of the queue after the first send | **green** | ### Deploy safety (read-only, re-checked) - Live `/var/lib/supervisor/brain`: 0 symlinks, 0 dirs unreadable by uid 1000, 0 find errors. Rollout removes nothing. - No `BRAIN_SYMLINK_SCAN_INTERVAL` is set on the deployment, so the new 30 s default applies. ### Not checked - A real Syncthing peer, including the deletion propagating to flamingo. - Live ntfy's actual reject threshold (inferred from config; I did not post to it). - Readers other than search.
Author
Owner

One consequence of the auto-remove decision that the record above didn't spell out (from the PR #160 review): the brain dir is a Syncthing sendreceive folder, so a symlink the tripwire removes on koala is also deleted on flamingo. Only the link is deleted, never its target. That follows from treating any symlink in the brain as an incident. If one were ever placed there on purpose on flamingo, it will disappear on both sides within one scan interval.


Created via git-mcp on behalf of @mathias

One consequence of the auto-remove decision that the record above didn't spell out (from the PR #160 review): the brain dir is a Syncthing **sendreceive** folder, so a symlink the tripwire removes on koala is also deleted on flamingo. Only the link is deleted, never its target. That follows from treating any symlink in the brain as an incident. If one were ever placed there on purpose on flamingo, it will disappear on both sides within one scan interval. --- _Created via git-mcp on behalf of @mathias_
Author
Owner

Gatekeeper review of PR #161 (slice 1, search, head 9c9ba31): BLOCK. Details are on the PR.

Symlinks at every depth, symlinked wing and hall filters, and symlinked wiki/ and knowledge/ roots all returned 0 results. hydrate under a swap race leaked 0 times. Blocking: the type check on the opened handle in brainfs.ReadRegular has no test, and it is load-bearing. Without it, a swapped-in FIFO whose writer is held open hangs Query after 16 iterations, and the suite stays green. Query is about +10 ms per call at the live brain size.

Gatekeeper review of PR #161 (slice 1, search, head 9c9ba31): **BLOCK**. Details are on the PR. Symlinks at every depth, symlinked wing and hall filters, and symlinked wiki/ and knowledge/ roots all returned 0 results. hydrate under a swap race leaked 0 times. Blocking: the type check on the opened handle in `brainfs.ReadRegular` has no test, and it is load-bearing. Without it, a swapped-in FIFO whose writer is held open hangs Query after 16 iterations, and the suite stays green. Query is about +10 ms per call at the live brain size.
Author
Owner

pr-gatekeeper verdict, round 2: BLOCK (PR #160 @ 01de163). Not merged.

Round-1 findings 1, 3 and 4 are closed, and I verified each by execution. CI run 8916 is green. The fix for finding 2 introduces a new silent loss: an ntfy outage longer than about 2.5 minutes loses alerts without any trace in the alert channel. That is the same class as #159 round-1 finding 3.

Blocking

symlinkguard.go alertQueue.flush: the drop-after-5-attempts rule can't tell a poison message from an outage, and drops are never counted.

  • When ntfy is down, each tick tries the head message once. After maxAttempts (5) ticks the head is dropped, then the next message, and so on.
  • At the 30 s default, an outage longer than 2.5 minutes, such as an ntfy pod restart or a node blip, discards the oldest alerts.
  • A drop only increments a log line. It does not increment suppressed, so the recovery summary undercounts and says nothing about the drops.
  • The summary is itself a queued item, so it can be dropped after 5 failures too.

Repro (executed at 01de163):

  • Long outage, single incident: plant one link, keep the notifier down for about 30 ticks (15 minutes at the default), then recover. The link is removed, and delivered=0, msgs=[]. The only incident is never reported, and nothing says an alert was lost.
  • Outage with re-planting: 300 plants, then recovery. 6 messages delivered. The summary says "221 further alert(s) were suppressed", so 74 removal events are missing even from the count.

The Watch doc comment still says "an outage delays them but loses none", which is now false. TestWatchDeliversEveryQueuedAlertAfterAnOutage passes only because its outage is shorter than 5 ticks. Rule 9: the name claims "every" alert after "an outage", but the test only proves it for outages under 5 attempts.

Fix (a correction):

  • Count every drop into suppressed.
  • Keep the summary as a counter that is retried every tick and never dropped. Don't make it a queued item.
  • Correct the doc comment.
  • Add a test with an outage longer than maxAttempts ticks that asserts the summary reports the dropped count.

Optionally, drop only on a permanent 4xx and keep retrying on transport errors or 5xx. That would need Notify to expose the status code.

Verified by execution (holds)

Check Result
2000-link poison repro (200-byte names, notifier rejects bodies over 4096) all links removed; 21 delivered, 0 rejected; largest message 2217 bytes. The 20 later incidents all flowed.
Queue bound during an outage 6 delivered for 300 plants: 5 queued plus the summary. Bounded, but the count is wrong (above).
Removal guards TestRemoveSymlinksRefusesNonSymlinksEscapesAndPathsThroughLinks covers a regular file, a path through an in-brain dir link, a ../ escape and a missing path
#162 deflake -race -count=20 on the api and watcher race tests, and -race -count=30 on symlinkguard: all green. Swappers are joined in a t.Cleanup that runs before TempDir removal.
Gate CI=1 task check EXIT=0, 0 FAILs
Deploy safety the live brain has 0 symlinks and 0 unreadable dirs

On the RemoveAll question: I agree that a dir swapped in after the Lstat can only be reached through a race, and that root.Remove refuses a non-empty dir. The code comment is enough; I don't need a race test.

Flip tests

Mutation Suite
never drop the poison message red
unbounded queue red
no path cap red
no suppressed summary red
drop the Lstat check (round-1 gap) red, per the author; covered by the new direct test
drops not counted in the summary no test exists; the behaviour is absent

Not checked

  • A real Syncthing peer.
  • Live ntfy's actual reject threshold.
  • Readers other than search.
## pr-gatekeeper verdict, round 2: **BLOCK** (PR #160 @ 01de163). Not merged. Round-1 findings 1, 3 and 4 are closed, and I verified each by execution. CI run 8916 is green. The fix for finding 2 introduces a new silent loss: **an ntfy outage longer than about 2.5 minutes loses alerts without any trace in the alert channel.** That is the same class as #159 round-1 finding 3. ### Blocking **`symlinkguard.go` `alertQueue.flush`: the drop-after-5-attempts rule can't tell a poison message from an outage, and drops are never counted.** - When ntfy is down, each tick tries the head message once. After `maxAttempts` (5) ticks the head is dropped, then the next message, and so on. - At the 30 s default, an outage longer than 2.5 minutes, such as an ntfy pod restart or a node blip, discards the oldest alerts. - A drop only increments a log line. It does not increment `suppressed`, so the recovery summary undercounts and says nothing about the drops. - The summary is itself a queued item, so it can be dropped after 5 failures too. Repro (executed at 01de163): - **Long outage, single incident:** plant one link, keep the notifier down for about 30 ticks (15 minutes at the default), then recover. The link is removed, and **delivered=0, msgs=[]**. The only incident is never reported, and nothing says an alert was lost. - **Outage with re-planting:** 300 plants, then recovery. 6 messages delivered. The summary says "221 further alert(s) were suppressed", so **74 removal events are missing even from the count.** The Watch doc comment still says "an outage delays them but loses none", which is now false. `TestWatchDeliversEveryQueuedAlertAfterAnOutage` passes only because its outage is shorter than 5 ticks. Rule 9: the name claims "every" alert after "an outage", but the test only proves it for outages under 5 attempts. Fix (a correction): - Count every drop into `suppressed`. - Keep the summary as a counter that is retried every tick and never dropped. Don't make it a queued item. - Correct the doc comment. - Add a test with an outage longer than `maxAttempts` ticks that asserts the summary reports the dropped count. Optionally, drop only on a permanent 4xx and keep retrying on transport errors or 5xx. That would need Notify to expose the status code. ### Verified by execution (holds) | Check | Result | |---|---| | 2000-link poison repro (200-byte names, notifier rejects bodies over 4096) | all links removed; **21 delivered, 0 rejected**; largest message 2217 bytes. The 20 later incidents all flowed. | | Queue bound during an outage | 6 delivered for 300 plants: 5 queued plus the summary. Bounded, but the count is wrong (above). | | Removal guards | `TestRemoveSymlinksRefusesNonSymlinksEscapesAndPathsThroughLinks` covers a regular file, a path through an in-brain dir link, a `../` escape and a missing path | | #162 deflake | `-race -count=20` on the api and watcher race tests, and `-race -count=30` on symlinkguard: all green. Swappers are joined in a `t.Cleanup` that runs before TempDir removal. | | Gate | `CI=1 task check` EXIT=0, 0 FAILs | | Deploy safety | the live brain has 0 symlinks and 0 unreadable dirs | On the RemoveAll question: I agree that a dir swapped in after the Lstat can only be reached through a race, and that `root.Remove` refuses a non-empty dir. The code comment is enough; I don't need a race test. ### Flip tests | Mutation | Suite | |---|---| | never drop the poison message | red | | unbounded queue | red | | no path cap | red | | no suppressed summary | red | | drop the Lstat check (round-1 gap) | red, per the author; covered by the new direct test | | drops not counted in the summary | **no test exists**; the behaviour is absent | ### Not checked - A real Syncthing peer. - Live ntfy's actual reject threshold. - Readers other than search.
Author
Owner

Gatekeeper round 2 on PR #161 (head ffe46c7): BLOCK. Details are on the PR.

The unit tests now pin readOpened, Rel and WalkRegular. Remaining: nothing checks that ReadRegular goes through readOpened. Bypassing it keeps the suite green and brings the held-writer FIFO hang back in Query (5 iterations). Fix: have the search FIFO swapper hold a writer open.

Gatekeeper round 2 on PR #161 (head ffe46c7): **BLOCK**. Details are on the PR. The unit tests now pin readOpened, Rel and WalkRegular. Remaining: nothing checks that `ReadRegular` goes through `readOpened`. Bypassing it keeps the suite green and brings the held-writer FIFO hang back in Query (5 iterations). Fix: have the search FIFO swapper hold a writer open.
Author
Owner

pr-gatekeeper verdict, round 3: PASS (PR #160 @ 4ba9d20). Not merged.

This is a verdict only. The PR is risk/high and CD deploys on merge, so the merge is Mathias's call.

Round-2 blocker: closed, verified by execution

Probe Result
Long outage, a single incident, ~100 ticks down, with 503 delivered after recovery (1 message, names only.md)
Same, with 429 delivered after recovery; nothing dropped
300 plants during a 503 outage 5 removal messages delivered + 295 counted in the summary = 300
300 plants during a 429 storm 5 + 295 = 300
Poison message: 2000 long-named links against a notifier limit forced down to 1000 bytes, so the alert gets a permanent 413 dropped; the summary says "1 further alert(s) were suppressed or undeliverable", and the next incident (s.md) is delivered

At the real ntfy limits, the capped alert (largest 2217 bytes in round 2) does not get a 413 at all.

Flip tests: all red

Mutation Suite
never drop a permanent rejection red (TestWatchDropsAPoisonMessageAndCountsIt)
drop on any failure red (...AfterAShortOutage, ...LosesNoAlertThroughALongOutage)
a permanent drop is not counted red
429 classified as permanent red (TestNotifyClassifiesRejectionsAsPermanentOrNot)
ntfy returns an untyped error red (same)
the summary is dropped when its send fails red (...QueueIsBoundedDuringAnOutage, ...AccountsForEveryRemovalDuringAnOutage)

Rule 9: I read the new and renamed test names against their assertions, and they match.

Gate and CI

  • CI run 8919 on 4ba9d20: success.
  • Local CI=1 task check: EXIT=0, 0 FAILs.
  • -race -count=10 on symlinkguard and audit: green.

Deploy safety (read-only, re-checked)

  • Live /var/lib/supervisor/brain: 0 symlinks, 0 unreadable dirs, 0 find errors. Rollout removes nothing.
  • The 30 s default applies, since no override is set on the deployment.

Non-blocking, filed

#164: a hung ntfy delays removal.

  • Flush runs in the scan goroutine, and it tries every queued message plus the summary, each with a 10 s HTTP timeout.
  • So an ntfy that accepts connections but never answers can stall scanning for about 60 s, and a link then lives about 90 s instead of 30 s.
  • Scaled repro (executed): interval 50 ms, notifier latency 40 ms, queue full. The worst link lifetime was 244 ms.
  • It is bounded and needs a hung connection rather than a refusal, so I don't block on it. The fix is a correction: stop the flush at the first transient failure, or deliver from a separate goroutine.

Other open items: the ntfy title header predates this PR (the author will file it), and #157 records that deletions propagate to flamingo.

Not checked

  • A real Syncthing peer.
  • Live ntfy's actual thresholds (I did not post to it).
  • Readers other than search.
## pr-gatekeeper verdict, round 3: **PASS** (PR #160 @ 4ba9d20). Not merged. This is a verdict only. The PR is risk/high and CD deploys on merge, so the merge is Mathias's call. ### Round-2 blocker: closed, verified by execution | Probe | Result | |---|---| | Long outage, a single incident, ~100 ticks down, with **503** | delivered after recovery (1 message, names `only.md`) | | Same, with **429** | delivered after recovery; nothing dropped | | 300 plants during a 503 outage | 5 removal messages delivered + 295 counted in the summary = **300** | | 300 plants during a 429 storm | 5 + 295 = **300** | | Poison message: 2000 long-named links against a notifier limit forced down to 1000 bytes, so the alert gets a permanent 413 | dropped; the summary says "1 further alert(s) were suppressed or undeliverable", and the next incident (`s.md`) is delivered | At the real ntfy limits, the capped alert (largest 2217 bytes in round 2) does not get a 413 at all. ### Flip tests: all red | Mutation | Suite | |---|---| | never drop a permanent rejection | red (`TestWatchDropsAPoisonMessageAndCountsIt`) | | drop on any failure | red (`...AfterAShortOutage`, `...LosesNoAlertThroughALongOutage`) | | a permanent drop is not counted | red | | 429 classified as permanent | red (`TestNotifyClassifiesRejectionsAsPermanentOrNot`) | | ntfy returns an untyped error | red (same) | | the summary is dropped when its send fails | red (`...QueueIsBoundedDuringAnOutage`, `...AccountsForEveryRemovalDuringAnOutage`) | **Rule 9:** I read the new and renamed test names against their assertions, and they match. ### Gate and CI - CI run 8919 on 4ba9d20: success. - Local `CI=1 task check`: EXIT=0, 0 FAILs. - `-race -count=10` on symlinkguard and audit: green. ### Deploy safety (read-only, re-checked) - Live `/var/lib/supervisor/brain`: 0 symlinks, 0 unreadable dirs, 0 find errors. Rollout removes nothing. - The 30 s default applies, since no override is set on the deployment. ### Non-blocking, filed **#164: a hung ntfy delays removal.** - Flush runs in the scan goroutine, and it tries every queued message plus the summary, each with a 10 s HTTP timeout. - So an ntfy that accepts connections but never answers can stall scanning for about 60 s, and a link then lives about 90 s instead of 30 s. - Scaled repro (executed): interval 50 ms, notifier latency 40 ms, queue full. The worst link lifetime was 244 ms. - It is bounded and needs a hung connection rather than a refusal, so I don't block on it. The fix is a correction: stop the flush at the first transient failure, or deliver from a separate goroutine. Other open items: the ntfy title header predates this PR (the author will file it), and #157 records that deletions propagate to flamingo. ### Not checked - A real Syncthing peer. - Live ntfy's actual thresholds (I did not post to it). - Readers other than search.
Author
Owner

Gatekeeper round 3 on PR #161 (slice 1, head fd33e04): PASS. Evidence is on the PR. Not merged, verdict only.

The readOpened bypass is now caught 10/10. No flakiness (140 runs, plus -race). No fd leak across tests, and the test is still effective at a 1024 fd limit.

Gatekeeper round 3 on PR #161 (slice 1, head fd33e04): **PASS**. Evidence is on the PR. Not merged, verdict only. The readOpened bypass is now caught 10/10. No flakiness (140 runs, plus -race). No fd leak across tests, and the test is still effective at a 1024 fd limit.
Author
Owner

Shipped and verified live (Mathias approved both merges): ingestion:eb77305 is rolled out and healthy, the tripwire logged nothing, and a live brain_query returns results through brainfs.

  • #160 (c986df6), 3 review rounds:
    • The runtime tripwire now removes any symlink it finds (link only; deletions propagate to flamingo), and scans every 30 s.
    • Alerts are bounded: 10 paths, 5 queued. A message is dropped only on a permanent rejection (ntfy 4xx except 429); outages are retried indefinitely; every removal is delivered or counted in a never-dropped summary.
  • #161 (eb77305), 3 review rounds: option 1 slice 1.
    • The new internal/brainfs package (os.Root, regular files only, non-blocking open plus handle check) is used by search.Query and hydrate.
    • This also fixed a live FIFO hang in search.

Still open here: option 1, remaining slices (vectorstore sync, the context excerpt, ReadNote/brain_get, graphsync, the wiki inventory, wing indexes, the ingest merge's reviseOrCreate, and the provenance re-stamp). Budget about +10 ms per full brain walk. Related: #164 (a hung ntfy can stall removal to about 90 s), #163 (ntfy title), #154 (wiring test), #158 (writes under raw/).


Created via git-mcp on behalf of @mathias

**Shipped and verified live** (Mathias approved both merges): `ingestion:eb77305` is rolled out and healthy, the tripwire logged nothing, and a live `brain_query` returns results through `brainfs`. - **#160 (`c986df6`)**, 3 review rounds: - The runtime tripwire now **removes** any symlink it finds (link only; deletions propagate to flamingo), and scans every **30 s**. - Alerts are bounded: 10 paths, 5 queued. A message is dropped only on a permanent rejection (ntfy 4xx except 429); outages are retried indefinitely; every removal is delivered or counted in a never-dropped summary. - **#161 (`eb77305`)**, 3 review rounds: option 1 slice 1. - The new `internal/brainfs` package (os.Root, regular files only, non-blocking open plus handle check) is used by `search.Query` and `hydrate`. - This also fixed a live FIFO hang in search. **Still open here:** option 1, remaining slices (vectorstore sync, the context excerpt, ReadNote/brain_get, graphsync, the wiki inventory, wing indexes, the ingest merge's reviseOrCreate, and the provenance re-stamp). Budget about +10 ms per full brain walk. Related: #164 (a hung ntfy can stall removal to about 90 s), #163 (ntfy title), #154 (wiring test), #158 (writes under raw/). --- _Created via git-mcp on behalf of @mathias_
Author
Owner

Gatekeeper review of PR #165 (slice 2: readExcerpt, ReadNote, ReadScoped; head b6f7dac): BLOCK. Details are on the PR.

Confinement held: every reader flip goes red, and there is no flakiness at -count=10. Blocking:
(1) ReadRegularMax checks the size on the handle but then reads without a bound. A file growing in place returned 100 MiB against a 1 MiB limit. Dropping the limit from readContained also stays green.
(2) Rule 9: the three tests are named ...IsConfinedTo..., a goal rather than a mechanism, and the ReadNote test has no positive control.
The brain_edit write path belongs with #158.

Gatekeeper review of PR #165 (slice 2: readExcerpt, ReadNote, ReadScoped; head b6f7dac): **BLOCK**. Details are on the PR. Confinement held: every reader flip goes red, and there is no flakiness at -count=10. Blocking: (1) `ReadRegularMax` checks the size on the handle but then reads without a bound. A file growing in place returned 100 MiB against a 1 MiB limit. Dropping the limit from `readContained` also stays green. (2) Rule 9: the three tests are named `...IsConfinedTo...`, a goal rather than a mechanism, and the ReadNote test has no positive control. The brain_edit write path belongs with #158.
Author
Owner

Gatekeeper round 2 on PR #165 (slice 2, head 02cecb2): PASS. Evidence is on the PR. Not merged, verdict only.

A file growing during the read now returns 0 bytes over the limit (round 1: 100 MiB). Dropping the limit at readContained goes red. The test names are honest. CI run 9017 is green.

Gatekeeper round 2 on PR #165 (slice 2, head 02cecb2): **PASS**. Evidence is on the PR. Not merged, verdict only. A file growing during the read now returns 0 bytes over the limit (round 1: 100 MiB). Dropping the limit at readContained goes red. The test names are honest. CI run 9017 is green.
Author
Owner

Slice 2 shipped, v0.21.1 (PR #165, squash 27a01dd). readExcerpt (brain_context), ReadNote (brain_get) and ReadScoped (brain_read) now read through brainfs.

  • ReadRegularMax bounds the read itself with LimitReader(max+1). Review round 1 BLOCKED on an unbounded read past the stat check; round 2 PASS.
  • Deploy check: CD run 9053 succeeded, and the pod runs ingestion:27a01dd. Live smoke test: brain_context, brain_get and brain_read all return wiki/entities/hyperguild.md.

Slice 3 is in review: PR #166 (vectorstore.Sync via brainfs). Symlinked notes were embedded into pgvector. Their chunks are purged on the first sync after the deploy.

Remaining readers (from a grep of os.ReadFile/WalkDir on main):

  • graphsync
  • wiki/inventory.go and wiki/index.go
  • brain/index.go, tunnel.go and linkcheck.go
  • pipeline (reviseOrCreate, refs, backfill, schema)
  • provenance.ApplyFile
  • frontmatter/scan.go
  • classification.yaml
  • api/update.go:98/181 (write path, overlaps #158)

Non-blocking from review: brainfs.wrapAfterStat is a package-global test seam, so brainfs tests must not use t.Parallel. I'll add a comment in the next brainfs touch.


Created via git-mcp on behalf of @mathias

**Slice 2 shipped, v0.21.1** (PR #165, squash 27a01dd). readExcerpt (brain_context), ReadNote (brain_get) and ReadScoped (brain_read) now read through brainfs. - `ReadRegularMax` bounds the read itself with `LimitReader(max+1)`. Review round 1 BLOCKED on an unbounded read past the stat check; round 2 PASS. - **Deploy check:** CD run 9053 succeeded, and the pod runs `ingestion:27a01dd`. Live smoke test: brain_context, brain_get and brain_read all return `wiki/entities/hyperguild.md`. **Slice 3 is in review:** PR #166 (vectorstore.Sync via brainfs). Symlinked notes were embedded into pgvector. Their chunks are purged on the first sync after the deploy. **Remaining readers** (from a grep of `os.ReadFile`/`WalkDir` on main): - graphsync - wiki/inventory.go and wiki/index.go - brain/index.go, tunnel.go and linkcheck.go - pipeline (reviseOrCreate, refs, backfill, schema) - provenance.ApplyFile - frontmatter/scan.go - classification.yaml - api/update.go:98/181 (write path, overlaps #158) **Non-blocking from review:** `brainfs.wrapAfterStat` is a package-global test seam, so brainfs tests must not use `t.Parallel`. I'll add a comment in the next brainfs touch. --- _Created via git-mcp on behalf of @mathias_
Author
Owner

Slice 3 merged (PR #166, squash 57d0fb0). vectorstore.Sync now goes through brainfs. Adversarial review rounds: BLOCKED, BLOCKED, PASS. The full reviews are on the PR (review ids 1–3).

  • Round 1 BLOCKED. fs.WalkDir stats its root with a follow, and os.Root allows in-brain symlinks. So knowledge -> . aliased raw/, sessions/ and root files into the embeddings, which main had not done. Fix: WalkRegular Lstat-checks its root, and Sync aborts before the delete pass on any walk error other than a missing scan root.
  • Round 2 BLOCKED. The Lstat covered only the last component, so search with Wing+Hall walked a symlinked <wing>. Fix: realDir checks every prefix. This also closes the same gap in search's walk from slice 1.
  • Round 3 PASS. Static probes of both callers return 0 hits, races aimed outside the brain return 0 leaks, and a real brain gives 1532/1532 parents and 27/27 Wing+Hall hits.

Accepted residual (Mathias, 2026-10-04): the directory-swap race is tracked in #167, with resolution triggers. A directory swapped for an in-brain symlink between the walk's listing and the read is still followed. The WalkRegular doc states this. The other accepted residual is hard links.

Non-blocking items left:

  • The new search tests have no positive control of their own.
  • "is not a directory: not a regular file" reads oddly; an ErrNotDir sentinel would be clearer.

I'll pick both up the next time I touch brainfs.

Next slices:

  • graphsync
  • wiki inventory/index
  • brain index/tunnel/linkcheck
  • pipeline
  • provenance.ApplyFile
  • frontmatter scan
  • classification.yaml

Created via git-mcp on behalf of @mathias

**Slice 3 merged** (PR #166, squash 57d0fb0). vectorstore.Sync now goes through brainfs. Adversarial review rounds: BLOCKED, BLOCKED, PASS. The full reviews are on the PR (review ids 1–3). - **Round 1 BLOCKED.** `fs.WalkDir` stats its root with a follow, and os.Root allows in-brain symlinks. So `knowledge -> .` aliased `raw/`, `sessions/` and root files into the embeddings, which main had not done. Fix: WalkRegular Lstat-checks its root, and Sync aborts before the delete pass on any walk error other than a missing scan root. - **Round 2 BLOCKED.** The Lstat covered only the last component, so search with Wing+Hall walked a symlinked `<wing>`. Fix: `realDir` checks every prefix. This also closes the same gap in search's walk from slice 1. - **Round 3 PASS.** Static probes of both callers return 0 hits, races aimed outside the brain return 0 leaks, and a real brain gives 1532/1532 parents and 27/27 Wing+Hall hits. **Accepted residual (Mathias, 2026-10-04):** the directory-swap race is tracked in #167, with resolution triggers. A directory swapped for an in-brain symlink between the walk's listing and the read is still followed. The WalkRegular doc states this. The other accepted residual is hard links. **Non-blocking items left:** - The new search tests have no positive control of their own. - "is not a directory: not a regular file" reads oddly; an `ErrNotDir` sentinel would be clearer. I'll pick both up the next time I touch brainfs. **Next slices:** - graphsync - wiki inventory/index - brain index/tunnel/linkcheck - pipeline - provenance.ApplyFile - frontmatter scan - classification.yaml --- _Created via git-mcp on behalf of @mathias_
Author
Owner

Gatekeeper review of PR #168 (slice 4: graphsync, wiki inventory/index; head c7a6429): BLOCK. Details are in review id 4 on the PR.

The static cases hold: outside links, in-brain file links, FIFOs, sockets, symlinked roots and type dirs, and a symlinked brainDir. CI run 9176 is green. Blocking:

  1. Every new symlink test points outside the brain, which os.Root refuses anyway. Bypassing the Lstat in indexDoc, readFrontmatter or pageFirstSentence keeps the suite green, and so does a last-component-only check in ReadDirRegular. Under the indexDoc bypass, a static in-brain link puts raw/ content into the graph.
  2. Rule 9, shown false by execution. With an in-brain target, the same swap harness leaks raw/ content at HEAD: RebuildIndex 8,229 times, LoadInventory 9,528, IndexDoc 109,472. This is a file swap between ReadRegular's Lstat and its open; #167 accepts a directory swap. An os.SameFile check took all three to 0 leaks, and would also cover slices 1–3. The alternative is that Mathias extends #167 to cover file swaps and the tests are renamed.
  3. Rule 9: ...SkipsASymlinkedTypeDir and ...SkipsSymlinkedNotesAndASymlinkedRoot. The code aborts in both cases; the first test discards the error, and silently skipping stays green.

Non-blocking: outside content still reaches the LLM prompts through readSourcePage and loadSchema (pipeline slice). LoadInventory on a missing brainDir now returns an error.

Gatekeeper review of PR #168 (slice 4: graphsync, wiki inventory/index; head c7a6429): **BLOCK**. Details are in review id 4 on the PR. The static cases hold: outside links, in-brain file links, FIFOs, sockets, symlinked roots and type dirs, and a symlinked brainDir. CI run 9176 is green. Blocking: 1. Every new symlink test points outside the brain, which os.Root refuses anyway. Bypassing the Lstat in indexDoc, readFrontmatter or pageFirstSentence keeps the suite green, and so does a last-component-only check in ReadDirRegular. Under the indexDoc bypass, a static in-brain link puts raw/ content into the graph. 2. Rule 9, shown false by execution. With an **in-brain** target, the same swap harness leaks raw/ content at HEAD: RebuildIndex 8,229 times, LoadInventory 9,528, IndexDoc 109,472. This is a *file* swap between ReadRegular's Lstat and its open; #167 accepts a *directory* swap. An `os.SameFile` check took all three to 0 leaks, and would also cover slices 1–3. The alternative is that Mathias extends #167 to cover file swaps and the tests are renamed. 3. Rule 9: `...SkipsASymlinkedTypeDir` and `...SkipsSymlinkedNotesAndASymlinkedRoot`. The code aborts in both cases; the first test discards the error, and silently skipping stays green. Non-blocking: outside content still reaches the LLM prompts through `readSourcePage` and `loadSchema` (pipeline slice). LoadInventory on a missing brainDir now returns an error.
Author
Owner

Gatekeeper round 2 on PR #168 (slice 4, head d90ce66): PASS. Evidence is in review id 5 on the PR. Not merged, verdict only.

All three round-1 blockers are closed, and I verified each one by execution:

  • In-brain swap races are now 0 leaks. In round 1 they were 109k (IndexDoc), 9.5k (LoadInventory) and 8.2k (RebuildIndex). os.SameFile in readOpenedMax closed them.
  • Every caller-level bypass flip now goes red.
  • The renamed tests assert what their names say.

CI run 9180 is green, CI=1 task check exits 0, and -race -count=5 is green.

Follow-up worth filing: the merged slice 1–3 callers have no per-caller in-brain case. Bypassing ReadRegular at search Query, hydrate, vectorstore Sync, the context excerpt or ReadNote stays green.

Gatekeeper round 2 on PR #168 (slice 4, head d90ce66): **PASS**. Evidence is in review id 5 on the PR. Not merged, verdict only. All three round-1 blockers are closed, and I verified each one by execution: - In-brain swap races are now **0 leaks**. In round 1 they were 109k (IndexDoc), 9.5k (LoadInventory) and 8.2k (RebuildIndex). `os.SameFile` in `readOpenedMax` closed them. - Every caller-level bypass flip now goes red. - The renamed tests assert what their names say. CI run 9180 is green, `CI=1 task check` exits 0, and `-race -count=5` is green. **Follow-up worth filing:** the merged slice 1–3 callers have no per-caller in-brain case. Bypassing ReadRegular at search Query, hydrate, vectorstore Sync, the context excerpt or ReadNote stays green.
Author
Owner

Slice 4 merged and live, v0.23.0 (PR #168 → 77e32de). Covers graphsync and wiki inventory/index, plus ReadRegular SameFile, which covers every reader. Pod runs ingestion:77e32de. The graph backfill indexed 1584 notes with no abort, and the embed sync added 16 with 0 errors. brain_query and brain_get work live. Follow-up: #171 (per-caller in-brain tests for slices 1–3).

Slice 5 scope, confirmed by the PR #170 review:

  • brain/index.go (BuildWingIndex)
  • tunnel DetectTunnels/readTitleAndCreated, which uses a blocking os.Open and hangs any brain_write/brain_update of a wiki note when a FIFO exists anywhere under wiki/; this is already true on main
  • linkcheck entryIndex

frontmatter.Scan backs only the frontmatterlint CLI, not the server, so it stays out of scope.

Slice 6: pipeline (readSourcePage and loadSchema reach the LLM prompt: the PR #168 review got 10 outside hits), plus BackfillRefs, provenance and classification.


Created via git-mcp on behalf of @mathias

**Slice 4 merged and live, v0.23.0** (PR #168 → 77e32de). Covers graphsync and wiki inventory/index, plus `ReadRegular` SameFile, which covers every reader. Pod runs `ingestion:77e32de`. The graph backfill indexed 1584 notes with no abort, and the embed sync added 16 with 0 errors. brain_query and brain_get work live. Follow-up: #171 (per-caller in-brain tests for slices 1–3). **Slice 5 scope, confirmed by the PR #170 review:** - brain/index.go (BuildWingIndex) - tunnel `DetectTunnels`/`readTitleAndCreated`, which uses a blocking `os.Open` and **hangs any brain_write/brain_update of a wiki note when a FIFO exists anywhere under `wiki/`**; this is already true on main - linkcheck entryIndex `frontmatter.Scan` backs only the `frontmatterlint` CLI, not the server, so it stays out of scope. **Slice 6:** pipeline (readSourcePage and loadSchema reach the LLM prompt: the PR #168 review got 10 outside hits), plus BackfillRefs, provenance and classification. --- _Created via git-mcp on behalf of @mathias_
Author
Owner

Gatekeeper review of PR #175 (slice 5a: wing indexes and linkcheck entryIndex through brainfs; head 6e3c51c): BLOCKED. Details are in review id 6 on the PR. Not merged, verdict only.

  1. Write-path regression. entryIndex now returns an error when a top-level linked tree (knowledge/ or wiki/) is not a real directory: a symlink, in-brain or dangling, or a regular file. StripUnresolvedLinks runs first on every brain_write and brain_update, so every write is refused and the learning is lost. Main skipped such a tree and the write landed. I verified it against both main and the head through brainstore.Write. Fix: skip such a tree with a warning, and add a write-lands test.
  2. Vacuous test (rule 9). The symlinked-hall assertion passes when no _index.md is written at all. If ReadDirDirs lists symlinks, one symlinked hall stops the whole wing being indexed and one symlinked wing aborts BuildAllWingIndexes, and every brain test stays green.

Other flips (main readers, ReadRegular Lstat/SameFile, ReadDirRegular, the error swallow) all go red. CI run 9311 is green, CI=1 task check exits 0, and -race -count=5 is green. Green covers neither finding.
Still open for slice 5b: brain_write still hangs on a FIFO under wiki/ through AutoTunnel→readTitleAndCreated (verified).

Gatekeeper review of PR #175 (slice 5a: wing indexes and linkcheck entryIndex through brainfs; head 6e3c51c): **BLOCKED**. Details are in review id 6 on the PR. Not merged, verdict only. 1. **Write-path regression.** `entryIndex` now returns an error when a top-level linked tree (`knowledge/` or `wiki/`) is not a real directory: a symlink, in-brain or dangling, or a regular file. `StripUnresolvedLinks` runs first on every brain_write and brain_update, so every write is refused and the learning is lost. Main skipped such a tree and the write landed. I verified it against both main and the head through `brainstore.Write`. Fix: skip such a tree with a warning, and add a write-lands test. 2. **Vacuous test (rule 9).** The symlinked-hall assertion passes when no `_index.md` is written at all. If `ReadDirDirs` lists symlinks, one symlinked hall stops the whole wing being indexed and one symlinked wing aborts `BuildAllWingIndexes`, and every brain test stays green. Other flips (main readers, ReadRegular Lstat/SameFile, ReadDirRegular, the error swallow) all go red. CI run 9311 is green, `CI=1 task check` exits 0, and `-race -count=5` is green. Green covers neither finding. Still open for slice 5b: brain_write still hangs on a FIFO under `wiki/` through `AutoTunnel`→`readTitleAndCreated` (verified).
Author
Owner

Gatekeeper round 2 on PR #175 (slice 5a, head 8fdf728): PASS. Evidence is in review id 7 on the PR. Not merged, verdict only (risk/high, Mathias merges).

  • Round-1 blocker 1 is closed. brain_write lands with a symlinked, dangling or plain-file knowledge/, verified through brainstore.Write.
  • Round-1 blocker 2 is closed. The hall/wing tests now go red when ReadDirDirs lists symlinks.
  • CI run 9324 is green, CI=1 task check exits 0, and -race -count=5 is green.
    Non-blocking, to track: (a) the skip test covers only knowledge/, not wiki/; (b) on main and here alike, an unreadable subdirectory under knowledge/ or wiki/ makes StripUnresolvedLinks abort and refuses every brain_write. That is a lost-learning path, not a regression, and wants its own issue. Slice 5b (FIFO hang via DetectTunnels) is still open.
Gatekeeper round 2 on PR #175 (slice 5a, head 8fdf728): **PASS**. Evidence is in review id 7 on the PR. Not merged, verdict only (risk/high, Mathias merges). - Round-1 blocker 1 is closed. brain_write lands with a symlinked, dangling or plain-file `knowledge/`, verified through `brainstore.Write`. - Round-1 blocker 2 is closed. The hall/wing tests now go red when `ReadDirDirs` lists symlinks. - CI run 9324 is green, `CI=1 task check` exits 0, and `-race -count=5` is green. Non-blocking, to track: (a) the skip test covers only `knowledge/`, not `wiki/`; (b) on main and here alike, an unreadable subdirectory under `knowledge/` or `wiki/` makes `StripUnresolvedLinks` abort and refuses every brain_write. That is a lost-learning path, not a regression, and wants its own issue. Slice 5b (FIFO hang via DetectTunnels) is still open.
Author
Owner

Gatekeeper review of PR #176 (slice 6: pipeline readers and classification.Load through brainfs; head 70df6db): BLOCKED. Details are in review id 8 on the PR. Not merged, verdict only.

Blocking:

  1. pipeline/backfill.go:82: the BackfillRefs concept-page read has no test. Reverting it to os.ReadFile keeps the suite green, because LoadInventory filters a static symlinked page before the changed line runs. The read is reachable by a swap race. A scratch probe gets 0 leaks on head, and with the flip it copies other-brain content into the concept page in 3 of 3 runs. Fix: a per-caller swap test (#171 pattern).

Held, verified by execution: no LLM-controlled page path escapes wiki/ (80 hostile type × title combos plus hostile sources; validPageType and wiki.Slug); every other reader flip goes red; classification.Load fails hard promptly on a FIFO, dir or dangling link, which is right because fail-open would drop explicit tags.

Recorded for #158: the same probe shows BackfillRefs writing through a symlink swapped in between its read and its write (about 5k target overwrites in 3 s on head; already true on main). Also, symlinkguard does not report FIFOs, so a planted FIFO classification.yaml passes the startup tripwire and crash-loops at Load. On main it hung startup instead.

CI run 9323 is green, CI=1 task check exits 0, and -race -count=5 is green. Green covers none of finding 1.

Gatekeeper review of PR #176 (slice 6: pipeline readers and classification.Load through brainfs; head 70df6db): **BLOCKED**. Details are in review id 8 on the PR. Not merged, verdict only. Blocking: 1. `pipeline/backfill.go:82`: the BackfillRefs concept-page read has no test. Reverting it to `os.ReadFile` keeps the suite green, because `LoadInventory` filters a static symlinked page before the changed line runs. The read is reachable by a swap race. A scratch probe gets 0 leaks on head, and with the flip it copies other-brain content into the concept page in 3 of 3 runs. Fix: a per-caller swap test (#171 pattern). Held, verified by execution: no LLM-controlled page path escapes `wiki/` (80 hostile type × title combos plus hostile sources; `validPageType` and `wiki.Slug`); every other reader flip goes red; classification.Load fails hard promptly on a FIFO, dir or dangling link, which is right because fail-open would drop explicit tags. Recorded for #158: the same probe shows BackfillRefs writing through a symlink swapped in between its read and its write (about 5k target overwrites in 3 s on head; already true on main). Also, symlinkguard does not report FIFOs, so a planted FIFO `classification.yaml` passes the startup tripwire and crash-loops at Load. On main it hung startup instead. CI run 9323 is green, `CI=1 task check` exits 0, and `-race -count=5` is green. Green covers none of finding 1.
Author
Owner

Slices 5a and 6 merged and live (PR #175 → ef39435; PR #176 → eedb110).

Check after #176:

  • The pod restarted cleanly. classification.yaml loaded through brainfs, since startup didn't exit.
  • The graph backfill indexed 1593 notes with no errors.
  • brain_query works live.

Remaining reader work: slice 5b, DetectTunnels (PR #186, fixes #174), is in review. Once it merges, every brain reader in the server goes through brainfs. frontmatter.Scan only backs the CLI, so it stays out of scope.

Related, in flight:

  • #187: a file behind a symlinked directory is refused for reads and writes (fixes #173).
  • #171: per-caller in-brain tests for slices 1–3 (the estate session's PR #172).

Open:

  • #158: write paths.
  • #167: the race residual.
  • #178: brain_write loses the learning on an unreadable subdir.
  • #179: symlinkguard ignores FIFOs.

This issue closes when #186 merges.


Created via git-mcp on behalf of @mathias

**Slices 5a and 6 merged and live** (PR #175 → ef39435; PR #176 → eedb110). **Check after #176:** - The pod restarted cleanly. classification.yaml loaded through brainfs, since startup didn't exit. - The graph backfill indexed 1593 notes with no errors. - brain_query works live. **Remaining reader work:** slice 5b, DetectTunnels (PR #186, fixes #174), is in review. Once it merges, every brain reader in the server goes through brainfs. `frontmatter.Scan` only backs the CLI, so it stays out of scope. **Related, in flight:** - #187: a file behind a symlinked directory is refused for reads and writes (fixes #173). - #171: per-caller in-brain tests for slices 1–3 (the estate session's PR #172). **Open:** - #158: write paths. - #167: the race residual. - #178: brain_write loses the learning on an unreadable subdir. - #179: symlinkguard ignores FIFOs. This issue closes when #186 merges. --- _Created via git-mcp on behalf of @mathias_
Author
Owner

Done. Every brain reader in the server now goes through brainfs. Slice 5b, DetectTunnels (PR #186, fixes #174), is merged and live: the pod restarted cleanly and the graph backfill indexed 1593 notes.

Slice PR Readers
1 #161 search
2 #165 context excerpt, brain_get, brain_read
3 #166 vectorstore
4 #168 graph, wiki inventory and index, plus ReadRegular SameFile
5a #175 wing indexes, link check
5b #186 tunnel detection
6 #176 pipeline, classification

Related, merged:

  • #170 (#169, caller-supplied paths)
  • #187 (#173: files behind a symlinked directory, and unclean paths, are refused for reads and writes)

Still open, each tracked:

  • #158: write paths
  • #188: brain_write create path follows a symlinked wing
  • #167: race residual
  • #171: per-caller in-brain tests for slices 1–3 (estate session's PR #172)
  • #178: learning lost on an unreadable subdir
  • #179: symlinkguard ignores FIFOs

frontmatter.Scan backs only the CLI, so it is out of scope. Closing.


Created via git-mcp on behalf of @mathias

**Done. Every brain reader in the server now goes through brainfs.** Slice 5b, DetectTunnels (PR #186, fixes #174), is merged and live: the pod restarted cleanly and the graph backfill indexed 1593 notes. | Slice | PR | Readers | |---|---|---| | 1 | #161 | search | | 2 | #165 | context excerpt, brain_get, brain_read | | 3 | #166 | vectorstore | | 4 | #168 | graph, wiki inventory and index, plus ReadRegular SameFile | | 5a | #175 | wing indexes, link check | | 5b | #186 | tunnel detection | | 6 | #176 | pipeline, classification | **Related, merged:** - #170 (#169, caller-supplied paths) - #187 (#173: files behind a symlinked directory, and unclean paths, are refused for reads and writes) **Still open, each tracked:** - #158: write paths - #188: brain_write create path follows a symlinked wing - #167: race residual - #171: per-caller in-brain tests for slices 1–3 (estate session's PR #172) - #178: learning lost on an unreadable subdir - #179: symlinkguard ignores FIFOs `frontmatter.Scan` backs only the CLI, so it is out of scope. Closing. --- _Created via git-mcp on behalf of @mathias_
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mathias/hyperguild#157