use std::path::Path;
use super::capabilities::AddonCapabilities;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Dims {
pub network_allowed: bool,
pub fs_writable: bool,
}
impl Dims {
#[must_use]
fn is_noop(self) -> bool {
self.network_allowed && self.fs_writable
}
}
#[must_use]
fn dims_for_mode(mode: SandboxMode) -> Dims {
match mode {
SandboxMode::Off => Dims {
network_allowed: true,
fs_writable: true,
},
SandboxMode::Auto => Dims {
network_allowed: false,
fs_writable: true,
},
SandboxMode::Strict => Dims {
network_allowed: false,
fs_writable: false,
},
}
}
#[must_use]
fn dims_for_caps(caps: &AddonCapabilities) -> Dims {
Dims {
network_allowed: caps.network_allowed(),
fs_writable: caps.filesystem_writable(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SandboxMode {
#[default]
Off,
Auto,
Strict,
}
impl SandboxMode {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Auto => "auto",
Self::Strict => "strict",
}
}
#[must_use]
pub fn parse(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"auto" => Self::Auto,
"strict" => Self::Strict,
_ => Self::Off,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Launcher {
SandboxExec,
Bwrap,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Plan {
Direct,
Wrap(Launcher),
Refuse(String),
}
#[must_use]
pub fn plan(mode: SandboxMode, launcher: Option<Launcher>) -> Plan {
match (mode, launcher) {
(SandboxMode::Off, _) | (SandboxMode::Auto, None) => Plan::Direct,
(_, Some(l)) => Plan::Wrap(l),
(SandboxMode::Strict, None) => Plan::Refuse(
"addons.sandbox = strict but no OS sandbox launcher (sandbox-exec / bwrap) is available"
.to_string(),
),
}
}
#[must_use]
pub fn detect_launcher() -> Option<Launcher> {
if cfg!(target_os = "macos") && which("sandbox-exec") {
Some(Launcher::SandboxExec)
} else if cfg!(target_os = "linux") && which("bwrap") {
Some(Launcher::Bwrap)
} else {
None
}
}
#[must_use]
pub fn wrap_argv(
launcher: Launcher,
mode: SandboxMode,
command: &str,
args: &[String],
) -> (String, Vec<String>) {
wrap_argv_dims(launcher, dims_for_mode(mode), command, args)
}
#[must_use]
fn wrap_argv_dims(
launcher: Launcher,
dims: Dims,
command: &str,
args: &[String],
) -> (String, Vec<String>) {
match launcher {
Launcher::SandboxExec => {
let profile = sbpl_profile_dims(dims);
let mut v = vec!["-p".to_string(), profile, command.to_string()];
v.extend(args.iter().cloned());
("sandbox-exec".to_string(), v)
}
Launcher::Bwrap => {
let mut v = bwrap_flags_dims(dims);
v.push(command.to_string());
v.extend(args.iter().cloned());
("bwrap".to_string(), v)
}
}
}
#[cfg(test)]
fn sbpl_profile(mode: SandboxMode) -> String {
sbpl_profile_dims(dims_for_mode(mode))
}
fn sbpl_profile_dims(dims: Dims) -> String {
let mut p = String::from("(version 1)\n(allow default)\n");
if !dims.network_allowed {
p.push_str("(deny network*)\n");
}
if !dims.fs_writable {
p.push_str("(deny file-write*)\n");
p.push_str("(allow file-write* (subpath \"/tmp\") (subpath \"/private/tmp\") (subpath \"/var/folders\"))\n");
}
p
}
#[cfg(test)]
fn bwrap_flags(mode: SandboxMode) -> Vec<String> {
bwrap_flags_dims(dims_for_mode(mode))
}
fn bwrap_flags_dims(dims: Dims) -> Vec<String> {
let mut f: Vec<String> = vec!["--die-with-parent".into()];
if !dims.network_allowed {
f.push("--unshare-net".into());
}
if dims.fs_writable {
f.extend(
["--bind", "/", "/", "--dev", "/dev", "--proc", "/proc"]
.iter()
.map(|s| (*s).to_string()),
);
} else {
f.extend(
[
"--ro-bind",
"/",
"/",
"--dev",
"/dev",
"--proc",
"/proc",
"--tmpfs",
"/tmp",
]
.iter()
.map(|s| (*s).to_string()),
);
}
f
}
pub fn apply(command: &str, args: &[String]) -> Result<(String, Vec<String>), String> {
let mode = crate::core::config::Config::load().addons.sandbox_mode();
if mode == SandboxMode::Off {
return Ok((command.to_string(), args.to_vec()));
}
match plan(mode, detect_launcher()) {
Plan::Direct => {
if mode != SandboxMode::Off {
tracing::warn!(
"addons.sandbox = {} but no OS sandbox launcher is available — \
spawning `{command}` UNSANDBOXED",
mode.as_str()
);
}
Ok((command.to_string(), args.to_vec()))
}
Plan::Wrap(launcher) => {
tracing::debug!(
"sandboxing `{command}` via {:?} ({} mode)",
launcher,
mode.as_str()
);
Ok(wrap_argv(launcher, mode, command, args))
}
Plan::Refuse(reason) => Err(reason),
}
}
pub fn apply_for(
command: &str,
args: &[String],
capabilities: Option<&AddonCapabilities>,
) -> Result<(String, Vec<String>), String> {
match capabilities {
Some(caps) => apply_caps(command, args, caps),
None => apply(command, args),
}
}
fn apply_caps(
command: &str,
args: &[String],
caps: &AddonCapabilities,
) -> Result<(String, Vec<String>), String> {
let dims = dims_for_caps(caps);
if dims.is_noop() {
return Ok((command.to_string(), args.to_vec()));
}
let enforce = crate::core::config::Config::load()
.addons
.enforce_capabilities;
let Some(launcher) = detect_launcher() else {
if enforce {
return Err(format!(
"addons.enforce_capabilities = true but no OS sandbox launcher \
(sandbox-exec / bwrap) is available to honour `{command}`'s declared \
restricted capabilities"
));
}
tracing::warn!(
"addon `{command}` declares restricted capabilities but no OS sandbox \
launcher is available — running UNSANDBOXED (set \
addons.enforce_capabilities = true to fail closed)"
);
return Ok((command.to_string(), args.to_vec()));
};
tracing::debug!(
"sandboxing `{command}` via {:?} (net={}, fs_write={})",
launcher,
dims.network_allowed,
dims.fs_writable
);
Ok(wrap_argv_dims(launcher, dims, command, args))
}
fn which(bin: &str) -> bool {
let Ok(path) = std::env::var("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| {
let p = dir.join(bin);
p.is_file() && is_executable(&p)
})
}
#[cfg(unix)]
fn is_executable(p: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p).is_ok_and(|m| m.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
fn is_executable(_p: &Path) -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_parse_roundtrip() {
assert_eq!(SandboxMode::parse("auto"), SandboxMode::Auto);
assert_eq!(SandboxMode::parse("STRICT"), SandboxMode::Strict);
assert_eq!(SandboxMode::parse(""), SandboxMode::Off);
assert_eq!(SandboxMode::parse("nonsense"), SandboxMode::Off);
assert_eq!(SandboxMode::Strict.as_str(), "strict");
}
#[test]
fn plan_off_is_always_direct() {
assert_eq!(plan(SandboxMode::Off, Some(Launcher::Bwrap)), Plan::Direct);
assert_eq!(plan(SandboxMode::Off, None), Plan::Direct);
}
#[test]
fn plan_auto_without_launcher_runs_direct() {
assert_eq!(plan(SandboxMode::Auto, None), Plan::Direct);
}
#[test]
fn plan_strict_without_launcher_refuses() {
assert!(matches!(plan(SandboxMode::Strict, None), Plan::Refuse(_)));
}
#[test]
fn plan_wraps_when_launcher_present() {
assert_eq!(
plan(SandboxMode::Auto, Some(Launcher::SandboxExec)),
Plan::Wrap(Launcher::SandboxExec)
);
}
#[test]
fn sandbox_exec_argv_prepends_profile_and_command() {
let (cmd, args) = wrap_argv(
Launcher::SandboxExec,
SandboxMode::Auto,
"my-mcp",
&["serve".into()],
);
assert_eq!(cmd, "sandbox-exec");
assert_eq!(args[0], "-p");
assert!(args[1].contains("(deny network*)"));
assert_eq!(args[2], "my-mcp");
assert_eq!(args[3], "serve");
}
#[test]
fn strict_sbpl_restricts_writes() {
let p = sbpl_profile(SandboxMode::Strict);
assert!(p.contains("(deny file-write*)"));
assert!(p.contains("/tmp"));
let auto = sbpl_profile(SandboxMode::Auto);
assert!(!auto.contains("(deny file-write*)"));
}
#[test]
fn bwrap_argv_unshares_network() {
let (cmd, args) = wrap_argv(Launcher::Bwrap, SandboxMode::Auto, "my-mcp", &["x".into()]);
assert_eq!(cmd, "bwrap");
assert!(args.iter().any(|a| a == "--unshare-net"));
assert!(args.iter().any(|a| a == "my-mcp"));
assert!(args.iter().any(|a| a == "x"));
}
#[test]
fn bwrap_strict_is_readonly_root() {
let (_c, args) = wrap_argv(Launcher::Bwrap, SandboxMode::Strict, "m", &[]);
assert!(args.iter().any(|a| a == "--ro-bind"));
assert!(args.iter().any(|a| a == "--tmpfs"));
}
use super::super::capabilities::{
AddonCapabilities, ExecAccess, FilesystemAccess, NetworkAccess,
};
#[test]
fn minimal_caps_block_network_and_writes() {
let dims = dims_for_caps(&AddonCapabilities::default());
assert!(!dims.network_allowed);
assert!(!dims.fs_writable);
let sbpl = sbpl_profile_dims(dims);
assert!(sbpl.contains("(deny network*)"));
assert!(sbpl.contains("(deny file-write*)"));
}
#[test]
fn full_network_caps_omit_network_deny() {
let caps = AddonCapabilities {
network: NetworkAccess::Full,
filesystem: FilesystemAccess::ReadOnly,
env: vec![],
exec: ExecAccess::default(),
};
let dims = dims_for_caps(&caps);
assert!(dims.network_allowed);
let sbpl = sbpl_profile_dims(dims);
assert!(!sbpl.contains("(deny network*)"));
assert!(sbpl.contains("(deny file-write*)"));
let flags = bwrap_flags_dims(dims);
assert!(!flags.iter().any(|f| f == "--unshare-net"));
assert!(flags.iter().any(|f| f == "--ro-bind"));
}
#[test]
fn permissive_net_fs_is_a_noop_regardless_of_exec() {
let caps = AddonCapabilities {
network: NetworkAccess::Full,
filesystem: FilesystemAccess::ReadWrite,
env: vec![],
exec: ExecAccess::default(), };
assert!(caps.exec_restricted());
assert!(dims_for_caps(&caps).is_noop());
let (cmd, args) = apply_for("my-mcp", &["serve".into()], Some(&caps)).expect("noop");
assert_eq!(cmd, "my-mcp");
assert_eq!(args, vec!["serve".to_string()]);
}
#[test]
fn caps_wrap_argv_prepends_launcher() {
let dims = dims_for_caps(&AddonCapabilities::default());
let (cmd, args) = wrap_argv_dims(Launcher::SandboxExec, dims, "my-mcp", &["x".into()]);
assert_eq!(cmd, "sandbox-exec");
assert_eq!(args[0], "-p");
assert!(args[1].contains("(deny network*)"));
assert_eq!(args[2], "my-mcp");
assert_eq!(args[3], "x");
}
#[test]
fn sandbox_profile_never_emits_process_exec() {
let dims = dims_for_caps(&AddonCapabilities::default());
let (_cmd, args) = wrap_argv_dims(Launcher::SandboxExec, dims, "my-mcp", &[]);
assert!(args[1].contains("(deny network*)"));
assert!(args[1].contains("(deny file-write*)"));
assert!(!args[1].contains("process-exec"));
}
#[test]
fn mode_path_unchanged_via_dims() {
assert!(sbpl_profile(SandboxMode::Auto).contains("(deny network*)"));
assert!(!sbpl_profile(SandboxMode::Auto).contains("(deny file-write*)"));
assert!(sbpl_profile(SandboxMode::Strict).contains("(deny file-write*)"));
assert!(
bwrap_flags(SandboxMode::Auto)
.iter()
.any(|f| f == "--unshare-net")
);
}
}