use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
fn validate_sandbox_str<'a>(path: &'a Path, label: &str) -> io::Result<&'a str> {
if !path.is_absolute() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"sandbox {label} must be an absolute path, got: {}",
path.display()
),
));
}
let s = path.to_str().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("sandbox {label} is not valid UTF-8: {}", path.display()),
)
})?;
if s.contains(['"', '\\', '\0']) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"sandbox {label} contains forbidden characters (double-quote, backslash, or null): {}",
path.display()
),
));
}
Ok(s)
}
#[cfg(test)]
fn validate_sandbox_path(path: &Path) -> io::Result<()> {
let s = path.to_str().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("sandbox path is not valid UTF-8: {}", path.display()),
)
})?;
if s.contains(['"', '\\', '\0']) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"sandbox path contains forbidden characters (double-quote, backslash, or null): {}",
path.display()
),
));
}
Ok(())
}
#[cfg(target_os = "macos")]
fn darwin_major_version() -> u32 {
std::process::Command::new("uname")
.arg("-r")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.split('.').next()?.parse().ok())
.unwrap_or(0)
}
pub struct SandboxCommand;
impl SandboxCommand {
#[allow(clippy::needless_pass_by_value)] pub fn wrap(inner_cmd: Command, worktree_path: &Path) -> io::Result<Command> {
let _ = validate_sandbox_str(worktree_path, "worktree path")?;
#[cfg(target_os = "linux")]
{
let mut bwrap = Command::new("bwrap");
bwrap
.arg("--ro-bind").arg("/").arg("/") .arg("--dev").arg("/dev") .arg("--proc").arg("/proc") .arg("--bind").arg(worktree_path).arg(worktree_path) .arg("--tmpfs").arg("/tmp") .arg("--unshare-all") .arg("--share-net") .arg("--die-with-parent");
bwrap.arg(inner_cmd.get_program());
for arg in inner_cmd.get_args() {
bwrap.arg(arg);
}
for (k, v) in inner_cmd.get_envs() {
if let Some(v) = v {
bwrap.env(k, v);
} else {
bwrap.env_remove(k);
}
}
if let Some(dir) = inner_cmd.get_current_dir() {
bwrap.current_dir(dir);
}
Ok(bwrap)
}
#[cfg(target_os = "macos")]
{
if darwin_major_version() >= 24 {
tracing::warn!(
"macOS 15+ detected: sandbox-exec is deprecated. Running host process unsandboxed."
);
return Ok(inner_cmd);
}
let worktree_str = worktree_path
.to_str()
.expect("unreachable: validated UTF-8 above");
let profile = format!(
r#"(version 1)
(deny default)
(allow process-exec*)
(allow process-fork)
(allow network*)
(allow sysctl-read)
(allow ipc-posix-shm)
(allow file-read*
(subpath "/usr")
(subpath "/bin")
(subpath "/sbin")
(subpath "/System")
(subpath "/Library")
(subpath "/opt")
(subpath "/dev")
(subpath "{worktree_str}")
(subpath "/private/tmp")
(subpath "/var/folders")
)
(allow file-write*
(subpath "{worktree_str}")
(subpath "/private/tmp")
(subpath "/var/folders")
(literal "/dev/null")
)"#
);
let mut sb_cmd = Command::new("sandbox-exec");
sb_cmd.arg("-p").arg(&profile);
sb_cmd.arg(inner_cmd.get_program());
for arg in inner_cmd.get_args() {
sb_cmd.arg(arg);
}
for (k, v) in inner_cmd.get_envs() {
if let Some(v) = v {
sb_cmd.env(k, v);
} else {
sb_cmd.env_remove(k);
}
}
if let Some(dir) = inner_cmd.get_current_dir() {
sb_cmd.current_dir(dir);
}
Ok(sb_cmd)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
tracing::warn!(
"Host-level sandboxing is not supported on this OS. Processes will run unsandboxed."
);
Ok(inner_cmd)
}
}
}
#[derive(Debug, Clone)]
pub struct SandboxPrefix {
pub program: OsString,
pub args: Vec<OsString>,
}
#[derive(Debug, Clone)]
pub struct ProcessSandboxConfig {
writable_root: PathBuf,
extra_read_paths: Vec<PathBuf>,
extra_write_paths: Vec<PathBuf>,
allow_network: bool,
hidden_paths: Vec<PathBuf>,
}
impl ProcessSandboxConfig {
#[must_use]
pub fn new(writable_root: impl Into<PathBuf>) -> Self {
Self {
writable_root: writable_root.into(),
extra_read_paths: Vec::new(),
extra_write_paths: Vec::new(),
allow_network: true,
hidden_paths: Vec::new(),
}
}
#[must_use]
pub fn with_network(mut self, allow: bool) -> Self {
self.allow_network = allow;
self
}
#[must_use]
pub fn with_extra_read(mut self, path: impl Into<PathBuf>) -> Self {
self.extra_read_paths.push(path.into());
self
}
#[must_use]
pub fn with_extra_write(mut self, path: impl Into<PathBuf>) -> Self {
self.extra_write_paths.push(path.into());
self
}
#[must_use]
pub fn with_hidden(mut self, path: impl Into<PathBuf>) -> Self {
self.hidden_paths.push(path.into());
self
}
pub fn sandbox_prefix(&self) -> io::Result<Option<SandboxPrefix>> {
self.validate_all_paths()?;
#[cfg(target_os = "linux")]
{
Ok(Some(self.build_bwrap_prefix()))
}
#[cfg(target_os = "macos")]
{
self.build_seatbelt_prefix().map(Some)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
tracing::warn!(
"Host-level sandboxing is not supported on this OS. \
MCP server will run unsandboxed."
);
Ok(None)
}
}
fn validate_all_paths(&self) -> io::Result<()> {
validate_sandbox_str(&self.writable_root, "writable root")?;
for p in &self.extra_read_paths {
validate_sandbox_str(p, "extra read path")?;
}
for p in &self.extra_write_paths {
validate_sandbox_str(p, "extra write path")?;
}
for p in &self.hidden_paths {
validate_sandbox_str(p, "hidden path")?;
}
Ok(())
}
#[cfg(target_os = "linux")]
fn build_bwrap_prefix(&self) -> SandboxPrefix {
let mut args: Vec<OsString> = Vec::new();
args.extend(["--ro-bind", "/", "/"].map(OsString::from));
args.extend(["--dev", "/dev"].map(OsString::from));
args.extend(["--proc", "/proc"].map(OsString::from));
args.extend([
OsString::from("--bind"),
self.writable_root.as_os_str().into(),
self.writable_root.as_os_str().into(),
]);
for path in &self.extra_write_paths {
args.extend([
OsString::from("--bind"),
path.as_os_str().into(),
path.as_os_str().into(),
]);
}
args.extend(["--tmpfs", "/tmp"].map(OsString::from));
for path in &self.hidden_paths {
args.extend([OsString::from("--tmpfs"), path.as_os_str().into()]);
}
args.push(OsString::from("--unshare-all"));
if self.allow_network {
args.push(OsString::from("--share-net"));
}
args.push(OsString::from("--die-with-parent"));
args.push(OsString::from("--"));
SandboxPrefix {
program: OsString::from("bwrap"),
args,
}
}
#[cfg(target_os = "macos")]
fn build_seatbelt_prefix(&self) -> io::Result<SandboxPrefix> {
let writable_root_str = validate_sandbox_str(&self.writable_root, "writable root")?;
let network_rule = if self.allow_network {
"(allow network*)"
} else {
""
};
let extra_read_rules: String = self
.extra_read_paths
.iter()
.map(|p| {
validate_sandbox_str(p, "extra read path").map(|s| format!(" (subpath \"{s}\")"))
})
.collect::<io::Result<Vec<_>>>()?
.join("\n");
let extra_write_rules: String = self
.extra_write_paths
.iter()
.map(|p| {
validate_sandbox_str(p, "extra write path")
.map(|s| format!(" (subpath \"{s}\")"))
})
.collect::<io::Result<Vec<_>>>()?
.join("\n");
let hidden_deny_rules: String = self
.hidden_paths
.iter()
.filter(|p| !self.writable_root.starts_with(p.as_path()))
.map(|p| {
validate_sandbox_str(p, "hidden path").map(|s| {
format!(
"(deny file-read* (subpath \"{s}\"))\n\
(deny file-write* (subpath \"{s}\"))"
)
})
})
.collect::<io::Result<Vec<_>>>()?
.join("\n");
let profile = format!(
r#"(version 1)
(deny default)
(allow process-exec*)
(allow process-fork)
{network_rule}
(allow sysctl-read)
(allow ipc-posix-shm)
(allow mach*)
(allow file-read*
(subpath "/usr")
(subpath "/bin")
(subpath "/sbin")
(subpath "/System")
(subpath "/Library")
(subpath "/opt")
(subpath "/dev")
(subpath "{writable_root_str}")
(subpath "/private/tmp")
(subpath "/var/folders")
(literal "/")
{extra_read_rules}
)
(allow file-write*
(subpath "{writable_root_str}")
(subpath "/private/tmp")
(subpath "/var/folders")
(literal "/dev/null")
{extra_write_rules}
)
{hidden_deny_rules}"#
);
let args = vec![OsString::from("-p"), OsString::from(&profile)];
Ok(SandboxPrefix {
program: OsString::from("sandbox-exec"),
args,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn validate_sandbox_path_accepts_normal_path() {
let path = PathBuf::from("/Users/agent/workspace/project");
assert!(validate_sandbox_path(&path).is_ok());
}
#[test]
fn validate_sandbox_path_accepts_path_with_spaces() {
let path = PathBuf::from("/Users/agent/my project/src");
assert!(validate_sandbox_path(&path).is_ok());
}
#[test]
fn validate_sandbox_path_rejects_double_quote() {
let path = PathBuf::from("/Users/agent/work\"inject");
let err = validate_sandbox_path(&path).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
assert!(
err.to_string().contains("forbidden characters"),
"unexpected error message: {err}"
);
}
#[test]
fn validate_sandbox_path_rejects_sbpl_injection_payload() {
let path = PathBuf::from(r#"/tmp/evil") (allow file-write* (subpath "/"))"#);
let err = validate_sandbox_path(&path).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
assert!(
err.to_string().contains("forbidden characters"),
"unexpected error message: {err}"
);
}
#[test]
fn validate_sandbox_path_rejects_backslash() {
let path = PathBuf::from("/tmp/work\\nspace");
let err = validate_sandbox_path(&path).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
assert!(
err.to_string().contains("forbidden characters"),
"unexpected error message: {err}"
);
}
#[test]
fn validate_sandbox_path_rejects_null_byte() {
let path = PathBuf::from("/tmp/work\0space");
let err = validate_sandbox_path(&path).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
assert!(
err.to_string().contains("forbidden characters"),
"unexpected error message: {err}"
);
}
#[test]
fn test_wrap_rejects_non_utf8_path() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let bad_bytes: &[u8] = b"/tmp/\xff\xfe/workspace";
let bad_path = Path::new(OsStr::from_bytes(bad_bytes));
let cmd = Command::new("echo");
let result = SandboxCommand::wrap(cmd, bad_path);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("not valid UTF-8"),
"error should mention UTF-8: {err_msg}"
);
}
#[test]
fn test_wrap_rejects_double_quote_path() {
let bad_path = Path::new("/tmp/evil\"injection/workspace");
let cmd = Command::new("echo");
let result = SandboxCommand::wrap(cmd, bad_path);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("forbidden characters"),
"error should mention forbidden chars: {err_msg}"
);
}
#[test]
fn test_wrap_rejects_null_byte_path() {
let bad_path = Path::new("/tmp/evil\0null/workspace");
let cmd = Command::new("echo");
let result = SandboxCommand::wrap(cmd, bad_path);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("forbidden characters"),
"error should mention forbidden chars: {err_msg}"
);
}
#[test]
fn test_wrap_rejects_backslash_path() {
let bad_path = Path::new("/tmp/work\\nspace");
let cmd = Command::new("echo");
let result = SandboxCommand::wrap(cmd, bad_path);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("forbidden characters"),
"error should mention forbidden chars: {err_msg}"
);
}
#[test]
fn test_wrap_rejects_relative_path() {
let bad_path = Path::new("relative/workspace");
let cmd = Command::new("echo");
let result = SandboxCommand::wrap(cmd, bad_path);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("absolute path"),
"error should mention absolute path: {err_msg}"
);
}
#[cfg(target_os = "macos")]
#[test]
fn wrap_uses_inline_profile() {
let cmd = Command::new("echo");
let path = PathBuf::from("/tmp/safe-workspace");
let wrapped = SandboxCommand::wrap(cmd, &path).unwrap();
if super::darwin_major_version() >= 24 {
assert_eq!(
wrapped.get_program(),
"echo",
"on macOS 15+, command should pass through unwrapped"
);
} else {
let args: Vec<_> = wrapped.get_args().collect();
assert_eq!(args[0], "-p", "expected -p for inline profile delivery");
let profile = args[1].to_string_lossy();
assert!(
profile.contains("/tmp/safe-workspace"),
"profile should contain the worktree path"
);
}
}
#[test]
fn test_sandbox_config_builder() {
let config = ProcessSandboxConfig::new("/project")
.with_network(false)
.with_extra_read("/data")
.with_extra_write("/output")
.with_hidden("/home/user/.astrid");
assert_eq!(config.writable_root, PathBuf::from("/project"));
assert!(!config.allow_network);
assert_eq!(config.extra_read_paths, vec![PathBuf::from("/data")]);
assert_eq!(config.extra_write_paths, vec![PathBuf::from("/output")]);
assert_eq!(
config.hidden_paths,
vec![PathBuf::from("/home/user/.astrid")]
);
}
#[test]
fn test_sandbox_config_defaults() {
let config = ProcessSandboxConfig::new("/project");
assert!(config.allow_network);
assert!(config.extra_read_paths.is_empty());
assert!(config.extra_write_paths.is_empty());
assert!(config.hidden_paths.is_empty());
}
#[cfg(target_os = "linux")]
#[test]
fn test_bwrap_prefix_basic() {
let config = ProcessSandboxConfig::new("/project");
let prefix = config.build_bwrap_prefix();
assert_eq!(prefix.program, OsString::from("bwrap"));
let args_str: Vec<String> = prefix
.args
.iter()
.map(|a| a.to_string_lossy().to_string())
.collect();
assert!(args_str.contains(&"--ro-bind".to_string()));
assert!(args_str.contains(&"--dev".to_string()));
assert!(args_str.contains(&"--proc".to_string()));
assert!(args_str.contains(&"--unshare-all".to_string()));
assert!(args_str.contains(&"--share-net".to_string()));
assert!(args_str.contains(&"--die-with-parent".to_string()));
assert!(args_str.contains(&"--".to_string()));
let bind_idx = args_str
.iter()
.position(|a| a == "--bind")
.expect("should have --bind");
assert_eq!(args_str[bind_idx + 1], "/project");
assert_eq!(args_str[bind_idx + 2], "/project");
}
#[cfg(target_os = "linux")]
#[test]
fn test_bwrap_prefix_no_network() {
let config = ProcessSandboxConfig::new("/project").with_network(false);
let prefix = config.build_bwrap_prefix();
let args_str: Vec<String> = prefix
.args
.iter()
.map(|a| a.to_string_lossy().to_string())
.collect();
assert!(args_str.contains(&"--unshare-all".to_string()));
assert!(!args_str.contains(&"--share-net".to_string()));
}
#[cfg(target_os = "linux")]
#[test]
fn test_bwrap_prefix_hidden_paths() {
let config = ProcessSandboxConfig::new("/project").with_hidden("/home/user/.astrid");
let prefix = config.build_bwrap_prefix();
let args_str: Vec<String> = prefix
.args
.iter()
.map(|a| a.to_string_lossy().to_string())
.collect();
let tmpfs_positions: Vec<usize> = args_str
.iter()
.enumerate()
.filter(|(_, a)| *a == "--tmpfs")
.map(|(i, _)| i)
.collect();
assert!(
tmpfs_positions.len() >= 2,
"should have at least 2 tmpfs mounts"
);
let hidden_tmpfs_found = tmpfs_positions
.iter()
.any(|&i| args_str.get(i + 1) == Some(&"/home/user/.astrid".to_string()));
assert!(hidden_tmpfs_found, "should have tmpfs for hidden path");
}
#[cfg(target_os = "linux")]
#[test]
fn test_bwrap_prefix_extra_paths() {
let config = ProcessSandboxConfig::new("/project")
.with_extra_read("/data")
.with_extra_write("/output");
let prefix = config.build_bwrap_prefix();
let args_str: Vec<String> = prefix
.args
.iter()
.map(|a| a.to_string_lossy().to_string())
.collect();
let bind_positions: Vec<usize> = args_str
.iter()
.enumerate()
.filter(|(_, a)| *a == "--bind")
.map(|(i, _)| i)
.collect();
let has_output_bind = bind_positions
.iter()
.any(|&i| args_str.get(i + 1) == Some(&"/output".to_string()));
assert!(has_output_bind, "should have --bind for extra write path");
let ro_positions: Vec<usize> = args_str
.iter()
.enumerate()
.filter(|(_, a)| *a == "--ro-bind")
.map(|(i, _)| i)
.collect();
let has_data_explicit = ro_positions
.iter()
.any(|&i| args_str.get(i + 1) == Some(&"/data".to_string()));
assert!(
!has_data_explicit,
"extra_read_paths should NOT produce --ro-bind on Linux (covered by --ro-bind / /)"
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_seatbelt_prefix_basic() {
let config = ProcessSandboxConfig::new("/project");
let prefix = config.build_seatbelt_prefix().unwrap();
assert_eq!(prefix.program, OsString::from("sandbox-exec"));
assert_eq!(prefix.args[0], OsString::from("-p"));
let profile = prefix.args[1].to_string_lossy().to_string();
assert!(profile.contains("(deny default)"));
assert!(profile.contains("(allow network*)"));
assert!(profile.contains(r#"(subpath "/project")"#));
assert!(profile.contains("(allow process-exec*)"));
}
#[cfg(target_os = "macos")]
#[test]
fn test_seatbelt_prefix_no_network() {
let config = ProcessSandboxConfig::new("/project").with_network(false);
let prefix = config.build_seatbelt_prefix().unwrap();
let profile = prefix.args[1].to_string_lossy().to_string();
assert!(!profile.contains("(allow network*)"));
}
#[cfg(target_os = "macos")]
#[test]
fn test_seatbelt_prefix_extra_paths() {
let config = ProcessSandboxConfig::new("/project")
.with_extra_read("/data")
.with_extra_write("/output");
let prefix = config.build_seatbelt_prefix().unwrap();
let profile = prefix.args[1].to_string_lossy().to_string();
assert!(profile.contains(r#"(subpath "/data")"#));
assert!(profile.contains(r#"(subpath "/output")"#));
}
#[cfg(target_os = "macos")]
#[test]
fn test_seatbelt_prefix_hidden_paths() {
let config = ProcessSandboxConfig::new("/project").with_hidden("/Users/testuser/.astrid");
let prefix = config.build_seatbelt_prefix().unwrap();
let profile = prefix.args[1].to_string_lossy().to_string();
assert!(
profile.contains(r#"(deny file-read* (subpath "/Users/testuser/.astrid"))"#),
"should deny file-read for hidden path"
);
assert!(
profile.contains(r#"(deny file-write* (subpath "/Users/testuser/.astrid"))"#),
"should deny file-write for hidden path"
);
}
#[test]
fn test_sandbox_prefix_rejects_relative_writable_root() {
let config = ProcessSandboxConfig::new("relative/project");
assert!(config.sandbox_prefix().is_err());
}
#[test]
fn test_sandbox_prefix_rejects_non_utf8_writable_root() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let bad_bytes: &[u8] = b"/tmp/\xff\xfe/workspace";
let bad_path = PathBuf::from(OsStr::from_bytes(bad_bytes));
let config = ProcessSandboxConfig::new(bad_path);
let result = config.sandbox_prefix();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not valid UTF-8"));
}
#[test]
fn test_sandbox_prefix_rejects_non_utf8_extra_paths() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let bad_bytes: &[u8] = b"/data/\xff\xfe";
let bad_path = PathBuf::from(OsStr::from_bytes(bad_bytes));
let config = ProcessSandboxConfig::new("/project").with_extra_read(bad_path.clone());
assert!(config.sandbox_prefix().is_err());
let config = ProcessSandboxConfig::new("/project").with_extra_write(bad_path.clone());
assert!(config.sandbox_prefix().is_err());
let config = ProcessSandboxConfig::new("/project").with_hidden(bad_path);
assert!(config.sandbox_prefix().is_err());
}
#[test]
fn test_sandbox_prefix_rejects_double_quote_in_paths() {
let config = ProcessSandboxConfig::new("/project/evil\"dir");
assert!(config.sandbox_prefix().is_err());
let config = ProcessSandboxConfig::new("/project").with_extra_read("/data/evil\"path");
assert!(config.sandbox_prefix().is_err());
let config = ProcessSandboxConfig::new("/project").with_extra_write("/output/evil\"path");
assert!(config.sandbox_prefix().is_err());
let config = ProcessSandboxConfig::new("/project").with_hidden("/hidden/evil\"path");
assert!(config.sandbox_prefix().is_err());
}
#[test]
fn test_sandbox_prefix_rejects_backslash_in_paths() {
let config = ProcessSandboxConfig::new("/project/evil\\dir");
assert!(config.sandbox_prefix().is_err());
let config = ProcessSandboxConfig::new("/project").with_extra_read("/data/evil\\path");
assert!(config.sandbox_prefix().is_err());
let config = ProcessSandboxConfig::new("/project").with_extra_write("/output/evil\\path");
assert!(config.sandbox_prefix().is_err());
let config = ProcessSandboxConfig::new("/project").with_hidden("/hidden/evil\\path");
assert!(config.sandbox_prefix().is_err());
}
#[test]
fn test_sandbox_prefix_rejects_null_byte_in_paths() {
let config = ProcessSandboxConfig::new("/project/evil\0dir");
assert!(config.sandbox_prefix().is_err());
let config = ProcessSandboxConfig::new("/project").with_extra_read("/data/evil\0path");
assert!(config.sandbox_prefix().is_err());
let config = ProcessSandboxConfig::new("/project").with_extra_write("/output/evil\0path");
assert!(config.sandbox_prefix().is_err());
let config = ProcessSandboxConfig::new("/project").with_hidden("/hidden/evil\0path");
assert!(config.sandbox_prefix().is_err());
}
}