use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(target_os = "linux")]
mod bwrap;
#[cfg(target_os = "macos")]
mod seatbelt;
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)
}
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 seatbelt::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>,
}
#[cfg(target_os = "linux")]
fn linux_unavailable_hint() -> &'static str {
"On Ubuntu 24.04+, this is most often caused by \
`kernel.apparmor_restrict_unprivileged_userns=1`. \
Fix with: `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0` \
(or persist via /etc/sysctl.d/). On other distros, ensure the \
`bubblewrap` package is installed."
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn unsupported_os_hint() -> &'static str {
"Astrid currently supports OS-level sandboxing on Linux (bwrap) and \
macOS (Seatbelt). On other platforms there is no sandbox layer \
available — native subprocess capsules cannot be safely contained."
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SandboxPolicy {
#[default]
Required,
Off,
}
impl SandboxPolicy {
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"required" => Some(Self::Required),
"off" => Some(Self::Off),
_ => None,
}
}
#[must_use]
pub fn from_env() -> Self {
match std::env::var("ASTRID_SANDBOX_POLICY") {
Ok(s) => {
if let Some(p) = Self::parse(&s) {
p
} else {
tracing::warn!(
value = %s,
"ASTRID_SANDBOX_POLICY value is not one of \
required / off — falling back to `required`"
);
Self::default()
}
},
Err(_) => Self::default(),
}
}
}
#[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>,
policy: SandboxPolicy,
}
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(),
policy: SandboxPolicy::from_env(),
}
}
#[must_use]
pub fn with_policy(mut self, policy: SandboxPolicy) -> Self {
self.policy = policy;
self
}
#[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()?;
if self.policy == SandboxPolicy::Off {
return Ok(None);
}
#[cfg(target_os = "linux")]
{
if bwrap::bwrap_available() {
return Ok(Some(self.build_bwrap_prefix()));
}
self.handle_unavailable_sandbox(linux_unavailable_hint())
}
#[cfg(target_os = "macos")]
{
self.build_seatbelt_prefix().map(Some)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
self.handle_unavailable_sandbox(unsupported_os_hint())
}
}
#[cfg(any(
target_os = "linux",
not(any(target_os = "linux", target_os = "macos"))
))]
fn handle_unavailable_sandbox(&self, hint: &str) -> io::Result<Option<SandboxPrefix>> {
match self.policy {
SandboxPolicy::Required => Err(io::Error::other(format!(
"OS-level sandbox unavailable and policy is `required` — \
refusing to launch native subprocess capsule without \
containment. {hint} To run without the sandbox anyway \
(trusted dev environments, CI runners where the kernel \
can't be configured), set `ASTRID_SANDBOX_POLICY=off`. \
The `required` default exists to keep the security \
guarantee documented in the README — see issue #655."
))),
SandboxPolicy::Off => 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(test)]
mod tests {
use super::*;
use std::path::PathBuf;
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(())
}
#[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::seatbelt::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());
}
#[test]
fn policy_parse_accepts_known_values() {
assert_eq!(
SandboxPolicy::parse("required"),
Some(SandboxPolicy::Required)
);
assert_eq!(
SandboxPolicy::parse("Required"),
Some(SandboxPolicy::Required)
);
assert_eq!(SandboxPolicy::parse("OFF"), Some(SandboxPolicy::Off));
assert_eq!(SandboxPolicy::parse(" off "), Some(SandboxPolicy::Off));
}
#[test]
fn policy_parse_rejects_unknown_values() {
assert_eq!(SandboxPolicy::parse(""), None);
assert_eq!(SandboxPolicy::parse("preferred"), None);
assert_eq!(SandboxPolicy::parse("relaxed"), None);
assert_eq!(SandboxPolicy::parse("required-ish"), None);
}
#[test]
fn policy_default_is_required() {
assert_eq!(SandboxPolicy::default(), SandboxPolicy::Required);
}
#[test]
#[allow(unsafe_code)] fn config_default_policy_is_required_when_env_unset() {
unsafe {
std::env::remove_var("ASTRID_SANDBOX_POLICY");
}
let config = ProcessSandboxConfig::new("/project");
assert_eq!(
config.policy,
SandboxPolicy::Required,
"fresh config with unset env must default to Required — \
silent unsandboxed launches are the bug from #655"
);
}
#[test]
fn with_policy_overrides_default() {
let config = ProcessSandboxConfig::new("/project").with_policy(SandboxPolicy::Off);
assert_eq!(config.policy, SandboxPolicy::Off);
}
#[test]
fn sandbox_prefix_with_off_policy_returns_none_silently() {
let config = ProcessSandboxConfig::new("/project").with_policy(SandboxPolicy::Off);
let result = config.sandbox_prefix();
assert!(matches!(result, Ok(None)));
}
#[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());
}
}