The ingestion REST API (/query, /write, /ingest*, /promote, /pending, ...) had no auth; only /mcp was wrapped. infra#517 found it on the internet and narrowed the public ingress. It is still open on the tailnet (koala:8443) and on LAN NodePort 30330. Separately, /ingest-path and MCP brain_ingest read any server path into the brain.
Closes #149
## Why
The ingestion REST API (`/query`, `/write`, `/ingest*`, `/promote`, `/pending`, ...) had no auth; only `/mcp` was wrapped. infra#517 found it on the internet and narrowed the public ingress. It is still open on the tailnet (`koala:8443`) and on LAN NodePort 30330. Separately, `/ingest-path` and MCP `brain_ingest` read any server path into the brain.
## 99fedea: REST auth
- `mountREST` puts the REST routes on a sub-mux mounted at `/` behind `BearerMiddleware`, with the same credential as `/mcp`.
- Public routes stay on the outer mux: `/mcp` (own auth), `/capture`, the HMAC webhook, OAuth metadata and `/metrics`.
- **Anything unregistered is authenticated by default.** This is the brain lesson `ingress-path-prefix-exposes-unauthenticated-sibling-routes`.
- The hyperguild CLI (`cmd/hyperguild`, `internal/brain`) now sends `BRAIN_MCP_TOKEN`.
## 21a53c4: ingest path confinement
- `ResolveIngestPath` resolves paths against `brain/raw/` (the watcher's inbox), evaluates symlinks, and refuses anything outside.
- A directory walk re-resolves each file, so a symlinked file inside `raw/` can't lead out.
## Caller audit
| Caller | Route | Token |
|---|---|---|
| brain-ui | /query | `BRAIN_MCP_TOKEN` (ESO, same 1Password item as ingestion) |
| brain-ingest-ntfy | /ingest | `BRAIN_TOKEN` from a hand-made secret: 44 bytes, same as ingestion's, not proven equal. **Moved to ESO from the same item in infra before this merges.** |
| hyperguild CLI | /query, /write | now sends `BRAIN_MCP_TOKEN` |
| ai-sessions `ingest_sessions.py` | /write | sends the token, but its URL is the public host (404 since infra#517); repointed to the tailnet |
| Prometheus | /metrics | stays public |
**Do not merge before the infra prep lands.** CD deploys on merge.
risk/high: control code (rule 8).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---
_Created via git-mcp on behalf of @mathias_
Only /mcp was wrapped in BearerMiddleware; /query, /write, /ingest*,
/promote, /pending and the rest on the same mux had no auth, and
infra#517 found them on the internet (mitigated there by narrowing the
ingress). They are still open on the tailnet and LAN NodePort 30330.
mountREST puts the REST routes on their own mux, mounted at "/" behind
the same bearer middleware and credential as /mcp. Routes registered on
the outer mux stay explicitly public (/mcp has its own auth, /capture,
the HMAC webhook, OAuth metadata, /metrics); anything else, including a
route added later, is authenticated by default.
The hyperguild CLI (cmd/hyperguild and internal/brain) now sends
BRAIN_MCP_TOKEN, which it never did. brain-ui and brain-ingest-ntfy
already send theirs.
Refs #149
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both read a caller-chosen server path with extract.Text, so any file the
pod could read (the ServiceAccount token, /etc/passwd) could be ingested
into the brain and read back with brain_query.
ResolveIngestPath resolves relative paths against brain/raw/ (the inbox
the watcher already owns), evaluates symlinks, and refuses anything
that lands outside. A directory ingest re-resolves every walked file,
so a symlinked file inside raw/ cannot lead out. Existing ingest-path
tests moved their fixtures under brain/raw/.
Refs #149
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
I reviewed this by running it, not by reading it. I built the real ingestion/cmd/server binary, started it in an isolated env (sentinel token, OAuth plus capture routes enabled) and attacked it over HTTP and MCP.
What held (verified against the live binary)
REST auth. Every REST route returns 401 with no token, a wrong token, Bearer, Bearer (empty), Bearer tok (two spaces), lowercase bearer and Basic, for all methods (GET/HEAD/OPTIONS/PUT/DELETE/PATCH/CONNECT/TRACE). The same holds for unregistered paths and /. These paths return 401 directly: /query/, /%2Fquery, /%71uery, /query;x, /query%00, /QUERY, /mcp%2F..%2Fquery, the absolute-form POST http://evil/query. These get a 307 to the clean path, which then needs auth: //query, /./query, /mcp/../query, /capture/../query, /metrics/../query, /.well-known/../query. /oauth/token refuses empty or wrong credentials. /capture with no auth returns 401. /metrics exposes only per-route latency and status-class counts.
Ingest path confinement, REST /ingest-path and MCP brain_ingest both. Refused: .., absolute paths outside raw/, /etc/hostname, /proc/self/environ, abs and rel symlinked files, a symlinked dir and paths through it, a sibling-prefix dir raw-evil/rawx. In a walk, symlinked files are skipped and symlinked dirs are not followed (sentinel not read). A missing raw/ fails closed.
Blocking
The main.go wiring has no test, and the done-when asks for a test of 401 on the NodePort.routes_test.go builds its own replica router. Two flips both stayed green on go test ./cmd/server:
replacing the auth closure in main.go:528 with return next
adding mux.HandleFunc("POST /query", h.Query) back onto the outer mux. That is the more specific pattern, so /query goes public again.
Either regression ships green. Suggested fix: pull the outer-mux construction out of main() into a function and test that function. Or assert that every pattern on the outer mux is in an explicit public allowlist.
TestMetricsStaysOpenForPrometheus only checks a handler the test defines itself (rule 9). I wrapped the real /metrics in main.go with BearerMiddleware and the test stayed green.
Caller breakage: hyperguild brain pass-rate now gets 401.cmd/hyperguild/http.goPassRate() never calls c.authorize(req). I reproduced it: with BRAIN_MCP_TOKEN set, it sends no Authorization header and gets status 401. TestBrainClientSendsBrainMCPToken only exercises Query and Write, so its name claims more than it checks and hides this (rule 9). The PR's caller audit also leaves out /pass-rate.
Should fix / follow-up (not the sole reason for the block)
The error text reveals which files exist (authenticated callers only). ingestpath_root.go puts the EvalSymlinks error into the response. ../../../../../../../etc/passwd gives the generic refusal, .../etc/nonexistent-zz gives lstat /etc/nonexistent-zz: no such file or directory, and .../root/.ssh gives permission denied. It also leaks the absolute brain dir. Return the generic error and log the detail.
Other raw/ readers follow symlinks that this PR refuses. This existed before the PR, but now the PR's own claim doesn't hold everywhere. A symlink raw/x.md -> <outside file> is refused by /ingest-path. Yet GET /pending returned the outside file's contents in its excerpt (sentinel found 2 times, bytes not printed). brain_pending uses the same code. brain_promote (promote.go:108) would copy the target into wiki/, and brain-sync would push it. raw/ is writable by a remote party:/var/lib/supervisor/brain is a Syncthing sendreceive folder shared with flamingo, raw/ is not in .stignore, and Syncthing syncs symlinks. It is also writable by uid 1000 on koala and by the brain-* CronJobs that mount the hostPath.
TOCTOU between ResolveIngestPath and extract.Text(path), which reopens the file by name. Exploiting it needs write access to raw/ (see 5) plus a race, so I rate it low. The proper fix is to open beneath the root (os.OpenRoot / openat2 RESOLVE_BENEATH) and extract from that file handle.
The resolver trusts a symlinked raw/ root. With raw -> /, ResolveIngestPath(d, "/etc/hostname") succeeds. That's by design, but nothing documents or tests it.
Stale docs.AGENTS.md (~l.489) says the REST API "remains available" with no mention of auth. docs/runbooks/exercising-pass-rate-traffic.md:27 says /pass-rate needs auth "none (read-only)".
Flip results
Broke
Test noticed?
mountREST mounts rest without auth
yes (2 fail)
main.go auth closure becomes identity
no
main.go re-registers public POST /query
no
main.go/metrics wrapped in auth
no
ResolveIngestPath drops EvalSymlinks
yes
walk re-resolve removed
yes
REST handler confinement removed
yes
MCP brain_ingest confinement removed
yes
CLI Write stops sending the token
yes
internal/brain.Query stops sending the token
yes
Gates
CI=1 task check in a clean clone: exit 0, 0 FAIL. CI run 8859 (pull_request, 21a53c4) succeeded. CI is green, but green covers none of findings 1-3.
Callers checked: brain-ui (/query) and brain-ingest-ntfy (/ingest) send Bearer <token> when the token is set. Infra 7aceee5 points the ntfy token at the ESO item. agentsquad, dispatch and agent-runner use only /mcp. Watcher and claudewatcher run in-process. ai-sessions/ingest_sessions.py sends the token. Not checked: brain-gardener (no local checkout), and whether the live tokens are equal.
## Gatekeeper verdict: BLOCK (head 21a53c4)
I reviewed this by running it, not by reading it. I built the real `ingestion/cmd/server` binary, started it in an isolated env (sentinel token, OAuth plus capture routes enabled) and attacked it over HTTP and MCP.
### What held (verified against the live binary)
- **REST auth.** Every REST route returns 401 with no token, a wrong token, `Bearer`, `Bearer ` (empty), `Bearer tok` (two spaces), lowercase `bearer` and `Basic`, for all methods (GET/HEAD/OPTIONS/PUT/DELETE/PATCH/CONNECT/TRACE). The same holds for unregistered paths and `/`. These paths return 401 directly: `/query/`, `/%2Fquery`, `/%71uery`, `/query;x`, `/query%00`, `/QUERY`, `/mcp%2F..%2Fquery`, the absolute-form `POST http://evil/query`. These get a 307 to the clean path, which then needs auth: `//query`, `/./query`, `/mcp/../query`, `/capture/../query`, `/metrics/../query`, `/.well-known/../query`. `/oauth/token` refuses empty or wrong credentials. `/capture` with no auth returns 401. `/metrics` exposes only per-route latency and status-class counts.
- **Ingest path confinement**, REST `/ingest-path` and MCP `brain_ingest` both. Refused: `..`, absolute paths outside raw/, `/etc/hostname`, `/proc/self/environ`, abs and rel symlinked files, a symlinked dir and paths through it, a sibling-prefix dir `raw-evil`/`rawx`. In a walk, symlinked files are skipped and symlinked dirs are not followed (sentinel not read). A missing raw/ fails closed.
### Blocking
1. **The `main.go` wiring has no test, and the done-when asks for a test of 401 on the NodePort.** `routes_test.go` builds its own replica router. Two flips both stayed green on `go test ./cmd/server`:
- replacing the auth closure in `main.go:528` with `return next`
- adding `mux.HandleFunc("POST /query", h.Query)` back onto the outer mux. That is the more specific pattern, so `/query` goes public again.
Either regression ships green. Suggested fix: pull the outer-mux construction out of `main()` into a function and test that function. Or assert that every pattern on the outer mux is in an explicit public allowlist.
2. **`TestMetricsStaysOpenForPrometheus` only checks a handler the test defines itself** (rule 9). I wrapped the real `/metrics` in `main.go` with BearerMiddleware and the test stayed green.
3. **Caller breakage: `hyperguild brain pass-rate` now gets 401.** `cmd/hyperguild/http.go` `PassRate()` never calls `c.authorize(req)`. I reproduced it: with `BRAIN_MCP_TOKEN` set, it sends no `Authorization` header and gets `status 401`. `TestBrainClientSendsBrainMCPToken` only exercises Query and Write, so its name claims more than it checks and hides this (rule 9). The PR's caller audit also leaves out `/pass-rate`.
### Should fix / follow-up (not the sole reason for the block)
4. **The error text reveals which files exist** (authenticated callers only). `ingestpath_root.go` puts the EvalSymlinks error into the response. `../../../../../../../etc/passwd` gives the generic refusal, `.../etc/nonexistent-zz` gives `lstat /etc/nonexistent-zz: no such file or directory`, and `.../root/.ssh` gives `permission denied`. It also leaks the absolute brain dir. Return the generic error and log the detail.
5. **Other raw/ readers follow symlinks that this PR refuses.** This existed before the PR, but now the PR's own claim doesn't hold everywhere. A symlink `raw/x.md -> <outside file>` is refused by `/ingest-path`. Yet `GET /pending` returned the outside file's contents in its excerpt (sentinel found 2 times, bytes not printed). `brain_pending` uses the same code. `brain_promote` (`promote.go:108`) would copy the target into wiki/, and brain-sync would push it. **raw/ is writable by a remote party:** `/var/lib/supervisor/brain` is a Syncthing `sendreceive` folder shared with flamingo, raw/ is not in `.stignore`, and Syncthing syncs symlinks. It is also writable by uid 1000 on koala and by the brain-* CronJobs that mount the hostPath.
6. **TOCTOU** between `ResolveIngestPath` and `extract.Text(path)`, which reopens the file by name. Exploiting it needs write access to raw/ (see 5) plus a race, so I rate it low. The proper fix is to open beneath the root (`os.OpenRoot` / openat2 RESOLVE_BENEATH) and extract from that file handle.
7. **The resolver trusts a symlinked raw/ root.** With `raw -> /`, `ResolveIngestPath(d, "/etc/hostname")` succeeds. That's by design, but nothing documents or tests it.
8. **Stale docs.** `AGENTS.md` (~l.489) says the REST API "remains available" with no mention of auth. `docs/runbooks/exercising-pass-rate-traffic.md:27` says `/pass-rate` needs auth "none (read-only)".
### Flip results
| Broke | Test noticed? |
|---|---|
| `mountREST` mounts `rest` without auth | yes (2 fail) |
| `main.go` auth closure becomes identity | **no** |
| `main.go` re-registers public `POST /query` | **no** |
| `main.go` `/metrics` wrapped in auth | **no** |
| `ResolveIngestPath` drops EvalSymlinks | yes |
| walk re-resolve removed | yes |
| REST handler confinement removed | yes |
| MCP `brain_ingest` confinement removed | yes |
| CLI `Write` stops sending the token | yes |
| `internal/brain.Query` stops sending the token | yes |
### Gates
`CI=1 task check` in a clean clone: exit 0, 0 FAIL. CI run 8859 (pull_request, 21a53c4) succeeded. CI is green, but green covers none of findings 1-3.
Callers checked: brain-ui (`/query`) and brain-ingest-ntfy (`/ingest`) send `Bearer <token>` when the token is set. Infra 7aceee5 points the ntfy token at the ESO item. agentsquad, dispatch and agent-runner use only `/mcp`. Watcher and claudewatcher run in-process. `ai-sessions/ingest_sessions.py` sends the token. Not checked: brain-gardener (no local checkout), and whether the live tokens are equal.
- main.go's wiring was untested (the test built its own copy of the
router, so dropping auth in main or re-registering POST /query as
public stayed green). Routing is now one function, buildMux, which
main calls and the tests call: it constructs the bearer middleware
itself, registers public handlers only for patterns on a fixed
allowlist (a public REST pattern would beat the authenticated
catch-all by specificity), refuses an empty token, and returns an
http.Handler so nothing can add a route to the result.
- hyperguild CLI: PassRate sent no token and would 401. Every request
now goes through brainClient.do, which adds it; the test covers
Query, Write and PassRate and is named for that.
- ResolveIngestPath returns one refusal for every bad path; the raw
EvalSymlinks error let a caller probe which server files exist and
leaked the brain directory.
- PROJECT.md (and derived adapters) and the pass-rate runbook state
that the REST API needs BRAIN_MCP_TOKEN and is tailnet/in-cluster.
Refs #149
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
I rebuilt the real ingestion/cmd/server binary from b5c2271, started it in an isolated env (invented sentinel token, OAuth plus capture enabled) and re-ran the whole round-1 attack set.
Results against the real binary
REST auth. Same as round 1. Every REST route, unregistered paths and / return 401 for all methods and all auth-header variants: none, wrong, Bearer, Bearer , two spaces, lowercase, Basic. Encoded, dot-segment and absolute-form paths also return 401. Non-clean paths get a 307 to /query, which still needs the token. A valid token returns 200. /metrics and the OAuth metadata stay open. /oauth/token refuses bad credentials. /mcp and /capture return 401 without auth.
Ingest confinement, REST and MCP. Refused: .., absolute paths, /etc/hostname, /proc/self/environ, abs and rel symlinked files, a symlinked dir. A walk skips the inner symlink (outside sentinel count 0).
The finding-4 oracle is closed..../etc/passwd, .../etc/nonexistent-zz and .../root/.ssh now return identical text, with no brain dir in it.
Flip table (round 2)
What I broke
Caught?
main.go adds public["POST /query"]
yes, at startup. Exit 1, build router: buildMux: "POST /query" is not an allowed public route. The suite stays green; CI can't catch this, the startup check does. The deployment uses the default RollingUpdate, so the old pod keeps serving.
buildMux bearer replaced with return next
yes (2 fail)
buildMux allowlist check removed
yes
buildMux empty-token check removed
yes
POST /query added to publicRoutes
yes (2 fail)
mountREST without auth
yes (2 fail)
CLI do() drops the Authorization header
yes
resolver puts the underlying error back into the refusal
yes
resolver without EvalSymlinks
yes (3 fail)
main.go wraps buildMux's result in a new outer mux carrying a public POST /query
no (residual, see below)
main.go passes mcpSrv to public["/mcp"] unwrapped
no (this existed before the PR, see below)
Residuals (not blocking; recorded here so they are not lost)
The routes_test comment says "nothing can register a route on the result", and main.go says "the test of buildMux is a test of production wiring". Both are true only of the returned value. A deliberate re-mux in main.go between buildMux and ListenAndServe still ships green. That leaves one untested line of wiring, which is a reasonable bar, but the wording claims slightly more than the test proves.
publicRoutes says each public route "authenticates itself". Nothing asserts that for /mcp: its BearerMiddleware is still applied in main.go, and the test router uses stubs. The wiring is the same as on main today. This deserves a follow-up test that /mcp with no token returns 401 through the production assembly.
Gates
CI=1 task check in a clean clone at b5c2271: exit 0, 0 FAIL, 44 packages ok.
CI run 8882 (pull_request, b5c2271) succeeded. The PR is mergeable.
Findings 5–7 are tracked in #153; I confirmed it's open and covers /pending, promote, Syncthing, TOCTOU and the symlinked raw root.
Finding 8: PROJECT.md (and regenerated adapters) and the runbook now state the bearer requirement.
Done-when
Unauthenticated POST /query returns 401: verified on the real binary, and asserted through buildMux, the function production calls.
brain_ingest with /etc/hostname is refused: verified over MCP, and covered by a test.
Note on the merge lane: this is risk/high. Under the gatekeeper procedure, a risk/high merge is Mathias's call. This PASS is the review evidence, not the merge authorisation.
## Gatekeeper verdict, round 2: PASS (head b5c2271). No blocking findings remain.
I rebuilt the real `ingestion/cmd/server` binary from b5c2271, started it in an isolated env (invented sentinel token, OAuth plus capture enabled) and re-ran the whole round-1 attack set.
### Results against the real binary
- **REST auth.** Same as round 1. Every REST route, unregistered paths and `/` return 401 for all methods and all auth-header variants: none, wrong, `Bearer`, `Bearer `, two spaces, lowercase, `Basic`. Encoded, dot-segment and absolute-form paths also return 401. Non-clean paths get a 307 to `/query`, which still needs the token. A valid token returns 200. `/metrics` and the OAuth metadata stay open. `/oauth/token` refuses bad credentials. `/mcp` and `/capture` return 401 without auth.
- **Ingest confinement**, REST and MCP. Refused: `..`, absolute paths, `/etc/hostname`, `/proc/self/environ`, abs and rel symlinked files, a symlinked dir. A walk skips the inner symlink (outside sentinel count 0).
- **The finding-4 oracle is closed.** `.../etc/passwd`, `.../etc/nonexistent-zz` and `.../root/.ssh` now return identical text, with no brain dir in it.
### Flip table (round 2)
| What I broke | Caught? |
|---|---|
| main.go adds `public["POST /query"]` | **yes, at startup.** Exit 1, `build router: buildMux: "POST /query" is not an allowed public route`. The suite stays green; CI can't catch this, the startup check does. The deployment uses the default RollingUpdate, so the old pod keeps serving. |
| buildMux bearer replaced with `return next` | yes (2 fail) |
| buildMux allowlist check removed | yes |
| buildMux empty-token check removed | yes |
| `POST /query` added to `publicRoutes` | yes (2 fail) |
| `mountREST` without auth | yes (2 fail) |
| CLI `do()` drops the Authorization header | yes |
| resolver puts the underlying error back into the refusal | yes |
| resolver without EvalSymlinks | yes (3 fail) |
| main.go wraps buildMux's result in a new outer mux carrying a public `POST /query` | **no** (residual, see below) |
| main.go passes `mcpSrv` to `public["/mcp"]` unwrapped | **no** (this existed before the PR, see below) |
### Residuals (not blocking; recorded here so they are not lost)
- The routes_test comment says "nothing can register a route on the result", and main.go says "the test of buildMux is a test of production wiring". Both are true only of the returned value. A deliberate re-mux in main.go between `buildMux` and `ListenAndServe` still ships green. That leaves one untested line of wiring, which is a reasonable bar, but the wording claims slightly more than the test proves.
- `publicRoutes` says each public route "authenticates itself". Nothing asserts that for `/mcp`: its BearerMiddleware is still applied in main.go, and the test router uses stubs. The wiring is the same as on main today. This deserves a follow-up test that `/mcp` with no token returns 401 through the production assembly.
### Gates
- `CI=1 task check` in a clean clone at b5c2271: exit 0, 0 FAIL, 44 packages ok.
- CI run 8882 (pull_request, b5c2271) succeeded. The PR is mergeable.
- Findings 5–7 are tracked in #153; I confirmed it's open and covers /pending, promote, Syncthing, TOCTOU and the symlinked raw root.
- Finding 8: PROJECT.md (and regenerated adapters) and the runbook now state the bearer requirement.
### Done-when
- Unauthenticated `POST /query` returns 401: verified on the real binary, and asserted through `buildMux`, the function production calls.
- `brain_ingest` with `/etc/hostname` is refused: verified over MCP, and covered by a test.
**Note on the merge lane:** this is `risk/high`. Under the gatekeeper procedure, a risk/high merge is Mathias's call. This PASS is the review evidence, not the merge authorisation.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #149
Why
The ingestion REST API (
/query,/write,/ingest*,/promote,/pending, ...) had no auth; only/mcpwas wrapped. infra#517 found it on the internet and narrowed the public ingress. It is still open on the tailnet (koala:8443) and on LAN NodePort 30330. Separately,/ingest-pathand MCPbrain_ingestread any server path into the brain.99fedea: REST authmountRESTputs the REST routes on a sub-mux mounted at/behindBearerMiddleware, with the same credential as/mcp./mcp(own auth),/capture, the HMAC webhook, OAuth metadata and/metrics.ingress-path-prefix-exposes-unauthenticated-sibling-routes.cmd/hyperguild,internal/brain) now sendsBRAIN_MCP_TOKEN.21a53c4: ingest path confinementResolveIngestPathresolves paths againstbrain/raw/(the watcher's inbox), evaluates symlinks, and refuses anything outside.raw/can't lead out.Caller audit
BRAIN_MCP_TOKEN(ESO, same 1Password item as ingestion)BRAIN_TOKENfrom a hand-made secret: 44 bytes, same as ingestion's, not proven equal. Moved to ESO from the same item in infra before this merges.BRAIN_MCP_TOKENingest_sessions.pyDo not merge before the infra prep lands. CD deploys on merge.
risk/high: control code (rule 8).
🤖 Generated with Claude Code
Created via git-mcp on behalf of @mathias
Gatekeeper verdict: BLOCK (head
21a53c4)I reviewed this by running it, not by reading it. I built the real
ingestion/cmd/serverbinary, started it in an isolated env (sentinel token, OAuth plus capture routes enabled) and attacked it over HTTP and MCP.What held (verified against the live binary)
Bearer,Bearer(empty),Bearer tok(two spaces), lowercasebearerandBasic, for all methods (GET/HEAD/OPTIONS/PUT/DELETE/PATCH/CONNECT/TRACE). The same holds for unregistered paths and/. These paths return 401 directly:/query/,/%2Fquery,/%71uery,/query;x,/query%00,/QUERY,/mcp%2F..%2Fquery, the absolute-formPOST http://evil/query. These get a 307 to the clean path, which then needs auth://query,/./query,/mcp/../query,/capture/../query,/metrics/../query,/.well-known/../query./oauth/tokenrefuses empty or wrong credentials./capturewith no auth returns 401./metricsexposes only per-route latency and status-class counts./ingest-pathand MCPbrain_ingestboth. Refused:.., absolute paths outside raw/,/etc/hostname,/proc/self/environ, abs and rel symlinked files, a symlinked dir and paths through it, a sibling-prefix dirraw-evil/rawx. In a walk, symlinked files are skipped and symlinked dirs are not followed (sentinel not read). A missing raw/ fails closed.Blocking
main.gowiring has no test, and the done-when asks for a test of 401 on the NodePort.routes_test.gobuilds its own replica router. Two flips both stayed green ongo test ./cmd/server:main.go:528withreturn nextmux.HandleFunc("POST /query", h.Query)back onto the outer mux. That is the more specific pattern, so/querygoes public again.Either regression ships green. Suggested fix: pull the outer-mux construction out of
main()into a function and test that function. Or assert that every pattern on the outer mux is in an explicit public allowlist.TestMetricsStaysOpenForPrometheusonly checks a handler the test defines itself (rule 9). I wrapped the real/metricsinmain.gowith BearerMiddleware and the test stayed green.hyperguild brain pass-ratenow gets 401.cmd/hyperguild/http.goPassRate()never callsc.authorize(req). I reproduced it: withBRAIN_MCP_TOKENset, it sends noAuthorizationheader and getsstatus 401.TestBrainClientSendsBrainMCPTokenonly exercises Query and Write, so its name claims more than it checks and hides this (rule 9). The PR's caller audit also leaves out/pass-rate.Should fix / follow-up (not the sole reason for the block)
ingestpath_root.goputs the EvalSymlinks error into the response.../../../../../../../etc/passwdgives the generic refusal,.../etc/nonexistent-zzgiveslstat /etc/nonexistent-zz: no such file or directory, and.../root/.sshgivespermission denied. It also leaks the absolute brain dir. Return the generic error and log the detail.raw/x.md -> <outside file>is refused by/ingest-path. YetGET /pendingreturned the outside file's contents in its excerpt (sentinel found 2 times, bytes not printed).brain_pendinguses the same code.brain_promote(promote.go:108) would copy the target into wiki/, and brain-sync would push it. raw/ is writable by a remote party:/var/lib/supervisor/brainis a Syncthingsendreceivefolder shared with flamingo, raw/ is not in.stignore, and Syncthing syncs symlinks. It is also writable by uid 1000 on koala and by the brain-* CronJobs that mount the hostPath.ResolveIngestPathandextract.Text(path), which reopens the file by name. Exploiting it needs write access to raw/ (see 5) plus a race, so I rate it low. The proper fix is to open beneath the root (os.OpenRoot/ openat2 RESOLVE_BENEATH) and extract from that file handle.raw -> /,ResolveIngestPath(d, "/etc/hostname")succeeds. That's by design, but nothing documents or tests it.AGENTS.md(~l.489) says the REST API "remains available" with no mention of auth.docs/runbooks/exercising-pass-rate-traffic.md:27says/pass-rateneeds auth "none (read-only)".Flip results
mountRESTmountsrestwithout authmain.goauth closure becomes identitymain.gore-registers publicPOST /querymain.go/metricswrapped in authResolveIngestPathdrops EvalSymlinksbrain_ingestconfinement removedWritestops sending the tokeninternal/brain.Querystops sending the tokenGates
CI=1 task checkin a clean clone: exit 0, 0 FAIL. CI run 8859 (pull_request,21a53c4) succeeded. CI is green, but green covers none of findings 1-3.Callers checked: brain-ui (
/query) and brain-ingest-ntfy (/ingest) sendBearer <token>when the token is set. Infra 7aceee5 points the ntfy token at the ESO item. agentsquad, dispatch and agent-runner use only/mcp. Watcher and claudewatcher run in-process.ai-sessions/ingest_sessions.pysends the token. Not checked: brain-gardener (no local checkout), and whether the live tokens are equal.Gatekeeper verdict, round 2: PASS (head
b5c2271). No blocking findings remain.I rebuilt the real
ingestion/cmd/serverbinary fromb5c2271, started it in an isolated env (invented sentinel token, OAuth plus capture enabled) and re-ran the whole round-1 attack set.Results against the real binary
/return 401 for all methods and all auth-header variants: none, wrong,Bearer,Bearer, two spaces, lowercase,Basic. Encoded, dot-segment and absolute-form paths also return 401. Non-clean paths get a 307 to/query, which still needs the token. A valid token returns 200./metricsand the OAuth metadata stay open./oauth/tokenrefuses bad credentials./mcpand/capturereturn 401 without auth..., absolute paths,/etc/hostname,/proc/self/environ, abs and rel symlinked files, a symlinked dir. A walk skips the inner symlink (outside sentinel count 0)..../etc/passwd,.../etc/nonexistent-zzand.../root/.sshnow return identical text, with no brain dir in it.Flip table (round 2)
public["POST /query"]build router: buildMux: "POST /query" is not an allowed public route. The suite stays green; CI can't catch this, the startup check does. The deployment uses the default RollingUpdate, so the old pod keeps serving.return nextPOST /queryadded topublicRoutesmountRESTwithout authdo()drops the Authorization headerPOST /querymcpSrvtopublic["/mcp"]unwrappedResiduals (not blocking; recorded here so they are not lost)
buildMuxandListenAndServestill ships green. That leaves one untested line of wiring, which is a reasonable bar, but the wording claims slightly more than the test proves.publicRoutessays each public route "authenticates itself". Nothing asserts that for/mcp: its BearerMiddleware is still applied in main.go, and the test router uses stubs. The wiring is the same as on main today. This deserves a follow-up test that/mcpwith no token returns 401 through the production assembly.Gates
CI=1 task checkin a clean clone atb5c2271: exit 0, 0 FAIL, 44 packages ok.b5c2271) succeeded. The PR is mergeable.Done-when
POST /queryreturns 401: verified on the real binary, and asserted throughbuildMux, the function production calls.brain_ingestwith/etc/hostnameis refused: verified over MCP, and covered by a test.Note on the merge lane: this is
risk/high. Under the gatekeeper procedure, a risk/high merge is Mathias's call. This PASS is the review evidence, not the merge authorisation.