use std::ffi::{OsStr, OsString};
#[cfg(any(target_os = "linux", target_os = "macos"))]
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::time::Duration;
use crate::{
best_available_sandbox, effective_sandbox_kind, enforcement_report, fence_strength,
AxisEnforcement, Caveats, SandboxKind, SandboxPolicy, ToolContext, ToolError, ToolResult,
};
use agent_mesh_protocol::Fingerprint;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
#[cfg(test)]
use crate::Scope;
#[derive(Debug)]
pub struct ConfinedChild {
pub child: Child,
pub sandbox_kind: SandboxKind,
}
#[derive(Debug)]
pub struct SandboxedWorkerChild {
pub child: Child,
pub sandbox_kind: SandboxKind,
control: Option<TrustedWorkerControl>,
}
impl SandboxedWorkerChild {
pub fn send_payload<T: Serialize>(&mut self, payload: &T, timeout: Duration) -> ToolResult<()> {
let mut control = self
.control
.take()
.ok_or_else(|| ToolError::denied("trusted worker request was already sent"))?;
control.send(payload, self.child.id(), timeout)
}
}
#[derive(Debug)]
struct TrustedWorkerControl {
#[cfg(any(target_os = "linux", target_os = "macos"))]
stream: std::os::unix::net::UnixStream,
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
unavailable: (),
nonce: String,
caveats: Caveats,
strength_floor: AxisEnforcement,
}
impl TrustedWorkerControl {
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn send<T: Serialize>(
&mut self,
payload: &T,
child_pid: u32,
timeout: Duration,
) -> ToolResult<()> {
self.stream
.set_read_timeout(Some(timeout))
.map_err(ToolError::from)?;
self.stream
.set_write_timeout(Some(timeout))
.map_err(ToolError::from)?;
self.stream
.write_all(&TRUSTED_WORKER_BOOTSTRAP)
.and_then(|()| self.stream.flush())
.map_err(ToolError::from)?;
let mut hello = [0_u8; TRUSTED_WORKER_HELLO_LEN];
self.stream
.read_exact(&mut hello)
.map_err(ToolError::from)?;
let (reported_pid, challenge) = decode_trusted_worker_hello(&hello)
.map_err(|error| ToolError::denied(format!("invalid worker hello: {error}")))?;
if reported_pid != child_pid {
return Err(ToolError::denied(format!(
"worker hello PID mismatch: spawned {child_pid}, reported {reported_pid}"
)));
}
let request = TrustedWorkerRequest {
version: TRUSTED_WORKER_PROTOCOL_VERSION,
nonce: self.nonce.clone(),
caveats: self.caveats.clone(),
strength_floor: self.strength_floor,
payload,
};
let body = serde_json::to_vec(&request)
.map_err(|error| ToolError::denied(format!("encode worker request: {error}")))?;
if body.len() > TRUSTED_WORKER_MAX_BODY {
return Err(ToolError::denied(
"trusted worker request exceeds its 1 MiB cap",
));
}
let header = encode_trusted_worker_frame_header(
challenge,
trusted_worker_frame_digest(&challenge, &body),
body.len(),
)
.map_err(ToolError::denied)?;
self.stream.write_all(&header).map_err(ToolError::from)?;
self.stream.write_all(&body).map_err(ToolError::from)?;
self.stream.flush().map_err(ToolError::from)?;
let mut ack = [0_u8; TRUSTED_WORKER_ACK.len()];
self.stream.read_exact(&mut ack).map_err(ToolError::from)?;
if ack != TRUSTED_WORKER_ACK {
return Err(ToolError::denied(
"trusted worker returned an invalid authentication ACK",
));
}
let _ = self.stream.shutdown(std::net::Shutdown::Write);
Ok(())
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn send<T: Serialize>(
&mut self,
payload: &T,
child_pid: u32,
timeout: Duration,
) -> ToolResult<()> {
let _ = (
&self.unavailable,
&self.nonce,
&self.caveats,
self.strength_floor,
payload,
child_pid,
timeout,
);
Err(ToolError::denied(
"trusted worker control channels are unavailable on this platform",
))
}
}
pub const TRUSTED_WORKER_PROTOCOL_VERSION: u8 = 1;
pub const TRUSTED_WORKER_MAX_BODY: usize = 1024 * 1024;
pub const TRUSTED_WORKER_BOOTSTRAP: [u8; 8] = *b"ABTW-B1\0";
pub const TRUSTED_WORKER_ACK: [u8; 8] = *b"ABTW-A1\0";
const TRUSTED_WORKER_HELLO_MAGIC: [u8; 8] = *b"ABTW-H1\0";
const TRUSTED_WORKER_FRAME_MAGIC: [u8; 8] = *b"ABTW-R1\0";
pub const TRUSTED_WORKER_HELLO_LEN: usize = 8 + 4 + 32;
pub const TRUSTED_WORKER_FRAME_HEADER_LEN: usize = 8 + 4 + 32 + 32;
const TRUSTED_WORKER_DIGEST_DOMAIN: &[u8] = b"agent-bridle/trusted-worker-frame/v1";
#[derive(Debug, Serialize, Deserialize)]
pub struct TrustedWorkerRequest<P> {
version: u8,
nonce: String,
caveats: Caveats,
strength_floor: AxisEnforcement,
payload: P,
}
impl<P> TrustedWorkerRequest<P> {
#[must_use]
pub fn into_parts(self) -> (String, Caveats, AxisEnforcement, P) {
(self.nonce, self.caveats, self.strength_floor, self.payload)
}
#[must_use]
pub fn has_supported_version(&self) -> bool {
self.version == TRUSTED_WORKER_PROTOCOL_VERSION
}
}
#[must_use]
pub fn encode_trusted_worker_hello(child_pid: u32, challenge: [u8; 32]) -> [u8; 44] {
let mut frame = [0_u8; TRUSTED_WORKER_HELLO_LEN];
frame[..8].copy_from_slice(&TRUSTED_WORKER_HELLO_MAGIC);
frame[8..12].copy_from_slice(&child_pid.to_le_bytes());
frame[12..].copy_from_slice(&challenge);
frame
}
pub fn decode_trusted_worker_hello(frame: &[u8]) -> Result<(u32, [u8; 32]), String> {
if frame.len() != TRUSTED_WORKER_HELLO_LEN || frame[..8] != TRUSTED_WORKER_HELLO_MAGIC {
return Err("bad trusted-worker hello framing".to_string());
}
let pid = u32::from_le_bytes(
frame[8..12]
.try_into()
.map_err(|_| "bad trusted-worker PID field")?,
);
let challenge = frame[12..]
.try_into()
.map_err(|_| "bad trusted-worker challenge field")?;
Ok((pid, challenge))
}
pub fn encode_trusted_worker_frame_header(
challenge: [u8; 32],
digest: [u8; 32],
body_len: usize,
) -> Result<[u8; TRUSTED_WORKER_FRAME_HEADER_LEN], String> {
let body_len = u32::try_from(body_len)
.map_err(|_| "trusted-worker request length does not fit its frame".to_string())?;
let mut frame = [0_u8; TRUSTED_WORKER_FRAME_HEADER_LEN];
frame[..8].copy_from_slice(&TRUSTED_WORKER_FRAME_MAGIC);
frame[8..12].copy_from_slice(&body_len.to_le_bytes());
frame[12..44].copy_from_slice(&challenge);
frame[44..].copy_from_slice(&digest);
Ok(frame)
}
pub fn decode_trusted_worker_frame_header(
frame: &[u8],
) -> Result<(usize, [u8; 32], [u8; 32]), String> {
if frame.len() != TRUSTED_WORKER_FRAME_HEADER_LEN || frame[..8] != TRUSTED_WORKER_FRAME_MAGIC {
return Err("bad trusted-worker response framing".to_string());
}
let body_len = u32::from_le_bytes(
frame[8..12]
.try_into()
.map_err(|_| "bad trusted-worker length field")?,
) as usize;
if body_len > TRUSTED_WORKER_MAX_BODY {
return Err("trusted-worker request exceeds its 1 MiB cap".to_string());
}
let challenge = frame[12..44]
.try_into()
.map_err(|_| "bad trusted-worker challenge field")?;
let digest = frame[44..]
.try_into()
.map_err(|_| "bad trusted-worker digest field")?;
Ok((body_len, challenge, digest))
}
#[must_use]
pub fn trusted_worker_frame_digest(challenge: &[u8; 32], body: &[u8]) -> [u8; 32] {
let mut framed =
Vec::with_capacity(TRUSTED_WORKER_DIGEST_DOMAIN.len() + challenge.len() + body.len());
framed.extend_from_slice(TRUSTED_WORKER_DIGEST_DOMAIN);
framed.extend_from_slice(challenge);
framed.extend_from_slice(body);
Fingerprint::of_bytes(&framed).0
}
pub fn decode_trusted_worker_request<P: DeserializeOwned>(
body: &[u8],
) -> Result<TrustedWorkerRequest<P>, String> {
serde_json::from_slice(body).map_err(|error| format!("invalid trusted-worker request: {error}"))
}
#[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>,
new_process_group: bool,
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,
new_process_group: false,
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
}
#[must_use]
pub fn new_process_group(mut self) -> Self {
self.new_process_group = true;
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> {
self.spawn_authorized(cx, effective, SpawnAuthority::ModelSelected)
}
fn spawn_authorized(
self,
cx: &ToolContext,
effective: Caveats,
authority: SpawnAuthority,
) -> ToolResult<ConfinedChild> {
if authority == SpawnAuthority::ModelSelected {
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 mechanism_effective = if authority == SpawnAuthority::TrustedWorker {
trusted_worker_mechanism_caveats(kind, &effective, &self.program)?
} else {
effective.clone()
};
let prefix = sandbox.command_prefix(&mechanism_effective)?;
let Self {
program,
args,
envs,
cwd,
stdin,
stdout,
stderr,
new_process_group,
sandbox_policy: _,
} = self;
let spawned = std::thread::spawn(move || -> ToolResult<Child> {
if prefix.is_empty() {
sandbox.apply(&mechanism_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);
}
#[cfg(unix)]
if new_process_group {
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(not(unix))]
let _ = new_process_group;
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,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpawnAuthority {
ModelSelected,
TrustedWorker,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustedWorkerKind {
Brush,
}
impl TrustedWorkerKind {
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn args(self) -> [&'static str; 2] {
match self {
Self::Brush => ["--agent-bridle-worker", "brush"],
}
}
}
#[derive(Debug, Clone)]
pub struct SandboxedWorker {
#[cfg(any(target_os = "linux", target_os = "macos"))]
kind: TrustedWorkerKind,
sandbox_policy: Arc<SandboxPolicy>,
}
impl SandboxedWorker {
#[must_use]
pub fn brush() -> Self {
Self {
#[cfg(any(target_os = "linux", target_os = "macos"))]
kind: TrustedWorkerKind::Brush,
sandbox_policy: Arc::new(SandboxPolicy::default()),
}
}
#[must_use]
pub fn sandbox_policy(mut self, policy: Arc<SandboxPolicy>) -> Self {
self.sandbox_policy = policy;
self
}
pub fn spawn(
self,
cx: &ToolContext,
nonce: &str,
cwd: &Path,
) -> ToolResult<SandboxedWorkerChild> {
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = (self, cx, nonce, cwd);
Err(ToolError::denied(
"refusing the Brush worker: this platform has no authenticated \
private-control transport",
))
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
self.spawn_supported(cx, nonce, cwd)
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn spawn_supported(
self,
cx: &ToolContext,
nonce: &str,
cwd: &Path,
) -> ToolResult<SandboxedWorkerChild> {
cx.check_path_read(cwd)?;
let request_caveats = cx.caveats().clone();
let request_strength_floor = cx.strength_floor();
let executable = std::env::current_exe()
.and_then(std::fs::canonicalize)
.map_err(|error| ToolError::denied(format!("worker executable is invalid: {error}")))?;
let executable = executable.to_string_lossy().into_owned();
let [flag, kind] = self.kind.args();
#[cfg(any(target_os = "linux", target_os = "macos"))]
let (control, child_control) = std::os::unix::net::UnixStream::pair().map_err(|error| {
ToolError::denied(format!("create worker control channel: {error}"))
})?;
let command = ConfinedCommand::new(executable)
.args([flag, kind])
.env("AGENT_BRIDLE_WORKER_NONCE", nonce)
.current_dir(cwd)
.stdin(Stdio::from(std::os::fd::OwnedFd::from(child_control)))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.new_process_group()
.sandbox_policy(self.sandbox_policy);
let confined = if crate::is_unbridled() {
command.spawn_authorized(cx, Caveats::top(), SpawnAuthority::TrustedWorker)
} else {
let effective = cx.caveats().clone();
let available = best_available_sandbox(&command.sandbox_policy).kind();
let reported = effective_sandbox_kind(available, &effective);
#[cfg(not(target_os = "linux"))]
let _ = reported;
#[cfg(target_os = "linux")]
if reported == SandboxKind::Landlock
&& crate::sandbox::restricts_fs(&effective)
&& !linux_user_namespaces_hardened()
{
return Err(ToolError::denied(
"refusing the Brush worker: Landlock filesystem confinement \
is not a complete boundary while unprivileged user namespaces \
remain available; disable them or add the namespace syscall backstop",
));
}
command.spawn_authorized(cx, effective, SpawnAuthority::TrustedWorker)
}?;
Ok(SandboxedWorkerChild {
child: confined.child,
sandbox_kind: confined.sandbox_kind,
control: Some(TrustedWorkerControl {
stream: control,
nonce: nonce.to_string(),
caveats: request_caveats,
strength_floor: request_strength_floor,
}),
})
}
}
fn trusted_worker_mechanism_caveats(
kind: SandboxKind,
effective: &Caveats,
program: &str,
) -> ToolResult<Caveats> {
match kind {
SandboxKind::Landlock
| SandboxKind::Seatbelt
| SandboxKind::MinimalRootfs
| SandboxKind::MicroVm => caveats_with_trusted_program(effective, program),
SandboxKind::AppContainer | SandboxKind::None => Ok(effective.clone()),
}
}
fn caveats_with_trusted_program(effective: &Caveats, program: &str) -> ToolResult<Caveats> {
let canonical = Path::new(program)
.canonicalize()
.map_err(|error| ToolError::denied(format!("cannot resolve trusted worker: {error}")))?
.to_string_lossy()
.into_owned();
let mut mechanism = effective.clone();
if let crate::Scope::Only(programs) = &mut mechanism.exec {
programs.insert(canonical);
}
Ok(mechanism)
}
#[cfg(target_os = "linux")]
fn linux_user_namespaces_hardened() -> bool {
fn sysctl_is(path: &str, expected: &str) -> bool {
std::fs::read_to_string(path).is_ok_and(|value| value.trim() == expected)
}
sysctl_is("/proc/sys/kernel/unprivileged_userns_clone", "0")
|| sysctl_is("/proc/sys/user/max_user_namespaces", "0")
|| sysctl_is(
"/proc/sys/kernel/apparmor_restrict_unprivileged_userns",
"1",
)
}
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")
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn trusted_worker_control_freezes_authority_and_is_take_once() {
let true_program = ["/usr/bin/true", "/bin/true"]
.into_iter()
.find(|path| Path::new(path).exists())
.expect("true executable");
let process = Command::new(true_program)
.spawn()
.expect("spawn PID holder");
let child_pid = process.id();
let (client, mut server) =
std::os::unix::net::UnixStream::pair().expect("private socketpair");
let (seen_tx, seen_rx) = std::sync::mpsc::channel();
let peer = std::thread::spawn(move || {
let mut bootstrap = [0_u8; TRUSTED_WORKER_BOOTSTRAP.len()];
server.read_exact(&mut bootstrap).expect("read bootstrap");
assert_eq!(bootstrap, TRUSTED_WORKER_BOOTSTRAP);
let challenge = [0x5a; 32];
server
.write_all(&encode_trusted_worker_hello(child_pid, challenge))
.and_then(|()| server.flush())
.expect("write hello");
let mut header = [0_u8; TRUSTED_WORKER_FRAME_HEADER_LEN];
server.read_exact(&mut header).expect("read header");
let (body_len, echoed, digest) =
decode_trusted_worker_frame_header(&header).expect("decode header");
assert_eq!(echoed, challenge);
let mut body = vec![0_u8; body_len];
server.read_exact(&mut body).expect("read body");
assert_eq!(digest, trusted_worker_frame_digest(&challenge, &body));
let request: TrustedWorkerRequest<serde_json::Value> =
decode_trusted_worker_request(&body).expect("decode request");
seen_tx.send(request.into_parts()).expect("report request");
server
.write_all(&TRUSTED_WORKER_ACK)
.and_then(|()| server.flush())
.expect("write ACK");
});
let frozen = Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
};
let control = TrustedWorkerControl {
stream: client,
nonce: "core-owned-nonce".to_string(),
caveats: frozen.clone(),
strength_floor: AxisEnforcement::Advisory,
};
let mut worker = SandboxedWorkerChild {
child: process,
sandbox_kind: SandboxKind::None,
control: Some(control),
};
let forged_payload = serde_json::json!({
"cmd": "echo ok",
"caveats": Caveats::top(),
"strength_floor": AxisEnforcement::Kernel,
});
worker
.send_payload(&forged_payload, Duration::from_secs(5))
.expect("first payload");
let (nonce, authority, floor, payload) = seen_rx.recv().expect("receive decoded request");
assert_eq!(nonce, "core-owned-nonce");
assert_eq!(authority, frozen, "payload must not replace frozen caveats");
assert_eq!(floor, AxisEnforcement::Advisory);
assert_eq!(payload, forged_payload);
assert!(
matches!(
worker.send_payload(&serde_json::json!({}), Duration::from_secs(1)),
Err(ToolError::Denied { .. })
),
"the private control endpoint must be take-once"
);
peer.join().expect("join fake worker");
let _ = worker.child.wait();
}
#[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"
);
}
#[test]
fn trusted_worker_preserves_appcontainer_exec_deny_all() {
let exec_denied = Caveats {
exec: Scope::only([] as [String; 0]),
..Caveats::top()
};
let mechanism = trusted_worker_mechanism_caveats(
SandboxKind::AppContainer,
&exec_denied,
"this-path-is-not-used-by-appcontainer",
)
.expect("AppContainer mechanism caveats");
assert!(
crate::sandbox::exec_fully_denied(&mechanism),
"the launcher must still select --no-child-process"
);
assert_eq!(
effective_sandbox_kind(SandboxKind::AppContainer, &mechanism),
SandboxKind::AppContainer,
"deny-all must still engage the AppContainer boundary"
);
assert_eq!(
enforcement_report(&mechanism, SandboxKind::AppContainer).exec,
Some(AxisEnforcement::Kernel),
"the preserved mechanism matches the reported kernel guarantee"
);
}
#[test]
fn trusted_worker_keeps_exec_allowance_for_allowlist_backends() {
let exec_denied = Caveats {
exec: Scope::only([] as [String; 0]),
..Caveats::top()
};
let current = std::env::current_exe()
.expect("current executable")
.canonicalize()
.expect("canonical current executable")
.to_string_lossy()
.into_owned();
for kind in [SandboxKind::Landlock, SandboxKind::Seatbelt] {
let mechanism = trusted_worker_mechanism_caveats(kind, &exec_denied, ¤t)
.expect("allowlist mechanism caveats");
assert!(
matches!(&mechanism.exec, Scope::Only(programs) if programs.contains(¤t)),
"{kind:?} must authorize the fixed worker executable"
);
}
}
#[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)"
);
}
}