#![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);
if !policy.fs.allow_git_config {
let root = project_root.to_string_lossy();
apply_git_config_deny(&mut cmd, &root);
}
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);
if !policy.fs.allow_git_config {
let root = project_root.to_string_lossy();
apply_git_config_deny(&mut cmd, &root);
}
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_git_config_deny(cmd: &mut Command, root: &str) {
let git_dir = format!("{root}/.git");
let hooks = format!("{git_dir}/hooks");
let config = format!("{git_dir}/config");
if let Ok(meta) = std::fs::symlink_metadata(&git_dir)
&& meta.is_file()
{
tracing::warn!(
target: "koda::sandbox",
git_dir = %git_dir,
"skipping apply_git_config_deny: .git is a file (worktree/submodule); \
rely on seatbelt-style policy or set fs.allow_git_config=true"
);
return;
}
let _ = std::fs::create_dir_all(&hooks);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&config)
{
Ok(_) | Err(_) => {} }
cmd.args(["--ro-bind", &hooks, &hooks]);
cmd.args(["--ro-bind", &config, &config]);
}
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> {
let override_path = std::env::var(STAGE2_BIN_ENV_KEY)
.ok()
.or_else(|| std::env::var("CARGO_BIN_EXE_koda-sandbox-stage2").ok());
stage2_binary_from(override_path.as_deref().map(std::path::Path::new))
}
pub fn stage2_binary_from(env_override: Option<&std::path::Path>) -> Result<PathBuf> {
if let Some(p) = env_override {
return Ok(p.to_path_buf());
}
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 p = stage2_binary_from(Some(std::path::Path::new("/tmp/fake-stage2"))).unwrap();
assert_eq!(p, std::path::PathBuf::from("/tmp/fake-stage2"));
}
#[test]
fn git_config_deny_pre_creates_for_non_git_dir_to_close_toctou() {
let dir = tempfile::tempdir().unwrap();
assert!(
!dir.path().join(".git").exists(),
"precondition: tempdir should not be a git repo"
);
let mut cmd = Command::new("true");
let root = dir.path().to_string_lossy();
apply_git_config_deny(&mut cmd, &root);
assert!(
dir.path().join(".git/hooks").is_dir(),
"hooks dir must be pre-created to give the bind-mount a source"
);
assert!(
dir.path().join(".git/config").is_file(),
"config file must be pre-created (empty) to close SEC-002"
);
assert_eq!(
std::fs::read(dir.path().join(".git/config")).unwrap().len(),
0,
"pre-created config must be empty (a valid git config = use defaults)"
);
let args: Vec<String> = cmd
.as_std()
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
args.windows(3)
.any(|w| { w[0] == "--ro-bind" && w[1].ends_with("/.git/hooks") }),
"hooks ro-bind must be present even on a fresh dir: {args:?}"
);
assert!(
args.windows(3)
.any(|w| { w[0] == "--ro-bind" && w[1].ends_with("/.git/config") }),
"config ro-bind must be present even on a fresh dir: {args:?}"
);
}
#[test]
fn git_config_deny_adds_ro_bind_for_existing_git_dir() {
let dir = tempfile::tempdir().unwrap();
let git = dir.path().join(".git");
std::fs::create_dir_all(git.join("hooks")).unwrap();
std::fs::write(git.join("config"), b"[core]\n").unwrap();
let mut cmd = Command::new("true");
let root = dir.path().to_string_lossy();
apply_git_config_deny(&mut cmd, &root);
assert_eq!(
std::fs::read(dir.path().join(".git/config")).unwrap(),
b"[core]\n",
"pre-existing config contents must be preserved"
);
let args: Vec<String> = cmd
.as_std()
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
args.windows(3)
.any(|w| { w[0] == "--ro-bind" && w[1].ends_with("/.git/hooks") }),
"hooks directory must be ro-bound: {args:?}"
);
assert!(
args.windows(3)
.any(|w| { w[0] == "--ro-bind" && w[1].ends_with("/.git/config") }),
"git config file must be ro-bound when it exists: {args:?}"
);
}
#[test]
fn build_command_skips_git_deny_when_allowed() {
if !is_available() {
eprintln!("bwrap not available; skipping");
return;
}
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".git/hooks")).unwrap();
let mut policy = SandboxPolicy::strict_default();
policy.fs.allow_git_config = true;
let cmd = build_command("true", dir.path(), &policy).unwrap();
let args: Vec<String> = cmd
.as_std()
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let has_hooks_ro_bind = args
.windows(3)
.any(|w| w[0] == "--ro-bind" && w[1].ends_with("/.git/hooks"));
assert!(
!has_hooks_ro_bind,
"allow_git_config=true must not add ro-bind for .git/hooks"
);
}
#[test]
fn git_config_deny_skips_worktree_gitdir_pointer() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".git"),
b"gitdir: /some/main/.git/worktrees/my-wt\n",
)
.unwrap();
let mut cmd = Command::new("true");
let root = dir.path().to_string_lossy();
apply_git_config_deny(&mut cmd, &root);
assert!(
std::fs::symlink_metadata(dir.path().join(".git"))
.unwrap()
.is_file(),
".git file must remain unchanged"
);
let args: Vec<String> = cmd
.as_std()
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
args.is_empty(),
"worktree case must add zero bind args (got: {args:?})"
);
}
#[test]
fn git_config_deny_preserves_existing_config_mtime() {
let dir = tempfile::tempdir().unwrap();
let git = dir.path().join(".git");
std::fs::create_dir_all(git.join("hooks")).unwrap();
let config = git.join("config");
std::fs::write(&config, b"[core]\nexisting=yes\n").unwrap();
let before = std::fs::metadata(&config).unwrap().modified().unwrap();
let mut cmd = Command::new("true");
let root = dir.path().to_string_lossy();
apply_git_config_deny(&mut cmd, &root);
let after = std::fs::metadata(&config).unwrap().modified().unwrap();
assert_eq!(
before, after,
"existing git config mtime must not be touched (TOCTOU fix invariant)"
);
assert_eq!(
std::fs::read(&config).unwrap(),
b"[core]\nexisting=yes\n",
"contents must be unchanged"
);
}
}