diff --git a/cmux-tui/crates/acpmux/spec/acpmux-protocol.json b/cmux-tui/crates/acpmux/spec/acpmux-protocol.json index f6811f580fea..0d06244d48a4 100644 --- a/cmux-tui/crates/acpmux/spec/acpmux-protocol.json +++ b/cmux-tui/crates/acpmux/spec/acpmux-protocol.json @@ -870,6 +870,18 @@ "params": true, "result": true }, + { + "kind": "request", + "name": "_acpmux/mac_approver_set", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/mac_approvers", + "params": true, + "result": true + }, { "kind": "request", "name": "_acpmux/new_chat_picks_set", diff --git a/cmux-tui/crates/acpmux/src/daemon.rs b/cmux-tui/crates/acpmux/src/daemon.rs index 6971c1385968..a28d9fce3fb2 100644 --- a/cmux-tui/crates/acpmux/src/daemon.rs +++ b/cmux-tui/crates/acpmux/src/daemon.rs @@ -319,6 +319,8 @@ pub async fn run(opts: DaemonOptions) -> Result<()> { hub.watches.open(&home().join("watches.json")); } crate::hub::watches::Watches::start(&hub); + // The Macs whose app may allow here over the peer link (`hub/mac_approvers.rs`). + hub.person.macs.open(home().join(crate::hub::mac_approvers::FILE)); if let Some(key) = person_key { // A Team-signed daemon takes the key only from the signed app's // `_acpmux/person_enroll`: a same-uid process could restart it with diff --git a/cmux-tui/crates/acpmux/src/hub/approvers.rs b/cmux-tui/crates/acpmux/src/hub/approvers.rs index 557b1d61289b..0b71f0a9de7b 100644 --- a/cmux-tui/crates/acpmux/src/hub/approvers.rs +++ b/cmux-tui/crates/acpmux/src/hub/approvers.rs @@ -56,37 +56,7 @@ impl ApproverSet { .get("approvers") .and_then(Value::as_array) .ok_or_else(|| RpcError::invalid_params("approvers must be a list"))?; - if list.len() > MAX_APPROVERS { - return Err(RpcError::invalid_params(format!("at most {MAX_APPROVERS} approvers"))); - } - let mut approvers = Vec::with_capacity(list.len()); - for entry in list { - let id = entry.get("id").and_then(Value::as_str).filter(|i| valid_id(i)).ok_or_else( - || RpcError::invalid_params("approver id must be 1 to 128 of [A-Za-z0-9_.:-]"), - )?; - let public_key = entry - .get("publicKey") - .and_then(Value::as_str) - .and_then(unhex) - .filter(|k| k.len() == 65 && k[0] == 4) - .ok_or_else(|| { - RpcError::invalid_params( - "publicKey must be a 65-byte uncompressed P-256 point as hex", - ) - })?; - let name: String = entry - .get("name") - .and_then(Value::as_str) - .unwrap_or_default() - .chars() - .filter(|c| !c.is_control()) - .take(64) - .collect(); - if approvers.iter().any(|a: &Approver| a.id == id) { - return Err(RpcError::invalid_params(format!("approver {id} is listed twice"))); - } - approvers.push(Approver { id: id.to_owned(), public_key, name }); - } + let approvers = parse_approvers(list)?; let count = approvers.len(); *self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = (mac.to_owned(), approvers); @@ -182,6 +152,45 @@ impl ApproverSet { } } +/// The approvers of a list `[{id, publicKey, name}]`, checked whole: at most +/// [`MAX_APPROVERS`], each id once, each key a 65-byte uncompressed P-256 point as hex. +/// The enrolled Macs' file is read with it too (`mac_approvers.rs`). +pub(super) fn parse_approvers(list: &[Value]) -> Result, RpcError> { + if list.len() > MAX_APPROVERS { + return Err(RpcError::invalid_params(format!("at most {MAX_APPROVERS} approvers"))); + } + let mut approvers = Vec::with_capacity(list.len()); + for entry in list { + let id = + entry.get("id").and_then(Value::as_str).filter(|i| valid_id(i)).ok_or_else(|| { + RpcError::invalid_params("approver id must be 1 to 128 of [A-Za-z0-9_.:-]") + })?; + let public_key = entry + .get("publicKey") + .and_then(Value::as_str) + .and_then(unhex) + .filter(|k| k.len() == 65 && k[0] == 4) + .ok_or_else(|| { + RpcError::invalid_params( + "publicKey must be a 65-byte uncompressed P-256 point as hex", + ) + })?; + let name: String = entry + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .chars() + .filter(|c| !c.is_control()) + .take(64) + .collect(); + if approvers.iter().any(|a: &Approver| a.id == id) { + return Err(RpcError::invalid_params(format!("approver {id} is listed twice"))); + } + approvers.push(Approver { id: id.to_owned(), public_key, name }); + } + Ok(approvers) +} + /// What an approver signs. pub struct Parts<'a> { pub instance: &'a str, @@ -290,16 +299,16 @@ pub fn fingerprint(key: &[u8]) -> String { hex(&sha2::Sha256::digest(key)[..8]) } -fn valid_id(s: &str) -> bool { +pub(super) fn valid_id(s: &str) -> bool { (1..=128).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b':' | b'-')) } -fn hex(bytes: &[u8]) -> String { +pub(super) fn hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } -fn unhex(s: &str) -> Option> { +pub(super) fn unhex(s: &str) -> Option> { if !s.len().is_multiple_of(2) || !s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) { diff --git a/cmux-tui/crates/acpmux/src/hub/mac_approvers.rs b/cmux-tui/crates/acpmux/src/hub/mac_approvers.rs new file mode 100644 index 000000000000..65e69fbc3327 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/hub/mac_approvers.rs @@ -0,0 +1,269 @@ +//! Enrolled Macs (peer-route approvals): the person's cmux app on a Mac may +//! allow a prompt of this daemon that reached the app through the Mac's own +//! daemon (a chat opened with `--host`, which that daemon mirrors over the +//! peer link), with a P-256 signature of its own. The person key never leaves +//! the Mac: only the signed answer crosses the link. +//! +//! - Who may sign is a file, `ACPMUX_HOME/mac-approvers.json` ([`FILE`]), +//! `{"macs": [{id, publicKey, name}]}`, written by `acpmux host approvals +//! add` on this machine (which `acpmux host enroll` on the Mac runs here over +//! ssh). No RPC adds a key: an agent of this daemon that may only talk to its +//! socket cannot enroll one. Writing the file takes this user's own power on +//! this machine (ssh, or a shell command the person allowed), which can +//! already replace this daemon. It is read as this user's private state (no +//! link, not writable by others). +//! - The Mac's `_acpmux/permission_respond` carries the phone's shape +//! (`approvers.rs`): `_meta.acpmux = {decisionNonce, approverDecision: +//! {approver, expiresAt, signature}}`. `signature` is ECDSA P-256 / SHA-256, +//! raw `r || s` as lowercase hex, over [`message`]: its own domain, then the +//! daemon run, the Mac's id, the session, the permission, the tool call id, +//! the option, the prompt's nonce, the expiry and the digest of the answers. +//! - It counts once (the person gate's spent-nonce store), only until +//! `expiresAt` (at most [`MAX_LIFETIME`] ahead), and only as a one-time allow +//! (`allow_once`) of a tool's ask or an agent's question ([`may_allow`]): never +//! a lasting allow, a plan, a mode switch or a folder-trust card, so it never +//! changes a mode, a policy or a rule. A deny needs no signature, from anyone. +//! - `acpmux host approvals rm ID` removes a Mac and then has the daemon reload +//! (`_acpmux/mac_approvers {reload: true}`), which takes the set's lock: it +//! returns only after an answer being committed, and from then on none of +//! that Mac's signatures counts. Every check also re-reads the file when it +//! changed, so a hand edit counts too. +//! - The app on this machine gives its own public key with +//! `_acpmux/mac_approver_set {id, publicKey, name}` (the person only, memory +//! only), so `acpmux host enroll` here knows which key to enroll on a peer. + +use super::approvers::{Approver, MAX_LIFETIME, answers_digest, fingerprint, parse_approvers}; +use super::*; +use std::path::{Path, PathBuf}; + +/// The enrolled Macs' file, in `ACPMUX_HOME`. +pub const FILE: &str = "mac-approvers.json"; +/// The domain of an enrolled Mac's decision signature. +const DOMAIN: &str = "acpmux-mac-approver-decision-v1"; +/// The file is a few kilobytes at most; one past this is refused (read as no Macs). +const MAX_BYTES: u64 = 256 * 1024; + +/// What a file looked like when it was read: its length and modification time. +type Stamp = (u64, Option); + +#[derive(Default)] +struct Inner { + path: Option, + /// The file as last read (None: not read yet, or missing). + stamp: Option, + macs: Vec, +} + +/// The Macs that may allow here, and this machine's own app key. +#[derive(Default)] +pub struct MacApprovers { + inner: StdMutex, + own: StdMutex>, +} + +impl MacApprovers { + /// Reads the enrolled Macs from `path` (the daemon's start). + pub fn open(&self, path: PathBuf) { + let mut inner = self.lock(); + inner.path = Some(path); + inner.stamp = None; + refresh(&mut inner); + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { + self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// `_acpmux/mac_approvers {reload?}`: the enrolled Macs (ids, names, key + /// fingerprints) and this machine's own app key. `reload` reads the file + /// again under the set's lock, so it returns only after an answer being + /// committed (`host approvals rm`). + pub fn view(&self, params: &Value) -> Value { + let reload = params.get("reload").and_then(Value::as_bool) == Some(true); + let mut inner = self.lock(); + if reload { + inner.stamp = None; + } + refresh(&mut inner); + let own = self.own.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone(); + json!({ + "macs": inner.macs.iter().map(|m| json!({ + "id": m.id, "name": m.name, "fingerprint": fingerprint(&m.public_key), + })).collect::>(), + "thisMac": own.map(|m| json!({ + "id": m.id, "name": m.name, "publicKey": super::approvers::hex(&m.public_key), + "fingerprint": fingerprint(&m.public_key), + })), + "file": inner.path, + }) + } + + /// `_acpmux/mac_approver_set {id, publicKey, name}` (the person only): this + /// machine's app key, which `host enroll` enrolls on a peer. Memory only. + pub fn set_own(&self, params: &Value) -> Result { + let mut parsed = parse_approvers(std::slice::from_ref(params))?; + let own = parsed.pop(); + let fingerprint = own.as_ref().map(|o| fingerprint(&o.public_key)); + *self.own.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = own; + Ok(json!({"fingerprint": fingerprint})) + } + + /// Whether `decision` is an enrolled Mac's valid, unexpired signature + /// over this exact answer to `request`, an answer a Mac may give + /// ([`may_allow`]); then `commit` (the answer counting: its nonce spent). + /// The set's lock is held from the key lookup through `commit`, so a + /// reload that drops the Mac waits for an answer being committed. Lock + /// order as `ApproverSet::verifies_then`: the prompt's lock (held by the + /// caller), then this set's, then the spent-nonce store. + #[allow(clippy::too_many_arguments)] + pub fn verifies_then( + &self, + instance: &str, + session: &str, + permission: &str, + request: &Value, + option: &str, + nonce: &str, + answers: Option<&Value>, + decision: &Value, + now: u64, + commit: impl FnOnce() -> bool, + ) -> bool { + let field = |f: &str| decision.get(f).and_then(Value::as_str); + let (Some(id), Some(signature)) = (field("approver"), field("signature")) else { + return false; + }; + let Some(expires) = decision.get("expiresAt").and_then(Value::as_u64) else { return false }; + if expires < now || expires > now.saturating_add(MAX_LIFETIME) { + return false; + } + if !may_allow(request, option, answers) { + return false; + } + let Some(signature) = super::approvers::unhex(signature).filter(|s| s.len() == 64) else { + return false; + }; + let tool_call = request.pointer("/toolCall/toolCallId").and_then(Value::as_str); + let mut inner = self.lock(); + refresh(&mut inner); + let Some(key) = inner.macs.iter().find(|m| m.id == id).map(|m| &m.public_key) else { + return false; + }; + let message = message(&Parts { + instance, + mac: id, + session, + permission, + tool_call: tool_call.unwrap_or_default(), + option, + nonce, + expires_at: expires, + answers: &answers_digest(answers), + }); + ring::signature::UnparsedPublicKey::new(&ring::signature::ECDSA_P256_SHA256_FIXED, key) + .verify(&message, &signature) + .is_ok() + && commit() + } +} + +/// Reads the file again when it is not what was read last. A file that is +/// missing, unreadable, too large or not a Macs file enrolls no Mac. +fn refresh(inner: &mut Inner) { + let Some(path) = inner.path.clone() else { return }; + let stamp = std::fs::symlink_metadata(&path).ok().map(|m| (m.len(), m.modified().ok())); + if stamp.is_some() && stamp == inner.stamp { + return; + } + inner.stamp = stamp; + inner.macs = match read(&path) { + Ok(macs) => macs, + Err(e) => { + tracing::warn!("{}: {e:#}; no Mac may allow until it is fixed", path.display()); + Vec::new() + } + }; +} + +/// The Macs in `path`; none when it does not exist. +pub fn read(path: &Path) -> anyhow::Result> { + use std::io::Read; + match std::fs::symlink_metadata(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e.into()), + Ok(_) => {} + } + let file = crate::config::private_state_file(path)?; + let mut bytes = Vec::new(); + file.take(MAX_BYTES + 1).read_to_end(&mut bytes)?; + anyhow::ensure!(bytes.len() as u64 <= MAX_BYTES, "larger than {MAX_BYTES} bytes"); + let v: Value = serde_json::from_slice(&bytes)?; + let list = v.get("macs").and_then(Value::as_array).cloned().unwrap_or_default(); + parse_approvers(&list).map_err(|e| anyhow::anyhow!("{}", e.message)) +} + +/// Whether an enrolled Mac may give `option` (with `answers`) to `request`: a +/// one-time allow offered once, of a tool's ask (no answers) or an agent's +/// question (with answers); never a plan, a mode switch or a folder-trust +/// card (each widens what runs, or stays the person's on this machine). +pub fn may_allow(request: &Value, option: &str, answers: Option<&Value>) -> bool { + let offered: Vec<&Value> = request["options"] + .as_array() + .into_iter() + .flatten() + .filter(|o| o["optionId"] == option) + .collect(); + if !matches!(offered.as_slice(), [one] if one["kind"] == "allow_once") { + return false; + } + let tool = &request["toolCall"]; + let widens = tool["kind"] == "switch_mode" + || super::questions::trust_cwd(request).is_some() + || tool.pointer("/_meta/claude/tool").and_then(Value::as_str) == Some("ExitPlanMode"); + let answers = answers.filter(|a| !a.is_null()); + !widens + && match answers { + None => super::permission_groups::plain_tool_ask(request), + Some(_) => super::questions::needs_person(request), + } +} + +/// What an enrolled Mac signs. +pub struct Parts<'a> { + pub instance: &'a str, + pub mac: &'a str, + pub session: &'a str, + pub permission: &'a str, + /// The prompt's `toolCall.toolCallId`, empty for none. + pub tool_call: &'a str, + pub option: &'a str, + pub nonce: &'a str, + pub expires_at: u64, + /// `approvers::answers_digest` of the answers, empty for none. + pub answers: &'a str, +} + +/// The signed bytes: the domain, then each part after one NUL. +pub fn message(p: &Parts<'_>) -> Vec { + let expires = p.expires_at.to_string(); + let mut out = DOMAIN.as_bytes().to_vec(); + for part in [ + p.instance, + p.mac, + p.session, + p.permission, + p.tool_call, + p.option, + p.nonce, + &expires, + p.answers, + ] { + out.push(0); + out.extend_from_slice(part.as_bytes()); + } + out +} + +#[cfg(test)] +#[path = "mac_approvers_tests.rs"] +mod tests; diff --git a/cmux-tui/crates/acpmux/src/hub/mac_approvers_tests.rs b/cmux-tui/crates/acpmux/src/hub/mac_approvers_tests.rs new file mode 100644 index 000000000000..9c703410b8b6 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/hub/mac_approvers_tests.rs @@ -0,0 +1,258 @@ +use super::*; +use ring::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair}; + +fn key() -> EcdsaKeyPair { + let rng = ring::rand::SystemRandom::new(); + let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap(); + EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng).unwrap() +} + +fn hex(bytes: &[u8]) -> String { + super::super::approvers::hex(bytes) +} + +fn dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("macap-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn write(path: &Path, macs: Value) { + crate::config::write_atomic(path, json!({"macs": macs}).to_string().as_bytes()).unwrap(); +} + +fn enrolled(k: &EcdsaKeyPair, tag: &str) -> (MacApprovers, PathBuf) { + let path = dir(tag).join(FILE); + write( + &path, + json!([{"id": "mac-1", "publicKey": hex(k.public_key().as_ref()), "name": "Mac"}]), + ); + let set = MacApprovers::default(); + set.open(path.clone()); + (set, path) +} + +fn tool_ask() -> Value { + json!({"toolCall": {"toolCallId": "call-1", "title": "rm", "kind": "execute"}, + "options": [{"optionId": "yes", "kind": "allow_once"}, + {"optionId": "always", "kind": "allow_always"}, + {"optionId": "no", "kind": "reject_once"}]}) +} + +fn parts<'a>(tool_call: &'a str, option: &'a str, expires: u64, answers: &'a str) -> Parts<'a> { + Parts { + instance: "inst", + mac: "mac-1", + session: "s1", + permission: "p1", + tool_call, + option, + nonce: "n1", + expires_at: expires, + answers, + } +} + +fn sign(k: &EcdsaKeyPair, p: &Parts<'_>) -> String { + hex(k.sign(&ring::rand::SystemRandom::new(), &message(p)).unwrap().as_ref()) +} + +#[allow(clippy::too_many_arguments)] +fn check( + set: &MacApprovers, + request: &Value, + option: &str, + sig: &str, + expires: u64, + answers: Option<&Value>, + now: u64, +) -> bool { + let decision = json!({"approver": "mac-1", "expiresAt": expires, "signature": sig}); + set.verifies_then("inst", "s1", "p1", request, option, "n1", answers, &decision, now, || true) +} + +#[test] +fn the_wire_payload_is_fixed() { + // The same vector is in the app's AcpmuxMacApproverTests. + use sha2::Digest; + let nonce = "ab".repeat(32); + let m = message(&Parts { + instance: "0123abcd", + mac: "mac-9f1c2d3e4f5a6b7c", + session: "sess-1", + permission: "perm-7", + tool_call: "toolu_01", + option: "allow_once", + nonce: &nonce, + expires_at: 1_760_000_000, + answers: "", + }); + assert!(m.starts_with(b"acpmux-mac-approver-decision-v1\0")); + assert_eq!( + hex(&sha2::Sha256::digest(&m)), + "e78a2bf7ed824fa614115c1c2fdc73b913c3486d1213b74e73b57c6bafcec4a0" + ); +} + +#[test] +fn a_macs_signature_counts_for_its_one_time_allow_and_every_change_fails() { + let k = key(); + let (set, _) = enrolled(&k, "allow"); + let now = 1_000; + let ask = tool_ask(); + let good = sign(&k, &parts("call-1", "yes", now + 120, "")); + assert!(check(&set, &ask, "yes", &good, now + 120, None, now)); + // Expired, too far ahead, a changed expiry, another key. + assert!(!check(&set, &ask, "yes", &good, now + 120, None, now + 121)); + let far = sign(&k, &parts("call-1", "yes", now + MAX_LIFETIME + 1, "")); + assert!(!check(&set, &ask, "yes", &far, now + MAX_LIFETIME + 1, None, now)); + assert!(!check(&set, &ask, "yes", &good, now + 119, None, now)); + let other = sign(&key(), &parts("call-1", "yes", now + 120, "")); + assert!(!check(&set, &ask, "yes", &other, now + 120, None, now)); + // Another tool call, session, permission, nonce or daemon run. + let mut moved = ask.clone(); + moved["toolCall"]["toolCallId"] = json!("call-2"); + assert!(!check(&set, &moved, "yes", &good, now + 120, None, now)); + let decision = json!({"approver": "mac-1", "expiresAt": now + 120, "signature": good}); + for (inst, s, p, n) in [ + ("x", "s1", "p1", "n1"), + ("inst", "s2", "p1", "n1"), + ("inst", "s1", "p2", "n1"), + ("inst", "s1", "p1", "n2"), + ] { + assert!(!set.verifies_then(inst, s, p, &ask, "yes", n, None, &decision, now, || true)); + } + // An unknown Mac. + let decision = json!({"approver": "mac-2", "expiresAt": now + 120, "signature": good}); + assert!(!set.verifies_then( + "inst", + "s1", + "p1", + &ask, + "yes", + "n1", + None, + &decision, + now, + || true + )); + // A lasting allow is never a Mac's, even signed. + let always = sign(&k, &parts("call-1", "always", now + 120, "")); + assert!(!check(&set, &ask, "always", &always, now + 120, None, now)); + // A failed commit (a spent nonce) counts nothing. + let decision = json!({"approver": "mac-1", "expiresAt": now + 120, "signature": good}); + assert!(!set.verifies_then( + "inst", + "s1", + "p1", + &ask, + "yes", + "n1", + None, + &decision, + now, + || false + )); +} + +#[test] +fn a_mac_never_allows_a_plan_a_mode_switch_or_a_folder_trust() { + let once = json!([{"optionId": "yes", "kind": "allow_once"}]); + let plan = json!({"toolCall": {"toolCallId": "c", "kind": "switch_mode"}, "options": once}); + let exit = json!({"toolCall": {"toolCallId": "c", "kind": "other", + "_meta": {"claude": {"tool": "ExitPlanMode"}}}, "options": once}); + let trust = json!({"toolCall": {"toolCallId": "c", "_meta": {"acpmux": {"trust": {"cwd": "/w"}}}}, + "options": once}); + for request in [plan, exit, trust] { + assert!(!may_allow(&request, "yes", None), "{request}"); + } + // An option offered twice is no one-time allow. + let twice = json!({"toolCall": {"toolCallId": "c", "kind": "execute"}, + "options": [{"optionId": "yes", "kind": "allow_once"}, + {"optionId": "yes", "kind": "allow_once"}]}); + assert!(!may_allow(&twice, "yes", None)); + assert!(may_allow(&tool_ask(), "yes", None)); + // Answers go with a question only. + assert!(!may_allow(&tool_ask(), "yes", Some(&json!({"q": "A"})))); +} + +#[test] +fn a_macs_signed_answer_covers_exactly_its_answers() { + let k = key(); + let (set, _) = enrolled(&k, "answers"); + let question = json!({"toolCall": {"toolCallId": "q-1", "kind": "other", + "_meta": {"claude": {"tool": "AskUserQuestion"}}}, + "options": [{"optionId": "yes", "kind": "allow_once"}]}); + let answers = json!({"Which?": "A"}); + let digest = answers_digest(Some(&answers)); + let sig = sign(&k, &parts("q-1", "yes", 200, &digest)); + assert!(check(&set, &question, "yes", &sig, 200, Some(&answers), 100)); + assert!(!check(&set, &question, "yes", &sig, 200, Some(&json!({"Which?": "B"})), 100)); + assert!(!check(&set, &question, "yes", &sig, 200, None, 100)); +} + +#[test] +fn removing_a_mac_from_the_file_stops_its_signatures() { + let k = key(); + let (set, path) = enrolled(&k, "rm"); + let good = sign(&k, &parts("call-1", "yes", 200, "")); + assert!(check(&set, &tool_ask(), "yes", &good, 200, None, 100)); + assert_eq!(set.view(&json!({}))["macs"][0]["id"], "mac-1"); + write(&path, json!([])); + // The reload of `host approvals rm`; a check after it reads the change. + assert_eq!(set.view(&json!({"reload": true}))["macs"], json!([])); + assert!(!check(&set, &tool_ask(), "yes", &good, 200, None, 100)); +} + +/// Like `ApproverSet`'s: a reload that drops the Mac while its signed answer +/// is being committed returns only after that commit, and from then on the +/// same signature counts nothing. +#[test] +fn a_reload_during_a_commit_waits_for_it_and_then_the_signature_counts_nothing() { + use std::time::Duration; + let k = key(); + let (set, path) = enrolled(&k, "race"); + let set = std::sync::Arc::new(set); + let good = sign(&k, &parts("call-1", "yes", 200, "")); + let decision = json!({"approver": "mac-1", "expiresAt": 200, "signature": good}); + let (reloaded, returned) = std::sync::mpsc::channel(); + let reloader = set.clone(); + let ask = tool_ask(); + let committed = + set.verifies_then("inst", "s1", "p1", &ask, "yes", "n1", None, &decision, 100, || { + write(&path, json!([])); + std::thread::spawn(move || { + reloader.view(&json!({"reload": true})); + let _ = reloaded.send(()); + }); + returned.recv_timeout(Duration::from_millis(300)).is_err() + }); + assert!(committed, "the reload returned while the answer was being committed"); + returned.recv_timeout(Duration::from_secs(10)).expect("the reload returns"); + assert!(!check(&set, &ask, "yes", &good, 200, None, 100)); +} + +#[cfg(unix)] +#[test] +fn a_file_others_may_write_enrolls_no_mac() { + use std::os::unix::fs::PermissionsExt; + let k = key(); + let (set, path) = enrolled(&k, "open"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)).unwrap(); + let good = sign(&k, &parts("call-1", "yes", 200, "")); + set.view(&json!({"reload": true})); + assert!(!check(&set, &tool_ask(), "yes", &good, 200, None, 100)); +} + +#[test] +fn only_a_valid_key_is_this_machines_own() { + let set = MacApprovers::default(); + let k = key(); + assert!(set.set_own(&json!({"id": "mac-1", "publicKey": "04"})).is_err()); + let r = set + .set_own(&json!({"id": "mac-1", "publicKey": hex(k.public_key().as_ref()), "name": "M"})) + .unwrap(); + assert_eq!(r["fingerprint"], fingerprint(k.public_key().as_ref())); + assert_eq!(set.view(&json!({}))["thisMac"]["id"], "mac-1"); +} diff --git a/cmux-tui/crates/acpmux/src/hub/mod.rs b/cmux-tui/crates/acpmux/src/hub/mod.rs index 02a43faaf01e..259d0f6e38c1 100644 --- a/cmux-tui/crates/acpmux/src/hub/mod.rs +++ b/cmux-tui/crates/acpmux/src/hub/mod.rs @@ -115,6 +115,7 @@ pub use new_request::{ pub use paging::{EventFilter, EventPage, MAX_PAGE_BYTES, page_bytes}; pub use spawn::expand_env_value; pub mod approvers; +pub mod mac_approvers; mod own_host; mod peer_cache; mod peer_lease; diff --git a/cmux-tui/crates/acpmux/src/hub/permissions.rs b/cmux-tui/crates/acpmux/src/hub/permissions.rs index f1ff368e95d2..223c1035b096 100644 --- a/cmux-tui/crates/acpmux/src/hub/permissions.rs +++ b/cmux-tui/crates/acpmux/src/hub/permissions.rs @@ -953,6 +953,7 @@ impl Hub { self.person.approver_spends( &session.id, permission_id, + &p.request, o, n, answers.as_ref(), @@ -1094,6 +1095,7 @@ impl Hub { && self.person.approver_proves( &session.id, permission_id, + &p.request, o, nonce, answers, diff --git a/cmux-tui/crates/acpmux/src/hub/person.rs b/cmux-tui/crates/acpmux/src/hub/person.rs index 09a580d808db..ea21b3ddcad1 100644 --- a/cmux-tui/crates/acpmux/src/hub/person.rs +++ b/cmux-tui/crates/acpmux/src/hub/person.rs @@ -370,6 +370,8 @@ pub struct PersonGate { spent: StdMutex>, /// The person's phones that may allow with a signature (`approvers.rs`). pub approvers: super::approvers::ApproverSet, + /// The Macs whose app may allow here over the peer link (`mac_approvers.rs`). + pub macs: super::mac_approvers::MacApprovers, /// The folders of the workspaces the person's app lists now /// (`_acpmux/workspace_roots_set`, memory only): a Web session may start /// there too (`remote_guard::web_roots`, which drops `/`, home and its @@ -540,51 +542,77 @@ impl PersonGate { cmux_local_auth::tokens_match(proof, &expected) && !self.spent(nonce) } - /// Whether `signed` is an enrolled approver's signature for this exact - /// prompt, option, nonce and answers on this daemon run, unexpired, with - /// a nonce that has not counted yet. It spends nothing. + /// Whether `signed` is an enrolled approver's (a phone's, or an enrolled + /// Mac's, `mac_approvers.rs`) signature for this exact prompt `request`, + /// option, nonce and answers on this daemon run, unexpired, with a nonce + /// that has not counted yet. It spends nothing. + #[allow(clippy::too_many_arguments)] pub fn approver_proves( &self, session: &str, permission: &str, + request: &Value, option: &str, nonce: &str, answers: Option<&Value>, signed: &Value, ) -> bool { - self.approvers.verifies( - self.instance(), - session, - permission, - option, - nonce, - answers, - signed, - super::approvers::unix_now(), - ) && !self.spent(nonce) + let now = super::approvers::unix_now(); + let instance = self.instance(); + (self + .approvers + .verifies(instance, session, permission, option, nonce, answers, signed, now) + || self.macs.verifies_then( + instance, + session, + permission, + request, + option, + nonce, + answers, + signed, + now, + || true, + )) + && !self.spent(nonce) } /// `approver_proves`, and the answer counting: its nonce spent while the approver is still - /// enrolled (`ApproverSet::verifies_then`), so a revocation that has returned stops it. - /// Called under the prompt's lock by the answer that counts. + /// enrolled (`ApproverSet::verifies_then`, `MacApprovers::verifies_then`), so a revocation + /// that has returned stops it. Called under the prompt's lock by the answer that counts. + #[allow(clippy::too_many_arguments)] pub fn approver_spends( &self, session: &str, permission: &str, + request: &Value, option: &str, nonce: &str, answers: Option<&Value>, signed: &Value, ) -> bool { + let now = super::approvers::unix_now(); + let instance = self.instance(); self.approvers.verifies_then( - self.instance(), + instance, session, permission, option, nonce, answers, signed, - super::approvers::unix_now(), + now, + || self.spend_decision(nonce), + ) || self.macs.verifies_then( + instance, + session, + permission, + request, + option, + nonce, + answers, + signed, + now, || self.spend_decision(nonce), ) } @@ -870,6 +898,8 @@ impl Hub { method::MUX_APPROVERS_SET | method::MUX_APPROVERS => { Err(person_required("changing or reading trusted approvers")) } + // Which key this machine's `host enroll` enrolls on a peer (`mac_approvers.rs`). + method::MUX_MAC_APPROVER_SET => Err(person_required("setting this Mac's approver key")), // Which folders a remote connection may start sessions in. method::MUX_WORKSPACE_ROOTS_SET => { Err(person_required("changing the workspace folders")) @@ -1051,13 +1081,21 @@ impl Hub { return false; }; let Some(pid) = params.get("permissionId").and_then(Value::as_str) else { return false }; - let pending_nonce = { + let (pending_nonce, request) = { let state = s.permissions.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let Some(p) = state.pending.get(pid) else { return false }; - p.decision_nonce.clone() + (p.decision_nonce.clone(), p.request.clone()) }; cmux_local_auth::tokens_match(nonce, &pending_nonce) - && self.person.approver_proves(&s.id, pid, option, nonce, params.get("answers"), signed) + && self.person.approver_proves( + &s.id, + pid, + &request, + option, + nonce, + params.get("answers"), + signed, + ) } /// Whether a relayed allow carries the person's decision proof for the diff --git a/cmux-tui/crates/acpmux/src/protocol/mod.rs b/cmux-tui/crates/acpmux/src/protocol/mod.rs index d78a6bc255a7..e211db984c9f 100644 --- a/cmux-tui/crates/acpmux/src/protocol/mod.rs +++ b/cmux-tui/crates/acpmux/src/protocol/mod.rs @@ -267,6 +267,9 @@ pub fn registry() -> Vec { // The person's phones and their chats (`hub/approvers.rs`, `hub/person.rs`). untyped(method::MUX_APPROVERS, Request), untyped(method::MUX_APPROVERS_SET, Request), + // Enrolled Macs (`hub/mac_approvers.rs`). + untyped(method::MUX_MAC_APPROVER_SET, Request), + untyped(method::MUX_MAC_APPROVERS, Request), untyped(method::MUX_NEW_CHAT_PICKS_SET, Request), untyped(method::MUX_WORKSPACE_ROOTS_SET, Request), untyped(method::MUX_SECRET_REQUEST, Request), diff --git a/cmux-tui/crates/acpmux/src/rpc.rs b/cmux-tui/crates/acpmux/src/rpc.rs index 0d81347ff979..d72864e355b4 100644 --- a/cmux-tui/crates/acpmux/src/rpc.rs +++ b/cmux-tui/crates/acpmux/src/rpc.rs @@ -317,6 +317,11 @@ pub mod method { /// shows a remote device so its New Agent Chat starts where the app's does. pub const MUX_NEW_CHAT_PICKS_SET: &str = "_acpmux/new_chat_picks_set"; pub const MUX_APPROVERS: &str = "_acpmux/approvers"; + /// The person's app: this machine's own approver key, which `host enroll` + /// enrolls on a peer (`hub/mac_approvers.rs`). + pub const MUX_MAC_APPROVER_SET: &str = "_acpmux/mac_approver_set"; + /// The Macs enrolled here and this machine's own key; `reload` reads the file again. + pub const MUX_MAC_APPROVERS: &str = "_acpmux/mac_approvers"; pub const MUX_PERMISSION_GROUPS: &str = "_acpmux/permission_groups"; pub const MUX_PERMISSION_GROUP_RESPOND: &str = "_acpmux/permission_group_respond"; pub const MUX_PERMISSION_CHAT_REVOKE: &str = "_acpmux/permission_chat_revoke"; diff --git a/cmux-tui/crates/acpmux/src/server/requests.rs b/cmux-tui/crates/acpmux/src/server/requests.rs index 1bb3b2ec1015..e451f92e99fd 100644 --- a/cmux-tui/crates/acpmux/src/server/requests.rs +++ b/cmux-tui/crates/acpmux/src/server/requests.rs @@ -684,6 +684,15 @@ async fn dispatch_request( method::MUX_APPROVERS_SET | method::MUX_APPROVERS => { Err(crate::hub::person::person_required("changing or reading trusted approvers")) } + method::MUX_MAC_APPROVER_SET if conn.is_person() => hub.person.macs.set_own(¶ms), + method::MUX_MAC_APPROVER_SET => { + Err(crate::hub::person::person_required("setting this Mac's approver key")) + } + // This machine's own: never a remote connection's (a peer's included). + method::MUX_MAC_APPROVERS if conn.origin.web_class() => Err(RpcError::invalid_params( + "the enrolled Macs are read only on this machine, over its unix socket or app", + )), + method::MUX_MAC_APPROVERS => Ok(hub.person.macs.view(¶ms)), method::MUX_WORKSPACE_ROOTS_SET if conn.is_person() => { hub.person.set_workspace_roots(¶ms) } diff --git a/cmux-tui/crates/acpmux/tests/peer_integration.rs b/cmux-tui/crates/acpmux/tests/peer_integration.rs index 6e503c101725..721aa1b70369 100644 --- a/cmux-tui/crates/acpmux/tests/peer_integration.rs +++ b/cmux-tui/crates/acpmux/tests/peer_integration.rs @@ -1303,6 +1303,159 @@ async fn a_peer_decides_a_relayed_allow_with_its_own_decision_proof() { assert_eq!(decided["msg"]["answeredBy"], by, "{decided}"); } +/// What an enrolled Mac signs (`hub/mac_approvers.rs`), written out here so +/// the test states the wire contract: ECDSA P-256 / SHA-256, raw r || s as +/// hex, over the domain and each part after a NUL. +#[allow(clippy::too_many_arguments)] +fn mac_signature( + key: &ring::signature::EcdsaKeyPair, + instance: &str, + mac: &str, + session: &str, + permission: &str, + tool_call: &str, + option: &str, + nonce: &str, + expires: u64, +) -> String { + let expires = expires.to_string(); + let mut message = b"acpmux-mac-approver-decision-v1".to_vec(); + for part in [instance, mac, session, permission, tool_call, option, nonce, expires.as_str(), ""] + { + message.push(0); + message.extend_from_slice(part.as_bytes()); + } + let sig = key.sign(&ring::rand::SystemRandom::new(), &message).unwrap(); + sig.as_ref().iter().map(|b| format!("{b:02x}")).collect() +} + +/// Peer-route approvals: the Mac's app signs a one-time Allow for a prompt of +/// a peer (B) that it reaches through its own daemon (A), and only the signed +/// answer crosses the link. B counts it against the Mac key enrolled in its +/// `mac-approvers.json`, once, for that prompt and tool call; an unsigned +/// Allow is refused, a deny from any client counts, and a Mac removed from +/// the file (with the reload `host approvals rm` sends) allows nothing more. +/// The session's policy and mode stay as they were. +#[tokio::test] +async fn a_peer_counts_an_enrolled_macs_signed_one_time_allow_until_it_is_unenrolled() { + use ring::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair}; + let rng = ring::rand::SystemRandom::new(); + let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap(); + let mac = + EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng).unwrap(); + let public: String = mac.public_key().as_ref().iter().map(|b| format!("{b:02x}")).collect(); + let home = std::env::temp_dir().join(format!("api-mac-file-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).unwrap(); + let file = home.join(acpmux::hub::mac_approvers::FILE); + let write = |macs: Value| { + acpmux::config::write_atomic(&file, json!({"macs": macs}).to_string().as_bytes()).unwrap() + }; + write(json!([{"id": "mac-1", "publicKey": public, "name": "Leo's Mac"}])); + + let b = hub(config(PermissionPolicy::Ask)).await; + b.person.macs.open(file.clone()); + let (port, peer_token) = listen_with_peer_token(b.clone(), "macs").await; + let mut cb = plain(b.clone()).await; + let s = cb + .call( + method::SESSION_NEW, + json!({"cwd": std::env::temp_dir(), "mcpServers": [], "_meta": {"acpmux": {"name": "mac"}}}), + ) + .await + .unwrap(); + let remote_id = s["sessionId"].as_str().unwrap().to_owned(); + let before = cb.call(method::MUX_INFO, json!({"sessionId": remote_id})).await.unwrap(); + let a = hub(config(PermissionPolicy::Ask)).await; + a.add_peer("b", &format!("ws://127.0.0.1:{port}"), Some("tok".into()), Some(peer_token), true) + .await + .unwrap(); + let mut app = client(a.clone()).await; + let mut relay = plain(a.clone()).await; + app.call(method::MUX_ATTACH, json!({"sessionId": remote_id, "limit": 0})).await.unwrap(); + let ask = async |app: &mut C, text: &str, after: Option<&str>| { + app.next += 1; + let line = Message::request( + app.next, + method::SESSION_PROMPT, + json!({"sessionId": remote_id, "prompt": [{"type": "text", "text": text}]}), + ); + app.tx.send(line.to_line()).await.unwrap(); + app.wait(method::MUX_PERMISSION_PENDING, |p| { + p["sessionId"] == remote_id && after.is_none_or(|a| p["permissionId"] != a) + }) + .await + }; + let pending = ask(&mut app, "ask: rm", None).await; + let perm = pending["permissionId"].as_str().unwrap().to_owned(); + let nonce = pending["decisionNonce"].as_str().unwrap().to_owned(); + let instance = pending["personInstance"].as_str().unwrap().to_owned(); + let tool_call = pending["request"]["toolCall"]["toolCallId"].as_str().unwrap().to_owned(); + let exp = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 120; + let respond = |perm: &str, nonce: &str, sig: &str| { + json!({"sessionId": remote_id, "permissionId": perm, "optionId": "yes", + "_meta": {"acpmux": {"decisionNonce": nonce, + "approverDecision": {"approver": "mac-1", "expiresAt": exp, "signature": sig}}}}) + }; + + // The person on A, unsigned: refused on B (A holds no key of B's). + let unsigned = json!({"sessionId": remote_id, "permissionId": perm, "optionId": "yes"}); + let e = app.call(method::MUX_PERMISSION_RESPOND, unsigned).await.unwrap_err(); + assert!(e.contains("approve this on the Mac app"), "{e}"); + // Signed for another tool call: refused. + let other = + mac_signature(&mac, &instance, "mac-1", &remote_id, &perm, "t9", "yes", &nonce, exp); + let e = app.call(method::MUX_PERMISSION_RESPOND, respond(&perm, &nonce, &other)).await; + assert!(e.unwrap_err().contains("approve this on the Mac app")); + // The Mac's signature for this prompt and tool call: the allow counts. + let good = + mac_signature(&mac, &instance, "mac-1", &remote_id, &perm, &tool_call, "yes", &nonce, exp); + app.call(method::MUX_PERMISSION_RESPOND, respond(&perm, &nonce, &good)).await.unwrap(); + let decided = app + .wait(method::MUX_EVENT, |p| { + p["kind"] == "permission_decision" && p["sessionId"] == remote_id + }) + .await; + assert_eq!(decided["msg"]["outcome"]["optionId"], "yes", "{decided}"); + + // Replayed on the next prompt: refused (its nonce and permission are spent). + let next = ask(&mut app, "ask: again", Some(&perm)).await; + let perm2 = next["permissionId"].as_str().unwrap().to_owned(); + let nonce2 = next["decisionNonce"].as_str().unwrap().to_owned(); + let e = app.call(method::MUX_PERMISSION_RESPOND, respond(&perm2, &nonce, &good)).await; + assert!(e.unwrap_err().contains("approve this on the Mac app")); + + // Unenrolled (`host approvals rm`: the file, then the reload): a fresh, + // valid signature counts nothing; a deny from a client that is no person does. + write(json!([])); + b.person.macs.view(&json!({"reload": true})); + let fresh = mac_signature( + &mac, &instance, "mac-1", &remote_id, &perm2, &tool_call, "yes", &nonce2, exp, + ); + let e = app.call(method::MUX_PERMISSION_RESPOND, respond(&perm2, &nonce2, &fresh)).await; + assert!(e.unwrap_err().contains("approve this on the Mac app")); + relay + .call( + method::MUX_PERMISSION_RESPOND, + json!({"sessionId": remote_id, "permissionId": perm2, "optionId": "no"}), + ) + .await + .unwrap(); + let decided = app + .wait(method::MUX_EVENT, |p| { + p["kind"] == "permission_decision" + && p["sessionId"] == remote_id + && p["msg"]["permissionId"] == perm2 + }) + .await; + assert_eq!(decided["msg"]["outcome"]["optionId"], "no", "{decided}"); + let after = cb.call(method::MUX_INFO, json!({"sessionId": remote_id})).await.unwrap(); + assert_eq!(after["policy"], before["policy"], "{after}"); + assert_eq!(after["modes"], before["modes"], "{after}"); + let _ = std::fs::remove_dir_all(&home); +} + /// cx-0q7o slice 2b: every answer for a peer's prompt goes to the peer, /// which is the only one that decides its prompts: a client that is not the /// person here may deny (as on the peer itself). A forwarded answer carries a diff --git a/webviews/src/agent-session/acpmux/protocol/generated/methods.ts b/webviews/src/agent-session/acpmux/protocol/generated/methods.ts index ae6c1ccc9f53..3aff68f4e6b6 100644 --- a/webviews/src/agent-session/acpmux/protocol/generated/methods.ts +++ b/webviews/src/agent-session/acpmux/protocol/generated/methods.ts @@ -127,6 +127,8 @@ export interface AcpmuxRequests { "_acpmux/person_holds": { params: unknown; result: unknown }; "_acpmux/approvers": { params: unknown; result: unknown }; "_acpmux/approvers_set": { params: unknown; result: unknown }; + "_acpmux/mac_approver_set": { params: unknown; result: unknown }; + "_acpmux/mac_approvers": { params: unknown; result: unknown }; "_acpmux/new_chat_picks_set": { params: unknown; result: unknown }; "_acpmux/workspace_roots_set": { params: unknown; result: unknown }; "_acpmux/secret_request": { params: unknown; result: unknown }; @@ -373,6 +375,8 @@ export const ACPMUX_METHODS: ReadonlyArray<{ name: string; kind: AcpmuxMethodKin { name: "_acpmux/person_holds", kind: "request", acp: false, typed: false }, { name: "_acpmux/approvers", kind: "request", acp: false, typed: false }, { name: "_acpmux/approvers_set", kind: "request", acp: false, typed: false }, + { name: "_acpmux/mac_approver_set", kind: "request", acp: false, typed: false }, + { name: "_acpmux/mac_approvers", kind: "request", acp: false, typed: false }, { name: "_acpmux/new_chat_picks_set", kind: "request", acp: false, typed: false }, { name: "_acpmux/workspace_roots_set", kind: "request", acp: false, typed: false }, { name: "_acpmux/secret_request", kind: "request", acp: false, typed: false },