use std::path::PathBuf;
use std::time::Instant;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use crate::backend::apply_snapshot_from;
use crate::backend::process::{drive_to_completion, RunningChild};
use crate::shim::ShimConfig;
use crate::{permits_loopback, BackendKind, Sandbox, SandboxError, SandboxOpts, SandboxOutcome};
const SHIM_PATH_ENV: &str = "NYX_SANDBOX_SHIM";
pub struct BirdcageSandbox {
shim_path: PathBuf,
inner: Option<RunningChild>,
last_logs: (Vec<u8>, Vec<u8>),
}
impl BirdcageSandbox {
pub fn new() -> Result<Self, SandboxError> {
let shim_path = resolve_shim_path()?;
Ok(Self { shim_path, inner: None, last_logs: (Vec::new(), Vec::new()) })
}
pub fn with_shim_path(shim_path: PathBuf) -> Self {
Self { shim_path, inner: None, last_logs: (Vec::new(), Vec::new()) }
}
}
impl Sandbox for BirdcageSandbox {
fn backend(&self) -> BackendKind {
BackendKind::Birdcage
}
async fn run(&mut self, mut opts: SandboxOpts) -> Result<(), SandboxError> {
if cfg!(not(any(target_os = "linux", target_os = "macos"))) {
return Err(SandboxError::BackendUnavailable {
backend: "birdcage",
reason: "requires Linux landlock or macOS Seatbelt".into(),
});
}
if self.inner.is_some() {
return Err(SandboxError::State("a child is already running"));
}
if opts.argv.is_empty() {
return Err(SandboxError::Config("argv is empty".into()));
}
let scratch_snapshot = apply_snapshot_from(&mut opts)?;
if !opts.workspace.exists() {
return Err(SandboxError::Config(format!(
"workspace {} does not exist",
opts.workspace.display()
)));
}
if !self.shim_path.is_file() {
return Err(SandboxError::Config(format!(
"nyx-sandbox-shim not found at {}",
self.shim_path.display()
)));
}
if let Some(lane) = opts.lane {
if opts.allow_loopback && !permits_loopback(lane, BackendKind::Birdcage) {
return Err(SandboxError::Config(format!(
"lane policy refuses allow_loopback on {} lane with birdcage backend; \
birdcage cannot scope loopback any tighter than all-network-or-none",
lane.as_str()
)));
}
}
let program = resolve_program(&opts.argv[0])?;
let mut allow_read: Vec<PathBuf> = opts.allow_read.clone();
for p in default_system_read_paths() {
let path = PathBuf::from(p);
if path.exists() {
allow_read.push(path);
}
}
allow_read.push(program.clone());
let mut allow_write: Vec<PathBuf> = opts.allow_write.clone();
allow_write.push(opts.workspace.clone());
let cfg = ShimConfig {
program,
args: opts.argv.iter().skip(1).cloned().collect(),
cwd: Some(opts.cwd.clone().unwrap_or_else(|| opts.workspace.clone())),
env: opts.env.clone(),
allow_read,
allow_write,
allow_env: opts.env.iter().map(|(k, _)| k.clone()).collect(),
allow_network: opts.allow_loopback,
write_status_fd: true,
};
let cfg_json = serde_json::to_vec(&cfg)
.map_err(|e| SandboxError::Config(format!("serialise shim config: {e}")))?;
let mut cmd = Command::new(&self.shim_path);
cmd.env_clear();
for k in ["PATH", "HOME", "LANG", "LC_ALL"] {
if let Some(v) = std::env::var_os(k) {
cmd.env(k, v);
}
}
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd.kill_on_drop(true);
#[cfg(unix)]
let (mut child, status_read_fd) = {
use std::os::fd::{FromRawFd, OwnedFd};
let (read_fd, write_fd) = unsafe { open_status_pipe()? };
unsafe {
cmd.pre_exec(move || {
if libc::dup2(write_fd, 3) == -1 {
return Err(std::io::Error::last_os_error());
}
if write_fd != 3 {
libc::close(write_fd);
}
Ok(())
});
}
let read = unsafe { OwnedFd::from_raw_fd(read_fd) };
let spawned = cmd.spawn().map_err(SandboxError::Spawn);
unsafe { libc::close(write_fd) };
let child = spawned?;
(child, read)
};
#[cfg(not(unix))]
let mut child = cmd.spawn().map_err(SandboxError::Spawn)?;
{
let mut stdin =
child.stdin.take().ok_or(SandboxError::State("shim stdin unavailable"))?;
stdin.write_all(&cfg_json).await.map_err(SandboxError::Io)?;
stdin.shutdown().await.map_err(SandboxError::Io)?;
}
self.inner = Some(RunningChild {
child,
started_at: Instant::now(),
timeout: opts.timeout,
max_output_bytes: opts.max_output_bytes,
killed_by_operator: false,
#[cfg(unix)]
status_fd: Some(status_read_fd),
scratch_snapshot,
workspace: opts.workspace,
capture_files: opts.capture_files,
});
Ok(())
}
async fn kill(&mut self) -> Result<(), SandboxError> {
if let Some(state) = self.inner.as_mut() {
state.killed_by_operator = true;
#[cfg(unix)]
if let Some(pid) = state.child.id() {
let ret = unsafe { libc::killpg(pid as libc::pid_t, libc::SIGKILL) };
if ret == 0 {
return Ok(());
}
}
let _ = state.child.start_kill();
}
Ok(())
}
async fn wait(&mut self) -> Result<SandboxOutcome, SandboxError> {
let mut state = self.inner.take().ok_or(SandboxError::State("no child to wait on"))?;
let outcome = drive_to_completion(&mut state, BackendKind::Birdcage).await?;
self.last_logs = (outcome.stdout.clone(), outcome.stderr.clone());
Ok(outcome)
}
fn logs(&self) -> (&[u8], &[u8]) {
(&self.last_logs.0, &self.last_logs.1)
}
}
fn resolve_shim_path() -> Result<PathBuf, SandboxError> {
if let Some(env) = std::env::var_os(SHIM_PATH_ENV) {
return Ok(PathBuf::from(env));
}
let current = std::env::current_exe().map_err(SandboxError::Io)?;
if let Some(dir) = current.parent() {
let candidate = dir.join("nyx-sandbox-shim");
if candidate.is_file() {
return Ok(candidate);
}
}
Err(SandboxError::Config(format!(
"nyx-sandbox-shim not found via ${SHIM_PATH_ENV} or alongside current_exe"
)))
}
fn resolve_program(arg: &str) -> Result<PathBuf, SandboxError> {
let p = PathBuf::from(arg);
if p.is_absolute() {
if p.is_file() {
return Ok(p);
}
return Err(SandboxError::Config(format!("program {arg} does not exist")));
}
if let Some(path_env) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&path_env) {
let candidate = dir.join(&p);
if candidate.is_file() {
return Ok(candidate);
}
}
}
Err(SandboxError::Config(format!("program {arg} not found on PATH")))
}
#[cfg(unix)]
unsafe fn open_status_pipe() -> Result<(libc::c_int, libc::c_int), SandboxError> {
let mut fds = [0i32; 2];
if libc::pipe(fds.as_mut_ptr()) != 0 {
return Err(SandboxError::Io(std::io::Error::last_os_error()));
}
let read_fd = fds[0];
let write_fd = fds[1];
if libc::fcntl(read_fd, libc::F_SETFD, libc::FD_CLOEXEC) == -1 {
let err = std::io::Error::last_os_error();
libc::close(read_fd);
libc::close(write_fd);
return Err(SandboxError::Io(err));
}
Ok((read_fd, write_fd))
}
fn default_system_read_paths() -> &'static [&'static str] {
#[cfg(target_os = "linux")]
{
&[
"/bin",
"/sbin",
"/usr",
"/lib",
"/lib64",
"/etc/ld.so.cache",
"/etc/ld.so.conf",
"/etc/ld.so.conf.d",
"/etc/alternatives",
"/etc/nsswitch.conf",
"/etc/resolv.conf",
"/etc/hosts",
"/proc/self",
"/proc/sys/kernel/version",
"/dev/null",
"/dev/urandom",
"/dev/random",
]
}
#[cfg(target_os = "macos")]
{
&[
"/bin",
"/sbin",
"/usr",
"/System",
"/Library",
"/private/etc",
"/private/var/db/dyld",
"/private/var/db/timezone",
"/dev/null",
"/dev/urandom",
"/dev/random",
]
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
&[]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Lane;
use std::path::Path;
use tempfile::tempdir;
fn fast_lane_loopback_opts(workspace: &Path, shim_path: &Path) -> SandboxOpts {
let mut opts = SandboxOpts::new(workspace.to_path_buf(), vec!["/bin/true".into()]);
opts.allow_loopback = true;
opts.lane = Some(Lane::Fast);
let _ = shim_path;
opts
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[tokio::test]
async fn fast_lane_birdcage_refuses_allow_loopback() {
let scratch = tempdir().expect("tempdir");
let shim = scratch.path().join("nyx-sandbox-shim-stub");
std::fs::write(&shim, b"#!/bin/sh\nexit 0\n").expect("write stub");
let mut sb = BirdcageSandbox::with_shim_path(shim.clone());
let opts = fast_lane_loopback_opts(scratch.path(), &shim);
let err = sb.run(opts).await.expect_err("fast-lane loopback must refuse");
match err {
SandboxError::Config(reason) => {
assert!(reason.contains("fast lane"), "reason must name the lane: {reason}");
assert!(reason.contains("loopback"), "reason must name the flag: {reason}");
}
other => panic!("expected Config, got {other:?}"),
}
}
}