From e68e3d14b8ad66a16d12531eebb4e69d4e92da14 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 27 Oct 2026 02:53:55 -0700 Subject: [PATCH] acpmux: a peer counts an enrolled Mac's signed one-time Allow (peer-route approvals 1/3) A chat the Mac's app reaches through its own daemon (--host) lives on a peer, which refused every Allow from the card: the app holds no key of that daemon's. The app now signs the Allow itself and only the signature crosses the peer link; the peer checks it against the Mac keys enrolled in its ACPMUX_HOME/mac-approvers.json (hub/mac_approvers.rs). - The signature binds the daemon run, the Mac, the session, the permission, the tool call id, the option, the prompt's nonce, the expiry (at most 300 s) and the answers' digest; it counts once (the spent-nonce store). - Only a one-time allow of a tool ask or a question: never a lasting allow, a plan, a mode switch or a folder trust. Unsigned allows stay refused; denies count from anyone. - No RPC enrolls a key: only the file, which host approvals writes. A reload (_acpmux/mac_approvers {reload: true}) takes the set's lock, so removing a Mac waits for an answer being committed and then stops its signatures. - _acpmux/mac_approver_set (the person only, memory only) gives this machine's own app key, for host enroll to enroll on a peer. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a --- .../crates/acpmux/spec/acpmux-protocol.json | 12 + cmux-tui/crates/acpmux/src/daemon.rs | 2 + cmux-tui/crates/acpmux/src/hub/approvers.rs | 77 ++--- .../crates/acpmux/src/hub/mac_approvers.rs | 269 ++++++++++++++++++ .../acpmux/src/hub/mac_approvers_tests.rs | 258 +++++++++++++++++ cmux-tui/crates/acpmux/src/hub/mod.rs | 1 + cmux-tui/crates/acpmux/src/hub/permissions.rs | 2 + cmux-tui/crates/acpmux/src/hub/person.rs | 78 +++-- cmux-tui/crates/acpmux/src/protocol/mod.rs | 3 + cmux-tui/crates/acpmux/src/rpc.rs | 5 + cmux-tui/crates/acpmux/src/server/requests.rs | 9 + .../crates/acpmux/tests/peer_integration.rs | 153 ++++++++++ .../acpmux/protocol/generated/methods.ts | 4 + 13 files changed, 819 insertions(+), 54 deletions(-) create mode 100644 cmux-tui/crates/acpmux/src/hub/mac_approvers.rs create mode 100644 cmux-tui/crates/acpmux/src/hub/mac_approvers_tests.rs 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 },