use std::ffi::OsString;
use std::path::PathBuf;
#[derive(Debug, Clone, Default)]
pub struct SandboxPolicy {
pub deny_network: bool,
pub allowed_writes: Vec<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Enforcement {
SelfApplied { fs_enforced: bool },
ExecArgv(Vec<OsString>),
Ran(i32),
}
pub fn enforce(policy: &SandboxPolicy, argv: &[OsString]) -> anyhow::Result<Enforcement> {
if !policy.deny_network && policy.allowed_writes.is_empty() {
return Ok(Enforcement::SelfApplied { fs_enforced: true });
}
#[cfg(target_os = "linux")]
{
use anyhow::Context as _;
let _ = argv;
let mut fs_enforced = true;
if !policy.allowed_writes.is_empty() {
fs_enforced = linux::apply_fs_confinement(&policy.allowed_writes)
.context("filesystem sandbox unavailable")?;
}
if policy.deny_network {
linux::apply_network_killswitch().context("network sandbox unavailable")?;
}
Ok(Enforcement::SelfApplied { fs_enforced })
}
#[cfg(target_os = "macos")]
{
anyhow::ensure!(
macos::sandbox_exec_present(),
"{} not found; refusing to run the command unconfined",
macos::SANDBOX_EXEC
);
Ok(Enforcement::ExecArgv(macos::wrap_argv(policy, argv)))
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = argv;
anyhow::bail!("no OS sandbox backend on this platform; refusing to run unconfined")
}
}
pub fn network_killswitch_available() -> bool {
#[cfg(target_os = "linux")]
{
linux::network_filter().is_ok()
}
#[cfg(target_os = "macos")]
{
macos::sandbox_exec_present()
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
false
}
}
pub fn fs_confinement_available() -> bool {
#[cfg(target_os = "linux")]
{
linux::fs_ruleset_builds()
}
#[cfg(target_os = "macos")]
{
macos::sandbox_exec_present()
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
false
}
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
mod macos {
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use super::SandboxPolicy;
pub(super) const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";
pub(super) fn sandbox_exec_present() -> bool {
Path::new(SANDBOX_EXEC).exists()
}
pub(super) fn profile(policy: &SandboxPolicy) -> (String, Vec<(String, PathBuf)>) {
let mut sbpl = String::from("(version 1)\n(allow default)\n");
if policy.deny_network {
sbpl.push_str("(deny network*)\n");
sbpl.push_str("(allow network* (remote unix))\n");
sbpl.push_str("(allow network* (local unix))\n");
}
let mut params: Vec<(String, PathBuf)> = Vec::new();
if !policy.allowed_writes.is_empty() {
for (n, root) in policy.allowed_writes.iter().enumerate() {
if !root.exists() {
continue;
}
params.push((format!("WR{n}"), root.clone()));
if let Ok(canonical) = std::fs::canonicalize(root)
&& canonical != *root
{
params.push((format!("WR{n}C"), canonical));
}
}
if params.is_empty() {
sbpl.push_str("(deny file-write*)\n");
} else {
sbpl.push_str("(deny file-write* (require-all");
for (name, _) in ¶ms {
sbpl.push_str(&format!(" (require-not (subpath (param \"{name}\")))"));
}
sbpl.push_str("))\n");
}
}
(sbpl, params)
}
pub(super) fn wrap_argv(policy: &SandboxPolicy, argv: &[OsString]) -> Vec<OsString> {
let (sbpl, params) = profile(policy);
let mut wrapped: Vec<OsString> = vec![SANDBOX_EXEC.into(), "-p".into(), sbpl.into()];
for (name, value) in params {
wrapped.push("-D".into());
let mut kv = OsString::from(format!("{name}="));
kv.push(value.as_os_str());
wrapped.push(kv);
}
wrapped.push("--".into());
wrapped.extend(argv.iter().cloned());
wrapped
}
#[cfg(test)]
mod tests {
use super::*;
fn policy(deny_network: bool, allowed_writes: &[PathBuf]) -> SandboxPolicy {
SandboxPolicy {
deny_network,
allowed_writes: allowed_writes.to_vec(),
}
}
fn tempdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"mermaid-sbpl-test-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn network_denial_lines_present_iff_requested() {
let (with_net, _) = profile(&policy(true, &[]));
assert!(with_net.contains("(deny network*)"));
assert!(with_net.contains("(allow network* (remote unix))"));
assert!(with_net.contains("(allow network* (local unix))"));
assert!(!with_net.contains("file-write"));
let dir = tempdir("no-net");
let (without_net, _) = profile(&policy(false, std::slice::from_ref(&dir)));
assert!(!without_net.contains("network"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_denial_nests_require_nots_under_require_all() {
let a = tempdir("ra-a");
let b = tempdir("ra-b");
let (sbpl, params) = profile(&policy(false, &[a.clone(), b.clone()]));
assert!(params.iter().any(|(n, _)| n == "WR0"));
assert!(params.iter().any(|(n, _)| n == "WR1"));
let deny = sbpl
.lines()
.find(|l| l.starts_with("(deny file-write*"))
.expect("deny file-write* line");
assert!(deny.starts_with("(deny file-write* (require-all (require-not"));
assert_eq!(
deny.matches("(require-not (subpath (param ").count(),
params.len(),
"one require-not per emitted param: {deny}"
);
let _ = std::fs::remove_dir_all(&a);
let _ = std::fs::remove_dir_all(&b);
}
#[test]
fn dual_literal_and_canonical_params_when_they_differ() {
let real = tempdir("canon");
let sub = real.join("sub");
std::fs::create_dir_all(&sub).unwrap();
let alias = sub.join("..");
let (sbpl, params) = profile(&policy(false, std::slice::from_ref(&alias)));
let literal = params.iter().find(|(n, _)| n == "WR0").expect("literal");
let canonical = params.iter().find(|(n, _)| n == "WR0C").expect("canonical");
assert_eq!(literal.1, alias);
assert_ne!(canonical.1, alias);
assert!(sbpl.contains("(param \"WR0\")"));
assert!(sbpl.contains("(param \"WR0C\")"));
let _ = std::fs::remove_dir_all(&real);
}
#[test]
fn nonexistent_roots_are_skipped_and_all_missing_denies_all_writes() {
let missing = std::env::temp_dir().join("mermaid-sbpl-test-definitely-missing");
let (sbpl, params) = profile(&policy(false, std::slice::from_ref(&missing)));
assert!(params.is_empty());
assert!(sbpl.contains("(deny file-write*)\n"));
assert!(!sbpl.contains("require-all"));
}
#[test]
fn paths_never_reach_the_profile_string() {
let evil = tempdir("evil").join("x) (allow default) (deny");
std::fs::create_dir_all(&evil).unwrap();
let (sbpl, params) = profile(&policy(true, std::slice::from_ref(&evil)));
assert!(!sbpl.contains("allow default) (deny"));
assert!(
params.iter().any(|(_, v)| *v == evil),
"the path must ride a param instead"
);
let _ = std::fs::remove_dir_all(evil.parent().unwrap());
}
#[test]
fn wrap_argv_shape_is_frozen() {
let dir = tempdir("argv");
let argv: Vec<OsString> = vec!["sh".into(), "-c".into(), "echo hi".into()];
let wrapped = wrap_argv(&policy(true, std::slice::from_ref(&dir)), &argv);
assert_eq!(wrapped[0], OsString::from(SANDBOX_EXEC));
assert_eq!(wrapped[1], OsString::from("-p"));
let profile_arg = wrapped[2].to_string_lossy();
assert!(profile_arg.starts_with("(version 1)\n(allow default)\n"));
assert_eq!(wrapped[3], OsString::from("-D"));
let kv = wrapped[4].to_string_lossy();
assert!(kv.starts_with("WR0="), "param assignment: {kv}");
let sep = wrapped
.iter()
.position(|a| a == "--")
.expect("-- separator");
assert_eq!(&wrapped[sep + 1..], argv.as_slice());
let _ = std::fs::remove_dir_all(&dir);
}
}
}
#[cfg(target_os = "linux")]
mod linux {
use std::collections::BTreeMap;
use anyhow::Context;
use seccompiler::{
BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter,
SeccompRule, apply_filter,
};
pub(super) fn network_filter() -> anyhow::Result<BpfProgram> {
let inet = SeccompRule::new(vec![SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Eq,
libc::AF_INET as u64,
)?])?;
let inet6 = SeccompRule::new(vec![SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Eq,
libc::AF_INET6 as u64,
)?])?;
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
rules.insert(libc::SYS_socket, vec![inet, inet6]);
let filter = SeccompFilter::new(
rules,
SeccompAction::Allow, SeccompAction::KillProcess, std::env::consts::ARCH
.try_into()
.context("seccomp: unsupported target arch")?,
)
.context("seccomp: build network filter")?;
let program: BpfProgram = filter.try_into().context("seccomp: assemble network BPF")?;
Ok(program)
}
pub fn apply_network_killswitch() -> anyhow::Result<()> {
let program = network_filter()?;
apply_filter(&program).context("seccomp: install network filter")?;
Ok(())
}
const LANDLOCK_ABI: landlock::ABI = landlock::ABI::V3;
pub(super) fn apply_fs_confinement(
allowed_writes: &[std::path::PathBuf],
) -> anyhow::Result<bool> {
use landlock::{
AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr, RulesetCreatedAttr,
RulesetStatus, path_beneath_rules,
};
let write_access = AccessFs::from_write(LANDLOCK_ABI);
let status = Ruleset::default()
.set_compatibility(CompatLevel::BestEffort)
.handle_access(write_access)
.context("landlock: handle write access")?
.create()
.context("landlock: create ruleset")?
.add_rules(path_beneath_rules(allowed_writes, write_access))
.context("landlock: add write rules")?
.restrict_self()
.context("landlock: restrict self")?;
Ok(status.ruleset != RulesetStatus::NotEnforced)
}
pub(super) fn fs_ruleset_builds() -> bool {
use landlock::{AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr};
Ruleset::default()
.set_compatibility(CompatLevel::BestEffort)
.handle_access(AccessFs::from_write(LANDLOCK_ABI))
.and_then(|r| r.create())
.is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn child_socket_status(bpf: &BpfProgram, domain: libc::c_int) -> libc::c_int {
unsafe {
let pid = libc::fork();
assert!(pid >= 0, "fork failed");
if pid == 0 {
if apply_filter(bpf).is_err() {
libc::_exit(77);
}
let fd = libc::socket(domain, libc::SOCK_STREAM, 0);
if fd >= 0 {
libc::close(fd);
}
libc::_exit(0);
}
let mut status: libc::c_int = 0;
let waited = libc::waitpid(pid, &mut status, 0);
assert_eq!(waited, pid, "waitpid failed");
status
}
}
#[test]
fn inet_socket_is_killed_with_sigsys() {
let bpf = network_filter().expect("build filter");
let status = child_socket_status(&bpf, libc::AF_INET);
assert!(
libc::WIFSIGNALED(status),
"AF_INET socket should be signal-killed, status={status}"
);
assert_eq!(
libc::WTERMSIG(status),
libc::SIGSYS,
"AF_INET socket should die with SIGSYS"
);
}
#[test]
fn unix_socket_is_allowed() {
let bpf = network_filter().expect("build filter");
let status = child_socket_status(&bpf, libc::AF_UNIX);
assert!(
libc::WIFEXITED(status),
"AF_UNIX socket should exit cleanly, status={status}"
);
assert_eq!(
libc::WEXITSTATUS(status),
0,
"AF_UNIX socket must be allowed under the network kill-switch"
);
}
#[test]
fn fs_confinement_allows_inside_and_denies_outside_writes() {
let base = std::env::temp_dir().join(format!(
"mermaid-landlock-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let allowed = base.join("allowed");
let outside = base.join("outside");
std::fs::create_dir_all(&allowed).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let status = unsafe {
let pid = libc::fork();
assert!(pid >= 0, "fork failed");
if pid == 0 {
let code = match apply_fs_confinement(std::slice::from_ref(&allowed)) {
Err(_) => 77,
Ok(false) => 42,
Ok(true) => {
let inside_ok = std::fs::write(allowed.join("in.txt"), b"x").is_ok();
let outside_ok = std::fs::write(outside.join("out.txt"), b"x").is_ok();
match (inside_ok, outside_ok) {
(true, false) => 0,
(false, _) => 10,
(true, true) => 11,
}
},
};
libc::_exit(code);
}
let mut status: libc::c_int = 0;
assert_eq!(libc::waitpid(pid, &mut status, 0), pid, "waitpid failed");
status
};
let _ = std::fs::remove_dir_all(&base);
assert!(
libc::WIFEXITED(status),
"child should exit, status={status}"
);
let code = libc::WEXITSTATUS(status);
if code == 42 {
eprintln!("skipping: kernel does not enforce Landlock");
return;
}
assert_eq!(
code, 0,
"confined child: 10 = allowed write failed, 11 = outside write \
succeeded, 77 = apply failed"
);
}
}
}