Skip to content

Privacy: announce an OFF master switch and say whether the app turned it off (H3) - #227

Open
Broccolito wants to merge 5 commits into
mainfrom
fix/privacy-switch-off-signal
Open

Privacy: announce an OFF master switch and say whether the app turned it off (H3)#227
Broccolito wants to merge 5 commits into
mainfrom
fix/privacy-switch-off-signal

Conversation

@Broccolito

Copy link
Copy Markdown
Collaborator

Addresses H3 of the 2026-09-10 security test drive of merged main at 7c96d796 (~/biorouter-runs/test-drive/qa-c/report.md).

What was measured

From a chat's developer__shell, overwriting <config>/privacy-tiers.json with {"enabled":false} turned off every privacy gate and the classification ratchet machine-wide at the next launch, and nothing said so: no log line at load, no banner in the app. The only traces were the "enforcement off" badge suffix and the strip inside Settings → Privacy. That the file can be written at all is DR-17's documented, accepted risk (master_switch.rs, DR-22). What was missing was any signal.

What this changes (bounded; the switch is not redesigned)

The switch still obeys the file, exactly as before. None of what follows is a barrier.

  1. The load warns. master_switch::load logs one WARN per process when the record resolves to OFF, naming the file and the fact. ON is not announced. load_privacy_tiers_from_config (both hosts) and resolve_privacy_tiers both go through it.
  2. Each door stamps what it writes. The record gains 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). An OFF record that no stamp vouches for is unrecorded, and the WARN and the app say "turned off outside the app". That covers a record with no stamp and one whose set_to contradicts its value.
    • The stamp records the value it wrote, not only its door. The edit an agent is most likely to make is a one-field flip (jq '.enabled = false'), which keeps every other byte. A stamp that named only its door would then vouch for a value it never wrote.
    • The stamp is parsed leniently. One that doesn't parse reads as no stamp and never makes the record unreadable, so a malformed stamp can't turn a user's off into the fail-safe on.
  3. The report travels with the value. The atomic's two writers (the loader and the confirmed arm) each remember a report next to their write. It is served on the existing /config and /config/read surface as BIOROUTER_PRIVACY_TIERS_RECORD: enabled, origin, path, last_change. It is served from memory, never by re-reading the file. Both read paths overwrite any copy config.yaml holds under that name, so a report pre-filled through /config/upsert reaches no reader. It was added to the existing surface rather than as a new route, so the renderer reads it from the same ConfigContext snapshot as the switch.
  4. The desktop app shows a standing note above both composers, Home's (Hub) and every chat's (BaseChat, in PinnedModelNote's slot), while tiers are off. It says they are off, where the switch is recorded, and how it got there. One control opens Settings → Privacy. There is no dismiss control. Anything that remembered a dismissal would be a file an agent can write, so the only way to clear the note is to turn tiers back on. The note's visibility comes from the switch alone; a missing report costs the explanation, never the note. Tone is warning for a deliberate off and danger for an unrecorded one. The Settings → Privacy strip uses the same copy (one definition) and re-reads the record after the user's own flip.
    • Home matters most: it's the route the app launches on, and an off switch takes effect at a launch. A source guard pins both mounts.
    • Why not a top-level strip: a strip above the chat header would pull the 44 px band off the edge it shares with the sidebar and artifact strip (--chrome-height). A new app-wide strip in <main> would be overflowed by the h-screen routes inside the shell.
  5. The record is created owner-only (0600) by both doors. The mode is set at creation, and re-applied best-effort for a leftover staging file, so it can never fail a flip on a filesystem without Unix modes. This keeps other local accounts out and does nothing about the agent, which runs as the owner. That limit is documented as a limit, not presented as a fix.

What this does not claim

  • The stamp is forgeable by anything that can write the file. It reliably catches the overwrite the drive measured and a one-field flip. It does not catch a forger who has read the docs.
  • No permission, ownership or integrity check at load can tell the agent from the user: same uid. A check keyed to a secret on this machine would only be as strong as the agent's inability to read that secret, which DR-17 doesn't provide.
  • A record written by a Biorouter older than the stamp also reads as unrecorded, including a deliberate off made in Settings. The copy says an older version could be the reason. Turning tiers on and off again in Settings → Privacy stamps the record, and the user-facing migration doc now says so.

Tests (fail-before shown)

Every new test below was run against the unmodified implementation first.

Test Before After
master_switch::tests::loading_an_off_record_warns_once_naming_the_file_and_the_fact FAIL: one WARN at load … left: 0 right: 1 ok
master_switch::tests::an_off_record_with_no_deliberate_change_recorded_is_flagged_as_outside_the_app FAIL: an OFF record no door recorded writing must be flagged ok
privacy_toggle_config::a_record_turned_off_outside_the_app_is_reported_as_such_through_the_config_surface FAIL: left: Null right: Bool(false) ok
privacy_toggle_config::a_deliberate_change_is_reported_as_deliberate_live_and_after_a_restart FAIL: left: Null right: Bool(false) ok
privacy_toggle_config::an_edit_that_flips_only_the_value_is_not_mistaken_for_a_deliberate_change FAIL: left: Null right: String("unrecorded") ok
PrivacyTiersOffNote.test.tsx (off → note present, names the path, flags "outside the app", names a contradicted last change, no dismiss, opens Settings → Privacy; on → absent) 6 of 7 FAIL against a render-nothing stub (the only pass was "renders nothing while on") 8 ok
PrivacyTiersOffNote.test.tsx: the note is mounted above both composers (source guard) FAILS against the previous commit's Hub.tsx (0 mounts) ok

Also added after the API existed: the report is what the surface serialises; a Settings write is stamped and reads back as deliberate; a one-field flip is flagged at library level; a migrated off is attributed to the migration; a malformed stamp never fails the record; both doors create the file 0600 and tighten a leftover staging file. Two PrivacyPanel tests cover the strip naming the record and re-reading it after a flip.

Gates (final tree, sequential, BIOROUTER_DISABLE_KEYRING=true)

  • cargo test -p biorouter --lib -- privacy::master_switch privacy::: 241 passed
  • cargo test -p biorouter --test privacy_disclosure_toggle --test privacy_toggle: 1 + 4 passed
  • cargo test -p biorouter-server --test privacy_toggle_config: 15 passed
  • cargo test -p biorouter-server --lib: 586 passed. One earlier run, before the last two small edits, had a single failure: routes::session::declassify_tests::a_second_single_click_confirm_is_a_success_not_a_refusal, which answered 500. That test is untouched by this diff and the machine was at load average ~150. The module passed alone (6/6), and the three full runs since have all been 586/586. I'm reporting it, not claiming it away.
  • cargo fmt --all -- --check: clean.
  • ./scripts/clippy-lint.sh: clean (strict -D warnings on --all-targets, the too_many_lines baseline, the banned-TLS check). cargo clippy -p biorouter -p biorouter-server --all-targets -- -D warnings re-run on the final tree: clean.
  • cd ui/desktop && npm run test:run on the final tree: 443/443 files, 4979 passed, 1 skipped. An earlier run under heavy load had one file time out closing a real Chromium in afterAll (artifactCdnAssets.browser.test.ts, untouched here). It passed alone and did not recur on this run.
  • npm run lint:check (tsc, ESLint --max-warnings 0, themes, contrast, tokens): clean on the final tree. Prettier --check on every changed file: clean. Both PrivacyPanel files were Prettier-clean on main, so formatting touched only my hunks.

Runtime (own sandboxed instance)

Branch daemon built with --features biorouter/privacy-test-auth. Sandbox h3-switch-off via ~/biorouter-runs/launch-dev-gui.sh, CDP 9397, config cloned from the seed; the real ~/.config/biorouter was never touched.

  1. Launch 1, seed record ON: no note on Home, 0 OFF warnings in the daemon log.

  2. Stop. Wrote the H3 payload byte for byte: printf '{"enabled":false}' > …/config/privacy-tiers.json. Relaunch: the daemon's first log line is the WARN, exactly once:

    WARN biorouter::privacy::master_switch: … privacy tiers are OFF, and they were turned off outside the app: /Users/wgu/biorouter-runs/h3-switch-off/config/privacy-tiers.json says they are off, but it records no change made in Settings > Privacy. … origin=Unrecorded

    The danger-toned note appeared above Home's composer and inside a chat, with the path. Its control landed on Settings → App → Privacy, where the strip repeats it.

  3. Deliberate path: turned tiers on in Settings, which wrote a stamped ON record (user_action: true, mode -rw-------). Turned them off with the typed phrase; the DR-20 seam approved. The strip and Home's note switched to amber: "turned off in Settings → Privacy on Sep 11, 2026, 2:25 AM, and your operating system confirmed it". Relaunch: the WARN said … records that they were turned off in Settings > Privacy at 2026-09-11T09:25:17…, confirmed by the operating system. origin=Settings, so the stamp survived the restart.

  4. Restored to ON through Settings: {"enabled": true, "changed_by": {"via": "settings", "set_to": true, …}}, -rw-------. Home shows no note.

Screenshots (7), the WARN lines per launch and the fail-before logs are in ~/biorouter-runs/test-drive/qa-c/h3-fix-evidence/. Probes deleted and instance stopped. Two launches left the launcher's known forge-build Electron alive with a daemon on the same sandbox. I killed those by explicit pid after an identity check, and warn-lines.md notes which log file belongs to which process.

Docs

docs/security/privacy-tiers.md: the §10.6 DR-17 residual now describes the signal and its limits, plus the Shipped bullet, the §9.5.2 row and §16 item 8. docs/security/privacy-tiers-migration.md covers the user-facing note and the old-record case. One bullet in CLAUDE.md's privacy section.

Not done here (follow-ups worth a ruling)

  • An earlier signal. An agent can write the record and wait. This change announces the OFF at the next launch, not when the file is written. Warning at write time would need a second reader of the record (a watcher, or reading it on /config), which the design deliberately avoids.
  • The mixing-policy record (BIOROUTER_PRIVACY_MIXING_POLICY, DR-27) has the same file channel and the same silence when set to open. Out of scope for H3.
  • The CLI logs to file only, so a terminal user sees the WARN only in the CLI log.

🤖 Generated with Claude Code

… it (H3)

The 2026-09-10 security test drive overwrote privacy-tiers.json with
{"enabled":false} from a chat's developer__shell; the next launch came up
with every gate and the ratchet off and nothing said so. The file channel is
DR-17's accepted risk and stays open. The silence is what this closes.

- master_switch::load warns once per process when the record resolves OFF,
  naming the file and the fact. The answer is unchanged: the loader still
  obeys the record.
- Both doors stamp what they write: changed_by {via, set_to,
  system_authenticated, user_action}. via is settings (the confirmed
  /config/upsert arm) or migration (DR-22's carry). An OFF record no stamp
  vouches for - none, or one whose set_to contradicts it - is "unrecorded"
  and the WARN says it was turned off outside the app. The stamp names its
  value so a one-field jq flip of a stamped record is still caught. It is
  parsed leniently: a stamp that does not parse can never make the record
  unreadable (which would fail a user's off towards on).
- The loader and the confirmed arm - the atomic's two writers - remember a
  SwitchReport beside their write, served from memory on /config and
  /config/read as BIOROUTER_PRIVACY_TIERS_RECORD. Both read paths overwrite
  any config.yaml copy of that key.
- The record is created 0600 (best-effort re-chmod of a leftover staging
  file); hygiene against other accounts, documented as no control against
  an agent that runs as the owner.

Tests: two lib tests and three privacy_toggle_config tests fail on the
unmodified implementation (0 WARNs; the surface key absent) and pass here.
…rs are off (H3)

PrivacyTiersOffNote sits in PinnedModelNote's slot above the composer and
says the tiers are off, where the switch is recorded, and how it got there:
turned off in Settings > Privacy, carried over from config.yaml, or - when
no door the app records wrote it - turned off outside the app (danger tone).
Its one control opens Settings > Privacy. No dismiss control: anything that
remembered a dismissal would be a file an agent could write, so turning the
tiers back on is the only way to clear it. Visibility is the switch's alone;
a daemon that sends no report costs the explanation, never the note.

The copy has one definition (privacyTiersOffCopy) shared with the Settings >
Privacy strip, which now names the record and re-reads it after the user's
own flip. usePrivacyTiersRecord reads the daemon's report off the same config
snapshot as usePrivacyTiersEnabled.

Not above the chat header (the 44px chrome band must stay level with the
sidebar and artifact strip) and not an app-wide strip (h-screen routes in the
shell would overflow it).

Tests: PrivacyTiersOffNote.test.tsx - off: present, names the path, flags
outside-the-app, names a contradicted last change, no dismiss, opens
Settings; on: absent. 6 of 7 fail against a render-nothing stub.
… (H3)

privacy-tiers.md: the DR-17 residual in 10.6 now describes the load WARN,
the per-door stamp, the BIOROUTER_PRIVACY_TIERS_RECORD report and the
composer note - and what none of it claims: the stamp is forgeable, 0600
does nothing about an agent running as the owner, and no ownership or
integrity check at load can tell the agent from the user. Shipped bullet,
the 9.5.2 row and 16 item 8 point there.

privacy-tiers-migration.md: the user-facing note, and why a switch turned
off in Settings by an older version reads as unrecorded until re-toggled.

CLAUDE.md: one bullet in the privacy section.
…(H3)

Home (Hub) renders its own ChatInput rather than BaseChat, so the note was
absent from the route the app LAUNCHES on - and a switch turned off outside
the app takes effect at a launch. Mounted there on the same mx-3 rails, and
pinned for both surfaces by a source guard (neither mounts cheaply in jsdom);
the guard fails against the previous commit's Hub.tsx.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant