fix: refuse to connect an agent from a translocated macOS app

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.
This commit is contained in:
swaits committed 2026-11-05 08:29:16 -07:00
1 parent 53a233d687
commit e2754898d8
3 files changed
+176 -1

No files matched your search

+133
View File
@@ -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"
);
}
+6 -1
View File
@@ -1262,6 +1262,12 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
"{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)"
)
})?;
// A translocated macOS app's path (P11a step 6) is not a place to point
// an agent: the copy goes away with the app.
let exe = crate::start::this_executable()?;
if let Some(why) = crate::start::translocation_refusal(&exe) {
return Err(why.to_string());
}
let mut lines = Vec::new();
// The token: kept in its file, or made now.
let found =
@@ -1279,7 +1285,6 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
));
}
// The agent's own list: the books' entry made anew.
let exe = crate::start::this_executable()?;
let name = format!("wanderbooks-{book}");
let mut serve: Vec<std::ffi::OsString> = vec![
exe.into_os_string(),
+37
View File
@@ -83,6 +83,21 @@ pub fn this_executable() -> Result<PathBuf, String> {
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<std::ffi::OsString> {
@@ -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))]