#[cfg(target_os = "macos")]
use anyhow::Context;
use anyhow::{Result, bail};
use std::process::Output;
#[cfg(target_os = "macos")]
use std::process::{Command, Stdio};
use crate::validation;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[allow(dead_code)]
pub enum SeatbeltProfile {
Permissive,
#[default]
Moderate,
Restrictive,
}
#[allow(dead_code)]
pub struct SeatbeltSandbox {
profile: SeatbeltProfile,
working_dir: Option<String>,
}
impl SeatbeltSandbox {
#[allow(dead_code)]
pub fn new(profile: SeatbeltProfile) -> Self {
Self {
profile,
working_dir: None,
}
}
#[allow(dead_code)]
pub fn with_working_dir(mut self, dir: &str) -> Result<Self> {
let validated = validation::validate_seatbelt_path(dir)?;
self.working_dir = Some(validated);
Ok(self)
}
#[allow(dead_code)]
pub fn is_available() -> bool {
#[cfg(target_os = "macos")]
{
Command::new("sandbox-exec")
.arg("-h")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok()
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
#[allow(dead_code)]
fn generate_profile(&self) -> String {
match self.profile {
SeatbeltProfile::Permissive => {
r#"(version 1)
(allow default)
(deny file-write* (subpath "/System"))
(deny file-write* (subpath "/Library"))
(deny file-write* (subpath "/usr"))
(deny process-exec* (subpath "/System"))
"#
.to_string()
}
SeatbeltProfile::Moderate => {
let working_dir = self
.working_dir
.as_deref()
.unwrap_or("/tmp/agentkernel-sandbox");
format!(
r#"(version 1)
(deny default)
(allow signal (target self))
(allow process-fork)
(allow process-exec)
(allow sysctl-read)
(allow mach-lookup)
(allow mach-register)
(allow ipc-posix*)
(allow system-socket)
; Network access
(allow network*)
; Allow read access to system paths
(allow file-read* (subpath "/"))
; Allow write to working directory
(allow file-write* (subpath "{}"))
(allow file-write* (subpath "/tmp"))
(allow file-write* (subpath "/var/folders"))
(allow file-write* (subpath "/private/tmp"))
(allow file-write* (subpath "/private/var/folders"))
; Allow executing binaries
(allow process-exec (subpath "/usr/bin"))
(allow process-exec (subpath "/usr/local/bin"))
(allow process-exec (subpath "/opt/homebrew/bin"))
(allow process-exec (subpath "/bin"))
(allow process-exec (subpath "/sbin"))
"#,
working_dir
)
}
SeatbeltProfile::Restrictive => {
let working_dir = self
.working_dir
.as_deref()
.unwrap_or("/tmp/agentkernel-sandbox");
format!(
r#"(version 1)
(deny default)
(allow signal (target self))
(allow process-fork)
(allow process-exec)
(allow sysctl-read)
(allow mach-lookup)
(allow ipc-posix*)
; NO network access
; Allow read access to essential paths
(allow file-read* (subpath "/usr"))
(allow file-read* (subpath "/bin"))
(allow file-read* (subpath "/sbin"))
(allow file-read* (subpath "/opt"))
(allow file-read* (subpath "/Library/Frameworks"))
(allow file-read* (subpath "/System/Library"))
(allow file-read* (subpath "/private/etc"))
(allow file-read* (subpath "/dev"))
; Allow read/write to working directory only
(allow file-read* (subpath "{}"))
(allow file-write* (subpath "{}"))
(allow file-write* (subpath "/tmp"))
(allow file-write* (subpath "/private/tmp"))
(allow file-write* (subpath "/dev/null"))
(allow file-write* (subpath "/dev/tty"))
; Allow executing binaries
(allow process-exec (subpath "/usr/bin"))
(allow process-exec (subpath "/bin"))
(allow process-exec (subpath "/opt/homebrew/bin"))
"#,
working_dir, working_dir
)
}
}
}
#[cfg(target_os = "macos")]
#[allow(dead_code)]
pub fn run(&self, command: &[String]) -> Result<Output> {
if command.is_empty() {
bail!("Empty command");
}
let profile = self.generate_profile();
let profile_path =
std::env::temp_dir().join(format!("agentkernel-seatbelt-{}.sb", std::process::id()));
std::fs::write(&profile_path, &profile).context("Failed to write Seatbelt profile")?;
let mut cmd = Command::new("sandbox-exec");
cmd.arg("-f").arg(&profile_path);
cmd.arg(command[0].clone());
cmd.args(&command[1..]);
if let Some(ref dir) = self.working_dir {
cmd.current_dir(dir);
}
let output = cmd.output().context("Failed to run sandboxed command")?;
let _ = std::fs::remove_file(&profile_path);
Ok(output)
}
#[cfg(not(target_os = "macos"))]
#[allow(dead_code)]
pub fn run(&self, _command: &[String]) -> Result<Output> {
bail!("Seatbelt sandbox is only available on macOS");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_profile_generation() {
let sandbox = SeatbeltSandbox::new(SeatbeltProfile::Restrictive)
.with_working_dir("/tmp/test")
.expect("Valid path");
let profile = sandbox.generate_profile();
assert!(profile.contains("(version 1)"));
assert!(profile.contains("(deny default)"));
assert!(profile.contains("/tmp/test"));
}
#[test]
fn test_invalid_working_dir_rejected() {
let result = SeatbeltSandbox::new(SeatbeltProfile::Moderate)
.with_working_dir("/tmp\"))(allow default)(\"");
assert!(result.is_err());
let result =
SeatbeltSandbox::new(SeatbeltProfile::Moderate).with_working_dir("/tmp/../etc/passwd");
assert!(result.is_err());
let result = SeatbeltSandbox::new(SeatbeltProfile::Moderate).with_working_dir("tmp/test");
assert!(result.is_err());
}
#[test]
fn test_permissive_profile() {
let sandbox = SeatbeltSandbox::new(SeatbeltProfile::Permissive);
let profile = sandbox.generate_profile();
assert!(profile.contains("(allow default)"));
}
}