fix(privacy): /sessions/running names a chat only to a caller that could open it - #301
Merged
Merged
Conversation
…uld open it `GET /sessions/running` returned `state.active_turn_session_ids()` verbatim: no `HeaderMap`, no reach gate, no tier filter. Every chat holding a turn — private ones included — was named to any caller holding the daemon secret, and because an id appears while the turn runs and vanishes when it ends, POLLING the route timed a private chat's turns. Measured on main (e700297) against a live `biorouter serve`, X-Secret-Key and no other header, while private chat 20260913_1 (versa_azure, tier private) held a turn: GET /sessions/running -> {"session_ids":["20260913_1"]} samples 4-10, then {"session_ids":[]} when the turn ended GET /sessions/20260913_1 -> 403, 284 bytes GET /sessions/zzz_missing -> 403, 284 bytes, byte-identical body GET /sessions -> 200, 4751 rows, that row absent GET /active_work -> {"items":[]} throughout So the sibling that refuses to confirm the chat EXISTS sat beside one publishing its id and its timing. The route now resolves one `session_reach::http_caller` for the whole list and keeps an id only when `HttpCaller::lists_work` admits it — the `GET /active_work` decision (PR #257), not `GET /sessions`' (PR #237), because this route holds IDS rather than rows: each chat has to be resolved, resolution can fail, and a turn held on a chat this daemon cannot read is `TargetTier::Unreadable`, answered as a private one. FILTER and not refuse, for `GET /sessions`' reason — a 403 would break `biorouter session list`'s liveness on the public chats this gate is deliberately inert on. An unproven caller now gets `{"session_ids":[]}`, byte-identical to the body it gets when nothing is running. Re-measured on the same store with the same private chat: secret only -> {"session_ids":[]} 45/45 samples x-caller-provider -> {"session_ids":["20260913_2"]} while the turn ran served cookie (SD-10)-> {"session_ids":["20260913_2"]} while the turn ran status 200, so it is a filter and not a refusal Callers. The renderer never calls this route — only the generated SDK declares it. The one real caller is the CLI (`biorouter session list`, `session watch`), and it is unaffected: `session_watch::DaemonAuth::headers` states this terminal's configured provider on every request, so a private-provider install still sees everything, and a public one sees exactly the chats `GET /sessions` already handed it — every row it can display keeps its liveness. Verified live: twelve consecutive `session list --subagents` runs with no "liveness is unknown" note, and the note does appear on a wrong secret, so the check can fail. The module header carried `/sessions/running` as deliberately open. Both halves of that reason were wrong and the doc now says so: "ids only" treated a chat's id as metadata when `GET /sessions/{id}` refuses to confirm the same id exists, and the claim that the CLI "needs it whole" had the dependency backwards — it needed exactly the filtered set. Tests. A fail-before HTTP test in `session_reach`: it fails on main's handler with `{"session_ids":["<private id>"]}`, and fails the other way too — stubbing the filter to drop every row trips the phase-3 assertion that the PUBLIC id must survive, so it cannot be satisfied by a route that refuses everyone. The census rows are extended, never duplicated: `http_caller` at session.rs 10 -> 11 refs with 8 calls unchanged (a listing, not a ninth reach decision), and a new `lists_work` site for session.rs. `running_sessions` is removed from the ordering test's ungated over-read controls, since it is gated now; `pub fn routes(` takes the after-side. The module's liveness test fabricates ids that are in no store, so it now states the user-action proof and says why. Gates: cargo fmt, clippy-lint.sh, 668/668 biorouter-server --lib, privacy guard-wiring and capability censuses, openapi schema + TS client regenerated, format:check and lint:check.
Broccolito
added a commit
that referenced
this pull request
Sep 14, 2026
Release notes for the 112 pull requests merged since v1.90.3. Drafted from each PR's own description, fact-checked claim by claim against the PRs and the code (27 unsupported claims corrected), then condensed with a second check that no claim was widened (15 more corrections). The schedule known-gap is worded from the handlers themselves. programmatic-session-access.md listed GET /sessions/running as deliberately unfiltered; #301 filtered it, so it moves to the listings table as built. The ungated schedule row now names all five routes that take neither the header nor the proof (create, run_now, pause, unpause, delete), measured by reading each handler, not two.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
GET /sessions/runningtook noHeaderMap, consulted no reach gate and applied notier filter — it returned
state.active_turn_session_ids()verbatim(
crates/biorouter-server/src/routes/session.rs:1746onmain). Every chatholding a turn, private ones included, was named to any caller holding the daemon
secret. And because an id appears while the turn runs and vanishes when it ends,
polling the route timed a private chat's turns.
Every sibling in this family refuses to confirm that a private chat even exists.
Before — measured on
main(e700297), livebiorouter servePrivate chat
20260913_1(versa_azure,privacy_tier: private) holding a turn.X-Secret-Key only — no cookie, no
x-caller-provider, no user-action proof:The 7-sample window is the leak: a secret-only caller learned exactly when a
private chat started and stopped working.
After — same store, same private chat, same daemon
The fix, and which precedent
One
session_reach::http_callerfor the whole list, thenHttpCaller::lists_workper id.
/active_work(PR #257), notGET /sessions(PR #237).GET /sessionshas theclassification in hand because it loaded the rows, so it can call
lists_session.This route holds ids, not rows: each chat must be resolved, resolution can
fail, and a turn held on a chat this daemon cannot read is
TargetTier::Unreadable— answered exactly as a private chat is, which is
work_reach's rule for workthat names no chat.
Filter, not refuse, for
GET /sessions' reason: a 403 would breakbiorouter session list's liveness on the public chats this gate is deliberatelyinert on. An unproven caller gets
{"session_ids":[]}— byte-identical to the bodyit gets when nothing is running, so the omission is not itself an oracle.
Callers — nothing is broken
runningSessions;no component imports it.
biorouter session list,session watch) — the one real caller, and it isunaffected.
session_watch::DaemonAuth::headersstates this terminal'sconfigured provider on every request, so a private-provider install still gets
every running id; a public one gets exactly the chats
GET /sessionsalreadyhanded it, so every row it can display keeps its liveness. Verified live: twelve
consecutive
session list --subagentsruns, noliveness is unknownnote — andthe note does appear on a wrong secret, so that check can fail.
workspace_parity.rsmaps the path in a static table; it makes no request.Sibling sweep
Swept every
#[utoipa::path]handler inroutes/for one that returns session ids,names or timing with no reach check. The only ones left are the three the module
header already records as knowingly open, each measured here:
GET /sessions/changes{"revision":0,"changes":[],"truncated":false}for a private id and for one that never existedupdated_at. Can disclose a named private chat's provider and tier on a change. Real but smaller; left as documented.GET /sessions/insightsGET /sessions/activityGET /usage/*andGET /statusname no session./diagnostics/{id}is gated.The
knowledge/bases/{id}handlers without aHeaderMapsit behind thegate_knowledge_baselayer.Tests
A fail-before HTTP test,
session_reach::bypass_tests::the_running_list_names_a_chat_only_to_a_caller_that_could_open_it,driving
routes::configurein three phases.It fails on
main's handler:And it fails the other way, which is what makes it worth having. Stubbing the
filter to drop every row (the over-broad "fix") trips phase 3:
So it cannot be satisfied by a route that refuses everyone. A final
dropof theturn guard pins that the map is read live, so phase 2 measured a running turn
rather than a row that is always absent.
Censuses extended, never duplicated:
http_caller's session.rs row 10 → 11refs with
callsunchanged at 8 (the new site is a listing, not a ninth reachdecision), plus a new
lists_worksite for session.rs.running_sessionsisremoved from the ordering test's ungated over-read controls because it is gated
now;
pub fn routes(takes the after-side, as it already does for three otherfiles. The module's own liveness test fabricates ids that are in no store — now
Unreadable— so it states the user-action proof and the helper says why.Doc correction
The module header listed
/sessions/runningas deliberately open. Both halves ofthe stated reason were wrong, and the header now records that: "ids only" treated
a chat's id as metadata when
GET /sessions/{id}refuses to confirm that same idexists, and "
biorouter session listneeds it whole" had the dependency backwards— that client needed exactly the filtered set.
Gates
cargo fmt --check,./scripts/clippy-lint.sh, 668/668biorouter-server --lib,privacy_guard_wiring(3/3),privacy_capability(4/4), OpenAPI schema and TSclient regenerated and committed (
check-openapi-schema.shexits 0),npm run format:check,npm run lint:check.🤖 Generated with Claude Code