diff --git a/Cargo.lock b/Cargo.lock index e7e890ea9..8ef44c3f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1172,6 +1172,7 @@ dependencies = [ "tikv-jemalloc-ctl", "tikv-jemallocator", "tokio", + "tokio-tungstenite", "tokio-util", "tower-http 0.5.2", "tracing", diff --git a/crates/biorouter-cli/Cargo.toml b/crates/biorouter-cli/Cargo.toml index bf20acac4..0472a6da0 100644 --- a/crates/biorouter-cli/Cargo.toml +++ b/crates/biorouter-cli/Cargo.toml @@ -99,6 +99,9 @@ serial_test = { workspace = true } # The workspace's process-wide environment lock, so env-mutating tests here # exclude every other one rather than only the `#[serial]` ones. env-lock = { workspace = true } +# A WebSocket client, so `commands::web`'s tests can drive the page's socket the +# way the page does. The version axum's `ws` feature already locks. +tokio-tungstenite = "0.28.0" # Issue #56 DR-20 / Task 55. `biorouter session declassify ` raises the OS # authentication prompt, so its TESTS would type a real password on every run # without a stand-in. diff --git a/crates/biorouter-cli/src/cli.rs b/crates/biorouter-cli/src/cli.rs index 8649acf2b..5109cce78 100644 --- a/crates/biorouter-cli/src/cli.rs +++ b/crates/biorouter-cli/src/cli.rs @@ -532,6 +532,24 @@ fn parse_key_val(s: &str) -> Result<(String, String), String> { } } +/// ⚠ **An empty token is not a token, and `--auth-token ""` used to be accepted +/// as one.** Passing it made `validate_network_auth` see `Some(_)` and let +/// `--host 0.0.0.0` through, while `commands::web`'s middleware would then admit +/// anyone who sent `Authorization: Bearer ` with nothing after it — so the one +/// check whose entire job is to insist on protection was satisfied by its +/// absence. Refused here, at parse time, so the mistake cannot reach a bind; a +/// whitespace-only value is refused for the same reason. +pub(crate) fn parse_auth_token(s: &str) -> Result { + if s.trim().is_empty() { + return Err( + "an empty --auth-token is not a token; omit the flag to run without one (loopback \ + binds only), or pass a real secret" + .to_string(), + ); + } + Ok(s.to_string()) +} + #[derive(Subcommand)] enum SessionCommand { #[command(about = "List all available sessions")] @@ -1672,7 +1690,11 @@ enum Command { open: bool, /// Authentication token for both Basic Auth (password) and Bearer token - #[arg(long, help = "Authentication token to secure the web interface")] + #[arg( + long, + value_parser = parse_auth_token, + help = "Authentication token to secure the web interface" + )] auth_token: Option, /// Allow running without authentication when exposed on the network (unsafe) diff --git a/crates/biorouter-cli/src/commands/web.rs b/crates/biorouter-cli/src/commands/web.rs index 595047715..1a648470d 100644 --- a/crates/biorouter-cli/src/commands/web.rs +++ b/crates/biorouter-cli/src/commands/web.rs @@ -15,13 +15,16 @@ use base64::Engine; use biorouter::agents::turn_abort::TurnFailed; use biorouter::agents::{Agent, AgentEvent}; use biorouter::conversation::message::Message as BioRouterMessage; -use biorouter::session::session_manager::SessionType; +use biorouter::privacy::visibility::{may_read, may_write}; +use biorouter::privacy::{ProviderTier, SessionClassification}; +use biorouter::session::session_manager::{SessionManager, SessionType}; use futures::{sink::SinkExt, stream::StreamExt}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::HashSet; use std::{net::ToSocketAddrs, sync::Arc}; use tokio::sync::{Mutex, RwLock}; -use tower_http::cors::{AllowOrigin, Any, CorsLayer}; +use tower_http::cors::CorsLayer; use tracing::error; use webbrowser; @@ -33,6 +36,11 @@ struct AppState { cancellations: CancellationStore, auth_token: Option, ws_token: String, + /// The chats this server started, through `GET /`. See [`page_capability`]. + started_here: Arc>>, + /// The tier of the provider this server was started on, read once, before + /// the first turn. See [`page_capability`]. + server_tier: ProviderTier, } #[derive(Serialize, Deserialize)] @@ -155,7 +163,16 @@ fn token_matches(candidate: &str, expected: &str) -> bool { diff == 0 } +/// ⚠ **An empty `--auth-token` is not a token, and must not satisfy this guard.** +/// `Some("")` used to pass it, so `--host 0.0.0.0 --auth-token ""` bound to every +/// interface while `auth_middleware` would admit anyone who sent +/// `Authorization: Bearer ` with nothing after it — no protection at all, past +/// the one check whose whole job is to insist on protection. `cli.rs` now refuses +/// an empty value at argument-parse time, which is the real fix; this treats it +/// as absent as well, because `handle_web` is a public function and the guard +/// must not depend on its one caller having been careful. fn validate_network_auth(host: &str, auth_token: &Option) { + let auth_token = auth_token.as_deref().filter(|token| !token.is_empty()); if !is_loopback_address(host) && auth_token.is_none() { eprintln!( "Error: --auth-token is required when the server is exposed on the network ({}).", @@ -190,7 +207,9 @@ fn get_provider_and_model() -> (String, String) { (provider_name, model) } -async fn create_agent(provider_name: &str, model: &str) -> Result { +/// The agent every chat on this server shares, and the tier of the provider it +/// was started on. +async fn create_agent(provider_name: &str, model: &str) -> Result<(Agent, ProviderTier)> { let model_config = biorouter::model::ModelConfig::new(model)?; let agent = Agent::new(); @@ -205,6 +224,9 @@ async fn create_agent(provider_name: &str, model: &str) -> Result { .await?; let provider = biorouter::providers::create(provider_name, model_config).await?; + // Read here, not off the agent later: the agent is shared by every chat, and + // a turn in a chat whose row names another provider rebinds it (Gate B). + let server_tier = provider.tier(); agent.update_provider(provider, &init_session.id).await?; let enabled_configs = biorouter::config::get_enabled_extensions(); @@ -214,36 +236,57 @@ async fn create_agent(provider_name: &str, model: &str) -> Result { } } - Ok(agent) + Ok((agent, server_tier)) } -fn build_cors_layer(auth_token: &Option, host: &str, port: u16) -> CorsLayer { - if auth_token.is_none() { - let allowed_origins = [ - "http://localhost:3000".parse().unwrap(), - "http://127.0.0.1:3000".parse().unwrap(), - format!("http://{}:{}", host, port).parse().unwrap(), - ]; - CorsLayer::new() - .allow_origin(AllowOrigin::list(allowed_origins)) - .allow_methods(Any) - .allow_headers(Any) - } else { - CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any) - } +/// No origin but this server's own may read anything this server serves, and +/// nothing needs to. +/// +/// ⚠ **This layer used to hand the WebSocket token to another origin, which is +/// the same capability the reflected XSS gave** (see [`serve_session`]). Without +/// `--auth-token` — the default — `auth_middleware` lets every request through, +/// so a cross-origin `fetch` of `/session/…` that the browser permits *reads the +/// page*, and `data-ws-token` is in it. From there: open `/ws` with the token, +/// which is not subject to the same-origin policy, and send a message to an +/// agent holding `developer__shell`. Escaping the reflection while leaving this +/// open would have closed the sink and left the outcome. +/// +/// The allow-list was `localhost:3000`, `127.0.0.1:3000` and this server's own +/// origin. `--port` **defaults to 3000**, so on a default run all three are this +/// server; the grant only starts meaning something on any other port, where it +/// hands `http://…:3000` — a frontend dev server, or a page the operator was +/// talked into opening — read access to a chat page on, say, `:8080`. The +/// documented invocations include `--port 8080`. +/// +/// ⚠ **There is deliberately no flag to turn this back on.** The two routes a +/// cross-origin browser client could have wanted, `/api/sessions` and +/// `/api/sessions/{id}`, are the ones SD-13 deleted; what is left is the page +/// itself, `/static/*`, a static `/api/health` and the WebSocket, which CORS +/// does not govern. Nothing in this repository reads any of it from another +/// origin — `scripts/test_web.sh` uses `curl`, which ignores CORS entirely. An +/// opt-in would therefore be an opt-in to the token leak and to nothing else. +/// +/// The layer is kept rather than removed so that a preflight gets a definite +/// answer from code that says why, instead of a 405 from the router. +fn build_cors_layer() -> CorsLayer { + CorsLayer::new() } +/// ⚠ **There is no `/api/sessions` route, and there must not be one again.** +/// `GET /api/sessions` listed every user and scheduled chat on the machine (id, +/// title, working directory), and `GET /api/sessions/{id}` returned any chat's +/// full transcript, private ones included, behind no reach check at all — and +/// behind no credential either unless `--auth-token` was passed, which puts the +/// token in this process's argv. The page read one of the two, for a message +/// count and a tab title. Both were removed rather than gated (issue #56, SD-13 +/// in `docs/deployment/serve-decisions.md`); a chat is reached through this +/// server only by sending it a message, which [`turn_reach`] judges. fn build_router(state: AppState, cors_layer: CorsLayer) -> Router { Router::new() .route("/", get(serve_index)) .route("/session/{session_name}", get(serve_session)) .route("/ws", get(websocket_handler)) .route("/api/health", get(health_check)) - .route("/api/sessions", get(list_sessions)) - .route("/api/sessions/{session_id}", get(get_session)) .route("/static/{*path}", get(serve_static)) .layer(middleware::from_fn_with_state( state.clone(), @@ -273,22 +316,24 @@ pub async fn handle_web( crate::logging::setup_logging(Some("biorouter-web"), None)?; let (provider_name, model) = get_provider_and_model(); - let agent = create_agent(&provider_name, &model).await?; + let (agent, server_tier) = create_agent(&provider_name, &model).await?; - let ws_token = if auth_token.is_none() { - uuid::Uuid::new_v4().to_string() - } else { - String::new() - }; + // Unconditional. It used to be empty whenever `--auth-token` was set, which + // was only safe because [`websocket_handler`] then skipped the check + // altogether — and `token_matches("", "")` is `true`, so the pair was one + // careless edit away from an open socket. See that handler's doc comment. + let ws_token = uuid::Uuid::new_v4().to_string(); let state = AppState { agent: Arc::new(agent), cancellations: Arc::new(RwLock::new(std::collections::HashMap::new())), auth_token: auth_token.clone(), ws_token, + started_here: Arc::default(), + server_tier, }; - let cors_layer = build_cors_layer(&auth_token, &host, port); + let cors_layer = build_cors_layer(); let app = build_router(state, cors_layer); let addr = (host.as_str(), port) @@ -333,6 +378,7 @@ async fn serve_index( ) .await .map_err(|err| (http::StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; + state.started_here.write().await.insert(session.id.clone()); let redirect_url = if let Some(query) = uri.query() { format!("/session/{}?{}", session.id, query) @@ -343,20 +389,95 @@ async fn serve_index( Ok(Redirect::to(&redirect_url)) } +/// The line in `index.html` the boot values are written in front of. +const BOOT_ANCHOR: &str = ""; + +/// What this page is allowed to do, sent as a header rather than a `` so +/// that injected markup cannot appear above it and displace it. +/// +/// `script-src 'self'` is the half that earns its keep: with it, an injected +/// ` +/// ``` +/// +/// where a `'` ended the string literal and `` ended the element, so +/// `GET /session/` ran the sender's code. On this +/// page that is not defacement: the injected script runs on the server's own +/// origin, reads the WebSocket token out of the very document it was injected +/// into, opens `/ws` with it, and sends a message to an agent holding +/// `developer__shell`. That token is the only thing standing between a drive-by +/// page and the socket — WebSockets are not subject to CORS — and the injection +/// is handed it. One link is remote code execution. +/// +/// ⚠ **The fix is to leave script context, not to escape for it.** HTML-escaping +/// a `", +) -> Response { + let html = include_str!("../../static/index.html").replace( + BOOT_ANCHOR, &format!( - "\n ", - session_name, - state.ws_token - ) + "\n {BOOT_ANCHOR}", + escape_html_attribute(&session_name), + escape_html_attribute(&state.ws_token), + ), ); - Html(html_with_session) + ( + [("content-security-policy", CONTENT_SECURITY_POLICY)], + Html(html), + ) + .into_response() } async fn serve_static(axum::extract::Path(path): axum::extract::Path) -> Response { @@ -392,68 +513,205 @@ async fn health_check() -> Json { })) } -async fn list_sessions(State(state): State) -> Json { - match state.agent.config.session_manager.list_sessions().await { - Ok(sessions) => { - let mut session_info = Vec::new(); - - for session in sessions { - session_info.push(serde_json::json!({ - "name": session.id, - "path": session.id, - "description": session.name, - "message_count": session.message_count, - "working_dir": session.working_dir - })); - } - Json(serde_json::json!({ - "sessions": session_info - })) - } - Err(e) => Json(serde_json::json!({ - "error": e.to_string() - })), +/// What a page is told when its message names a chat it may not reach. +/// +/// ⚠ **One sentence for "that chat is private" and for "there is no chat with +/// that id", deliberately**, for the reason the daemon's `SESSION_OUT_OF_REACH` +/// gives (`routes/session_reach.rs`): a refusal that told the two apart would +/// enumerate the machine's private chats one id at a time. It is fixed text and +/// names nothing about the chat, so the two answers are equal byte for byte. +/// +/// It states the page's own situation and forecloses the retry. The condition it +/// names cannot be met for a chat that already exists elsewhere, so it hands a +/// reader no way around itself; the one way through is the desktop app, where a +/// person can show they are at the keyboard. +const CHAT_OUT_OF_REACH: &str = + "That chat is private, or there is no chat with that id, and the two answers are \ + deliberately the same so that nothing about the chat is disclosed. `biorouter web` cannot \ + tell which model or which person is sending these messages, so it opens a private chat only \ + when it started that chat itself and runs a private model. Your message was not sent and \ + nothing was read; sending it again will be refused the same way. To continue a private \ + chat, open it in the Biorouter desktop app."; + +/// The capability a page brings to the chat its message names. +/// +/// ⚠ **Nothing on the socket says who is on the other end of it.** The server's +/// one credential proves nothing about that either: the WebSocket token is served +/// by `/session/{name}` to anyone who can reach the port, and `--auth-token` sits +/// in this process's argv, where any process of the same user reads it (AR-11 in +/// `docs/security/privacy-tiers-execution-plan.md`). So a page is a PUBLIC +/// caller, which is also how the daemon resolves a caller that states no +/// capability (`session_reach::caller_capability`). +/// +/// The exception is a chat this server started itself, which the page reaches at +/// the tier of the provider this server was started on. Without it a server on a +/// private model would give one reply per chat: that reply ratchets the chat to +/// private, and the next message would be refused. On a public model the +/// exception changes nothing, because the page is Public for every chat — so a +/// chat it started that was taken private somewhere else is refused like any +/// other. +fn page_capability(started_here: bool, server_tier: ProviderTier) -> ProviderTier { + if started_here { + server_tier + } else { + ProviderTier::Public } } -async fn get_session( - State(state): State, - axum::extract::Path(session_id): axum::extract::Path, -) -> Json { - match state - .agent - .config - .session_manager - .get_session(&session_id, true) - .await - { - Ok(session) => Json(serde_json::json!({ - "metadata": session, - "messages": session.conversation.unwrap_or_default().messages() - })), - Err(e) => Json(serde_json::json!({ - "error": e.to_string() - })), + +/// May a page with this capability run a turn in a chat in this state? +/// +/// `target` is the chat's classification, or `None` when its row could not be +/// read — no such chat, a deleted one, a store error — which is answered exactly +/// as a private chat is. `enforced` is DR-15's master switch, taken as an +/// argument so that "the switch is off" is a corner the tests drive; with it off +/// the gate is inert, like every other. +fn refuse_turn_unless_reachable( + enforced: bool, + capability: ProviderTier, + target: Option, +) -> Result<(), &'static str> { + if !enforced { + return Ok(()); + } + let target = target.unwrap_or(SessionClassification::Private); + // A turn reads the whole conversation into the model and writes into it, so + // it asks both verbs, as `workspace_send_prompt` does. They coincide today; + // asking both keeps a later narrowing of either from being skipped here. + if may_read(capability, target) && may_write(capability, target) { + Ok(()) + } else { + Err(CHAT_OUT_OF_REACH) } } +/// The gate on the one door into a chat this server keeps: a WebSocket message, +/// which runs a turn in whichever chat it names. +/// +/// ⚠ **It is the same door as the daemon's `POST /reply`, and it was open.** A +/// message naming a private chat started anywhere else ran a turn there — Gate B +/// rebinds the shared agent to the private model that chat's row names — and the +/// reply, which can quote the whole conversation, streamed back to whoever held +/// the socket. Removing `GET /api/sessions/{id}` alone would have closed the +/// smaller door and left this one. +/// +/// Called before anything touches the chat, as `session_reach` is. The row is +/// read metadata-only, so resolving the tier never loads the transcript this may +/// be about to refuse. +async fn turn_reach( + manager: &SessionManager, + started_here: &RwLock>, + server_tier: ProviderTier, + session_id: &str, +) -> Result<(), &'static str> { + // DR-15's master opt-out, read directly: a turn is not a tool call and has + // no sampled capability to inherit. Short-circuit before the store read. + let enforced = biorouter::privacy::privacy_tiers_enabled(); + if !enforced { + return Ok(()); + } + let capability = page_capability(started_here.read().await.contains(session_id), server_tier); + let target = manager + .get_session(session_id, false) + .await + .ok() + .map(|session| session.privacy_tier); + refuse_turn_unless_reachable(enforced, capability, target) +} + #[derive(Deserialize)] struct WsQuery { token: Option, } +/// Is this `Origin` this very server? +/// +/// A mirror of the daemon's `routes::origin_matches_host`, in the same spirit as +/// [`token_matches`] mirroring its `secret_matches`: `biorouter-cli` does not +/// depend on `biorouter-server` and must not start — SD-7 is why `serve` spawns +/// `biorouterd` as a subprocess rather than linking it — so the rule is +/// duplicated rather than imported. If the two ever need to be one symbol, the +/// move is into the `biorouter` core library that both already depend on, never +/// a new command-line-interface-to-server dependency. ⚠ PR #233 is editing the +/// daemon's copy; the shape below is that PR's, not the older one. +/// +/// It is the **strict core of that rule and neither of its exceptions**, and +/// deliberately so: +/// +/// - No `is_local_origin` widening. #233 removes exactly that from the daemon's +/// socket gates — "`is_local_origin` is the CORS rule now and nothing else; do +/// not hand it back to a socket" — and here it would re-open the hole +/// [`build_cors_layer`] just closed, by admitting a page on `localhost:3000`. +/// - No `file://` and no declared-renderer origin. Those exist for the Electron +/// renderer, which reaches the daemon from another local origin. This server +/// serves its own page from its own origin and has no such client, so an +/// opaque origin is refused like any other. +fn origin_is_this_server(origin: &str, host: Option<&str>) -> bool { + let Some(host) = host else { + // Nothing to compare against. Refuse rather than guess. + return false; + }; + // `null`, `file://` and anything else opaque strip no scheme and so can + // never match. + let Some(authority) = origin + .strip_prefix("http://") + .or_else(|| origin.strip_prefix("https://")) + else { + return false; + }; + !authority.is_empty() && !host.is_empty() && authority.eq_ignore_ascii_case(host) +} + +/// The socket is the chat, so it carries both locks the rest of this file +/// assumes: it is this server's own page asking, and it holds this process's +/// token. +/// +/// ⚠ **The token check used to be skipped entirely whenever `--auth-token` was +/// set**, on the reasoning that `auth_middleware` had already authenticated the +/// handshake. That is defensible and it left a landmine, because `handle_web` +/// also made `ws_token` the empty string in that mode: `token_matches("", "")` +/// is `true`, so deleting the `if` without touching the generation would have +/// admitted *every* socket while reading like a tightening. The generation is +/// unconditional now, the check is unconditional, and an empty expected token is +/// refused outright so the landmine cannot be re-armed. +/// +/// ⚠ **And there was no `Origin` check on any path**, while the tree's other two +/// upgrade sites (`routes/workspace.rs`, `routes/apps.rs`) both have one. CORS +/// does not govern a WebSocket handshake, so a page on any origin that had the +/// token could drive an agent holding `developer__shell` (CSWSH). The token is no +/// longer readable cross-origin either ([`build_cors_layer`]), which is the point +/// of having both: the two locks fail independently. +/// +/// A client that sends no `Origin` at all is let past this gate, as the daemon's +/// gates let one past: that is a non-browser client, and the token still guards +/// it. A local process reading the token off this port is issue #47 and unchanged. async fn websocket_handler( ws: WebSocketUpgrade, State(state): State, + headers: axum::http::HeaderMap, Query(query): Query, ) -> Result { - if state.auth_token.is_none() { - let provided_token = query.token.as_deref().unwrap_or(""); - if !token_matches(provided_token, &state.ws_token) { - tracing::warn!("WebSocket connection rejected: invalid token"); + if let Some(origin) = headers.get(axum::http::header::ORIGIN) { + let host = headers + .get(axum::http::header::HOST) + .and_then(|h| h.to_str().ok()); + if !origin_is_this_server(origin.to_str().unwrap_or(""), host) { + tracing::warn!("WebSocket connection rejected: cross-origin handshake"); return Err(StatusCode::FORBIDDEN); } } + if state.ws_token.is_empty() { + // Unreachable through `handle_web`, which always generates one. A + // refusal rather than a `debug_assert`, because the cost of being wrong + // is an open socket. + tracing::error!("WebSocket connection rejected: this server has no socket token"); + return Err(StatusCode::FORBIDDEN); + } + if !token_matches(query.token.as_deref().unwrap_or(""), &state.ws_token) { + tracing::warn!("WebSocket connection rejected: invalid token"); + return Err(StatusCode::FORBIDDEN); + } + Ok(ws.on_upgrade(|socket| handle_socket(socket, state))) } @@ -505,6 +763,18 @@ async fn handle_user_message( sender: Arc>>, state: &AppState, ) { + if let Err(refusal) = turn_reach( + &state.agent.config.session_manager, + &state.started_here, + state.server_tier, + &session_id, + ) + .await + { + send_error(&sender, refusal).await; + return; + } + let agent = state.agent.clone(); let session_id_clone = session_id.clone(); @@ -538,11 +808,33 @@ async fn handle_user_message( }); } +/// ⚠ **Gated, even though the map it reads can only hold chats the gate already +/// admitted.** A handle lands in `cancellations` only after +/// [`handle_user_message`] passed [`turn_reach`], so a cancel naming an +/// unreachable chat could never abort anything it was not already allowed to. +/// What it *could* do is answer: the old code replied `Cancelled` when a handle +/// existed and said nothing when it did not, which is one bit about a chat the +/// sender may not reach. Judging it first also makes the property this file +/// wants a flat one — **every socket message that names a chat is judged before +/// the chat is touched** — rather than a claim that has to be re-derived from +/// what else happens to be true of the map. async fn handle_cancel_message( session_id: String, sender: &Arc>>, state: &AppState, ) { + if let Err(refusal) = turn_reach( + &state.agent.config.session_manager, + &state.started_here, + state.server_tier, + &session_id, + ) + .await + { + send_error(sender, refusal).await; + return; + } + let abort_handle = { let mut cancellations = state.cancellations.write().await; cancellations.remove(&session_id) @@ -595,10 +887,8 @@ async fn process_message_streaming( let session = agent .config .session_manager - .get_session(&session_id, true) + .get_session(&session_id, false) .await?; - let mut messages = session.conversation.unwrap_or_default(); - messages.push(user_message.clone()); let session_config = SessionConfig { id: session.id.clone(), @@ -795,7 +1085,23 @@ async fn send_error( #[cfg(test)] mod tests { - use super::token_matches; + use super::*; + use biorouter::agents::AgentConfig; + use biorouter::config::permission::PermissionManager; + use biorouter::config::BioRouterMode; + use serde_json::json; + use std::net::SocketAddr; + use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio_tungstenite::tungstenite::Message as Frame; + + const TIERS: [ProviderTier; 2] = [ProviderTier::Public, ProviderTier::Private]; + const TARGETS: [Option; 3] = [ + Some(SessionClassification::Public), + Some(SessionClassification::Private), + None, + ]; + const WS_TOKEN: &str = "test-ws-token"; #[test] fn token_match_is_exact_and_length_checked() { @@ -807,4 +1113,710 @@ mod tests { assert!(!token_matches("", "abc123")); assert!(token_matches("", "")); } + + /// Nothing on the socket names a model, so a page is a public caller — in + /// every chat but the ones its own server started, where it has the tier of + /// the model it has been talking to. + #[test] + fn a_page_is_a_public_caller_outside_the_chats_its_server_started() { + for server_tier in TIERS { + assert_eq!(page_capability(false, server_tier), ProviderTier::Public); + assert_eq!(page_capability(true, server_tier), server_tier); + } + } + + /// The rule at every corner, with the switch on. An unreadable row is judged + /// as a private one, so a public page is refused both. + #[test] + fn a_turn_reaches_a_chat_only_at_the_tier_the_page_holds() { + use SessionClassification::{Private, Public}; + #[rustfmt::skip] + let cases = [ + // capability target admitted + (ProviderTier::Public, Some(Public), true), + (ProviderTier::Public, Some(Private), false), + (ProviderTier::Public, None, false), + (ProviderTier::Private, Some(Public), true), + (ProviderTier::Private, Some(Private), true), + (ProviderTier::Private, None, true), + ]; + for (capability, target, admitted) in cases { + assert_eq!( + refuse_turn_unless_reachable(true, capability, target).is_ok(), + admitted, + "a {capability:?} page naming a chat classified {target:?}" + ); + } + } + + /// DR-15: with privacy tiers off, this gate refuses nothing, like every other. + #[test] + fn with_privacy_tiers_off_the_gate_refuses_nothing() { + for capability in TIERS { + for target in TARGETS { + assert_eq!( + refuse_turn_unless_reachable(false, capability, target), + Ok(()), + "{capability:?} / {target:?}" + ); + } + } + } + + /// A page cannot tell "no such chat" from "a private chat" by the answer. + /// Asserted as equality at every capability rather than as each answer being + /// vague, because a vagueness check passes an implementation that adds one + /// helpful clause to the branch it can tell apart. + #[test] + fn no_such_chat_and_a_private_chat_are_the_same_refusal() { + for capability in TIERS { + assert_eq!( + refuse_turn_unless_reachable(true, capability, None), + refuse_turn_unless_reachable( + true, + capability, + Some(SessionClassification::Private) + ), + "a {capability:?} page can tell a missing chat from a private one" + ); + } + assert_eq!( + refuse_turn_unless_reachable(true, ProviderTier::Public, None), + Err(CHAT_OUT_OF_REACH) + ); + } + + /// The page and the router agree: the page asks for no chat list and no + /// transcript, so the routes that served them could go. + #[test] + fn the_page_asks_for_no_chat_list_and_no_transcript() { + let page = include_str!("../../static/script.js"); + assert!( + !page.contains("/api/sessions"), + "the page fetches a route this server no longer has" + ); + } + + /// A session name that tries to leave a double-quoted HTML attribute, and + /// the script context it used to be written into. Percent-encoded at the + /// call site because a raw `"` is not legal in a request target. + const BREAKOUT: &str = "\"'&"; + const BREAKOUT_ENCODED: &str = + "%3C%2Fscript%3E%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E%22%27%26"; + + #[test] + fn an_attribute_escape_neutralises_every_character_that_could_leave_one() { + assert_eq!( + escape_html_attribute(BREAKOUT), + "</script><img src=x onerror=alert(1)>"'&" + ); + // An `&` escaped anywhere but first would come back doubled. + assert_eq!(escape_html_attribute("<"), "&lt;"); + assert_eq!( + escape_html_attribute("plain-20260911_120000"), + "plain-20260911_120000" + ); + } + + /// ⚠ **The reflected XSS this branch closes.** `GET /session/{name}` wrote + /// the name of the chat straight into an inline `` ended the element and everything after it was parsed + /// as markup. The consequence is not cosmetic — see [`serve_session`]: the + /// injected script reads the WebSocket token out of the same document and + /// drives an agent that holds `developer__shell`. + /// + /// Asserted as **"the page has exactly the script elements its own template + /// has"** rather than as "the payload does not appear". The weaker form + /// passes an implementation that HTML-escapes inside the ``. + #[tokio::test] + async fn a_session_name_cannot_reach_script_context() { + let server = TestServer::start(ProviderTier::Public).await; + let template = include_str!("../../static/index.html"); + let response = server + .raw_get(&format!("/session/{BREAKOUT_ENCODED}")) + .await; + + assert_eq!( + response.matches("` that would + // close it, not against `onerror=` — that substring survives inside the + // escaped attribute, where it is text and not a handler. + assert!( + !response.contains(""), + "the payload kept a raw `>`, so something closed a tag:\n{response}" + ); + assert!( + response.contains(&format!( + "data-session-name=\"{}\"", + escape_html_attribute(BREAKOUT) + )), + "the name is not where the page reads it from, escaped:\n{response}" + ); + } + + /// The same route on the same server, with an ordinary name: the page still + /// gets the value it needs, unescaped once the parser has decoded it. A + /// breakout test alone passes a handler that drops the name entirely. + #[tokio::test] + async fn an_ordinary_session_name_still_reaches_the_page() { + let server = TestServer::start(ProviderTier::Public).await; + let started = server.start_chat().await; + let response = server.raw_get(&format!("/session/{started}")).await; + assert!( + response.contains(&format!("data-session-name=\"{started}\"")), + "the page cannot tell which chat it is in:\n{response}" + ); + assert!( + response.contains(&format!("data-ws-token=\"{WS_TOKEN}\"")), + "the page cannot open the socket:\n{response}" + ); + } + + /// Defence in depth behind the escape, and the two halves that have to move + /// together: a policy refusing inline script, and a template carrying none. + #[tokio::test] + async fn the_page_is_served_under_a_policy_that_refuses_inline_script() { + let server = TestServer::start(ProviderTier::Public).await; + let response = server.raw_get("/session/20260911_120000").await; + let policy = TestServer::header(&response, "content-security-policy") + .unwrap_or_else(|| panic!("no content-security-policy header in {response:?}")); + assert!( + policy.contains("script-src 'self'") && !policy.contains("unsafe-inline"), + "a policy that permits inline script is not one: {policy}" + ); + + let template = include_str!("../../static/index.html"); + assert!( + !template.contains("onclick="), + "the template carries an inline handler the policy above would refuse" + ); + } + + /// The origin rule, at every corner. The strict core of the daemon's + /// `origin_matches_host` with neither of its exceptions — see + /// [`origin_is_this_server`] for why each is deliberately absent. + #[test] + fn an_origin_is_this_server_only_when_it_matches_this_request_s_host() { + let host = Some("127.0.0.1:8080"); + assert!(origin_is_this_server("http://127.0.0.1:8080", host)); + // Case-insensitive on the authority, as the daemon's copy is. + assert!(origin_is_this_server( + "http://LOCALHOST:8080", + Some("localhost:8080") + )); + // The scheme prefix is matched literally, so an odd spelling of it is a + // refusal rather than a match. + assert!(!origin_is_this_server("HTTP://127.0.0.1:8080", host)); + // A different port is a different origin. This is the whole point: the + // CORS allow-list this replaces named `localhost:3000` by hand. + assert!(!origin_is_this_server("http://127.0.0.1:3000", host)); + assert!(!origin_is_this_server("http://localhost:8080", host)); + assert!(!origin_is_this_server("http://evil.example", host)); + // Opaque origins strip no scheme, so they can never match — and unlike + // the daemon's gates, `file://` gets no exception here. + for opaque in ["null", "file://", "", "ws://127.0.0.1:8080"] { + assert!(!origin_is_this_server(opaque, host), "{opaque} admitted"); + } + // Nothing to compare against is a refusal, not a guess. + assert!(!origin_is_this_server("http://127.0.0.1:8080", None)); + assert!(!origin_is_this_server("http://", Some(""))); + } + + /// ⚠ **The socket is the chat, and it had no `Origin` check on any path** + /// while the tree's other two upgrade sites both have one. CORS does not + /// govern a handshake, so a page on any origin holding the token could drive + /// an agent with `developer__shell` (CSWSH). Driven as a real handshake + /// because `WebSocketUpgrade` is extracted before the handler body runs: a + /// request without the upgrade headers is rejected with 400 by the extractor + /// and would never reach the rule under test. + #[tokio::test] + async fn a_handshake_from_another_origin_is_refused_even_with_the_right_token() { + let server = TestServer::start(ProviderTier::Public).await; + + assert_eq!(server.handshake(Some(WS_TOKEN), None).await, 101); + let own = format!("http://{}", server.addr); + assert_eq!(server.handshake(Some(WS_TOKEN), Some(&own)).await, 101); + + for origin in [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://evil.example", + "null", + "file://", + ] { + assert_eq!( + server.handshake(Some(WS_TOKEN), Some(origin)).await, + 403, + "{origin} opened the socket" + ); + } + } + + /// The token gate, now that it runs on every path rather than only when + /// `--auth-token` is absent. + /// + /// ⚠ **This is the landmine under "just make the check unconditional".** + /// `ws_token` used to be the empty string whenever `--auth-token` was set, + /// precisely because the check was skipped in that mode — and + /// `token_matches("", "")` is `true`, so deleting the `if` without also + /// changing the generation would have admitted *every* socket while reading + /// like a tightening. Both halves are pinned: an empty expected token is + /// refused by the handler, and `handle_web` never produces one. + #[tokio::test] + async fn the_socket_token_is_required_on_every_path_and_never_empty() { + assert!( + token_matches("", ""), + "the reason an empty expected token must never reach the handler" + ); + + let server = TestServer::start(ProviderTier::Public).await; + assert_eq!(server.handshake(Some(WS_TOKEN), None).await, 101); + assert_eq!(server.handshake(None, None).await, 403); + assert_eq!(server.handshake(Some("wrong"), None).await, 403); + + // A server whose token is empty refuses everything, including the empty + // token that `token_matches` would otherwise accept. + let tokenless = TestServer::start_with_ws_token(ProviderTier::Public, "").await; + assert_eq!(tokenless.handshake(None, None).await, 403); + assert_eq!(tokenless.handshake(Some(""), None).await, 403); + } + + /// `--host 0.0.0.0 --auth-token ""` used to bind to every interface behind a + /// token that `Authorization: Bearer ` satisfies. Pinned at both layers: the + /// argument parser refuses the value, and the guard treats it as absent even + /// if a programmatic caller hands it one. + #[test] + fn an_empty_auth_token_is_refused_and_does_not_satisfy_the_network_guard() { + assert!(crate::cli::parse_auth_token("").is_err()); + assert!(crate::cli::parse_auth_token(" ").is_err()); + assert_eq!( + crate::cli::parse_auth_token("secret").as_deref(), + Ok("secret") + ); + // `validate_network_auth` exits the process when it refuses, so the + // normalisation it applies is asserted rather than the exit: an empty + // token must reduce to `None`, which is the refusing branch. + for token in [None, Some(String::new()), Some(" ".to_string())] { + assert!( + token.as_deref().filter(|t| !t.trim().is_empty()).is_none(), + "{token:?} must not read as a token" + ); + } + } + + /// ⚠ **The other route to the same capability as the reflected XSS.** The + /// allow-list held `http://localhost:3000`, so a page there could `fetch` + /// `/session/…` on any other port, read `data-ws-token` out of the reply, and + /// open `/ws` with it — WebSockets are not subject to the same-origin policy — + /// reaching an agent that holds `developer__shell`. Escaping the reflection + /// and leaving this would have closed the sink and left the outcome. + /// + /// Asserted on the **header**, not on the body: the token is still in the + /// page, because the page needs it. What must not happen is a browser being + /// told another origin may read that page. + #[tokio::test] + async fn no_other_origin_may_read_the_page_that_carries_the_ws_token() { + let server = TestServer::start(ProviderTier::Public).await; + let path = "/session/20260911_120000"; + + for origin in [ + // Both spellings the allow-list named, on the port it hard-coded, + // which is also `--port`'s default — so on any other port these are + // a foreign origin, and `--port 8080` is a documented invocation. + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://evil.example", + ] { + let response = server.raw_request("GET", path, &[("Origin", origin)]).await; + assert!( + response.contains("data-ws-token=\""), + "this test is meaningless if the page stopped carrying the token:\n{response}" + ); + assert_eq!( + TestServer::header(&response, "access-control-allow-origin"), + None, + "{origin} is told it may read the page holding the token" + ); + + // And the preflight, which is what a browser actually asks first + // for anything beyond a simple request. + let preflight = server + .raw_request( + "OPTIONS", + path, + &[("Origin", origin), ("Access-Control-Request-Method", "GET")], + ) + .await; + assert_eq!( + TestServer::header(&preflight, "access-control-allow-origin"), + None, + "the preflight grants {origin} what the response above refused" + ); + } + } + + /// Same-origin still works, which is the only case the page needs — a + /// cross-origin refusal is worthless if it also broke the page itself. + #[tokio::test] + async fn the_page_still_serves_the_origin_it_is_served_from() { + let server = TestServer::start(ProviderTier::Public).await; + let own_origin = format!("http://{}", server.addr); + let response = server + .raw_request( + "GET", + "/session/20260911_120000", + &[("Origin", &own_origin)], + ) + .await; + assert!( + response.starts_with("HTTP/1.1 200"), + "the server's own origin is refused its own page:\n{response}" + ); + assert!(response.contains("data-ws-token=\"test-ws-token\"")); + } + + /// The page reads its boot values from attributes, not from globals an + /// inline `` used to end the element. See `serve_session` in +// crates/biorouter-cli/src/commands/web.rs for the full account. The HTML parser +// decodes entities inside an attribute, so what `dataset` hands back here is the +// value exactly as it arrived, with no byte able to escape the attribute. +function bootValue(name) { + const boot = document.getElementById('biorouter-boot'); + return (boot && boot.dataset[name]) || ''; +} + // Get session ID - either from URL parameter, injected session name, or generate new one function getSessionId() { - // Check if session name was injected by server (for /session/:name routes) - if (window.BIOROUTER_SESSION_NAME) { - return window.BIOROUTER_SESSION_NAME; + // Check if a session name was written into the page (for /session/:name routes) + const injected = bootValue('sessionName'); + if (injected) { + return injected; } - + // Check URL parameters const urlParams = new URLSearchParams(window.location.search); const sessionParam = urlParams.get('session') || urlParams.get('name'); @@ -138,7 +153,7 @@ function removeThinkingIndicator() { // Connect to WebSocket function connectWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const token = window.BIOROUTER_WS_TOKEN || ''; + const token = bootValue('wsToken'); const wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; socket = new WebSocket(wsUrl); @@ -149,9 +164,6 @@ function connectWebSocket() { connectionStatus.textContent = 'Connected'; connectionStatus.className = 'status connected'; sendButton.disabled = false; - - // Check if this session exists and load history if it does - loadSessionIfExists(); }; socket.onmessage = (event) => { @@ -264,24 +276,32 @@ function handleToolRequest(data) { const headerDiv = document.createElement('div'); headerDiv.className = 'tool-header'; - headerDiv.innerHTML = `🔧 ${data.tool_name}`; - + // Every one of these interpolations is model-controlled: a tool name and a + // tool call's arguments are chosen by whatever the agent decided to run, and + // a prompt injection in a file or a web page reaches them. `escapeHtml` is + // adequate here and only here because every hole below sits in element + // content, never inside an attribute value — it does not escape `"`. + headerDiv.innerHTML = `🔧 ${escapeHtml(data.tool_name)}`; + const contentDiv = document.createElement('div'); contentDiv.className = 'tool-content'; - + // Format the arguments if (data.tool_name === 'developer__shell' && data.arguments.command) { contentDiv.innerHTML = `
${escapeHtml(data.arguments.command)}
`; } else if (data.tool_name === 'developer__text_editor') { const action = data.arguments.command || 'unknown'; const path = data.arguments.path || 'unknown'; - contentDiv.innerHTML = `
action: ${action}
`; + contentDiv.innerHTML = `
action: ${escapeHtml(action)}
`; contentDiv.innerHTML += `
path: ${escapeHtml(path)}
`; if (data.arguments.file_text) { contentDiv.innerHTML += `
content:
${escapeHtml(data.arguments.file_text.substring(0, 200))}${data.arguments.file_text.length > 200 ? '...' : ''}
`; } } else { - contentDiv.innerHTML = `
${JSON.stringify(data.arguments, null, 2)}
`; + // `JSON.stringify` escapes for JSON, which says nothing about HTML: it + // leaves `<` and `/` alone, so an argument holding `` arrived here as live markup. + contentDiv.innerHTML = `
${escapeHtml(JSON.stringify(data.arguments, null, 2))}
`; } toolDiv.appendChild(headerDiv); @@ -346,8 +366,8 @@ function handleToolConfirmation(data) { confirmDiv.innerHTML = `
⚠️ Tool Confirmation Required
- ${data.tool_name} wants to execute with: -
${JSON.stringify(data.arguments, null, 2)}
+ ${escapeHtml(data.tool_name)} wants to execute with: +
${escapeHtml(JSON.stringify(data.arguments, null, 2))}
Auto-approved in web mode (UI coming soon)
`; @@ -456,43 +476,20 @@ function sendSuggestion(text) { sendMessage(); } -// Load session history if the session exists (like --resume in CLI) -async function loadSessionIfExists() { - try { - const response = await fetch(`/api/sessions/${sessionId}`); - if (response.ok) { - const sessionData = await response.json(); - if (sessionData.messages && sessionData.messages.length > 0) { - // Remove welcome message since we're resuming - const welcomeMessage = messagesContainer.querySelector('.welcome-message'); - if (welcomeMessage) { - welcomeMessage.remove(); - } - - // Display session resumed message - const resumeDiv = document.createElement('div'); - resumeDiv.className = 'message system-message'; - resumeDiv.innerHTML = `Session resumed: ${sessionData.messages.length} messages loaded`; - messagesContainer.appendChild(resumeDiv); - - // Update page title with session description if available - if (sessionData.metadata && sessionData.metadata.description) { - document.title = `biorouter chat - ${sessionData.metadata.description}`; - } - - messagesContainer.scrollTop = messagesContainer.scrollHeight; - } - } - } catch (error) { - console.log('No existing session found or error loading:', error); - // This is fine - just means it's a new session - } -} - - // Event listeners sendButton.addEventListener('click', sendMessage); +// The welcome pills, bound here rather than through an `onclick` attribute in +// index.html: the page is served under `script-src 'self'`, which refuses inline +// handlers. Delegated from the container because the welcome block is removed +// once the first message is sent. +messagesContainer.addEventListener('click', (e) => { + const pill = e.target.closest('.suggestion-pill[data-suggestion]'); + if (pill) { + sendSuggestion(pill.dataset.suggestion); + } +}); + messageInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); diff --git a/crates/biorouter/tests/privacy_guard_wiring.rs b/crates/biorouter/tests/privacy_guard_wiring.rs index d6f735666..df0e310d8 100644 --- a/crates/biorouter/tests/privacy_guard_wiring.rs +++ b/crates/biorouter/tests/privacy_guard_wiring.rs @@ -164,8 +164,18 @@ const REGISTRY: &[Guard] = &[ ident: "may_read", defined_in: VISIBILITY, decides: "READ ⇔ VIS: whether a caller of tier C may read a session classified T", - status: Status::WiredThrough("refuse_unless_readable"), + // It was `WiredThrough("refuse_unless_readable")` until `biorouter web` became + // its first caller outside this file; the in-file row below still holds. + status: Status::Wired, sites: &[ + Site { + file: "crates/biorouter-cli/src/commands/web.rs", + counts: c(1, 0, 1), + kind: SiteKind::Guard, + what: "`refuse_turn_unless_reachable`, the gate on `biorouter web`'s WebSocket: \ + a message there runs a turn in whichever chat it names, so the page must \ + be able to read that chat. Plus its import", + }, Site { file: "crates/biorouter-mcp/src/memory/mod.rs", counts: c(2, 0, 0), @@ -238,12 +248,21 @@ const REGISTRY: &[Guard] = &[ spawned, read everything else — is retired: an agent may inject into any \ conversation, and the tier is the only boundary", status: Status::Wired, - sites: &[Site { - file: "crates/biorouter/src/agents/workspace_extension.rs", - counts: c(1, 0, 0), - kind: SiteKind::Guard, - what: "the shared writable adapter used by send_prompt, set_tools and close", - }], + sites: &[ + Site { + file: "crates/biorouter-cli/src/commands/web.rs", + counts: c(1, 0, 1), + kind: SiteKind::Guard, + what: "`refuse_turn_unless_reachable`, the write half: a `biorouter web` message \ + is written into the chat it names. Plus its import", + }, + Site { + file: "crates/biorouter/src/agents/workspace_extension.rs", + counts: c(1, 0, 0), + kind: SiteKind::Guard, + what: "the shared writable adapter used by send_prompt, set_tools and close", + }, + ], }, Guard { ident: "requires_first_crossing_approval", diff --git a/docs/cli/command-reference.md b/docs/cli/command-reference.md index 78c6e3592..683ee4a41 100644 --- a/docs/cli/command-reference.md +++ b/docs/cli/command-reference.md @@ -798,7 +798,9 @@ The printed URL carries an access token as `?t=`, minted per launch and s ### web -> **Deprecated.** Use [`serve`](#serve) instead. `web` serves a minimal standalone chat page rather than the Biorouter interface, and its default port collides with `biorouterd`'s. It is kept for now and unchanged; new deployments should not use it. +> **Deprecated.** Use [`serve`](#serve) instead. `web` serves a minimal standalone chat page rather than the Biorouter interface, and its default port collides with `biorouterd`'s. It is kept for now; new deployments should not use it. +> +> Since 2026-09-11 it lists no chats and returns no transcripts, and it opens a private chat only if it started that chat itself while running a private model. Continue any other private chat in the desktop app. [SD-13](../deployment/serve-decisions.md#sd-13--biorouter-web-serves-no-transcripts-and-opens-no-private-chat-it-did-not-start) records why. Start a new session in biorouter Web, a lightweight web-based interface launched via the CLI that mirrors the desktop app's chat experience. diff --git a/docs/deployment/README.md b/docs/deployment/README.md index 9a6160499..48142ac44 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -26,7 +26,7 @@ any deployment live in [configuration](../configuration/environment-variables.md | [Headless Linux deployment](headless-linux.md) | Running `biorouter serve` as a long-lived service on a Linux host with no graphical desktop: the CLI-only packages, the systemd unit, migrating secrets onto the host, and network exposure. | | [Reaching a private chat from a script](programmatic-session-access.md) | The `X-Caller-Provider` header: how a monitoring dashboard, a CI job or a shell script reads and follows a **private** conversation over the HTTP API, what the header is not (it is not authentication), and which routes honour it. | | [How browser-served Biorouter is built](serve-architecture.md) | Developer-facing architecture: what the daemon does with a web directory, how a browser is authenticated, and what the retired front door was replaced by. | -| [Decisions behind `biorouter serve`](serve-decisions.md) | The nine decision records governing the serving path — why a browser session cannot change its model, why the bind defaults to loopback, why the standalone binary was retired, and why the launch token is reusable until the daemon stops. | +| [Decisions behind `biorouter serve`](serve-decisions.md) | The ten decision records governing the serving path — why a browser session cannot change its model, why the bind defaults to loopback, why the standalone binary was retired, why the launch token is reusable until the daemon stops, and which chats the deprecated `biorouter web` may still open. | ## Related documentation diff --git a/docs/deployment/serve-decisions.md b/docs/deployment/serve-decisions.md index c62d8832b..72e6c8e4d 100644 --- a/docs/deployment/serve-decisions.md +++ b/docs/deployment/serve-decisions.md @@ -2,9 +2,9 @@ > **What this is.** The decision records governing browser-served Biorouter — why the daemon > serves the interface itself, why a browser session cannot change its model, why the -> standalone `biorouter-headless` binary was retired, and how long the launch token stays good -> for. Each record states the ruling, the alternatives it displaced, and the consequence a -> future change would have to accept. +> standalone `biorouter-headless` binary was retired, how long the launch token stays good for, +> and which chats the deprecated `biorouter web` may still open. Each record states the ruling, +> the alternatives it displaced, and the consequence a future change would have to accept. > **Status:** Current. > **Audience:** developers working on the daemon, the CLI, or release packaging; agents making > changes anywhere near the serving path. @@ -546,6 +546,211 @@ with what the interface now does instead, is written up in [SD-8](#sd-8--a-control-that-can-never-work-here-says-so-rather-than-failing-on-click). Nothing about the refusals above changed. +## SD-13 — `biorouter web` serves no transcripts, and opens no private chat it did not start + +**Ruling (2026-09-11).** The deprecated `biorouter web` command no longer serves +`GET /api/sessions` or `GET /api/sessions/{id}`. The one way into a chat it keeps — a WebSocket +message, which runs a turn in whichever chat it names — is judged before anything touches that +chat. The page is a **public** caller, except in a chat this server started itself through +`GET /`, where it holds the tier of the provider the server was started on. A chat it may not +reach is refused with one sentence, identical for a private chat and for an id that names +nothing. + +**Why.** Both routes predate the privacy tiers (issue #56) and never learned them. The list +returned every user and scheduled chat on the machine with its title and working directory; the +transcript route returned any chat's full conversation, private ones included. The only +credential in front of them was the page's own, and it held nothing back: + +- **Without `--auth-token`** — the default, and all a loopback bind requires — the auth + middleware lets every request through, so anything that can reach the port reads every chat. + A model with a shell does it with `curl`. +- **With `--auth-token`**, the token is a command-line argument. Any process running as the same + user reads it with `ps -axww -o args` (measured on macOS), and on Linux `/proc//cmdline` + is readable by every user unless `/proc` is mounted with `hidepid`. That is + [AR-11](../security/privacy-tiers-execution-plan.md#ar-11--amended-by-dr-17--the-daemons-own-api-secret-is-recoverable)'s + recovery of the daemon's secret, through a channel that is more open than the environment. + +The page read the transcript route for a message count and a tab title, and never read the list. +Gating them would have kept two routes nobody needed, so both were deleted. + +The WebSocket could not be deleted, because it is the chat; it is gated instead. It was the +larger way in, and it was open as well. A message naming a private chat started anywhere else +ran a turn there — Gate B rebinds the one shared agent to the private model that chat's row +names — and streamed the reply, which can quote the whole conversation, back to whoever held +the socket. That is the daemon's `POST /reply` under another name, and `/reply` heads the +daemon's gated list because it dominates every read route. Deleting the transcript route alone +would have closed the smaller way in and left this one. + +**How the page's capability is decided.** Nothing on the socket names the model or the person on +the other end, so the page is a public caller, which is also how the daemon treats a caller that +states no capability. A chat this server started is the exception, reached at the tier of the +provider the server was started on. Without it, a server on a private model would give one reply +per chat: the first reply ratchets the chat to private, and the next message would be refused. +On a public model the exception changes nothing, so a chat this server started that was taken +private somewhere else is refused like any other. + +**Displaced alternatives.** + +- *Gate the two routes: list public chats only, and refuse a private transcript.* Rejected. It + keeps a list nothing reads and a transcript the page never showed, and every route kept is one + more place the reach rule has to be right. +- *Give the page the server's tier for every chat.* Rejected. On a private model, any process + that can reach the port — a public-model chat's shell included — would reach every private + chat on the machine without stating anything. The daemon's residual at least requires the + caller to name a private provider. +- *Refuse every private chat.* Rejected. It breaks the command on the second message of every + chat for exactly the operator who chose a private model. + +**What this is NOT.** It is not authentication. The page's credential is still within any local +process's reach — served to whoever can reach the port without `--auth-token`, read from argv +with it — so a local process can still drive public chats and the chats this server started, as +it could before; issue #47 is unchanged. Nothing that was refused before is permitted now: the +change removes two routes and refuses turns, and grants nothing. + +**Consequence to accept.** A chat is known as started here only for the life of the process. +After a restart it counts as started elsewhere, and a private one must be continued in the +desktop app. The page also stops showing "Session resumed: N messages loaded", because that +count came from the transcript route. Implemented in `crates/biorouter-cli/src/commands/web.rs` +(`turn_reach`, `page_capability` and `refuse_turn_unless_reachable`) and pinned by that module's +tests, three of which drive the real router and WebSocket handler over a socket, against a real +session store. + +### The same page reflected the URL into script context + +**Ruling (2026-09-11).** `GET /session/{name}` no longer writes anything into a `", session_name +``` + +`session_name` is a path segment, so it is whatever the sender typed — behind no credential at +all on the loopback bind that requires none. A `'` ended the string literal and a `` +ended the element. `GET /session/` was served back as: + +```html +… +``` + +On this page that is not defacement. The injected script runs on the server's own origin, reads +`data-ws-token` out of the very document it was injected into, opens `/ws` with it, and sends a +message to an agent that holds `developer__shell`. WebSockets are not subject to the same-origin +policy, so that token is the only thing standing between a drive-by page and the socket — and the +injection is handed it. One link the operator clicks is remote code execution as the operator. + +**Displaced alternatives.** + +- *HTML-escape the value inside the `