use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use crate::{
best_available_sandbox, effective_sandbox_kind, enforcement_report, fence_strength,
AxisEnforcement, Caveats, SandboxKind, SandboxPolicy, ToolContext, ToolError, ToolResult,
};
#[cfg(test)]
use crate::Scope;
#[derive(Debug)]
pub struct ConfinedChild {
pub child: Child,
pub sandbox_kind: SandboxKind,
}
#[derive(Debug)]
pub struct ConfinedCommand {
program: String,
args: Vec<OsString>,
envs: Vec<(OsString, OsString)>,
cwd: Option<PathBuf>,
stdin: Option<Stdio>,
stdout: Option<Stdio>,
stderr: Option<Stdio>,
sandbox_policy: Arc<SandboxPolicy>,
}
impl ConfinedCommand {
pub fn new(program: impl Into<String>) -> Self {
Self {
program: program.into(),
args: Vec::new(),
envs: Vec::new(),
cwd: None,
stdin: None,
stdout: None,
stderr: None,
sandbox_policy: Arc::new(SandboxPolicy::default()),
}
}
#[must_use]
pub fn sandbox_policy(mut self, policy: Arc<SandboxPolicy>) -> Self {
self.sandbox_policy = policy;
self
}
#[must_use]
pub fn arg(mut self, arg: impl AsRef<OsStr>) -> Self {
self.args.push(arg.as_ref().to_os_string());
self
}
#[must_use]
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.args
.extend(args.into_iter().map(|a| a.as_ref().to_os_string()));
self
}
#[must_use]
pub fn env(mut self, key: impl AsRef<OsStr>, val: impl AsRef<OsStr>) -> Self {
self.envs
.push((key.as_ref().to_os_string(), val.as_ref().to_os_string()));
self
}
#[must_use]
pub fn current_dir(mut self, dir: impl AsRef<Path>) -> Self {
self.cwd = Some(dir.as_ref().to_path_buf());
self
}
#[must_use]
pub fn stdin(mut self, cfg: Stdio) -> Self {
self.stdin = Some(cfg);
self
}
#[must_use]
pub fn stdout(mut self, cfg: Stdio) -> Self {
self.stdout = Some(cfg);
self
}
#[must_use]
pub fn stderr(mut self, cfg: Stdio) -> Self {
self.stderr = Some(cfg);
self
}
pub fn spawn(self, cx: &ToolContext) -> ToolResult<ConfinedChild> {
let effective = cx.caveats().clone();
self.spawn_with_effective(cx, effective)
}
fn spawn_with_effective(
self,
cx: &ToolContext,
effective: Caveats,
) -> ToolResult<ConfinedChild> {
cx.check_exec(&self.program)?;
let sandbox = best_available_sandbox(&self.sandbox_policy);
let kind = sandbox.kind();
let reported_kind = effective_sandbox_kind(kind, &effective);
if confinement_unenforceable(reported_kind, &effective, cx.strength_floor()) {
return Err(ToolError::denied(format!(
"refusing to spawn {:?}: a restricted filesystem/exec/net axis cannot be \
enforced on a subprocess at the required strength floor ({:?}) by the \
governing sandbox ({:?})",
self.program,
cx.strength_floor(),
reported_kind
)));
}
let prefix = sandbox.command_prefix(&effective)?;
let Self {
program,
args,
envs,
cwd,
stdin,
stdout,
stderr,
sandbox_policy: _,
} = self;
let spawned = std::thread::spawn(move || -> ToolResult<Child> {
if prefix.is_empty() {
sandbox.apply(&effective)?;
}
let (spawn_program, spawn_args) = wrap_argv(&prefix, &program, &args);
let mut cmd = Command::new(&spawn_program);
cmd.args(&spawn_args);
cmd.env_clear(); for (k, v) in &envs {
cmd.env(k, v); }
if let Some(dir) = &cwd {
cmd.current_dir(dir);
}
if let Some(cfg) = stdin {
cmd.stdin(cfg);
}
if let Some(cfg) = stdout {
cmd.stdout(cfg);
}
if let Some(cfg) = stderr {
cmd.stderr(cfg);
}
cmd.spawn().map_err(ToolError::from)
})
.join()
.map_err(|_| ToolError::denied("confined-spawn thread panicked before exec"))?;
Ok(ConfinedChild {
child: spawned?,
sandbox_kind: reported_kind,
})
}
}
pub fn spawn_confined_subprocess(
program: &str,
args: &[String],
cx: &ToolContext,
env_allow: &[(String, String)],
cwd: Option<&Path>,
) -> ToolResult<ConfinedChild> {
let mut cmd = ConfinedCommand::new(program).args(args);
for (k, v) in env_allow {
cmd = cmd.env(k, v);
}
if let Some(dir) = cwd {
cmd = cmd.current_dir(dir);
}
cmd.spawn(cx)
}
#[cfg(all(unix, feature = "spawn-tokio"))]
pub use tokio_spawn::ConfinedTokioChild;
#[cfg(all(unix, feature = "spawn-tokio"))]
mod tokio_spawn {
use super::{ConfinedChild, ConfinedCommand, SandboxKind, ToolContext, ToolResult};
use crate::net_proxy::ProxyHandle;
use crate::{egress_proxy_plan, ToolError};
use std::os::fd::OwnedFd;
use std::process::Child;
use tokio::net::unix::pipe;
#[derive(Debug)]
pub struct ConfinedTokioChild {
pub sandbox_kind: SandboxKind,
stdin: Option<pipe::Sender>,
stdout: Option<pipe::Receiver>,
stderr: Option<pipe::Receiver>,
child: Option<Child>,
proxy: Option<ProxyHandle>,
}
impl ConfinedTokioChild {
pub fn take_stdin(&mut self) -> Option<pipe::Sender> {
self.stdin.take()
}
pub fn take_stdout(&mut self) -> Option<pipe::Receiver> {
self.stdout.take()
}
pub fn take_stderr(&mut self) -> Option<pipe::Receiver> {
self.stderr.take()
}
pub fn egress_proxied(&self) -> bool {
self.proxy.is_some()
}
pub fn refused_hosts(&self) -> Vec<String> {
self.proxy
.as_ref()
.map(ProxyHandle::refused_hosts)
.unwrap_or_default()
}
}
impl Drop for ConfinedTokioChild {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
std::thread::spawn(move || {
let _ = child.wait();
});
}
}
}
impl ConfinedCommand {
pub fn spawn_tokio(mut self, cx: &ToolContext) -> ToolResult<ConfinedTokioChild> {
let mut proxy = None;
let mut effective = cx.caveats().clone();
if let Some((hosts, fenced)) = egress_proxy_plan(&effective, &self.sandbox_policy) {
let handle = crate::net_proxy::start_for_hosts(hosts).map_err(|e| {
ToolError::Exec(std::io::Error::other(format!(
"refusing to spawn {:?}: the egress proxy could not bind \
loopback ({e})",
self.program
)))
})?;
for (k, v) in handle.proxy_env() {
self = self.env(k, v);
}
proxy = Some(handle);
effective = fenced;
}
let ConfinedChild {
mut child,
sandbox_kind,
} = self.spawn_with_effective(cx, effective)?;
let stdin = child
.stdin
.take()
.map(|h| pipe::Sender::from_owned_fd(OwnedFd::from(h)))
.transpose()?;
let stdout = child
.stdout
.take()
.map(|h| pipe::Receiver::from_owned_fd(OwnedFd::from(h)))
.transpose()?;
let stderr = child
.stderr
.take()
.map(|h| pipe::Receiver::from_owned_fd(OwnedFd::from(h)))
.transpose()?;
Ok(ConfinedTokioChild {
sandbox_kind,
stdin,
stdout,
stderr,
child: Some(child),
proxy,
})
}
}
}
fn wrap_argv(prefix: &[String], program: &str, args: &[OsString]) -> (OsString, Vec<OsString>) {
if prefix.is_empty() {
return (OsString::from(program), args.to_vec());
}
let mut argv: Vec<OsString> = prefix[1..].iter().map(OsString::from).collect();
argv.push(OsString::from(program));
argv.extend(args.iter().cloned());
(OsString::from(&prefix[0]), argv)
}
#[must_use]
pub fn confinement_unenforceable(
kind: SandboxKind,
caveats: &Caveats,
floor: AxisEnforcement,
) -> bool {
let report = enforcement_report(caveats, kind);
let below_kernel = |e: Option<AxisEnforcement>| e.is_some_and(|e| e != AxisEnforcement::Kernel);
if below_kernel(report.fs_write) || below_kernel(report.fs_read) {
return true;
}
fence_strength(&report).is_some_and(|s| s < floor)
}
#[cfg(all(unix, feature = "spawn-tokio", test))]
mod tokio_spawn_tests {
use super::*;
use crate::{Gate, Tool};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
fn ctx(granted: Caveats) -> ToolContext {
struct AnyTool;
#[async_trait::async_trait]
impl Tool for AnyTool {
fn name(&self) -> &str {
"any"
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({})
}
async fn invoke(
&self,
_a: serde_json::Value,
_c: &ToolContext,
) -> ToolResult<serde_json::Value> {
Ok(serde_json::Value::Null)
}
}
Gate::new(0)
.authorize(&AnyTool, &granted)
.expect("authorize")
}
fn find_cat() -> Option<&'static str> {
["/usr/bin/cat", "/bin/cat"]
.into_iter()
.find(|p| Path::new(p).exists())
}
#[tokio::test]
async fn json_line_round_trips_over_tokio_pipes() {
let Some(cat) = find_cat() else {
eprintln!("skipping: no cat(1) found");
return;
};
let cx = ctx(Caveats {
exec: Scope::only(["cat".to_string()]),
..Caveats::top()
});
let mut child = ConfinedCommand::new(cat)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn_tokio(&cx)
.expect("spawn_tokio cat");
let mut stdin = child.take_stdin().expect("stdin piped");
let stdout = child.take_stdout().expect("stdout piped");
assert!(child.take_stdin().is_none(), "stdin taken once");
let msg = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#;
stdin.write_all(msg.as_bytes()).await.expect("write");
stdin.write_all(b"\n").await.expect("write nl");
stdin.flush().await.expect("flush");
let mut lines = BufReader::new(stdout).lines();
let got = tokio::time::timeout(Duration::from_secs(5), lines.next_line())
.await
.expect("recv did not time out")
.expect("recv ok");
assert_eq!(got.as_deref(), Some(msg));
}
#[tokio::test]
async fn dropping_the_guard_kills_the_child() {
let Some(cat) = find_cat() else {
eprintln!("skipping: no cat(1) found");
return;
};
let cx = ctx(Caveats {
exec: Scope::only(["cat".to_string()]),
..Caveats::top()
});
let mut child = ConfinedCommand::new(cat)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn_tokio(&cx)
.expect("spawn_tokio cat");
let stdout = child.take_stdout().expect("stdout piped");
let _stdin = child.take_stdin().expect("stdin piped");
drop(child);
let mut lines = BufReader::new(stdout).lines();
let eof = tokio::time::timeout(Duration::from_secs(5), lines.next_line())
.await
.expect("EOF did not time out")
.expect("read ok");
assert_eq!(
eof, None,
"kill-on-drop must terminate the child and close its stdout (EOF)"
);
}
#[tokio::test]
async fn remote_net_grant_without_fence_backend_spawns_inert() {
let caveats = Caveats {
exec: Scope::only(["true".to_string()]),
net: Scope::only(["api.example.com".to_string()]),
..Caveats::top()
};
let plan_engages = crate::egress_proxy_plan(
&caveats,
&std::sync::Arc::new(crate::SandboxPolicy::default()),
)
.is_some();
if plan_engages {
eprintln!("skipping: this host CAN emit the loopback fence (engage path)");
return;
}
let cx = ctx(caveats);
let child = ConfinedCommand::new("true")
.spawn_tokio(&cx)
.expect("inert path spawns as before");
assert!(!child.egress_proxied(), "no fence backend → no proxy");
assert!(child.refused_hosts().is_empty());
drop(child);
}
#[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
#[tokio::test]
#[ignore = "integration: real Seatbelt fence + curl subprocess + loopback proxy"]
async fn remote_net_grant_with_fence_spawns_proxied_and_refuses_off_list() {
if !crate::seatbelt_is_supported() {
eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
return;
}
let curl = "/usr/bin/curl"; let caveats = Caveats {
exec: Scope::only([curl.to_string()]),
net: Scope::only(["api.example.com".to_string()]),
..Caveats::top()
};
let cx = ctx(caveats);
let child = ConfinedCommand::new(curl)
.arg("-s")
.arg("-m")
.arg("5")
.arg("https://evil.example.net/")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn_tokio(&cx)
.expect("proxied spawn");
assert!(child.egress_proxied(), "fence host → proxy must engage");
let _ = tokio::time::timeout(Duration::from_secs(10), async {
tokio::time::sleep(Duration::from_secs(1)).await;
})
.await;
assert!(
child
.refused_hosts()
.contains(&"evil.example.net".to_string()),
"the off-allow-list host must be refused and recorded: {:?}",
child.refused_hosts()
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Gate, Tool};
fn ctx(granted: Caveats) -> ToolContext {
struct AnyTool;
#[async_trait::async_trait]
impl Tool for AnyTool {
fn name(&self) -> &str {
"any"
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({})
}
async fn invoke(
&self,
_args: serde_json::Value,
_cx: &ToolContext,
) -> ToolResult<serde_json::Value> {
Ok(serde_json::Value::Null)
}
}
Gate::new(0)
.authorize(&AnyTool, &granted)
.expect("authorize")
}
#[test]
fn exec_outside_scope_is_denied_before_any_spawn() {
let cx = ctx(Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
});
let res = ConfinedCommand::new("rm").arg("-rf").spawn(&cx);
assert!(matches!(res, Err(ToolError::Denied { .. })));
}
#[test]
fn unenforceable_predicate_fs_axes_always_strength_floor_for_exec() {
use AxisEnforcement::{Advisory, Kernel};
let fs_write = Caveats {
fs_write: Scope::only(["/tmp/x".to_string()]),
..Caveats::top()
};
let fs_read = Caveats {
fs_read: Scope::only(["/tmp/x".to_string()]),
..Caveats::top()
};
let exec = Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
};
assert!(confinement_unenforceable(
SandboxKind::None,
&fs_write,
Advisory
));
assert!(confinement_unenforceable(
SandboxKind::None,
&fs_read,
Advisory
));
assert!(!confinement_unenforceable(
SandboxKind::Landlock,
&fs_write,
Advisory
));
assert!(!confinement_unenforceable(
SandboxKind::Landlock,
&fs_read,
Advisory
));
assert!(!confinement_unenforceable(
SandboxKind::None,
&exec,
Advisory
));
assert!(confinement_unenforceable(SandboxKind::None, &exec, Kernel));
assert!(!confinement_unenforceable(
SandboxKind::None,
&Caveats::top(),
Kernel
));
}
#[test]
fn fs_restricted_under_appcontainer_engages_the_launcher() {
let fs = Caveats {
fs_write: Scope::only(["/tmp/x".to_string()]),
..Caveats::top()
};
let governing = effective_sandbox_kind(SandboxKind::AppContainer, &fs);
assert_eq!(
governing,
SandboxKind::AppContainer,
"fs-only must engage AppContainer (ACL narrowing wired, #51)"
);
let report = enforcement_report(&fs, governing);
assert_eq!(report.fs_write, Some(AxisEnforcement::Kernel));
assert!(
!confinement_unenforceable(governing, &fs, AxisEnforcement::Advisory),
"fs-restricted AppContainer is enforceable (launcher wired)"
);
}
#[test]
fn exec_deny_all_under_appcontainer_is_kernel() {
let exec_denied = Caveats {
exec: Scope::only([] as [String; 0]),
..Caveats::top()
};
let governing = effective_sandbox_kind(SandboxKind::AppContainer, &exec_denied);
assert_eq!(
governing,
SandboxKind::AppContainer,
"exec deny-all must engage AppContainer"
);
assert!(
!confinement_unenforceable(governing, &exec_denied, AxisEnforcement::Advisory),
"exec deny-all under AppContainer is enforceable (kernel-level block)"
);
let report = enforcement_report(&exec_denied, governing);
assert_eq!(
report.exec,
Some(AxisEnforcement::Kernel),
"exec deny-all must be Kernel under AppContainer"
);
}
#[cfg(not(any(
all(target_os = "linux", feature = "linux-landlock"),
all(target_os = "macos", feature = "macos-seatbelt"),
all(target_os = "windows", feature = "windows-appcontainer")
)))]
#[test]
fn restrictive_write_refused_when_no_sandbox_available() {
let cx = ctx(Caveats {
exec: Scope::All,
fs_write: Scope::only(["/tmp/allowed".to_string()]),
..Caveats::top()
});
let res = ConfinedCommand::new("true").spawn(&cx);
assert!(
matches!(res, Err(ToolError::Denied { .. })),
"must fail closed when confinement is requested but unenforceable"
);
}
#[cfg(unix)]
#[test]
fn environment_is_scrubbed_to_the_granted_allow_list() {
let env_bin = ["/usr/bin/env", "/bin/env"]
.into_iter()
.find(|p| Path::new(p).exists());
let Some(env_bin) = env_bin else {
eprintln!("skipping env-scrub test: no env(1) found");
return;
};
let cx = ctx(Caveats {
exec: Scope::only(["env".to_string()]),
..Caveats::top()
});
let spawned = ConfinedCommand::new(env_bin)
.env("ALLOWED", "yes")
.stdout(Stdio::piped())
.spawn(&cx)
.expect("spawn env");
let out = spawned.child.wait_with_output().expect("wait");
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("ALLOWED=yes"), "granted var must be present");
assert!(
!text.contains("HOME="),
"ambient parent env must NOT leak into the child: {text:?}"
);
}
}
#[cfg(all(target_os = "linux", feature = "linux-landlock", test))]
mod landlock_child_tests {
use super::*;
use crate::{landlock_is_supported, Gate, Tool};
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
fn ctx(granted: Caveats) -> ToolContext {
struct AnyTool;
#[async_trait::async_trait]
impl Tool for AnyTool {
fn name(&self) -> &str {
"any"
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({})
}
async fn invoke(
&self,
_a: serde_json::Value,
_c: &ToolContext,
) -> ToolResult<serde_json::Value> {
Ok(serde_json::Value::Null)
}
}
Gate::new(0)
.authorize(&AnyTool, &granted)
.expect("authorize")
}
fn unique_dir(tag: &str) -> PathBuf {
static N: AtomicU64 = AtomicU64::new(0);
let mut d = std::env::temp_dir();
d.push(format!(
"agent-bridle-spawn-{}-{}-{}",
tag,
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn child_inherits_fs_write_domain_out_of_scope_denied_in_scope_allowed() {
if !landlock_is_supported() {
eprintln!("skipping: kernel lacks Landlock");
return;
}
let touch = ["/usr/bin/touch", "/bin/touch"]
.into_iter()
.find(|p| std::path::Path::new(p).exists());
let Some(touch) = touch else {
eprintln!("skipping: no touch(1) found");
return;
};
let allowed = unique_dir("allowed");
let forbidden = unique_dir("forbidden");
let cx = ctx(Caveats {
exec: Scope::only(["touch".to_string()]),
fs_write: Scope::only([allowed.to_string_lossy().into_owned()]),
..Caveats::top()
});
let mut out = ConfinedCommand::new(touch)
.arg(forbidden.join("escape.txt"))
.spawn(&cx)
.expect("spawn");
assert_eq!(out.sandbox_kind, SandboxKind::Landlock);
let status = out.child.wait().expect("wait");
assert!(
!status.success(),
"child write outside fs_write must be kernel-denied"
);
assert!(!forbidden.join("escape.txt").exists());
let mut ok = ConfinedCommand::new(touch)
.arg(allowed.join("ok.txt"))
.spawn(&cx)
.expect("spawn");
assert!(ok.child.wait().expect("wait").success());
assert!(allowed.join("ok.txt").exists());
let _ = fs::remove_dir_all(&allowed);
let _ = fs::remove_dir_all(&forbidden);
}
#[test]
fn confined_command_honors_sandbox_policy_base_read() {
if !landlock_is_supported() {
eprintln!("skipping: kernel lacks Landlock");
return;
}
let cat = ["/usr/bin/cat", "/bin/cat"]
.into_iter()
.find(|p| std::path::Path::new(p).exists());
let Some(cat) = cat else {
eprintln!("skipping: no cat(1) found");
return;
};
let allowed = unique_dir("cfg-allowed");
let extra = unique_dir("cfg-extra");
fs::write(extra.join("data.txt"), b"configured").unwrap();
let cx = ctx(Caveats {
exec: Scope::only(["cat".to_string()]),
fs_read: Scope::only([allowed.to_string_lossy().into_owned()]),
..Caveats::top()
});
let mut denied = ConfinedCommand::new(cat)
.arg(extra.join("data.txt"))
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn(&cx)
.expect("spawn");
assert!(
!denied.child.wait().expect("wait").success(),
"default base read must deny the child reading the out-of-scope file"
);
let mut base = SandboxPolicy::default().base_read_paths;
base.extra.push(extra.to_string_lossy().into_owned());
let policy = Arc::new(SandboxPolicy {
base_read_paths: base,
..SandboxPolicy::default()
});
let mut ok = ConfinedCommand::new(cat)
.arg(extra.join("data.txt"))
.sandbox_policy(policy)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn(&cx)
.expect("spawn");
assert!(
ok.child.wait().expect("wait").success(),
"a config-widened base_read_paths must let the child read the extra file"
);
let _ = fs::remove_dir_all(&allowed);
let _ = fs::remove_dir_all(&extra);
}
}
#[cfg(all(target_os = "macos", feature = "macos-seatbelt", test))]
mod seatbelt_child_tests {
use super::*;
use crate::{seatbelt_is_supported, Gate, Tool};
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
fn ctx(granted: Caveats) -> ToolContext {
struct AnyTool;
#[async_trait::async_trait]
impl Tool for AnyTool {
fn name(&self) -> &str {
"any"
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({})
}
async fn invoke(
&self,
_a: serde_json::Value,
_c: &ToolContext,
) -> ToolResult<serde_json::Value> {
Ok(serde_json::Value::Null)
}
}
Gate::new(0)
.authorize(&AnyTool, &granted)
.expect("authorize")
}
fn unique_dir(tag: &str) -> PathBuf {
static N: AtomicU64 = AtomicU64::new(0);
let mut d = std::env::temp_dir();
d.push(format!(
"agent-bridle-spawn-sb-{}-{}-{}",
tag,
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn child_inherits_fs_write_domain_out_of_scope_denied_in_scope_allowed() {
if !seatbelt_is_supported() {
eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
return;
}
let allowed = unique_dir("allowed");
let forbidden = unique_dir("forbidden");
let cx = ctx(Caveats {
exec: Scope::only(["/usr/bin/touch".to_string()]),
fs_write: Scope::only([allowed.to_string_lossy().into_owned()]),
..Caveats::top()
});
let mut out = ConfinedCommand::new("/usr/bin/touch")
.arg(forbidden.join("escape.txt"))
.spawn(&cx)
.expect("spawn");
assert_eq!(out.sandbox_kind, SandboxKind::Seatbelt);
let status = out.child.wait().expect("wait");
assert!(
!status.success(),
"child write outside fs_write must be kernel-denied"
);
assert!(!forbidden.join("escape.txt").exists());
let mut ok = ConfinedCommand::new("/usr/bin/touch")
.arg(allowed.join("ok.txt"))
.spawn(&cx)
.expect("spawn");
assert!(ok.child.wait().expect("wait").success());
assert!(allowed.join("ok.txt").exists());
let _ = fs::remove_dir_all(&allowed);
let _ = fs::remove_dir_all(&forbidden);
}
#[test]
fn top_grant_confines_nothing_reports_none() {
if !seatbelt_is_supported() {
eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
return;
}
let cx = ctx(Caveats::top());
let child = ConfinedCommand::new("/usr/bin/true")
.spawn(&cx)
.expect("spawn");
assert_eq!(
child.sandbox_kind,
SandboxKind::None,
"nothing restricted => nothing confined => None, not the raw backend kind"
);
}
#[test]
fn restricted_exec_engages_seatbelt() {
if !seatbelt_is_supported() {
eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
return;
}
let cx = ctx(Caveats {
exec: Scope::only(["/usr/bin/true".to_string()]),
..Caveats::top()
});
let child = ConfinedCommand::new("/usr/bin/true")
.spawn(&cx)
.expect("spawn");
assert_eq!(
child.sandbox_kind,
SandboxKind::Seatbelt,
"a restricted exec axis is kernel-confined by process-exec* (ADR 0014)"
);
}
}