From dc171c784eacece6ae0820c6fd7faac3bbbb198f Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:52:50 -0700 Subject: [PATCH 1/4] shell: a process app's program stays readable inside the OctoSense home (G6 regression) G6 (#156) closes the OctoSense home last in the process sandbox. Desktop builds put process apps' programs in /build, so the Terminal lost read access to its own bundle: Metal's device setup then crashed at start (SIGSEGV in IOSurface/CFBundleCopyBundleURL, 5 crash reports on macOS). Reproduced with the generated profile (exit 139) and fixed by re-allowing the program read-only after the private rules (exit 0). - macOS: after the private deny and the app's own jail, re-allow read only the program paths that lie inside a private directory. - Linux (Landlock): program rules are no longer dropped inside private directories, like the app's own jail and secrets. - Test: a program inside the OctoSense home is readable after the deny, read-only, and nothing else is reopened. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/sandbox/linux.rs | 4 +++- crates/shell/src/sandbox/macos.rs | 8 ++++++++ crates/shell/src/sandbox/tests.rs | 20 ++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/shell/src/sandbox/linux.rs b/crates/shell/src/sandbox/linux.rs index d0248122..3cf3181e 100644 --- a/crates/shell/src/sandbox/linux.rs +++ b/crates/shell/src/sandbox/linux.rs @@ -171,7 +171,9 @@ pub fn rules(policy: &Policy, abi: u32, via_cargo: bool) -> Vec { let own = [policy.jail.clone(), policy.secrets.clone()]; let mut split = Vec::new(); for rule in out { - if own.contains(&rule.path) { + // Its own jail and secrets, and its program (desktop builds live in + // `/build`), keep their rights inside the private dirs. + if own.contains(&rule.path) || policy.program.contains(&rule.path) { split.push(rule); } else { around_private(rule, &policy.private, &mut split); diff --git a/crates/shell/src/sandbox/macos.rs b/crates/shell/src/sandbox/macos.rs index 7014d6c5..f4dc7ef3 100644 --- a/crates/shell/src/sandbox/macos.rs +++ b/crates/shell/src/sandbox/macos.rs @@ -96,6 +96,14 @@ pub fn profile(policy: &Policy) -> String { out.push_str(&format!("(allow file-read-metadata{lits})\n")); } out.push_str(&format!(";; only its own jail and secrets inside them\n(allow file-read* file-write*{})\n", subpaths(&own))); + // Its program may live inside them too (desktop builds go to + // `/build`): without this, Metal cannot even read the + // program's own bundle and the app crashes at start. + let program_inside: Vec = + program.iter().filter(|p| private.iter().any(|root| p.starts_with(root))).cloned().collect(); + if !program_inside.is_empty() { + out.push_str(&format!(";; its program, read-only, even inside them\n(allow file-read*{})\n", subpaths(&program_inside))); + } } if policy.network == Network::None { out.push_str(&format!( diff --git a/crates/shell/src/sandbox/tests.rs b/crates/shell/src/sandbox/tests.rs index 75a01f89..49cd69f7 100644 --- a/crates/shell/src/sandbox/tests.rs +++ b/crates/shell/src/sandbox/tests.rs @@ -385,3 +385,23 @@ fn the_allow_list_never_admits_a_secret_shaped_name() { assert!(inherited_var(name), "{name}"); } } + +#[test] +fn a_program_inside_the_octosense_home_stays_readable() { + // The desktop builds process apps into `/build`: the + // private deny must not take the app's own program away (the Terminal + // crashed at start, unable to read its own bundle). + let root = Path::new("/nonexistent/home"); + let mut p = home_rw_with_octosense_home(root); + let build = root.join(".octosense/build/makepad"); + p.program = vec![build.clone()]; + let text = macos::profile(&p); + let deny = text.find("(deny file-read* file-write* (subpath \"/nonexistent/home/.octosense\")").expect(&text); + let program = text.rfind(&format!("(allow file-read* (subpath \"{}\"))", build.display())).expect(&text); + assert!(deny < program, "its program is readable after the private deny\n{text}"); + assert!(!text[deny..].contains(&format!("(allow file-read* file-write* (subpath \"{}\"))", build.display())), "read-only, never writable\n{text}"); + // Nothing else inside the private dirs is reopened. + let mut outside = p.clone(); + outside.program = vec![PathBuf::from("/nonexistent/programs")]; + assert!(!macos::profile(&outside).contains("even inside them")); +} From 3619f0db4440c6f404046b4a8bf49c500236c750 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:56:15 -0700 Subject: [PATCH 2/4] shell: reopen a program path only when it holds no private data (review) Review on #178: `program` also carries the checkout and the cargo cache, and `starts_with` matched a path equal to a private root, so a dev setup whose checkout or target dir is the OctoSense home would have reopened the whole home read-only (peer tokens, secrets, other apps' jails). - sandbox::program_reopenable: only a path strictly inside a private directory that neither is nor contains one, and is not inside a sensitive one (apps, secrets, the kernel's home and core dir, app-peers). - macOS: a program path inside a private dir that is not reopenable is skipped, logged, and recorded in the profile. - Linux: the kept program rule is masked to read+execute (never write, even under cargo); a skipped one is logged. - Tests: every unsafe shape is refused and nothing is reopened; the Linux rules keep read+execute only (Linux-only test). Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/sandbox/linux.rs | 19 +++++++++-- crates/shell/src/sandbox/macos.rs | 15 +++++--- crates/shell/src/sandbox/mod.rs | 34 ++++++++++++++++++ crates/shell/src/sandbox/tests.rs | 57 +++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 7 deletions(-) diff --git a/crates/shell/src/sandbox/linux.rs b/crates/shell/src/sandbox/linux.rs index 3cf3181e..35da6626 100644 --- a/crates/shell/src/sandbox/linux.rs +++ b/crates/shell/src/sandbox/linux.rs @@ -111,6 +111,11 @@ pub struct Rule { pub access: u64, } +/// Read and execute: what a program path keeps inside the private dirs. +pub fn read_exec() -> u64 { + read() | FS_EXECUTE +} + fn read() -> u64 { FS_READ_FILE | FS_READ_DIR } @@ -171,11 +176,19 @@ pub fn rules(policy: &Policy, abi: u32, via_cargo: bool) -> Vec { let own = [policy.jail.clone(), policy.secrets.clone()]; let mut split = Vec::new(); for rule in out { - // Its own jail and secrets, and its program (desktop builds live in - // `/build`), keep their rights inside the private dirs. - if own.contains(&rule.path) || policy.program.contains(&rule.path) { + if own.contains(&rule.path) { split.push(rule); + } else if policy.program.contains(&rule.path) + && policy.private.iter().any(|root| rule.path.starts_with(root)) + && super::program_reopenable(&rule.path, &policy.private) + { + // Its program inside the private dirs (desktop builds live in + // `/build`): read and execute only, never write. + split.push(Rule { path: rule.path, access: rule.access & rx }); } else { + if policy.program.contains(&rule.path) && policy.private.iter().any(|root| rule.path.starts_with(root)) { + makepad_widgets::log!("sandbox {}: program path {} holds private data; not reopened", policy.app, rule.path.display()); + } around_private(rule, &policy.private, &mut split); } } diff --git a/crates/shell/src/sandbox/macos.rs b/crates/shell/src/sandbox/macos.rs index f4dc7ef3..b753cc83 100644 --- a/crates/shell/src/sandbox/macos.rs +++ b/crates/shell/src/sandbox/macos.rs @@ -99,10 +99,17 @@ pub fn profile(policy: &Policy) -> String { // Its program may live inside them too (desktop builds go to // `/build`): without this, Metal cannot even read the // program's own bundle and the app crashes at start. - let program_inside: Vec = - program.iter().filter(|p| private.iter().any(|root| p.starts_with(root))).cloned().collect(); - if !program_inside.is_empty() { - out.push_str(&format!(";; its program, read-only, even inside them\n(allow file-read*{})\n", subpaths(&program_inside))); + let (reopened, kept_closed): (Vec, Vec) = program + .iter() + .filter(|p| private.iter().any(|root| p.starts_with(root))) + .cloned() + .partition(|p| super::program_reopenable(p, &private)); + for path in &kept_closed { + makepad_widgets::log!("sandbox {}: program path {} holds private data; not reopened", policy.app, path.display()); + out.push_str(&format!(";; not reopened (it holds private data): {}\n", quote(path))); + } + if !reopened.is_empty() { + out.push_str(&format!(";; its program, read-only, even inside them\n(allow file-read*{})\n", subpaths(&reopened))); } } if policy.network == Network::None { diff --git a/crates/shell/src/sandbox/mod.rs b/crates/shell/src/sandbox/mod.rs index e2b0cd3b..f2183994 100644 --- a/crates/shell/src/sandbox/mod.rs +++ b/crates/shell/src/sandbox/mod.rs @@ -221,6 +221,40 @@ impl Policy { } } +/// Whether a process app's program path may be reopened (read and execute +/// only) inside the host's private directories, where desktop builds put +/// programs (`/build`). Only a path strictly inside a private +/// directory that neither contains nor equals one, and that is not inside a +/// sensitive one (another app's jail or secrets, the kernel's home or core +/// dir, the peers' host tokens). A checkout or target dir that is the +/// OctoSense home itself is never reopened. +pub fn program_reopenable(program: &Path, private: &[PathBuf]) -> bool { + if !private.iter().any(|root| program != root && program.starts_with(root)) { + return false; + } + if private.iter().any(|dir| dir.starts_with(program)) { + return false; // it is, or contains, a private directory + } + // Sensitive: a private directory inside another one (apps, secrets, the + // kernel's core dir), and the kernel's home around its core dir. + let sensitive = |dir: &PathBuf| { + private.iter().any(|other| other != dir && dir.starts_with(other)) + || dir.file_name().is_some_and(|n| n == ".octos") + || private.contains(&dir.join(".octos")) + }; + if private.iter().any(|dir| sensitive(dir) && program.starts_with(dir)) { + return false; + } + // The peers' host tokens live directly in the OctoSense home. + !private.iter().any(|root| { + program + .strip_prefix(root) + .ok() + .and_then(|rel| rel.components().next()) + .is_some_and(|c| c.as_os_str() == "app-peers") + }) +} + /// The host's private directories for a sandbox: the OctoSense home, the /// apps and secrets roots (they may live elsewhere), the kernel's core dir, /// and the kernel home around a `.octos` core dir. Each resolved, without diff --git a/crates/shell/src/sandbox/tests.rs b/crates/shell/src/sandbox/tests.rs index 49cd69f7..9324afef 100644 --- a/crates/shell/src/sandbox/tests.rs +++ b/crates/shell/src/sandbox/tests.rs @@ -405,3 +405,60 @@ fn a_program_inside_the_octosense_home_stays_readable() { outside.program = vec![PathBuf::from("/nonexistent/programs")]; assert!(!macos::profile(&outside).contains("even inside them")); } + +#[test] +fn a_program_path_that_is_or_holds_private_data_is_never_reopened() { + // A dev setup whose checkout or target dir is the OctoSense home itself + // (or holds it), or a program inside a sensitive directory, must not + // reopen the home: peer tokens, other apps' jails and secrets, the kernel. + let root = Path::new("/nonexistent/home"); + let octo = root.join(".octosense"); + let p = home_rw_with_octosense_home(root); + for (program, why) in [ + (octo.clone(), "the OctoSense home itself"), + (root.to_path_buf(), "a directory holding the OctoSense home"), + (octo.join("apps/other/bin"), "another app's jail"), + (octo.join("secrets"), "the secrets root"), + (octo.join("app-peers"), "the peers' host tokens"), + (root.join("octos-home/.octos/bin"), "the kernel's core dir"), + (root.join("octos-home/tools"), "the kernel's home"), + ] { + assert!(!crate::sandbox::program_reopenable(&program, &p.private), "{why} must not be reopenable"); + let mut q = p.clone(); + q.program = vec![program.clone()]; + let text = macos::profile(&q); + let deny = text.find("(deny file-read* file-write* (subpath \"/nonexistent/home/.octosense\")").expect(&text); + assert!(!text[deny..].contains("even inside them"), "{why}: nothing reopened\n{text}"); + if program.starts_with(&octo) || program.starts_with(root.join("octos-home")) { + assert!(text[deny..].contains("not reopened (it holds private data)"), "{why}: the skip is recorded\n{text}"); + } + } + assert!(crate::sandbox::program_reopenable(&octo.join("build/makepad"), &p.private)); +} + +#[cfg(target_os = "linux")] +#[test] +fn the_linux_rules_keep_a_program_inside_the_home_read_and_execute_only() { + let scratch = Scratch::new("octoprog"); + let root = &scratch.0; + let octo = root.join(".octosense"); + std::fs::create_dir_all(octo.join("build/makepad")).unwrap(); + std::fs::create_dir_all(octo.join("apps/probe")).unwrap(); + let mut p = home_rw_with_octosense_home(root); + let build = octo.join("build/makepad"); + p.program = vec![build.clone()]; + // Under cargo the program gets every right elsewhere; inside the home it + // keeps read and execute only. + for via_cargo in [false, true] { + let rules = linux::rules(&p, 3, via_cargo); + let kept: Vec<_> = rules.iter().filter(|r| r.path == build).collect(); + assert!(!kept.is_empty(), "the program stays reachable (via_cargo={via_cargo})"); + let rx = linux::read_exec(); + for r in kept { + assert_eq!(r.access & !rx, 0, "read and execute only, never write (via_cargo={via_cargo}): {:#x}", r.access); + } + } + // The home itself as the program: dropped, nothing reopened. + p.program = vec![octo.clone()]; + assert!(linux::rules(&p, 3, true).iter().all(|r| r.path != octo), "the OctoSense home is never granted"); +} From 8b1c0acc7b3382f4560569e9cace8c02cda347f6 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:58:01 -0700 Subject: [PATCH 3/4] shell: Landlock rules compare real paths with the private dirs (review) Review on #178: macOS already resolves program and private paths before comparing; Linux compared raw spellings. Landlock follows links when it opens a rule's path, so a linked checkout or grant into the OctoSense home could slip past the containment check (this held for every rule in around_private, not only programs). The split step now resolves the rule paths, the program paths, the app's own jail and secrets, and the private dirs before comparing. Test: a checkout linked to the OctoSense home reopens nothing on macOS (and, on Linux, Landlock never grants the home through the link); a link to the build dir itself still resolves strictly inside and is reopened. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/sandbox/linux.rs | 18 ++++++++++++------ crates/shell/src/sandbox/tests.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/crates/shell/src/sandbox/linux.rs b/crates/shell/src/sandbox/linux.rs index 35da6626..5fb88fb4 100644 --- a/crates/shell/src/sandbox/linux.rs +++ b/crates/shell/src/sandbox/linux.rs @@ -173,23 +173,29 @@ pub fn rules(policy: &Policy, abi: u32, via_cargo: bool) -> Vec { add(program.clone(), all); } } - let own = [policy.jail.clone(), policy.secrets.clone()]; + // Compare real paths: Landlock follows links when it opens a rule's + // path, so a linked checkout or grant must not slip past the private + // directories by its spelling. + let private: Vec = policy.private.iter().map(|p| super::resolved(p)).collect(); + let program: Vec = policy.program.iter().map(|p| super::resolved(p)).collect(); + let own = [super::resolved(&policy.jail), super::resolved(&policy.secrets)]; let mut split = Vec::new(); for rule in out { + let rule = Rule { path: super::resolved(&rule.path), access: rule.access }; if own.contains(&rule.path) { split.push(rule); - } else if policy.program.contains(&rule.path) - && policy.private.iter().any(|root| rule.path.starts_with(root)) - && super::program_reopenable(&rule.path, &policy.private) + } else if program.contains(&rule.path) + && private.iter().any(|root| rule.path.starts_with(root)) + && super::program_reopenable(&rule.path, &private) { // Its program inside the private dirs (desktop builds live in // `/build`): read and execute only, never write. split.push(Rule { path: rule.path, access: rule.access & rx }); } else { - if policy.program.contains(&rule.path) && policy.private.iter().any(|root| rule.path.starts_with(root)) { + if program.contains(&rule.path) && private.iter().any(|root| rule.path.starts_with(root)) { makepad_widgets::log!("sandbox {}: program path {} holds private data; not reopened", policy.app, rule.path.display()); } - around_private(rule, &policy.private, &mut split); + around_private(rule, &private, &mut split); } } split diff --git a/crates/shell/src/sandbox/tests.rs b/crates/shell/src/sandbox/tests.rs index 9324afef..b2cfbb07 100644 --- a/crates/shell/src/sandbox/tests.rs +++ b/crates/shell/src/sandbox/tests.rs @@ -462,3 +462,32 @@ fn the_linux_rules_keep_a_program_inside_the_home_read_and_execute_only() { p.program = vec![octo.clone()]; assert!(linux::rules(&p, 3, true).iter().all(|r| r.path != octo), "the OctoSense home is never granted"); } + +#[cfg(unix)] +#[test] +fn a_linked_checkout_into_the_octosense_home_is_not_reopened() { + // A checkout or target dir reached through a link must be judged by the + // path it resolves to: a link to the OctoSense home reopens nothing. + let scratch = Scratch::new("octolink"); + let root = &scratch.0; + let octo = root.join(".octosense"); + std::fs::create_dir_all(octo.join("apps/probe")).unwrap(); + std::fs::create_dir_all(octo.join("build/makepad")).unwrap(); + let link = root.join("checkout"); + std::os::unix::fs::symlink(&octo, &link).unwrap(); + let mut p = home_rw_with_octosense_home(root); + p.program = vec![link.clone()]; + let text = macos::profile(&p); + assert!(!text.contains("even inside them"), "a link to the home reopens nothing\n{text}"); + // A link to the build dir itself is fine: it resolves strictly inside. + let build_link = root.join("build-link"); + std::os::unix::fs::symlink(octo.join("build/makepad"), &build_link).unwrap(); + p.program = vec![build_link]; + assert!(macos::profile(&p).contains("even inside them")); + #[cfg(target_os = "linux")] + { + p.program = vec![link]; + let real = crate::sandbox::resolved(&octo); + assert!(linux::rules(&p, 3, true).iter().all(|r| r.path != real), "Landlock never grants the home through a link"); + } +} From 7acd3e374024d548cb4b7a0de92659358f8ce4f6 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:00:57 -0700 Subject: [PATCH 4/4] shell: a sandboxed app's Makepad home is its own jail (G6 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the program fix the Terminal starts, but every settings save failed: 'could not save settings to ~/.octosense/terminal/settings.conf: Operation not permitted'. The shell hands every child MAKEPAD_HOME = the OctoSense home, which G6 closes to process apps. A sandboxed process app now gets MAKEPAD_HOME = its jail (ADR 0004 §11), so the Terminal keeps settings in ~/.octosense/apps/terminal/terminal/. Its old folder (/) is copied into the jail once, by the host, before launch; the old copy stays. Test: the legacy folder is copied once, never over a newer jail copy, and nothing is created when there is nothing to adopt. Shell tests 578/578. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/clients.rs | 59 +++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/shell/src/clients.rs b/crates/shell/src/clients.rs index dad143b3..2b2e1cd3 100644 --- a/crates/shell/src/clients.rs +++ b/crates/shell/src/clients.rs @@ -1028,6 +1028,14 @@ pub fn spawn_client( // where the platform lets the build stay outside. let policy = sandbox_policy(app, root.as_deref(), &program, hub_port); let (mut cmd, applied) = crate::sandbox::command(&program, &args, policy.as_ref(), via_cargo); + // A sandboxed app's Makepad home is its own jail (ADR 0004 §11): the + // OctoSense home is closed to it (G6), so settings it kept under + // `//` could no longer be written. Its old data + // is copied into the jail once, by the host. + if let Some(policy) = &policy { + adopt_legacy_app_home(&crate::octosense::paths::home().join(&app.id), &policy.jail.join(&app.id)); + cmd.env("MAKEPAD_HOME", &policy.jail); + } match &applied { Some(crate::sandbox::Applied::Sandboxed(how)) => makepad_widgets::log!("sandbox: {how}"), Some(crate::sandbox::Applied::Unavailable(why)) => makepad_widgets::log!("sandbox: UNSANDBOXED {why}"), @@ -1129,8 +1137,59 @@ pub fn spawn_client( }) } +/// Copies an app's data from where it lived before its jail (`legacy`) to +/// `into`, once: only when `into` does not exist yet. Files and folders +/// are copied, links skipped; the old copy stays where it was. +pub(crate) fn adopt_legacy_app_home(legacy: &Path, into: &Path) { + fn copy(from: &Path, to: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(to)?; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + let kind = entry.file_type()?; + let target = to.join(entry.file_name()); + if kind.is_dir() { + copy(&entry.path(), &target)?; + } else if kind.is_file() { + std::fs::copy(entry.path(), &target)?; + } + } + Ok(()) + } + if into.exists() || !legacy.is_dir() { + return; + } + match copy(legacy, into) { + Ok(()) => makepad_widgets::log!("storage: moved {} into its jail ({})", legacy.display(), into.display()), + Err(e) => makepad_widgets::log!("storage: could not copy {} into {}: {e}", legacy.display(), into.display()), + } +} + #[cfg(test)] mod tests { + + #[test] + fn an_apps_legacy_home_is_copied_into_its_jail_once() { + let root = std::env::temp_dir().join(format!("octosense-adopt-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let legacy = root.join("home/terminal"); + let jail = root.join("home/apps/terminal/terminal"); + std::fs::create_dir_all(legacy.join("profiles")).unwrap(); + std::fs::write(legacy.join("settings.conf"), "font_size = 14").unwrap(); + std::fs::write(legacy.join("profiles/dark.conf"), "x").unwrap(); + super::adopt_legacy_app_home(&legacy, &jail); + assert_eq!(std::fs::read_to_string(jail.join("settings.conf")).unwrap(), "font_size = 14"); + assert!(jail.join("profiles/dark.conf").is_file()); + assert!(legacy.join("settings.conf").is_file(), "the old copy stays"); + // Once: a later change in the jail is never overwritten. + std::fs::write(jail.join("settings.conf"), "font_size = 16").unwrap(); + super::adopt_legacy_app_home(&legacy, &jail); + assert_eq!(std::fs::read_to_string(jail.join("settings.conf")).unwrap(), "font_size = 16"); + // Nothing to adopt: nothing created. + let none = root.join("home/apps/other/other"); + super::adopt_legacy_app_home(&root.join("home/other"), &none); + assert!(!none.exists()); + let _ = std::fs::remove_dir_all(&root); + } use super::*; #[test]