diff --git a/Cargo.lock b/Cargo.lock index a43d2d5a8..8a062d75a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5173,6 +5173,7 @@ dependencies = [ "whatsapp-rust-tokio-transport", "whatsapp-rust-ureq-http-client", "whisper-rs", + "windows-sys 0.61.2", "wiremock", "x25519-dalek", "xz2", diff --git a/Cargo.toml b/Cargo.toml index 86a01d8c4..df843a7e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -183,6 +183,19 @@ wacore = { version = "0.5", optional = true, default-features = false } # connections honor the Windows cert store, including corporate CAs # installed by AV / TLS-inspection proxies. See run-dev-win.sh notes. tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "handshake", "native-tls"] } +# AppContainer / process-jail backend in `openhuman::cwd_jail`. +# Feature list mirrors the Win32 surface used by cwd_jail/windows.rs: +# AppContainer profile APIs, ACL editing, STARTUPINFOEXW process spawn, +# and the GENERIC_* file access masks. +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Security_Isolation", + "Win32_Storage_FileSystem", + "Win32_System_Memory", + "Win32_System_Threading", +] } [target.'cfg(not(windows))'.dependencies] # macOS / Linux: keep rustls + Mozilla webpki-roots — the historical diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index a856b2d80..74ff66efb 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -5314,6 +5314,7 @@ dependencies = [ "walkdir", "webpki-roots 1.0.7", "whisper-rs", + "windows-sys 0.61.2", "x25519-dalek", "xz2", "zip 2.4.2", diff --git a/src/openhuman/cwd_jail/detect.rs b/src/openhuman/cwd_jail/detect.rs new file mode 100644 index 000000000..f6d592219 --- /dev/null +++ b/src/openhuman/cwd_jail/detect.rs @@ -0,0 +1,35 @@ +//! Platform auto-detection. Picks the strongest available backend. + +use std::sync::Arc; + +use super::jail::JailBackend; +use super::noop::NoopBackend; + +pub fn pick_backend() -> Arc { + #[cfg(target_os = "linux")] + { + let lb = super::linux::LandlockBackend::new(); + if lb.is_available() { + log::info!("[cwd_jail] backend=landlock"); + return Arc::new(lb); + } + } + #[cfg(target_os = "macos")] + { + let sb = super::macos::SeatbeltBackend::new(); + if sb.is_available() { + log::info!("[cwd_jail] backend=seatbelt"); + return Arc::new(sb); + } + } + #[cfg(target_os = "windows")] + { + let ac = super::windows::AppContainerBackend::new(); + if ac.is_available() { + log::info!("[cwd_jail] backend=appcontainer"); + return Arc::new(ac); + } + } + log::warn!("[cwd_jail] no OS sandbox available, falling back to noop"); + Arc::new(NoopBackend) +} diff --git a/src/openhuman/cwd_jail/jail.rs b/src/openhuman/cwd_jail/jail.rs new file mode 100644 index 000000000..988dd5417 --- /dev/null +++ b/src/openhuman/cwd_jail/jail.rs @@ -0,0 +1,160 @@ +//! Cross-platform directory-jail facade. +//! +//! A [`Jail`] describes *what* the agent is allowed to see; a [`JailBackend`] +//! enforces it on a specific OS. Callers only interact with [`Jail`] and the +//! top-level [`crate::openhuman::cwd_jail::spawn`] function — they +//! never pick a backend by name. + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; + +/// Declarative description of a directory jail. +/// +/// One `root` (read/write), zero or more `read_only` paths, an optional +/// allow-list of extra paths the child *may* read, and a network toggle. +/// Backends translate this into Landlock rules, a Seatbelt profile, or an +/// AppContainer ACL. +#[derive(Debug, Clone)] +pub struct Jail { + /// Primary read/write root. The child cannot escape this directory for + /// writes. Must be an existing, canonicalizable directory. + pub root: PathBuf, + /// Extra paths the child may read (e.g. `/usr/lib`, the runtime-node + /// install). Writes are still denied. + pub read_only: Vec, + /// Allow outbound network. Most agent tools need this; some risky tools + /// (untrusted code execution) should disable it. + pub allow_net: bool, + /// Allow the child to spawn further child processes. AppContainer and + /// Seatbelt can deny this; Landlock cannot. + pub allow_subprocess: bool, + /// Free-form label used by audit logs and (on Windows) as the basis for + /// the AppContainer profile name. Keep it short and ASCII. + pub label: String, +} + +impl Jail { + /// Convenience: read/write jail rooted at `root` with networking enabled + /// and no additional read-only mounts. + pub fn new(root: impl AsRef, label: impl Into) -> Self { + Self { + root: root.as_ref().to_path_buf(), + read_only: Vec::new(), + allow_net: true, + allow_subprocess: true, + label: label.into(), + } + } + + pub fn add_read_only(mut self, path: impl AsRef) -> Self { + self.read_only.push(path.as_ref().to_path_buf()); + self + } + + pub fn deny_net(mut self) -> Self { + self.allow_net = false; + self + } + + pub fn deny_subprocess(mut self) -> Self { + self.allow_subprocess = false; + self + } + + /// Canonicalize `root` and `read_only` so backends never see `..` or + /// symlink trickery. Returns an error if `root` does not exist. + pub fn canonicalize(&mut self) -> std::io::Result<()> { + self.root = self.root.canonicalize()?; + for p in self.read_only.iter_mut() { + if let Ok(c) = p.canonicalize() { + *p = c; + } + } + Ok(()) + } +} + +/// OS-specific enforcement of a [`Jail`]. +/// +/// We model spawning rather than `Command` mutation because Windows +/// AppContainer requires custom `CreateProcess` flags that `std`'s +/// `Command::spawn` does not expose. +pub trait JailBackend: Send + Sync { + /// Stable identifier, used in logs / audit ("landlock", "seatbelt", + /// "appcontainer", "noop"). + fn name(&self) -> &'static str; + + /// Whether the backend can actually enforce the jail in this process / + /// on this kernel build. Auto-detection consults this before returning + /// a backend. + fn is_available(&self) -> bool; + + /// Spawn `cmd` under the jail described by `jail`. Backends own how the + /// jail is materialized (Landlock ruleset, sandbox-exec wrapper, + /// AppContainer profile + restricted token). + fn spawn(&self, jail: &Jail, cmd: Command) -> std::io::Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_permissive() { + let j = Jail::new("/tmp", "x"); + assert!(j.allow_net); + assert!(j.allow_subprocess); + assert_eq!(j.label, "x"); + assert!(j.read_only.is_empty()); + } + + #[test] + fn deny_net_is_idempotent() { + let j = Jail::new("/tmp", "x").deny_net().deny_net(); + assert!(!j.allow_net); + } + + #[test] + fn deny_subprocess_is_idempotent() { + let j = Jail::new("/tmp", "x").deny_subprocess().deny_subprocess(); + assert!(!j.allow_subprocess); + } + + #[test] + fn add_read_only_appends_in_order() { + let j = Jail::new("/tmp", "x") + .add_read_only("/a") + .add_read_only("/b") + .add_read_only("/c"); + assert_eq!(j.read_only.len(), 3); + assert_eq!(j.read_only[0], PathBuf::from("/a")); + assert_eq!(j.read_only[2], PathBuf::from("/c")); + } + + #[test] + fn canonicalize_resolves_real_path() { + let dir = std::env::temp_dir(); + let mut j = Jail::new(&dir, "x"); + j.canonicalize().unwrap(); + // After canonicalize, root has no `..` and resolves to a real path. + assert!(j.root.is_absolute()); + assert!(j.root.exists()); + } + + #[test] + fn canonicalize_swallows_missing_read_only() { + // read_only entries that don't exist are silently dropped from + // canonicalization (they stay as-is). Verify no panic. + let dir = std::env::temp_dir(); + let mut j = Jail::new(&dir, "x").add_read_only("/this/never/existed"); + j.canonicalize().unwrap(); + assert_eq!(j.read_only.len(), 1); + } + + #[test] + fn canonicalize_errors_on_missing_root() { + let mut j = Jail::new("/no/such/root/here", "x"); + let err = j.canonicalize().unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); + } +} diff --git a/src/openhuman/cwd_jail/linux.rs b/src/openhuman/cwd_jail/linux.rs new file mode 100644 index 000000000..8747ce14d --- /dev/null +++ b/src/openhuman/cwd_jail/linux.rs @@ -0,0 +1,121 @@ +//! Linux backend: Landlock LSM (kernel 5.13+). +//! +//! Reuses the existing [`crate::openhuman::security::landlock`] implementation +//! but wraps it behind the [`JailBackend`] trait so callers don't have to +//! plumb `SecurityConfig`. Landlock is applied via `pre_exec`, which runs +//! in the *child* process after `fork()` and before `exec()` — the parent +//! retains its broader privileges, the child gets the ruleset before any +//! user code runs. Same model used by Chromium's Linux sandbox. + +#![cfg(target_os = "linux")] + +use std::process::{Child, Command}; + +use super::jail::{Jail, JailBackend}; + +pub struct LandlockBackend; + +impl LandlockBackend { + pub fn new() -> Self { + Self + } +} + +impl JailBackend for LandlockBackend { + fn name(&self) -> &'static str { + "landlock" + } + + fn is_available(&self) -> bool { + #[cfg(feature = "sandbox-landlock")] + { + use landlock::{AccessFs, Ruleset, RulesetAttr, RulesetCreatedAttr}; + Ruleset::default() + .handle_access(AccessFs::ReadFile) + .and_then(|r| r.create()) + .is_ok() + } + #[cfg(not(feature = "sandbox-landlock"))] + { + false + } + } + + fn spawn(&self, jail: &Jail, mut cmd: Command) -> std::io::Result { + #[cfg(feature = "sandbox-landlock")] + { + use landlock::{ + AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, + }; + use std::os::unix::process::CommandExt; + + let root = jail.root.clone(); + let read_only = jail.read_only.clone(); + + // SAFETY: pre_exec runs after fork() in the child, before exec. + // We apply Landlock there so the parent process keeps its + // privileges (the parent may legitimately need broader access). + unsafe { + cmd.pre_exec(move || { + let mut ruleset = Ruleset::default() + .handle_access( + AccessFs::Execute + | AccessFs::ReadFile + | AccessFs::WriteFile + | AccessFs::ReadDir + | AccessFs::RemoveDir + | AccessFs::RemoveFile + | AccessFs::MakeReg + | AccessFs::MakeDir + | AccessFs::MakeSym, + ) + .and_then(|r| r.create()) + .map_err(|e| std::io::Error::other(e.to_string()))?; + + let root_fd = + PathFd::new(&root).map_err(|e| std::io::Error::other(e.to_string()))?; + ruleset = ruleset + .add_rule(PathBeneath::new( + root_fd, + AccessFs::Execute + | AccessFs::ReadFile + | AccessFs::WriteFile + | AccessFs::ReadDir + | AccessFs::RemoveFile + | AccessFs::RemoveDir + | AccessFs::MakeReg + | AccessFs::MakeDir, + )) + .map_err(|e| std::io::Error::other(e.to_string()))?; + + // read_only paths also need Execute so the child can + // run binaries it found there (e.g. /usr/bin/sh). + // Without it, Landlock blocks `execve` on anything + // outside `root`. + for ro in &read_only { + if let Ok(fd) = PathFd::new(ro) { + ruleset = ruleset + .add_rule(PathBeneath::new( + fd, + AccessFs::Execute | AccessFs::ReadFile | AccessFs::ReadDir, + )) + .map_err(|e| std::io::Error::other(e.to_string()))?; + } + } + + ruleset + .restrict_self() + .map_err(|e| std::io::Error::other(e.to_string()))?; + Ok(()) + }); + } + + cmd.spawn() + } + #[cfg(not(feature = "sandbox-landlock"))] + { + let _ = jail; + cmd.spawn() + } + } +} diff --git a/src/openhuman/cwd_jail/macos.rs b/src/openhuman/cwd_jail/macos.rs new file mode 100644 index 000000000..483b6b8e9 --- /dev/null +++ b/src/openhuman/cwd_jail/macos.rs @@ -0,0 +1,281 @@ +//! macOS backend: Seatbelt via `sandbox-exec`. +//! +//! `sandbox-exec` is a built-in macOS binary that takes a Scheme-style +//! profile (the "Seatbelt" / TrustedBSD policy language) and execs the +//! requested command under it. Chromium, iOS simulators and Apple's own +//! tools use the same SPI under the hood. The CLI is technically +//! deprecated but has stayed shipping for a decade and is the only +//! supported way to apply Seatbelt without private framework bindings. + +#![cfg(target_os = "macos")] + +use std::process::{Child, Command}; + +use super::jail::{Jail, JailBackend}; + +pub struct SeatbeltBackend; + +impl SeatbeltBackend { + pub fn new() -> Self { + Self + } +} + +impl JailBackend for SeatbeltBackend { + fn name(&self) -> &'static str { + "seatbelt" + } + + fn is_available(&self) -> bool { + std::path::Path::new("/usr/bin/sandbox-exec").exists() + } + + fn spawn(&self, jail: &Jail, cmd: Command) -> std::io::Result { + let profile = render_profile(jail); + + // sandbox-exec only accepts profiles from disk or from `-p`. Inline + // (`-p`) is simpler and avoids a tempfile lifecycle problem (the + // child may outlive our parent scope). + let program = cmd.get_program().to_os_string(); + let args: Vec<_> = cmd.get_args().map(|a| a.to_os_string()).collect(); + let envs: Vec<_> = cmd + .get_envs() + .map(|(k, v)| (k.to_os_string(), v.map(|s| s.to_os_string()))) + .collect(); + let cwd = cmd.get_current_dir().map(|p| p.to_path_buf()); + + let mut wrapper = Command::new("/usr/bin/sandbox-exec"); + wrapper.arg("-p").arg(profile).arg(program).args(args); + for (k, v) in envs { + match v { + Some(val) => { + wrapper.env(k, val); + } + None => { + wrapper.env_remove(k); + } + } + } + if let Some(d) = cwd { + wrapper.current_dir(d); + } + // Inherit stdio from the original command intent. `std::process` + // doesn't expose the original `Stdio`, so we leave the inherited + // defaults — callers can re-wire by spawning into a pre-set stdio + // via the returned `Child` is not possible; for now we match the + // sandbox-exec defaults (inherit). Document this in mod.rs. + wrapper.spawn() + } +} + +/// Render a Seatbelt profile. +/// +/// The model is **allow-default for reads, deny-default for writes**. +/// A deny-everything profile is unworkable on macOS — Mach-O binaries need +/// dyld, libsystem, the shared cache, mach lookups for Foundation, and a +/// dozen other things that change between OS releases. Locking those down +/// breaks tools faster than it stops attackers. +/// +/// What we actually want from a *directory jail* is: the child can read +/// pretty much anything, but it can only **write** inside `jail.root` and +/// the system scratchpad. That's what this profile enforces. +fn render_profile(jail: &Jail) -> String { + let mut out = String::new(); + out.push_str("(version 1)\n"); + out.push_str("(allow default)\n"); + + // Network gate. `allow default` enables network*; only flip it off + // when the jail explicitly denies it. + if !jail.allow_net { + out.push_str("(deny network*)\n"); + } + + // Subprocess gate. Same idea — only restrict on opt-in. + if !jail.allow_subprocess { + out.push_str("(deny process-fork)\n"); + out.push_str("(deny process-exec)\n"); + } + + // The actual directory jail: deny writes everywhere, then re-allow + // them under root + /private/tmp (the macOS scratchpad most tools + // assume exists and is writable). + out.push_str("(deny file-write*)\n"); + out.push_str(&format!( + "(allow file-write*\n (subpath \"{}\")\n (subpath \"/private/tmp\")\n)\n", + escape(&jail.root.to_string_lossy()) + )); + + // `read_only` is informational on macOS — reads are already allowed + // by `(allow default)`. We keep the field on `Jail` because Landlock + // and AppContainer use it, and it lets callers express intent + // uniformly across platforms. + let _ = jail.read_only.len(); + + out +} + +fn escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::process::Stdio; + + #[test] + fn profile_allow_net_by_default_has_no_deny() { + let jail = Jail::new("/tmp", "x"); + let p = render_profile(&jail); + assert!(p.contains("(allow default)")); + assert!(!p.contains("(deny network*)")); + } + + #[test] + fn profile_deny_subprocess_emits_deny_rules() { + let jail = Jail::new("/tmp", "x").deny_subprocess(); + let p = render_profile(&jail); + assert!(p.contains("(deny process-fork)")); + assert!(p.contains("(deny process-exec)")); + } + + #[test] + fn profile_allow_subprocess_default_has_no_process_deny() { + let jail = Jail::new("/tmp", "x"); + let p = render_profile(&jail); + assert!(!p.contains("(deny process-fork)")); + assert!(!p.contains("(deny process-exec)")); + } + + #[test] + fn escape_handles_backslash_and_quote() { + assert_eq!(escape("a\\b"), "a\\\\b"); + assert_eq!(escape("a\"b"), "a\\\"b"); + assert_eq!(escape("a\\\"b"), "a\\\\\\\"b"); + assert_eq!(escape("plain"), "plain"); + } + + #[test] + fn is_available_reflects_sandbox_exec_presence() { + let backend = SeatbeltBackend::new(); + let expected = std::path::Path::new("/usr/bin/sandbox-exec").exists(); + assert_eq!(backend.is_available(), expected); + assert_eq!(backend.name(), "seatbelt"); + } + + #[test] + fn seatbelt_passes_cwd_through() { + let backend = SeatbeltBackend::new(); + if !backend.is_available() { + return; + } + let root = std::env::temp_dir().join(format!("oh-cwd-{}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + let mut jail = Jail::new(&root, "cwd"); + // `/tmp` canonicalizes to `/private/tmp` on macOS — subpath + // matching in the Seatbelt profile is by canonical path, so + // unless we resolve first the write inside root gets denied. + // This is exactly what the `spawn` facade does for callers. + jail.canonicalize().unwrap(); + let mut cmd = Command::new("/bin/sh"); + cmd.arg("-c") + .arg("pwd > pwd.out") + .current_dir(&root) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut child = backend.spawn(&jail, cmd).expect("spawn"); + let status = child.wait().expect("wait"); + assert!(status.success()); + let written = fs::read_to_string(root.join("pwd.out")).unwrap(); + // pwd resolves through /private on macOS — we just check it ends + // with the basename of root. + let last = root.file_name().unwrap().to_string_lossy().to_string(); + assert!( + written.trim().ends_with(&last), + "pwd output {written:?} did not end with {last}" + ); + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn seatbelt_passes_env_through() { + let backend = SeatbeltBackend::new(); + if !backend.is_available() { + return; + } + let root = std::env::temp_dir().join(format!("oh-env-{}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + let mut jail = Jail::new(&root, "env"); + jail.canonicalize().unwrap(); + let mut cmd = Command::new("/bin/sh"); + cmd.arg("-c") + .arg("echo $OPENHUMAN_TEST_VAR > env.out") + .env("OPENHUMAN_TEST_VAR", "hello-from-jail") + .current_dir(&root) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut child = backend.spawn(&jail, cmd).expect("spawn"); + child.wait().expect("wait"); + let written = fs::read_to_string(root.join("env.out")).unwrap(); + assert_eq!(written.trim(), "hello-from-jail"); + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn profile_allows_default_and_jails_writes() { + let jail = Jail::new("/tmp/abc", "test").deny_net(); + let p = render_profile(&jail); + assert!(p.contains("(allow default)")); + assert!(p.contains("(deny file-write*)")); + assert!(p.contains("(subpath \"/tmp/abc\")")); + assert!(p.contains("(deny network*)")); + } + + #[test] + fn seatbelt_spawn_runs_true() { + let backend = SeatbeltBackend::new(); + if !backend.is_available() { + return; + } + let dir = std::env::temp_dir(); + let jail = Jail::new(&dir, "test.true"); + let mut cmd = Command::new("/usr/bin/true"); + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + let mut child = backend.spawn(&jail, cmd).expect("spawn"); + let status = child.wait().expect("wait"); + assert!(status.success(), "sandboxed /usr/bin/true exited non-zero"); + } + + #[test] + fn seatbelt_blocks_write_outside_root() { + let backend = SeatbeltBackend::new(); + if !backend.is_available() { + return; + } + // Root = a fresh tempdir. Try to touch a file *outside* it. + let root = std::env::temp_dir().join(format!("openhuman-encap-{}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + let outside = + std::env::temp_dir().join(format!("openhuman-encap-outside-{}", std::process::id())); + let _ = fs::remove_file(&outside); + + let jail = Jail::new(&root, "test.blocked"); + let mut cmd = Command::new("/usr/bin/touch"); + cmd.arg(&outside) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut child = backend.spawn(&jail, cmd).expect("spawn"); + let status = child.wait().expect("wait"); + + // Either the touch failed (good — sandbox blocked it) or it + // succeeded (sandbox didn't apply). Assert the file does not exist. + assert!( + !outside.exists(), + "Seatbelt failed to block write to {}, status={:?}", + outside.display(), + status + ); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/src/openhuman/cwd_jail/mod.rs b/src/openhuman/cwd_jail/mod.rs new file mode 100644 index 000000000..4b564497c --- /dev/null +++ b/src/openhuman/cwd_jail/mod.rs @@ -0,0 +1,195 @@ +//! Directory jail (cwd_jail): jail an agent/tool into a single workspace. +//! +//! ## Why this exists +//! +//! `src/openhuman/security/` already has a `Sandbox` trait that wraps +//! `Command`s (Landlock / Firejail / Bubblewrap / Docker). It works well +//! for Linux but the macOS branch is a stub (`bwrap` doesn't exist there) +//! and there is no Windows backend at all. Callers also have to thread +//! `SecurityConfig` through every call site. +//! +//! `cwd_jail` is the user-facing facade. Callers describe *what* the +//! jail looks like ([`Jail`]) and the module picks the right OS backend: +//! +//! | OS | Backend | Mechanism | +//! |---------|---------------|--------------------------------------------| +//! | Linux | landlock | Kernel 5.13+ LSM, applied in `pre_exec` | +//! | macOS | seatbelt | `sandbox-exec -p '' …` | +//! | Windows | appcontainer | `CreateAppContainerProfile` + `STARTUPINFOEX` | +//! | other | noop | Plain `Command::spawn`, audit-only | +//! +//! ## Quick start +//! +//! ```ignore +//! use openhuman::openhuman::cwd_jail::{spawn, Jail}; +//! use std::process::Command; +//! +//! let mut jail = Jail::new("/Users/x/work/proj", "agent.delegate") +//! .add_read_only("/usr/lib") +//! .deny_subprocess(); +//! jail.canonicalize_or_log(); +//! +//! let mut cmd = Command::new("node"); +//! cmd.arg("script.js"); +//! let child = spawn(&jail, cmd)?; +//! ``` +//! +//! ## What this does *not* do +//! +//! - It does not jail the current process. Backends spawn a child. The core +//! itself is trusted; only the things it shells out to are caged. +//! - It does not replace `security::SecurityPolicy`. The autonomy gate +//! still decides *whether* a command may run; this module decides +//! *what filesystem* it sees once approved. +//! - It does not encrypt files. ACLs / Landlock rules / Seatbelt profiles +//! are the wall — anything inside `root` is fully visible to the child. + +pub mod detect; +pub mod jail; +pub mod noop; +pub mod registry; + +#[cfg(target_os = "linux")] +pub mod linux; +#[cfg(target_os = "macos")] +pub mod macos; +#[cfg(target_os = "windows")] +pub mod windows; + +pub use jail::{Jail, JailBackend}; +pub use noop::NoopBackend; +pub use registry::{JailRecord, JailRegistry}; + +use std::process::{Child, Command}; +use std::sync::{Arc, OnceLock}; + +/// Cached default backend for the current platform. +static DEFAULT_BACKEND: OnceLock> = OnceLock::new(); + +/// Returns the process-wide default backend, lazily auto-detected. +pub fn default_backend() -> Arc { + DEFAULT_BACKEND.get_or_init(detect::pick_backend).clone() +} + +/// Spawn `cmd` inside the jail described by `spawn`, using the default backend. +/// +/// `jail.canonicalize()` is called once here so the backends never see +/// `..` or symlinks. If the root does not exist, the spawn fails with +/// `NotFound` (canonicalize bubbles it up) — callers should create the +/// workspace before encapsulating. +pub fn spawn(jail: &Jail, cmd: Command) -> std::io::Result { + let mut jail = jail.clone(); + jail.canonicalize()?; + default_backend().spawn(&jail, cmd) +} + +/// Same as [`jail`] but with a caller-supplied backend. Useful in +/// tests and for callers that want to opt into a weaker backend +/// explicitly (e.g. forcing [`NoopBackend`] during local dev). +pub fn spawn_with(backend: &dyn JailBackend, jail: &Jail, cmd: Command) -> std::io::Result { + let mut jail = jail.clone(); + jail.canonicalize()?; + backend.spawn(&jail, cmd) +} + +impl Jail { + /// Best-effort canonicalize that swallows errors and logs them. Most + /// callers should use the validating [`Jail::canonicalize`] path that + /// [`jail`] runs automatically. + pub fn canonicalize_or_log(&mut self) { + if let Err(e) = self.canonicalize() { + log::warn!( + "[cwd_jail] failed to canonicalize jail root {}: {}", + self.root.display(), + e + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn noop_backend_spawns_unrestricted() { + let dir = std::env::temp_dir(); + let jail = Jail::new(&dir, "test.noop"); + let mut child = spawn_with(&NoopBackend, &jail, { + let mut c = Command::new(if cfg!(windows) { "cmd" } else { "true" }); + if cfg!(windows) { + c.args(["/C", "exit"]); + } + c + }) + .expect("noop spawn"); + let status = child.wait().expect("wait"); + assert!(status.success() || cfg!(windows)); + } + + #[test] + fn jail_builder_chains() { + let j = Jail::new("/tmp", "x") + .add_read_only("/usr/lib") + .deny_net() + .deny_subprocess(); + assert_eq!(j.read_only.len(), 1); + assert!(!j.allow_net); + assert!(!j.allow_subprocess); + } + + #[test] + fn missing_root_errors() { + let jail = Jail::new("/this/does/not/exist/ever", "x"); + let err = spawn_with(&NoopBackend, &jail, Command::new("true")).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); + } + + #[test] + fn default_backend_returns_something() { + let b = default_backend(); + assert!(!b.name().is_empty()); + } + + #[test] + fn default_backend_is_cached() { + // OnceLock guarantees the same Arc on every call. + let a = default_backend(); + let b = default_backend(); + assert!(Arc::ptr_eq(&a, &b)); + } + + #[test] + fn spawn_uses_default_backend() { + let dir = std::env::temp_dir(); + let jail = Jail::new(&dir, "default-spawn"); + let cmd = if cfg!(windows) { + let mut c = Command::new("cmd"); + c.args(["/C", "exit"]); + c + } else { + Command::new("true") + }; + // Must succeed via whichever platform backend is detected (or + // noop). The point of the test is that we go through the public + // `spawn` entry rather than `spawn_with`. + let mut child = spawn(&jail, cmd).expect("spawn spawn"); + let _ = child.wait().expect("wait"); + } + + #[test] + fn canonicalize_or_log_does_not_panic_on_missing() { + // The lossy helper is supposed to log + continue rather than + // propagate. Verify it doesn't panic for the missing-root case. + let mut jail = Jail::new("/no/such/place", "lossy"); + jail.canonicalize_or_log(); + // root stays as-is on failure. + assert_eq!(jail.root, std::path::PathBuf::from("/no/such/place")); + } + + #[test] + fn noop_backend_metadata() { + assert_eq!(NoopBackend.name(), "noop"); + assert!(NoopBackend.is_available()); + } +} diff --git a/src/openhuman/cwd_jail/noop.rs b/src/openhuman/cwd_jail/noop.rs new file mode 100644 index 000000000..cb9901ee3 --- /dev/null +++ b/src/openhuman/cwd_jail/noop.rs @@ -0,0 +1,26 @@ +//! Fallback backend: no enforcement, just spawns. +//! +//! Used when no OS-level jail is available (unsupported platform, missing +//! kernel feature, etc.). Callers can still rely on application-layer +//! `validate_path_within_root` checks. + +use std::process::{Child, Command}; + +use super::jail::{Jail, JailBackend}; + +#[derive(Debug, Default)] +pub struct NoopBackend; + +impl JailBackend for NoopBackend { + fn name(&self) -> &'static str { + "noop" + } + + fn is_available(&self) -> bool { + true + } + + fn spawn(&self, _jail: &Jail, mut cmd: Command) -> std::io::Result { + cmd.spawn() + } +} diff --git a/src/openhuman/cwd_jail/registry.rs b/src/openhuman/cwd_jail/registry.rs new file mode 100644 index 000000000..b300ad50d --- /dev/null +++ b/src/openhuman/cwd_jail/registry.rs @@ -0,0 +1,430 @@ +//! Jail registry — manage many jailed workspaces side-by-side. +//! +//! A [`JailRegistry`] is rooted at a single base directory (typically +//! `~/.openhuman/jails/` or `/jails/`) and owns every active +//! jail underneath it. Each jail has: +//! +//! - A stable **id** (UUID-ish, used in paths and the index). +//! - A user-visible **label** (free text, displayed in UI, used for +//! AppContainer profile derivation on Windows). +//! - A **directory** at `//` that the [`crate::openhuman::cwd_jail::Jail`] +//! is rooted in. +//! - **Metadata**: created/updated timestamps, backend used at create +//! time, optional notes. +//! +//! All metadata is persisted to `/index.json`. The on-disk index is +//! the source of truth; the in-memory state is rebuilt from it on every +//! [`JailRegistry::open`]. +//! +//! Concurrency: a `std::sync::Mutex` guards mutations. The index file +//! is rewritten atomically via write-temp + rename. This is sufficient +//! for the single-process core; if we ever want multi-process registry +//! access we'll need OS-level file locking — explicit non-goal for now. + +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use super::jail::{Jail, JailBackend}; +use super::{default_backend, spawn_with}; + +/// Metadata persisted for each jail. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JailRecord { + pub id: String, + pub label: String, + pub dir: PathBuf, + pub backend_at_create: String, + pub created_at_unix: u64, + pub updated_at_unix: u64, + #[serde(default)] + pub notes: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct Index { + /// id → record. BTreeMap so list() is deterministically ordered. + records: BTreeMap, + #[serde(default)] + schema_version: u32, +} + +const INDEX_SCHEMA_VERSION: u32 = 1; +const INDEX_FILENAME: &str = "index.json"; + +/// Top-level manager for multiple jailed workspaces. +#[derive(Debug)] +pub struct JailRegistry { + base: PathBuf, + index: Mutex, +} + +impl JailRegistry { + /// Open (or create) a registry rooted at `base`. The directory is + /// created if it does not exist; the index file is loaded if present + /// and seeded blank otherwise. + pub fn open(base: impl AsRef) -> io::Result { + let base = base.as_ref().to_path_buf(); + fs::create_dir_all(&base)?; + let idx_path = base.join(INDEX_FILENAME); + let index = if idx_path.exists() { + log::debug!( + "[cwd_jail] registry.open loading index {}", + idx_path.display() + ); + let raw = fs::read(&idx_path)?; + serde_json::from_slice::(&raw) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))? + } else { + log::debug!("[cwd_jail] registry.open fresh index at {}", base.display()); + Index { + records: BTreeMap::new(), + schema_version: INDEX_SCHEMA_VERSION, + } + }; + log::debug!( + "[cwd_jail] registry.open base={} records={}", + base.display(), + index.records.len() + ); + Ok(Self { + base, + index: Mutex::new(index), + }) + } + + /// Root directory of this registry. + pub fn base(&self) -> &Path { + &self.base + } + + /// Create a new jail directory. Returns the persisted record. + pub fn create(&self, label: impl Into) -> io::Result { + let label = label.into(); + // Label is free-form user input — log only its length so we get + // a useful breadcrumb without leaking arbitrary text into logs. + log::debug!("[cwd_jail] registry.create label_len={}", label.len()); + + // Loop until we find an id not already in the index. After a + // process restart in the same second, the time+counter id can + // repeat — without this loop we would silently overwrite an + // existing record. + let mut idx = self.index.lock().unwrap(); + let (id, dir) = loop { + let candidate = generate_id(); + if !idx.records.contains_key(&candidate) { + let dir = self.base.join(&candidate); + fs::create_dir_all(&dir)?; + break (candidate, dir); + } + log::trace!("[cwd_jail] id collision, regenerating"); + }; + + let now = now_unix(); + let record = JailRecord { + id: id.clone(), + label, + dir, + backend_at_create: default_backend().name().to_string(), + created_at_unix: now, + updated_at_unix: now, + notes: None, + }; + idx.records.insert(id.clone(), record.clone()); + // Roll back both the in-memory insert and the freshly-created + // directory if persistence fails — otherwise the registry would + // expose a record through get()/list() that does not exist on + // disk, and a subsequent reopen would silently lose it. + if let Err(e) = self.persist(&idx) { + idx.records.remove(&id); + let _ = fs::remove_dir_all(&record.dir); + log::warn!("[cwd_jail] registry.create persist failed; rolled back: {e}"); + return Err(e); + } + log::debug!( + "[cwd_jail] registry.create id={id} dir={}", + record.dir.display() + ); + Ok(record) + } + + /// Look up a jail by id. + pub fn get(&self, id: &str) -> Option { + self.index.lock().unwrap().records.get(id).cloned() + } + + /// List every active jail. Deterministic order (by id). + pub fn list(&self) -> Vec { + self.index + .lock() + .unwrap() + .records + .values() + .cloned() + .collect() + } + + /// Search by label substring (case-insensitive). Useful for UI. + pub fn find_by_label(&self, needle: &str) -> Vec { + let needle = needle.to_lowercase(); + self.index + .lock() + .unwrap() + .records + .values() + .filter(|r| r.label.to_lowercase().contains(&needle)) + .cloned() + .collect() + } + + /// Rename: changes the *label* only. The directory id stays put so + /// existing path references keep working. AppContainer profile names + /// are derived from `id` (stable), not `label`, for the same reason. + pub fn rename(&self, id: &str, new_label: impl Into) -> io::Result { + let new_label = new_label.into(); + log::debug!( + "[cwd_jail] registry.rename id={id} new_label_len={}", + new_label.len() + ); + let mut idx = self.index.lock().unwrap(); + let record = idx + .records + .get_mut(id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("no jail {id}")))?; + // Snapshot the prior label/timestamp so we can revert on + // persist failure — without that the in-memory record would + // diverge from disk. + let prev_label = std::mem::replace(&mut record.label, new_label); + let prev_updated = std::mem::replace(&mut record.updated_at_unix, now_unix()); + let cloned = record.clone(); + if let Err(e) = self.persist(&idx) { + if let Some(r) = idx.records.get_mut(id) { + r.label = prev_label; + r.updated_at_unix = prev_updated; + } + log::warn!("[cwd_jail] registry.rename persist failed; rolled back: {e}"); + return Err(e); + } + Ok(cloned) + } + + /// Update the free-form notes field. + pub fn set_notes(&self, id: &str, notes: Option) -> io::Result { + log::debug!( + "[cwd_jail] registry.set_notes id={id} has_notes={}", + notes.is_some() + ); + let mut idx = self.index.lock().unwrap(); + let record = idx + .records + .get_mut(id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("no jail {id}")))?; + let prev_notes = std::mem::replace(&mut record.notes, notes); + let prev_updated = std::mem::replace(&mut record.updated_at_unix, now_unix()); + let cloned = record.clone(); + if let Err(e) = self.persist(&idx) { + if let Some(r) = idx.records.get_mut(id) { + r.notes = prev_notes; + r.updated_at_unix = prev_updated; + } + log::warn!("[cwd_jail] registry.set_notes persist failed; rolled back: {e}"); + return Err(e); + } + Ok(cloned) + } + + /// Delete a jail. Removes both the directory and the index entry. + /// + /// Refuses to delete a jail whose directory is not under `self.base` — + /// belt-and-suspenders against a corrupted index pointing at `/`. + /// Disk deletion happens *before* the in-memory record is removed, + /// so a filesystem error doesn't leave the registry in a state + /// where the entry is gone in-memory but the directory survives on + /// disk until the next `open()` reload. + pub fn delete(&self, id: &str) -> io::Result<()> { + let mut idx = self.index.lock().unwrap(); + let record = idx + .records + .get(id) + .cloned() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("no jail {id}")))?; + log::debug!( + "[cwd_jail] registry.delete id={id} dir={}", + record.dir.display() + ); + + let resolved = record + .dir + .canonicalize() + .unwrap_or_else(|_| record.dir.clone()); + let resolved_base = self + .base + .canonicalize() + .unwrap_or_else(|_| self.base.clone()); + if !resolved.starts_with(&resolved_base) { + // Index is suspicious — don't touch anything on disk and + // leave the in-memory record alone too. The caller can + // diagnose and fix. + log::warn!( + "[cwd_jail] refusing delete: dir {} not under base {}", + resolved.display(), + resolved_base.display() + ); + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "refusing to delete jail outside registry base: {}", + resolved.display() + ), + )); + } + + if record.dir.exists() { + fs::remove_dir_all(&record.dir)?; + } + // Disk side succeeded — now remove from the index and persist. + // If persist fails here the directory is already gone, so we + // can't fully roll back; we keep the in-memory removal aligned + // with disk reality and surface the error. + idx.records.remove(id); + if let Err(e) = self.persist(&idx) { + log::warn!( + "[cwd_jail] registry.delete persist failed after dir removal; \ + index.json may resurrect id={id} on next reopen: {e}" + ); + return Err(e); + } + Ok(()) + } + + /// Drop *all* jails. Convenience for tests / "reset everything" + /// flows. Returns the number of jails removed. + pub fn clear(&self) -> io::Result { + let ids: Vec = self.index.lock().unwrap().records.keys().cloned().collect(); + let n = ids.len(); + log::debug!("[cwd_jail] registry.clear dropping n={n}"); + for id in ids { + self.delete(&id)?; + } + Ok(n) + } + + /// Spawn `cmd` inside the named jail, using the default backend. + /// Convenience wrapper — the same effect as + /// `spawn(&Jail::new(record.dir, record.label), cmd)`. + pub fn spawn_in(&self, id: &str, cmd: Command) -> io::Result { + let jail = self.jail_for(id)?; + log::debug!("[cwd_jail] registry.spawn_in id={id}"); + default_backend().spawn(&jail, cmd) + } + + /// Same as [`spawn_in`] but with a caller-supplied backend. + pub fn spawn_in_with( + &self, + id: &str, + backend: &dyn JailBackend, + cmd: Command, + ) -> io::Result { + let jail = self.jail_for(id)?; + log::debug!( + "[cwd_jail] registry.spawn_in_with id={id} backend={}", + backend.name() + ); + spawn_with(backend, &jail, cmd) + } + + /// Build a canonicalized [`Jail`] for the given id, refusing if the + /// persisted record points outside `self.base`. Centralizes the + /// containment check so both `spawn_in` and `spawn_in_with` are + /// protected against a corrupted index that could otherwise be used + /// to bypass the directory jail root. + fn jail_for(&self, id: &str) -> io::Result { + let record = self + .get(id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("no jail {id}")))?; + + let resolved = record + .dir + .canonicalize() + .unwrap_or_else(|_| record.dir.clone()); + let resolved_base = self + .base + .canonicalize() + .unwrap_or_else(|_| self.base.clone()); + if !resolved.starts_with(&resolved_base) { + log::warn!( + "[cwd_jail] refusing spawn: jail {id} dir {} not under base {}", + resolved.display(), + resolved_base.display() + ); + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "jail {id} dir {} is outside registry base {}", + resolved.display(), + resolved_base.display() + ), + )); + } + + let mut jail = Jail::new(&record.dir, &record.label); + jail.canonicalize()?; + Ok(jail) + } + + /// Atomic-rename write of the index. Falls back to direct write on + /// Windows if rename-over fails (Windows traditionally refused + /// rename-over-existing, though modern NTFS/Win10 supports it). + fn persist(&self, idx: &Index) -> io::Result<()> { + let path = self.base.join(INDEX_FILENAME); + let tmp = self.base.join(format!("{INDEX_FILENAME}.tmp")); + let bytes = serde_json::to_vec_pretty(idx) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + fs::write(&tmp, &bytes)?; + match fs::rename(&tmp, &path) { + Ok(()) => { + log::trace!( + "[cwd_jail] registry.persist atomic-rename n={}", + idx.records.len() + ); + Ok(()) + } + Err(e) => { + // Fallback: direct overwrite. + log::debug!( + "[cwd_jail] registry.persist rename failed ({e}); falling back to overwrite" + ); + fs::write(&path, &bytes)?; + let _ = fs::remove_file(&tmp); + Ok(()) + } + } + } +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Short, URL-safe id. Not cryptographically random — we use it as a +/// directory name, not a token. +fn generate_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let ts = now_unix(); + format!("j{ts:x}{n:x}") +} + +#[cfg(test)] +#[path = "registry_test.rs"] +mod tests; diff --git a/src/openhuman/cwd_jail/registry_test.rs b/src/openhuman/cwd_jail/registry_test.rs new file mode 100644 index 000000000..c7674a0df --- /dev/null +++ b/src/openhuman/cwd_jail/registry_test.rs @@ -0,0 +1,326 @@ +//! Unit tests for [`super::JailRegistry`]. +//! +//! Lives next to `registry.rs` (wired in via `#[cfg(test)] #[path = +//! "registry_test.rs"] mod tests;`) so the production module stays under +//! the ~500-line guideline. + +use super::*; + +fn tempdir(tag: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!( + "openhuman-registry-{}-{}-{}", + tag, + std::process::id(), + now_unix() + )); + fs::create_dir_all(&p).unwrap(); + p +} + +#[test] +fn create_list_get_roundtrip() { + let base = tempdir("crud"); + let reg = JailRegistry::open(&base).unwrap(); + let a = reg.create("alpha").unwrap(); + let b = reg.create("beta").unwrap(); + assert_ne!(a.id, b.id); + assert!(a.dir.exists()); + assert!(b.dir.exists()); + let listed = reg.list(); + assert_eq!(listed.len(), 2); + assert_eq!(reg.get(&a.id).unwrap().label, "alpha"); + assert_eq!(reg.get(&b.id).unwrap().label, "beta"); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn rename_changes_label_not_id_or_dir() { + let base = tempdir("rename"); + let reg = JailRegistry::open(&base).unwrap(); + let a = reg.create("old").unwrap(); + let renamed = reg.rename(&a.id, "new").unwrap(); + assert_eq!(renamed.id, a.id); + assert_eq!(renamed.dir, a.dir); + assert_eq!(renamed.label, "new"); + assert!(renamed.updated_at_unix >= a.updated_at_unix); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn delete_removes_dir_and_record() { + let base = tempdir("delete"); + let reg = JailRegistry::open(&base).unwrap(); + let a = reg.create("doomed").unwrap(); + let dir = a.dir.clone(); + assert!(dir.exists()); + reg.delete(&a.id).unwrap(); + assert!(!dir.exists()); + assert!(reg.get(&a.id).is_none()); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn delete_missing_errors() { + let base = tempdir("missing"); + let reg = JailRegistry::open(&base).unwrap(); + let err = reg.delete("nope").unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn index_persists_across_reopen() { + let base = tempdir("persist"); + let reg = JailRegistry::open(&base).unwrap(); + let a = reg.create("persistent").unwrap(); + drop(reg); + let reg2 = JailRegistry::open(&base).unwrap(); + let listed = reg2.list(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, a.id); + assert_eq!(listed[0].label, "persistent"); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn find_by_label_substring() { + let base = tempdir("find"); + let reg = JailRegistry::open(&base).unwrap(); + reg.create("agent-alpha").unwrap(); + reg.create("agent-beta").unwrap(); + reg.create("tool-gamma").unwrap(); + assert_eq!(reg.find_by_label("AGENT").len(), 2); + assert_eq!(reg.find_by_label("gamma").len(), 1); + assert_eq!(reg.find_by_label("nope").len(), 0); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn clear_drops_everything() { + let base = tempdir("clear"); + let reg = JailRegistry::open(&base).unwrap(); + reg.create("a").unwrap(); + reg.create("b").unwrap(); + reg.create("c").unwrap(); + let n = reg.clear().unwrap(); + assert_eq!(n, 3); + assert_eq!(reg.list().len(), 0); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn parallel_jails_have_distinct_dirs() { + let base = tempdir("parallel"); + let reg = JailRegistry::open(&base).unwrap(); + let jails: Vec<_> = (0..5) + .map(|i| reg.create(format!("p{i}")).unwrap()) + .collect(); + let mut dirs: Vec<_> = jails.iter().map(|r| r.dir.clone()).collect(); + dirs.sort(); + dirs.dedup(); + assert_eq!(dirs.len(), 5); + for r in &jails { + assert!(r.dir.exists()); + } + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn set_notes_roundtrips() { + let base = tempdir("notes"); + let reg = JailRegistry::open(&base).unwrap(); + let a = reg.create("with-notes").unwrap(); + assert!(a.notes.is_none()); + let updated = reg.set_notes(&a.id, Some("hello".into())).unwrap(); + assert_eq!(updated.notes.as_deref(), Some("hello")); + let cleared = reg.set_notes(&a.id, None).unwrap(); + assert!(cleared.notes.is_none()); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn set_notes_on_missing_id_errors() { + let base = tempdir("notes-missing"); + let reg = JailRegistry::open(&base).unwrap(); + let err = reg.set_notes("nope", Some("x".into())).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn rename_on_missing_id_errors() { + let base = tempdir("rename-missing"); + let reg = JailRegistry::open(&base).unwrap(); + let err = reg.rename("nope", "x").unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn delete_twice_second_is_not_found() { + let base = tempdir("delete-twice"); + let reg = JailRegistry::open(&base).unwrap(); + let a = reg.create("once").unwrap(); + reg.delete(&a.id).unwrap(); + let err = reg.delete(&a.id).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn spawn_in_with_missing_id_errors() { + let base = tempdir("spawn-missing"); + let reg = JailRegistry::open(&base).unwrap(); + let err = reg + .spawn_in_with("nope", &super::super::NoopBackend, Command::new("true")) + .unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn spawn_in_uses_default_backend() { + let base = tempdir("spawn-default"); + let reg = JailRegistry::open(&base).unwrap(); + let a = reg.create("def").unwrap(); + let cmd = if cfg!(windows) { + let mut c = Command::new("cmd"); + c.args(["/C", "exit"]); + c + } else { + Command::new("true") + }; + let mut child = reg.spawn_in(&a.id, cmd).unwrap(); + let _ = child.wait().unwrap(); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn clear_on_empty_registry_is_zero() { + let base = tempdir("empty-clear"); + let reg = JailRegistry::open(&base).unwrap(); + assert_eq!(reg.clear().unwrap(), 0); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn find_by_label_on_empty_registry() { + let base = tempdir("empty-find"); + let reg = JailRegistry::open(&base).unwrap(); + assert!(reg.find_by_label("anything").is_empty()); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn open_creates_base_directory_if_missing() { + let base = std::env::temp_dir().join(format!( + "oh-reg-mkdir-{}-{}", + std::process::id(), + now_unix() + )); + assert!(!base.exists()); + let reg = JailRegistry::open(&base).unwrap(); + assert!(base.exists()); + assert!(reg.list().is_empty()); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn corrupt_index_returns_invalid_data() { + let base = tempdir("corrupt"); + fs::write(base.join("index.json"), b"this is not json").unwrap(); + let err = JailRegistry::open(&base).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn persist_writes_index_file() { + let base = tempdir("persist-file"); + let reg = JailRegistry::open(&base).unwrap(); + reg.create("x").unwrap(); + let path = base.join("index.json"); + assert!(path.exists()); + let raw = fs::read_to_string(&path).unwrap(); + assert!(raw.contains("\"label\": \"x\"")); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn base_accessor_returns_open_dir() { + let base = tempdir("base-accessor"); + let reg = JailRegistry::open(&base).unwrap(); + assert_eq!(reg.base(), base.as_path()); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn delete_refuses_path_outside_base() { + // Corrupt the index so a record points at /tmp directly (outside + // base). delete() should refuse without touching anything on disk. + let base = tempdir("escape"); + let reg = JailRegistry::open(&base).unwrap(); + let a = reg.create("escape").unwrap(); + { + let mut idx = reg.index.lock().unwrap(); + idx.records.get_mut(&a.id).unwrap().dir = std::env::temp_dir(); + } + let err = reg.delete(&a.id).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + assert!(std::env::temp_dir().exists()); + // Record is still there because we refuse cleanly without removing. + assert!(reg.get(&a.id).is_some()); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn spawn_in_refuses_path_outside_base() { + // Same corruption as delete_refuses_path_outside_base, but for the + // spawn path — covers the base-containment guard in `jail_for()`. + let base = tempdir("spawn-escape"); + let reg = JailRegistry::open(&base).unwrap(); + let a = reg.create("escape").unwrap(); + { + let mut idx = reg.index.lock().unwrap(); + idx.records.get_mut(&a.id).unwrap().dir = std::env::temp_dir(); + } + let err = reg + .spawn_in_with(&a.id, &super::super::NoopBackend, Command::new("true")) + .unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn spawn_in_uses_record_dir_as_root() { + let base = tempdir("spawn"); + let reg = JailRegistry::open(&base).unwrap(); + let a = reg.create("spawn-target").unwrap(); + let mut cmd = Command::new(if cfg!(windows) { "cmd" } else { "true" }); + if cfg!(windows) { + cmd.args(["/C", "exit"]); + } + let mut child = reg + .spawn_in_with(&a.id, &super::super::NoopBackend, cmd) + .unwrap(); + let status = child.wait().unwrap(); + assert!(status.success() || cfg!(windows)); + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn create_consecutive_ids_are_unique_in_same_second() { + // The atomic counter inside generate_id() guarantees distinct ids + // within a single process even when system time has not advanced. + // Across process restarts, the create() loop is what catches + // collisions; we cover that path via the collision-loop branch + // being unreachable here without a process restart, so this test + // just confirms the happy path remains collision-free. + let base = tempdir("ids"); + let reg = JailRegistry::open(&base).unwrap(); + let ids: std::collections::HashSet<_> = (0..32) + .map(|i| reg.create(format!("j{i}")).unwrap().id) + .collect(); + assert_eq!(ids.len(), 32); + fs::remove_dir_all(&base).ok(); +} diff --git a/src/openhuman/cwd_jail/windows.rs b/src/openhuman/cwd_jail/windows.rs new file mode 100644 index 000000000..1cf1dcbaa --- /dev/null +++ b/src/openhuman/cwd_jail/windows.rs @@ -0,0 +1,397 @@ +//! Windows backend: AppContainer. +//! +//! AppContainer is Microsoft's strict process-isolation model — the same one +//! UWP apps and Edge's renderer use. Each container gets a unique SID; the +//! process can only touch filesystem objects whose DACL explicitly grants +//! that SID access. To jail an agent in `jail.root`: +//! +//! 1. `CreateAppContainerProfile` → derive a per-jail SID. +//! 2. Grant the SID `GENERIC_READ | GENERIC_WRITE | DELETE` on `jail.root` +//! via `SetNamedSecurityInfoW` (additive ACE on the existing DACL). +//! 3. Build `STARTUPINFOEXW` with `PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES`. +//! 4. `CreateProcessW` with `EXTENDED_STARTUPINFO_PRESENT`. +//! +//! Things this backend deliberately does not do: +//! +//! - Network capability requests. By default an AppContainer has *no* +//! network capabilities — we honor `jail.allow_net` by adding +//! `internetClient` and `privateNetworkClientServer` capabilities. +//! - Persistent profile cleanup. We use a deterministic profile name +//! derived from `jail.label` so reruns reuse the same SID. The host +//! should call `DeleteAppContainerProfile` on uninstall — out of scope. +//! +//! Validated paths: this implementation has been compile-checked on +//! Windows targets but **must be tested on real Windows hardware** before +//! shipping. Win32 has many subtle wrong-trees you can bark up. + +#![cfg(target_os = "windows")] + +use std::ffi::OsStr; +use std::io; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::io::{FromRawHandle, OwnedHandle}; +use std::path::Path; +use std::process::{Child, Command}; +use std::ptr; + +use windows_sys::core::PWSTR; +use windows_sys::Win32::Foundation::{CloseHandle, LocalFree, HANDLE, HLOCAL}; +use windows_sys::Win32::Security::Authorization::{ + GetNamedSecurityInfoW, SetEntriesInAclW, SetNamedSecurityInfoW, EXPLICIT_ACCESS_W, SET_ACCESS, + SE_FILE_OBJECT, TRUSTEE_IS_GROUP, TRUSTEE_IS_SID, TRUSTEE_W, +}; +use windows_sys::Win32::Security::Isolation::{ + CreateAppContainerProfile, DeriveAppContainerSidFromAppContainerName, +}; +use windows_sys::Win32::Security::{ + FreeSid, ACL, DACL_SECURITY_INFORMATION, PSID, SECURITY_CAPABILITIES, SID_AND_ATTRIBUTES, +}; + +// Well-known Win32 access masks. `windows-sys` has moved these constants +// between modules across releases (0.59 had GENERIC_READ in +// `Win32::Storage::FileSystem`, 0.60+ shuffled them around). Inlining the +// canonical values is both stable and avoids guessing which module the +// installed minor version exposes them through. Source: WinNT.h. +const GENERIC_READ: u32 = 0x8000_0000; +const GENERIC_WRITE: u32 = 0x4000_0000; +const DELETE: u32 = 0x0001_0000; +const NO_INHERITANCE: u32 = 0; +use windows_sys::Win32::System::Memory::{LocalAlloc, LPTR}; +use windows_sys::Win32::System::Threading::{ + CreateProcessW, DeleteProcThreadAttributeList, InitializeProcThreadAttributeList, + UpdateProcThreadAttribute, EXTENDED_STARTUPINFO_PRESENT, LPPROC_THREAD_ATTRIBUTE_LIST, + PROCESS_INFORMATION, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, STARTUPINFOEXW, STARTUPINFOW, +}; + +use super::jail::{Jail, JailBackend}; + +pub struct AppContainerBackend; + +impl AppContainerBackend { + pub fn new() -> Self { + Self + } +} + +impl JailBackend for AppContainerBackend { + fn name(&self) -> &'static str { + "appcontainer" + } + + fn is_available(&self) -> bool { + // AppContainer is available on Windows 8+ and Server 2012+. + // We could probe `CreateAppContainerProfile`'s availability via + // GetProcAddress, but treating the target_os = "windows" cfg as + // good enough — supported Windows versions are all 10+. + true + } + + fn spawn(&self, jail: &Jail, cmd: Command) -> io::Result { + unsafe { spawn_in_container(jail, cmd) } + } +} + +unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result { + // 1. Create or open the AppContainer profile and obtain its SID. + let profile_name = sanitize_profile_name(&jail.label); + let wide_name = to_wide(&profile_name); + let display = to_wide(&format!("openhuman {}", jail.label)); + let desc = to_wide("openhuman spawnd agent process"); + + let mut sid: PSID = ptr::null_mut(); + let hr = CreateAppContainerProfile( + wide_name.as_ptr(), + display.as_ptr(), + desc.as_ptr(), + ptr::null_mut(), + 0, + &mut sid, + ); + if hr != 0 { + // 0x800700B7 = HRESULT_FROM_WIN32(ERROR_ALREADY_EXISTS). + // In that case derive the SID from the existing profile. + if hr as u32 == 0x800700B7 { + let mut existing: PSID = ptr::null_mut(); + let drv = DeriveAppContainerSidFromAppContainerName(wide_name.as_ptr(), &mut existing); + if drv != 0 { + return Err(io::Error::from_raw_os_error(drv)); + } + sid = existing; + } else { + return Err(io::Error::from_raw_os_error(hr)); + } + } + let _sid_guard = SidGuard(sid); + + // 2. Grant the container SID access to the root + read-only paths. + grant_sid_access(&jail.root, sid, GENERIC_READ | GENERIC_WRITE | DELETE)?; + for ro in &jail.read_only { + grant_sid_access(ro, sid, GENERIC_READ)?; + } + + // 3. Build SECURITY_CAPABILITIES. For now we attach no capability SIDs; + // network capabilities (`internetClient`) would need their own + // well-known SID lookups via DeriveCapabilitySidsFromName — left as + // a TODO with a clear failure mode (`jail.allow_net == true` will + // log a warning until implemented). + if jail.allow_net { + log::warn!( + "[cwd_jail] AppContainer network capabilities not yet wired; \ + jail.allow_net=true is currently equivalent to no-net on Windows" + ); + } + let mut caps = SECURITY_CAPABILITIES { + AppContainerSid: sid, + Capabilities: ptr::null_mut(), + CapabilityCount: 0, + Reserved: 0, + }; + + // 4. Build STARTUPINFOEXW with the security-capabilities attribute. + let mut size: usize = 0; + InitializeProcThreadAttributeList(ptr::null_mut(), 1, 0, &mut size); + let attr_buf = LocalAlloc(LPTR, size) as LPPROC_THREAD_ATTRIBUTE_LIST; + if attr_buf.is_null() { + return Err(io::Error::last_os_error()); + } + let _attr_guard = AttrListGuard(attr_buf); + if InitializeProcThreadAttributeList(attr_buf, 1, 0, &mut size) == 0 { + return Err(io::Error::last_os_error()); + } + if UpdateProcThreadAttribute( + attr_buf, + 0, + PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES as usize, + &mut caps as *mut _ as *mut _, + std::mem::size_of::(), + ptr::null_mut(), + ptr::null_mut(), + ) == 0 + { + return Err(io::Error::last_os_error()); + } + + let mut si: STARTUPINFOEXW = std::mem::zeroed(); + si.StartupInfo.cb = std::mem::size_of::() as u32; + si.lpAttributeList = attr_buf; + + // 5. Build the command line and current directory. + let cmdline = build_command_line(&cmd); + let mut cmdline_w = to_wide(&cmdline); + let cwd_w = cmd.get_current_dir().map(|p| to_wide(&p.to_string_lossy())); + + let mut pi: PROCESS_INFORMATION = std::mem::zeroed(); + let ok = CreateProcessW( + ptr::null(), + cmdline_w.as_mut_ptr() as PWSTR, + ptr::null_mut(), + ptr::null_mut(), + 0, // bInheritHandles + EXTENDED_STARTUPINFO_PRESENT, + ptr::null_mut(), + cwd_w.as_ref().map(|s| s.as_ptr()).unwrap_or(ptr::null()), + &mut si as *mut _ as *mut STARTUPINFOW, + &mut pi, + ); + if ok == 0 { + return Err(io::Error::last_os_error()); + } + + // 6. Wrap the raw process handle in a std `Child`. + // + // std::process::Child has no public constructor from a raw HANDLE on + // Windows. We close the thread handle (we don't need it) and leak the + // process handle into an OwnedHandle so the caller can at least wait + // via the OS. Returning a `Child` requires nightly's `FromRawHandle for + // Child`, which is unstable. For now we close the process handle too + // and return an error explaining the limitation — see TODO below. + CloseHandle(pi.hThread); + + let _process_handle = OwnedHandle::from_raw_handle(pi.hProcess as _); + + // TODO: bridge OwnedHandle -> std::process::Child once + // `std::os::windows::process::ChildExt::from_raw_handle` lands, OR + // expose a custom `OpenhumanChild` from the cwd_jail module that + // mirrors the bits of `Child` callers actually need (id, wait, kill). + Err(io::Error::new( + io::ErrorKind::Unsupported, + "AppContainer spawn succeeded but cannot yet be returned as std::process::Child; \ + see TODO in src/openhuman/cwd_jail/windows.rs", + )) +} + +unsafe fn grant_sid_access(path: &Path, sid: PSID, access: u32) -> io::Result<()> { + let path_w = to_wide(&path.to_string_lossy()); + + // First, fetch the *existing* DACL from the path. Passing + // `ptr::null_mut()` as the old-ACL argument to `SetEntriesInAclW` + // would build a fresh DACL containing only the AppContainer ACE, + // and `SetNamedSecurityInfoW` would then replace the entire DACL — + // locking out the owner / SYSTEM / Administrators. Merge instead. + let mut sd_ptr: *mut std::ffi::c_void = ptr::null_mut(); + let mut existing_dacl: *mut ACL = ptr::null_mut(); + let mut dacl_present: i32 = 0; + let rc = GetNamedSecurityInfoW( + path_w.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + ptr::null_mut(), + ptr::null_mut(), + &mut existing_dacl, + ptr::null_mut(), + &mut sd_ptr, + ); + if rc != 0 { + return Err(io::Error::from_raw_os_error(rc as i32)); + } + let _sd_guard = LocalGuard(sd_ptr as HLOCAL); + let _ = dacl_present; + + let mut ea: EXPLICIT_ACCESS_W = std::mem::zeroed(); + ea.grfAccessPermissions = access; + ea.grfAccessMode = SET_ACCESS; + ea.grfInheritance = NO_INHERITANCE; + ea.Trustee = TRUSTEE_W { + pMultipleTrustee: ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_GROUP, + ptstrName: sid as *mut _, + }; + + let mut new_acl: *mut ACL = ptr::null_mut(); + let rc = SetEntriesInAclW(1, &mut ea, existing_dacl, &mut new_acl); + if rc != 0 { + return Err(io::Error::from_raw_os_error(rc as i32)); + } + let _acl_guard = LocalGuard(new_acl as HLOCAL); + + let rc = SetNamedSecurityInfoW( + path_w.as_ptr() as PWSTR, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + ptr::null_mut(), + ptr::null_mut(), + new_acl, + ptr::null_mut(), + ); + if rc != 0 { + return Err(io::Error::from_raw_os_error(rc as i32)); + } + Ok(()) +} + +fn build_command_line(cmd: &Command) -> String { + // Minimal CommandLineToArgvW-compatible quoting. std's internal + // `make_command_line` is private; this mirrors the same rules. + let mut out = String::new(); + let prog = cmd.get_program().to_string_lossy().into_owned(); + push_arg(&mut out, &prog); + for a in cmd.get_args() { + out.push(' '); + push_arg(&mut out, &a.to_string_lossy()); + } + out +} + +fn push_arg(out: &mut String, a: &str) { + let needs_quotes = a.is_empty() || a.contains([' ', '\t', '"']); + if !needs_quotes { + out.push_str(a); + return; + } + out.push('"'); + let mut backslashes = 0; + for c in a.chars() { + match c { + '\\' => backslashes += 1, + '"' => { + for _ in 0..(backslashes * 2 + 1) { + out.push('\\'); + } + out.push('"'); + backslashes = 0; + } + _ => { + for _ in 0..backslashes { + out.push('\\'); + } + backslashes = 0; + out.push(c); + } + } + } + for _ in 0..(backslashes * 2) { + out.push('\\'); + } + out.push('"'); +} + +fn to_wide(s: &str) -> Vec { + OsStr::new(s) + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +fn sanitize_profile_name(label: &str) -> String { + // AppContainer profile names must be ≤ 64 chars and use a restricted + // charset. Keep ASCII alnum + dot; map everything else to `_`. + let mut s: String = label + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' { + c + } else { + '_' + } + }) + .collect(); + s.truncate(60); + format!("openhuman.{s}") +} + +struct SidGuard(PSID); +impl Drop for SidGuard { + fn drop(&mut self) { + if !self.0.is_null() { + // MSDN: SIDs returned by both `CreateAppContainerProfile` + // and `DeriveAppContainerSidFromAppContainerName` must be + // freed with `FreeSid`. The older comment that said + // `LocalFree` is "safer" was wrong — the buffers are not + // necessarily LocalAlloc-backed, and using the wrong free + // function corrupts the heap on some Windows builds. + unsafe { + FreeSid(self.0); + } + } + } +} + +struct AttrListGuard(LPPROC_THREAD_ATTRIBUTE_LIST); +impl Drop for AttrListGuard { + fn drop(&mut self) { + unsafe { + DeleteProcThreadAttributeList(self.0); + LocalFree(self.0 as HLOCAL); + } + } +} + +struct LocalGuard(HLOCAL); +impl Drop for LocalGuard { + fn drop(&mut self) { + unsafe { + LocalFree(self.0); + } + } +} + +// Unused import suppression on this platform. +#[allow(dead_code)] +fn _unused() { + let _ = SID_AND_ATTRIBUTES { + Sid: ptr::null_mut(), + Attributes: 0, + }; +} diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 48d027e68..111549006 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -32,6 +32,7 @@ pub mod context; pub mod cost; pub mod credentials; pub mod cron; +pub mod cwd_jail; pub mod desktop_companion; pub mod dev_paths; pub mod devices; diff --git a/tests/cwd_jail_e2e.rs b/tests/cwd_jail_e2e.rs new file mode 100644 index 000000000..234b82369 --- /dev/null +++ b/tests/cwd_jail_e2e.rs @@ -0,0 +1,274 @@ +//! End-to-end tests for `openhuman::cwd_jail`. +//! +//! Each test goes through the public surface only — `Jail`, `spawn`, +//! `JailRegistry`, `default_backend` — and (where the platform allows it) +//! actually exercises the OS sandbox by trying to do something it should +//! be blocked from doing. +//! +//! Platform breakdown: +//! - **Common** (all OSes): registry CRUD + spawn via `NoopBackend`, jail +//! builder semantics. Runs in every CI matrix slot. +//! - **Linux**: `target_os = "linux"` gate exercises Landlock by spawning +//! `/bin/sh` and trying to write outside the jail. +//! - **macOS**: same shape, exercises Seatbelt via `/usr/bin/touch`. +//! - **Windows**: AppContainer integration is marked `#[ignore]` until +//! the raw-`HANDLE` → `Child` bridge lands (see TODO in +//! `src/openhuman/cwd_jail/windows.rs`). + +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use openhuman_core::openhuman::cwd_jail::{ + default_backend, spawn, spawn_with, Jail, JailRegistry, NoopBackend, +}; + +fn unique_tempdir(tag: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!( + "openhuman-e2e-{}-{}-{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0), + )); + fs::create_dir_all(&p).unwrap(); + p +} + +// ── Common: runs on every platform ────────────────────────────────── + +#[test] +fn registry_full_lifecycle_with_noop() { + let base = unique_tempdir("lifecycle"); + let reg = JailRegistry::open(&base).unwrap(); + + // Create several jails in parallel. + let a = reg.create("agent-a").unwrap(); + let b = reg.create("agent-b").unwrap(); + let c = reg.create("agent-c").unwrap(); + assert_eq!(reg.list().len(), 3); + + // Rename + notes update timestamps. + let renamed = reg.rename(&a.id, "agent-a-renamed").unwrap(); + assert_eq!(renamed.label, "agent-a-renamed"); + let noted = reg + .set_notes(&a.id, Some("owner=stevent95".into())) + .unwrap(); + assert_eq!(noted.notes.as_deref(), Some("owner=stevent95")); + + // Spawn through the registry into one of the jails. + let mut cmd = noop_exit_zero_cmd(); + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + let mut child = reg.spawn_in_with(&b.id, &NoopBackend, cmd).unwrap(); + let status = child.wait().unwrap(); + assert!(status.success() || cfg!(windows)); + + // Delete one, clear the rest. + reg.delete(&c.id).unwrap(); + assert!(reg.get(&c.id).is_none()); + let cleared = reg.clear().unwrap(); + assert_eq!(cleared, 2); + assert!(reg.list().is_empty()); + + // Reopen — empty index round-trips. + drop(reg); + let reg2 = JailRegistry::open(&base).unwrap(); + assert!(reg2.list().is_empty()); + + fs::remove_dir_all(&base).ok(); +} + +#[test] +fn jail_canonicalize_rejects_missing_root() { + let jail = Jail::new("/does/not/exist/at/all", "missing"); + let err = spawn_with(&NoopBackend, &jail, noop_exit_zero_cmd()).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); +} + +#[test] +fn default_backend_is_named_and_available() { + let b = default_backend(); + assert!(!b.name().is_empty()); + // On every supported platform the auto-detected backend should be + // available — even noop returns true. + assert!(b.is_available()); +} + +#[test] +fn jail_builder_carries_intent_through_clone() { + // `spawn` clones the jail before canonicalize; verify a chained + // builder still produces the right shape. + let dir = unique_tempdir("builder"); + let j = Jail::new(&dir, "build") + .add_read_only("/usr/lib") + .add_read_only("/usr/share") + .deny_net() + .deny_subprocess(); + assert_eq!(j.read_only.len(), 2); + assert!(!j.allow_net); + assert!(!j.allow_subprocess); + fs::remove_dir_all(&dir).ok(); +} + +// ── Linux: Landlock real-sandbox enforcement ──────────────────────── + +#[cfg(all(target_os = "linux", feature = "sandbox-landlock"))] +#[test] +fn linux_landlock_blocks_write_outside_root() { + let root = unique_tempdir("ll-root"); + let outside = unique_tempdir("ll-outside"); + let outside_target = outside.join("forbidden.txt"); + + let jail = Jail::new(&root, "e2e.landlock"); + let mut cmd = Command::new("/bin/sh"); + cmd.arg("-c") + .arg(format!("echo hi > {}", outside_target.display())) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut child = spawn(&jail, cmd).expect("spawn under landlock"); + let _ = child.wait().expect("wait"); + + assert!( + !outside_target.exists(), + "Landlock failed to block write to {}", + outside_target.display() + ); + fs::remove_dir_all(&root).ok(); + fs::remove_dir_all(&outside).ok(); +} + +#[cfg(all(target_os = "linux", feature = "sandbox-landlock"))] +#[test] +fn linux_landlock_allows_write_inside_root() { + let root = unique_tempdir("ll-root-write"); + let inside = root.join("ok.txt"); + + let jail = Jail::new(&root, "e2e.landlock.ok"); + let mut cmd = Command::new("/bin/sh"); + cmd.arg("-c") + .arg(format!("echo hi > {}", inside.display())) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut child = spawn(&jail, cmd).expect("spawn under landlock"); + let status = child.wait().expect("wait"); + assert!(status.success(), "write inside root should succeed"); + assert!(inside.exists()); + fs::remove_dir_all(&root).ok(); +} + +// ── macOS: Seatbelt real-sandbox enforcement ──────────────────────── + +#[cfg(target_os = "macos")] +#[test] +fn macos_seatbelt_blocks_write_outside_root() { + if !PathBuf::from("/usr/bin/sandbox-exec").exists() { + return; + } + let root = unique_tempdir("sb-root"); + let outside = + std::env::temp_dir().join(format!("openhuman-e2e-sb-forbidden-{}", std::process::id())); + let _ = fs::remove_file(&outside); + + let jail = Jail::new(&root, "e2e.seatbelt"); + let mut cmd = Command::new("/usr/bin/touch"); + cmd.arg(&outside) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut child = spawn(&jail, cmd).expect("spawn under seatbelt"); + let _ = child.wait().expect("wait"); + + assert!( + !outside.exists(), + "Seatbelt failed to block write to {}", + outside.display() + ); + fs::remove_dir_all(&root).ok(); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_seatbelt_allows_write_inside_root() { + if !PathBuf::from("/usr/bin/sandbox-exec").exists() { + return; + } + let root = unique_tempdir("sb-root-ok"); + let inside = root.join("ok.txt"); + + let jail = Jail::new(&root, "e2e.seatbelt.ok"); + let mut cmd = Command::new("/usr/bin/touch"); + cmd.arg(&inside).stdout(Stdio::null()).stderr(Stdio::null()); + + let mut child = spawn(&jail, cmd).expect("spawn under seatbelt"); + let status = child.wait().expect("wait"); + assert!(status.success(), "writing inside root should succeed"); + assert!(inside.exists()); + fs::remove_dir_all(&root).ok(); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_seatbelt_blocks_network_when_denied() { + if !PathBuf::from("/usr/bin/sandbox-exec").exists() { + return; + } + // `nc -z 1.1.1.1 80` is a simple connect probe. Under deny_net the + // sandbox should refuse the socket; under allow_net (default) it may + // succeed *or* fail depending on environment, so we only assert the + // deny side here. + let root = unique_tempdir("sb-net"); + let jail = Jail::new(&root, "e2e.seatbelt.nonet").deny_net(); + let mut cmd = Command::new("/bin/sh"); + cmd.arg("-c") + .arg("/usr/bin/nc -z -w 1 1.1.1.1 80 2>/dev/null && echo OPEN") + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let child = spawn(&jail, cmd).expect("spawn under seatbelt"); + let out = child.wait_with_output().expect("wait"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + !stdout.contains("OPEN"), + "Seatbelt failed to block network: stdout={stdout:?}" + ); + fs::remove_dir_all(&root).ok(); +} + +// ── Windows: AppContainer (gated until HANDLE→Child bridge lands) ─── + +#[cfg(target_os = "windows")] +#[test] +#[ignore = "AppContainer spawn returns Unsupported pending Child handle bridge; see windows.rs TODO"] +fn windows_appcontainer_blocks_write_outside_root() { + let root = unique_tempdir("ac-root"); + let outside = std::env::temp_dir().join(format!( + "openhuman-e2e-ac-forbidden-{}.txt", + std::process::id() + )); + let _ = fs::remove_file(&outside); + + let jail = Jail::new(&root, "e2e.appcontainer"); + let mut cmd = Command::new("cmd"); + cmd.args(["/C", &format!("echo hi > \"{}\"", outside.display())]); + + // Once the Child bridge is implemented, flip this from `unwrap_err` + // to `wait` + assert(!outside.exists()). + let err = spawn(&jail, cmd).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::Unsupported); + fs::remove_dir_all(&root).ok(); +} + +// ── Helpers ───────────────────────────────────────────────────────── + +fn noop_exit_zero_cmd() -> Command { + if cfg!(windows) { + let mut c = Command::new("cmd"); + c.args(["/C", "exit"]); + c + } else { + Command::new("true") + } +}