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> {
cx.check_exec(&self.program)?;
let effective = cx.caveats().clone();
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)
}
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(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)"
);
}
}