Skip to content

fix(privacy): /sessions/running names a chat only to a caller that could open it - #301

Merged
Broccolito merged 1 commit into
mainfrom
fix/running-sessions-reach-gate
Sep 13, 2026
Merged

fix(privacy): /sessions/running names a chat only to a caller that could open it#301
Broccolito merged 1 commit into
mainfrom
fix/running-sessions-reach-gate

Conversation

@Broccolito

Copy link
Copy Markdown
Collaborator

The defect

GET /sessions/running took no HeaderMap, consulted no reach gate and applied no
tier filter — it returned state.active_turn_session_ids() verbatim
(crates/biorouter-server/src/routes/session.rs:1746 on main). 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.

Every sibling in this family refuses to confirm that a private chat even exists.

Before — measured on main (e700297), live biorouter serve

Private 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:

GET /sessions/running       -> {"session_ids":["20260913_1"]}   samples 4-10 of a
                               1 Hz poll, then {"session_ids":[]} at sample 11
                               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

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

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
HTTP status, secret only -> 200   (a filter, not a refusal)

The fix, and which precedent

One session_reach::http_caller for the whole list, then HttpCaller::lists_work
per id.

/active_work (PR #257), not GET /sessions (PR #237). GET /sessions has the
classification 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 work
that names no chat.

Filter, 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 gets {"session_ids":[]} — byte-identical to the body
it gets when nothing is running, so the omission is not itself an oracle.

Callers — nothing is broken

  • Renderer: never calls it. Only the generated SDK declares runningSessions;
    no component imports it.
  • CLI (biorouter session list, session watch) — the one real caller, and it is
    unaffected.
    session_watch::DaemonAuth::headers states this terminal's
    configured provider on every request, so a private-provider install still gets
    every running id; a public one gets exactly the chats GET /sessions already
    handed it, so every row it can display keeps its liveness. Verified live: twelve
    consecutive session list --subagents runs, no liveness is unknown note — and
    the note does appear on a wrong secret, so that check can fail.
  • workspace_parity.rs maps the path in a static table; it makes no request.

Sibling sweep

Swept every #[utoipa::path] handler in routes/ 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:

Route Measured Assessment
GET /sessions/changes {"revision":0,"changes":[],"truncated":false} for a private id and for one that never existed No enumeration (caller supplies the ids) and no timing — the row is provider/model/tier, not updated_at. Can disclose a named private chat's provider and tier on a change. Real but smaller; left as documented.
GET /sessions/insights machine-wide totals only No ids, no names. Private chats counted in aggregates.
GET /sessions/activity per-day counts only Same.

GET /usage/* and GET /status name no session. /diagnostics/{id} is gated.
The knowledge/bases/{id} handlers without a HeaderMap sit behind the
gate_knowledge_base layer.

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::configure in three phases.

It fails on main's handler:

panicked at session_reach.rs:3766:
a caller holding only the daemon secret was handed the id of a PRIVATE chat
while it held a turn: {"session_ids":["20260913_1"]}

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:

panicked at session_reach.rs:3786:
the person at the keyboard lost the private chat's running turn: {"session_ids":[]}

So it cannot be satisfied by a route that refuses everyone. A final drop of the
turn 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 → 11
refs with calls unchanged at 8 (the new site is a listing, not a ninth reach
decision), plus a new lists_work site for session.rs. running_sessions is
removed 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 other
files. 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/running as deliberately open. Both halves of
the 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 id
exists, and "biorouter session list needs it whole" had the dependency backwards
— that client needed exactly the filtered set.

Gates

cargo fmt --check, ./scripts/clippy-lint.sh, 668/668 biorouter-server --lib,
privacy_guard_wiring (3/3), privacy_capability (4/4), OpenAPI schema and TS
client regenerated and committed (check-openapi-schema.sh exits 0),
npm run format:check, npm run lint:check.

🤖 Generated with Claude Code

…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
Broccolito merged commit 22f8a2b into main Sep 13, 2026
16 checks passed
@Broccolito
Broccolito deleted the fix/running-sessions-reach-gate branch September 13, 2026 02:48
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant