use super::SandboxConfig;
fn sbpl_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
const SYSTEM_READ_PATHS: &[&str] = &[
"/System",
"/Library",
"/usr",
"/bin",
"/sbin",
"/dev",
"/etc",
"/var/run",
"/tmp",
"/private/tmp",
"/private/etc",
"/private/var/run",
"/opt/homebrew",
"/nix",
];
pub(crate) async fn generate_seatbelt_profile(
config: &SandboxConfig,
initial_program: Option<&std::path::Path>,
) -> String {
let mut rules = Vec::new();
rules.push("(version 1)".to_string());
rules.push("(allow default)".to_string());
if config.effective_deny_write() {
rules.push("(deny file-write*)".to_string());
if !config.deny_temp_write {
rules.push("(allow file-write* (subpath \"/tmp\"))".to_string());
rules.push("(allow file-write* (subpath \"/private/tmp\"))".to_string());
}
rules.push("(allow file-write* (subpath \"/dev\"))".to_string());
for path in &config.allow_write {
let path_str = sbpl_escape(&path.to_string_lossy());
rules.push(format!("(allow file-write* (subpath \"{path_str}\"))"));
rules.push(format!("(allow file-write* (literal \"{path_str}\"))"));
}
}
if config.effective_deny_read() {
rules.push("(deny file-read*)".to_string());
rules.push("(allow file-read-data (literal \"/\"))".to_string());
rules.push("(allow file-read-metadata (literal \"/private\"))".to_string());
for path in SYSTEM_READ_PATHS {
rules.push(format!("(allow file-read* (subpath \"{path}\"))"));
}
let data_dir = &*crate::env::MISE_DATA_DIR;
let data_str = sbpl_escape(&data_dir.to_string_lossy());
rules.push(format!("(allow file-read* (subpath \"{data_str}\"))"));
for path in &config.allow_read {
let path_str = sbpl_escape(&path.to_string_lossy());
rules.push(format!("(allow file-read* (subpath \"{path_str}\"))"));
rules.push(format!("(allow file-read* (literal \"{path_str}\"))"));
}
for path in &config.allow_write {
let path_str = sbpl_escape(&path.to_string_lossy());
rules.push(format!("(allow file-read* (subpath \"{path_str}\"))"));
rules.push(format!("(allow file-read* (literal \"{path_str}\"))"));
}
}
if config.effective_deny_net() {
rules.push("(deny network*)".to_string());
rules.push("(allow network* (local unix))".to_string());
if !config.allow_net.is_empty() {
rules.push(
"(allow network* (remote unix-socket (path-literal \"/var/run/mDNSResponder\")))"
.to_string(),
);
let lookups: Vec<_> = config
.allow_net
.iter()
.map(|host| {
let host = host.clone();
tokio::spawn(async move {
match tokio::net::lookup_host(format!("{host}:0")).await {
Ok(addrs) => {
let ips: Vec<_> = addrs.map(|a| a.ip()).collect();
(host, ips)
}
Err(_) => (host, vec![]),
}
})
})
.collect();
for handle in lookups {
if let Ok((host, ips)) = handle.await {
if ips.is_empty() {
let host = sbpl_escape(&host);
rules.push(format!("(allow network* (remote ip \"{host}:*\"))"));
} else {
for ip in ips {
rules.push(format!("(allow network* (remote ip \"{ip}:*\"))"));
}
}
}
}
}
}
if config.deny_process {
rules.push("(deny process-fork)".to_string());
rules.push("(deny process-exec)".to_string());
if let Some(path) = initial_program {
let path_str = sbpl_escape(&path.to_string_lossy());
rules.push(format!("(allow process-exec (literal \"{path_str}\"))"));
if let Ok(canonical) = path.canonicalize()
&& canonical != path
{
let canonical = sbpl_escape(&canonical.to_string_lossy());
rules.push(format!("(allow process-exec (literal \"{canonical}\"))"));
}
}
}
rules.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
fs,
path::{Path, PathBuf},
process::Command,
};
#[tokio::test]
async fn test_deny_write_profile() {
let config = SandboxConfig {
deny_write: true,
..Default::default()
};
let profile = generate_seatbelt_profile(&config, None).await;
assert!(profile.contains("(deny file-write*)"));
assert!(profile.contains("(allow file-write* (subpath \"/tmp\"))"));
assert!(!profile.contains("(deny file-read*)"));
assert!(!profile.contains("(deny network*)"));
}
#[tokio::test]
async fn test_deny_net_profile() {
let config = SandboxConfig {
deny_net: true,
..Default::default()
};
let profile = generate_seatbelt_profile(&config, None).await;
assert!(profile.contains("(deny network*)"));
assert!(!profile.contains("(deny file-write*)"));
}
#[tokio::test]
async fn test_allow_write_implies_deny() {
let config = SandboxConfig {
allow_write: vec![PathBuf::from("/tmp/mydir")],
..Default::default()
};
let profile = generate_seatbelt_profile(&config, None).await;
assert!(profile.contains("(deny file-write*)"));
assert!(profile.contains("(allow file-write* (subpath \"/tmp/mydir\"))"));
}
#[tokio::test]
async fn test_allow_net_per_host() {
let config = SandboxConfig {
allow_net: vec!["1.2.3.4".to_string()],
..Default::default()
};
let profile = generate_seatbelt_profile(&config, None).await;
assert!(profile.contains("(deny network*)"));
assert!(profile.contains("(allow network* (remote ip \"1.2.3.4:*\"))"));
assert_eq!(
profile.matches("mDNSResponder").count(),
1,
"mDNSResponder rule should appear once"
);
}
#[tokio::test]
async fn test_deny_read_includes_system_paths() {
let config = SandboxConfig {
deny_read: true,
..Default::default()
};
let profile = generate_seatbelt_profile(&config, None).await;
assert!(profile.contains("(deny file-read*)"));
assert!(profile.contains("(allow file-read* (subpath \"/usr\"))"));
assert!(profile.contains("(allow file-read* (subpath \"/System\"))"));
}
#[tokio::test]
async fn test_allow_read_executes_shell_without_reading_siblings() {
let root = tempfile::tempdir().unwrap();
let allowed_dir = root.path().join("allowed");
fs::create_dir(&allowed_dir).unwrap();
let allowed_file = allowed_dir.join("allowed.txt");
let denied_file = root.path().join("denied.txt");
fs::write(&allowed_file, "allowed").unwrap();
fs::write(&denied_file, "denied").unwrap();
let allowed_file = allowed_file.canonicalize().unwrap();
let denied_file = denied_file.canonicalize().unwrap();
let mut config = SandboxConfig {
deny_read: true,
allow_read: vec![allowed_file.clone()],
..Default::default()
};
config.resolve_paths();
let profile = generate_seatbelt_profile(&config, Some(Path::new("/bin/sh"))).await;
let read = |path: &std::path::Path| {
Command::new("sandbox-exec")
.current_dir("/")
.args(["-p", &profile, "--", "/bin/sh", "-c", "cat \"$1\"", "sh"])
.arg(path)
.output()
.unwrap()
};
let allowed = read(&allowed_file);
assert!(
allowed.status.success(),
"sandboxed shell failed: {}",
String::from_utf8_lossy(&allowed.stderr)
);
assert!(!read(&denied_file).status.success());
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn test_private_metadata_does_not_expose_directory_contents() {
let config = SandboxConfig {
deny_read: true,
..Default::default()
};
let profile = generate_seatbelt_profile(&config, None).await;
let run = |program: &str, args: &[&str]| {
Command::new("sandbox-exec")
.args(["-p", &profile, "--", program])
.args(args)
.output()
.unwrap()
};
let metadata = run("/usr/bin/stat", &["-f", "%N", "/private"]);
assert!(
metadata.status.success(),
"sandboxed stat failed: {}",
String::from_utf8_lossy(&metadata.stderr)
);
assert!(!run("/bin/ls", &["/private"]).status.success());
}
#[tokio::test]
async fn test_deny_all() {
let config = SandboxConfig {
deny_read: true,
deny_write: true,
deny_net: true,
deny_env: true,
..Default::default()
};
let profile = generate_seatbelt_profile(&config, None).await;
assert!(profile.contains("(deny file-read*)"));
assert!(profile.contains("(deny file-write*)"));
assert!(profile.contains("(deny network*)"));
}
#[tokio::test]
async fn test_deny_process_allows_only_initial_executable() {
let config = SandboxConfig {
deny_process: true,
..Default::default()
};
let profile = generate_seatbelt_profile(&config, Some(Path::new("/usr/bin/ruby"))).await;
assert!(profile.contains("(deny process-fork)"));
assert!(profile.contains("(deny process-exec)"));
assert!(profile.contains("(allow process-exec (literal \"/usr/bin/ruby\"))"));
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn test_deny_process_at_runtime() {
let config = SandboxConfig {
deny_process: true,
..Default::default()
};
let profile = generate_seatbelt_profile(&config, Some(Path::new("/usr/bin/ruby"))).await;
let script = r#"
puts "ruby started"
begin
fork { exit! }
abort "fork escaped sandbox"
rescue SystemCallError
end
begin
exec "/usr/bin/true"
rescue SystemCallError
end
puts "child processes blocked"
"#;
let output = Command::new("sandbox-exec")
.args([
"-p",
&profile,
"--",
"/usr/bin/ruby",
"--disable-gems",
"-e",
script,
])
.output()
.unwrap();
assert!(
output.status.success(),
"sandboxed Ruby failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"ruby started\nchild processes blocked\n"
);
}
}