use std::path::Path;
use crate::policy::sandbox_types::{NetworkPolicy, SandboxPolicy};
use tracing::{Level, info, instrument};
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
pub mod proxy;
#[derive(Debug)]
pub enum SupportLevel {
Full,
Partial { missing: Vec<String> },
Unsupported { reason: String },
}
#[derive(Debug, thiserror::Error)]
pub enum SandboxError {
#[error("sandbox not supported: {0}")]
Unsupported(String),
#[error("failed to apply sandbox: {0}")]
Apply(String),
#[error("failed to exec command: {0}")]
Exec(#[from] std::io::Error),
}
pub trait SandboxBackend {
fn apply_and_exec(
&self,
policy: &SandboxPolicy,
cwd: &Path,
command: &[String],
trace_path: Option<&Path>,
) -> Result<(), SandboxError>;
fn check_support(&self) -> SupportLevel;
}
#[cfg(test)]
pub struct MockSandbox {
pub applications: std::cell::RefCell<Vec<MockApplication>>,
pub support: SupportLevel,
}
#[cfg(test)]
#[derive(Debug, Clone)]
pub struct MockApplication {
pub policy: SandboxPolicy,
pub cwd: String,
pub command: Vec<String>,
}
#[cfg(test)]
impl MockSandbox {
pub fn new() -> Self {
Self {
applications: std::cell::RefCell::new(Vec::new()),
support: SupportLevel::Full,
}
}
pub fn with_support(mut self, support: SupportLevel) -> Self {
self.support = support;
self
}
pub fn applications(&self) -> Vec<MockApplication> {
self.applications.borrow().clone()
}
}
#[cfg(test)]
impl SandboxBackend for MockSandbox {
fn apply_and_exec(
&self,
policy: &SandboxPolicy,
cwd: &Path,
command: &[String],
_trace_path: Option<&Path>,
) -> Result<(), SandboxError> {
self.applications.borrow_mut().push(MockApplication {
policy: policy.clone(),
cwd: cwd.to_string_lossy().into_owned(),
command: command.to_vec(),
});
Ok(())
}
fn check_support(&self) -> SupportLevel {
SupportLevel::Full
}
}
#[instrument(level = Level::TRACE, skip(policy))]
pub fn exec_sandboxed(
policy: &SandboxPolicy,
cwd: &Path,
command: &[String],
trace_path: Option<&Path>,
) -> Result<std::convert::Infallible, SandboxError> {
if command.is_empty() {
return Err(SandboxError::Apply("no command specified".into()));
}
let policy = &policy.expand_worktree_rules(cwd);
#[cfg(target_os = "linux")]
{
let _ = trace_path;
linux::exec_sandboxed(policy, cwd, command)
}
#[cfg(target_os = "macos")]
{
macos::exec_sandboxed(policy, cwd, command, trace_path)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = trace_path;
Err(SandboxError::Unsupported(
"sandbox only supported on Linux and macOS".into(),
))
}
}
pub fn compile_sandbox_profile(policy: &SandboxPolicy, cwd: &Path) -> Result<String, SandboxError> {
let policy = &policy.expand_worktree_rules(cwd);
let cwd_str = cwd.to_string_lossy();
#[cfg(target_os = "macos")]
{
Ok(macos::compile_to_sbpl(policy, &cwd_str))
}
#[cfg(target_os = "linux")]
{
let _ = (policy, &cwd_str);
Err(SandboxError::Unsupported(
"sandbox profile compilation not supported on Linux (Landlock is applied in-process)"
.into(),
))
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = (policy, &cwd_str);
Err(SandboxError::Unsupported(
"sandbox only supported on Linux and macOS".into(),
))
}
}
pub fn spawn_sandboxed_shell(
policy: &SandboxPolicy,
cwd: &Path,
) -> Result<std::process::ExitStatus, SandboxError> {
let profile = compile_sandbox_profile(policy, cwd)?;
let _proxy_handle = maybe_start_proxy(&policy.network)?;
#[cfg(target_os = "macos")]
{
let mut cmd = std::process::Command::new("sandbox-exec");
cmd.args(["-p", &profile, "--", "/bin/bash", "-i"])
.current_dir(cwd)
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
if let Some(ref handle) = _proxy_handle {
set_proxy_env(&mut cmd, handle.addr);
}
let status = cmd.status().map_err(SandboxError::Exec)?;
Ok(status)
}
#[cfg(not(target_os = "macos"))]
{
let _ = profile;
Err(SandboxError::Unsupported(
"interactive sandboxed shell only supported on macOS".into(),
))
}
}
fn maybe_start_proxy(network: &NetworkPolicy) -> Result<Option<proxy::ProxyHandle>, SandboxError> {
match network {
NetworkPolicy::AllowDomains(domains) => {
let config = proxy::ProxyConfig {
allowed_domains: domains.clone(),
};
let handle = proxy::start_proxy(config)
.map_err(|e| SandboxError::Apply(format!("failed to start proxy: {}", e)))?;
info!(addr = %handle.addr, domains = ?domains, "started domain-filtering proxy");
Ok(Some(handle))
}
_ => Ok(None),
}
}
#[cfg(target_os = "macos")]
fn set_proxy_env(cmd: &mut std::process::Command, addr: std::net::SocketAddr) {
let proxy_url = format!("http://{}", addr);
cmd.env("HTTP_PROXY", &proxy_url)
.env("HTTPS_PROXY", &proxy_url)
.env("http_proxy", &proxy_url)
.env("https_proxy", &proxy_url);
}
#[instrument(level = Level::TRACE)]
pub fn check_support() -> SupportLevel {
#[cfg(target_os = "linux")]
{
linux::check_support()
}
#[cfg(target_os = "macos")]
{
macos::check_support()
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
SupportLevel::Unsupported {
reason: "sandbox only supported on Linux and macOS".into(),
}
}
}
#[instrument(level = Level::TRACE)]
pub(crate) fn do_exec(command: &[String]) -> Result<std::convert::Infallible, SandboxError> {
use std::ffi::CString;
let c_command = CString::new(command[0].as_str())
.map_err(|e| SandboxError::Apply(format!("invalid command: {}", e)))?;
let c_args: Vec<CString> = command
.iter()
.map(|arg| CString::new(arg.as_str()))
.collect::<Result<_, _>>()
.map_err(|e| SandboxError::Apply(format!("invalid argument: {}", e)))?;
let c_args_ptrs: Vec<*const libc::c_char> = c_args
.iter()
.map(|arg| arg.as_ptr())
.chain(std::iter::once(std::ptr::null()))
.collect();
unsafe {
libc::execvp(c_command.as_ptr(), c_args_ptrs.as_ptr());
}
Err(SandboxError::Exec(std::io::Error::last_os_error()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::sandbox_types::{Cap, NetworkPolicy, PathMatch, RuleEffect, SandboxRule};
fn simple_policy() -> SandboxPolicy {
SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![
SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::all(),
path: "/tmp".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
SandboxRule {
effect: RuleEffect::Deny,
caps: Cap::READ,
path: "/etc/shadow".into(),
path_match: PathMatch::Literal,
follow_worktrees: false,
doc: None,
},
],
network: NetworkPolicy::Deny,
doc: None,
}
}
#[test]
fn mock_sandbox_records_applications() {
let mock = MockSandbox::new();
let policy = simple_policy();
let cwd = Path::new("/home/user");
let command = vec!["ls".into(), "-la".into()];
mock.apply_and_exec(&policy, cwd, &command, None).unwrap();
let apps = mock.applications();
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].cwd, "/home/user");
assert_eq!(apps[0].command, vec!["ls", "-la"]);
assert_eq!(apps[0].policy.network, NetworkPolicy::Deny);
assert_eq!(apps[0].policy.rules.len(), 2);
}
#[test]
fn mock_sandbox_records_multiple_applications() {
let mock = MockSandbox::new();
let policy = simple_policy();
mock.apply_and_exec(&policy, Path::new("/a"), &["cmd1".into()], None)
.unwrap();
mock.apply_and_exec(&policy, Path::new("/b"), &["cmd2".into()], None)
.unwrap();
let apps = mock.applications();
assert_eq!(apps.len(), 2);
assert_eq!(apps[0].cwd, "/a");
assert_eq!(apps[1].cwd, "/b");
}
#[test]
fn mock_sandbox_reports_full_support() {
let mock = MockSandbox::new();
assert!(matches!(mock.check_support(), SupportLevel::Full));
}
#[test]
fn effective_caps_default_only() {
let policy = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![],
network: NetworkPolicy::Allow,
doc: None,
};
let caps = policy.effective_caps("/any/path", "/cwd");
assert_eq!(caps, Cap::READ | Cap::EXECUTE);
}
#[test]
fn effective_caps_allow_rule() {
let policy = SandboxPolicy {
default: Cap::READ,
rules: vec![SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::WRITE,
path: "/tmp".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
}],
network: NetworkPolicy::Allow,
doc: None,
};
let caps = policy.effective_caps("/tmp/file.txt", "/cwd");
assert_eq!(caps, Cap::READ | Cap::WRITE);
}
#[test]
fn effective_caps_deny_overrides_allow() {
let policy = SandboxPolicy {
default: Cap::all(),
rules: vec![SandboxRule {
effect: RuleEffect::Deny,
caps: Cap::WRITE | Cap::DELETE,
path: "/etc".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
}],
network: NetworkPolicy::Allow,
doc: None,
};
let caps = policy.effective_caps("/etc/passwd", "/cwd");
assert!(caps.contains(Cap::READ));
assert!(caps.contains(Cap::EXECUTE));
assert!(!caps.contains(Cap::WRITE));
assert!(!caps.contains(Cap::DELETE));
}
#[test]
fn effective_caps_deny_overrides_default_and_allow() {
let policy = SandboxPolicy {
default: Cap::READ,
rules: vec![
SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::WRITE,
path: "/data".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
SandboxRule {
effect: RuleEffect::Deny,
caps: Cap::WRITE,
path: "/data/readonly".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
},
],
network: NetworkPolicy::Allow,
doc: None,
};
assert_eq!(
policy.effective_caps("/data/file.txt", "/cwd"),
Cap::READ | Cap::WRITE
);
assert_eq!(
policy.effective_caps("/data/readonly/file.txt", "/cwd"),
Cap::READ
);
}
#[test]
fn effective_caps_literal_match() {
let policy = SandboxPolicy {
default: Cap::READ,
rules: vec![SandboxRule {
effect: RuleEffect::Deny,
caps: Cap::READ,
path: "/secret.key".into(),
path_match: PathMatch::Literal,
follow_worktrees: false,
doc: None,
}],
network: NetworkPolicy::Allow,
doc: None,
};
assert_eq!(policy.effective_caps("/secret.key", "/cwd"), Cap::empty());
assert_eq!(policy.effective_caps("/secret.key.bak", "/cwd"), Cap::READ);
}
#[test]
fn effective_caps_no_match_uses_default() {
let policy = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::all(),
path: "/tmp".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
}],
network: NetworkPolicy::Allow,
doc: None,
};
assert_eq!(
policy.effective_caps("/home/user/file", "/cwd"),
Cap::READ | Cap::EXECUTE
);
}
}