diff --git a/crates/shell/src/clients.rs b/crates/shell/src/clients.rs index 256048b3..63c62a34 100644 --- a/crates/shell/src/clients.rs +++ b/crates/shell/src/clients.rs @@ -970,6 +970,11 @@ pub fn sandbox_policy(app: &AppDef, root: Option<&Path>, program: &Path, hub_por roots.sort(); roots.dedup(); let mut policy = crate::sandbox::Policy::for_app(native, paths.jail, paths.secrets, &home, roots, hub_port); + // The host's private directories stay closed whatever the manifest + // grants (G6): the OctoSense home (peer host tokens, every app's jail + // and secrets), the storage roots and the kernel's core dir. + let core = crate::ai_host::core_dir(None); + policy.private = crate::sandbox::host_private_dirs(&crate::octosense::paths::home(), layout.apps_root(), layout.secrets_root(), core.as_deref()); // Cargo reads `.cargo/config.toml` from its working directory up (the // manifest's directory, else the checkout): every `[env]` host variable // it would hand the app is taken back out. diff --git a/crates/shell/src/sandbox/linux.rs b/crates/shell/src/sandbox/linux.rs index 3933ca08..d0248122 100644 --- a/crates/shell/src/sandbox/linux.rs +++ b/crates/shell/src/sandbox/linux.rs @@ -9,6 +9,16 @@ //! allowed only from the program's roots (and the system's, when //! `processes` is true). //! +//! **The host's private directories** ([`Policy::private`]: the OctoSense +//! home, the kernel's core dir) stay closed whatever a grant opens. Landlock +//! cannot deny beneath an allowed directory, so a grant that contains one +//! (the Terminal's `home:rw`) is split: each entry of the granted directory +//! is granted on its own, recursing only into directories on the way to a +//! private one, which get no right at all. The app's own jail and secrets +//! are granted by themselves. The limits of that split: entries created in +//! a split directory after the app started (a new file directly in `~`) are +//! not covered, and the directories on the way cannot be listed. +//! //! seccomp refuses, with `EPERM`, what no process app needs: `ptrace`, //! `process_vm_readv/writev`, `perf_event_open`, `bpf`, `userfaultfd`, //! `kexec_load`, mounts, namespaces and the kernel keyring; with @@ -158,7 +168,41 @@ pub fn rules(policy: &Policy, abi: u32, via_cargo: bool) -> Vec { add(program.clone(), all); } } - out + let own = [policy.jail.clone(), policy.secrets.clone()]; + let mut split = Vec::new(); + for rule in out { + if own.contains(&rule.path) { + split.push(rule); + } else { + around_private(rule, &policy.private, &mut split); + } + } + split +} + +/// `rule`, minus the private directories: dropped when it lies inside one; +/// when it contains one, replaced by a rule per entry of its directory, +/// recursing toward the private ones (which get nothing). +pub fn around_private(rule: Rule, private: &[PathBuf], out: &mut Vec) { + if private.iter().any(|p| rule.path.starts_with(p)) { + return; + } + if !private.iter().any(|p| p.starts_with(&rule.path)) { + out.push(rule); + return; + } + let Ok(entries) = std::fs::read_dir(&rule.path) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + // Landlock grants what a link resolves to: a link into (or above) a + // private directory gets nothing. + let real = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone()); + if real != path && private.iter().any(|p| real.starts_with(p) || p.starts_with(&real)) { + continue; + } + let access = if path.is_dir() { rule.access } else { rule.access & FILE_RIGHTS }; + around_private(Rule { path, access }, private, out); + } } /// The seccomp program for `processes` on this architecture, `None` where diff --git a/crates/shell/src/sandbox/macos.rs b/crates/shell/src/sandbox/macos.rs index d2663110..7014d6c5 100644 --- a/crates/shell/src/sandbox/macos.rs +++ b/crates/shell/src/sandbox/macos.rs @@ -6,8 +6,11 @@ //! (libraries, fonts, the window server, Metal's shader cache) and closes //! the person's data roots except the grants. In SBPL the last matching //! rule wins, so the order below is the policy: close the roots, reopen the -//! ancestors' metadata (paths must resolve), the program read-only, then -//! the jail, secrets and `external` grants. +//! ancestors' metadata (paths must resolve), the program read-only, the +//! jail, secrets and `external` grants; then close the host's private +//! directories again ([`Policy::private`]: the OctoSense home, the kernel's +//! core dir), whatever a grant opened, and reopen only the app's own jail +//! and secrets inside them. use std::path::{Path, PathBuf}; @@ -73,6 +76,27 @@ pub fn profile(policy: &Policy) -> String { out.push_str(&format!("(allow file-read*{})\n", subpaths(&ro))); } out.push_str(&format!(";; its jail, its secrets and external grants\n(allow file-read* file-write*{})\n", subpaths(&rw))); + let private: Vec = policy.private.iter().map(|p| resolved(p)).collect(); + if !private.is_empty() { + let own = [resolved(&policy.jail), resolved(&policy.secrets)]; + out.push_str(&format!( + ";; the host's private directories stay closed whatever a grant opened (peer tokens, other apps, the kernel)\n(deny file-read* file-write*{})\n", + subpaths(&private) + )); + let mut inside: Vec = Vec::new(); + for path in &own { + for a in ancestors(path) { + if private.iter().any(|root| a.starts_with(root)) && !inside.contains(&a) { + inside.push(a); + } + } + } + if !inside.is_empty() { + let lits: String = inside.iter().map(|p| format!(" (literal {})", quote(p))).collect(); + 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))); + } if policy.network == Network::None { out.push_str(&format!( ";; network: the shell's hub only\n(deny network*)\n(allow network-outbound (remote ip \"localhost:{}\"))\n", diff --git a/crates/shell/src/sandbox/mod.rs b/crates/shell/src/sandbox/mod.rs index 0efc15ee..4ae6127a 100644 --- a/crates/shell/src/sandbox/mod.rs +++ b/crates/shell/src/sandbox/mod.rs @@ -90,6 +90,12 @@ pub struct Policy { pub program: Vec, /// The person's data roots: closed except for the grants above. pub protected: Vec, + /// The host's own private directories (the OctoSense home, the apps + /// and secrets roots, the kernel's core dir): closed LAST, after every + /// grant, so even a broad grant (the Terminal's `home:rw`) never reaches + /// peer host tokens, other apps' jails and secrets, or kernel data. Only + /// the app's own jail and secrets are opened again inside them. + pub private: Vec, pub network: Network, /// The shell's hub port, reachable on loopback whatever `network` is. pub hub_port: u16, @@ -171,6 +177,7 @@ impl Policy { external, program, protected, + private: Vec::new(), network: app.network, hub_port, processes: app.processes, @@ -210,6 +217,35 @@ impl Policy { } } +/// 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 +/// duplicates. +pub fn host_private_dirs(octosense_home: &Path, apps_root: &Path, secrets_root: &Path, core_dir: Option<&Path>) -> Vec { + let mut out: Vec = Vec::new(); + let mut push = |p: &Path| { + if p.as_os_str().is_empty() || p.parent().is_none() { + return; // never the file system root + } + let p = p.to_path_buf(); + if !out.contains(&p) { + out.push(p); + } + }; + push(octosense_home); + push(apps_root); + push(secrets_root); + if let Some(core) = core_dir { + push(core); + if core.file_name().is_some_and(|n| n == ".octos") { + if let Some(kernel_home) = core.parent() { + push(kernel_home); + } + } + } + out +} + /// Whether `OCTOSENSE_SANDBOX_NARROW` names `app` (see [`Policy::narrowed`]). pub fn narrowed_by_env(app: &str) -> bool { std::env::var("OCTOSENSE_SANDBOX_NARROW").map(|v| v.split(',').any(|a| a.trim() == app)).unwrap_or(false) diff --git a/crates/shell/src/sandbox/tests.rs b/crates/shell/src/sandbox/tests.rs index a6383389..fdc7a8bc 100644 --- a/crates/shell/src/sandbox/tests.rs +++ b/crates/shell/src/sandbox/tests.rs @@ -196,3 +196,121 @@ fn no_child_inherits_a_host_token_or_a_kernel_path() { assert!(!is_host_secret_var(name), "the child sees {line}"); } } + +// ---------------------------------------------------------------- the OctoSense home (G6) + +/// A broad app (the Terminal's `home:rw`) whose scratch "home" also holds +/// the OctoSense home and the kernel's core dir, as on a real machine. +fn home_rw_with_octosense_home(root: &Path) -> Policy { + let octo = root.join(".octosense"); + let jail = octo.join("apps/probe"); + let secrets = octo.join("secrets/probe"); + let mut p = jail_only(root, 1); + p.jail = jail; + p.secrets = secrets; + p.external = vec![(root.to_path_buf(), Access::ReadWrite)]; + p.network = Network::Any; + p.processes = true; + p.private = host_private_dirs(&octo, &octo.join("apps"), &octo.join("secrets"), Some(&root.join("octos-home/.octos"))); + p +} + +#[cfg(unix)] +#[test] +fn home_rw_never_reaches_the_octosense_home_or_the_kernel() { + if !sandbox_works_here() { + eprintln!("no process sandbox on this machine; skipped"); + return; + } + let scratch = Scratch::new("octohome"); + let root = &scratch.0; + let octo = root.join(".octosense"); + for dir in ["apps/probe", "apps/other", "secrets/probe", "secrets/other", "app-peers"] { + std::fs::create_dir_all(octo.join(dir)).unwrap(); + } + std::fs::create_dir_all(root.join("octos-home/.octos")).unwrap(); + std::fs::create_dir_all(root.join("Documents")).unwrap(); + std::fs::write(root.join("Documents/note.txt"), "the person's note").unwrap(); + std::fs::write(octo.join("apps/probe/mine.txt"), "mine").unwrap(); + std::fs::write(octo.join("secrets/probe/key"), "its own secret").unwrap(); + std::fs::write(octo.join("apps/other/theirs.txt"), "another app's").unwrap(); + std::fs::write(octo.join("secrets/other/key"), "another app's secret").unwrap(); + std::fs::write(octo.join("app-peers/rinx.token"), "peer-host-token").unwrap(); + std::fs::write(octo.join("settings.json"), "{}").unwrap(); + std::fs::write(root.join("octos-home/.octos/profile.json"), "kernel data").unwrap(); + std::fs::write(root.join("octos-home/notes.md"), "kernel home").unwrap(); + let policy = home_rw_with_octosense_home(root); + + let cat = |p: PathBuf| run("/bin/cat", &[p.to_str().unwrap()], &policy); + let (ok, out) = cat(root.join("Documents/note.txt")); + assert!(ok && out.contains("the person's note"), "home:rw still reaches the person's files: {out}"); + let (ok, out) = cat(octo.join("apps/probe/mine.txt")); + assert!(ok && out.contains("mine"), "its own jail: {out}"); + let (ok, out) = cat(octo.join("secrets/probe/key")); + assert!(ok && out.contains("its own secret"), "its own secrets: {out}"); + for (path, what) in [ + (octo.join("app-peers/rinx.token"), "peer-host-token"), + (octo.join("apps/other/theirs.txt"), "another app's"), + (octo.join("secrets/other/key"), "another app's secret"), + (octo.join("settings.json"), "{}"), + (root.join("octos-home/.octos/profile.json"), "kernel data"), + (root.join("octos-home/notes.md"), "kernel home"), + ] { + let (ok, out) = cat(path.clone()); + assert!(!ok && !out.contains(what), "{} must be refused: {out}", path.display()); + } + let sh = |script: String| run("/bin/sh", &["-c", &script], &policy); + let (ok, out) = sh(format!("echo x > {}/app-peers/planted.token", octo.display())); + assert!(!ok, "nothing is written into the OctoSense home: {out}"); + let (ok, out) = sh(format!("ls {}/apps", octo.display())); + assert!(!ok || !out.contains("other"), "other apps' jails are not even listed: {out}"); + let (ok, out) = sh(format!("echo x > {}/apps/probe/written", octo.display())); + assert!(ok, "it writes in its own jail: {out}"); +} + +#[test] +fn the_macos_profile_closes_the_octosense_home_after_every_grant() { + let root = Path::new("/nonexistent/home"); + let p = home_rw_with_octosense_home(root); + let text = macos::profile(&p); + let grant = text.find("(allow file-read* file-write* (subpath \"/nonexistent/home/.octosense/apps/probe\") (subpath \"/nonexistent/home/.octosense/secrets/probe\") (subpath \"/nonexistent/home\"))").expect(&text); + let deny = text.find("(deny file-read* file-write* (subpath \"/nonexistent/home/.octosense\")").expect(&text); + let own = text.rfind("(allow file-read* file-write* (subpath \"/nonexistent/home/.octosense/apps/probe\") (subpath \"/nonexistent/home/.octosense/secrets/probe\"))").expect(&text); + assert!(grant < deny && deny < own, "home:rw, then the private deny, then only its own jail and secrets\n{text}"); + for dir in ["/nonexistent/home/octos-home/.octos", "/nonexistent/home/octos-home"] { + assert!(text[deny..].contains(&format!("(subpath \"{dir}\")")), "{dir} is closed\n{text}"); + } + // Nothing private: no extra rules. + let mut plain = p.clone(); + plain.private.clear(); + assert!(!macos::profile(&plain).contains("private directories")); +} + +#[test] +fn the_private_dirs_are_the_homes_roots_and_the_kernels() { + let octo = Path::new("/h/.octosense"); + let dirs = host_private_dirs(octo, &octo.join("apps"), Path::new("/elsewhere/secrets"), Some(Path::new("/h/octos-home/.octos"))); + assert_eq!( + dirs, + vec![octo.to_path_buf(), octo.join("apps"), PathBuf::from("/elsewhere/secrets"), PathBuf::from("/h/octos-home/.octos"), PathBuf::from("/h/octos-home")] + ); + assert!(host_private_dirs(Path::new("/"), Path::new("/a"), Path::new("/b"), Some(Path::new("/core"))).iter().all(|p| p != Path::new("/")), "never the root"); +} + +#[cfg(target_os = "linux")] +#[test] +fn landlock_splits_a_grant_around_the_private_dirs() { + let scratch = Scratch::new("split"); + let root = &scratch.0; + let octo = root.join(".octosense"); + std::fs::create_dir_all(octo.join("apps/probe")).unwrap(); + std::fs::create_dir_all(root.join("Documents")).unwrap(); + std::fs::write(root.join("top.txt"), "t").unwrap(); + std::os::unix::fs::symlink(&octo, root.join("sneaky")).unwrap(); + let mut out = Vec::new(); + linux::around_private(linux::Rule { path: root.clone(), access: 0xfff }, &[octo.clone()], &mut out); + let paths: Vec = out.iter().map(|r| r.path.clone()).collect(); + assert!(paths.contains(&root.join("Documents")) && paths.contains(&root.join("top.txt")), "{paths:?}"); + assert!(!paths.iter().any(|p| p.starts_with(&octo) || p == root), "{paths:?}"); + assert!(!paths.contains(&root.join("sneaky")), "a link into a private dir gets nothing: {paths:?}"); +}