mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 20:46:24 +00:00
275 lines
9.3 KiB
Rust
275 lines
9.3 KiB
Rust
//! 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")
|
|
}
|
|
}
|