use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use tracing::debug;
const DENY_READ_DIRS: &[&str] = &[
".ssh",
".aws",
".gnupg",
".kube",
".azure",
".config/gcloud",
".config/workmux",
".docker",
".claude", ".gemini", ".codex", ".local/share/opencode", ".config/gh", ".config/op", ".config/sops", ".password-store", ];
const DENY_READ_FILES: &[&str] = &[
".npmrc", ".pypirc", ".netrc", ".gem/credentials", ".claude-sandbox.json", ".bash_history", ".zsh_history", ".vault-token", ];
#[cfg(target_os = "macos")]
const DENY_READ_PATHS_MACOS: &[&str] = &[
"Library/Keychains",
"Library/Cookies",
"Library/Application Support/Google/Chrome",
"Library/Application Support/Firefox",
"Library/Application Support/Arc", ".zsh_sessions", ];
const ALLOW_WRITE_DIRS: &[&str] = &[
".cache",
".cargo",
".rustup",
".npm",
".local/state",
".local/share/devbox",
];
#[cfg(target_os = "macos")]
const ALLOW_WRITE_DIRS_MACOS: &[&str] = &["Library/Caches", "Library/Logs"];
fn extra_xdg_write_dirs(home: &Path) -> Vec<PathBuf> {
let mut dirs = Vec::new();
for (var, default_suffix) in [
("XDG_CACHE_HOME", ".cache"),
("XDG_STATE_HOME", ".local/state"),
] {
if let Ok(val) = std::env::var(var) {
let path = PathBuf::from(&val);
if path.is_absolute() && path.starts_with(home) && path != home.join(default_suffix) {
dirs.push(path);
}
}
}
dirs
}
fn extra_xdg_deny_dirs(home: &Path) -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Ok(val) = std::env::var("XDG_CONFIG_HOME") {
let path = PathBuf::from(&val);
if path.is_absolute() && path.starts_with(home) && path != home.join(".config") {
dirs.push(path.join("workmux"));
}
}
dirs
}
pub fn spawn_sandboxed(
program: &str,
args: &[String],
worktree: &Path,
envs: &HashMap<String, String>,
allow_unsandboxed: bool,
) -> Result<Child> {
if allow_unsandboxed {
tracing::warn!(
"dangerously_allow_unsandboxed_host_exec is set, skipping filesystem sandbox"
);
return spawn_unsandboxed(program, args, worktree, envs);
}
#[cfg(target_os = "macos")]
{
spawn_macos(program, args, worktree, envs)
}
#[cfg(target_os = "linux")]
{
spawn_linux(program, args, worktree, envs)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
tracing::warn!("host-exec sandboxing not supported on this OS, running unsandboxed");
spawn_unsandboxed(program, args, worktree, envs)
}
}
fn spawn_unsandboxed(
program: &str,
args: &[String],
worktree: &Path,
envs: &HashMap<String, String>,
) -> Result<Child> {
let mut cmd = Command::new(program);
cmd.args(args);
cmd.current_dir(worktree);
cmd.env_clear();
cmd.envs(envs);
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd.spawn().context("Failed to spawn command")
}
#[cfg(target_os = "macos")]
fn spawn_macos(
program: &str,
args: &[String],
worktree: &Path,
envs: &HashMap<String, String>,
) -> Result<Child> {
let home = std::env::var("HOME").unwrap_or_else(|_| "/var/empty".to_string());
let worktree_str = worktree
.to_str()
.context("Worktree path is not valid UTF-8")?;
let home_path = Path::new(&home);
let extra_write = extra_xdg_write_dirs(home_path);
let extra_deny = extra_xdg_deny_dirs(home_path);
let profile = generate_macos_profile(&extra_write, &extra_deny);
let mut cmd = Command::new("/usr/bin/sandbox-exec");
cmd.arg("-p").arg(&profile);
cmd.arg("-D").arg(format!("HOME_DIR={}", home));
cmd.arg("-D").arg(format!("WORKTREE={}", worktree_str));
cmd.arg(program);
cmd.args(args);
cmd.current_dir(worktree);
cmd.env_clear();
cmd.envs(envs);
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
debug!(program, "spawning under sandbox-exec");
cmd.spawn().context("Failed to spawn sandbox-exec")
}
#[cfg(target_os = "macos")]
fn generate_macos_profile(extra_write_dirs: &[PathBuf], extra_deny_dirs: &[PathBuf]) -> String {
let mut profile = String::from("(version 1)\n(allow default)\n\n");
profile.push_str("; Deny reading credentials and secrets\n");
profile.push_str("(deny file-read* (with no-report)\n");
for dir in DENY_READ_DIRS {
profile.push_str(&format!(
" (subpath (string-append (param \"HOME_DIR\") \"/{}\" ))\n",
dir
));
}
for file in DENY_READ_FILES {
profile.push_str(&format!(
" (subpath (string-append (param \"HOME_DIR\") \"/{}\" ))\n",
file
));
}
for dir in DENY_READ_PATHS_MACOS {
profile.push_str(&format!(
" (subpath (string-append (param \"HOME_DIR\") \"/{}\" ))\n",
dir
));
}
for dir in extra_deny_dirs {
if let Some(s) = dir.to_str() {
profile.push_str(&format!(" (subpath \"{}\")\n", s));
}
}
profile.push_str(")\n\n");
profile.push_str("; Deny writing to HOME except allowed caches\n");
profile.push_str("(deny file-write* (with no-report)\n");
profile.push_str(" (subpath (param \"HOME_DIR\"))\n");
profile.push_str(")\n\n");
profile.push_str("; Allow full access to worktree\n");
profile.push_str("(allow file-read* file-write*\n");
profile.push_str(" (subpath (param \"WORKTREE\"))\n");
profile.push_str(")\n\n");
profile.push_str("; Allow writing to caches and toolchains\n");
profile.push_str("(allow file-write*\n");
for dir in ALLOW_WRITE_DIRS {
profile.push_str(&format!(
" (subpath (string-append (param \"HOME_DIR\") \"/{}\" ))\n",
dir
));
}
for dir in ALLOW_WRITE_DIRS_MACOS {
profile.push_str(&format!(
" (subpath (string-append (param \"HOME_DIR\") \"/{}\" ))\n",
dir
));
}
for dir in extra_write_dirs {
if let Some(s) = dir.to_str() {
profile.push_str(&format!(" (subpath \"{}\")\n", s));
}
}
profile.push_str(")\n\n");
profile.push_str("; Allow temp directories\n");
profile.push_str("(allow file-read* file-write*\n");
profile.push_str(" (subpath \"/tmp\")\n");
profile.push_str(" (subpath \"/private/tmp\")\n");
profile.push_str(" (subpath \"/var/folders\")\n");
profile.push_str(")\n\n");
profile.push_str("; Allow nix store (read-only by default, allow write for installs)\n");
profile.push_str("(allow file-read* file-write* (subpath \"/nix\"))\n");
profile
}
#[cfg(target_os = "linux")]
const TRUSTED_BWRAP_PATHS: &[&str] = &[
"/usr/bin/bwrap",
"/usr/sbin/bwrap",
"/usr/local/bin/bwrap",
"/run/current-system/sw/bin/bwrap", "/home/linuxbrew/.linuxbrew/bin/bwrap", ];
#[cfg(target_os = "linux")]
fn find_bwrap() -> Option<&'static str> {
TRUSTED_BWRAP_PATHS
.iter()
.find(|p| Path::new(p).is_file())
.copied()
}
#[cfg(target_os = "linux")]
fn spawn_linux(
program: &str,
args: &[String],
worktree: &Path,
envs: &HashMap<String, String>,
) -> Result<Child> {
if let Some(bwrap_path) = find_bwrap() {
spawn_bwrap(bwrap_path, program, args, worktree, envs)
} else {
anyhow::bail!(
"bwrap (bubblewrap) not found at any trusted path ({}). \
Install bubblewrap or set sandbox.dangerously_allow_unsandboxed_host_exec: true \
in your global config to run without filesystem isolation",
TRUSTED_BWRAP_PATHS.join(", ")
)
}
}
#[cfg(target_os = "linux")]
fn spawn_bwrap(
bwrap_path: &str,
program: &str,
args: &[String],
worktree: &Path,
envs: &HashMap<String, String>,
) -> Result<Child> {
let home = std::env::var("HOME").unwrap_or_else(|_| "/var/empty".to_string());
let home_path = Path::new(&home);
let extra_write = extra_xdg_write_dirs(home_path);
let extra_deny = extra_xdg_deny_dirs(home_path);
let mut cmd = Command::new(bwrap_path);
cmd.args(["--ro-bind", "/", "/"]);
cmd.args(["--dev", "/dev"]);
cmd.args(["--proc", "/proc"]);
cmd.args(["--tmpfs", "/tmp"]);
let wt = worktree
.to_str()
.context("Worktree path is not valid UTF-8")?;
cmd.args(["--bind", wt, wt]);
for dir in DENY_READ_DIRS {
let path = home_path.join(dir);
if path.exists() {
if let Some(s) = path.to_str() {
cmd.args(["--tmpfs", s]);
}
}
}
for path in &extra_deny {
if path.exists() {
if let Some(s) = path.to_str() {
cmd.args(["--tmpfs", s]);
}
}
}
for file in DENY_READ_FILES {
let path = home_path.join(file);
if path.is_file() {
if let Some(s) = path.to_str() {
cmd.args(["--ro-bind", "/dev/null", s]);
}
}
}
for dir in ALLOW_WRITE_DIRS {
let path = home_path.join(dir);
if !path.exists() {
if let Err(e) = std::fs::create_dir_all(&path) {
debug!(?path, error = %e, "failed to create cache dir for bwrap binding");
continue;
}
}
if let Some(s) = path.to_str() {
cmd.args(["--bind", s, s]);
}
}
for path in &extra_write {
if !path.exists() {
if let Err(e) = std::fs::create_dir_all(path) {
debug!(?path, error = %e, "failed to create XDG dir for bwrap binding");
continue;
}
}
if let Some(s) = path.to_str() {
cmd.args(["--bind", s, s]);
}
}
cmd.arg("--share-net");
cmd.arg("--");
cmd.arg(program);
cmd.args(args);
cmd.current_dir(worktree);
cmd.env_clear();
cmd.envs(envs);
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
debug!(program, "spawning under bwrap");
cmd.spawn().context("Failed to spawn bwrap")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deny_read_dirs_are_valid() {
for dir in DENY_READ_DIRS {
assert!(!dir.starts_with('/'), "should be relative: {}", dir);
assert!(!dir.contains(".."), "no traversal: {}", dir);
assert!(!dir.is_empty(), "no empty entries");
}
}
#[test]
fn test_deny_read_files_are_valid() {
for file in DENY_READ_FILES {
assert!(!file.starts_with('/'), "should be relative: {}", file);
assert!(!file.contains(".."), "no traversal: {}", file);
assert!(!file.is_empty(), "no empty entries");
}
}
#[test]
fn test_allow_write_dirs_are_valid() {
for dir in ALLOW_WRITE_DIRS {
assert!(!dir.starts_with('/'), "should be relative: {}", dir);
assert!(!dir.contains(".."), "no traversal: {}", dir);
}
}
#[test]
fn test_deny_and_allow_dont_overlap() {
for deny in DENY_READ_DIRS.iter().chain(DENY_READ_FILES.iter()) {
for allow in ALLOW_WRITE_DIRS {
assert_ne!(
*deny, *allow,
"overlap between deny-read and allow-write: {}",
deny
);
}
}
}
#[cfg(target_os = "macos")]
#[test]
fn test_macos_profile_uses_params() {
let profile = generate_macos_profile(&[], &[]);
assert!(profile.contains("(param \"HOME_DIR\")"));
assert!(profile.contains("(param \"WORKTREE\")"));
assert!(!profile.contains("/Users/"));
assert!(!profile.contains("/home/"));
}
#[cfg(target_os = "macos")]
#[test]
fn test_macos_profile_contains_all_deny_paths() {
let profile = generate_macos_profile(&[], &[]);
for p in DENY_READ_DIRS
.iter()
.chain(DENY_READ_FILES.iter())
.chain(DENY_READ_PATHS_MACOS.iter())
{
assert!(profile.contains(p), "missing deny path in profile: {p}");
}
}
#[cfg(target_os = "macos")]
#[test]
fn test_macos_profile_allows_worktree_and_caches() {
let profile = generate_macos_profile(&[], &[]);
assert!(profile.contains("WORKTREE"));
assert!(profile.contains(".cache"));
assert!(profile.contains(".cargo"));
}
#[cfg(target_os = "linux")]
#[test]
fn test_trusted_bwrap_paths_are_absolute() {
for path in TRUSTED_BWRAP_PATHS {
assert!(
path.starts_with('/'),
"trusted bwrap path must be absolute: {}",
path
);
}
}
#[cfg(target_os = "linux")]
#[test]
fn test_trusted_bwrap_paths_end_with_bwrap() {
for path in TRUSTED_BWRAP_PATHS {
assert!(
path.ends_with("/bwrap"),
"trusted bwrap path must end with /bwrap: {}",
path
);
}
}
}