Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0dcc509
fix(privacy): one reach gate for every HTTP route that names a chat o…
Broccolito Sep 11, 2026
936f7f8
fix(desktop): send the user's proof on every call the reach gate now …
Broccolito Sep 11, 2026
5a9f3fb
test(privacy): run the knowledge-base sweep and the serve standing wh…
Broccolito Sep 11, 2026
ccc3e79
test(privacy): the knowledge-base sweep covers the credibility overri…
Broccolito Sep 11, 2026
01115e7
test(privacy): reach the serve standing through the library path the …
Broccolito Sep 11, 2026
4eabd13
feat(active-work): read one registry entry by id
Broccolito Sep 11, 2026
57510c9
fix(developer): a shell command's active-work row names the chat that…
Broccolito Sep 11, 2026
d42ed37
Merge origin/main (6455bc21) into claude/sweet-pare-965d18
Broccolito Sep 11, 2026
6f2a5c5
Merge origin/main (d6b39693) into claude/sweet-pare-965d18
Broccolito Sep 11, 2026
01f4a28
fix(privacy): /active_work lists and stops only the work of chats the…
Broccolito Sep 11, 2026
edbff6a
chore(api): regenerate the OpenAPI spec and client for the /active_wo…
Broccolito Sep 11, 2026
4207c02
test(desktop): a call to the active-work routes must carry the person…
Broccolito Sep 11, 2026
0722c11
docs(privacy): /active_work is gated; record the no-chat decision and…
Broccolito Sep 11, 2026
1a690bf
Merge remote-tracking branch 'origin/main' into claude/sweet-pare-965d18
Broccolito Sep 12, 2026
187cd21
Merge remote-tracking branch 'origin/claude/sweet-pare-965d18' into c…
Broccolito Sep 12, 2026
c4056bd
fix(privacy): stopping a scheduled run is gated wherever it is asked for
Broccolito Sep 12, 2026
93aa328
fix(privacy): inspecting a schedule names its chat only to a caller t…
Broccolito Sep 12, 2026
1a0b4c1
fix(privacy): a schedule listing names only the chats the caller coul…
Broccolito Sep 12, 2026
1e25123
docs(privacy): the schedule residual is closed; regenerate the contract
Broccolito Sep 12, 2026
7bc3abe
Merge remote-tracking branch 'origin/main' into claude/ecstatic-einst…
Broccolito Sep 12, 2026
9071a62
Merge origin/main into claude/ecstatic-einstein-dd05d6
Broccolito Sep 12, 2026
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
22 changes: 21 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,26 @@ what did not" section first**; the rest of that document is the design, not the
- **Knowledge bases ratchet too.** A base takes the tier of the most sensitive session that wrote to
it (four write choke points), is refused to a public caller at the read choke points, and a
refusal names what it refused. `biorouter-mcp/src/knowledge/tier*.rs`.
- **Holding the daemon secret does not make a caller the user.** That was the premise behind
leaving the `/knowledge/*` read routes, `GET /sessions` and `DELETE /sessions/{id}` ungated. A
public chat's shell recovers the secret with `ps eww`, and QA used it to read a private base and
delete a private chat (H2/M1/M2/F0, 2026-09-10). Every HTTP route that names a chat or a
knowledge base now asks `routes::session_reach`'s one decision: a private target needs the
user-action proof or a stated private capability. The rules that follow:
- A route that names one chat calls `session_reach`, and refuses with its exact plain text.
- Listings filter through `HttpCaller::lists_session`.
- Running work is the same rule reached through the row's chat. `GET /active_work` filters
through `HttpCaller::lists_work`, and its cancel asks `work_reach` before anything stops. A
row that names no chat is treated as a private chat's, so a registrant that knows its chat must
set `ActiveWorkItem::session_id`. The shell's rows take it from the `_meta` session id.
- Every `/knowledge/bases/{id}` route sits in `knowledge::router`'s `base_routes`, behind
`gate_knowledge_base`. Put any new `{id}` route there.
- ⚠ **The renderer must send `userActionHeaders()` on every such call.** A missing proof is not an
error: private rows silently vanish, and the Knowledge view's prune effects then read them as
deleted.
- A `biorouter serve` browser gets its operator's tier on listings and knowledge bases only
(SD-10).
- The wiring census (`crates/biorouter/tests/privacy_guard_wiring.rs`) counts every call site.
- **Affiliation is a third axis** (DR-26, plan Phase 6): tier asks *how sensitive*, affiliation asks
*whose*. HIPAA compliance does not transfer between institutions, so a UCSF model reaching another
institution's private connector is warned/refused even though both endpoints are Private.
Expand Down Expand Up @@ -1217,7 +1237,7 @@ Test the gate where it is: the unit tests in `agents/agent.rs`
prints a URL. The daemon serves the SPA **on its own origin**, so nothing is proxied. This
replaced a standalone `biorouter-headless` binary and its Linux tarball, both deleted
2026-08-23; release assets went 11 → 10. Design and reasoning:
[`docs/deployment/serve-decisions.md`](docs/deployment/serve-decisions.md) (SD-1..SD-9, SD-11),
[`docs/deployment/serve-decisions.md`](docs/deployment/serve-decisions.md) (SD-1..SD-11),
[`serve-architecture.md`](docs/deployment/serve-architecture.md),
[`browser-access.md`](docs/deployment/browser-access.md).

Expand Down
74 changes: 62 additions & 12 deletions crates/biorouter-mcp/src/active_work.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ struct Entry {
cancel: Option<CancelFn>,
}

impl Entry {
fn snapshot(&self, id: &str) -> ActiveWorkItem {
ActiveWorkItem {
id: id.to_string(),
kind: self.kind,
title: self.title.clone(),
detail: self.detail.clone(),
session_id: self.session_id.clone(),
started_at_epoch_ms: self.started_at_epoch_ms,
cancellable: self.cancel.is_some(),
}
}
}

/// A closure-free snapshot of one active-work entry, safe to hand to the HTTP
/// layer.
#[derive(Clone, Debug, PartialEq, Eq)]
Expand All @@ -90,6 +104,15 @@ pub struct ActiveWorkItem {
pub kind: ActiveWorkKind,
pub title: String,
pub detail: Option<String>,
/// The chat this work belongs to, when the subsystem that registered it
/// knows.
///
/// ⚠ **Not decoration** (issue #56): `GET /active_work` shows a row only to
/// a caller that could open this chat, and a row with `None` only to a
/// caller that could open a PRIVATE chat, because its title and detail are
/// some chat's command or prompt and nothing says whose. A registrant that
/// knows its chat and leaves this `None` hides its own row from that chat's
/// client.
pub session_id: Option<String>,
pub started_at_epoch_ms: u128,
/// Whether this entry carries a cancel action.
Expand Down Expand Up @@ -146,18 +169,16 @@ impl ActiveWorkRegistry {

/// Snapshot every live entry, sorted by id (creation order within a kind).
pub fn list(&self) -> Vec<ActiveWorkItem> {
self.lock()
.iter()
.map(|(id, e)| ActiveWorkItem {
id: id.clone(),
kind: e.kind,
title: e.title.clone(),
detail: e.detail.clone(),
session_id: e.session_id.clone(),
started_at_epoch_ms: e.started_at_epoch_ms,
cancellable: e.cancel.is_some(),
})
.collect()
self.lock().iter().map(|(id, e)| e.snapshot(id)).collect()
}

/// Snapshot the one live entry `id` names, if it still names one.
///
/// `POST /active_work/{id}/cancel` reads the owning chat off this before it
/// fires anything (issue #56): the id names work, not a chat, and the reach
/// gate is a question about the chat.
pub fn get(&self, id: &str) -> Option<ActiveWorkItem> {
self.lock().get(id).map(|e| e.snapshot(id))
}

/// Fire an entry's cancel action. Returns `false` if no such entry exists.
Expand Down Expand Up @@ -320,6 +341,35 @@ mod tests {
assert!(!reg.cancel("sub-999"), "unknown id should report failure");
}

/// `get` is `list` narrowed to one id — the same snapshot, including the
/// owning chat the cancel route gates on — and `None` once the id names
/// nothing, including after the owner deregistered it.
#[test]
fn get_is_the_listed_snapshot_of_one_entry() {
let reg = fresh();
let owned = reg.register(
ActiveWorkKind::Subagent,
"task",
Some("child session c".to_string()),
Some("s-parent".to_string()),
Some(Arc::new(|| {})),
);
let unowned = reg.register(ActiveWorkKind::ForegroundCommand, "cmd", None, None, None);

for id in [&owned, &unowned] {
let listed = reg.list().into_iter().find(|i| &i.id == id);
assert_eq!(reg.get(id), listed, "{id}");
}
assert_eq!(
reg.get(&owned).and_then(|i| i.session_id).as_deref(),
Some("s-parent")
);
assert_eq!(reg.get(&unowned).map(|i| i.session_id), Some(None));
assert_eq!(reg.get("sub-999"), None);
reg.deregister(&owned);
assert_eq!(reg.get(&owned), None);
}

#[test]
fn cancel_without_closure_is_a_noop_success() {
let reg = fresh();
Expand Down
24 changes: 15 additions & 9 deletions crates/biorouter-mcp/src/developer/background.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,17 @@ impl BackgroundJobs {
/// Spawn `command` as a background job in its own process group, wire up
/// output capture and a supervisor that records the terminal status, and
/// register the job. Returns the new job id.
///
/// `session_id` is the chat that started the job, carried onto its
/// active-work row: the listing shows a row only to a caller that could open
/// its chat, and answers a row that names no chat as a private chat's
/// (issue #56).
pub async fn spawn(
&self,
command: &str,
label: Option<String>,
working_dir: Option<PathBuf>,
session_id: Option<String>,
) -> Result<String, String> {
let id = format!("job-{}", self.next_id.fetch_add(1, Ordering::SeqCst));
let label = label.unwrap_or_else(|| command.chars().take(40).collect());
Expand Down Expand Up @@ -173,7 +179,7 @@ impl BackgroundJobs {
ActiveWorkKind::BackgroundJob,
format!("{id}: {label}"),
Some(command.to_string()),
None,
session_id,
Some(Arc::new(move || {
killed_for_cancel.store(true, Ordering::SeqCst);
kill_process_group(pid_for_cancel, identity_for_cancel.clone());
Expand Down Expand Up @@ -943,7 +949,7 @@ mod tests {
#[tokio::test]
async fn start_lists_and_completes_with_output() {
let jobs = new_jobs();
let id = jobs.spawn("echo hello-bg", None, None).await.unwrap();
let id = jobs.spawn("echo hello-bg", None, None, None).await.unwrap();
assert!(jobs.list().await.contains(&id));
assert_eq!(
wait_terminal(&jobs, &id, JOB_WAIT_MS).await,
Expand All @@ -956,7 +962,7 @@ mod tests {
#[tokio::test]
async fn list_reports_command_status_and_unread_output() {
let jobs = new_jobs();
let id = jobs.spawn("echo listme", None, None).await.unwrap();
let id = jobs.spawn("echo listme", None, None, None).await.unwrap();
assert_eq!(
wait_terminal(&jobs, &id, JOB_WAIT_MS).await,
JobStatus::Exited(0)
Expand Down Expand Up @@ -998,7 +1004,7 @@ mod tests {
#[tokio::test]
async fn nonzero_exit_code_is_surfaced() {
let jobs = new_jobs();
let id = jobs.spawn("exit 3", None, None).await.unwrap();
let id = jobs.spawn("exit 3", None, None, None).await.unwrap();
assert_eq!(
wait_terminal(&jobs, &id, JOB_WAIT_MS).await,
JobStatus::Exited(3)
Expand All @@ -1013,7 +1019,7 @@ mod tests {
} else {
"echo first; sleep 2; echo second"
};
let id = jobs.spawn(command, None, None).await.unwrap();
let id = jobs.spawn(command, None, None, None).await.unwrap();
let first = collect_output_until(&jobs, &id, "first", JOB_WAIT_MS).await;
assert!(first.contains("first"), "first read: {first}");
assert!(!first.contains("second"), "second leaked early: {first}");
Expand All @@ -1029,7 +1035,7 @@ mod tests {
#[tokio::test]
async fn wait_returns_early_on_completion() {
let jobs = new_jobs();
let id = jobs.spawn("echo done", None, None).await.unwrap();
let id = jobs.spawn("echo done", None, None, None).await.unwrap();
let started = Instant::now();
let out = jobs.wait(&id, 30).await.unwrap();
assert!(out.contains("finished"), "wait result: {out}");
Expand All @@ -1039,7 +1045,7 @@ mod tests {
#[tokio::test]
async fn wait_times_out_without_killing_then_kill_works() {
let jobs = new_jobs();
let id = jobs.spawn("sleep 30", None, None).await.unwrap();
let id = jobs.spawn("sleep 30", None, None, None).await.unwrap();
let out = jobs.wait(&id, 1).await.unwrap();
assert!(out.contains("Still running"), "wait result: {out}");
assert_eq!(
Expand Down Expand Up @@ -1215,7 +1221,7 @@ mod tests {
#[tokio::test]
async fn recorded_identity_matches_the_live_child_of_a_real_spawn() {
let jobs = new_jobs();
let id = jobs.spawn("sleep 30", None, None).await.unwrap();
let id = jobs.spawn("sleep 30", None, None, None).await.unwrap();
let job = jobs.job(&id).await.unwrap();
let pid = job.pid.unwrap();

Expand Down Expand Up @@ -1419,7 +1425,7 @@ mod tests {
async fn spawn_records_pidfile_and_terminal_removes_it() {
let dir = ensure_test_run_dir().to_path_buf();
let jobs = new_jobs();
let id = jobs.spawn("sleep 30", None, None).await.unwrap();
let id = jobs.spawn("sleep 30", None, None, None).await.unwrap();
let pid = jobs.job(&id).await.unwrap().pid.unwrap();

let pidfile = dir.join(pidfile_name(std::process::id(), pid));
Expand Down
Loading
Loading