Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 51 additions & 2 deletions crates/biorouter-server/src/routes/config_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -804,6 +823,12 @@ pub async fn read_all_config() -> Result<Json<ConfigResponse>, 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.
Expand Down Expand Up @@ -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.
///
Expand Down
208 changes: 208 additions & 0 deletions crates/biorouter-server/tests/privacy_toggle_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
Loading
Loading