fix(privacy): /active_work shows and stops only the work of chats the caller could open - #257
Merged
Merged
Conversation
…r a knowledge base QA on merged main 7c96d79 (2026-09-10) measured four places where a caller holding nothing but the daemon secret - which a public chat's shell recovers with ps eww - was answered by the daemon while the tool path refused it: - H2: every /knowledge/bases/{id}/... route served a private base's pages, graph, history, location and a .brkb export; GET /knowledge/bases listed it. - M1: GET /sessions returned every chat on the machine, private ones titled. - M2: GET /agent/tools?session_id=<private> returned private-extension tool names while add_extension on the same chat refused. - F0: DELETE /sessions/{id} deleted a private chat the read refused, 4 of 4. Each is now the singular read's own gate (session_reach's pure decision), composed rather than re-derived: - Knowledge bases: one route_layer (session_reach::gate_knowledge_base) on a sub-router holding exactly the routes that name a base by {id}, reads and writes alike. An absent or malformed id is answered as a private one. The bases list and /knowledge/active omit what the caller cannot reach, and a selection write cannot move what its caller cannot see (KnowledgeService::set_selection_within). - Chats: session_reach on DELETE, rename, workflow values, the in-place edit arm (it truncates), extensions, usage, /agent/tools, callable_tool_count, /workflows/create and /skills/session; every refusal is GET /sessions/{id}'s byte for byte. GET /sessions, /sessions/sidebar and /schedule/{id}/sessions filter to the rows that gate admits (the sidebar scans so paging stays whole). Conversation ingest checks every named chat. - biorouter serve: its own interface (the served document's cookie) keeps the operator's configured-provider tier on listings and knowledge bases, which were open to it before; the transcript gate never reads it. Nothing refused before is permitted now. Tests show each route failing before and passing after; the wiring census names every new call site.
…covers The daemon now answers a request without X-User-Action as a public model on every route that names a chat or a knowledge base (previous commit), so the desktop - the person at the keyboard - says so on each of them: - Knowledge view: knowledgeFetch and the ingest/lint SSE stream attach the proof centrally; listBases, getActive, getGraph, getLocation, getPageBody, previewState, listHistory, restoreState, getKbTier and deleteBase carry it. The listing matters twice: KnowledgeContext prunes its selection against it, so a filtered list would read as deleted bases. - Chats: the session list cache, sidebar, first-run privacy notice, delete, rename, workflow values, in-place edit, extensions, usage, tool count, skills, schedule runs and create-workflow. - History's Export also sends it: the export route was gated in an earlier sweep and exporting a private chat from History had no proof to show. sessionListCache captures include_subagents before the proof's async hop, so an orphaned request still asks for the list it was issued for. Tests that pinned exact call arguments now assert the proof is sent. Docs: privacy-tiers.md records what shipped and what did not change; the execution plan's 'the Knowledge view is the user' scope note is marked superseded and open question 15(b) answered; serve-decisions.md gains SD-9 (the served interface keeps its operator's reach on listings and knowledge bases, and gains no transcript); programmatic-session-access.md's route tables match the tree; session_reach.rs answers its open listing question. OpenAPI spec and TS client regenerated for the new 403 responses.
…ere CI looks CI runs cargo test --workspace --lib --bins, so the integration binaries that held the H2 route sweep (tests/knowledge_routes.rs) and the served-operator standing (tests/serve_operator_reach.rs) were not what kept either door shut. Both now have a copy in routes::session_reach's lib tests, driven through routes::configure — the tree the daemon serves. The serve decision record becomes SD-10 (SD-9 is claimed by PR #229), and its transcript bullet is reworded so it holds whether or not the interface states a capability: the cookie never reaches a transcript, and a stated capability is judged at those routes exactly as any caller's is.
…bin also links routes::session_reach is compiled into the biorouterd bin as well as the library, and the bin has no auth module of its own, so the new served-operator test named crate::auth and failed to build there (clippy caught it; cargo test --lib alone did not). It now goes through biorouter_server::auth, which is the static http_caller reads in either binary.
The /active_work cancel route has to resolve an id to the chat that owns the work before it fires anything (issue #56). `get` gives it the one entry, built by the same snapshot `list` uses, so the two cannot disagree about an entry. Documents `ActiveWorkItem::session_id` as load-bearing: GET /active_work now shows a row only to a caller that could open its chat, and a row with no chat only to a caller that could open a private one.
… ran it Background jobs and foreground commands registered with no session id. The /active_work reach filter (next commit) answers such a row as a private chat's, so it would have hidden every shell command, a public chat's included, from any caller not shown private chats. The developer server now reads the dispatching chat from the `biorouter-session-id` key that Biorouter's MCP client stamps on every tool call's `_meta`. The client composes that key itself; the model supplies arguments, never `_meta`. The chat id is threaded into `BackgroundJobs::spawn` and `ForegroundWorkGuard::register`. Both new tests drive `shell()` with a stamped context, and both failed before this change (`left: None`).
Seventeen PRs landed since this branch left 7c96d79. Seven files conflicted; every resolution keeps both sides' gates. - auth.rs: the served-operator standing (SD-10) and #231's failed-auth budget constants were added at the same spot. Both kept. - commands/agent.rs: #226 gave run() an exit_with_parent parameter. Taken, beside served_operator_capability(), which is unchanged. - routes/web_ui.rs: doc comment only. #226's paragraph (the token is not consumed, SD-9) and this branch's (the cookie's one narrowing reader, SD-10) both kept. - serve-decisions.md: #226's SD-9 and this branch's SD-10 were both appended after SD-8. Both kept, in number order. SD-10 gains a bullet: the standing follows the address, not a person, because SD-9 made the address reusable. - MentionPopover.tsx, CreateWorkflowFromSessionModal.tsx: #235/#243's readKnowledgeSelection() replaces this branch's inline getActive(). It sends the proof and returns null on failure, and its capture-nothing-on-failure path is kept. listBases() keeps the proof: under this branch the daemon omits a private base from a caller without it, which main did not. - KnowledgeContext.tsx: comments only. Both sides already sent the proof, and main's explanatory comments are taken. Also reconciled: browser-access.md no longer says the cookie does "nothing else" (it narrows listings, SD-10), and CLAUDE.md points at SD-1..SD-10. No route came in from main that names a chat or a knowledge base, and every gated call in the renderer files main changed still sends the proof.
… caller could open
GET /active_work returned every running job, subagent, detached turn and
scheduled run to a caller holding only the daemon secret. Each row
carries its chat's id and the shell command or task prompt. POST
/active_work/{id}/cancel stopped any of them. The residual table in
programmatic-session-access.md recorded both as open.
- The list resolves the caller once (`http_caller`) and keeps a row only
if `HttpCaller::lists_work` admits it. That is `lists_session`'s answer
for the row's chat, resolved metadata-only. Refused rows are omitted,
never redacted, and a caller shown every row skips the store reads.
- The cancel resolves its id to the owning chat (the registry entry, or
the running schedule) and asks `work_reach` before anything stops. For
a chat, `work_reach` is `session_reach` by the same call, so it refuses
with GET /sessions/{id}'s exact status and words.
- A row that names no chat is `Unreadable`, so both routes answer it as
a private chat's row. So are an unreadable chat, a handle that names
nothing and a schedule that is not running.
- SD-10: the list honours a serve browser's operator standing, because
it is a listing. The cancel does not, like `session_reach`.
`lists_session` now delegates to a private `admits`, so the listing
decision keeps one spelling. The census gains rows for `lists_work` and
`work_reach`, and updated counts for `session_reach`,
`refuse_unless_reachable`, `http_caller` and `target_tier`.
Tests are lib tests, so CI runs them. The two HTTP regressions in
`bypass_tests` failed before this change: the private chat's row was
listed, and its cancel answered 200 "Requested cancel of 'sub-5'"
instead of the read's 403. Also added:
- the fast path is checked sound at every corner;
- `lists_work` is checked against a real temp store;
- `work_reach` is byte-equal to `session_reach`;
- the route's filter is checked over every row kind, a scheduled run
included;
- a served interface is listed the work and cannot stop it;
- two ordering rows pin the gate ahead of both effects.
…rk gate `just generate-openapi` + `npm run generate-api`. The list route's description now says what it withholds, and the cancel route documents its 403. No other drift.
…'s proof Nothing in the renderer calls `listActiveWork` or `cancelActiveWork` yet (measured; the panel is deferred), so there was no call site to add `userActionHeaders()` to. A call without the proof is not an error. The list comes back 200 with the private chats' work missing. So this source guard fails the day a call is added without the proof. Its positive controls show it firing. A walk-size check stops it passing vacuously, and so does a real file that names `/active_work` only in comments.
… the schedule residual
programmatic-session-access.md: both /active_work rows move out of the
known-residual table. The cancel joins the gated routes and the list
joins the listings. The page now records three things:
- work that names no chat is treated as a private chat's;
- a serve browser keeps its operator's reach on the list and not on the
cancel;
- the scheduled half is still open through /schedule/list,
/schedule/{id}/inspect and /schedule/{id}/kill. The last of these is
added to the residual table, with the chat ids /schedule/list exposes.
privacy-tiers.md: a "What shipped" entry, and open question 10 answered.
The execution plan: question 10 answered. developer.md: the row names
the chat that ran the command. CLAUDE.md: the rule, beside #237's.
…laude/ecstatic-einstein-dd05d6
`POST /schedule/{id}/kill` called `Scheduler::kill_running_job` with no gate
of any kind, so any holder of the daemon secret stopped any chat's scheduled
run. On its own that was a documented residual. What made it worse than that
is the route beside it: `POST /active_work/{id}/cancel` gates the SAME kill,
on the SAME scheduler, by the SAME schedule id. A caller refused there
re-issued the request one URL over and stopped the run anyway — so the gate
was bypassable by a one-word change of URL, and a gate that is trivially
routed around is worse than none, because it reads as protection.
The kill route now resolves the run to the chat it is in and asks the chat
READ's own gate (`work_reach`) before anything is stopped: the same status and
the same bytes as the cancel route, and the same answer for a schedule that is
not running and one that does not exist, so a refusal says nothing about which
it was.
The stop is also now checked against the run it was authorized against.
`kill_running_job_in_session` holds the scheduler's `jobs` lock across the
check AND the cancel, where the two used to be separate acquisitions. A
schedule id is stable across runs while `current_session_id` is not, so a
public run could end and a private run of the same schedule begin between the
gate's read and the kill, and the kill would land on the run nobody
authorized. Both routes now pass the chat they admitted, and both refuse
rather than stop a run that changed underneath. There is deliberately no
`.await` between the lock and `token.cancel()`.
Fail-before evidence: the new row in
`every_gated_route_resolves_the_tier_before_it_touches_the_session` panicked
with "pub async fn kill_running_job( does not consult its session-reach gate
(`work_reach(`)" against the unfixed handler.
Census: the `work_reach` guard gains a second site (schedule.rs), and the
`session_reach` module-qualifier row for schedule.rs goes 1 -> 2 refs. The
`cancel_active_work` ordering row's action needle follows the rename to
`kill_running_job_in_session(`.
…hat could open it
`GET /schedule/{id}/inspect` answered any holder of the daemon secret with the
`session_id` of the chat a schedule is running in, and the run's start time.
That is exactly the work-to-chat association `GET /active_work` omits and `GET
/schedule/list` redacts — handed over whole, one route away, by a handler that
did not even accept a `HeaderMap`.
It now resolves the run once, gates on the chat with `work_reach`, and builds
the answer from the value it already resolved. Asking the scheduler a second
time after the gate would be a second read of a fact that can change between
them, which is the shape of defect the sibling kill route just fixed.
The refusal is the chat read's own, so an unadmitted caller cannot tell a
private chat's running schedule from a schedule that is idle or absent: all
three resolve to no reachable chat and answer identically. An admitted caller
still gets the truth, including the 404.
Fail-before evidence: the new ordering row panicked with "pub async fn
inspect_running_job( does not consult its session-reach gate (`work_reach(`)"
against the unfixed handler.
Census: `work_reach` in schedule.rs goes 1 -> 2 calls, and the module-qualifier
row for that file 2 -> 3 refs. Both are the existing rows extended, not new
ones.
…d open `GET /schedule/list` returned every schedule on the machine with the chat that created it (`creator_session_id`) and, for a running one, the chat the run is in (`current_session_id`) — to any holder of the daemon secret, from a handler that did not accept a `HeaderMap`. That is the same work-to-chat association `GET /active_work` omits, and it made this route the easy way around it. ⚠ This one REDACTS rather than omitting, and the difference is the subject. Everywhere else a row IS its chat's content, so the row goes. A schedule is not a chat: it is a cron line and a workflow path that merely NAME chats, and an idle or paused schedule names none at all. Applying the sibling routes' "names no chat is unreadable" rule row by row would therefore drop every non-running schedule for every caller without a private capability — emptying the Schedules interface in order to close an association. So every row stays and the two chat-naming fields go. The fixture list in the new test carries an idle schedule naming no chat precisely to pin that. The two fields are asked about separately: a schedule created from a public chat can be running in a private one, and the reverse. No response shape change, so no client regeneration is forced: both fields are already `Option<String>`, redaction yields the `null` an idle schedule has always produced, and the generated TS is already `?: string | null`. Measured what the client does with a missing one before changing it — `ScheduleDetailView.tsx:504` guards with `running && current_session_id &&`, so it simply omits the line; `creator_session_id` has no renderer reader at all. `lists_work` is reused rather than joined by a `may_name_chat` beside it: "may this caller be told this chat exists" must have one spelling, and a second is the drift the census exists to catch. Its doc now names both subjects. Fail-before evidence, both halves, each measured by breaking exactly one thing: - the decision, with the `current_session_id` arm neutered: "a private chat was named in the schedule listing (proof: false)"; - the wiring, with the handler's call removed: "`GET /schedule/list` does not redact the chats it names". An earlier HTTP-level version of this test also captured the raw leak (`"current_session_id":"20260912_1"` for a private chat) before the fix; it was replaced because seeding a schedule needs `add_scheduled_job`, which registers on the process-global tokio-cron-scheduler while each `#[tokio::test]` brings its own runtime — it passes alone and fails with `CantAdd` in the suite. The chats, tiers and caller in the replacement are real; only the rows are hand-built, and a body scan holds the route's wiring. Census: `lists_work` gains a schedule.rs site (2 calls), `http_caller` there goes 1 -> 2, and the module-qualifier row 3 -> 5 refs. Existing rows extended, never duplicated.
Three documents and two module headers still described `GET /schedule/list`,
`GET /schedule/{id}/inspect` and `POST /schedule/{id}/kill` as an open hole.
They are now gated, so saying otherwise is not a harmless stale line — a
residual table that lists a closed hole teaches the next reader to distrust the
table, and one that omits an open one is worse.
- `programmatic-session-access.md`: the three routes leave "Ungated, and a known
residual". `kill` and `inspect` join the gated table, with the reason `kill`
had to be gated (it reaches the same `Scheduler` kill as
`/active_work/{id}/cancel` by the schedule id, so an ungated twin made that
gate bypassable by a one-word change of URL). `GET /schedule/list` joins the
listing table with its own note: the one listing that redacts FIELDS instead
of dropping rows.
- `routes/active_work.rs` and `routes/session_reach.rs` headers say the same,
and say where the gate lives, so neither file claims a hole its neighbour
closed.
⚠ `POST /schedule/{id}/run_now` and `POST /schedule/create` stay in the
residual table. They launch scheduled work that may run in a private session,
which is a different question from naming or stopping an existing run, and
nothing here closes them.
OpenAPI: `just generate-openapi` (+ `npm run generate-api`). `kill` gains 403 /
404 / 400 and an `id` param it never documented; `inspect` gains 403; `list`'s
200 now says which fields a row may lose and that rows are never dropped. The
400 wording follows `classify_kill_error`, which maps the run-changed refusal to
BAD_REQUEST — the annotation said 409 for one draft and was corrected to what
the code actually returns.
`ScheduledJob`'s two fields were already `Option<String>`, so redaction changes
no schema; the +24 lines in `types.gen.ts` are the new responses.
Picks up PR #262, which fixes the harness defect this branch's CI kept tripping over: `MentionPopover.privateChat.test.tsx` installed a jsdom `scrollIntoView` stub in `beforeEach` and deleted it in `afterEach`, but the scroll runs from a PASSIVE effect that React flushes during setup.ts's `cleanup()` — which vitest runs AFTER the spec's own `afterEach`. Measured: the polyfill is on `origin/main` (99bf415) and on neither PR branch, and both branches still carried the per-test install/delete at lines 77-78 and 109-111. One scheduler turn wide, so it only opens on a loaded runner. Conflicts, and how each was settled: * CLAUDE.md, docs/deployment/serve-decisions.md, docs/deployment/programmatic-session-access.md — both sides appended a numbered record or a table row. Kept BOTH, in number order: this branch's SD-10 then main's SD-11, and main's `/agent/cancel` row beside the `authorize_agent_control` row it shares a mechanism with. The SD range in CLAUDE.md becomes the union, SD-1..SD-11. * routes/session_reach.rs — the module doc's gated list and its "two spellings, one list" paragraph. Union of both sides' rows; the count is now seven routes reached through a helper (four via `authorize_agent_control`, two more via `authorize_turn_control` into it, and `/active_work/{id}/cancel` via `work_reach`). The over-read control list drops `get_session_extensions`, which this branch GATED and which therefore can no longer serve as a control, and keeps main's extra `reply::routes` control. * routes/agent.rs — both branches gated `GET /agent/callable_tool_count`, independently. Kept main's body (it handles an initializing child and places the gate before the agent is minted) inside this branch's wrapper, because the wrapper is what makes the refusal PLAIN TEXT through `SessionOutOfReach::into_response` instead of JSON through the route's `ErrorResponse`. That is not cosmetic: `one boundary has one body` is measured by two tests in `routes::session_reach`, and `?` failed both. This branch's `GET /agent/tools` gate is untouched by the merge and its `/agent/tools` row replaces main's now-false "deliberately not here" note — reach and Gate-E filtering are different questions, and only the second stays deliberate there. * tests/privacy_guard_wiring.rs — one census row per file, extended not added: agent.rs is `c(6, 6, 0)`, this branch's two new gates plus the four that were already there, with main's SD-11 note that the turn-control gate adds no call. Verified by running the census rather than by counting. * ui/desktop KnowledgeContext.tsx, useHistory.ts, useKnowledgeBases.ts — main's refactor already sends the user-action proof this branch was adding (`fetchKnowledgeSelection`, `refreshBases`), so main's side stands, keeping only this branch's proof header on the base delete. The `setPrimaryKbId(null)` beside it goes: the docstring directly above it says there must not be one, because the delete is itself the repair (D2). * openapi.json / types.gen.ts — kept in step with the utoipa annotation. Also fixes the one genuine, deterministic failure on this branch: `crates/biorouter-server/tests/privacy_ar15_is_retired.rs` refused the new line `the secret as "not a human" for changing a tier (AR-11/AR-15)` in docs/security/privacy-tiers-execution-plan.md — a bare citation of a closed risk with no disclosure word within 250 bytes, which reads as a live one. Fixed in the prose: both risks are now links, and the sentence says AR-15 is retired and that the tier route's posture is *why*. Measured after the merge: cargo test -p biorouter-server --test privacy_ar15_is_retired 7 passed cargo test -p biorouter --test privacy_guard_wiring 3 passed cargo test -p biorouter-server --lib -- routes::session_reach 47 passed
Broccolito
added a commit
that referenced
this pull request
Sep 12, 2026
Broccolito
added a commit
that referenced
this pull request
Sep 12, 2026
Merged with `-X diff-algorithm=patience`, which is what kept `session_reach.rs` to four hunks of ≤14 lines instead of one interleaved block: 20 hunks over 10 files, and the two branches had already agreed about the thing that mattered most. **`callable_tool_count` needed no reconciling.** #257 and #237 resolved it identically — main's body inside a wrapper that returns the refusal through `SessionOutOfReach`'s own `IntoResponse`, plain text, not `?` through this route's JSON `ErrorResponse`. All three hunks in `routes/agent.rs` were comment-only; the code matched line for line. One gate remains, and it is that one. The two ⚠ notes are combined: the integration side names the mechanism and where the rule is written down, this branch's names the sibling with the same shape (`get_tools`) and the test that fails on the wrapping alone with the words unchanged. **The census caught what neither branch could see.** `refuse_unless_reachable` was declared `c(4, 0, 0)` on BOTH sides and the count auto-merged clean, because each branch had added one subject and written "four": #257/#278 added `work_reach`'s no-chat arm (and folded `lists_session`/`lists_work` into `HttpCaller::admits`), #237 added `mints_knowledge_base`. Measured on the merged tree: **5 calls**. Corrected to `c(5, 0, 0)` by running the census, not by counting with grep. The other rows needed no number: `agent.rs` stayed `c(6, 6, 0)` and `knowledge.rs` auto-merged to `c(1, 7, 0)`, where only this branch's prose explains the seventh ref — `http_caller` is called from four handlers now that `POST /knowledge/bases` is gated. One row per file throughout; `lists_work`, `work_reach` and `mints_knowledge_base` are three separate registry entries, kept in the file's thematic order. `routes/session_reach.rs`, four hunks: the module header takes this branch's correction (the sidebar does NOT page by scanning any more, and `has_more` cannot count what it hid because the tier is a SQL predicate and the page resumes from a keyset) *and* the integration's `GET /active_work` addition. The other three take the integration side, which is a strict superset: it counts three helper-gated rows and seven routes where this branch counted two and six, and it names `reply::steer_refusal`. `authorize_steer` no longer exists, so every mention of it here was stale — SD-11's settled ruling is that `/interrupt` asks for the proof on both kinds of daemon. Docs: `serve-decisions.md` keeps SD-1..SD-13 in number order, nothing renumbered, no duplicate heading. `CLAUDE.md` keeps the numbering-free "(the `SD-n` records)" — this branch's "SD-1..SD-11" was already wrong at SD-13. `programmatic-session-access.md` keeps the integration's `/schedule/list` field-redaction row and `/active_work` section, with this branch's `next_offset` → `next_cursor` correction. The execution-plan note takes the integration's fuller form, which carries the anchors and says why AR-15 is closed. `openapi.json` and `types.gen.ts` were not hand-resolved: one side taken, then `generate_schema` + `openapi-ts` run, and a second run confirmed zero diff. Verified on the merged result, BIOROUTER_DISABLE_KEYRING=true throughout: biorouter-server --lib routes:: 544 · knowledge_routes 68 · privacy_capability 4 · privacy_guard_wiring 3 · privacy_toggle 1 · privacy_disclosure_toggle 4 · biorouter --lib privacy:: 246 · cargo fmt --check clean · frontend lint:check and format:check clean · vitest 5293 passed, 1 skipped, 0 failed over 470 files. The `declassify` DEFERRED-transaction race did not fire in this run.
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.
What was open
docs/deployment/programmatic-session-access.md's residual table recorded it as the most significant open item.GET /active_workreturned every running background job, foreground command, subagent, detached turn and scheduled run to any caller holding the daemon secret. Each row carries its chat'ssessionIdand atitle/detailholding the shell command or task prompt, which is content rather than metadata.POST /active_work/{id}/cancelstopped any of them by registry id, also ungated.Fail-before. These are the new regression tests, run against #237's head with only the tests added:
All four pass after the change.
What changed
The list.
GET /active_workresolves the caller once (http_caller) and keeps a row only ifHttpCaller::lists_work(manager, row.session_id)admits it. That is exactlylists_session's answer for the row's chat, resolved metadata-only throughtarget_tier. A refused row is omitted, never redacted. A caller that is shown every row (the desktop's proof, a private capability, or tiers off) is answered without a store read. This is the sidebar's fast path, and a test pins its soundness at every (switch × capability × served standing × proof) corner.The cancel. The route resolves the id to the owning chat: the registry entry (new
ActiveWorkRegistry::get), or the running schedule (get_running_job_info). It then askswork_reachbefore anything is stopped. For a chat,work_reachissession_reach, by the same call, so the status and the plain-text words matchGET /sessions/{id}'s byte for byte. Two rows added toevery_gated_route_resolves_the_tier_before_it_touches_the_sessionpin the gate ahead of both effects (kill_running_job(andactive_work().cancel().Decision: a row with no session id is treated as
Unreadable. That means it is handled exactly like a private chat's row. It is listed to, and can be stopped by, only a caller that could open a private chat: the desktop app (proof), a program stating a private capability, or anyone with tiers switched off. The same answer covers a chat that cannot be read, a handle that names nothing, and a schedule that is not running, so a refusal does not say which of them it was. A caller that is admitted still gets the truthful 404. The reason: an unattributed row still carries some chat's command or prompt, and nothing says whose. This is written down insession_reach.rs(lists_work,work_reach), inroutes/active_work.rs's module header, onActiveWorkItem::session_id, and in the user-facing doc.Attribution, so that decision bites only where it must. Background jobs and foreground commands registered with
session_id: None. The rule above alone would therefore have hidden every shell command, public chats' included, from any caller not shown private chats. The developer server now reads the dispatching chat from thebiorouter-session-id_metakey and threads it intoBackgroundJobs::spawnandForegroundWorkGuard::register. Biorouter's MCP client stamps that key on every tool call, and the client composes it itself (McpMeta::inject_into_extensions); the model supplies arguments, never_meta. TheUnreadablearm is left to work that genuinely has no chat. This is a separate commit and can be dropped on its own if unwanted.SD-10.
GET /active_workis a listing, so abiorouter servebrowser keeps its operator's reach there. The cancel names one chat's work, so, likesession_reach, it never reads the served-operator standing. The browser can be listed work it cannot stop, just as it is listed chats it cannot open or delete. Pinned ina_served_interface_keeps_its_listing_reach_and_gains_no_transcript.Renderer. Measured: nothing in
ui/desktop/srccallslistActiveWorkorcancelActiveWork(the panel is deferred; only the generated client exports them), so there was no call site to adduserActionHeaders()to. Instead,ui/desktop/src/activeWorkUserProof.test.tsis a source guard that fails the day such a call is added without the proof. Without the proof the call is not an error: it returns a silent 200 with the private chats' work missing. The guard's positive controls show it firing, and a walk-size check keeps it from passing vacuously.Census. In
crates/biorouter/tests/privacy_guard_wiring.rs: new rows forlists_workandwork_reach(bothWired, fromroutes/active_work.rs), and updated counts and reasons forsession_reach,refuse_unless_reachable,http_callerandtarget_tier.lists_sessionnow delegates to a privateadmits, so there is still one spelling of the listing decision.Docs. In
programmatic-session-access.md, both/active_workrows moved out of "Ungated, and a known residual" into the gated and listing tables, and the no-session-id decision and the SD-10 split are written down. Also updated:session_reach.rs's module header, open question 10 inprivacy-tiers.md, question 10 in the execution plan, the "What shipped" ledger,developer.md, and theCLAUDE.mdrule list.Nothing refused before is permitted now
Every change narrows what a caller gets. A public chat's work stays listed to, and stoppable by, a secret-only caller, exactly as before. The gate is deliberately inert on public chats, as it is for
GET /sessions. Rows of private chats, rows naming no chat, and rows naming an unreadable chat are now withheld from a caller with neither proof nor private capability. A cancel that used to answer an unknown id with 404 now answers such a caller with the 403. Attribution only adds a chat id to rows that had none, and each of those rows is then judged by that chat's tier.Also closed here: the schedule routes that made the
sched:gate bypassable⚠ Added after an adversarial security review rated
POST /schedule/{id}/killHIGH, andit was right. This PR's
/active_work/{id}/cancelgate has asched:arm that stops ascheduled run.
POST /schedule/{id}/killreached the sameSchedulerkill, by thesame schedule id, with no gate of any kind — so that arm's gate was bypassable by a
one-word change of URL and protected nothing, while reading as protection. A documented
residual is honest; a gate that is trivially routed around is worse than no gate. Three
commits, one per surface, each revertable alone.
POST /schedule/{id}/killwork_reach, and the stop is checked against the run it was authorized againstGET /schedule/{id}/inspectGET /schedule/listGET /schedule/listredacts fields rather than dropping rows, and that is deliberate.Everywhere else a row IS its chat's content, so the row goes. A schedule is not a chat: it
is a cron line and a workflow path that merely name chats, and an idle or paused schedule
names none at all. Applying the sibling routes' "names no chat ⇒
Unreadable" rule row byrow would therefore drop every non-running schedule for every caller without a private
capability — emptying the Schedules view in order to close an association. The new
test's fixture list carries an idle schedule naming no chat precisely to pin that it
survives for every caller.
No schema change: both fields were already
Option<String>, redaction yields thenullanidle schedule always produced, and the generated TS is already
?: string | null. What theclient does with a missing one was measured before the change —
ScheduleDetailView.tsx:504guardsrunning && current_session_id &&so it omits the line,and
creator_session_idhas no renderer reader at all.lists_workis reused rather than joined by a newmay_name_chat: "may this caller be toldthis chat exists" must have ONE spelling, and a second is the drift the census exists to
catch. Only what a
falsedoes differs.The TOCTOU this change made live, and closed
⚠ A precondition for anyone touching this again:
jobsmust be held acrosscheck-and-kill.
get_running_job_infotook and releasedScheduler::jobs(
scheduler.rs:2000) andkill_running_jobre-acquired it (:1957). A schedule id isstable across runs while
current_session_idis not, so between the gate's read and thekill a public run can end and a private run of the same schedule begin — and the kill lands
on the run nobody authorized. Ungated, that window granted nothing (the whole route was
open). Gating the route is exactly what makes it exploitable, so the two had to ship
together.
Scheduler::kill_running_job_in_sessionacquiresjobsonce and holds it across thecheck and the
token.cancel(), and refuses when the run no longer matches the chat thecaller was admitted to. There is deliberately no
.awaitbetween the lock and thecancel; re-introducing one re-opens the window and nothing in the type system will say so.
running_tasks(a std mutex) is taken inside thejobsguard — the same nestingclaim_run_slotandrun_nowuse, so the order cannot deadlock./active_work's schedulerarm now calls the checked variant too.
SchedulerTraitgains the method with no defaultbody, deliberately: a defaulted delegation to the unchecked kill would be a silent hole
for any future implementor.
Fail-before evidence (each measured by breaking exactly one thing)
pub async fn kill_running_job( does not consult its session-reach gate (work_reach()pub async fn inspect_running_job( does not consult its session-reach gate (work_reach()current_session_idarm neutered):a private chat was named in the schedule listing (proof: false)`GET /schedule/list` does not redact the chats it names⚠ A discarded first attempt, recorded because it is a trap for the next person. The list
test was originally a real HTTP test seeding schedules with
add_scheduled_job. It capturedthe leak verbatim (
"current_session_id":"20260912_1"for a private chat) and then passedalone and failed in the suite with
SchedulerInternalError("CantAdd"):add_scheduled_jobregisters a task on the process-global tokio-cron-scheduler, while every#[tokio::test]brings its own runtime, so a scheduler started under an earlier test'sruntime is already dead. It is now a decision test (real chats, real tiers, real
HttpCaller; hand-built rows) plus a body scan for the wiring.Still not closed
POST /schedule/{id}/run_nowandPOST /schedule/createremain in the residual table.They launch work that may run in a private session, which is a different question from
naming or stopping an existing run, and nothing here closes them.
Not closed here
The scheduled half, through the schedule routes (recorded in the residual table).
⚠ Correction to an earlier reading of this residual:
GET /schedule/{id}/sessionsisNOT part of it — fix(privacy): one reach gate for every HTTP route that names a chat or a knowledge base (QA H2, M1, M2, F0) #237 already gated it (
sessions_handlerbuilds anhttp_callerand filters rows before applying the limit). What remains open isexactly three routes, none of whose handlers even accepts a
HeaderMap:GET /schedule/list(routes/schedule.rs:191),GET /schedule/{id}/inspect(:604)and
POST /schedule/{id}/kill(:549). A caller this PR refuses a private chat'sscheduled run can still find its chat and stop it there.
Why this is a design decision and not a small mechanical addition — the part worth
carrying forward, because the mechanical version looks tempting and is wrong twice:
inspectandkillalone would be security theater. Both resolvetheir owner through
scheduler::get_running_job_info, the very callPOST /active_work/{id}/cancelalready uses, so gating them is three lines — butGET /schedule/listhands out the identicalcurrent_session_idfor every runningjob, so a caller refused by
inspectreads the same fact one route over. A gatethat visibly refuses what its neighbour gives away is worse than an honest hole: it
invites the belief that the surface is closed.
GET /schedule/listcannot be closed by applyinglists_workrow by row. Anidle or paused schedule has no
current_session_idat all, so this PR's "names nochat ⇒
Unreadable" rule would hide every non-running schedule from every callerwithout a private capability — emptying the Schedules interface for an ordinary
public caller. A schedule is not a chat: it has a creator chat, a current-run chat,
and sometimes neither, and deciding which governs a row (and what an idle row falls
back to) is a decision this PR does not take.
⚠ A precondition on whoever closes
POST /schedule/{id}/kill: hold thejobslockacross check-and-kill.
get_running_job_infotakes and releasesself.jobs(
scheduler.rs:2000) andkill_running_jobre-acquires it (:1957). A schedule id isstable across runs while
current_session_idchanges per run, so between the two apublic run can end and a private run of the same schedule can begin, and the kill lands
on the private one. This PR's
sched:cancel arm inherits that window. It grantsnothing today —
POST /schedule/{id}/killis entirely ungated, so the same stop isavailable with no race at all — but it becomes a live finding the moment that route is
gated. Closing the hole without closing the window would reintroduce it as the
surviving path. The registry arm has no such window: ids are minted from a monotonic
AtomicU64and never reused, and an entry'ssession_idis written once atregister,so the id→chat binding cannot be rebound under the gate.
A subagent row names its child's id in
detail, and the row is judged by the parent's tier. In normal operation the two are equal, because a spawn never changes the tier. They diverge only if the user hand-raises the running child alone to private. A public caller shown the public parent's row would then learn the private child's id, but not its content. That is a narrow metadata edge. Closing it needs the registry to carry both chats structurally rather than the child's id as text.ActiveWorkRegistry::getis a registry accessor, not a reach decision, and lives outside the census'sCOMPLETE_MODULES, so it carries no census row. That is the same reasoningcaller_capabilityrecords for itself.Base branch state
Resolved — this branch is now current and mergeable. #237 (
claude/sweet-pare-965d18)brought itself up to date with
mainin its own catch-up merge, resolving the sevenconflicted files that were previously attributed to this PR —
auth.rs,commands/agent.rs,web_ui.rs,serve-decisions.md,MentionPopover.tsx,KnowledgeContext.tsx,CreateWorkflowFromSessionModal.tsx. None of the seven is inthis PR's own 15-file diff; the conflicts were always #237's.
This branch then merged #237's current head. The merge was clean (no conflicts), and
git merge origin/mainreported Already up to date —git rev-list --count HEAD..origin/mainis 0. #237's two load-bearing resolutions were verified in themerged tree rather than taken on trust:
web_ui.rsscopes its absolute sentence toauthentication ("gates the document, and authenticates nothing else") so the SD-10
narrowing paragraph below it is not a contradiction, and
serve-decisions.mdrunsSD-1…SD-10 in number order with nothing renumbered.
⚠ The base stays
mainon purpose. Merging #237 auto-deletes its head branch, whichwould close this PR if it were targeted there. This PR is stacked on #237's content
while targeting
main, and must stay that way. #237 must still merge first — thisbranch cannot be cherry-picked onto
main, becauseroutes/active_work.rscallsHttpCaller,http_caller,lists_work,work_reachandCALLER_PROVIDER_HEADER, andHttpCallerdoes not exist onmain.⚠ Reading this PR's diff:
gh pr view 257 --filesreports ~62 files, the union of#237's commits and this one's, because #237's head is an ancestor. This PR owns 15
files, +1318/−69. The reliable anchor is the merge-base,
git rev-parse 4eabd13f^(
01115e7a) — notorigin/claude/sweet-pare-965d18..HEAD, which went stale the moment#237 merged
mainand now reports 141 files.Verification
All re-run on the merged tree (this branch is 0 behind
main).cargo test -p biorouter --test privacy_guard_wiring --test privacy_capabilitycargo test -p biorouter-server --lib --bins --no-fail-fastbiorouterdbin 612 passed, 0 failedcargo test -p biorouter-server --tests --no-fail-fastcargo test -p biorouter-mcp --lib -- developer:: active_workjust generate-openapi+npm run generate-apitypes.gen.ts+24/−1,sdk.gen.ts4 lines,index.ts2ui/desktop:npm run lint:checkui/desktop:npm run test:runcargo fmt --all && cargo fmt --checkcargo clippy -p biorouter-server -p biorouter --all-targets -- -D warningsCargo runs used
BIOROUTER_DISABLE_KEYRING=true.⚠
npm run test:runexits 1 with zero failing tests. The only failure is a suitefailure:
src/utils/artifactCdnAssets.browser.test.ts'safterAll→browser.close()exceeding the 30 s hook timeout. Pre-existing and reproducible on pristine
main; unrelatedto this change.
⚠ One RED Rust test, and it is #237's — proven, not asserted.
cargo test -p biorouter-server --test privacy_ar15_is_retiredfailsno_document_still_describes_it_as_open: "privacy-tiers-execution-plan.md mentions AR-15 atbyte 491052 with nothing nearby saying it was retired." Two independent measurements place
it outside this PR:
git diff --quiet 187cd216 HEADreports UNCHANGED fortests/privacy_ar15_is_retired.rs,privacy-tiers-execution-plan.md,privacy-tiers.mdandprivacy-tiers-implementation-brief.md. A test whose every input is unchanged cannot havebeen turned red here.
936f7f8b, whichgit merge-base --is-ancestorplaces onclaude/sweet-pare-965d18and NOT onorigin/main— so it isfix(privacy): one reach gate for every HTTP route that names a chat or a knowledge base (QA H2, M1, M2, F0) #237's, and not a pre-existing failure of
maineither.Deliberately not fixed here: it is a one-line doc edit, but it is #237's security
documentation and that branch is being actively committed to, so editing it concurrently
invites a merge conflict in exactly the files that most need a careful read. #237 owns the
fix, and since #237 merges first, this PR goes green the moment it lands.
Security-sensitive (privacy gates on auth-adjacent routes, plus a lock-ordering change in the
scheduler), so it needs human review. Do not merge without it, and not before #237.
🤖 Generated with Claude Code