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/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" diff --git a/docs/security/privacy-tiers-migration.md b/docs/security/privacy-tiers-migration.md index 2783586f7..d5f60651b 100644 --- a/docs/security/privacy-tiers-migration.md +++ b/docs/security/privacy-tiers-migration.md @@ -124,6 +124,19 @@ 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 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* +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 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. + */} +
+ {/* 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 new file mode 100644 index 000000000..10bceaa74 --- /dev/null +++ b/ui/desktop/src/components/privacy/PrivacyTiersOffNote.test.tsx @@ -0,0 +1,176 @@ +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'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PrivacyTiersOffNote } from './PrivacyTiersOffNote'; +import type { PrivacyTiersRecord } from '../settings/privacy/privacyTiers'; + +/** + * H3 (2026-09-10 security test drive). The switch's record is agent-writable — + * DR-17's accepted risk — and a `{"enabled": false}` written from a chat's shell + * turned every gate off at the next launch with nothing in the app saying so. + * This note is the in-app half of the fix: it stands above the composer for as + * long as the tiers are off, and says where the switch was recorded and whether + * the app recorded turning it off. + * + * The two hooks are the seam, as in `PrivacyBadge.test.tsx`: mounting a real + * `ConfigProvider` would drag in the daemon client, the provider list and the + * extension sync. + */ +const configMocks = vi.hoisted(() => ({ + 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"}'); + }); + + /** + * 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(/ 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), + }; +}