feat(encapsulation): cross-platform directory jail for agents/tools (#2557)

This commit is contained in:
Steven Enamakel
2026-05-23 20:39:19 -07:00
committed by GitHub
parent 85e84210db
commit f66e7e433c
14 changed files with 2261 additions and 0 deletions
Generated
+1
View File
@@ -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",
+13
View File
@@ -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
+1
View File
@@ -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",
+35
View File
@@ -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<dyn JailBackend> {
#[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)
}
+160
View File
@@ -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<PathBuf>,
/// 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<Path>, label: impl Into<String>) -> 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<Path>) -> 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<Child>;
}
#[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);
}
}
+121
View File
@@ -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<Child> {
#[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()
}
}
}
+281
View File
@@ -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<Child> {
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);
}
}
+195
View File
@@ -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 '<profile>' …` |
//! | 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<Arc<dyn JailBackend>> = OnceLock::new();
/// Returns the process-wide default backend, lazily auto-detected.
pub fn default_backend() -> Arc<dyn JailBackend> {
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<Child> {
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<Child> {
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());
}
}
+26
View File
@@ -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<Child> {
cmd.spawn()
}
}
+430
View File
@@ -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 `<workspace>/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 `<base>/<id>/` 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 `<base>/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<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct Index {
/// id → record. BTreeMap so list() is deterministically ordered.
records: BTreeMap<String, JailRecord>,
#[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<Index>,
}
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<Path>) -> io::Result<Self> {
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::<Index>(&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<String>) -> io::Result<JailRecord> {
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<JailRecord> {
self.index.lock().unwrap().records.get(id).cloned()
}
/// List every active jail. Deterministic order (by id).
pub fn list(&self) -> Vec<JailRecord> {
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<JailRecord> {
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<String>) -> io::Result<JailRecord> {
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<String>) -> io::Result<JailRecord> {
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<usize> {
let ids: Vec<String> = 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<Child> {
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<Child> {
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<Jail> {
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;
+326
View File
@@ -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();
}
+397
View File
@@ -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<Child> {
unsafe { spawn_in_container(jail, cmd) }
}
}
unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result<Child> {
// 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::<SECURITY_CAPABILITIES>(),
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::<STARTUPINFOEXW>() 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<u16> {
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,
};
}
+1
View File
@@ -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;
+274
View File
@@ -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")
}
}