Skip to content

Feature request: privacy tiers — keep private conversations away from externally hosted models #56

Description

@Broccolito

Summary

Two independent lattices, one column, one predicate, five gates. The full design — capability matrix, enforcement points, migration, and the eleven rulings it needs — is committed at docs/security/privacy-tiers.md (Status: Proposed). This issue is the summary and the tracking point.

  • Capability — what a session may do — is the least privileged model currently bound to it. A mixed lead/worker configuration therefore has public reach, because its transcript already goes to the public worker.
  • Classification — how sensitive a session's contents are — is the most sensitive thing it has ever touched, and is a permanent ratchet in SQL.

A public model must never reach a private session — not once, not read-only, not indirectly. The converse is unrestricted: a private model may read anything.

A session can therefore be classified private while holding only public capability. Those are genuinely independent properties, and conflating them is the mistake the design is built to avoid.


2. The problem, and why today's code cannot express it

2.1 There is no tier anywhere in the backend

Concept Today Verified
Model tier Does not exist in Rust. ProviderMetadata has exactly eight fields (name, display_name, description, default_model, known_models, model_doc_link, config_keys, allows_unlisted_models) and no tier. crates/biorouter/src/providers/base.rs:145-164
The only tier-shaped list Lives in the renderer: const INSTITUTIONAL = new Set(['versa_azure','versa_bedrock']); const LOCAL = new Set(['llamacpp','ollama']); — presentation only, unreachable from the CLI, the daemon or any gate. ui/desktop/src/components/settings/providers/providerOrdering.ts:4-5
Session tier No column. The sessions DDL ends provider_name, model_config_json, diverged_from, external_key, branch_point_msg_uid. CURRENT_SCHEMA_VERSION is 16. session_manager.rs:29, :1865-1889
Extension tier No field on struct Extension (config, client, server_info, _temp_dir, inprocess, _pooled) and no classification field in the marketplace registry. extension_manager.rs:56-72; landing/registry.json (version 1, 37 extensions, 129 skills, keys id name organization version description tags github download filename license)

2.2 The one existing approximation is backwards

provider_class (routes/apps.rs:2089) classifies a provider name into Local /
Institutional / External and gates three real sites (provider_allowed_for_app,
resolve_route, worker-profile admission). Membership is exact equality:

if p.contains("local") || LOCAL_PROVIDERS.iter().any(|x| p == *x) { return Local; }
if p.contains("institution") || INSTITUTIONAL_PROVIDERS.iter().any(|x| p == *x) { return Institutional; }
External

versa_azure and versa_bedrock match neither list nor either substring, so they fall through to
External — while azure, azure_openai, aws_bedrock, bedrock, databricks, vertex
are listed Institutional. Agent Drafter today refuses a Versa route for a sensitive app and
permits a direct Bedrock one. The function's own test table (:6447) never exercises a versa_*
name, which is why the inversion is green.

2.3 Five present-tense paths put private history in front of a public model

Each verified by reading the code, each fixed as a by-product of this design:

  1. Agent::update_provider performs no check at all (agent.rs:4936-4956) and swaps the
    in-memory provider before it persists. It is nevertheless the sole writer of both
    Agent::provider and sessions.provider_name — a grep for .provider_name( outside
    session_manager.rs returns exactly one hit, at agent.rs:4951. That is what makes a single
    bind gate sufficient.
  2. chatrecall LOAD mode has no filter of any kind (chatrecall_extension.rs:91-158). Given
    any session id it calls get_session(&sid, true) and emits the session name, working
    directory, message count and six verbatim messages. It does not even carry SEARCH's
    exclude_session_id guard. This is the only fully-open cross-session read in the product today.
  3. Three independent session-copy paths carry the conversation but not the provider.
    copy_session (session_manager.rs:4138-4168), diverge_session (:4204-4265 — the primary
    GUI diverge, and it does not call copy_session) and import_session (:4096-4135) each
    hand-roll their own update builder. None sets provider_name or model_config, so a branch of
    a private chat resolves through restore_provider_from_session's Config::global() fallback
    (agent.rs:4963-4978) and runs private history on the user's default public model, with no
    prompt.
  4. Agent Drafter's ClientFrame::ModelSelect (routes/apps.rs:3409-3428) calls
    create_provider then agent.update_provider with no check whatsoever, over the
    GET /apps/{id}/agent WebSocket, which auth.rs:52-77 exempts from secret-key auth.
  5. POST /agent/call_tool (routes/agent.rs:1140-1163) dispatches straight into
    ExtensionManager::dispatch_tool_call, skipping Agent::dispatch_tool_call and every
    ToolInspector. Any control expressed as an inspector is invisible to it.


4. The two lattices

They are not two reductions over one set. They are reductions over different domains, which is
what makes them provably consistent rather than contradictory.

capability(S)     = least over { components of the provider bound to S RIGHT NOW }
                    domain: providers.  Pure function of live state.  NOT STORED.

classification(S) = max   over { floor(capability) at each turn S has ever run,
                                 Private for each private MCP call S made,
                                 the classification inherited at creation }
                    domain: events over time.  Monotone accumulator.  STORED.

Capability has no memory. Classification has no reference to live state. The independence is
enforced three ways:

  1. Type level. Two Rust types that do not interconvert.

    // crates/biorouter/src/privacy/mod.rs
    
    /// CAPABILITY. Deliberately NO Ord: `max` over this type is always a bug.
    pub enum ProviderTier { Public, Private }
    impl ProviderTier { pub fn least(a: Self, b: Self) -> Self {} }
    
    /// CLASSIFICATION. Monotone in time.  Public < Private.
    #[derive(PartialOrd, Ord,)]
    pub enum Classification { Public, Private }
    
    /// The ONE crossing. pub(crate); a repo-grep test asserts exactly two callers.
    pub(crate) fn floor(t: ProviderTier) -> Classification {}

    There is no From/Into. A single Ord-derived tier would be simpler and would invite
    max() where least() belongs.

  2. Storage level. Capability is Agent::capability_tier(), a method reading
    self.provider.lock().await.tier(). No field, no column, no cache that can go stale.

  3. Scope level. Of the five gates, exactly one reads both (Gate A/B, the bind). Gate C reads
    capability and extension.tier. Gate D reads capability and the target's classification,
    never the caller's.

Invariant, and it should be a property test: for any sequence of legal binds,
capability(S) ≥ classification(S). The bind admits P only if tier(P) ≥ classification(S); the
ratchet then sets classification := max(old, floor(tier(P))) ≤ floor(tier(P)). By induction, the
naive failure — "the very next turn feeds private history to a public model" — has no code path.

4.1 The mixed configuration, worked through

Private lead + public worker. tier = least(Private, Public) = Public.

  • Capability is Public: cannot call a private MCP server, cannot read a private session.
  • Classification does not ratchet, because floor(Public) = Public. The transcript has already
    gone to the public worker, so there is nothing left on that axis to protect — and marking it
    Private would make the bind gate refuse that same composite on the next resume, bricking a
    working configuration.

This is the one place the design reads R3 in spirit rather than letter (R3 says "switched to a
private model even once → private permanently", and a composite contains a private model). It
needs an explicit ruling — see §17, question 1.

LeadWorkerProvider::tier() returning least(lead, worker) is the only override needed, and
it is the reason tier() is an instance method rather than a lookup on get_name():
get_name() on a composite returns the lead's name (verified,
providers/lead_worker.rs:332-334), which would badge a private-lead/public-worker session Private
— the exact inverse of R2.

4.2 The residual state

classification = Private, capability = Public is representable but unreachable through any legal
bind. It arises only from legacy rows, a pre-fix copy path, an LRU-evicted agent rehydrated with
the process default, or a scheduled job built from global config. Gate B owns it, and in that
state both properties hold at once: the session cannot run a turn until repaired or declassified,
and it stays invisible to every public reader, because Gate D keys on the target's classification
against the caller's capability, independent of the target's own capability. A corrupted private
session cannot leak while it is broken.



7. The capability matrix

Inputs.

  • C = capability(caller) = least over the components of the caller's currently-bound
    provider. Pub | Priv.
  • T = target.privacy_tier, the stored classification. Pub | Priv.
  • L = lineage of target relative to caller: self · child
    (target.parent_session_id == caller_session_id) · other (includes NULL parent and every
    transitive descendant).

Lineage is one hop. A grandchild is other: R6 says "sessions the caller did spawn", and a
grandchild was spawned by the child. BR-71's workspace_list { parent_session_id: "<me>" } filter
already yields exactly the one-hop set, so no recursive CTE and no new "control my subtree" surface
is invented. A leader that needs deeper control asks its child.

The three rules.

VIS(T)     ⇔  T ≤ C                      // a public caller sees public only
READ       ⇔  VIS                        // any lineage — R6's read-only floor
WRITE      ⇔  VIS ∧ L ∈ {self, child}
BIND(P→T)  ⇔  WRITE ∧ tier(P) ≥ T        // Gate A, evaluated on the target

A downgrade write (C=Priv, T=Pub) is permitted, with a first-use approval. R4 explicitly
permits a private session to spawn public children, and a rule that lets you spawn a public child
but never send it a prompt makes the permission useless — it would forbid exactly the
private-leader/public-worker arrangement R2 names. But the prompt text is private-origin content
crossing into a public model, so the first workspace_send_prompt / workspace_set_tools from a
given caller into a given public target raises an approval showing the exact payload.

The matrix. allowed · ✓! allowed, first crossing requires approval showing the payload ·
refused with a teaching message · omitted from results entirely. self and child behave
identically under every rule and are merged; columns D and F prove it.

BR-71 tool Class A
C=Pub T=Pub
self/child
B
C=Pub T=Pub
other
C
C=Pub T=Priv
any L
D
C=Priv T=Pub
self/child
E
C=Priv T=Pub
other
F
C=Priv T=Priv
self/child
G
C=Priv T=Priv
other
workspace_list read ∅ row omitted
workspace_read_conversation read
workspace_watch read
workspace_open (existing session) read
workspace_send_prompt (turn / steer / note) write ✗ R6 ✓! ✗ R6 ✗ R6
workspace_set_tools — extensions / skills / KBs write ✗ R6 ✓! ✗ R6 ✗ R6
workspace_set_toolsadd_extensions naming a private extension write ✗ target is public-capability ✗ R6
workspace_set_tools{ provider, model } bind ✓ if tier(P) ≥ Pub (always) ✗ R6 ✓! if tier(P) ≥ Pub ✗ R6 only if tier(P)=Priv ✗ R6
workspace_close write ✗ R6 ✗ R6 ✗ R6
workspace_spawn_subagent / workspace_open { new: … } spawn see §8.2

workspace_list omits private rows rather than redacting them. The operator ruled existence
leaks acceptable, not required, and omission is strictly simpler: a workspace_list row
carries a title, and a session title in this product is LLM-generated from the conversation,
i.e. content. Redacting would mean a redaction pass to review; omitting is one WHERE clause and
removes the temptation to then call workspace_read_conversation on the id.

7.1 Non-BR-71 surfaces the same matrix governs

Surface Rule
chatrecall SEARCH VIS as a SQL predicate (Gate D)
chatrecall LOAD (session_id) VIS, refused before any text is built
Any tool on a private MCP extension extension.tier ≤ C (Gate C)
MCP read_resource, read_resource_tool, list_resources, get_ui_resources Same as Gate C
MCP list_prompts, list_prompts_from_extension, get_prompt Same as Gate C
Tool discovery (filter_tools) Private extensions absent from a public model's tool list
active_work registry (ActiveWorkItem.title is built from a subagent's task prompt) VIS
GET /sessions, the History list Not filtered — this is the user's own UI, not a model. Badges instead. Conditional on §9.1 being fixed; see the bypass.
Agent Drafter route / worker admission The BIND predicate, replacing provider_class

7.2 Delegation is not amplification

A private parent may spawn a public child (R4). The child is Public, sees only public sessions, and
cannot call private extensions — because VIS is evaluated against the child's own capability,
never its parent's. A public parent cannot spawn a private child at all, so it can never mint a
private-capability agent to read from; and even if it held one, workspace_read_conversation on
that child would be C=Pub, T=Priv → refused.



Enforcement, MCP badges, chatrecall, declassification, migration

See §9–§15 of the design document. In outline:

  • Five gates, each covering a bypass the others miss. The ratchet fires on the first turn and on a permitted private-extension dispatch — deliberately not on the bind, because binding is not when content appears, and ratcheting there would privatise a chat on a mis-click while still missing POST /agent/call_tool, which dispatches straight into the extension manager without touching the reply path.
  • The BAAM registry is the sole grantor of a private badge. Anything not on BAAM is public. The private set is exactly ucsfomopagent and cdwagent. Built-ins are public; skills carry no classification.
  • chatrecall obeys the barrier — private models recall from private and public chats, public models from public only. Side channels (existence, counts, timing) are explicitly out of scope; only content must not cross.
  • Declassification is the user's alone — an explicit deprivatise action in history settings with a graded confirmation. Nothing automatic, nothing an agent can invoke.
  • Migration is fail-open for existing sessions, which is stated as a risk rather than hidden.

16. The honest cost

Private is the default state on a real machine, not the exception. Measured directly from
~/.local/share/biorouter/sessions/sessions.db (aggregate provider_name counts only, no message
content):

session_type would backfill private public NULL provider total
user 934 358 29 1,321
scheduled 121 76 0 197
hidden 52 459 0 511
sub_agent 11 33 0 44

934 of 1,321 user conversations — 70.7% — go private on first launch. 848 of those are
versa_azure (706) or versa_bedrock (142); 86 are llamacpp (48) or ollama (38).

And it is not an accident of one machine. ProviderGuard.tsx:177-186 orders the onboarding cards
Llama Server → Ollama → Institutional → Commercial — three of the four first-run cards are
private-tier — and CLAUDE.md states the ordering is deliberate. A new UCSF user's default,
zero-setup path produces private sessions. Every friction below should be re-costed at that
multiplier before it ships.

Where this annoys someone who has done nothing wrong:

  1. You can never move a private conversation to a public model. Not with a warning. A researcher
    who used Versa once and now wants Opus on the same thread must stay on Versa, start a new chat,
    or declassify. This is the design's central cost and it is deliberate. The
    ratchet-at-first-turn correction (§6.1) removes the mis-click version, and the graded
    confirmation (§12.4) removes the friction from the turn:-only majority.
  2. Turning on lead/worker with a public lead locks you out of your private chats. least()
    makes the composite Public, so one global setting one click from the composer affects unrelated
    conversations, which reads as a bug unless the panel pre-flight-warns. At a 71% private base rate
    this is not a footnote.
  3. Right after upgrade, an orchestration loses control of its existing children.
    parent_session_id only exists going forward, so every pre-upgrade subagent is other and
    read-only to its own parent. No clean mitigation; it resolves as new work is spawned.
  4. A remote llama.cpp or Ollama badges Private with no way to say otherwise — the one place this
    design over-trusts. See §17.
  5. A private extension simply vanishes from a public model's tool list (Gate E). Without the
    pairing-aware state in §14.5 this reads as "the OMOP tool is broken".
  6. Declassification is one chat at a time for mcp:-reason sessions.
  7. A shared workflow pinning a public provider stops working in private sessions with nothing
    explaining why, unless §14.7(d) ships.

If these are not budgeted, the honest prediction is that the first user with 900 private chats and
a commercial subscription tries to turn the feature off — and discovers the R7 opt-out covers only
Gate C, i.e. not the part annoying them. They file a bug instead.



17. Open questions needing an operator ruling

  1. Does a mixed lead/worker composite ratchet the session? R3 says "switched to a private model
    even once → private permanently", and a private-lead/public-worker composite contains a private
    model. This design says it does not ratchet, because tier = least and the transcript has
    already gone to the public worker, and because ratcheting on max would make the bind gate
    refuse that same composite on the next resume. Using one reduction for both the gate and the
    ratchet is what makes capability ≥ classification provable by induction. This is the single
    place the letter of a requirement was not followed.
  2. Is the spawn-downgrade an approval or a refusal? R4 permits it, so it is an approval showing
    the task prompt. But the prompt is written by a private-context model and is the only leak
    vector, and it is the one control a planted PermissionRequest hook could bypass — hooks load
    from ~/.config/biorouter/config.yaml and, with allow_project_hooks, from
    .biorouter/hooks.yaml in the working directory, both writable by an agent with text_editor.
    An operator wanting zero risk makes it a Deny.
  3. Does the R7 opt-out really stop at Gate C? The operator wrote "opt out of the entire
    protection layer", but R3/R4/R6/R13 are stated as invariants without a "by default". Scoping it
    to the MCP gate is a materially different design from scoping it to everything.
  4. Is the first cross-tier write approval remembered per (caller, target) or per call?
    Per-pair-per-session-lifetime was chosen because a confirmation on every steer of a public worker
    is miserable and would be clicked through.
  5. Institutional Ollama versus hosted Ollama SaaS. R1 says self-hosted or institution-hosted
    is private, and config cannot tell a lab GPU box at OLLAMA_HOST=gpu.lab.ucsf.edu from a hosted
    SaaS. "Non-loopback stays Private, the badge names the resolved host" is a false-private — the
    one place this design is permissive rather than restrictive. Certainty needs a
    BIOROUTER_PRIVATE_HOSTS allowlist, a new concept deliberately not added (it would need its own
    protection and settings UI).
  6. Should versa_azure get its own config keys? It shares all three AZURE_OPENAI_* keys with
    the public azure_openai provider, whose shipped default endpoint is the same UCSF gateway. The
    demotion rule catches the dangerous direction, but it means a user can lose their private tier
    by configuring an unrelated provider.
  7. Should the compiled-in private baseline be a signed registry snapshot? Signing would let a
    downgrade be trusted offline. Today the union rule means an extension can only ever gain a
    private badge without a fresh fetch — safe, but a genuine reclassification-to-public needs
    connectivity.
  8. Who is "who" in the declassification record? The app is single-user, so the local OS username
    is recorded. On a shared lab machine that is right; in a multi-account setup it is not, and there
    is no user identity in the product to record instead.
  9. Skills (R12) carry no classification, which leaves three gaps. (a) A skill authored while a
    private chat was open can embed pasted private text and is then readable by every session and
    publishable to the marketplace. (b) A skill can instruct the model to call ucsfomopagent
    harmless in effect because Gate C refuses at dispatch, but the steering is unblocked and produces
    confusing refusals. (c) BR-71 Task 15 lets one session add skills to another. The v1 mitigation
    is a line in the skill-creation UI; closing (a) needs skills to carry a classification, which
    contradicts R12.
  10. ActiveWorkItem.title is cross-session content and predates all of this — derived from a
    subagent's task prompt and surfaced process-wide with a session id. The visibility rule is
    applied to it, but it is exposed only via GET /active_work for the GUI (the model-facing
    subagent_status is session-scoped), so it may deserve its own fix rather than riding this one.
  11. POST /agent/call_tool remains inspector-free. This design is correct either way because
    the barrier is in the extension manager, but the route is a standing hazard for every future
    inspector-based control, including BR-71's.


Relationship to BR-71 (#30)

BR-71 introduces exactly the tools this policy must govern — workspace_list, workspace_read_conversation, workspace_send_prompt, workspace_set_tools (which can change a session's provider and model mid-flight), workspace_close, workspace_watch, workspace_open, and the merged subagent. It also adds sessions.parent_session_id, which is the lineage this design needs, and a mutation inspector that is the closest existing precedent for a dispatch-time gate.

Nothing here is structural to BR-71: the additions are per-task, and the schema column can ride BR-71's own migration. See §18 of the design document.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions