use async_trait::async_trait;
use harness_core::{RepoView, World};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SandboxError {
#[error("git not available: {0}")]
GitMissing(String),
#[error("git error: {0}")]
Git(String),
#[error("io error: {0}")]
Io(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FsPolicy {
Confined,
HostReadConfinedWrite,
Unrestricted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetPolicy {
None,
Allowed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Isolation {
None,
Changes,
Process,
}
#[async_trait]
pub trait Sandbox: Send + Sync {
async fn spawn(&self) -> Result<SandboxHandle, SandboxError>;
fn fs_policy(&self) -> FsPolicy;
fn net_policy(&self) -> NetPolicy;
fn isolation(&self) -> Isolation;
}
pub struct SandboxHandle {
pub world: World,
pub root: PathBuf,
cleanup: Option<Box<dyn FnOnce() + Send>>,
keep: bool,
label: String,
}
impl SandboxHandle {
pub fn label(&self) -> &str {
&self.label
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn keep(&mut self) {
self.keep = true;
}
pub fn into_world(self) -> World {
let md = std::mem::ManuallyDrop::new(self);
unsafe { std::ptr::read(&md.world) }
}
}
impl Drop for SandboxHandle {
fn drop(&mut self) {
if self.keep {
return;
}
if let Some(f) = self.cleanup.take() {
f();
}
}
}
pub struct WorktreeSandbox {
pub source: PathBuf,
pub branch: String,
pub start_ref: Option<String>,
pub target: Option<PathBuf>,
}
impl WorktreeSandbox {
pub fn new(source: impl Into<PathBuf>, branch: impl Into<String>) -> Self {
Self {
source: source.into(),
branch: branch.into(),
start_ref: None,
target: None,
}
}
pub fn with_target(mut self, p: impl Into<PathBuf>) -> Self {
self.target = Some(p.into());
self
}
pub fn with_start_ref(mut self, r: impl Into<String>) -> Self {
self.start_ref = Some(r.into());
self
}
}
#[async_trait]
impl Sandbox for WorktreeSandbox {
fn fs_policy(&self) -> FsPolicy {
FsPolicy::Confined
}
fn net_policy(&self) -> NetPolicy {
NetPolicy::Allowed
}
fn isolation(&self) -> Isolation {
Isolation::Changes
}
async fn spawn(&self) -> Result<SandboxHandle, SandboxError> {
let target = match &self.target {
Some(p) => p.clone(),
None => {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!("harness-worktree-{stamp}"))
}
};
let probe = tokio::process::Command::new("git")
.arg("--version")
.output()
.await
.map_err(|e| SandboxError::GitMissing(format!("`git --version` failed: {e}")))?;
if !probe.status.success() {
return Err(SandboxError::GitMissing(
String::from_utf8_lossy(&probe.stderr).into_owned(),
));
}
let start = self.start_ref.clone().unwrap_or_else(|| "HEAD".to_string());
let target_str = target.to_string_lossy().to_string();
let args: Vec<&str> = vec!["worktree", "add", "-b", &self.branch, &target_str, &start];
tracing::info!(branch=%self.branch, target=%target.display(), "spawning worktree");
let out = tokio::process::Command::new("git")
.current_dir(&self.source)
.args(&args)
.output()
.await
.map_err(|e| SandboxError::Io(e.to_string()))?;
if !out.status.success() {
return Err(SandboxError::Git(format!(
"git worktree add failed: {}",
String::from_utf8_lossy(&out.stderr)
)));
}
let world = World {
repo: RepoView {
root: target.clone(),
},
runner: Arc::new(harness_context::TokioRunner),
clock: Arc::new(harness_context::SystemClock),
kv: Arc::new(harness_context::InMemoryKv::new()),
profile: harness_core::UserProfile::default(),
session: None,
};
let cleanup_source = self.source.clone();
let cleanup_target = target.clone();
let cleanup_branch = self.branch.clone();
let cleanup: Box<dyn FnOnce() + Send> = Box::new(move || {
let target_str = cleanup_target.to_string_lossy().to_string();
let args: Vec<&str> = vec!["worktree", "remove", "--force", &target_str];
let res = std::process::Command::new("git")
.current_dir(&cleanup_source)
.args(&args)
.output();
if let Ok(o) = res
&& !o.status.success()
{
tracing::warn!(
"git worktree remove failed for {}: {}",
cleanup_target.display(),
String::from_utf8_lossy(&o.stderr)
);
}
let _ = std::process::Command::new("git")
.current_dir(&cleanup_source)
.args(["branch", "-D", &cleanup_branch])
.output();
});
Ok(SandboxHandle {
world,
root: target,
cleanup: Some(cleanup),
keep: false,
label: format!("worktree:{}", self.branch),
})
}
}
pub struct ContainerSandbox {
pub image: String,
pub source: PathBuf,
pub name: Option<String>,
pub no_net: bool,
}
impl ContainerSandbox {
pub fn new(image: impl Into<String>, source: impl Into<PathBuf>) -> Self {
Self {
image: image.into(),
source: source.into(),
name: None,
no_net: true,
}
}
pub fn with_network(mut self, allow: bool) -> Self {
self.no_net = !allow;
self
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
}
#[async_trait]
impl Sandbox for ContainerSandbox {
fn fs_policy(&self) -> FsPolicy {
FsPolicy::Confined
}
fn net_policy(&self) -> NetPolicy {
if self.no_net {
NetPolicy::None
} else {
NetPolicy::Allowed
}
}
fn isolation(&self) -> Isolation {
Isolation::Process
}
async fn spawn(&self) -> Result<SandboxHandle, SandboxError> {
let probe = tokio::process::Command::new("docker")
.arg("--version")
.output()
.await
.map_err(|e| SandboxError::GitMissing(format!("docker missing: {e}")))?;
if !probe.status.success() {
return Err(SandboxError::GitMissing(
String::from_utf8_lossy(&probe.stderr).to_string(),
));
}
let name = self.name.clone().unwrap_or_else(|| {
format!(
"harness-sb-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
)
});
let mount = format!("{}:/workspace", self.source.display());
let mut args = vec![
"run",
"-d",
"--rm",
"--name",
&name,
"-v",
&mount,
"-w",
"/workspace",
];
if self.no_net {
args.push("--network");
args.push("none");
}
args.push(&self.image);
args.push("sleep");
args.push("infinity");
tracing::info!(image=%self.image, name=%name, "spawning container sandbox");
let out = tokio::process::Command::new("docker")
.args(&args)
.output()
.await
.map_err(|e| SandboxError::Io(e.to_string()))?;
if !out.status.success() {
return Err(SandboxError::Git(format!(
"docker run failed: {}",
String::from_utf8_lossy(&out.stderr)
)));
}
let runner: Arc<dyn harness_core::ProcessRunner> = Arc::new(DockerExecRunner {
container: name.clone(),
});
let world = World {
repo: RepoView {
root: self.source.clone(),
},
runner,
clock: Arc::new(harness_context::SystemClock),
kv: Arc::new(harness_context::InMemoryKv::new()),
profile: harness_core::UserProfile::default(),
session: None,
};
let kill_name = name.clone();
let cleanup: Box<dyn FnOnce() + Send> = Box::new(move || {
let _ = std::process::Command::new("docker")
.args(["kill", &kill_name])
.output();
});
Ok(SandboxHandle {
world,
root: self.source.clone(),
cleanup: Some(cleanup),
keep: false,
label: format!("container:{name}"),
})
}
}
struct DockerExecRunner {
container: String,
}
#[async_trait]
impl harness_core::ProcessRunner for DockerExecRunner {
async fn exec(
&self,
program: &str,
args: &[&str],
cwd: Option<&std::path::Path>,
) -> std::io::Result<harness_core::ProcessOutput> {
let mut docker_args: Vec<String> = vec!["exec".into()];
if let Some(c) = cwd {
docker_args.push("-w".into());
let inside = if c.is_absolute() {
c.display().to_string()
} else {
format!("/workspace/{}", c.display())
};
docker_args.push(inside);
}
docker_args.push(self.container.clone());
docker_args.push(program.into());
for a in args {
docker_args.push((*a).into());
}
let out = tokio::process::Command::new("docker")
.args(&docker_args)
.output()
.await?;
Ok(harness_core::ProcessOutput {
status: out.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&out.stdout).to_string(),
stderr: String::from_utf8_lossy(&out.stderr).to_string(),
})
}
}
pub struct SeatbeltSandbox {
pub root: PathBuf,
pub allow_net: bool,
pub confine_writes: bool,
pub confine_reads: bool,
}
impl SeatbeltSandbox {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
allow_net: false,
confine_writes: false,
confine_reads: false,
}
}
pub fn with_network(mut self, allow: bool) -> Self {
self.allow_net = allow;
self
}
pub fn with_confine_writes(mut self, confine: bool) -> Self {
self.confine_writes = confine;
self
}
pub fn with_confine_reads(mut self, confine: bool) -> Self {
self.confine_reads = confine;
if confine {
self.confine_writes = true;
}
self
}
}
const MACOS_READ_ALLOW: &[&str] = &[
"/usr",
"/bin",
"/sbin",
"/System",
"/Library",
"/opt",
"/nix",
"/etc",
"/private/etc",
"/var/select",
"/dev",
"/private/var/db/dyld",
];
pub fn seatbelt_profile(
root: &Path,
allow_net: bool,
confine_writes: bool,
confine_reads: bool,
) -> String {
let mut p = String::from("(version 1)\n(allow default)\n");
if !allow_net {
p.push_str("(deny network*)\n");
}
if confine_writes {
p.push_str("(deny file-write*)\n");
p.push_str(&format!(
"(allow file-write* (subpath \"{}\"))\n",
root.display()
));
p.push_str(
"(allow file-write* (subpath \"/private/var/folders\") (subpath \"/private/tmp\"))\n",
);
p.push_str("(allow file-write* (literal \"/dev/null\") (literal \"/dev/stdout\") (literal \"/dev/stderr\") (literal \"/dev/dtracehelper\"))\n");
}
if confine_reads {
p.push_str("(deny file-read*)\n");
p.push_str("(allow file-read-metadata)\n");
p.push_str("(allow file-read* (literal \"/\"))\n");
p.push_str(&format!(
"(allow file-read* (subpath \"{}\"))\n",
root.display()
));
for path in MACOS_READ_ALLOW {
p.push_str(&format!("(allow file-read* (subpath \"{path}\"))\n"));
}
}
p
}
struct SeatbeltRunner {
profile: String,
private_tmp: Option<PathBuf>,
}
#[async_trait]
impl harness_core::ProcessRunner for SeatbeltRunner {
async fn exec(
&self,
program: &str,
args: &[&str],
cwd: Option<&std::path::Path>,
) -> std::io::Result<harness_core::ProcessOutput> {
let mut sb: Vec<String> = vec!["-p".into(), self.profile.clone(), program.into()];
sb.extend(args.iter().map(|a| (*a).to_string()));
let mut cmd = tokio::process::Command::new("sandbox-exec");
cmd.args(&sb);
if let Some(t) = &self.private_tmp {
std::fs::create_dir_all(t)?;
cmd.env("TMPDIR", t).env("TMP", t).env("TEMP", t);
}
if let Some(c) = cwd {
cmd.current_dir(c);
}
let out = cmd.output().await?;
Ok(harness_core::ProcessOutput {
status: out.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&out.stdout).to_string(),
stderr: String::from_utf8_lossy(&out.stderr).to_string(),
})
}
}
#[async_trait]
impl Sandbox for SeatbeltSandbox {
fn fs_policy(&self) -> FsPolicy {
match (self.confine_reads, self.confine_writes) {
(true, _) => FsPolicy::Confined,
(false, true) => FsPolicy::HostReadConfinedWrite,
(false, false) => FsPolicy::Unrestricted,
}
}
fn net_policy(&self) -> NetPolicy {
if self.allow_net {
NetPolicy::Allowed
} else {
NetPolicy::None
}
}
fn isolation(&self) -> Isolation {
Isolation::Process
}
async fn spawn(&self) -> Result<SandboxHandle, SandboxError> {
let probe = tokio::process::Command::new("sandbox-exec")
.arg("-p")
.arg("(version 1)(allow default)")
.arg("/usr/bin/true")
.output()
.await
.map_err(|e| SandboxError::GitMissing(format!("sandbox-exec unavailable: {e}")))?;
if !probe.status.success() {
return Err(SandboxError::GitMissing(
"sandbox-exec probe failed (macOS only)".into(),
));
}
let world = World {
repo: RepoView {
root: self.root.clone(),
},
runner: Arc::new(SeatbeltRunner {
profile: seatbelt_profile(
&self.root,
self.allow_net,
self.confine_writes,
self.confine_reads,
),
private_tmp: self.confine_reads.then(|| self.root.join(".tmp")),
}),
clock: Arc::new(harness_context::SystemClock),
kv: Arc::new(harness_context::InMemoryKv::new()),
profile: harness_core::UserProfile::default(),
session: None,
};
Ok(SandboxHandle {
world,
root: self.root.clone(),
cleanup: None,
keep: false,
label: "seatbelt".into(),
})
}
}
pub struct BubblewrapSandbox {
pub root: PathBuf,
pub allow_net: bool,
pub confine_writes: bool,
pub confine_reads: bool,
}
impl BubblewrapSandbox {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
allow_net: false,
confine_writes: true,
confine_reads: false,
}
}
pub fn with_network(mut self, allow: bool) -> Self {
self.allow_net = allow;
self
}
pub fn with_confine_writes(mut self, confine: bool) -> Self {
self.confine_writes = confine;
self
}
pub fn with_confine_reads(mut self, confine: bool) -> Self {
self.confine_reads = confine;
if confine {
self.confine_writes = true;
}
self
}
}
const LINUX_READ_BIND: &[&str] = &[
"/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt", "/nix",
];
pub fn bwrap_args(
root: &Path,
allow_net: bool,
confine_writes: bool,
confine_reads: bool,
) -> Vec<String> {
let r = root.display().to_string();
let mut a: Vec<String> = Vec::new();
if confine_reads {
for path in LINUX_READ_BIND {
a.extend(["--ro-bind-try".into(), (*path).into(), (*path).into()]);
}
a.extend(["--bind".into(), r.clone(), r]);
a.extend(["--tmpfs".into(), "/tmp".into()]);
} else if confine_writes {
a.extend(["--ro-bind".into(), "/".into(), "/".into()]);
a.extend(["--bind".into(), r.clone(), r]);
} else {
a.extend(["--bind".into(), "/".into(), "/".into()]);
}
a.extend(["--proc".into(), "/proc".into()]);
a.extend(["--dev".into(), "/dev".into()]);
if !allow_net {
a.push("--unshare-net".into());
}
a
}
struct BwrapRunner {
args: Vec<String>,
}
#[async_trait]
impl harness_core::ProcessRunner for BwrapRunner {
async fn exec(
&self,
program: &str,
args: &[&str],
cwd: Option<&std::path::Path>,
) -> std::io::Result<harness_core::ProcessOutput> {
let mut full = self.args.clone();
full.push(program.into());
full.extend(args.iter().map(|a| (*a).to_string()));
let mut cmd = tokio::process::Command::new("bwrap");
cmd.args(&full);
if let Some(c) = cwd {
cmd.current_dir(c);
}
let out = cmd.output().await?;
Ok(harness_core::ProcessOutput {
status: out.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&out.stdout).to_string(),
stderr: String::from_utf8_lossy(&out.stderr).to_string(),
})
}
}
#[async_trait]
impl Sandbox for BubblewrapSandbox {
fn fs_policy(&self) -> FsPolicy {
match (self.confine_reads, self.confine_writes) {
(true, _) => FsPolicy::Confined,
(false, true) => FsPolicy::HostReadConfinedWrite,
(false, false) => FsPolicy::Unrestricted,
}
}
fn net_policy(&self) -> NetPolicy {
if self.allow_net {
NetPolicy::Allowed
} else {
NetPolicy::None
}
}
fn isolation(&self) -> Isolation {
Isolation::Process
}
async fn spawn(&self) -> Result<SandboxHandle, SandboxError> {
let probe = tokio::process::Command::new("bwrap")
.arg("--version")
.output()
.await
.map_err(|e| SandboxError::GitMissing(format!("bwrap unavailable: {e}")))?;
if !probe.status.success() {
return Err(SandboxError::GitMissing(
"bwrap probe failed (Linux only)".into(),
));
}
let runner = Arc::new(BwrapRunner {
args: bwrap_args(
&self.root,
self.allow_net,
self.confine_writes,
self.confine_reads,
),
});
let world = World {
repo: RepoView {
root: self.root.clone(),
},
runner,
clock: Arc::new(harness_context::SystemClock),
kv: Arc::new(harness_context::InMemoryKv::new()),
profile: harness_core::UserProfile::default(),
session: None,
};
Ok(SandboxHandle {
world,
root: self.root.clone(),
cleanup: None,
keep: false,
label: "bubblewrap".into(),
})
}
}
pub struct NullSandbox {
pub root: PathBuf,
}
impl NullSandbox {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
}
#[async_trait]
impl Sandbox for NullSandbox {
fn fs_policy(&self) -> FsPolicy {
FsPolicy::Unrestricted
}
fn net_policy(&self) -> NetPolicy {
NetPolicy::Allowed
}
fn isolation(&self) -> Isolation {
Isolation::None
}
async fn spawn(&self) -> Result<SandboxHandle, SandboxError> {
let world = World {
repo: RepoView {
root: self.root.clone(),
},
runner: Arc::new(harness_context::TokioRunner),
clock: Arc::new(harness_context::SystemClock),
kv: Arc::new(harness_context::InMemoryKv::new()),
profile: harness_core::UserProfile::default(),
session: None,
};
Ok(SandboxHandle {
world,
root: self.root.clone(),
cleanup: None,
keep: false,
label: "null".into(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn container_sandbox_fails_cleanly_without_docker() {
let s = ContainerSandbox::new("harness-nonexistent-image-xyzzy:latest", ".");
let outcome = tokio::time::timeout(std::time::Duration::from_secs(20), s.spawn()).await;
match outcome {
Ok(spawned) => assert!(spawned.is_err(), "expected error spawning bogus container"),
Err(_elapsed) => { }
}
}
#[tokio::test]
async fn null_sandbox_returns_world() {
let s = NullSandbox::new(".");
let h = s.spawn().await.unwrap();
assert_eq!(h.label(), "null");
}
#[test]
fn seatbelt_profile_denies_network_by_default() {
let p = seatbelt_profile(std::path::Path::new("/tmp/x"), false, false, false);
assert!(p.contains("(deny network*)"));
let allowed = seatbelt_profile(std::path::Path::new("/tmp/x"), true, false, false);
assert!(!allowed.contains("(deny network*)"));
}
#[test]
fn seatbelt_read_confinement_is_off_unless_asked() {
let p = seatbelt_profile(std::path::Path::new("/work"), false, true, false);
assert!(
!p.contains("(deny file-read*)"),
"write confinement must not silently imply read confinement"
);
}
#[test]
fn seatbelt_read_confinement_allows_root_and_system_paths() {
let p = seatbelt_profile(std::path::Path::new("/work/tenant-a"), false, true, true);
assert!(p.contains("(deny file-read*)"));
assert!(p.contains("(allow file-read* (subpath \"/work/tenant-a\"))"));
for required in ["/usr", "/System", "/private/var/db/dyld"] {
assert!(
p.contains(&format!("(allow file-read* (subpath \"{required}\"))")),
"missing read allow for {required}"
);
}
assert!(p.contains("(allow file-read-metadata)"));
assert!(p.contains("(allow file-read* (literal \"/\"))"));
assert!(!p.contains("/work/tenant-b"));
assert!(p.contains(
"(allow file-write* (subpath \"/private/var/folders\") (subpath \"/private/tmp\"))"
));
assert!(!p.contains("(allow file-read* (subpath \"/private/tmp\"))"));
assert!(!p.contains("(allow file-read* (subpath \"/private/var/folders\"))"));
}
#[test]
fn seatbelt_confine_reads_implies_confine_writes() {
let s = SeatbeltSandbox::new("/work").with_confine_reads(true);
assert!(s.confine_writes);
assert_eq!(s.fs_policy(), FsPolicy::Confined);
assert_eq!(
SeatbeltSandbox::new("/work")
.with_confine_writes(true)
.fs_policy(),
FsPolicy::HostReadConfinedWrite
);
}
#[test]
fn bwrap_args_deny_net_and_confine_writes_by_default() {
let a = bwrap_args(std::path::Path::new("/work"), false, true, false);
assert!(a.iter().any(|x| x == "--unshare-net"));
assert!(a.windows(3).any(|w| w == ["--ro-bind", "/", "/"]));
assert!(a.windows(3).any(|w| w == ["--bind", "/work", "/work"]));
let net = bwrap_args(std::path::Path::new("/work"), true, true, false);
assert!(!net.iter().any(|x| x == "--unshare-net"));
}
#[test]
fn bwrap_read_confinement_binds_system_paths_only() {
let a = bwrap_args(std::path::Path::new("/work/tenant-a"), false, true, true);
assert!(
!a.windows(3).any(|w| w == ["--ro-bind", "/", "/"]),
"read-confined sandbox must not bind the whole filesystem"
);
assert!(a.windows(3).any(|w| w == ["--ro-bind-try", "/usr", "/usr"]));
assert!(
a.windows(3)
.any(|w| w == ["--bind", "/work/tenant-a", "/work/tenant-a"])
);
assert!(!a.iter().any(|x| x == "/home"));
assert!(a.windows(2).any(|w| w == ["--tmpfs", "/tmp"]));
}
#[test]
fn bwrap_confine_reads_implies_confine_writes() {
let s = BubblewrapSandbox::new("/work").with_confine_reads(true);
assert!(s.confine_writes);
assert_eq!(s.fs_policy(), FsPolicy::Confined);
}
#[tokio::test]
async fn bubblewrap_fails_cleanly_off_linux() {
let absent = std::process::Command::new("bwrap")
.arg("--version")
.output()
.map(|o| !o.status.success())
.unwrap_or(true);
if absent {
assert!(BubblewrapSandbox::new(".").spawn().await.is_err());
}
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn seatbelt_confined_reads_block_a_sibling_tenant() {
let base =
std::env::temp_dir().join(format!("harness-sandbox-reads-{}", std::process::id()));
let mine = base.join("tenant-a");
let theirs = base.join("tenant-b");
std::fs::create_dir_all(&mine).unwrap();
std::fs::create_dir_all(&theirs).unwrap();
let my_file = mine.join("ok.txt");
let their_secret = theirs.join("secret.txt");
std::fs::write(&my_file, "mine\n").unwrap();
std::fs::write(&their_secret, "SECRET\n").unwrap();
let real_root = std::fs::canonicalize(&mine).unwrap();
let real_secret = std::fs::canonicalize(&their_secret).unwrap();
let h = SeatbeltSandbox::new(&real_root)
.with_confine_reads(true)
.spawn()
.await
.expect("seatbelt spawns");
let own = h
.world
.runner
.exec(
"/bin/cat",
&[real_root.join("ok.txt").to_str().unwrap()],
None,
)
.await
.unwrap();
assert_eq!(own.status, 0, "own workspace must stay readable: {own:?}");
assert!(own.stdout.contains("mine"));
let stolen = h
.world
.runner
.exec("/bin/cat", &[real_secret.to_str().unwrap()], None)
.await
.unwrap();
assert_ne!(stolen.status, 0, "sibling tenant read must be denied");
assert!(
!stolen.stdout.contains("SECRET"),
"secret leaked out of the sandbox: {stolen:?}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn seatbelt_denies_child_network_without_touching_parent() {
let curl = "/usr/bin/curl";
if !std::path::Path::new(curl).exists() {
return;
}
let probe = ["-s", "-m", "6", "-o", "/dev/null", "https://example.com"];
let parent_net = || {
std::process::Command::new(curl)
.args(probe)
.output()
.map(|o| o.status.code().unwrap_or(-1))
.unwrap_or(-1)
};
if parent_net() != 0 {
return; }
let h = SeatbeltSandbox::new(".")
.spawn()
.await
.expect("seatbelt spawns");
let echo = h
.world
.runner
.exec("/bin/echo", &["hi"], None)
.await
.unwrap();
assert_eq!(echo.status, 0);
let blocked = h.world.runner.exec(curl, &probe, None).await.unwrap();
assert_ne!(blocked.status, 0, "child network must be denied");
let unrestricted = (0..3).any(|_| parent_net() == 0);
assert!(unrestricted, "parent must remain unsandboxed");
}
#[tokio::test]
async fn worktree_sandbox_spawns_and_cleans_up() {
let probe = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output();
let Ok(out) = probe else {
return;
};
if !out.status.success() {
return;
}
let src = String::from_utf8_lossy(&out.stdout).trim().to_string();
let branch = format!("harness-test-{}", std::process::id());
let s = WorktreeSandbox::new(&src, &branch);
let handle = s.spawn().await.expect("worktree spawns");
assert!(handle.root().exists(), "worktree directory exists");
assert!(handle.label().starts_with("worktree:"));
let root = handle.root().to_path_buf();
drop(handle);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async {
while root.exists() {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
})
.await;
}
}