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
5 changes: 5 additions & 0 deletions crates/shell/src/clients.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 45 additions & 1 deletion crates/shell/src/sandbox/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -158,7 +168,41 @@ pub fn rules(policy: &Policy, abi: u32, via_cargo: bool) -> Vec<Rule> {
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<Rule>) {
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
Expand Down
28 changes: 26 additions & 2 deletions crates/shell/src/sandbox/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<PathBuf> = 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<PathBuf> = 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",
Expand Down
36 changes: 36 additions & 0 deletions crates/shell/src/sandbox/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ pub struct Policy {
pub program: Vec<PathBuf>,
/// The person's data roots: closed except for the grants above.
pub protected: Vec<PathBuf>,
/// 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<PathBuf>,
pub network: Network,
/// The shell's hub port, reachable on loopback whatever `network` is.
pub hub_port: u16,
Expand Down Expand Up @@ -171,6 +177,7 @@ impl Policy {
external,
program,
protected,
private: Vec::new(),
network: app.network,
hub_port,
processes: app.processes,
Expand Down Expand Up @@ -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<PathBuf> {
let mut out: Vec<PathBuf> = 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)
Expand Down
118 changes: 118 additions & 0 deletions crates/shell/src/sandbox/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> = 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:?}");
}