use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
macro_rules! args {
($v:expr, $($s:expr),+ $(,)?) => {{ $( $v.push($s.to_string()); )+ }};
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Backend {
#[default]
None,
Bwrap,
Docker,
}
impl Backend {
pub fn as_str(self) -> &'static str {
match self {
Backend::None => "none",
Backend::Bwrap => "bwrap",
Backend::Docker => "docker",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SandboxConfig {
pub kind: Backend,
pub network: bool,
pub writable: Vec<PathBuf>,
pub readable: Vec<PathBuf>,
pub env: Vec<String>,
pub image: String,
pub memory_mb: Option<u64>,
pub cpus: Option<f64>,
}
impl Default for SandboxConfig {
fn default() -> Self {
SandboxConfig {
kind: Backend::None,
network: false,
writable: Vec::new(),
readable: Vec::new(),
env: Vec::new(),
image: "debian:stable-slim".into(),
memory_mb: None,
cpus: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Sandbox {
cfg: SandboxConfig,
}
impl Sandbox {
pub fn new(cfg: SandboxConfig) -> Self {
Sandbox { cfg }
}
pub fn backend(&self) -> Backend {
self.cfg.kind
}
pub fn with_network(&self, network: bool) -> Self {
Sandbox {
cfg: SandboxConfig {
network,
..self.cfg.clone()
},
}
}
pub fn is_enabled(&self) -> bool {
self.cfg.kind != Backend::None
}
pub fn can_reach_network(&self) -> bool {
!self.is_enabled() || self.cfg.network
}
pub fn reaches_beyond_workspace(&self) -> bool {
!self.is_enabled() || !self.cfg.writable.is_empty() || !self.cfg.readable.is_empty()
}
pub fn command(
&self,
command: &str,
workspace: &Path,
cwd: &Path,
) -> Result<tokio::process::Command> {
match self.cfg.kind {
Backend::None => {
let mut c = tokio::process::Command::new("bash");
c.arg("-lc").arg(command).current_dir(cwd);
Ok(c)
}
_ => self.wrap_argv("bash", &["-lc".into(), command.into()], workspace, cwd),
}
}
pub fn wrap_argv(
&self,
program: &str,
args: &[String],
workspace: &Path,
cwd: &Path,
) -> Result<tokio::process::Command> {
match self.cfg.kind {
Backend::None => {
let mut c = tokio::process::Command::new(program);
c.args(args).current_dir(cwd);
Ok(c)
}
Backend::Bwrap => {
let mut c = tokio::process::Command::new("bwrap");
c.args(self.bwrap_args(workspace, cwd)?);
c.arg("--").arg(program).args(args);
Ok(c)
}
Backend::Docker => {
let mut c = tokio::process::Command::new("docker");
c.args(self.docker_args(workspace, cwd)?);
c.arg(program).args(args);
Ok(c)
}
}
}
pub fn child_env(passthrough: &[String]) -> Vec<(String, String)> {
const BASE: [&str; 5] = ["PATH", "HOME", "LANG", "LC_ALL", "TZ"];
BASE.iter()
.map(|s| s.to_string())
.chain(passthrough.iter().cloned())
.filter_map(|name| std::env::var(&name).ok().map(|v| (name, v)))
.collect()
}
pub fn bwrap_args(&self, workspace: &Path, cwd: &Path) -> Result<Vec<String>> {
let mut a: Vec<String> = Vec::new();
args!(
a,
"--die-with-parent",
"--new-session",
"--unshare-user",
"--unshare-pid",
"--unshare-ipc",
"--unshare-uts",
"--unshare-cgroup-try",
);
if !self.cfg.network {
args!(a, "--unshare-net");
}
for dir in ["/usr", "/etc", "/opt"] {
if Path::new(dir).is_dir() {
args!(a, "--ro-bind-try", dir, dir);
}
}
for dir in ["/bin", "/sbin", "/lib", "/lib32", "/lib64"] {
match std::fs::symlink_metadata(dir) {
Ok(meta) if meta.file_type().is_symlink() => {
let target = std::fs::read_link(dir)?;
args!(a, "--symlink", target.to_string_lossy(), dir);
}
Ok(_) => args!(a, "--ro-bind-try", dir, dir),
Err(_) => {}
}
}
args!(a, "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp");
for path in &self.cfg.readable {
args!(a, "--ro-bind-try", path.display(), path.display());
}
let workspace = absolute(workspace)?;
args!(a, "--bind", workspace.display(), workspace.display());
for path in &self.cfg.writable {
args!(a, "--bind-try", path.display(), path.display());
}
args!(a, "--chdir", absolute(cwd)?.display());
args!(
a,
"--clearenv",
"--setenv",
"PATH",
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"--setenv",
"HOME",
workspace.display(),
);
for name in &self.cfg.env {
if let Ok(value) = std::env::var(name) {
args!(a, "--setenv", name, value);
}
}
Ok(a)
}
pub fn docker_args(&self, workspace: &Path, cwd: &Path) -> Result<Vec<String>> {
let workspace = absolute(workspace)?;
let mut a: Vec<String> = Vec::new();
args!(a, "run", "--rm", "-i");
args!(
a,
"--network",
if self.cfg.network { "bridge" } else { "none" }
);
#[cfg(unix)]
{
let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
args!(a, "--user", format!("{uid}:{gid}"));
}
args!(
a,
"--security-opt",
"no-new-privileges",
"--cap-drop",
"ALL"
);
if let Some(mb) = self.cfg.memory_mb {
args!(a, "--memory", format!("{mb}m"));
}
if let Some(cpus) = self.cfg.cpus {
args!(a, "--cpus", cpus);
}
for path in &self.cfg.readable {
args!(a, "-v", format!("{}:{}:ro", path.display(), path.display()));
}
args!(
a,
"-v",
format!("{}:{}", workspace.display(), workspace.display())
);
for path in &self.cfg.writable {
args!(a, "-v", format!("{}:{}", path.display(), path.display()));
}
args!(a, "-w", absolute(cwd)?.display());
for name in &self.cfg.env {
if let Ok(value) = std::env::var(name) {
args!(a, "-e", format!("{name}={value}"));
}
}
args!(a, self.cfg.image);
Ok(a)
}
pub async fn preflight(&self, workspace: &Path) -> Result<()> {
if !self.is_enabled() {
return Ok(());
}
let marker = "mecha-sandbox-ok";
let mut command = self
.command(&format!("echo {marker}"), workspace, workspace)
.context("building the sandbox command")?;
let output = tokio::time::timeout(
std::time::Duration::from_secs(60),
command.stdin(std::process::Stdio::null()).output(),
)
.await
.map_err(|_| anyhow::anyhow!("the {} sandbox timed out starting", self.cfg.kind.as_str()))?
.with_context(|| {
format!(
"cannot run `{}` — is it installed?",
match self.cfg.kind {
Backend::Docker => "docker",
_ => "bwrap",
}
)
})?;
if output.status.success() && String::from_utf8_lossy(&output.stdout).contains(marker) {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
anyhow::bail!(
"the {} sandbox does not work here: {}{}",
self.cfg.kind.as_str(),
if stderr.is_empty() {
"no output".into()
} else {
stderr.clone()
},
diagnose(self.cfg.kind, &stderr)
)
}
}
fn diagnose(kind: Backend, stderr: &str) -> String {
match kind {
Backend::Bwrap if stderr.contains("uid map") || stderr.contains("user namespace") => {
"\n\nUnprivileged user namespaces are blocked. On Ubuntu 23.10+ this is \
usually AppArmor rather than the kernel:\n \
sysctl kernel.apparmor_restrict_unprivileged_userns # 1 means blocked\n\
Either install an AppArmor profile for bwrap, or set \
`kernel.apparmor_restrict_unprivileged_userns=0` (system-wide, weaker), \
or use `kind = \"docker\"` instead."
.into()
}
Backend::Bwrap if stderr.contains("loopback") => {
"\n\nbwrap could not configure loopback in the new network namespace. \
Set `network = true` to share the host's, or use `kind = \"docker\"`."
.into()
}
Backend::Docker if stderr.contains("permission denied") => {
"\n\nThe docker socket is not accessible. Add yourself to the `docker` \
group, or use `kind = \"bwrap\"`."
.into()
}
Backend::Docker => {
"\n\nCheck the image exists (`docker pull <image>`) and the daemon is running.".into()
}
_ => String::new(),
}
}
fn absolute(path: &Path) -> Result<PathBuf> {
path.canonicalize()
.with_context(|| format!("cannot resolve {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(kind: Backend) -> SandboxConfig {
SandboxConfig {
kind,
..SandboxConfig::default()
}
}
#[test]
fn a_disabled_sandbox_runs_bash_directly() {
let sandbox = Sandbox::new(cfg(Backend::None));
assert!(!sandbox.is_enabled());
assert!(sandbox.can_reach_network());
assert!(sandbox.reaches_beyond_workspace());
}
#[test]
fn confinement_without_network_closes_the_exfiltration_route() {
let sandbox = Sandbox::new(cfg(Backend::Bwrap));
assert!(!sandbox.can_reach_network());
assert!(!sandbox.reaches_beyond_workspace());
let sandbox = Sandbox::new(SandboxConfig {
network: true,
..cfg(Backend::Bwrap)
});
assert!(sandbox.can_reach_network());
}
#[test]
fn a_bind_outside_the_workspace_is_still_reach_beyond_it() {
let sandbox = Sandbox::new(SandboxConfig {
readable: vec![PathBuf::from("/opt/toolchain")],
..cfg(Backend::Bwrap)
});
assert!(
sandbox.reaches_beyond_workspace(),
"an extra bind is exactly how private data gets back in reach"
);
}
#[test]
fn bwrap_confines_the_environment_and_the_network() {
let workspace = std::env::temp_dir();
let args = Sandbox::new(cfg(Backend::Bwrap))
.bwrap_args(&workspace, &workspace)
.unwrap();
assert!(
args.contains(&"--unshare-net".into()),
"no network by default"
);
assert!(
args.contains(&"--clearenv".into()),
"the parent env must not leak"
);
assert!(args.contains(&"--unshare-user".into()));
assert!(args.contains(&"--die-with-parent".into()));
assert!(args.contains(&"--new-session".into()));
let workspace = workspace.canonicalize().unwrap();
let binds: Vec<_> = args
.iter()
.enumerate()
.filter(|(_, a)| *a == "--bind")
.map(|(i, _)| args[i + 1].clone())
.collect();
assert_eq!(binds, vec![workspace.display().to_string()]);
}
#[test]
fn network_is_shared_only_when_asked_for() {
let workspace = std::env::temp_dir();
let args = Sandbox::new(SandboxConfig {
network: true,
..cfg(Backend::Bwrap)
})
.bwrap_args(&workspace, &workspace)
.unwrap();
assert!(!args.contains(&"--unshare-net".into()));
}
#[test]
fn docker_drops_privileges_and_the_network() {
let workspace = std::env::temp_dir();
let args = Sandbox::new(cfg(Backend::Docker))
.docker_args(&workspace, &workspace)
.unwrap();
assert_eq!(args[0], "run");
assert!(
args.contains(&"--rm".into()),
"containers must not accumulate"
);
assert!(args
.windows(2)
.any(|w| w[0] == "--network" && w[1] == "none"));
assert!(args
.windows(2)
.any(|w| w[0] == "--cap-drop" && w[1] == "ALL"));
assert!(args
.windows(2)
.any(|w| w[0] == "--security-opt" && w[1] == "no-new-privileges"));
assert!(args.iter().any(|a| a == "--user"));
}
#[test]
fn a_child_inherits_only_the_base_and_what_was_named() {
std::env::set_var("MECHA_TEST_TOKEN", "sk-should-not-cross");
std::env::set_var("MECHA_TEST_WANTED", "fine");
let names: Vec<String> = Sandbox::child_env(&["MECHA_TEST_WANTED".into()])
.into_iter()
.map(|(k, _)| k)
.collect();
assert!(names.contains(&"MECHA_TEST_WANTED".to_string()));
assert!(
!names.contains(&"MECHA_TEST_TOKEN".to_string()),
"an unnamed variable must not reach a third-party process"
);
assert!(names.contains(&"PATH".to_string()));
assert!(names.contains(&"HOME".to_string()));
}
#[test]
fn wrapping_an_argv_does_not_route_through_a_shell() {
let workspace = std::env::temp_dir();
let sandbox = Sandbox::new(cfg(Backend::Bwrap));
let command = sandbox
.wrap_argv(
"node",
&["server.js".into(), "--flag with space".into()],
&workspace,
&workspace,
)
.unwrap();
let argv: Vec<_> = command
.as_std()
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
!argv.iter().any(|a| a == "-lc"),
"no shell should be involved"
);
assert!(
argv.contains(&"--flag with space".to_string()),
"args stay one argv entry"
);
}
#[test]
fn only_named_environment_variables_cross_the_boundary() {
std::env::set_var("MECHA_TEST_ALLOWED", "yes");
std::env::set_var("MECHA_TEST_SECRET", "no");
let workspace = std::env::temp_dir();
let sandbox = Sandbox::new(SandboxConfig {
env: vec!["MECHA_TEST_ALLOWED".into()],
..cfg(Backend::Bwrap)
});
let args = sandbox.bwrap_args(&workspace, &workspace).unwrap();
assert!(args.contains(&"MECHA_TEST_ALLOWED".into()));
assert!(
!args.iter().any(|a| a == "MECHA_TEST_SECRET" || a == "no"),
"an unlisted variable must not cross"
);
}
}