Skip to content

fix(privacy): one reach gate for every HTTP route that names a chat or a knowledge base (QA H2, M1, M2, F0) - #237

Merged
Broccolito merged 5 commits into
mainfrom
claude/sweet-pare-965d18
Sep 12, 2026
Merged

fix(privacy): one reach gate for every HTTP route that names a chat or a knowledge base (QA H2, M1, M2, F0)#237
Broccolito merged 5 commits into
mainfrom
claude/sweet-pare-965d18

Conversation

@Broccolito

@Broccolito Broccolito commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

What QA measured (merged main 7c96d79, 2026-09-10, X-Secret-Key only)

Finding Measured
H2 Private knowledge base over HTTP GET /knowledge/bases/{id}/page, /pages, /pages/{path}, /graph, /history, /export, /location and GET /knowledge/bases handed a private base's content to a caller holding only the daemon secret. The tool path refused the same caller.
M1 Session listing GET /sessions returned every row, private ones included. This was the open question in session_reach.rs.
M2 Tool surface GET /agent/tools?session_id=<private> listed a private chat's extension tools.
F0 Delete versus read GET /sessions/{private} returned 403, but DELETE /sessions/{private} returned 200 in 4 of 4 tries.

The root cause is the same for all four. The axum handlers call the services directly, and the plan's scope note ("the Knowledge view is the user, not a model") treated holding the secret as proof that a human was asking. It is not: the agent can recover the secret (AR-11).

The fix: one gate, applied everywhere

Every HTTP route that names a chat or a knowledge base now asks the same question the single-chat read asks, through routes::session_reach:

  • A caller that proves it is the user (X-User-Action) keeps full access, and so does one that states a private capability (X-Caller-Provider).
  • A caller with only the secret is treated as a public model. It gets the read path's refusal, byte for byte, and a missing id gets the same answer, so the refusal never confirms that something exists.

Where it is wired:

  • Knowledge bases (H2). One route_layer (gate_knowledge_base) covers a sub-router that holds every /knowledge/bases/{id}… route: reads, writes, macros, tier, merge, restore, export, delete and the credibility override. A route added there later is gated automatically. GET /knowledge/bases and GET|POST /knowledge/active are filtered, and a write to the selection can only change what that caller can see (KnowledgeService::set_selection_within). ingest-conversation gates each session it reads.
  • Listings (M1): filter, not refuse. This answers the open question in session_reach.rs. Refusing the whole list would break every client for public chats, which the gate is designed to leave alone. GET /sessions, /sessions/sidebar (paged correctly across the filter) and /schedule/{id}/sessions show each caller exactly the rows the single-chat gate would let it open. Rows are omitted, not redacted.
  • Tool surface (M2). GET /agent/tools?session_id= and /agent/callable_tool_count go through the gate.
  • Mutations (F0, and a sweep for the same bug elsewhere). DELETE /sessions/{id} is gated before cancel_turn. The sweep also gated rename, user_workflow_values, in-place edit_message, /sessions/{id}/extensions, /sessions/{id}/usage, POST /workflows/create (which reads the transcript) and POST /skills/session.
  • Desktop renderer. Every call the gate now covers sends the user's proof (userActionHeaders()), so the desktop app works as it did before. The OpenAPI spec and the generated client are regenerated.

biorouter serve: SD-10 (new decision record)

A serve daemon holds no proof, because its stdin is closed (SD-7). A request that carries the served page's HttpOnly; SameSite=Strict cookie gets the tier of the provider the operator configured (SD-1) on listings and knowledge bases only. A private provider therefore keeps the History list and the Knowledge view it had before. A public provider, or anything holding only the secret, is treated as public.

The transcript gate never reads this standing. Opening, exporting, deleting or renaming a private chat from the browser is refused exactly as before. Whether a serve operator should reach private transcripts is recorded as an open decision and is not taken here. The cookie narrows a caller that already holds the secret; it never admits anyone, so there is no CSRF surface. Details: docs/deployment/serve-decisions.md SD-10.

Interactions with other open PRs

Refused before, and still refused

No floor, raise_privacy or .call_tool( call site was added. The privacy wiring census passes with the new guards registered. session_reach itself is unchanged, and a source-scan test pins that it never reads the serve standing.

Evidence

Runtime: before and after on my own sandboxed daemons

Both daemons ran against identical fixtures: one private knowledge base (rt-private-probe), one private chat (imported), and empty data otherwise. The config came from the seed and the digest was piped on stdin. Every row below used the secret only:

Route before (7c96d79) after (this branch)
H2 GET /knowledge/bases/{kb}/page 200, page text 403
H2 GET …/pages 200 403
H2 GET …/pages/{path} 200, page text 403
H2 GET …/graph 200 403
H2 GET …/history 200 403
H2 GET …/export 200 (19 KB .brkb) 403
H2 GET …/location 200 403
H2 GET /knowledge/bases 200, private base listed 200, private base omitted
M1 GET /sessions 200, 1 private row of 1 200, 0 private rows
M2 GET /agent/tools?session_id={private} 200, 3 tool names 403
F0 GET /sessions/{private} 403 403
F0 DELETE /sessions/{private} 200, chat deleted (read back: 404) 403, chat intact (read back: 200)
  • With the proof (X-User-Action), after: every row returns 200, the transcript included, and DELETE returns 200.
  • Refusal bodies, after: the knowledge-base routes are byte-identical to the page read's refusal (6/6, including a missing id). The chat routes are byte-identical to GET /sessions/{id}'s refusal (4/4, including a missing id).

Desktop app

I ran a sandboxed dev GUI from this worktree on CDP port 9391, with this branch's biorouterd:

  • The sidebar and Chat history list the private fixture chat, and opening it shows the transcript.
  • The Knowledge view selects the private base and shows the Private badge, 1 page and the graph.
  • The captured traffic was 70 session requests and 55 knowledge requests. All returned 200 except one long poll still open; there were no 403s. GET /sessions/{id} carries X-User-Action.

biorouter serve

Same fixtures, CLI and daemon from this branch, driven in a headless browser:

  • Private provider (versa_azure). The daemon logs capability=Private. History lists the private chat. Opening it shows the SD-7 keyless refusal ("Nothing was read and nothing was changed … use the desktop app."). The Knowledge view reads the private base.
  • Public provider (claude_code). The daemon logs capability=Public. History shows no chats, and the private base's name appears nowhere in the Knowledge view. The stored machine-wide selection (.active-kb, which still names the private base) was not rewritten while this caller was browsing.

Fail before

These are the new tests run against base sources (logs kept locally):

  • routes::session_reach lib tests: 6 failed, 1 passed. GET /sessions/{id} refused a secret-only caller while 20 routes answered it differently: DELETE … 200, PUT …/name 200, GET /agent/tools?session_id= 200 with tool names, …/extensions 200, edit_message 200, and more.
  • knowledge_routes::h2_http_barrier: 4 failed. The private page was served with 200 where 403 was expected, and ingest-conversation answered 400 where 403 was expected.

Mutation checks on the new lib tests

  • Knowledge-base layer bypassed: both new tests fail.
  • Transcript gate made to honour the serve cookie (the one widening this change must never make): both the source-scan test and the new runtime test fail.

Suites

Command (all with BIOROUTER_DISABLE_KEYRING=true) Result
cargo fmt --all -- --check clean
cargo test -p biorouter-server --lib --bins 1194 passed (603 lib + 591 bin)
cargo test -p biorouter-server --test knowledge_routes --test privacy_toggle_config --test serve_operator_reach 82 passed
cargo test -p biorouter --lib privacy:: 232 passed
cargo test -p biorouter-mcp --lib knowledge::tier 31 passed
cargo test -p biorouter --test privacy_guard_wiring (census) 3 passed
./scripts/clippy-lint.sh clean
just generate-openapi + npm run generate-api no drift
ui/desktop: npm run lint:check, npx vitest run OK; 442 files, 4969 passed

Until #239 lands, CI runs only cargo test --workspace --lib --bins (rust.yml:261), so integration binaries are not what keeps these gates in place. That includes knowledge_routes, serve_operator_reach and the privacy wiring census. The knowledge-base route sweep and the serve standing therefore also have copies in routes::session_reach's lib tests, driven through routes::configure. Clippy caught one of those copies failing to build in the bin target; --lib alone would not have.

Documentation

  • docs/security/privacy-tiers.md ("What shipped")
  • The execution plan's scope note, which is marked superseded, and OQ15 (b), which is answered
  • docs/deployment/serve-decisions.md (SD-10), browser-access.md, serve-architecture.md and programmatic-session-access.md (the routes that honour the header, and the listing and knowledge-base table)
  • One CLAUDE.md bullet: holding the daemon secret does not make a caller the user

Left open on purpose (documented as residuals)

  • GET /sessions/running, /sessions/changes, insights/activity, GET /active_work and the skills catalog were not gated here. They are listed in programmatic-session-access.md.
  • UX: on a public-provider serve daemon, the History empty state reads "No chats yet" even when private chats exist. The copy could say that some chats are hidden.

Security-sensitive (auth and privacy gates), so it needs human review. Do not merge without it.

🤖 Generated with Claude Code

Broccolito added a commit that referenced this pull request Sep 11, 2026
serve-decisions.md conflicted: main's #226 added SD-9 (the launch token
works until the daemon stops) and made the same SD-1 route fix this branch
made. SD-1 takes main's wording; main's SD-9 stays SD-9; this branch's
record becomes SD-12, after it. SD-10 and SD-11 are claimed by open #237
and #240, so SD-12 duplicates no number on main or in any open PR.

Every reference to this branch's record moves with it (code comments, the
two OpenAPI 409 descriptions, the daemon's keyless warning, the docs and
their anchors, CLAUDE.md). References to main's SD-9 are untouched.
CLAUDE.md's '(SD-1..SD-9)' range is replaced by no number at all, since
every record that lands would make it stale.
Broccolito added a commit that referenced this pull request Sep 12, 2026
…not the renderer's restraint

#260 stopped the renderer calling this route for a subagent's chat, because it
answers through `get_or_create_agent` and would mint a bare placeholder agent
under the child's session id. The review's point is that routing a client around
an ungated route leaves the route ungated: it had no gate of any kind — not
`session_reach`, not the subagent refusal — so any caller holding the daemon
secret could name any chat and have an agent materialised for it, with the
route's own 424 reporting what it had found.

Gated the way the tier-bearing session reads are (`GET /sessions/{id}`, `POST
/agent/resume`): `session_reach` FIRST, before the agent is fetched. The
ordering is the substance — a gate under the fetch would satisfy a status
assertion and still create the agent, which is why the test asserts
`peek_agent(..).is_none()` as well. This is the hazard `agent_add_extension`
already spells out at its own gate.

#237 does not cover it. `routes::session_reach` is already on `main` and
`get_callable_tool_count` was not among its four call sites in this file, so
there is no conflict to avoid.

⚠ The review's premise about the sibling is wrong and worth recording: `GET
/agent/tools` is also ungated, deliberately — it is the unfiltered
permission-editor surface, so a person can administer private tools a public
model cannot see, and it is not on `session_reach`'s gated list.
`callable_tool_count` is the opposite kind of route, the model-facing count
after Gate E.

The handler now takes `HeaderMap` and returns `ErrorResponse` rather than a bare
`StatusCode`, so its 424s keep the `{"message": …}` shape every other refusal in
the file has instead of changing envelope with the failure
(`agent_not_initialized`).

Fail-before: 424 where 403 is now required. The new 403 is pinned in the
generated spec by `openapi_describes_the_agent_route_failures_clients_must_handle`
— extended to carry a method, so a GET route can be asserted at all — and the
contract is regenerated.
… unverified

The agent writing these was killed by a session rate limit mid-task, between
"all checks pass" and splitting the work into three commits. Committed by the
coordinator to preserve it; NOT re-verified in this state.

Covers the three review findings on #237:
  - HIGH: `next_offset` was an index into the UNFILTERED recency ordering, so a
    caller shown only public rows could subtract consecutive cursors to recover
    the exact count of hidden private chats — and, because `updated_at` is
    stamped on every token, poll it for a live activity side-channel.
  - MEDIUM: `POST /knowledge/bases` answered "already exists at <path>" for a
    private base, an existence oracle plus a filesystem-path disclosure.
  - MEDIUM: the `route_layer` doc claimed routes added later are gated; axum's
    route_layer is a snapshot, so that guarantee is false.

Re-run before trusting: privacy_capability, privacy_guard_wiring,
knowledge_routes, and the regenerated OpenAPI/TS client.
Eleven conflicts, and two of them were substantive rather than textual.

`routes/agent.rs` — both sides gated `GET /agent/callable_tool_count`, this
branch through a wrapper and main (8437e94, the SD-8 review of #260) inline.
The resolution keeps ONE gate: main's body and its fuller reasoning, in this
branch's wrapper shape, because the gate has to hand back the refusal the way
`session_reach` wrote it. Taking main's `?` into this route's `ErrorResponse`
compiled and passed 526 tests, then failed the two that matter — the refusal
came back as `{"message": …}` where every other gated route answers the
PLAIN-TEXT bytes `GET /sessions/{id}` returns. One boundary, one body; the
envelope is part of the boundary, and `get_tools` beside it has the same shape
for the same reason.

`useSidebarSessions.ts` — auto-merged with no conflict and did not compile.
Main's deleted-chat handler (#264) decremented `nextOffsetRef`, which this
branch had renamed; git took both halves. The decrement is now gone rather
than renamed: it existed because an offset counts rows and the server's list
had lost one, and a cursor names the sort key of the last row a page returned,
so the boundary lands correctly even when the deleted row is the one it names.

The rest:
  - `CLAUDE.md`, `serve-decisions.md` — this branch appended SD-10, main SD-11;
    both kept, in number order, and the CLAUDE.md range is now SD-1..SD-11.
  - `programmatic-session-access.md` — main's turn-control row placed with the
    other lifecycle rows; and the `next_offset` this branch renamed, which
    this file still spelled the old way.
  - `session_reach.rs` — main's SD-11 wording supersedes ours on `/agent/cancel`
    (SD-11 changed that fact); main's row joins the gated-list table beside the
    other helper-gated one; the "last two rows" sentence now NAMES those rows,
    since both sides appended after them and it had already stopped being true.
    `get_session_extensions` is dropped as an over-read control — this branch
    gated it, so main's row for it would now fail — and main's `reply.rs`
    `routes(` control is kept.
  - `privacy_guard_wiring.rs` — the `agent.rs` row is the union at c(6, 6, 0),
    not a second row: both sides gated `callable_tool_count` (one call either
    way) and this branch also gated `GET /agent/tools`; main's own text says
    SD-11 added no call in that file.
  - `KnowledgeContext.tsx` — main's #249 had already absorbed this branch's
    proof header and error handling into `refreshBases`/`fetchKnowledgeSelection`,
    so main's side stands, with the one thing only our comment said (a filtered
    list is pruned against, so a missing base reads as "deleted") folded in.
  - `useKnowledgeBases.ts` — our proof header on the delete, without our
    `setPrimaryKbId(null)`: main removed that deliberately, the daemon repairs
    the pointer, and `primaryKbId` is no longer in scope.
  - `openapi.json`, `types.gen.ts` — regenerated from the merged routes.

Verified on the merged tree: privacy_capability 4, privacy_guard_wiring 3,
biorouter-server --lib routes:: 528, knowledge_routes 68, biorouter --lib
session::session_manager 215, frontend lint:check + format:check clean, vitest
306 over BioRouterSidebar + knowledge. `cargo fmt --check` clean. The OpenAPI
spec and TS client regenerate to no diff.
…ch's

`./scripts/clippy-lint.sh` is red on pristine `origin/main` in three
independent ways, measured at 9096d37 in a detached worktree: `result_large_err`
on `reply::authorize_steer` (SD-11a), `string_slice` on `commands/agent.rs:523`,
and two baseline `too_many_lines` violations that are not in
`clippy-baselines/too_many_lines.txt` (`handle_execute_code`,
`backfill_privacy_from_recorded_provenance`). None of them is touched here —
they are main's to fix, and an allow slipped into a security branch's merge is
how that debt stops being visible.

What IS this branch's, and is fixed:

  - `session_reach.rs` — the two source-scan helpers the WIP commit added index
    strings at offsets `str::find` returned. Those are char boundaries by
    construction, which the restriction lint cannot see, so they are allowed
    with that stated, as nine existing sites in this repo already do.

  - `knowledge/service.rs` — spelling the two refusals carefully enough to name
    no filesystem path pushed `create_base_as_with_checkpoint` past
    `too_many_lines`. The stale-registry refusal is lifted into
    `refuse_if_the_id_is_registered`, which is the part of that function that
    was never about creating anything.

After this the merged tree's baseline violations are EXACTLY main's two, set for
set — the branch contributes none.

Worth knowing before anyone treats the above as a CI regression: CI does not run
this script. `rust.yml`'s clippy step is labelled "informational until warnings
are burned down" and runs `cargo clippy --workspace --all-targets --locked`
without `-D warnings` and without the baseline check, so none of these gate a
pull request today.

Verified after the change: biorouter-mcp --lib knowledge:: 799, biorouter-server
--lib routes:: 528, privacy_capability 4, privacy_guard_wiring 3,
knowledge_routes 68, cargo fmt --check clean.
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.
…anch already holds

Content-free by construction: `git diff b1d3232 origin/main` is empty, because
#275 merged the integration branch this PR was already merged with. Recorded so
the branch CONTAINS main in its ancestry and not merely in its content, which is
what stops a later read of this PR coming back DIRTY. Tree unchanged — verified
by comparing the tree hash either side of this commit.
@Broccolito
Broccolito merged commit 4792667 into main Sep 12, 2026
24 of 25 checks passed
@Broccolito
Broccolito deleted the claude/sweet-pare-965d18 branch September 12, 2026 09:08
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