fix(ingestion): authenticate the REST API; confine ingest paths to brain/raw/ (#149) #152

Merged
mathias merged 3 commits from fix/rest-auth-149 into main 2026-10-03 14:38:07 +00:00
Owner

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


Created via git-mcp on behalf of @mathias

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_
mathias added 2 commits 2026-10-03 14:17:57 +00:00
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>
fix(ingestion): confine /ingest-path and brain_ingest paths to brain/raw/
CI / Lint / Test / Vet (pull_request) Successful in 35s
CI / Mirror to GitHub (pull_request) Skipped
21a53c4159
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>
mathias added the risk/high label 2026-10-03 14:18:13 +00:00
Author
Owner

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)

  1. 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.
  2. 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.
  3. 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.
  4. The resolver trusts a symlinked raw/ root. With raw -> /, ResolveIngestPath(d, "/etc/hostname") succeeds. That's by design, but nothing documents or tests it.
  5. 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.
mathias added 1 commit 2026-10-03 14:32:21 +00:00
fix(ingestion): close the PR #152 review findings on REST auth
CI / Lint / Test / Vet (pull_request) Successful in 35s
CI / Mirror to GitHub (pull_request) Skipped
b5c227179f
- 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>
Author
Owner

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.

## 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.
mathias merged commit cfe3f24e25 into main 2026-10-03 14:38:07 +00:00
Sign in to join this conversation.