Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions crates/shell/src/clients.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// `<OctoSense home>/<app id>/` 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}"),
Expand Down Expand Up @@ -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]
Expand Down
25 changes: 23 additions & 2 deletions crates/shell/src/sandbox/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -168,13 +173,29 @@ pub fn rules(policy: &Policy, abi: u32, via_cargo: bool) -> Vec<Rule> {
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<PathBuf> = policy.private.iter().map(|p| super::resolved(p)).collect();
let program: Vec<PathBuf> = 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 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
// `<OctoSense home>/build`): read and execute only, never write.
split.push(Rule { path: rule.path, access: rule.access & rx });
} else {
around_private(rule, &policy.private, &mut split);
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, &private, &mut split);
}
}
split
Expand Down
15 changes: 15 additions & 0 deletions crates/shell/src/sandbox/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,21 @@ 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
// `<OctoSense home>/build`): without this, Metal cannot even read the
// program's own bundle and the app crashes at start.
let (reopened, kept_closed): (Vec<PathBuf>, Vec<PathBuf>) = 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 {
out.push_str(&format!(
Expand Down
34 changes: 34 additions & 0 deletions crates/shell/src/sandbox/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<OctoSense home>/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
Expand Down
106 changes: 106 additions & 0 deletions crates/shell/src/sandbox/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,3 +385,109 @@ 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 `<OctoSense home>/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"));
}

#[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");
}

#[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");
}
}
Loading