use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub mod noop;
#[cfg(target_os = "macos")]
pub mod macos;
#[cfg(all(target_os = "linux", feature = "sandbox"))]
pub mod linux;
pub use noop::NoopSandbox;
#[cfg(target_os = "macos")]
pub use macos::MacosSandbox;
#[cfg(all(target_os = "linux", feature = "sandbox"))]
pub use linux::LinuxSandbox;
#[derive(Debug, Clone)]
pub struct SandboxPolicy {
pub profile: SandboxProfile,
pub allow_read: Vec<PathBuf>,
pub allow_write: Vec<PathBuf>,
pub allow_network: bool,
pub allow_exec: Vec<PathBuf>,
pub env_inherit: Vec<String>,
}
impl SandboxPolicy {
#[must_use]
pub fn canonicalized(mut self) -> Self {
self.allow_read = canonicalize_paths(self.allow_read);
self.allow_write = canonicalize_paths(self.allow_write);
self.allow_exec = canonicalize_paths(self.allow_exec);
self
}
}
fn canonicalize_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
paths
.into_iter()
.filter_map(|p| match std::fs::canonicalize(&p) {
Ok(canonical) => {
if canonical != p {
tracing::debug!(
"sandbox: resolved symlink {} → {}",
p.display(),
canonical.display()
);
}
Some(canonical)
}
Err(e) => {
tracing::warn!(
path = %p.display(),
error = %e,
"sandbox: allow-list path could not be canonicalized and was dropped from policy"
);
None
}
})
.collect()
}
impl Default for SandboxPolicy {
fn default() -> Self {
let cwd =
std::fs::canonicalize(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
.unwrap_or_else(|_| PathBuf::from("/"));
Self {
profile: SandboxProfile::Workspace,
allow_read: vec![cwd.clone()],
allow_write: vec![cwd],
allow_network: false,
allow_exec: vec![],
env_inherit: vec![],
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SandboxProfile {
ReadOnly,
#[default]
Workspace,
#[serde(rename = "network-allow-all", alias = "network")]
NetworkAllowAll,
Off,
}
#[derive(Debug, Error)]
pub enum SandboxError {
#[error("sandbox backend unavailable: {reason}")]
Unavailable { reason: String },
#[error("policy not supported by {backend}: {reason}")]
UnsupportedPolicy {
backend: &'static str,
reason: String,
},
#[error("sandbox setup failed: {0}")]
Setup(#[from] std::io::Error),
#[error("policy generation failed: {0}")]
Policy(String),
}
pub trait Sandbox: Send + Sync + std::fmt::Debug {
fn name(&self) -> &'static str;
fn supports(&self, policy: &SandboxPolicy) -> Result<(), SandboxError>;
fn wrap(
&self,
cmd: &mut tokio::process::Command,
policy: &SandboxPolicy,
) -> Result<(), SandboxError>;
}
pub fn build_sandbox(strict: bool) -> Result<Box<dyn Sandbox>, SandboxError> {
#[cfg(target_os = "macos")]
{
let _ = strict;
Ok(Box::new(MacosSandbox::new()))
}
#[cfg(all(target_os = "linux", feature = "sandbox"))]
{
linux::LinuxSandbox::new(strict).map(|s| Box::new(s) as Box<dyn Sandbox>)
}
#[cfg(not(any(target_os = "macos", all(target_os = "linux", feature = "sandbox"))))]
{
if strict {
return Err(SandboxError::Unavailable {
reason: "OS sandbox not supported on this platform and strict=true".into(),
});
}
tracing::warn!(
"OS sandbox not supported on this platform — running without subprocess isolation"
);
Ok(Box::new(NoopSandbox))
}
}
#[cfg(test)]
mod tests {
#[test]
#[cfg(not(any(target_os = "macos", all(target_os = "linux", feature = "sandbox"))))]
fn build_sandbox_strict_fails_when_unsupported() {
use super::{SandboxError, build_sandbox};
let err = build_sandbox(true).expect_err("strict must fail on unsupported platform");
assert!(matches!(err, SandboxError::Unavailable { .. }));
}
#[test]
#[cfg(not(any(target_os = "macos", all(target_os = "linux", feature = "sandbox"))))]
fn build_sandbox_nonstrict_falls_back_to_noop() {
use super::build_sandbox;
let sb = build_sandbox(false).expect("noop fallback ok");
assert_eq!(sb.name(), "noop");
}
#[test]
fn canonicalize_paths_drops_nonexistent_path() {
use super::{SandboxPolicy, SandboxProfile};
use std::path::PathBuf;
let policy = SandboxPolicy {
profile: SandboxProfile::Workspace,
allow_read: vec![PathBuf::from(
"/this/path/does/not/exist/zeph-test-sentinel",
)],
allow_write: vec![],
allow_network: false,
allow_exec: vec![],
env_inherit: vec![],
}
.canonicalized();
assert!(
policy.allow_read.is_empty(),
"non-existent path must be dropped by canonicalized()"
);
}
}