From 8fae8249c19d8b1ed773b264f2738c30a022e72f Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 02:05:25 -0700 Subject: [PATCH 1/5] fix(privacy): announce an OFF master switch and record which door set it (H3) The 2026-09-10 security test drive overwrote privacy-tiers.json with {"enabled":false} from a chat's developer__shell; the next launch came up with every gate and the ratchet off and nothing said so. The file channel is DR-17's accepted risk and stays open. The silence is what this closes. - master_switch::load warns once per process when the record resolves OFF, naming the file and the fact. The answer is unchanged: the loader still obeys the record. - Both doors stamp what they write: changed_by {via, set_to, system_authenticated, user_action}. via is settings (the confirmed /config/upsert arm) or migration (DR-22's carry). An OFF record no stamp vouches for - none, or one whose set_to contradicts it - is "unrecorded" and the WARN says it was turned off outside the app. The stamp names its value so a one-field jq flip of a stamped record is still caught. It is parsed leniently: a stamp that does not parse can never make the record unreadable (which would fail a user's off towards on). - The loader and the confirmed arm - the atomic's two writers - remember a SwitchReport beside their write, served from memory on /config and /config/read as BIOROUTER_PRIVACY_TIERS_RECORD. Both read paths overwrite any config.yaml copy of that key. - The record is created 0600 (best-effort re-chmod of a leftover staging file); hygiene against other accounts, documented as no control against an agent that runs as the owner. Tests: two lib tests and three privacy_toggle_config tests fail on the unmodified implementation (0 WARNs; the surface key absent) and pass here. --- .../src/routes/config_management.rs | 53 +- .../tests/privacy_toggle_config.rs | 208 ++++++ crates/biorouter/src/privacy/master_switch.rs | 680 +++++++++++++++++- crates/biorouter/src/privacy/mod.rs | 40 +- crates/biorouter/tests/privacy_toggle.rs | 3 +- 5 files changed, 960 insertions(+), 24 deletions(-) diff --git a/crates/biorouter-server/src/routes/config_management.rs b/crates/biorouter-server/src/routes/config_management.rs index 02aa556e2..03ca34662 100644 --- a/crates/biorouter-server/src/routes/config_management.rs +++ b/crates/biorouter-server/src/routes/config_management.rs @@ -352,6 +352,14 @@ pub async fn upsert_config( // polkit action — strands a machine with the feature disabled and no way // to turn it back on. That is the same asymmetry Task 55 Step 1 applies // to a `turn:*` chat: spend the cost where the consequence is. + let mut confirmation = biorouter::privacy::master_switch::Confirmation { + system_authenticated: false, + // Recorded, not required — see note (c) in privacy-tiers.md + // §12.2 for why this arm does not demand the header. The stamp says + // whether it came, so an audit can tell the app's own window from a + // caller holding only the daemon secret. + user_action: is_user_action(&headers), + }; if !on { let prompter = biorouter::privacy::system_auth::prompter(); let request = biorouter::privacy::system_auth::AuthRequest::about( @@ -372,15 +380,20 @@ pub async fn upsert_config( format!("{MASTER_SWITCH_AUTH_REFUSED} {refusal}"), )); } + confirmation.system_authenticated = true; } - return match biorouter::privacy::master_switch::write_for(config, on) { - Ok(()) => { + return match biorouter::privacy::master_switch::write_for(config, on, confirmation) { + Ok(report) => { // Hardening measure (3): the authoritative value lives in daemon // memory, so the write to disk is not enough — this is the // SECOND of the toggle's two writers (the first is start-up's // `load_privacy_tiers_from_config`). biorouter_mcp::privacy_toggle::set_privacy_tiers_enabled(on); + // H3: and the report moves with the value, by the same writer, + // so the surface says "Settings > Privacy" the moment it lands + // rather than repeating what the last launch loaded. + biorouter::privacy::master_switch::remember(report); Ok(Json(Value::String(format!("Upserted key {}", query.key)))) } // The live value is deliberately NOT moved when the record could not @@ -691,6 +704,12 @@ pub async fn read_config( if biorouter::privacy::is_privacy_tiers_key(&query.key) { return Ok(Json(ConfigValueResponse::Value(privacy_tiers_wire_value()))); } + // H3 — and on both read paths, for the reason the mixing arm below gives. + if biorouter::privacy::is_privacy_tiers_record_key(&query.key) { + return Ok(Json(ConfigValueResponse::Value( + privacy_tiers_record_wire_value(), + ))); + } // Issue #56 Task 52, DR-27 — and this arm is not optional. The value is not // in `config.yaml`, so without it `config.get` answers `NotFound` → `null`, @@ -804,6 +823,12 @@ pub async fn read_all_config() -> Result, StatusCode> { biorouter::privacy::PRIVACY_TIERS_CONFIG_KEY.to_string(), privacy_tiers_wire_value(), ); + // H3. INSERTED, not merged: a copy of this key in `config.yaml` — which + // `/config/upsert` writes for any key — is replaced here, never passed on. + values.insert( + biorouter::privacy::PRIVACY_TIERS_RECORD_KEY.to_string(), + privacy_tiers_record_wire_value(), + ); // Issue #56 Task 52, DR-27 — both read paths, for the reason the single-key // one gives: the value is not in `config.yaml`, so a bulk read that skipped // it would report the setting as absent on every machine. @@ -857,6 +882,30 @@ fn privacy_tiers_wire_value() -> Value { ) } +/// The switch's record report as the two config READ paths serve it (H3, the +/// 2026-09-10 security test drive): where the record is and which door last +/// wrote it, so the app can say "off, and turned off outside the app" instead +/// of nothing. +/// +/// ⚠ **From memory, never a second read of the record** — for +/// [`privacy_tiers_wire_value`]'s reason. The report is what the loader loaded +/// or the confirmed write wrote, remembered beside the atomic by the same two +/// writers; a fresh read of the file would describe what the NEXT launch will +/// do, which is not the control in force. +/// +/// ⚠ **`null` when the report does not describe the live value** — a process +/// that never loaded the switch, or a test that moved the atomic directly. The +/// renderer then shows the off-state without an explanation; that loses the +/// "how", and it never loses the notice, whose visibility is the switch's alone. +fn privacy_tiers_record_wire_value() -> Value { + match biorouter::privacy::master_switch::remembered() { + Some(report) if report.enabled == biorouter::privacy::privacy_tiers_enabled() => { + serde_json::to_value(report).unwrap_or(Value::Null) + } + _ => Value::Null, + } +} + /// How long one provider gets to construct itself before its affiliation is /// given up on. /// diff --git a/crates/biorouter-server/tests/privacy_toggle_config.rs b/crates/biorouter-server/tests/privacy_toggle_config.rs index 7a36da98a..ecddf0cd5 100644 --- a/crates/biorouter-server/tests/privacy_toggle_config.rs +++ b/crates/biorouter-server/tests/privacy_toggle_config.rs @@ -740,6 +740,214 @@ async fn the_retired_key_is_migrated_once_and_then_ignored() { ); } +// ───────────────────────────────────────────────────────────────────────────── +// H3 (2026-09-10 security test drive): an OFF switch is announced, and says how +// it got there. +// +// The record is agent-writable — DR-17's accepted risk, unchanged here. What the +// drive measured on top of it was the SILENCE: `{"enabled": false}` written from +// a chat's shell disabled every gate at the next launch and nothing in the app +// said so. The config surface the renderer already reads now carries the record +// beside the switch — where it lives and which door last wrote it — so the +// banner can say "off, and turned off outside the app". +// ───────────────────────────────────────────────────────────────────────────── + +/// The wire name, as a literal. `settings/privacy/privacyTiers.ts` mirrors it, +/// and a literal here pins what the renderer reads rather than agreeing with a +/// Rust constant whatever it happens to say. +const RECORD_KEY: &str = "BIOROUTER_PRIVACY_TIERS_RECORD"; + +/// What BOTH config read paths say about the record — asserted equal, because +/// `ConfigContext` reads the map and a single-key reader must not be told +/// something different. +async fn record_on_the_surface() -> Value { + let from_map = read_all_config() + .await + .expect("reading the config map must not fail") + .0 + .config + .get(RECORD_KEY) + .cloned() + .unwrap_or(Value::Null); + let from_key = match read_config(Json(ConfigKeyQuery { + key: RECORD_KEY.to_string(), + is_secret: false, + })) + .await + .expect("reading the record must not fail") + .0 + { + ConfigValueResponse::Value(v) => v, + ConfigValueResponse::MaskedValue(_) => panic!("the record is not a secret"), + }; + assert_eq!(from_map, from_key, "the two config read paths disagree"); + from_map +} + +fn record_path() -> String { + config_dir() + .join("privacy-tiers.json") + .display() + .to_string() +} + +/// THE MEASURED WRITE, then a restart: the surface reports OFF, where the record +/// is, and that no door the app records wrote it. +#[tokio::test] +#[serial_test::serial] +async fn a_record_turned_off_outside_the_app_is_reported_as_such_through_the_config_surface() { + let _fixture = PrivacyToggleFixture::capture(); + reset_switch_storage(); + biorouter::privacy::load_privacy_tiers_from_config(); + + // Byte for byte what the drive wrote from `developer__shell`. + std::fs::write( + config_dir().join("privacy-tiers.json"), + r#"{"enabled":false}"#, + ) + .unwrap(); + biorouter_mcp::privacy_toggle::set_privacy_tiers_enabled(true); + biorouter::privacy::load_privacy_tiers_from_config(); + assert!( + !biorouter::privacy::privacy_tiers_enabled(), + "the file channel is DR-17's accepted risk: the loader still obeys it" + ); + + let record = record_on_the_surface().await; + assert_eq!(record["enabled"], Value::Bool(false), "{record}"); + assert_eq!( + record["origin"], + Value::String("unrecorded".to_string()), + "an OFF record no door recorded writing must be flagged: {record}" + ); + assert_eq!(record["path"], Value::String(record_path()), "{record}"); + assert_eq!(record["last_change"], Value::Null, "{record}"); + + // A copy of the key written into `config.yaml` — which `/config/upsert` + // will do for any key — must not be what the surface serves: the report is + // the daemon's, not a value the agent can pre-fill. + Config::global() + .set( + RECORD_KEY, + &serde_json::json!({"enabled": false, "origin": "settings"}), + false, + ) + .unwrap(); + assert_eq!( + record_on_the_surface().await["origin"], + Value::String("unrecorded".to_string()), + "a forged copy in config.yaml reached the surface" + ); + Config::global().delete(RECORD_KEY).ok(); +} + +/// The mirror: the deliberate path is reported as deliberate — live, the moment +/// it lands, and again after a restart reads it back from disk. Without this, +/// the test above would be satisfied by a surface that calls every OFF +/// "outside the app". +#[tokio::test] +#[serial_test::serial] +async fn a_deliberate_change_is_reported_as_deliberate_live_and_after_a_restart() { + let _env = env_lock::lock_env([("BIOROUTER_PRIVACY_TEST_AUTH", None::<&str>)]); + let _fixture = PrivacyToggleFixture::capture(); + install_user_action_key_once(); + reset_switch_storage(); + biorouter::privacy::load_privacy_tiers_from_config(); + + arm_the_system_prompt(biorouter::privacy::system_auth::AuthOutcome::Approved); + let _ok = upsert_config( + user_action_headers(), + Json(upsert( + biorouter::privacy::PRIVACY_TIERS_CONFIG_KEY, + "off", + Some(biorouter::privacy::PRIVACY_TIERS_DISABLE_PHRASE), + )), + ) + .await + .expect("the confirmed, authenticated flip is the deliberate door"); + assert!(!biorouter::privacy::privacy_tiers_enabled()); + + let live = record_on_the_surface().await; + assert_eq!(live["enabled"], Value::Bool(false), "{live}"); + assert_eq!( + live["origin"], + Value::String("settings".to_string()), + "{live}" + ); + assert_eq!(live["path"], Value::String(record_path()), "{live}"); + let change = &live["last_change"]; + assert_eq!( + change["via"], + Value::String("settings".to_string()), + "{live}" + ); + assert_eq!(change["set_to"], Value::Bool(false), "{live}"); + assert_eq!(change["system_authenticated"], Value::Bool(true), "{live}"); + assert_eq!(change["user_action"], Value::Bool(true), "{live}"); + assert!( + change["at"].as_str().is_some_and(|at| !at.is_empty()), + "the deliberate change must say when: {live}" + ); + + // THE RESTART reads the stamp back off the disk. + biorouter_mcp::privacy_toggle::set_privacy_tiers_enabled(true); + biorouter::privacy::load_privacy_tiers_from_config(); + assert!(!biorouter::privacy::privacy_tiers_enabled()); + let reloaded = record_on_the_surface().await; + assert_eq!( + reloaded["origin"], + Value::String("settings".to_string()), + "the deliberate stamp did not survive a restart: {reloaded}" + ); + assert_eq!(reloaded["last_change"], live["last_change"], "{reloaded}"); +} + +/// The edit an agent is likeliest to make is not an overwrite but a one-field +/// flip — `jq '.enabled = false'` — which keeps whatever else the record held. +/// A stamp that named only its door would then vouch for a value it never +/// wrote, so the stamp names the value too, and a disagreement is flagged. +#[tokio::test] +#[serial_test::serial] +async fn an_edit_that_flips_only_the_value_is_not_mistaken_for_a_deliberate_change() { + let _fixture = PrivacyToggleFixture::capture(); + reset_switch_storage(); + biorouter::privacy::load_privacy_tiers_from_config(); + + // A deliberate ON, through the door — which needs no system prompt. + let _ok = upsert_config( + HeaderMap::new(), + Json(upsert( + biorouter::privacy::PRIVACY_TIERS_CONFIG_KEY, + "on", + Some(biorouter::privacy::PRIVACY_TIERS_DISABLE_PHRASE), + )), + ) + .await + .expect("re-enabling goes through the same door"); + + // THE ONE-FIELD FLIP, keeping every other byte of the stamped record. + let path = config_dir().join("privacy-tiers.json"); + let mut on_disk: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()) + .expect("the door writes JSON"); + on_disk["enabled"] = Value::Bool(false); + std::fs::write(&path, serde_json::to_string_pretty(&on_disk).unwrap()).unwrap(); + + biorouter::privacy::load_privacy_tiers_from_config(); + assert!(!biorouter::privacy::privacy_tiers_enabled()); + let record = record_on_the_surface().await; + assert_eq!( + record["origin"], + Value::String("unrecorded".to_string()), + "a stamp that set ON vouched for an OFF it never wrote: {record}" + ); + assert_eq!( + record["last_change"]["set_to"], + Value::Bool(true), + "the surface keeps what the last recorded change DID set, which is the \ + evidence of the edit: {record}" + ); +} + // ───────────────────────────────────────────────────────────────────────────── // Task 52 (DR-27): the cross-institution mixing policy's WRITE DOOR. // diff --git a/crates/biorouter/src/privacy/master_switch.rs b/crates/biorouter/src/privacy/master_switch.rs index 1fa8ce3b3..1c12aff0c 100644 --- a/crates/biorouter/src/privacy/master_switch.rs +++ b/crates/biorouter/src/privacy/master_switch.rs @@ -35,6 +35,36 @@ //! deferred, or an OS-authenticated store; neither is in v1, and this module //! must not be cited as though it were either. //! +//! ⚠ **What it does instead is refuse to be SILENT about an OFF answer** (H3 of +//! the 2026-09-10 security test drive). The drive wrote `{"enabled": false}` +//! over this record from a chat's shell; at the next launch every gate was off, +//! and nothing — no log line, no banner — said so. Three things now do: +//! +//! 1. **[`load`] warns, once, whenever the record resolves to OFF**, naming the +//! file and the fact. +//! 2. **Each door stamps the record it writes** ([`ChangeStamp`]): which door, +//! and the value it wrote. A record whose value no stamp vouches for is +//! [`SwitchOrigin::Unrecorded`], and the WARN says "turned off outside the +//! app". The stamp names its VALUE, not only its door, because the edit an +//! agent is likeliest to make is a one-field flip (`jq '.enabled = false'`) +//! that keeps every other byte — a stamp naming only its door would then +//! vouch for a value it never wrote. +//! 3. **The report is served beside the switch** on the two config read paths +//! ([`remember`] / [`remembered`], `super::PRIVACY_TIERS_RECORD_KEY`), and +//! the desktop app shows a standing note above the composer from it. +//! +//! ⚠ **None of the three is a barrier, and the stamp is not a proof.** Anything +//! that can write this file can write a plausible stamp into it; what the stamp +//! reliably catches is the write the drive measured and a one-field flip, not a +//! forger who read this comment. And the file is created owner-only (`0600`), +//! which keeps OTHER local accounts from reading or writing it and does nothing +//! about the agent: the agent's shell runs as the user, so to the operating +//! system it IS the owner — a permission, ownership or integrity check at load +//! cannot tell the two apart, and one keyed to a secret on this machine would +//! only be as strong as the agent's inability to read that secret, which DR-17 +//! does not give. The signal exists so the OFF state cannot hide; the switch +//! still obeys the file, exactly as DR-17 accepted. +//! //! ⚠ **The store is created even when the answer is the default**, and that is //! load-bearing rather than tidy. Its existence is the migration's "already //! done" marker (see [`migrate_once`]), and on the overwhelming majority of @@ -67,6 +97,229 @@ pub struct MasterSwitchRecord { /// for missing it would fail towards *on* in a way the user did not ask for. #[serde(default)] pub changed_at: String, + /// Which door wrote this record, and what it wrote (H3). Absent on a record + /// no door wrote — and on one written before doors stamped. + /// + /// ⚠ **Read leniently, for `changed_at`'s reason.** A stamp that does not + /// parse — a hand edit, a door a later version adds — reads as *no stamp* + /// and never fails the record: whether the record reads is `enabled`'s + /// question alone, and a stamp that could turn a user's `off` into an + /// unreadable record would fail towards ON in a way they did not ask for. + #[serde( + default, + deserialize_with = "lenient_stamp", + skip_serializing_if = "Option::is_none" + )] + pub changed_by: Option, +} + +/// A stamp that fails to parse is no stamp; see [`MasterSwitchRecord::changed_by`]. +fn lenient_stamp<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let raw = serde_json::Value::deserialize(deserializer)?; + Ok(serde_json::from_value(raw).ok()) +} + +/// The two things in the tree that write the record. There is no third. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChangeDoor { + /// Settings → Privacy's typed confirmation, through `/config/upsert`'s + /// gated arm — [`write_in`]. + Settings, + /// DR-22's one-time migration, carrying a `config.yaml` value across — + /// [`migrate_once`]. + Migration, +} + +/// What a door leaves on the record it writes (H3): which door, and the value. +/// +/// ⚠ **`set_to` is load-bearing, not redundant with `enabled`.** A stamp is a +/// statement about one write. A record whose `enabled` disagrees with its +/// stamp's `set_to` was edited after that write, and [`SwitchReport::of`] reads +/// it as [`SwitchOrigin::Unrecorded`] — which is what makes a one-field flip of +/// a stamped record visible at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChangeStamp { + pub via: ChangeDoor, + /// The value this door wrote. + pub set_to: bool, + /// DR-20: the operating system confirmed the person at the keyboard. Only + /// ever true for an OFF written through Settings, which cannot be written + /// without it. + #[serde(default)] + pub system_authenticated: bool, + /// DR-16: the request carried the app's own proof of a user, as opposed to + /// only the daemon secret. + #[serde(default)] + pub user_action: bool, +} + +/// What the confirmed `/config/upsert` arm knows about who asked — the "who" of +/// a deliberate change, recorded rather than claimed. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Confirmation { + /// DR-20's system prompt was raised for this write and approved. + pub system_authenticated: bool, + /// The request carried `X-User-Action` and it verified. + pub user_action: bool, +} + +/// How the record got the value it has — the verdict [`SwitchReport::of`] +/// reaches, and what the WARN and the app's note are worded from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SwitchOrigin { + /// No readable record: the fail-safe ON. Never describes an OFF switch. + Default, + /// Written by Settings → Privacy and unchanged since. + Settings, + /// Carried across by the migration and unchanged since. + Migration, + /// No door recorded writing this value: the record was edited directly, or + /// written by a version too old to stamp it. For an OFF record this is + /// "turned off outside the app". + Unrecorded, +} + +/// The last change a door recorded, as the record carries it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RecordedChange { + pub via: ChangeDoor, + /// What that door wrote — which, on an [`SwitchOrigin::Unrecorded`] record, + /// is not what the record says now. That disagreement is the evidence. + pub set_to: bool, + /// The record's `changed_at`. As forgeable as the rest of the file. + pub at: String, + pub system_authenticated: bool, + pub user_action: bool, +} + +/// What the switch's record says and how it got there: logged by [`load`], +/// remembered for the config surface, and served to the renderer as +/// `super::PRIVACY_TIERS_RECORD_KEY`. +/// +/// ⚠ **It explains the switch; it never decides it.** `enabled` is what the +/// load resolved, and the process-global atomic every gate reads is set from +/// it by the loader alone. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SwitchReport { + pub enabled: bool, + pub origin: SwitchOrigin, + /// The record's absolute path, on or off, so a notice can say where to look. + pub path: String, + pub last_change: Option, +} + +impl SwitchReport { + /// Classify a record read from (or just written to) `path`. `None` is the + /// absent-or-unreadable record, which the loader resolves to ON. + pub fn of(path: &Path, record: Option<&MasterSwitchRecord>) -> Self { + let path = path.display().to_string(); + let Some(record) = record else { + return Self { + enabled: true, + origin: SwitchOrigin::Default, + path, + last_change: None, + }; + }; + let origin = match record.changed_by { + Some(stamp) if stamp.set_to == record.enabled => match stamp.via { + ChangeDoor::Settings => SwitchOrigin::Settings, + ChangeDoor::Migration => SwitchOrigin::Migration, + }, + _ => SwitchOrigin::Unrecorded, + }; + let last_change = record.changed_by.map(|stamp| RecordedChange { + via: stamp.via, + set_to: stamp.set_to, + at: record.changed_at.clone(), + system_authenticated: stamp.system_authenticated, + user_action: stamp.user_action, + }); + Self { + enabled: record.enabled, + origin, + path, + last_change, + } + } + + /// The one line [`load`] logs at WARN, or `None` for an enforcing switch — + /// ON is the default and the common state, and announcing it would teach + /// every reader of the log to skip the line that matters. + pub fn off_warning(&self) -> Option { + if self.enabled { + return None; + } + let path = &self.path; + let consequence = + "Every privacy gate and the classification ratchet are disabled in this process."; + Some(match (self.origin, &self.last_change) { + (SwitchOrigin::Settings, Some(change)) => format!( + "privacy tiers are OFF: {path} records that they were turned off in \ + Settings > Privacy at {at}{confirmed}. {consequence}", + at = change.at, + confirmed = if change.system_authenticated { + ", confirmed by the operating system" + } else { + "" + }, + ), + (SwitchOrigin::Migration, Some(change)) => format!( + "privacy tiers are OFF: {path} records that they were carried over as off \ + from config.yaml at {at}. {consequence}", + at = change.at, + ), + (_, last_change) => { + // Two shapes, and only one of them can be an older version: a + // Biorouter from before the stamp rewrites the whole record + // without one, so a stamp that CONTRADICTS the value can only + // be an edit made after the write it describes. + let (history, how) = match last_change { + Some(change) if change.set_to => ( + format!( + "the last change Biorouter recorded turned them ON at {}", + change.at + ), + "It has been edited since, outside the app — by hand, by a script or \ + by an agent's shell.", + ), + _ => ( + "it records no change made in Settings > Privacy".to_string(), + "It was edited directly — by hand, by a script or by an agent's shell \ + — or written by a Biorouter too old to record changes.", + ), + }; + format!( + "privacy tiers are OFF, and they were turned off outside the app: {path} \ + says they are off, but {history}. {how} {consequence} Turn them back on \ + in Settings > Privacy." + ) + } + }) + } +} + +/// What the config surface reports beside the live switch, for this process. +/// +/// Written by exactly the two writers of the switch's atomic, each beside its +/// own write — start-up's loader and `/config/upsert`'s gated arm — so the +/// report and the value move together. Nothing else may call [`remember`]. +static REPORTED: std::sync::RwLock> = std::sync::RwLock::new(None); + +/// Replace the report the config surface serves. See [`REPORTED`] for who may. +pub fn remember(report: SwitchReport) { + *REPORTED.write().unwrap_or_else(|e| e.into_inner()) = Some(report); +} + +/// The report, or `None` in a process that has neither loaded nor written the +/// switch — every test binary that pokes the atomic directly, for one. +pub fn remembered() -> Option { + REPORTED.read().unwrap_or_else(|e| e.into_inner()).clone() } /// The directory the record lives in: the one holding `config.yaml`. @@ -112,10 +365,13 @@ pub fn path_for(config: &Config) -> PathBuf { /// enforces, and rewriting a file this function is only supposed to read would /// make a reader into a third writer. pub fn read_in(config_dir: &Path) -> Option { + read_record_in(config_dir).map(|record| record.enabled) +} + +/// The whole record, stamp included, or `None` exactly when [`read_in`] is. +pub fn read_record_in(config_dir: &Path) -> Option { let raw = std::fs::read_to_string(path_in(config_dir)).ok()?; - serde_json::from_str::(&raw) - .ok() - .map(|record| record.enabled) + serde_json::from_str::(&raw).ok() } /// [`read_in`], for the directory a given [`Config`] lives in. @@ -142,12 +398,29 @@ pub fn exists_in(config_dir: &Path) -> bool { /// A process that dies inside that window would silently re-enable a feature the /// user turned off. A rename within one directory is atomic, so the record is /// only ever absent or complete. -pub fn write_in(config_dir: &Path, enabled: bool) -> std::io::Result<()> { +/// +/// **This is the Settings door** — `/config/upsert`'s confirmed arm is its only +/// caller — so it stamps [`ChangeDoor::Settings`] with what `confirmation` +/// says, and returns the report the config surface should now serve. +pub fn write_in( + config_dir: &Path, + enabled: bool, + confirmation: Confirmation, +) -> std::io::Result { std::fs::create_dir_all(config_dir)?; + let record = stamped( + enabled, + ChangeStamp { + via: ChangeDoor::Settings, + set_to: enabled, + system_authenticated: confirmation.system_authenticated, + user_action: confirmation.user_action, + }, + ); let staging = staging_path(config_dir); - std::fs::write(&staging, body(enabled)?)?; + write_owner_only(&staging, &serialise(&record)?)?; match std::fs::rename(&staging, path_in(config_dir)) { - Ok(()) => Ok(()), + Ok(()) => Ok(SwitchReport::of(&path_in(config_dir), Some(&record))), Err(e) => { // Do not leave the staging file in the user's config directory. let _ = std::fs::remove_file(&staging); @@ -156,16 +429,61 @@ pub fn write_in(config_dir: &Path, enabled: bool) -> std::io::Result<()> { } } -/// The record's serialised body, timestamped now. -fn body(enabled: bool) -> std::io::Result { - let record = MasterSwitchRecord { +/// A record written now, by the door `stamp` names. +fn stamped(enabled: bool, stamp: ChangeStamp) -> MasterSwitchRecord { + MasterSwitchRecord { enabled, changed_at: chrono::Utc::now().to_rfc3339(), - }; - serde_json::to_string_pretty(&record) + changed_by: Some(stamp), + } +} + +/// The record's serialised body. +fn serialise(record: &MasterSwitchRecord) -> std::io::Result { + serde_json::to_string_pretty(record) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) } +/// Create `path` readable and writable by its owner only, where the platform +/// has the notion (H3). +/// +/// ⚠ **Hygiene against OTHER accounts, not a control against the agent.** The +/// agent's shell runs as the user, so it owns this file as surely as the user +/// does and can `chmod` it back. What `0600` buys is that a second local +/// account cannot read or rewrite a security record in someone else's config +/// directory — the default umask would leave it world-readable. +/// +/// The mode is requested at creation, so a freshly staged record never exists +/// with looser bits, AND set again on the open handle: a staging file left by a +/// crashed process whose pid has since been reused is opened rather than +/// created, and `mode` applies only to a file it creates. Truncating rather than +/// `create_new`, because refusing that leftover would fail the user's flip for a +/// reason they cannot see — `fs::write`, which this replaces, overwrote it. +/// +/// ⚠ **The second `chmod` is best-effort, and must stay so.** A configuration +/// directory on a filesystem without Unix modes — exFAT, some network mounts — +/// can refuse it, and hygiene must never be the reason a user's flip fails +/// where `fs::write` used to succeed. +fn write_owner_only(path: &Path, contents: &str) -> std::io::Result<()> { + use std::io::Write as _; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let mut file = options.open(path)?; + #[cfg(unix)] + let _ = file.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600)); + let written = file + .write_all(contents.as_bytes()) + .and_then(|()| file.sync_all()); + if written.is_err() { + // A torn staging file is litter in the user's config directory; the + // caller has not published it, so nothing else can be pointing at it. + let _ = std::fs::remove_file(path); + } + written +} + /// Where a write stages before it is published. **A fresh path per call.** /// /// The process id keeps two Biorouter processes writing at the same moment out @@ -203,11 +521,24 @@ fn staging_path(config_dir: &Path) -> PathBuf { /// permanently blocking the migration that would have carried the user's `off` /// across). Linking a fully-written staging file into place is the one operation /// that is both. +/// +/// Stamped [`ChangeDoor::Migration`]: the carried value came out of a +/// `config.yaml` DR-22 names as agent-writable, so it is attributed to the +/// migration that carried it rather than to a Settings change nobody made. fn claim_in(config_dir: &Path, enabled: bool) -> std::io::Result<()> { std::fs::create_dir_all(config_dir)?; let target = path_in(config_dir); let staging = staging_path(config_dir); - std::fs::write(&staging, body(enabled)?)?; + let record = stamped( + enabled, + ChangeStamp { + via: ChangeDoor::Migration, + set_to: enabled, + system_authenticated: false, + user_action: false, + }, + ); + write_owner_only(&staging, &serialise(&record)?)?; let claimed = match std::fs::hard_link(&staging, &target) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e), @@ -230,8 +561,36 @@ fn claim_in(config_dir: &Path, enabled: bool) -> std::io::Result<()> { } /// [`write_in`], for the directory a given [`Config`] lives in. -pub fn write_for(config: &Config, enabled: bool) -> std::io::Result<()> { - write_in(&dir_of(config), enabled) +pub fn write_for( + config: &Config, + enabled: bool, + confirmation: Confirmation, +) -> std::io::Result { + write_in(&dir_of(config), enabled, confirmation) +} + +/// Resolve the switch from disk — the one-time migration, the read, the +/// classification — and **say so when the answer is OFF** (H3). +/// +/// The whole of the disk resolution: `super::resolve_privacy_tiers` is +/// `load(config).enabled`, and `super::load_privacy_tiers_from_config` is this +/// plus [`remember`] plus the atomic. Once per process, so the WARN is once per +/// process — at the moment the gates go dark, which is the moment the drive +/// measured nothing being said. +/// +/// ⚠ **The WARN does not change the answer.** An OFF record is obeyed exactly +/// as before — the file channel is DR-17's accepted risk, and a loader that +/// second-guessed the record would be a redesign of the switch, not a signal +/// about it. Absent and unreadable still resolve to ON, silently: that is the +/// fail-safe direction and nothing to announce. +pub fn load(config: &Config) -> SwitchReport { + migrate_once(config); + let dir = dir_of(config); + let report = SwitchReport::of(&path_in(&dir), read_record_in(&dir).as_ref()); + if let Some(warning) = report.off_warning() { + tracing::warn!(origin = ?report.origin, "{warning}"); + } + report } /// Carry a pre-DR-22 `config.yaml` value into the store, **once**, and retire @@ -338,10 +697,10 @@ mod tests { #[test] fn the_record_round_trips_in_both_positions() { let dir = tempfile::tempdir().unwrap(); - write_in(dir.path(), false).unwrap(); + write_in(dir.path(), false, Confirmation::default()).unwrap(); assert_eq!(read_in(dir.path()), Some(false)); assert!(exists_in(dir.path())); - write_in(dir.path(), true).unwrap(); + write_in(dir.path(), true, Confirmation::default()).unwrap(); assert_eq!(read_in(dir.path()), Some(true)); } @@ -534,4 +893,293 @@ mod tests { "the environment reached the migration's read of the retired key" ); } + + /// Formatted tracing output, so a test can assert what a load SAID and at + /// what level — the same seam `slash_commands.rs` uses. + #[derive(Clone, Default)] + struct CapturedLogs(std::sync::Arc>>); + + impl std::io::Write for CapturedLogs { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs { + type Writer = CapturedLogs; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } + } + + fn capture(f: impl FnOnce() -> T) -> (T, String) { + let logs = CapturedLogs::default(); + let subscriber = tracing_subscriber::fmt() + .with_writer(logs.clone()) + .with_max_level(tracing::Level::DEBUG) + .with_ansi(false) + .finish(); + let value = tracing::subscriber::with_default(subscriber, f); + let text = String::from_utf8_lossy(&logs.0.lock().unwrap()).to_string(); + (value, text) + } + + fn warnings(logs: &str) -> Vec<&str> { + logs.lines() + .filter(|line| line.contains(" WARN ")) + .collect() + } + + /// H3 (2026-09-10 security test drive): overwriting the record with + /// `{"enabled": false}` from a chat's shell turned every gate off at the next + /// launch and NOTHING said so. The file channel is the accepted risk; the + /// silence was not. The load now says it once, naming the file and the fact. + #[test] + fn loading_an_off_record_warns_once_naming_the_file_and_the_fact() { + let (dir, config) = scratch(); + std::fs::write(path_in(dir.path()), r#"{"enabled": false}"#).unwrap(); + + let (on, logs) = capture(|| super::super::resolve_privacy_tiers(&config)); + + assert!(!on, "the record says off and the loader must still obey it"); + let warns = warnings(&logs); + assert_eq!( + warns.len(), + 1, + "one WARN at load, not zero and not one per gate: {logs}" + ); + let path = path_in(dir.path()).display().to_string(); + assert!( + warns[0].contains(&path), + "the WARN must name the record, so the reader knows what to inspect: {logs}" + ); + assert!( + warns[0].contains("privacy tiers are OFF"), + "the WARN must state the fact: {logs}" + ); + } + + /// The measured write carries no trace of the door it did not come + /// through, and the load says so rather than presenting it like a choice the + /// user made in Settings → Privacy. + #[test] + fn an_off_record_with_no_deliberate_change_recorded_is_flagged_as_outside_the_app() { + let (dir, config) = scratch(); + std::fs::write(path_in(dir.path()), r#"{"enabled": false}"#).unwrap(); + + let (_on, logs) = capture(|| super::super::resolve_privacy_tiers(&config)); + + let warns = warnings(&logs); + assert!( + warns + .iter() + .any(|line| line.contains("turned off outside the app")), + "an OFF record no door recorded writing must be flagged: {logs}" + ); + } + + /// The unit half of "reports OFF through the status surface": what the load + /// resolves is what the loader remembers, and what the config surface + /// serialises is the report verbatim — OFF, where, and how. + #[test] + #[serial_test::serial] + fn loading_an_off_record_reports_off_through_the_status_surface() { + let (dir, config) = scratch(); + std::fs::write(path_in(dir.path()), r#"{"enabled": false}"#).unwrap(); + + let (report, logs) = capture(|| load(&config)); + assert_eq!(warnings(&logs).len(), 1, "{logs}"); + assert!(!report.enabled); + assert_eq!(report.origin, SwitchOrigin::Unrecorded); + assert_eq!(report.path, path_in(dir.path()).display().to_string()); + + let previous = remembered(); + remember(report.clone()); + assert_eq!(remembered().as_ref(), Some(&report)); + assert_eq!( + serde_json::to_value(&report).unwrap(), + serde_json::json!({ + "enabled": false, + "origin": "unrecorded", + "path": path_in(dir.path()).display().to_string(), + "last_change": null, + }), + "the wire shape `privacyTiers.ts` parses" + ); + if let Some(previous) = previous { + remember(previous); + } + } + + /// The deliberate door stamps what it wrote and who confirmed it, and the + /// load reads that back as deliberate — no "outside the app". + #[test] + fn a_settings_write_is_stamped_and_reads_back_as_deliberate() { + let (dir, config) = scratch(); + let written = write_for( + &config, + false, + Confirmation { + system_authenticated: true, + user_action: true, + }, + ) + .unwrap(); + assert_eq!(written.origin, SwitchOrigin::Settings); + + let record = read_record_in(dir.path()).expect("the door writes a readable record"); + assert_eq!( + record.changed_by, + Some(ChangeStamp { + via: ChangeDoor::Settings, + set_to: false, + system_authenticated: true, + user_action: true, + }) + ); + + let (report, logs) = capture(|| load(&config)); + assert_eq!( + report, written, + "the restart reads back what the door wrote" + ); + let warns = warnings(&logs); + assert_eq!( + warns.len(), + 1, + "OFF is announced however it got there: {logs}" + ); + assert!(warns[0].contains("Settings > Privacy"), "{logs}"); + assert!( + warns[0].contains("confirmed by the operating system"), + "{logs}" + ); + assert!( + !warns[0].contains("outside the app"), + "a deliberate change was reported as tampering: {logs}" + ); + } + + /// `jq '.enabled = false'` over a stamped ON record keeps the stamp. The + /// stamp names the value it wrote, so the flip is still flagged — and the + /// report keeps the stamp as the evidence. + #[test] + fn a_one_field_flip_of_a_stamped_record_is_flagged() { + let (dir, config) = scratch(); + write_for(&config, true, Confirmation::default()).unwrap(); + let mut record: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path_in(dir.path())).unwrap()).unwrap(); + record["enabled"] = serde_json::Value::Bool(false); + std::fs::write(path_in(dir.path()), record.to_string()).unwrap(); + + let (report, logs) = capture(|| load(&config)); + assert!(!report.enabled); + assert_eq!(report.origin, SwitchOrigin::Unrecorded); + assert_eq!(report.last_change.as_ref().map(|c| c.set_to), Some(true)); + let warns = warnings(&logs); + assert!( + warns[0].contains("turned off outside the app") + && warns[0].contains("the last change Biorouter recorded turned them ON"), + "{logs}" + ); + } + + /// A carried `off` came out of a `config.yaml` DR-22 names as + /// agent-writable. It is attributed to the migration that carried it — + /// neither to a Settings change nobody made nor to tampering nobody did. + #[test] + fn a_migrated_off_is_attributed_to_the_migration() { + let (_dir, config) = scratch(); + config + .set( + super::super::PRIVACY_TIERS_CONFIG_KEY, + &serde_json::Value::String("off".to_string()), + false, + ) + .unwrap(); + + let (report, logs) = capture(|| load(&config)); + assert!(!report.enabled); + assert_eq!(report.origin, SwitchOrigin::Migration); + let warns = warnings(&logs); + assert!( + warns[0].contains("carried over as off from config.yaml"), + "{logs}" + ); + } + + /// A stamp that does not parse must never make the RECORD unreadable: that + /// would turn a user's `off` into the fail-safe ON. It reads as no stamp. + #[test] + fn a_malformed_stamp_never_fails_the_record() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + path_in(dir.path()), + r#"{"enabled": false, "changed_by": {"via": "a-door-from-the-future", "set_to": 7}}"#, + ) + .unwrap(); + let record = read_record_in(dir.path()).expect("the record still reads"); + assert!(!record.enabled); + assert_eq!(record.changed_by, None); + assert_eq!( + SwitchReport::of(&path_in(dir.path()), Some(&record)).origin, + SwitchOrigin::Unrecorded + ); + } + + /// Both doors create the record owner-only. Hygiene against OTHER local + /// accounts — the agent runs as the owner, as the module doc says. + #[cfg(unix)] + #[test] + fn both_doors_create_the_record_owner_only() { + use std::os::unix::fs::PermissionsExt; + let mode = |dir: &Path| { + std::fs::metadata(path_in(dir)) + .unwrap() + .permissions() + .mode() + & 0o777 + }; + + let settings = tempfile::tempdir().unwrap(); + write_in(settings.path(), false, Confirmation::default()).unwrap(); + assert_eq!(mode(settings.path()), 0o600, "the Settings door"); + + let migration = tempfile::tempdir().unwrap(); + claim_in(migration.path(), true).unwrap(); + assert_eq!(mode(migration.path()), 0o600, "the migration's claim"); + + // A leftover staging file with looser bits — a crashed process whose pid + // was reused — is tightened, not published as it was. + let leftover = tempfile::tempdir().unwrap(); + let staging = staging_path(leftover.path()); + std::fs::write(&staging, "stale").unwrap(); + std::fs::set_permissions(&staging, std::fs::Permissions::from_mode(0o644)).unwrap(); + write_owner_only(&staging, "{}").unwrap(); + assert_eq!( + std::fs::metadata(&staging).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!(std::fs::read_to_string(&staging).unwrap(), "{}"); + } + + /// ON is the default and the overwhelmingly common state; announcing it + /// would train every reader of the log to skip the line that matters. + #[test] + fn an_on_record_loads_without_a_warning() { + let (dir, config) = scratch(); + std::fs::write(path_in(dir.path()), r#"{"enabled": true}"#).unwrap(); + + let (on, logs) = capture(|| super::super::resolve_privacy_tiers(&config)); + + assert!(on); + assert!( + warnings(&logs).is_empty(), + "an enforcing load must not warn: {logs}" + ); + } } diff --git a/crates/biorouter/src/privacy/mod.rs b/crates/biorouter/src/privacy/mod.rs index 2cf539bda..2d2c618fa 100644 --- a/crates/biorouter/src/privacy/mod.rs +++ b/crates/biorouter/src/privacy/mod.rs @@ -107,8 +107,15 @@ pub use biorouter_mcp::privacy_toggle::privacy_tiers_enabled; /// /// A load error resolves to ON, for the same reason absence does: the failure of /// the loader must not be a way to disable the control. +/// +/// ⚠ **An OFF answer is announced, not merely obeyed** (H3, 2026-09-10 security +/// test drive): [`master_switch::load`] logs one WARN naming the record and how +/// it got its value, and the report is [`master_switch::remember`]ed here — +/// beside the atomic, by the same writer — for the config surface the app reads. pub fn load_privacy_tiers_from_config() { - let on = resolve_privacy_tiers(crate::config::Config::global()); + let report = master_switch::load(crate::config::Config::global()); + let on = report.enabled; + master_switch::remember(report); biorouter_mcp::privacy_toggle::set_privacy_tiers_enabled(on); } @@ -139,10 +146,12 @@ pub fn load_mixing_policy_from_record() { } pub fn resolve_privacy_tiers(config: &crate::config::Config) -> bool { - // Once, on the first start-up after the upgrade, and never again — the only - // read of the retired `config.yaml` key in the tree. - master_switch::migrate_once(config); - master_switch::read_for(config).unwrap_or(true) // nothing recorded OR unreadable => on + // `load` runs the migration (once, on the first start-up after the upgrade, + // and never again — the only read of the retired `config.yaml` key in the + // tree), reads the record, and resolves nothing recorded OR unreadable to + // ON. It also logs the OFF warning, so this seam exercises exactly what the + // loader says as well as what it stores. + master_switch::load(config).enabled } /// The key the master switch is addressed by — over `/config/upsert`, over @@ -171,6 +180,27 @@ pub fn is_privacy_tiers_key(key: &str) -> bool { key == PRIVACY_TIERS_CONFIG_KEY } +/// The key the master switch's RECORD REPORT is served under, beside +/// [`PRIVACY_TIERS_CONFIG_KEY`] on `/config/read` and `/config` (H3): where the +/// record lives and which door last wrote it — [`master_switch::SwitchReport`], +/// as the process last loaded or wrote it. The renderer mirrors the spelling in +/// `settings/privacy/privacyTiers.ts`. +/// +/// ⚠ **A report, never a setting, and it has no writer on the wire.** Both read +/// paths answer it from [`master_switch::remembered`] and overwrite whatever +/// `config.yaml` holds under the same name, so an agent that `/config/upsert`s a +/// flattering copy writes a line nothing reads. That is why neither write verb +/// refuses it — the reason `/config/read`'s synthetic `model-limits` key needs +/// no refusal either — and why no reader may ever consult `config.yaml` for it: +/// the moment one does, "turned off outside the app" becomes something the +/// agent can pre-empt. +pub const PRIVACY_TIERS_RECORD_KEY: &str = "BIOROUTER_PRIVACY_TIERS_RECORD"; + +/// Is this key the switch's record report? See [`PRIVACY_TIERS_RECORD_KEY`]. +pub fn is_privacy_tiers_record_key(key: &str) -> bool { + key == PRIVACY_TIERS_RECORD_KEY +} + /// The typed phrase Settings → Privacy sends with the flip. A **UX guard against /// an accidental or model-composed config write**, not an authorization /// boundary: the phrase is a fixed string in the shipped source, so a caller diff --git a/crates/biorouter/tests/privacy_toggle.rs b/crates/biorouter/tests/privacy_toggle.rs index b6f031ea1..1b5ee82d4 100644 --- a/crates/biorouter/tests/privacy_toggle.rs +++ b/crates/biorouter/tests/privacy_toggle.rs @@ -980,7 +980,8 @@ async fn no_environment_variable_can_turn_protection_off() { // …and the same resolution DOES honour a record on disk, so the assertion // above is about the environment rather than about a resolution that can // never say `off` at all. - biorouter::privacy::master_switch::write_for(&config, false).expect("record the switch"); + biorouter::privacy::master_switch::write_for(&config, false, Default::default()) + .expect("record the switch"); assert!( !biorouter::privacy::resolve_privacy_tiers(&config), "the switch's own record is what the resolution reads" From 730f1f558fd363b72517652790ccaa210c54d8fb Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 02:05:25 -0700 Subject: [PATCH 2/5] feat(desktop): a standing note above every composer while privacy tiers are off (H3) PrivacyTiersOffNote sits in PinnedModelNote's slot above the composer and says the tiers are off, where the switch is recorded, and how it got there: turned off in Settings > Privacy, carried over from config.yaml, or - when no door the app records wrote it - turned off outside the app (danger tone). Its one control opens Settings > Privacy. No dismiss control: anything that remembered a dismissal would be a file an agent could write, so turning the tiers back on is the only way to clear it. Visibility is the switch's alone; a daemon that sends no report costs the explanation, never the note. The copy has one definition (privacyTiersOffCopy) shared with the Settings > Privacy strip, which now names the record and re-reads it after the user's own flip. usePrivacyTiersRecord reads the daemon's report off the same config snapshot as usePrivacyTiersEnabled. Not above the chat header (the 44px chrome band must stay level with the sidebar and artifact strip) and not an app-wide strip (h-screen routes in the shell would overflow it). Tests: PrivacyTiersOffNote.test.tsx - off: present, names the path, flags outside-the-app, names a contradicted last change, no dismiss, opens Settings; on: absent. 6 of 7 fail against a render-nothing stub. --- ui/desktop/src/components/BaseChat.tsx | 9 + ui/desktop/src/components/ConfigContext.tsx | 25 ++- .../privacy/PrivacyTiersOffNote.test.tsx | 159 ++++++++++++++++++ .../privacy/PrivacyTiersOffNote.tsx | 70 ++++++++ .../src/components/privacy/RecordedIn.tsx | 15 ++ .../components/privacy/privacyTiersOffCopy.ts | 93 ++++++++++ .../settings/privacy/PrivacyPanel.test.tsx | 62 +++++++ .../settings/privacy/PrivacyPanel.tsx | 53 +++++- .../settings/privacy/privacyTiers.ts | 92 +++++++++- 9 files changed, 566 insertions(+), 12 deletions(-) create mode 100644 ui/desktop/src/components/privacy/PrivacyTiersOffNote.test.tsx create mode 100644 ui/desktop/src/components/privacy/PrivacyTiersOffNote.tsx create mode 100644 ui/desktop/src/components/privacy/RecordedIn.tsx create mode 100644 ui/desktop/src/components/privacy/privacyTiersOffCopy.ts diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index d3abed351..9a6b7d826 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -42,6 +42,7 @@ import { WorkflowHeader } from './WorkflowHeader'; import { WorkflowWarningModal } from './ui/WorkflowWarningModal'; import { NonPrivateModelDisclosureGate } from './privacy/NonPrivateModelDisclosureGate'; import { PinnedModelNote } from './privacy/PinnedModelNote'; +import { PrivacyTiersOffNote } from './privacy/PrivacyTiersOffNote'; import { usePinnedModel } from './privacy/usePinnedModel'; import { scanWorkflow } from '../workflow'; import { useCostTracking } from '../hooks/useCostTracking'; @@ -2113,6 +2114,14 @@ function BaseChatContent({ Mounted unconditionally — it renders nothing when there is nothing to say, which is almost always. */} + {/* + H3 (2026-09-10 security test drive) — privacy tiers are OFF, where the + switch is recorded, and whether the app recorded turning it off. Same + slot, same rails and the same unconditional mount as the note below, + and first of the two: it is about the whole machine, that one about + this chat. It renders nothing while the tiers are on. + */} + ({ + enabled: true, + record: null as PrivacyTiersRecord | null, +})); +vi.mock('../ConfigContext', () => ({ + usePrivacyTiersEnabled: () => configMocks.enabled, + usePrivacyTiersRecord: () => configMocks.record, +})); + +const PATH = '/Users/someone/.config/biorouter/privacy-tiers.json'; + +const record = (overrides: Partial): PrivacyTiersRecord => ({ + enabled: false, + origin: 'unrecorded', + path: PATH, + lastChange: null, + ...overrides, +}); + +/** Where a click on the note's one control took the app. */ +function Location() { + const location = useLocation(); + return ( +

+ {location.pathname} {JSON.stringify(location.state)} +

+ ); +} + +const mount = () => + render( + + + } /> + } /> + + + ); + +beforeEach(() => { + configMocks.enabled = true; + configMocks.record = null; +}); +afterEach(cleanup); + +describe('PrivacyTiersOffNote', () => { + it('renders nothing while privacy tiers are on', () => { + configMocks.record = record({ enabled: true, origin: 'settings' }); + mount(); + expect(screen.queryByTestId('privacy-tiers-off-note')).toBeNull(); + }); + + it('stands while privacy tiers are off, and says where the switch is recorded', () => { + configMocks.enabled = false; + configMocks.record = record({ origin: 'unrecorded' }); + mount(); + + const note = screen.getByTestId('privacy-tiers-off-note'); + expect(note).toHaveTextContent(/Privacy tiers are off/i); + expect(note).toHaveTextContent(PATH); + // A standing condition is `status`, not `alert` — nothing just failed. + expect(note).toHaveAttribute('role', 'status'); + }); + + it('says the switch was turned off outside the app when no deliberate change is recorded', () => { + configMocks.enabled = false; + configMocks.record = record({ origin: 'unrecorded' }); + mount(); + + expect(screen.getByTestId('privacy-tiers-off-note')).toHaveTextContent( + /turned off outside the app/i + ); + }); + + it('names the last recorded change when a one-field edit contradicts it', () => { + configMocks.enabled = false; + configMocks.record = record({ + origin: 'unrecorded', + lastChange: { + via: 'settings', + setTo: true, + at: '2026-09-10T18:04:00+00:00', + systemAuthenticated: false, + userAction: true, + }, + }); + mount(); + + expect(screen.getByTestId('privacy-tiers-off-note')).toHaveTextContent( + /last change recorded in the app turned them on/i + ); + }); + + it('does not accuse anyone when the change was made in Settings → Privacy', () => { + configMocks.enabled = false; + configMocks.record = record({ + origin: 'settings', + lastChange: { + via: 'settings', + setTo: false, + at: '2026-09-10T18:04:00+00:00', + systemAuthenticated: true, + userAction: true, + }, + }); + mount(); + + const note = screen.getByTestId('privacy-tiers-off-note'); + expect(note).toHaveTextContent(/Settings → Privacy/); + expect(note).not.toHaveTextContent(/outside the app/i); + expect(note).toHaveTextContent(PATH); + }); + + it('still stands when the daemon sends no record at all', () => { + // An older daemon behind the external-backend setup serves the switch but + // not the record. The note must not need the record to exist. + configMocks.enabled = false; + configMocks.record = null; + mount(); + + expect(screen.getByTestId('privacy-tiers-off-note')).toHaveTextContent( + /Privacy tiers are off/i + ); + }); + + it('has no dismiss control, and its one control opens Settings → Privacy', async () => { + const user = userEvent.setup(); + configMocks.enabled = false; + configMocks.record = record({ origin: 'unrecorded' }); + mount(); + + const note = screen.getByTestId('privacy-tiers-off-note'); + const controls = Array.from(note.querySelectorAll('button')); + expect(controls).toHaveLength(1); + expect(controls[0]).not.toHaveAccessibleName(/dismiss|close|hide/i); + + await user.click(controls[0]); + expect(screen.getByTestId('location')).toHaveTextContent('/settings {"section":"privacy"}'); + }); +}); diff --git a/ui/desktop/src/components/privacy/PrivacyTiersOffNote.tsx b/ui/desktop/src/components/privacy/PrivacyTiersOffNote.tsx new file mode 100644 index 000000000..53fc8591f --- /dev/null +++ b/ui/desktop/src/components/privacy/PrivacyTiersOffNote.tsx @@ -0,0 +1,70 @@ +import { useNavigate } from 'react-router-dom'; +import { Button } from '../ui/button'; +import { Note } from '../ui/note'; +import { usePrivacyTiersEnabled, usePrivacyTiersRecord } from '../ConfigContext'; +import { PRIVACY_TIERS_OFF_CONSEQUENCE, privacyTiersOffCopy } from './privacyTiersOffCopy'; +import { RecordedIn } from './RecordedIn'; + +/** + * The standing statement that privacy tiers are OFF, above every composer (H3, + * 2026-09-10 security test drive). + * + * The drive wrote `{"enabled": false}` into the switch's record from a chat's + * `developer__shell`; at the next launch every gate was off and the app's only + * trace was a badge suffix and the strip inside Settings → Privacy. The file + * channel is DR-17's accepted risk and stays open. The silence is what this + * closes: the note says the switch is off, where it is recorded, and — when the + * app recorded no deliberate change — that it was turned off outside the app. + * + * ⚠ **No dismiss control, deliberately.** Anything that could hide it would + * need somewhere to remember the dismissal, and every such place is a file an + * agent with a shell can write; the only way to make this go away is to turn + * the tiers back on, which is the safe direction for anyone to take. The same + * reasoning `PinnedModelNote` records: the condition is standing, so the + * statement of it is too. + * + * ⚠ **Visibility is the switch's alone.** It shows whenever + * {@link usePrivacyTiersEnabled} reads off; the record only chooses the words. + * A daemon that sends no record costs the explanation, never the notice. + * + * On the composer's own rails, in the slot `PinnedModelNote` uses — NOT above + * the chat header, whose 44px band must stay level with the sidebar's and the + * artifact strip's (`--chrome-height`), and not as a new app-wide strip, which + * the `h-screen` routes inside the shell would overflow. + */ +export function PrivacyTiersOffNote({ + className, +}: { + /** Layout only — `mx-*`, `mb-*`. */ + className?: string; +}) { + const enabled = usePrivacyTiersEnabled(); + const record = usePrivacyTiersRecord(); + const navigate = useNavigate(); + if (enabled) return null; + + const copy = privacyTiersOffCopy(record); + return ( + navigate('/settings', { state: { section: 'privacy' } })} + > + Privacy settings + + } + > + {copy.headline} {copy.how && <>{copy.how} } + {PRIVACY_TIERS_OFF_CONSEQUENCE} + + + ); +} diff --git a/ui/desktop/src/components/privacy/RecordedIn.tsx b/ui/desktop/src/components/privacy/RecordedIn.tsx new file mode 100644 index 000000000..e815cb139 --- /dev/null +++ b/ui/desktop/src/components/privacy/RecordedIn.tsx @@ -0,0 +1,15 @@ +/** + * " Recorded in ." — the one place a notice names the record, shared + * with Settings → Privacy's strip. The path is where to look, so it is set in + * the mono face paths take everywhere in Settings, and allowed to break + * anywhere: a home directory is long enough to overrun a narrow composer. + */ +export function RecordedIn({ path }: { path: string | null }) { + if (!path) return null; + return ( + <> + {' '} + Recorded in {path}. + + ); +} diff --git a/ui/desktop/src/components/privacy/privacyTiersOffCopy.ts b/ui/desktop/src/components/privacy/privacyTiersOffCopy.ts new file mode 100644 index 000000000..8e8a051ea --- /dev/null +++ b/ui/desktop/src/components/privacy/privacyTiersOffCopy.ts @@ -0,0 +1,93 @@ +import type { PrivacyTiersRecord } from '../settings/privacy/privacyTiers'; + +/** + * What the app says while privacy tiers are off, and how the switch got there + * (H3, 2026-09-10 security test drive). + * + * ⚠ **One definition, two surfaces** — the note above the composer and the + * strip in Settings → Privacy. The note's one control opens that strip, so a + * second hand-written account there would be the first thing to contradict + * the sentence that sent the user to it. Each surface keeps its own statement + * of the consequence; the headline, the HOW and the path are shared. + * + * ⚠ **What the origin can and cannot vouch for.** The record is an ordinary + * file DR-17 leaves writable by anything holding `developer__shell`, so the + * stamp that makes an origin `settings` can be forged by something that knows + * its shape. What it reliably catches is the write the drive measured — an + * overwrite with `{"enabled": false}` — and a one-field flip of a stamped + * record, because the stamp names the value it wrote. So the copy never says + * "you turned this off": it says what the record says, and the unrecorded case + * is the one that gets the stronger tone. + */ +export type PrivacyTiersOffCopy = { + /** `danger` only when no door the app records wrote the OFF. */ + tone: 'warning' | 'danger'; + headline: string; + /** How the switch got to off, or `null` when the daemon did not say. */ + how: string | null; + /** The record's path, or `null` when the daemon sent no report. */ + path: string | null; +}; + +/** The composer note's statement of the consequence. */ +export const PRIVACY_TIERS_OFF_CONSEQUENCE = + 'Nothing is separating private chats, extensions or knowledge bases from public models.'; + +/** The OFF headline in every case but one: the state first, the explanation after. */ +const OFF = 'Privacy tiers are off.'; + +function formatWhen(at: string | undefined): string | null { + if (!at) return null; + const date = new Date(at); + if (Number.isNaN(date.getTime())) return null; + return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +} + +const on = (when: string | null) => (when ? ` on ${when}` : ''); + +export function privacyTiersOffCopy(record: PrivacyTiersRecord | null): PrivacyTiersOffCopy { + // No report, or one describing an ON switch while the switch itself reads + // off: both come from one config snapshot, so this is a daemon that serves + // the switch but not the record. Say what is known and nothing more. + if (!record || record.enabled) { + return { tone: 'warning', headline: OFF, how: null, path: null }; + } + + const change = record.lastChange; + const when = formatWhen(change?.at); + + switch (record.origin) { + case 'settings': + return { + tone: 'warning', + headline: OFF, + how: + `They were turned off in Settings → Privacy${on(when)}` + + `${change?.systemAuthenticated ? ', and your operating system confirmed it' : ''}.`, + path: record.path, + }; + case 'migration': + return { + tone: 'warning', + headline: OFF, + how: `They were carried over as off from an older version’s config.yaml${on(when)}.`, + path: record.path, + }; + case 'unrecorded': + return { + tone: 'danger', + headline: 'Privacy tiers are off, and they were turned off outside the app.', + how: change?.setTo + ? `The last change recorded in the app turned them on${on(when)}, so their record ` + + 'has been edited since.' + : 'No change in Settings → Privacy is recorded for them: their record was edited ' + + 'directly — by hand, by a script or by an agent with a shell — or written by an ' + + 'older version of Biorouter.', + path: record.path, + }; + default: + // `default` is the fail-safe ON and cannot describe an OFF switch; if the + // two ever disagree, explain nothing rather than guess. + return { tone: 'warning', headline: OFF, how: null, path: record.path }; + } +} diff --git a/ui/desktop/src/components/settings/privacy/PrivacyPanel.test.tsx b/ui/desktop/src/components/settings/privacy/PrivacyPanel.test.tsx index b6055f80b..301219ca1 100644 --- a/ui/desktop/src/components/settings/privacy/PrivacyPanel.test.tsx +++ b/ui/desktop/src/components/settings/privacy/PrivacyPanel.test.tsx @@ -2,6 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import PrivacyPanel, { DISABLE_PHRASE, PRIVACY_TIERS_KEY } from './PrivacyPanel'; +import { PRIVACY_TIERS_RECORD_KEY } from './privacyTiers'; import { __resetDisclosureStoreForTests } from '../../privacy/disclosureCopy'; const mocks = vi.hoisted(() => ({ @@ -254,4 +255,65 @@ describe('Settings > Privacy', () => { expect(screen.queryByTestId('non-private-model-statement')).toBeNull(); }); }); + + /** + * H3 (2026-09-10 security test drive). The composer's off-state note sends + * the user here, so this strip carries the same account of how the switch + * got to off and where it is recorded — and re-reads it after the user's own + * flip, so it never quotes a stale "outside the app" beside a change they + * just made. + */ + describe('where the switch is recorded, and how it got to off', () => { + const PATH = '/Users/someone/.config/biorouter/privacy-tiers.json'; + let record: unknown = null; + + beforeEach(() => { + record = null; + // Key-aware: the switch and its record are two keys on one surface. + mocks.read.mockImplementation(async (key: string) => + key === PRIVACY_TIERS_RECORD_KEY ? record : mocks.value + ); + }); + + it('says the switch was turned off outside the app, and where it is recorded', async () => { + mocks.value = 'off'; + record = { enabled: false, origin: 'unrecorded', path: PATH, last_change: null }; + render(); + + const strip = await screen.findByTestId('privacy-enforcement-off-strip'); + await waitFor(() => expect(strip).toHaveTextContent(/turned off outside the app/i)); + expect(strip).toHaveTextContent(PATH); + }); + + it('re-reads the record after the user turns the tiers off here', async () => { + const user = userEvent.setup(); + mocks.upsert.mockImplementation(async (_key: string, value: unknown) => { + mocks.value = value; + // What the daemon's confirmed arm now remembers beside the value. + record = { + enabled: false, + origin: 'settings', + path: PATH, + last_change: { + via: 'settings', + set_to: false, + at: '2026-09-10T18:04:00+00:00', + system_authenticated: true, + user_action: true, + }, + }; + }); + render(); + await waitFor(() => screen.getByRole('switch', { name: /Privacy tiers/ })); + + await user.click(screen.getByRole('switch', { name: /Privacy tiers/ })); + await user.type(screen.getByLabelText('Confirmation phrase'), DISABLE_PHRASE); + await user.click(screen.getByRole('button', { name: /Turn off privacy tiers/ })); + + const strip = await screen.findByTestId('privacy-enforcement-off-strip'); + await waitFor(() => expect(strip).toHaveTextContent(/turned off in Settings → Privacy/i)); + expect(strip).not.toHaveTextContent(/outside the app/i); + expect(strip).toHaveTextContent(PATH); + }); + }); }); diff --git a/ui/desktop/src/components/settings/privacy/PrivacyPanel.tsx b/ui/desktop/src/components/settings/privacy/PrivacyPanel.tsx index 8b13b2ddc..31203db76 100644 --- a/ui/desktop/src/components/settings/privacy/PrivacyPanel.tsx +++ b/ui/desktop/src/components/settings/privacy/PrivacyPanel.tsx @@ -7,7 +7,16 @@ import { Skeleton } from '../../ui/skeleton'; import { useConfig } from '../../ConfigContext'; import { disclosureTitle, useDisclosure } from '../../privacy/disclosureCopy'; import { DisclosureProse } from '../../privacy/DisclosureProse'; -import { DISABLE_PHRASE, PRIVACY_TIERS_KEY, privacyTiersEnabledFromConfig } from './privacyTiers'; +import { privacyTiersOffCopy } from '../../privacy/privacyTiersOffCopy'; +import { RecordedIn } from '../../privacy/RecordedIn'; +import { + DISABLE_PHRASE, + PRIVACY_TIERS_KEY, + PRIVACY_TIERS_RECORD_KEY, + privacyTiersEnabledFromConfig, + privacyTiersRecordFromConfig, + type PrivacyTiersRecord, +} from './privacyTiers'; // Re-exported so the panel stays the name every existing importer already // reaches for; the definitions live in `privacyTiers.ts` because @@ -39,11 +48,23 @@ export default function PrivacyPanel() { // configuration where the exposure is largest. const { copy: disclosure } = useDisclosure(); const [enabled, setEnabled] = useState(null); + const [record, setRecord] = useState(null); const [confirming, setConfirming] = useState(false); const [typed, setTyped] = useState(''); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + // H3. How the switch got its value and where it is recorded — the + // explanation, never the state: a failed read leaves `null`, which drops the + // "how" from the strip and never the strip itself. + const readRecord = useCallback(async () => { + try { + setRecord(privacyTiersRecordFromConfig(await read(PRIVACY_TIERS_RECORD_KEY, false))); + } catch { + setRecord(null); + } + }, [read]); + const refresh = useCallback(async () => { try { setEnabled(privacyTiersEnabledFromConfig(await read(PRIVACY_TIERS_KEY, false))); @@ -52,7 +73,8 @@ export default function PrivacyPanel() { // the failure of a read must not be a way to *display* the feature as off. setEnabled(true); } - }, [read]); + await readRecord(); + }, [read, readRecord]); useEffect(() => { void refresh(); @@ -81,6 +103,9 @@ export default function PrivacyPanel() { // refusal instead of being displayed as a success. const applied = privacyTiersEnabledFromConfig(await read(PRIVACY_TIERS_KEY, false)); setEnabled(applied); + // The write moved the record too: a strip still quoting the previous + // launch's "outside the app" beside the user's own flip would be false. + await readRecord(); if (applied !== on) { setError( 'Biorouter did not apply that change. Privacy tiers are still ' + @@ -101,7 +126,7 @@ export default function PrivacyPanel() { setBusy(false); } }, - [upsert, read, refresh] + [upsert, read, readRecord, refresh] ); if (enabled === null) { @@ -115,6 +140,8 @@ export default function PrivacyPanel() { ); } + const offCopy = privacyTiersOffCopy(record); + return ( // `data-privacy-panel` marks this section's root so SettingsView's suite // can assert WHERE it sits among its siblings, not merely that it mounted. @@ -130,11 +157,21 @@ export default function PrivacyPanel() {
{!enabled && ( - - Privacy tiers are off. Nothing on this machine is separating private - chats, extensions or knowledge bases from public models, and Biorouter is not recording - which chats touch private material. Every badge in the app reads{' '} - enforcement off while this is the case. + // H3: the headline, the HOW and the path are the composer note's own + // (`privacyTiersOffCopy`) — its one control lands here, and the two + // must not tell the user different stories. The consequence stays + // this panel's, which says more than the composer has room for. + + {offCopy.headline} {offCopy.how && <>{offCopy.how} } + Nothing on this machine is separating private chats, extensions or knowledge bases from + public models, and Biorouter is not recording which chats touch private material. Every + badge in the app reads enforcement off while this is the case. + )} diff --git a/ui/desktop/src/components/settings/privacy/privacyTiers.ts b/ui/desktop/src/components/settings/privacy/privacyTiers.ts index 3fc0f84a8..8a88b7fd8 100644 --- a/ui/desktop/src/components/settings/privacy/privacyTiers.ts +++ b/ui/desktop/src/components/settings/privacy/privacyTiers.ts @@ -1,9 +1,10 @@ /** * The master privacy switch, as the renderer sees it (issue #56, DR-15). * - * ⚠ **Its own module, and not `PrivacyPanel.tsx`.** These three values are read - * by `ConfigContext`, which every surface in the app mounts, and by - * `PrivacyBadge`, which is a leaf `ui/` component. Leaving them in the panel + * ⚠ **Its own module, and not `PrivacyPanel.tsx`.** These values are read by + * `ConfigContext`, which every surface in the app mounts, by `PrivacyBadge`, + * which is a leaf `ui/` component, and by the composer's off-state note + * (`privacy/PrivacyTiersOffNote.tsx`). Leaving them in the panel * would pull the whole Settings → Privacy screen — switch, input, buttons — into * every session row and every model chip, and would make `ConfigContext` import * a settings screen that imports `ConfigContext`. @@ -39,3 +40,88 @@ export function privacyTiersEnabledFromConfig(value: unknown): boolean { const v = value.trim().toLowerCase(); return !(v === 'off' || v === 'false' || v === 'no'); } + +/** + * The daemon's report on the switch's RECORD, served beside the switch on the + * same two config read paths (H3, 2026-09-10 security test drive): where the + * record lives, and which door last wrote it. Mirrors + * `biorouter::privacy::PRIVACY_TIERS_RECORD_KEY`. + * + * ⚠ **A report, never a setting.** The daemon composes it from what it loaded + * and what its one confirmed write recorded, and both read paths overwrite any + * copy of this key that `config.yaml` happens to hold — so nothing written + * through `/config/upsert` reaches a reader, and nothing should try. + */ +export const PRIVACY_TIERS_RECORD_KEY = 'BIOROUTER_PRIVACY_TIERS_RECORD'; + +/** + * Which door the record's value came through. + * + * - `settings` — Settings → Privacy's typed confirmation, unchanged since. + * - `migration` — carried across from an older `config.yaml`, unchanged since. + * - `unrecorded` — no door the app records wrote this value: the record was + * edited directly, or written by a Biorouter too old to stamp it. + * - `default` — no readable record, so the fail-safe ON. + */ +export type PrivacyTiersOrigin = 'default' | 'settings' | 'migration' | 'unrecorded'; + +/** The last change a door recorded, as the record carries it. */ +export type PrivacyTiersChange = { + via: 'settings' | 'migration'; + /** The value that door wrote — which can differ from the record's now. */ + setTo: boolean; + /** RFC 3339, UTC. */ + at: string; + /** DR-20: the operating system confirmed the person at the keyboard. */ + systemAuthenticated: boolean; + /** DR-16: the request carried the app's own proof of a user. */ + userAction: boolean; +}; + +export type PrivacyTiersRecord = { + enabled: boolean; + origin: PrivacyTiersOrigin; + /** The record's absolute path, so a notice can say where to look. */ + path: string; + lastChange: PrivacyTiersChange | null; +}; + +const ORIGINS: readonly PrivacyTiersOrigin[] = ['default', 'settings', 'migration', 'unrecorded']; + +function isRecordObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function changeFromWire(value: unknown): PrivacyTiersChange | null { + if (!isRecordObject(value)) return null; + const { via, set_to, at, system_authenticated, user_action } = value; + if ((via !== 'settings' && via !== 'migration') || typeof set_to !== 'boolean') return null; + return { + via, + setTo: set_to, + at: typeof at === 'string' ? at : '', + systemAuthenticated: system_authenticated === true, + userAction: user_action === true, + }; +} + +/** + * The report, or `null` when the daemon sent none — an older daemon behind the + * external-backend setup, or a process that never loaded the switch. + * + * ⚠ **`null` must never hide the off-state.** Callers decide whether the tiers + * are off from {@link privacyTiersEnabledFromConfig}; this only says HOW, and a + * missing or malformed report degrades the explanation, not the notice. + */ +export function privacyTiersRecordFromConfig(value: unknown): PrivacyTiersRecord | null { + if (!isRecordObject(value)) return null; + const { enabled, origin, path, last_change } = value; + if (typeof enabled !== 'boolean' || typeof path !== 'string') return null; + if (!ORIGINS.includes(origin as PrivacyTiersOrigin)) return null; + return { + enabled, + origin: origin as PrivacyTiersOrigin, + path, + lastChange: changeFromWire(last_change), + }; +} From 2faafe3f550fc48244c3ce7ddcbfbed9fb5cef2a Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 02:05:26 -0700 Subject: [PATCH 3/5] docs(privacy): describe the master switch's OFF signal and its limits (H3) privacy-tiers.md: the DR-17 residual in 10.6 now describes the load WARN, the per-door stamp, the BIOROUTER_PRIVACY_TIERS_RECORD report and the composer note - and what none of it claims: the stamp is forgeable, 0600 does nothing about an agent running as the owner, and no ownership or integrity check at load can tell the agent from the user. Shipped bullet, the 9.5.2 row and 16 item 8 point there. privacy-tiers-migration.md: the user-facing note, and why a switch turned off in Settings by an older version reads as unrecorded until re-toggled. CLAUDE.md: one bullet in the privacy section. --- CLAUDE.md | 6 ++- docs/security/privacy-tiers-migration.md | 12 ++++++ docs/security/privacy-tiers.md | 53 ++++++++++++++++++++++-- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 17e8c1799..a8b69ad25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -279,7 +279,11 @@ what did not" section first**; the rest of that document is the design, not the institution's private connector is warned/refused even though both endpoints are Private. - **The master switch** lives in its own record beside `config.yaml`, **not in it** and **not in an env var** — the agent has `developer__shell`, so a switch it can edit is not a switch. Loaded once - per process; a load error resolves to ON. + per process; a load error resolves to ON. ⚠ The record is still agent-writable (DR-17), so an OFF + answer is **announced, never prevented**: one `WARN` at load, `BIOROUTER_PRIVACY_TIERS_RECORD` on + `/config` (served from memory), and `PrivacyTiersOffNote` above every composer. Each door stamps + the record with the value it wrote; OFF with no matching stamp reads *turned off outside the app*. + The stamp is forgeable — a signal, not a barrier; see `privacy/master_switch.rs` and §10.6. - **Lineage is NOT a boundary; the tier is the only one.** `may_write` ⇔ `may_read` ⇔ `VIS`, so an agent may inject a prompt into any conversation it can see — a child, a sibling, an unrelated chat. R6's old "steer what you spawned, read everything else" rule is retired and diff --git a/docs/security/privacy-tiers-migration.md b/docs/security/privacy-tiers-migration.md index 2783586f7..e8ed22018 100644 --- a/docs/security/privacy-tiers-migration.md +++ b/docs/security/privacy-tiers-migration.md @@ -124,6 +124,18 @@ says. Change the switch in **Settings → Privacy**, which is the only thing tha If you want to check the state from outside the app, read `privacy-tiers.json` — `{"enabled": false}` means enforcement is off. +**While privacy tiers are off, the app says so.** A note stands above every chat's composer, and +Settings → Privacy repeats it, with where the switch is recorded and how it got to off: turned off +in Settings → Privacy, carried over from your old `config.yaml`, or — when the app recorded no such +change — *turned off outside the app*, meaning `privacy-tiers.json` was edited directly. The note +has no dismiss button; turning privacy tiers back on is what clears it. The daemon also logs one +warning at every start-up while they are off. + +If you turned privacy tiers off in Settings → Privacy with a version from before that note existed, +your record carries no trace of which door wrote it, so the note reads *turned off outside the app* +and says an older version could be the reason. To have it describe your change accurately, turn the +tiers on and then off again in Settings → Privacy. + ## Rolling back Downgrading leaves the columns in place and ignored, and the ledger inert. Nothing is moved, diff --git a/docs/security/privacy-tiers.md b/docs/security/privacy-tiers.md index 8153afa87..af32a1697 100644 --- a/docs/security/privacy-tiers.md +++ b/docs/security/privacy-tiers.md @@ -86,7 +86,10 @@ this section is the ledger. `docs/releases/v1.89.0-verification-log.md` § F-13. - **The master switch** (R7 / DR-15 / DR-22) in Settings → Privacy: one control that disables every gate and the ratchet, behind a typed confirmation, stored in its own record beside `config.yaml` - rather than in it — because a switch an agent can edit with `text_editor` is not a switch. + rather than in it — because a switch an agent can edit with `text_editor` is not a switch. The + record itself stays agent-writable (DR-17), so an OFF answer is **announced** rather than merely + obeyed: one `WARN` at load, a standing note above every composer, and *turned off outside the app* + when no door the app records wrote it — a signal, not a barrier (§10.6). - **The badges** (§14) on every session, model and extension surface, and the **registry and marketplace tiers** (§13). - **The migration and the day-one notice** (§15) — see @@ -1589,7 +1592,7 @@ working on Windows. | `/knowledge` | `knowledge::paths::knowledge_root()` | the tree the KB barrier gates | | `/memory` | `memory::global_memory_dir()` | the global store §9.3 B3 is about | | `/agent_drafter` | `agent_drafter::default_root()` | app source, `.vault/`, **and app ids** | -| `/privacy-tiers.json` | `privacy::master_switch::path_for` | **the master switch itself.** §10.6's toggle is loaded from this record at startup, and it is an ordinary non-`SecretGuard` file: a public model can edit it and the next restart has privacy tiers off | +| `/privacy-tiers.json` | `privacy::master_switch::path_for` | **the master switch itself.** §10.6's toggle is loaded from this record at startup, and it is an ordinary non-`SecretGuard` file: a public model can edit it and the next restart has privacy tiers off — *announced* since H3 (a load `WARN` and a standing note in the app, §10.6), never prevented | | `/config.yaml` | `Paths::config_dir()` + `CONFIG_YAML_NAME` | **hooks.** A planted `PermissionRequest` hook loads from here (§17 Q2) and is the one control that can bypass the spawn-downgrade approval. Five tools can write it — `text_editor`, `shell`, `cache` **delete**, `kb_export`'s `dest_path` and `export_app`'s `target_dir` — so protecting one of them protects nothing, and the same five reach every other entry in this table | ⚠ **The switch's row moved, and an implementer working from this table must not harden the wrong @@ -1988,6 +1991,47 @@ acknowledgement writable. What the move buys is that the documented key is inert has one writer; closing the file channel outright needs the barrier DR-17 deferred or an OS-authenticated store, and neither is in v1. +**What it does instead: an OFF answer is never silent** (H3 of the 2026-09-10 security test drive). +The drive overwrote the record with `{"enabled":false}` from a chat's `developer__shell`; at the +next launch every gate and the ratchet were off and nothing said so — no log line, no banner, only +a badge suffix and the strip inside Settings → Privacy. The file channel is still the accepted +risk above; the silence is what changed. + +- **The load announces it.** `master_switch::load` logs one `WARN` per process whenever the record + resolves to OFF, naming the file and the fact. ON is the default and is not announced. +- **Each door stamps what it writes.** The record carries `changed_by: {via, set_to, + system_authenticated, user_action}` — `via` is `settings` (the typed-confirmation arm of + `/config/upsert`) or `migration` (DR-22's one-time carry). A record whose `enabled` no stamp + vouches for — no stamp at all, or one whose `set_to` disagrees — is **unrecorded**, and the WARN + and the app both say *turned off outside the app*. The stamp names its value and not only its door + because the edit an agent is likeliest to make is a one-field flip (`jq '.enabled = false'`) that + keeps every other byte; a door-only stamp would vouch for a value it never wrote. A stamp that + does not parse is read as no stamp and never makes the record unreadable, for `changed_at`'s + reason: a stamp must not be able to turn a user's `off` into the fail-safe `on`. +- **The report travels with the value.** The loader and the confirmed write — the atomic's two + writers — each remember a report beside their write, served on `/config` and `/config/read` as + `BIOROUTER_PRIVACY_TIERS_RECORD`: `enabled`, `origin` (`default` / `settings` / `migration` / + `unrecorded`), the record's `path` and the `last_change`. It is served from memory, never a + second read of the file, and both read paths overwrite any copy `config.yaml` holds under that + name, so a pre-filled report written through `/config/upsert` reaches no reader. +- **The app stands a note above every composer** while the tiers are off + (`PrivacyTiersOffNote`), and Settings → Privacy's strip carries the same account. It has no + dismiss control — anything that remembered a dismissal would be a file an agent could write — so + the only way to clear it is to turn the tiers back on. Its visibility is the switch's alone; a + daemon that sends no report costs the explanation, never the note. +- **The record is created owner-only (`0600`)** by both doors. + +⚠ **None of this is a barrier, and the stamp is not a proof.** The switch still obeys the file, +exactly as DR-17 accepted. Anything that can write the record can write a plausible stamp into it; +what the stamp reliably catches is the overwrite the drive measured and a one-field flip, not a +forger who has read this section. `0600` keeps *other* local accounts out and does nothing about +the agent, whose shell runs as the user and therefore owns the file — so no permission, ownership +or integrity check at load can tell the agent from the user, and a check keyed to a secret on this +machine would be exactly as strong as the agent's inability to read that secret, which DR-17 does +not give. A record written by a Biorouter older than the stamp — including a deliberate `off` from +Settings → Privacy — also reads as unrecorded, and the copy says so rather than accusing anyone; +turning the tiers on and off again in Settings → Privacy stamps it. + Because it is now one predicate read by every gate, the test that matters is a **matrix**: each gate asserted in both toggle positions. A master toggle wired to three gates out of ten passes every textual check and is the failure this design is most likely to ship. @@ -2876,7 +2920,10 @@ Where this annoys someone who has done nothing wrong: §9.5.** A public chat reads `config.yaml` like any other file, so "why isn't my extension loading" debugging stays where it is. The corollary is the one §9.5.2 warned about and this design now accepts: **a master switch a public model can read and edit is not a switch**, so the toggle's - integrity rests on nothing but the file's own permissions. + integrity rests on nothing but the file's own permissions — owner-only (`0600`) since H3 of the + 2026-09-10 test drive, which keeps other accounts out and is no obstacle to an agent running as + the owner. What H3 added is that the edit cannot be *silent*: an OFF record is announced at load + and in the app, and one no door stamped reads *turned off outside the app* (§10.6). 9. ~~**On Windows and on Linux without bubblewrap, a public chat loses the five tools that spawn a child process.**~~ **Not paid — [DR-17](privacy-tiers-execution-plan.md#scope-ruling--dr-17-narrows-this-plan-to-the-session-store) descopes §9.5.** `developer__shell` and its four siblings keep working for a public-capability chat on every platform. This was the single largest From 91aa8e82694d1867059cfdd916ed55d395291a64 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 02:09:02 -0700 Subject: [PATCH 4/5] fix(desktop): show the privacy-tiers-off note on Home's composer too (H3) Home (Hub) renders its own ChatInput rather than BaseChat, so the note was absent from the route the app LAUNCHES on - and a switch turned off outside the app takes effect at a launch. Mounted there on the same mx-3 rails, and pinned for both surfaces by a source guard (neither mounts cheaply in jsdom); the guard fails against the previous commit's Hub.tsx. --- ui/desktop/src/components/Hub.tsx | 7 +++++++ .../privacy/PrivacyTiersOffNote.test.tsx | 17 +++++++++++++++++ .../components/privacy/PrivacyTiersOffNote.tsx | 11 +++++++---- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx index 8b4228478..b601916f5 100644 --- a/ui/desktop/src/components/Hub.tsx +++ b/ui/desktop/src/components/Hub.tsx @@ -28,6 +28,7 @@ import { import { getInitialWorkingDir } from '../utils/workingDir'; import { createSession } from '../sessions'; import LoadingBioRouter from './LoadingBioRouter'; +import { PrivacyTiersOffNote } from './privacy/PrivacyTiersOffNote'; import type { UserAttachment } from '../types/message'; export default function Hub({ @@ -81,6 +82,12 @@ export default function Hub({
+ {/* H3 — the same standing off-state note every chat's composer + carries, on the same `mx-3` rails. Home is the route the app + LAUNCHES on, and a switch turned off outside the app takes effect + at a launch, so a note that only chats carried would first be + seen after the user had already started one. */} + {isCreatingSession && (
diff --git a/ui/desktop/src/components/privacy/PrivacyTiersOffNote.test.tsx b/ui/desktop/src/components/privacy/PrivacyTiersOffNote.test.tsx index 51db9427f..10bceaa74 100644 --- a/ui/desktop/src/components/privacy/PrivacyTiersOffNote.test.tsx +++ b/ui/desktop/src/components/privacy/PrivacyTiersOffNote.test.tsx @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; import { cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; @@ -156,4 +158,19 @@ describe('PrivacyTiersOffNote', () => { await user.click(controls[0]); expect(screen.getByTestId('location')).toHaveTextContent('/settings {"section":"privacy"}'); }); + + /** + * A note nobody mounts is the same silence the drive measured. Asserted at the + * source, as `PrivacyBadge.test.tsx` does for its own call sites, because + * neither surface mounts cheaply in jsdom. Home is the load-bearing one: it + * is the route the app LAUNCHES on, and a switch turned off outside the app + * takes effect at a launch. + */ + it('is mounted above both composers the app has: every chat, and Home', () => { + // vitest runs with `ui/desktop` as its root. + const source = (file: string) => readFileSync(path.join(process.cwd(), file), 'utf8'); + for (const file of ['src/components/BaseChat.tsx', 'src/components/Hub.tsx']) { + expect(source(file), `${file} does not mount the note`).toMatch(/ Date: Fri, 11 Sep 2026 02:09:20 -0700 Subject: [PATCH 5/5] docs(privacy): the off-state note also stands above Home's composer --- docs/security/privacy-tiers-migration.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/security/privacy-tiers-migration.md b/docs/security/privacy-tiers-migration.md index e8ed22018..d5f60651b 100644 --- a/docs/security/privacy-tiers-migration.md +++ b/docs/security/privacy-tiers-migration.md @@ -124,12 +124,13 @@ says. Change the switch in **Settings → Privacy**, which is the only thing tha If you want to check the state from outside the app, read `privacy-tiers.json` — `{"enabled": false}` means enforcement is off. -**While privacy tiers are off, the app says so.** A note stands above every chat's composer, and -Settings → Privacy repeats it, with where the switch is recorded and how it got to off: turned off -in Settings → Privacy, carried over from your old `config.yaml`, or — when the app recorded no such -change — *turned off outside the app*, meaning `privacy-tiers.json` was edited directly. The note -has no dismiss button; turning privacy tiers back on is what clears it. The daemon also logs one -warning at every start-up while they are off. +**While privacy tiers are off, the app says so.** A note stands above every composer — Home's +and every chat's — and Settings → Privacy repeats it, with where the switch is recorded and how +it got to off: turned off in Settings → Privacy, carried over from your old `config.yaml`, or — +when the app recorded no such change — *turned off outside the app*, meaning +`privacy-tiers.json` was edited directly. The note has no dismiss button; turning privacy tiers +back on is what clears it. The daemon also logs one warning at every start-up while they are +off. If you turned privacy tiers off in Settings → Privacy with a version from before that note existed, your record carries no trace of which door wrote it, so the note reads *turned off outside the app*