pub mod bwrap;
pub mod policy;
pub mod seatbelt;
pub use policy::SandboxPolicy;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use tokio::process::Command;
use tracing::{debug, warn};
use crate::config::SandboxConfig;
pub trait SandboxStrategy: Send + Sync {
fn name(&self) -> &'static str;
fn wrap_command(&self, cmd: Command, policy: &SandboxPolicy) -> Command;
}
pub struct NoopStrategy;
impl SandboxStrategy for NoopStrategy {
fn name(&self) -> &'static str {
"noop"
}
fn wrap_command(&self, cmd: Command, _policy: &SandboxPolicy) -> Command {
cmd
}
}
pub struct SandboxExecutor {
strategy: Arc<dyn SandboxStrategy>,
policy: SandboxPolicy,
enabled: bool,
allow_bypass: bool,
}
impl SandboxExecutor {
pub fn from_config(config: &SandboxConfig, project_dir: &Path) -> Self {
Self::from_config_with_bypass(config, project_dir, true)
}
pub fn from_config_with_bypass(
config: &SandboxConfig,
project_dir: &Path,
allow_bypass: bool,
) -> Self {
let policy = SandboxPolicy::from_config(config, project_dir);
let strategy = pick_strategy(&config.strategy);
if config.enabled && strategy.name() == "noop" {
warn!(
"sandbox enabled in config but no working strategy on this platform; \
running without OS-level isolation"
);
}
Self {
strategy,
policy,
enabled: config.enabled,
allow_bypass,
}
}
pub fn from_session_config(config: &crate::config::Config, project_dir: &Path) -> Self {
Self::from_config_with_bypass(
&config.sandbox,
project_dir,
!config.security.disable_bypass_permissions,
)
}
pub fn strategy_name(&self) -> &'static str {
self.strategy.name()
}
pub fn is_active(&self) -> bool {
self.enabled && self.strategy.name() != "noop"
}
pub fn policy(&self) -> &SandboxPolicy {
&self.policy
}
pub fn allow_bypass(&self) -> bool {
self.allow_bypass
}
pub fn wrap(&self, cmd: Command) -> Command {
if !self.is_active() {
return cmd;
}
debug!(
strategy = self.strategy.name(),
project_dir = %self.policy.project_dir.display(),
"wrapping subprocess with sandbox"
);
let mut wrapped = self.strategy.wrap_command(cmd, &self.policy);
wrapped
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null());
wrapped
}
pub fn disabled() -> Self {
Self {
strategy: Arc::new(NoopStrategy),
policy: SandboxPolicy {
project_dir: PathBuf::from("."),
allowed_write_paths: Vec::new(),
forbidden_paths: Vec::new(),
allow_network: true,
},
enabled: false,
allow_bypass: true,
}
}
}
fn pick_strategy(requested: &str) -> Arc<dyn SandboxStrategy> {
match requested {
"none" => Arc::new(NoopStrategy),
"seatbelt" => make_seatbelt_or_noop(),
"bwrap" => make_bwrap_or_noop(),
"auto" | "" => auto_detect(),
other => {
warn!("unknown sandbox strategy {other:?}; falling back to noop");
Arc::new(NoopStrategy)
}
}
}
fn auto_detect() -> Arc<dyn SandboxStrategy> {
if cfg!(target_os = "macos") {
make_seatbelt_or_noop()
} else if cfg!(target_os = "linux") {
make_bwrap_or_noop()
} else {
Arc::new(NoopStrategy)
}
}
fn make_seatbelt_or_noop() -> Arc<dyn SandboxStrategy> {
if cfg!(target_os = "macos") && binary_on_path("sandbox-exec") {
Arc::new(seatbelt::SeatbeltStrategy)
} else {
Arc::new(NoopStrategy)
}
}
fn make_bwrap_or_noop() -> Arc<dyn SandboxStrategy> {
if cfg!(target_os = "linux") && binary_on_path("bwrap") {
Arc::new(bwrap::BwrapStrategy)
} else {
Arc::new(NoopStrategy)
}
}
fn binary_on_path(name: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
for dir in std::env::split_paths(&path) {
if dir.join(name).is_file() {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_config(enabled: bool, strategy: &str) -> SandboxConfig {
SandboxConfig {
enabled,
strategy: strategy.to_string(),
allowed_write_paths: vec![],
forbidden_paths: vec![],
allow_network: false,
}
}
#[test]
fn pick_strategy_none_is_noop() {
assert_eq!(pick_strategy("none").name(), "noop");
}
#[test]
fn pick_strategy_empty_is_auto() {
assert_eq!(pick_strategy("").name(), auto_detect().name());
}
#[test]
fn pick_strategy_auto_matches_auto_detect() {
assert_eq!(pick_strategy("auto").name(), auto_detect().name());
}
#[test]
fn pick_strategy_unknown_is_noop() {
assert_eq!(pick_strategy("martian").name(), "noop");
}
#[test]
#[cfg(not(target_os = "macos"))]
fn auto_detect_off_macos_is_noop() {
assert_eq!(auto_detect().name(), "noop");
}
#[test]
#[cfg(target_os = "macos")]
fn auto_detect_on_macos_picks_seatbelt() {
assert_eq!(auto_detect().name(), "seatbelt");
}
#[test]
#[cfg(target_os = "linux")]
fn auto_detect_on_linux_picks_bwrap_or_noop() {
let name = auto_detect().name();
assert!(
name == "bwrap" || name == "noop",
"expected bwrap or noop on Linux, got {name}"
);
}
#[test]
#[cfg(target_os = "macos")]
fn pick_strategy_seatbelt_matches_make_seatbelt() {
assert_eq!(pick_strategy("seatbelt").name(), "seatbelt");
}
#[test]
#[cfg(not(target_os = "macos"))]
fn pick_strategy_seatbelt_off_macos_is_noop() {
assert_eq!(pick_strategy("seatbelt").name(), "noop");
}
#[test]
#[cfg(target_os = "linux")]
fn pick_strategy_bwrap_on_linux_matches_make_bwrap() {
let name = pick_strategy("bwrap").name();
assert!(
name == "bwrap" || name == "noop",
"expected bwrap or noop on Linux, got {name}"
);
}
#[test]
#[cfg(not(target_os = "linux"))]
fn pick_strategy_bwrap_off_linux_is_noop() {
assert_eq!(pick_strategy("bwrap").name(), "noop");
}
#[test]
fn disabled_executor_is_inactive() {
let exec = SandboxExecutor::disabled();
assert!(!exec.is_active());
assert_eq!(exec.strategy_name(), "noop");
assert!(exec.allow_bypass());
}
#[test]
fn disabled_executor_wrap_is_identity_program() {
let exec = SandboxExecutor::disabled();
let cmd = Command::new("echo");
let wrapped = exec.wrap(cmd);
let program = wrapped.as_std().get_program();
assert_eq!(program, "echo");
}
#[test]
fn from_config_disabled_is_inactive_regardless_of_strategy() {
let cfg = sample_config(false, "seatbelt");
let exec = SandboxExecutor::from_config(&cfg, std::path::Path::new("/tmp"));
assert!(!exec.is_active());
}
#[test]
fn from_config_strategy_none_is_inactive_even_when_enabled() {
let cfg = sample_config(true, "none");
let exec = SandboxExecutor::from_config(&cfg, std::path::Path::new("/tmp"));
assert!(!exec.is_active());
assert_eq!(exec.strategy_name(), "noop");
}
#[test]
fn from_config_policy_contains_project_dir() {
let cfg = sample_config(false, "auto");
let exec = SandboxExecutor::from_config(&cfg, std::path::Path::new("/work/repo"));
assert_eq!(exec.policy().project_dir, PathBuf::from("/work/repo"));
}
#[test]
fn from_config_with_bypass_respects_flag() {
let cfg = sample_config(true, "auto");
let allowed =
SandboxExecutor::from_config_with_bypass(&cfg, std::path::Path::new("/tmp"), true);
let denied =
SandboxExecutor::from_config_with_bypass(&cfg, std::path::Path::new("/tmp"), false);
assert!(allowed.allow_bypass());
assert!(!denied.allow_bypass());
}
#[test]
fn from_session_config_honors_disable_bypass_permissions() {
let base = crate::config::Config {
sandbox: sample_config(true, "none"),
..Default::default()
};
let mut denied_cfg = base.clone();
denied_cfg.security.disable_bypass_permissions = true;
let denied =
SandboxExecutor::from_session_config(&denied_cfg, std::path::Path::new("/tmp"));
assert!(!denied.allow_bypass());
let mut allowed_cfg = base;
allowed_cfg.security.disable_bypass_permissions = false;
let allowed =
SandboxExecutor::from_session_config(&allowed_cfg, std::path::Path::new("/tmp"));
assert!(allowed.allow_bypass());
}
#[test]
#[cfg(target_os = "macos")]
fn active_seatbelt_wrap_replaces_program() {
let cfg = sample_config(true, "seatbelt");
let exec = SandboxExecutor::from_config(&cfg, std::path::Path::new("/tmp"));
if !exec.is_active() {
eprintln!("skipping: seatbelt unavailable");
return;
}
let wrapped = exec.wrap(Command::new("echo"));
let std_cmd = wrapped.as_std();
assert_eq!(std_cmd.get_program(), "sandbox-exec");
let args: Vec<_> = std_cmd.get_args().collect();
assert_eq!(args.first().map(|a| a.to_str().unwrap()), Some("-p"));
assert!(args.iter().any(|a| a.to_str() == Some("echo")));
}
#[test]
fn noop_strategy_returns_command_untouched() {
let cmd = Command::new("cat");
let policy = SandboxPolicy {
project_dir: PathBuf::from("/tmp"),
allowed_write_paths: vec![],
forbidden_paths: vec![],
allow_network: false,
};
let wrapped = NoopStrategy.wrap_command(cmd, &policy);
assert_eq!(wrapped.as_std().get_program(), "cat");
}
}