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
25 changes: 16 additions & 9 deletions cmux-tui/crates/cmux-tui-core/src/server/agent_session_start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ pub type AcpmuxStarter = Arc<dyn Fn() -> Result<(), String> + Send + Sync>;

/// What kind of machine this daemon runs on, as the binary reads it from the
/// machine's own identity at each start. A remote start is served on
/// [`AgentStartHost::Allowed`], and on a Cloud VM with no team evidence only
/// [`AgentStartHost::Allowed`], and on any Cloud VM (a team VM included) only
/// when the caller says the Cloud API answered `vmKind: "owner"` for it
/// (`vm_kind`); a daemon that was never told refuses (fail closed).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
Expand All @@ -85,8 +85,10 @@ pub enum AgentStartHost {
OwnerCloud,
/// A Cloud VM whose kind the daemon cannot tell: as [`Self::OwnerCloud`].
UnknownCloud,
/// A team VM: shared by a team, so no remote start (hq-84 rule until the
/// team policy for local-control sessions exists).
/// A team VM: shared by a team. A remote start goes on only on the
/// caller's `vm_kind: "owner"`, which the Cloud API gives the machine's
/// creator alone; anyone else is refused (hq-84 rule for teammates until
/// the team's chat grants exist).
TeamVm,
}

Expand Down Expand Up @@ -125,15 +127,19 @@ impl AgentSessionStarts {
/// lands after the daemon started counts at once. `caller_owns_vm`: the
/// call's `vm_kind` is exactly `owner` (the Cloud API's answer for the
/// caller on its link). A Cloud VM goes on only on that word, never on
/// its own record, and team evidence refuses even with it.
/// its own record. On a team VM the same word lets the caller in: the
/// Cloud API answers `owner` per caller, for the machine's creator only
/// (web vmKindForCaller since #28788), so a teammate who did not create
/// it still gets [`Refusal::TeamVmBlocked`] (Leo, 2026-10-31: "we can't
/// simply block always").
fn host_gate(&self, caller_owns_vm: bool) -> Result<(), Refusal> {
let source = self.host.lock().unwrap_or_else(|e| e.into_inner()).clone();
match source.map(|read| read()) {
Some(AgentStartHost::Allowed) => Ok(()),
Some(
AgentStartHost::OwnerCloud | AgentStartHost::UnknownCloud | AgentStartHost::TeamVm,
) if caller_owns_vm => Ok(()),
Some(AgentStartHost::TeamVm) => Err(Refusal::TeamVmBlocked),
Some(AgentStartHost::OwnerCloud | AgentStartHost::UnknownCloud) if caller_owns_vm => {
Ok(())
}
_ => Err(Refusal::HostUnverified),
}
}
Expand Down Expand Up @@ -607,7 +613,8 @@ mod tests {

/// A call on a Cloud VM (a start, a chat move in, folder trust) runs only
/// on the caller's `vm_kind: "owner"`; the VM's own `owner` record never
/// allows it, and team evidence refuses even then.
/// allows it. On a team VM that word (the creator's, from the Cloud API)
/// lets the call in too; without it a team VM still says so.
#[test]
fn a_call_on_a_cloud_vm_needs_the_callers_owner_word() {
use AgentStartHost::{Allowed, OwnerCloud, TeamVm, UnknownCloud};
Expand All @@ -619,7 +626,7 @@ mod tests {
(Allowed, Ok(()), Ok(())),
(OwnerCloud, Ok(()), unverified),
(UnknownCloud, Ok(()), unverified),
(TeamVm, team, team),
(TeamVm, Ok(()), team),
];
for (host, owner_word, no_word) in cases {
starts.set_host(Arc::new(move || host));
Expand Down
10 changes: 6 additions & 4 deletions cmux-tui/crates/cmux-tui/src/agent_start_host.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
//! The machine kind `agent-session-start` needs (cx-d0tq, hq-84 rule), and
//! with it a chat move in (`agent-transfer-receive`, `-finish`) and folder
//! trust (`agent-session-trust`): refused on a team VM, and on any Cloud VM
//! unless the caller says the Cloud API answered `vmKind: "owner"` for it on
//! its link (the call's `vm_kind`, cmux-tui-core `agent_session_start.rs`). The kind
//! trust (`agent-session-trust`): refused on any Cloud VM, a team VM
//! included, unless the caller says the Cloud API answered `vmKind: "owner"`
//! for it on its link (the call's `vm_kind`, cmux-tui-core
//! `agent_session_start.rs`). On a team VM the Cloud API gives that answer to
//! the machine's creator alone (web vmKindForCaller, #28788). The kind
//! comes from this machine's own identity, read again at every start (a few
//! file checks).
//!
//! Agents keep sudo on a Cloud VM, so anything on it (the records below)
//! says whatever an agent writes there: a record may only make the answer
//! stricter. An `owner` record never allows a call by itself; team evidence
//! refuses even with the caller's word.
//! refuses every caller without the owner word (`team_vm_blocked`).
//!
//! The daemon runs as the work user and cannot read cmux-host's state
//! (/var/lib/cmux is 0700 root), so cmux-host records the kind where any
Expand Down
24 changes: 18 additions & 6 deletions cmux-tui/crates/cmux-tui/tests/cli/agent_session_start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,8 +362,10 @@ fn agent_session_start_passes_the_host_gate_on_an_ssh_host() {

/// The guest's own `owner` record never allows a start on a Cloud VM (its
/// agents keep sudo): only the caller's `vm_kind: "owner"`, the Cloud API's
/// answer for it, does, and team evidence refuses even then. A machine that
/// is not a Cloud VM needs no word.
/// answer for it, does. On a team VM that word is the creator's (the Cloud
/// API gives it to nobody else), so it goes on there too; any other word or
/// none is still `team_vm_blocked`. A machine that is not a Cloud VM needs no
/// word.
#[test]
fn a_cloud_vm_start_needs_the_callers_owner_word_not_the_vms_record() {
let owner = Daemon::start_cloud("vmowner", "owner");
Expand All @@ -380,7 +382,12 @@ fn a_cloud_vm_start_needs_the_callers_owner_word_not_the_vms_record() {

let team = Daemon::start_cloud("vmteam", "team");
let surface = team.own_agent_tab();
// The creator's word: past the host gate, the next check is the folder's trust.
let reply = team.start_chat_as(surface, "owner");
assert_eq!(reply["error_code"], "agent_session.untrusted_folder", "{reply}");
let reply = team.start_chat(surface);
assert_eq!(reply["error_code"], "agent_session.team_vm_blocked", "{reply}");
let reply = team.start_chat_as(surface, "team");
assert_eq!(reply["error_code"], "agent_session.team_vm_blocked", "{reply}");

let ssh = Daemon::start("vmssh", None);
Expand All @@ -400,8 +407,8 @@ fn a_cloud_vm_start_needs_the_callers_owner_word_not_the_vms_record() {
}

/// Folder trust on a Cloud VM takes the start's rule: only the caller's
/// `vm_kind: "owner"` reaches it, never the VM's own record, and team
/// evidence refuses even then. A page cannot vouch for the Cloud API's
/// `vm_kind: "owner"` reaches it, never the VM's own record; on a team VM
/// that word is the creator's and reaches it too. A page cannot vouch for the Cloud API's
/// answer, so its routed operations never reach a Cloud VM's trust.
#[test]
fn agent_session_trust_on_a_cloud_vm_needs_the_callers_owner_word() {
Expand Down Expand Up @@ -439,8 +446,13 @@ fn agent_session_trust_on_a_cloud_vm_needs_the_callers_owner_word() {

let team = Daemon::start_cloud("trustteam", "team");
let surface = team.own_agent_tab();
let reply = trust(&team, surface, json!("owner"));
assert_eq!(reply["error_code"], "agent_session.team_vm_blocked", "{reply}");
for kind in [json!(null), json!("team")] {
let reply = trust(&team, surface, kind);
assert_eq!(reply["error_code"], "agent_session.team_vm_blocked", "{reply}");
}
// The creator's word (the Cloud API's owner answer) reaches the trust read.
let read = trust(&team, surface, json!("owner"));
assert_eq!(read["ok"], true, "{read}");

// A machine that shows no Cloud sign (its root may have removed them):
// a word that is not `owner`, an explicit `null` included, refuses.
Expand Down
13 changes: 8 additions & 5 deletions cmux-tui/spec/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -9543,9 +9543,11 @@ peer, preset or MCP server param; `model` is only accepted with the additive
`agent_session.bad_request`. The machine kind decides first, read again at
each start: a team VM (kind `team` in the root-owned `/etc/cmux/vm-kind` that
cmux-host writes at a team enroll, or the team files root `/srv/team`,
root:`cmux-ssh`) answers `agent_session.team_vm_blocked`; Macs and SSH hosts
go on; any other Cloud VM goes on only with `vm_kind: "owner"` and otherwise
answers `agent_session.host_unverified`. `vm_kind`
root:`cmux-ssh`) goes on only with `vm_kind: "owner"` (the Cloud API gives
that answer to the machine's creator alone) and otherwise answers
`agent_session.team_vm_blocked`; Macs and SSH hosts go on; any other Cloud VM
goes on only with `vm_kind: "owner"` and otherwise answers
`agent_session.host_unverified`. `vm_kind`
(`agent-session-start-vm-kind-v1`) is the Cloud API's `vmKind` for the
caller, as the caller read it on its link to this machine (attach-endpoint);
only the exact string `owner` counts. Any other value says the caller knows
Expand Down Expand Up @@ -9979,8 +9981,9 @@ receives are kept, checked before acpmux makes one.
Machine kind: receive and finish read this machine's kind at each call, as
`agent-session-start` does, each with its own `vm_kind`
(`agent-transfer-vm-kind-v1`, only `owner` counts), and answer
`agent_session.team_vm_blocked` on a team VM and
`agent_session.host_unverified` on a Cloud VM without the caller's
`agent_session.team_vm_blocked` on a team VM without the caller's
`vm_kind: "owner"` and
`agent_session.host_unverified` on any other Cloud VM without the caller's
`vm_kind: "owner"` (its own `owner` record never counts) or on a daemon never
told its kind. `cmux link agent move --vm-kind KIND` (the caller says the
target is a Cloud machine) sends it to a daemon that serves it; to an older
Expand Down
Loading