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
16 changes: 15 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1187,7 +1187,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),
[`docs/deployment/serve-decisions.md`](docs/deployment/serve-decisions.md) (SD-1..SD-9, SD-11),
[`serve-architecture.md`](docs/deployment/serve-architecture.md),
[`browser-access.md`](docs/deployment/browser-access.md).

Expand All @@ -1211,6 +1211,20 @@ replaced a standalone `biorouter-headless` binary and its Linux tarball, both de
operator-pinned-off extension and its ordinary path needs none. ⚠ Not a security change:
nothing that was refused becomes permitted. The availability flag is sampled ONCE per roster
and threaded, so a roster can never half-believe a person is reachable.
- **Stop answers to the reach gate on a keyless daemon; steering does not** (SD-11).
`/agent/cancel` and the two `/agent/continuation/*` routes take the proof on a daemon that
holds a key, and on one that holds none gate through `authorize_agent_control` — the *same
call* `/agent/stop` makes — via `reply.rs::authorize_turn_control`. Tightening that gate
tightens who may press Stop in a browser. ⚠ **`/interrupt` is NOT one of them.** It keeps the
proof on both kinds of daemon: the keyless arm's whole argument is that the caller already
reaches the same effect through `/agent/stop` and `/reply`, and `/reply` is refused `409` by
the BR-33 single-turn lock in the exact state where a steer lands — so admitting it would add
silent mid-turn injection into a turn already in flight, which nothing else there can do
(`reply.rs::steer_refusal`). Its keyless refusal carries `STEER_NO_KEY` and is **never an
empty 403**, because an empty turn-control 403 is how `biorouter session attach` recognises a
daemon that holds a key and asks the person for it. A subagent's tab stays refused throughout.
⚠ Keyless behaviour can only be tested in its own binary (the digest is a process-global
`OnceLock`): `cargo test -p biorouter-server --test turn_control_no_user_key`.
- **Proof of a person is checked at the resolution choke point, not at one route.** Every door
that answers a parked decision — the HTTP route, an Agent Drafter app's WebSocket, ACP, the
CLI prompt, the TUI modal, an ancestor agent's relay — passes a `DecisionAuthority` into
Expand Down
204 changes: 191 additions & 13 deletions crates/biorouter-server/src/commands/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,89 @@ fn watch_parent(expected: u32) -> CancellationToken {
CancellationToken::new()
}

/// Why this daemon came up holding no user-action digest.
///
/// Four causes, kept apart because they mean very different things and only one
/// [`read_user_action_digest`] returns is a *mistake*. Before SD-11 they were one
/// `None`, which was survivable while a keyless daemon simply refused every
/// control that needed the proof: the failure was loud at the first click. Now
/// three of the four turn-control routes fall back to the reach gate there
/// (`routes::reply::authorize_turn_control`), so a desktop launcher that misses
/// the 2 s window comes up **quietly** weaker than the one the user installed
/// rather than visibly broken. Naming the cause is what keeps that from being a
/// silent degradation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NoUserActionKey {
/// stdin is a terminal: a person started this daemon at a prompt
/// (`just run-server`, `biorouterd agent` by hand). Expected.
HandStarted,
/// stdin closed with nothing on it. `biorouter serve` spawns its daemon with
/// `Stdio::null()` precisely so that this happens (SD-7). Expected.
NoneOffered,
/// ⚠ A writer held the pipe open and put no digest on it inside the bound.
/// Nothing Biorouter ships does that on purpose, so this is the arm that
/// means a launcher is broken or the machine was too loaded to make the
/// window — the one case where a keyless daemon is an accident.
TimedOut,
/// ⚠ A line arrived and was not 32 bytes of hex. Also a launcher fault.
Malformed,
}

impl NoUserActionKey {
/// One sentence saying what happened and, always, what it costs.
///
/// Every arm names **both** consequences, because a reader who has just
/// learnt their daemon is keyless needs to know what that daemon now does
/// differently, not only what it refuses. `unit_tests` below asserts it of
/// each arm rather than leaving it to whoever edits one of them.
fn warning(self) -> String {
let cause = match self {
Self::HandStarted => {
"no user-action key: stdin is a terminal, so this daemon was started by hand"
}
Self::NoneOffered => {
"no user-action key: stdin closed without one, which is how `biorouter serve` \
starts its daemon"
}
Self::TimedOut => {
"no user-action key: something held stdin open and wrote no digest within 2s. \
If this is the desktop application's daemon, its launcher FAILED to hand the \
key over and this daemon is weaker than the one you installed — restart it"
}
Self::Malformed => {
"no user-action key: the line on stdin was not a 32-byte hex digest. If this is \
the desktop application's daemon, its launcher is broken — restart it"
}
};
format!(
"{cause}. This daemon cannot verify that a request came from the person at the \
keyboard, so it will refuse every request that raises a session's privacy \
capability, including one made by that person; and Stop, Stop-and-Send and the \
continuation routes answer to the reach gate instead of to the proof (serve \
decision SD-11), which admits any caller holding the daemon secret to the chats \
that gate admits. Mid-turn steering stays refused."
)
}
}

/// The digest, or why there is none, from the line stdin produced — `None` for a
/// read that did not finish inside the bound.
///
/// Split out from the I/O so the mapping is testable: the whole point of the
/// four arms is that they are told apart, and a classification that lives inside
/// an `async fn` reading real stdin is one nothing can check.
fn classify_digest_line(line: Option<String>) -> Result<[u8; 32], NoUserActionKey> {
let Some(line) = line else {
return Err(NoUserActionKey::TimedOut);
};
let line = line.trim();
if line.is_empty() {
return Err(NoUserActionKey::NoneOffered);
}
let bytes = hex::decode(line).map_err(|_| NoUserActionKey::Malformed)?;
<[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| NoUserActionKey::Malformed)
}

/// Read the launcher's SHA-256 user-action digest off stdin, as one hex line
/// (issue #56, DR-16).
///
Expand All @@ -117,12 +200,16 @@ fn watch_parent(expected: u32) -> CancellationToken {
/// by a child, and the raw key was never there to begin with.
///
/// It must **never block a hand-started daemon**, so it is guarded twice.
async fn read_user_action_digest() -> Option<[u8; 32]> {
///
/// ⚠ The 2 s bound is unchanged. It is not raised here because nothing measured
/// says the desktop launcher misses it; what changed is that missing it is now
/// *reported* rather than folded into the three expected ways of holding no key.
async fn read_user_action_digest() -> Result<[u8; 32], NoUserActionKey> {
use std::io::IsTerminal;
// (1) A terminal is a human at a prompt, not a launcher with a key. Reading
// it would hang `just run-server` forever waiting for a line.
if std::io::stdin().is_terminal() {
return None;
return Err(NoUserActionKey::HandStarted);
}
// (2) And a pipe whose writer never closes would hang just as hard, so the
// read is bounded. 2s is far longer than a local `write` + `end`.
Expand All @@ -144,12 +231,15 @@ async fn read_user_action_digest() -> Option<[u8; 32]> {
// The receiver is gone on the timeout path; nothing to report to.
let _ = tx.send(read);
});
// A timeout, a dropped sender and a failed `read_line` are all "no line
// arrived inside the bound", which is the one arm that means a launcher
// wrote nothing it promised.
let line = tokio::time::timeout(std::time::Duration::from_secs(2), rx)
.await
.ok()?
.ok()??;
let bytes = hex::decode(line.trim()).ok()?;
<[u8; 32]>::try_from(bytes.as_slice()).ok()
.ok()
.and_then(Result::ok)
.flatten();
classify_digest_line(line)
}

pub async fn run(exit_with_parent: Option<u32>) -> Result<()> {
Expand Down Expand Up @@ -209,13 +299,18 @@ pub async fn run(exit_with_parent: Option<u32>) -> Result<()> {
// tool that reads a caller-named path (`/proc/self/environ`) or, on macOS,
// by `sysctl(KERN_PROCARGS2)`, which is not a path at all and which no
// sandbox profile can gate.
let user_action_digest = read_user_action_digest().await;
if user_action_digest.is_none() {
tracing::warn!(
"no user-action key on stdin: this daemon will refuse every request that raises a \
session's privacy capability, including one made by the person at the keyboard"
);
}
let user_action_digest = match read_user_action_digest().await {
Ok(digest) => Some(digest),
Err(reason) => {
// ⚠ One WARN, and it names the SD-11 consequence as well as the
// privacy one. Before SD-11 a keyless desktop daemon announced
// itself at the first click — Stop answered 403 and the user
// complained. Now Stop works there, so the same misconfiguration is
// silent unless this line says so.
tracing::warn!("{}", reason.warning());
None
}
};
// A tool whose approval can never be granted must not be offered. `serve`
// spawns this daemon with `Stdio::null()`, so it holds no key and every
// proof-backed approval refuses forever — the install and delete tools take
Expand Down Expand Up @@ -344,6 +439,89 @@ pub async fn run(exit_with_parent: Option<u32>) -> Result<()> {
Ok(())
}

/// The keyless-startup report, on every platform (unlike the `unix`-only module
/// below).
#[cfg(test)]
mod keyless_report_tests {
use super::{classify_digest_line, NoUserActionKey};

/// The four causes are told apart. They were one `None` until SD-11 made a
/// keyless daemon behave differently rather than merely refuse more, at
/// which point a launcher that misses the window stops being visible.
#[test]
fn the_four_ways_of_holding_no_key_are_distinguishable() {
let digest = "a".repeat(64);
assert_eq!(
classify_digest_line(Some(format!("{digest}\n"))),
Ok([0xaa; 32])
);
// Nothing arrived inside the bound: a writer held the pipe open. The
// only arm that means something is wrong.
assert_eq!(classify_digest_line(None), Err(NoUserActionKey::TimedOut));
// `Stdio::null()`, which is how `biorouter serve` starts its daemon: the
// read succeeds at EOF and yields nothing.
for empty in ["", "\n", " \n"] {
assert_eq!(
classify_digest_line(Some(empty.to_string())),
Err(NoUserActionKey::NoneOffered),
"{empty:?}"
);
}
// Present and wrong, which a launcher fault also looks like: not hex at
// all, and hex of the wrong length in both directions.
let short = "a".repeat(62);
let long = "a".repeat(66);
for bad in ["not-hex", "abcd", short.as_str(), long.as_str()] {
assert_eq!(
classify_digest_line(Some(bad.to_string())),
Err(NoUserActionKey::Malformed),
"{bad:?}"
);
}
}

/// Every arm names BOTH consequences — what this daemon refuses, and what it
/// now admits instead (SD-11) — and the two launcher faults say they are
/// faults. A reader who has just learnt their daemon is keyless needs the
/// second half as much as the first.
#[test]
fn every_warning_names_what_a_keyless_daemon_does_differently() {
for reason in [
NoUserActionKey::HandStarted,
NoUserActionKey::NoneOffered,
NoUserActionKey::TimedOut,
NoUserActionKey::Malformed,
] {
let warning = reason.warning();
assert!(
warning.contains("privacy capability"),
"{reason:?} does not name what it refuses: {warning}"
);
assert!(
warning.contains("SD-11") && warning.contains("reach gate"),
"{reason:?} does not name what it admits instead: {warning}"
);
assert!(
warning.contains("Mid-turn steering stays refused"),
"{reason:?} does not say steering is still refused: {warning}"
);
}
// The two that mean a launcher is broken say so, and say what to do.
for fault in [NoUserActionKey::TimedOut, NoUserActionKey::Malformed] {
let warning = fault.warning();
assert!(
warning.contains("restart it"),
"{fault:?} is a misconfiguration and must be actionable: {warning}"
);
}
// …and the two expected ones do not, so the WARN cannot cry wolf on
// every `biorouter serve` start.
for expected in [NoUserActionKey::HandStarted, NoUserActionKey::NoneOffered] {
assert!(!expected.warning().contains("restart it"), "{expected:?}");
}
}
}

/// Only [`until_orphaned`], never [`watch_parent`]: the latter arms a
/// `process::exit`, which would take the test binary down with it.
#[cfg(all(test, unix))]
Expand Down
46 changes: 39 additions & 7 deletions crates/biorouter-server/src/routes/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,40 @@ pub struct UpdateFromSessionRequest {
const SUBAGENT_USER_ACTION_REQUIRED: &str =
"Changing or resuming a subagent from its tab requires proof that the request came from the person at the keyboard.";

/// …and when the daemon holds no user-action key at all.
///
/// A separate sentence, per Task 18A's open question 23 and SD-8: telling a
/// person at a `biorouter serve` page that their request "requires proof" sends
/// them hunting for a permission this daemon can never grant anyone. It names
/// the daemon as the reason, in the register `CROSS_AFFILIATION_GRANT_NO_KEY`
/// and `session_reach::SESSION_REACH_NO_KEY` already use. Since SD-11 this is
/// also what a keyless daemon answers a Stop aimed at a subagent's turn, because
/// that route gates through [`authorize_agent_control`] there. A *steer* at the
/// same turn is refused one step earlier, by `reply::steer_refusal`, which
/// never reads the row — so the two sentences differ, and both open by naming
/// this daemon rather than the caller.
const SUBAGENT_CONTROL_NO_KEY: &str =
"This daemon was started without a user-action key, so it cannot verify that a request came \
from the person at the keyboard, and changing, resuming, stopping or steering a subagent from \
its tab requires that proof. Nothing was changed. This control is unavailable on this \
daemon; use the desktop app.";

fn refuse_subagent_unless_user(
session: &Session,
headers: &HeaderMap,
) -> Result<(), ErrorResponse> {
if session.session_type == SessionType::SubAgent && !is_user_action(headers) {
return Err(ErrorResponse {
message: SUBAGENT_USER_ACTION_REQUIRED.to_string(),
status: StatusCode::FORBIDDEN,
});
if session.session_type != SessionType::SubAgent {
return Ok(());
}
Ok(())
let message = match user_action_proof(headers) {
UserActionProof::Proven => return Ok(()),
UserActionProof::Unproven => SUBAGENT_USER_ACTION_REQUIRED,
UserActionProof::NoKeyInstalled => SUBAGENT_CONTROL_NO_KEY,
};
Err(ErrorResponse {
message: message.to_string(),
status: StatusCode::FORBIDDEN,
})
}

#[async_trait::async_trait]
Expand Down Expand Up @@ -146,7 +169,16 @@ async fn read_update_session(
/// Authorize an HTTP control-plane operation before it can touch an agent or a
/// queued child handle. The daemon bearer proves only that the caller reached
/// this process; it does not prove that a person chose to mutate a subagent.
async fn authorize_agent_control(
///
/// ⚠ **Also the turn-control gate on a daemon with no user-action key** (SD-11):
/// `routes::reply`'s `authorize_turn_control` calls this for `/agent/cancel` and
/// the two continuation routes there, so that stopping a turn admits exactly the
/// callers `/agent/stop` admits. Tightening this therefore tightens those three
/// too, which is the point — but it is a change to who may press Stop in a
/// browser, and `tests/turn_control_no_user_key.rs` will say so. `/interrupt` is
/// NOT among them: `reply::steer_refusal` keeps the proof on every daemon,
/// because the dominance argument that admits a Stop does not reach a steer.
pub(crate) async fn authorize_agent_control(
state: &AppState,
session_id: &str,
headers: &HeaderMap,
Expand Down
Loading
Loading