From e2754898d8378be4cf94508c478eec98d407f406 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 5 Nov 2026 08:29:16 -0700 Subject: [PATCH] fix: refuse to connect an agent from a translocated macOS app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `.dmg` downloaded from the internet keeps its quarantine bit, so Gatekeeper may run the app from a read-only copy under `/private/var/folders/…/AppTranslocation/…` that vanishes when the app closes. Connected from there, an agent would be pointed at a path that will not exist the next time it runs. Connect refuses instead, before a token is made or an agent's list is touched, and says to move the app out of Downloads and open it again (P11a step 6). The rule is on the path the program would give the agent (`start::translocation_refusal` over `this_executable`), so the same check covers the CLI's `connect` and the viewer's Connect. The test stands in for Gatekeeper's copy on any Unix by naming a file under an `AppTranslocation` path in `APPIMAGE`; the agent never runs and no books home is made, while the same program at an ordinary path connects. A unit test holds the marker to a whole path part, not letters in a name. --- crates/ledger-cli/tests/translocation.rs | 133 +++++++++++++++++++++++ crates/ledger-host/src/commands.rs | 7 +- crates/ledger-host/src/start.rs | 37 +++++++ 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 crates/ledger-cli/tests/translocation.rs diff --git a/crates/ledger-cli/tests/translocation.rs b/crates/ledger-cli/tests/translocation.rs new file mode 100644 index 0000000..c4fed96 --- /dev/null +++ b/crates/ledger-cli/tests/translocation.rs @@ -0,0 +1,133 @@ +//! A macOS app run translocated refuses to connect an agent (P11a step 6): +//! Gatekeeper runs a quarantined download from a read-only copy under +//! `/private/var/folders/…/AppTranslocation/…`, and that path goes away +//! with the app; an agent pointed at it would lose the books. The owner +//! moves the app out of Downloads first. +//! +//! The check is on the path the program would give the agent +//! (`ledger_host::start::this_executable`), so a test can stand in for +//! macOS on any Unix: `APPIMAGE` names a file there, and a path with an +//! `AppTranslocation` part is refused just as the real one is. + +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const KNOBS: &str = + "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300"; + +struct Home { + dir: tempfile::TempDir, +} + +impl Home { + fn new() -> Self { + let dir = tempfile::Builder::new() + .prefix("wb-translocation-") + .tempdir() + .unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + .args(["setup", "--home"]) + .arg(dir.path()) + .env_remove("XDG_DATA_HOME") + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + Self { dir } + } + + fn root(&self) -> PathBuf { + ledger_store::root::default_root(self.dir.path()) + } + + /// The program with a root of its own and `PATH` holding the stand-in + /// agent. Nothing here touches the account's real data root. + fn cmd(&self, mode: &str, path: &Path) -> Command { + let mut c = Command::new(env!("CARGO_BIN_EXE_wanderbooks")); + c.arg(mode) + .arg("--root") + .arg(self.root()) + .args(["--test-knobs", KNOBS]) + .env_clear() + .env("PATH", format!("{}:/usr/bin:/bin", path.display())) + .env("HOME", self.dir.path()) + .stdin(Stdio::null()); + c + } +} + +/// A stand-in `claude` that leaves a mark when it is run, so the test can +/// say the agent never was. +fn fake_agent(home: &Home) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let bin = home.dir.path().join("agent-bin"); + std::fs::create_dir_all(&bin).unwrap(); + let script = bin.join("claude"); + std::fs::write( + &script, + format!( + "#!/bin/sh\ntouch '{}'\nexit 0\n", + home.dir.path().join("agent-ran").display() + ), + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + bin +} + +#[test] +fn translocated_app_refuses_to_connect() { + let home = Home::new(); + let out = home + .cmd("books", Path::new("/nonexistent")) + .args(["create", "beta"]) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + + let bin = fake_agent(&home); + // What macOS hands the program from Gatekeeper's copy: a path under + // AppTranslocation. A bare file is enough (this_executable only asks + // that it is one). + let translocated = home.dir.path().join( + "private/var/folders/x/AppTranslocation/uuid/d/Wanderbooks.app/Contents/MacOS/wanderbooks", + ); + std::fs::create_dir_all(translocated.parent().unwrap()).unwrap(); + std::fs::write(&translocated, b"").unwrap(); + let out = home + .cmd("connect", &bin) + .env("APPIMAGE", &translocated) + .args(["claude", "--book", "beta"]) + .output() + .unwrap(); + assert!( + !out.status.success(), + "a translocated app connected an agent" + ); + let said = String::from_utf8_lossy(&out.stderr); + assert!(said.contains("move wanderbooks out of Downloads"), "{said}"); + // Refused before anything was done: no agent ran, no books home was + // made for a path that will vanish. + assert!(!home.dir.path().join("agent-ran").exists(), "the agent ran"); + assert!( + !home.dir.path().join("Documents/Wanderbooks/beta").exists(), + "a books home was made" + ); + + // The same program at an ordinary path connects: it is the + // translocation, not the agent or the books, that was refused. + let ordinary = home.dir.path().join("Wanderbooks.AppImage"); + std::fs::write(&ordinary, b"").unwrap(); + let out = home + .cmd("connect", &bin) + .env("APPIMAGE", &ordinary) + .args(["claude", "--book", "beta"]) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + assert!( + home.dir.path().join("agent-ran").exists(), + "the agent wasn't run" + ); +} diff --git a/crates/ledger-host/src/commands.rs b/crates/ledger-host/src/commands.rs index aef7d0a..72cb68f 100644 --- a/crates/ledger-host/src/commands.rs +++ b/crates/ledger-host/src/commands.rs @@ -1262,6 +1262,12 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result Result = vec![ exe.into_os_string(), diff --git a/crates/ledger-host/src/start.rs b/crates/ledger-host/src/start.rs index 931fc2e..a31a16c 100644 --- a/crates/ledger-host/src/start.rs +++ b/crates/ledger-host/src/start.rs @@ -83,6 +83,21 @@ pub fn this_executable() -> Result { Ok(exe) } +/// Why this program can't point an agent at itself, if it can't (P11a +/// step 6): macOS runs a quarantined app from a read-only copy under +/// `/private/var/folders/…/AppTranslocation/…`, a path that goes away when +/// the app closes. The app may run, but an agent connected from it would +/// lose the books, so the person moves it out of Downloads first. +/// `exe` is the path this program would give the agent +/// ([`this_executable`]). +pub fn translocation_refusal(exe: &Path) -> Option<&'static str> { + exe.components() + .any(|part| part.as_os_str() == "AppTranslocation") + .then_some( + "wanderbooks is running from the copy macOS makes of a quarantined download, and that copy goes away when the app closes: move wanderbooks out of Downloads (Applications, for example), open it there, and connect the agent again", + ) +} + /// The AppImage this process runs from: `APPIMAGE` when it is an /// absolute path to a file. pub fn own_appimage(value: Option<&std::ffi::OsStr>) -> Option { @@ -633,6 +648,28 @@ mod tests { let exe = this_executable().unwrap(); assert!(exe.is_file(), "{exe:?}"); } + + #[test] + fn only_a_translocated_path_refuses_an_agent() { + assert_eq!( + translocation_refusal(Path::new( + "/Applications/Wanderbooks.app/Contents/MacOS/wanderbooks" + )), + None + ); + let copy = translocation_refusal(Path::new( + "/private/var/folders/xy/T/AppTranslocation/1234/d/Wanderbooks.app/Contents/MacOS/wanderbooks", + )) + .expect("a translocated path is refused"); + assert!(copy.contains("out of Downloads"), "{copy}"); + // A part that only shares letters is not the marker macOS makes. + assert_eq!( + translocation_refusal(Path::new( + "/Applications/AppTranslocation Notes/wanderbooks" + )), + None + ); + } } #[cfg(all(test, windows))]