From fe6dcc0f5f30dc266ba1e8fa7a5ddad10b2ab781 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:54:00 -0700 Subject: [PATCH] shell: the process sandbox closes the OctoSense home and the kernel's core dir last (G6) [skip ci] The Terminal's macOS profile emitted `home:rw` after everything else, and nothing closed the OctoSense home again, so a process app with a broad grant could read peer host tokens (app-peers/*.token), every app's jail and secrets files, and the kernel's data. - Policy::private: the OctoSense home, the apps and secrets roots, the kernel's core dir (and the kernel home around a `.octos` core dir), set for every process app where the shell builds its sandbox. - macOS: after every grant, `(deny file-read* file-write* ...)` on those, then only the app's own jail and secrets are opened again (with the metadata of their ancestors inside). - Linux Landlock cannot deny beneath an allowed directory, so a grant that contains a private directory is split: each entry is granted on its own, recursing only toward the private ones, which get nothing; links into them get nothing either. Limits (documented): entries created later directly in a split directory are not covered, and the directories on the way cannot be listed. Not run here (macOS), unit-tested on Linux. - Tests: a real sandboxed child with `home:rw` over a scratch home reads the person's files and its own jail and secrets, and is refused peer tokens, another app's jail and secrets, the shell's settings, the kernel's core dir and kernel home, and writing into the OctoSense home; the profile's rule order; the private dir list; the Landlock split (Linux only). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/shell/src/clients.rs | 5 ++ crates/shell/src/sandbox/linux.rs | 46 +++++++++++- crates/shell/src/sandbox/macos.rs | 28 ++++++- crates/shell/src/sandbox/mod.rs | 36 +++++++++ crates/shell/src/sandbox/tests.rs | 118 ++++++++++++++++++++++++++++++ 5 files changed, 230 insertions(+), 3 deletions(-) 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:?}"); +}