#![cfg(target_os = "linux")]
use crate::defaults::{CREDENTIAL_CONFIG_FULL_DENY, PROTECTED_PROJECT_SUBDIRS};
use crate::policy::SandboxPolicy;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use tokio::process::Command;
pub const STAGE2_BIN_ENV_KEY: &str = "KODA_SANDBOX_STAGE2_BIN";
const STAGE2_PROXY_REWRITE_KEYS: &str =
"HTTPS_PROXY,HTTP_PROXY,ALL_PROXY,https_proxy,http_proxy,all_proxy";
pub fn build_command(
command: &str,
project_root: &Path,
policy: &SandboxPolicy,
) -> Result<Command> {
if !is_available() {
anyhow::bail!(
"Sandbox requested but bwrap is not installed. \
Install with: apt install bubblewrap / dnf install bubblewrap"
);
}
let (mut cmd, home) = base_cmd(project_root);
apply_credential_denies(&mut cmd, &home);
apply_policy_overlay(&mut cmd, policy)?;
cmd.args(["--", "sh", "-c", command])
.current_dir(project_root);
Ok(cmd)
}
pub fn build_command_with_proxy(
command: &str,
project_root: &Path,
policy: &SandboxPolicy,
proxy_port: u16,
uds_path: &Path,
stage2_bin: &Path,
) -> Result<Command> {
if !is_available() {
anyhow::bail!(
"Sandbox requested but bwrap is not installed. \
Install with: apt install bubblewrap / dnf install bubblewrap"
);
}
let (mut cmd, home) = base_cmd(project_root);
apply_credential_denies(&mut cmd, &home);
apply_policy_overlay(&mut cmd, policy)?;
cmd.args(["--unshare-net", "--unshare-user", "--unshare-pid"]);
let uds_str = uds_path.to_string_lossy().into_owned();
cmd.args(["--bind", &uds_str, &uds_str]);
cmd.env(crate::bwrap_proxy::STAGE2_UDS_ENV_KEY, &uds_str);
cmd.env(
crate::bwrap_proxy::STAGE2_REWRITE_KEYS_ENV_KEY,
STAGE2_PROXY_REWRITE_KEYS,
);
cmd.env("KODA_SANDBOX_PROXY_PORT_DEBUG", proxy_port.to_string());
let stage2_str = stage2_bin.to_string_lossy().into_owned();
cmd.args(["--", &stage2_str, "sh", "-c", command])
.current_dir(project_root);
Ok(cmd)
}
fn apply_credential_denies(cmd: &mut Command, home: &str) {
for rel in CREDENTIAL_CONFIG_FULL_DENY {
let p = format!("{home}/.config/{rel}");
if Path::new(&p).exists() {
cmd.args(["--tmpfs", &p]);
}
}
}
fn apply_policy_overlay(cmd: &mut Command, policy: &SandboxPolicy) -> Result<()> {
for arg_pair in policy_overlay_args(policy)? {
cmd.args(&arg_pair);
}
Ok(())
}
pub fn stage2_binary() -> Result<PathBuf> {
if let Ok(p) = std::env::var(STAGE2_BIN_ENV_KEY) {
return Ok(PathBuf::from(p));
}
if let Ok(p) = std::env::var("CARGO_BIN_EXE_koda-sandbox-stage2") {
return Ok(PathBuf::from(p));
}
let exe = std::env::current_exe().context("locate koda executable")?;
let mut sibling = exe.clone();
sibling.set_file_name("koda-sandbox-stage2");
if sibling.exists() {
return Ok(sibling);
}
if let Some(parent) = exe.parent().and_then(|p| p.parent()) {
let cargo_built = parent.join("koda-sandbox-stage2");
if cargo_built.exists() {
return Ok(cargo_built);
}
}
anyhow::bail!(
"koda-sandbox-stage2 not found next to {}; set {STAGE2_BIN_ENV_KEY} to override",
sibling.display()
)
}
pub fn is_available() -> bool {
use std::sync::OnceLock;
static AVAILABLE: OnceLock<bool> = OnceLock::new();
*AVAILABLE.get_or_init(|| {
std::process::Command::new("bwrap")
.args(["--ro-bind", "/", "/", "--", "true"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
})
}
fn base_cmd(project_root: &Path) -> (Command, String) {
let root = project_root.to_string_lossy().into_owned();
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
let mut cmd = Command::new("bwrap");
cmd.args(["--ro-bind", "/", "/"]);
cmd.args(["--bind", &root, &root]);
cmd.args(["--bind", "/tmp", "/tmp"]);
if Path::new("/var/tmp").exists() {
cmd.args(["--bind", "/var/tmp", "/var/tmp"]);
}
for subdir in &[".cargo", ".npm", ".cache"] {
let p = format!("{home}/{subdir}");
if Path::new(&p).exists() {
cmd.args(["--bind", p.as_str(), p.as_str()]);
}
}
cmd.args(["--dev", "/dev"]).args(["--proc", "/proc"]);
for rel in PROTECTED_PROJECT_SUBDIRS {
let p = format!("{root}/{rel}");
let _ = std::fs::create_dir_all(&p);
cmd.args(["--ro-bind", &p, &p]);
}
(cmd, home)
}
pub(crate) fn policy_overlay_args(policy: &SandboxPolicy) -> Result<Vec<Vec<String>>> {
let fs = &policy.fs;
if fs.deny_read.is_empty()
&& fs.allow_read_within_deny.is_empty()
&& fs.allow_write.is_empty()
&& fs.deny_write_within_allow.is_empty()
{
return Ok(Vec::new());
}
let mut out = Vec::new();
for p in &fs.deny_read {
let s = p.as_path().to_string_lossy().into_owned();
out.push(vec!["--tmpfs".to_string(), s]);
}
for p in &fs.allow_read_within_deny {
let s = p.as_path().to_string_lossy().into_owned();
out.push(vec!["--ro-bind".to_string(), s.clone(), s]);
}
for p in &fs.allow_write {
let s = p.as_path().to_string_lossy().into_owned();
out.push(vec!["--bind".to_string(), s.clone(), s]);
}
for p in &fs.deny_write_within_allow {
let s = p.as_path().to_string_lossy().into_owned();
out.push(vec!["--ro-bind".to_string(), s.clone(), s]);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn policy_overlay_args_empty_for_default_policy() {
let args = policy_overlay_args(&SandboxPolicy::strict_default()).unwrap();
assert!(args.is_empty(), "default policy must add zero args");
}
#[test]
fn policy_overlay_args_render_deny_read_as_tmpfs() {
let mut policy = SandboxPolicy::strict_default();
policy.fs.deny_read = vec!["/secrets".into()];
let args = policy_overlay_args(&policy).unwrap();
assert_eq!(args.len(), 1);
assert_eq!(args[0], vec!["--tmpfs", "/secrets"]);
}
#[test]
fn policy_overlay_args_render_allow_write_as_bind() {
let mut policy = SandboxPolicy::strict_default();
policy.fs.allow_write = vec!["/work".into()];
let args = policy_overlay_args(&policy).unwrap();
assert_eq!(args.len(), 1);
assert_eq!(args[0], vec!["--bind", "/work", "/work"]);
}
#[test]
fn policy_overlay_args_render_deny_write_within_as_ro_bind() {
let mut policy = SandboxPolicy::strict_default();
policy.fs.deny_write_within_allow = vec!["/work/.git/config".into()];
let args = policy_overlay_args(&policy).unwrap();
assert_eq!(args.len(), 1);
assert_eq!(
args[0],
vec!["--ro-bind", "/work/.git/config", "/work/.git/config"]
);
}
#[test]
fn policy_overlay_args_emit_layers_in_correct_order() {
let mut policy = SandboxPolicy::strict_default();
policy.fs.deny_read = vec!["/secrets".into()];
policy.fs.allow_read_within_deny = vec!["/secrets/public".into()];
policy.fs.allow_write = vec!["/work".into()];
policy.fs.deny_write_within_allow = vec!["/work/.git".into()];
let args = policy_overlay_args(&policy).unwrap();
assert_eq!(args.len(), 4);
assert_eq!(args[0][0], "--tmpfs"); assert_eq!(args[1][0], "--ro-bind"); assert_eq!(args[2][0], "--bind"); assert_eq!(args[3][0], "--ro-bind"); }
#[test]
fn build_command_with_proxy_adds_unshare_and_stage2() {
if !is_available() {
eprintln!("bwrap not available; skipping");
return;
}
let dir = tempfile::tempdir().unwrap();
let uds = dir.path().join("bridge.sock");
std::fs::write(&uds, b"placeholder").unwrap();
let stage2 = dir.path().join("koda-sandbox-stage2");
std::fs::write(&stage2, b"#!/bin/sh\n").unwrap();
let cmd = build_command_with_proxy(
"echo hi",
dir.path(),
&SandboxPolicy::strict_default(),
12345,
&uds,
&stage2,
)
.unwrap();
let args: Vec<String> = cmd
.as_std()
.get_args()
.map(|a| a.to_string_lossy().to_string())
.collect();
assert!(
args.iter().any(|a| a == "--unshare-net"),
"--unshare-net missing"
);
assert!(
args.iter().any(|a| a == "--unshare-user"),
"--unshare-user missing"
);
assert!(
args.iter().any(|a| a == "--unshare-pid"),
"--unshare-pid missing"
);
let uds_str = uds.to_string_lossy().to_string();
let bind_idx = args
.windows(3)
.position(|w| w[0] == "--bind" && w[1] == uds_str && w[2] == uds_str)
.expect("--bind <uds> <uds> missing");
let unshare_idx = args.iter().position(|a| a == "--unshare-net").unwrap();
assert!(
bind_idx > unshare_idx,
"--bind UDS must follow --unshare-net (got bind@{bind_idx} unshare@{unshare_idx})"
);
let dash_dash = args.iter().rposition(|a| a == "--").expect("-- missing");
assert_eq!(args[dash_dash + 1], stage2.to_string_lossy());
assert_eq!(args[dash_dash + 2], "sh");
assert_eq!(args[dash_dash + 3], "-c");
assert_eq!(args[dash_dash + 4], "echo hi");
}
#[test]
fn build_command_with_proxy_sets_stage2_env_vars() {
if !is_available() {
eprintln!("bwrap not available; skipping");
return;
}
let dir = tempfile::tempdir().unwrap();
let uds = dir.path().join("bridge.sock");
std::fs::write(&uds, b"placeholder").unwrap();
let stage2 = dir.path().join("koda-sandbox-stage2");
std::fs::write(&stage2, b"#!/bin/sh\n").unwrap();
let cmd = build_command_with_proxy(
"true",
dir.path(),
&SandboxPolicy::strict_default(),
54321,
&uds,
&stage2,
)
.unwrap();
let envs: std::collections::HashMap<String, String> = cmd
.as_std()
.get_envs()
.filter_map(|(k, v)| {
Some((
k.to_string_lossy().to_string(),
v?.to_string_lossy().to_string(),
))
})
.collect();
assert_eq!(
envs.get(crate::bwrap_proxy::STAGE2_UDS_ENV_KEY),
Some(&uds.to_string_lossy().to_string()),
"stage 2 must learn the UDS path via env"
);
let rewrite = envs
.get(crate::bwrap_proxy::STAGE2_REWRITE_KEYS_ENV_KEY)
.expect("stage 2 rewrite-keys env missing");
for key in [
"HTTPS_PROXY",
"HTTP_PROXY",
"ALL_PROXY",
"https_proxy",
"http_proxy",
"all_proxy",
] {
assert!(
rewrite.split(',').any(|k| k == key),
"rewrite key {key} missing from {rewrite}"
);
}
}
#[test]
fn stage2_binary_respects_env_override() {
let original = std::env::var(STAGE2_BIN_ENV_KEY).ok();
unsafe { std::env::set_var(STAGE2_BIN_ENV_KEY, "/tmp/fake-stage2") };
let p = stage2_binary().unwrap();
assert_eq!(p, std::path::PathBuf::from("/tmp/fake-stage2"));
unsafe {
match original {
Some(v) => std::env::set_var(STAGE2_BIN_ENV_KEY, v),
None => std::env::remove_var(STAGE2_BIN_ENV_KEY),
}
}
}
}