use anyhow::Result;
use std::path::Path;
use tokio::process::Command;
#[allow(dead_code)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) enum SandboxMode {
#[default]
None,
Project,
Strict,
}
#[allow(dead_code)] impl SandboxMode {
fn level(&self) -> u8 {
match self {
Self::None => 0,
Self::Project => 1,
Self::Strict => 2,
}
}
pub fn stricter(&self, other: &Self) -> Self {
if self.level() >= other.level() {
self.clone()
} else {
other.clone()
}
}
pub fn parse(s: &str) -> Self {
match s.to_lowercase().as_str() {
"project" => Self::Project,
"strict" => Self::Strict,
"none" | "" => Self::None,
other => {
tracing::warn!(
"Unknown --sandbox value {:?} — defaulting to none. \
Valid values: none, project, strict",
other
);
Self::None
}
}
}
pub fn is_active(&self) -> bool {
self != &Self::None
}
}
impl std::fmt::Display for SandboxMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::None => f.write_str("none"),
Self::Project => f.write_str("project"),
Self::Strict => f.write_str("strict"),
}
}
}
const CREDENTIAL_SUBDIRS: &[&str] = &[
".ssh", ".aws", ".gnupg", ".kube", ".azure", ".password-store", ".terraform.d", ];
const CREDENTIAL_CONFIG_SUBDIRS: &[&str] = &[
"gcloud", "koda/db", "gh", "op", "helm", ];
const CREDENTIAL_FILES: &[&str] = &[
".netrc", ".git-credentials", ".npmrc", ".pypirc", ".docker/config.json", ".vault-token", ".env", ];
const PROTECTED_PROJECT_SUBDIRS: &[&str] = &[
".koda/agents", ".koda/skills", ];
pub fn is_available() -> bool {
use std::sync::OnceLock;
static AVAILABLE: OnceLock<bool> = OnceLock::new();
*AVAILABLE.get_or_init(|| {
build_inner("true", std::path::Path::new("/tmp"), &SandboxMode::Strict).is_ok()
})
}
pub fn build(
command: &str,
project_root: &Path,
_trust: &crate::trust::TrustMode,
) -> Result<Command> {
match build_inner(command, project_root, &SandboxMode::Strict) {
Ok(cmd) => Ok(cmd),
Err(e) => {
tracing::warn!("Sandbox unavailable, running unsandboxed: {e}");
Ok(plain_sh(command, project_root))
}
}
}
fn build_inner(command: &str, project_root: &Path, mode: &SandboxMode) -> Result<Command> {
match mode {
SandboxMode::None => Ok(plain_sh(command, project_root)),
SandboxMode::Project => build_project(command, project_root),
SandboxMode::Strict => build_strict(command, project_root),
}
}
fn plain_sh(command: &str, project_root: &Path) -> Command {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(command).current_dir(project_root);
cmd
}
fn build_project(command: &str, project_root: &Path) -> Result<Command> {
#[cfg(target_os = "macos")]
return macos_project(command, project_root);
#[cfg(target_os = "linux")]
return linux_project(command, project_root);
#[allow(unreachable_code)]
Err(anyhow::anyhow!(
"Sandbox mode 'project' requested but no sandbox backend available \
on this platform. Supported: macOS (sandbox-exec), Linux (bwrap)."
))
}
fn build_strict(command: &str, project_root: &Path) -> Result<Command> {
#[cfg(target_os = "macos")]
return macos_strict(command, project_root);
#[cfg(target_os = "linux")]
return linux_strict(command, project_root);
#[allow(unreachable_code)]
Err(anyhow::anyhow!(
"Sandbox mode 'strict' requested but no sandbox backend available \
on this platform. Supported: macOS (sandbox-exec), Linux (bwrap)."
))
}
#[cfg(target_os = "macos")]
fn validate_seatbelt_path(s: &str) -> Result<()> {
const FORBIDDEN: &[char] = &['"', '\\', '(', ')', '\0'];
if let Some(c) = s.chars().find(|c| FORBIDDEN.contains(c)) {
anyhow::bail!("Path contains character {c:?} unsafe for seatbelt profile: {s:?}");
}
Ok(())
}
#[cfg(target_os = "macos")]
fn home_path(home: &str, rel: &str) -> String {
let p = Path::new(home).join(rel);
p.canonicalize().unwrap_or(p).to_string_lossy().into_owned()
}
#[cfg(target_os = "macos")]
fn macos_project_profile(root: &str, home: &str) -> String {
format!(
"(version 1)\n\
(deny default)\n\
(allow file-read*)\n\
(allow file-write*\n\
(subpath \"{root}\")\n\
(subpath \"/private/tmp\")\n\
(subpath \"/tmp\")\n\
(subpath \"{home}/.cargo\")\n\
(subpath \"{home}/.npm\")\n\
(subpath \"{home}/.cache\")\n\
(literal \"/dev/null\")\n\
(literal \"/dev/stderr\")\n\
(literal \"/dev/stdout\")\n\
(literal \"/dev/urandom\"))\n\
(allow network*)\n\
(allow process-exec*)\n\
(allow process-fork)\n\
(allow sysctl-read)\n\
(allow ipc-posix*)\n\
(allow mach*)\n"
)
}
#[cfg(target_os = "macos")]
fn protected_subdir_deny_rules_macos(root: &str) -> String {
let mut rules = String::from(
"; ── deny writes to protected project subdirs (.koda/agents, .koda/skills) ──\n",
);
for rel in PROTECTED_PROJECT_SUBDIRS {
let p = Path::new(root).join(rel);
let canonical = p.canonicalize().unwrap_or(p).to_string_lossy().into_owned();
rules.push_str(&format!("(deny file-write* (subpath \"{canonical}\"))\n"));
}
rules
}
#[cfg(target_os = "macos")]
fn credential_deny_rules_macos(home: &str) -> String {
let mut rules =
String::from("; ── strict: deny reads+writes to credential dirs ──────────────\n");
for rel in CREDENTIAL_SUBDIRS {
let p = home_path(home, rel);
rules.push_str(&format!(
"(deny file-read* file-write* (subpath \"{p}\"))\n"
));
}
for rel in CREDENTIAL_CONFIG_SUBDIRS {
let p = home_path(home, &format!(".config/{rel}"));
rules.push_str(&format!(
"(deny file-read* file-write* (subpath \"{p}\"))\n"
));
}
for rel in CREDENTIAL_FILES {
let p = home_path(home, rel);
rules.push_str(&format!(
"(deny file-read* file-write* (literal \"{p}\"))\n"
));
}
rules
}
#[cfg(target_os = "macos")]
fn macos_project(command: &str, project_root: &Path) -> Result<Command> {
let canonical = project_root
.canonicalize()
.unwrap_or_else(|_| project_root.to_path_buf());
let root = canonical.to_string_lossy();
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users".into());
validate_seatbelt_path(&root)?;
validate_seatbelt_path(&home)?;
let mut profile = macos_project_profile(&root, &home);
profile.push_str(&protected_subdir_deny_rules_macos(&root));
let mut cmd = Command::new("sandbox-exec");
cmd.arg("-p")
.arg(profile)
.arg("sh")
.arg("-c")
.arg(command)
.current_dir(project_root);
Ok(cmd)
}
#[cfg(target_os = "macos")]
fn macos_strict(command: &str, project_root: &Path) -> Result<Command> {
let canonical = project_root
.canonicalize()
.unwrap_or_else(|_| project_root.to_path_buf());
let root = canonical.to_string_lossy();
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users".into());
validate_seatbelt_path(&root)?;
validate_seatbelt_path(&home)?;
let mut profile = macos_project_profile(&root, &home);
profile.push_str(&protected_subdir_deny_rules_macos(&root));
profile.push_str(&credential_deny_rules_macos(&home));
let mut cmd = Command::new("sandbox-exec");
cmd.arg("-p")
.arg(profile)
.arg("sh")
.arg("-c")
.arg(command)
.current_dir(project_root);
Ok(cmd)
}
#[cfg(target_os = "linux")]
fn bwrap_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())
})
}
#[cfg(target_os = "linux")]
fn linux_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)
}
#[cfg(target_os = "linux")]
fn linux_project(command: &str, project_root: &Path) -> Result<Command> {
if !bwrap_available() {
anyhow::bail!(
"Sandbox mode 'project' requested but bwrap is not installed. \
Install with: apt install bubblewrap / dnf install bubblewrap"
);
}
let (mut cmd, _home) = linux_base_cmd(project_root);
cmd.args(["--", "sh", "-c", command])
.current_dir(project_root);
Ok(cmd)
}
#[cfg(target_os = "linux")]
fn linux_strict(command: &str, project_root: &Path) -> Result<Command> {
if !bwrap_available() {
anyhow::bail!(
"Sandbox mode 'strict' requested but bwrap is not installed. \
Install with: apt install bubblewrap / dnf install bubblewrap"
);
}
let (mut cmd, home) = linux_base_cmd(project_root);
for rel in CREDENTIAL_SUBDIRS {
let p = format!("{home}/{rel}");
if Path::new(&p).exists() {
cmd.args(["--tmpfs", &p]);
}
}
for rel in CREDENTIAL_CONFIG_SUBDIRS {
let p = format!("{home}/.config/{rel}");
if Path::new(&p).exists() {
cmd.args(["--tmpfs", &p]);
}
}
for rel in CREDENTIAL_FILES {
let p = format!("{home}/{rel}");
if Path::new(&p).exists() {
cmd.args(["--ro-bind", "/dev/null", &p]);
}
}
cmd.args(["--", "sh", "-c", command])
.current_dir(project_root);
Ok(cmd)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stricter_returns_higher_level() {
use SandboxMode::*;
assert_eq!(None.stricter(&None), None);
assert_eq!(Project.stricter(&Project), Project);
assert_eq!(Strict.stricter(&Strict), Strict);
assert_eq!(None.stricter(&Project), Project);
assert_eq!(Project.stricter(&None), Project);
assert_eq!(None.stricter(&Strict), Strict);
assert_eq!(Strict.stricter(&None), Strict);
assert_eq!(Project.stricter(&Strict), Strict);
assert_eq!(Strict.stricter(&Project), Strict);
}
#[test]
fn parse_roundtrip() {
assert_eq!(SandboxMode::parse("none"), SandboxMode::None);
assert_eq!(SandboxMode::parse("project"), SandboxMode::Project);
assert_eq!(SandboxMode::parse("PROJECT"), SandboxMode::Project);
assert_eq!(SandboxMode::parse("strict"), SandboxMode::Strict);
assert_eq!(SandboxMode::parse("STRICT"), SandboxMode::Strict);
assert_eq!(SandboxMode::parse(""), SandboxMode::None);
assert_eq!(SandboxMode::parse("banana"), SandboxMode::None);
}
#[test]
fn display_roundtrip() {
assert_eq!(SandboxMode::None.to_string(), "none");
assert_eq!(SandboxMode::Project.to_string(), "project");
assert_eq!(SandboxMode::Strict.to_string(), "strict");
}
#[test]
fn default_is_none() {
assert_eq!(SandboxMode::default(), SandboxMode::None);
}
#[test]
fn is_active() {
assert!(!SandboxMode::None.is_active());
assert!(SandboxMode::Project.is_active());
assert!(SandboxMode::Strict.is_active());
}
#[cfg(target_os = "macos")]
#[test]
fn strict_profile_denies_ssh_dir() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/test".into());
let rules = credential_deny_rules_macos(&home);
let ssh = home_path(&home, ".ssh");
assert!(
rules.contains(&format!(
"(deny file-read* file-write* (subpath \"{ssh}\"))"
)),
"strict profile must contain deny rule for ~/.ssh"
);
}
#[cfg(target_os = "macos")]
#[test]
fn strict_profile_denies_aws_dir() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/test".into());
let rules = credential_deny_rules_macos(&home);
let aws = home_path(&home, ".aws");
assert!(
rules.contains(&format!(
"(deny file-read* file-write* (subpath \"{aws}\"))"
)),
"strict profile must contain deny rule for ~/.aws"
);
}
#[cfg(target_os = "macos")]
#[test]
fn strict_profile_denies_koda_db() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/test".into());
let rules = credential_deny_rules_macos(&home);
let koda_db = home_path(&home, ".config/koda/db");
assert!(
rules.contains(&format!(
"(deny file-read* file-write* (subpath \"{koda_db}\"))"
)),
"strict profile must deny reads to ~/.config/koda/db (plaintext API keys in SQLite, #847)"
);
}
#[cfg(target_os = "macos")]
#[test]
fn strict_profile_denies_credential_files() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/test".into());
let rules = credential_deny_rules_macos(&home);
let netrc = home_path(&home, ".netrc");
assert!(
rules.contains(&format!(
"(deny file-read* file-write* (literal \"{netrc}\"))"
)),
"strict profile must contain deny rule for ~/.netrc"
);
}
#[cfg(target_os = "macos")]
#[test]
fn strict_deny_rules_come_after_broad_allow() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/test".into());
let project = Path::new("/tmp/test-project");
let root = project.to_string_lossy().into_owned();
let profile = macos_project_profile(&root, &home);
let deny_rules = credential_deny_rules_macos(&home);
let full = format!("{profile}{deny_rules}");
let allow_pos = full.find("(allow file-read*)").unwrap();
let deny_pos = full.find("(deny file-read* file-write*").unwrap();
assert!(
deny_pos > allow_pos,
"deny rules must appear after the broad allow (last-match-wins)"
);
}
#[tokio::test]
async fn build_always_succeeds_and_runs_echo() {
let dir = tempfile::tempdir().unwrap();
let status = build("echo ok", dir.path(), &crate::trust::TrustMode::Safe)
.unwrap()
.status()
.await
.unwrap();
assert!(status.success());
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn macos_allows_write_inside_project() {
let dir = tempfile::tempdir().unwrap();
let status = build(
"touch sandbox_canary",
dir.path(),
&crate::trust::TrustMode::Safe,
)
.unwrap()
.status()
.await
.unwrap();
assert!(status.success(), "write inside project must succeed");
assert!(dir.path().join("sandbox_canary").exists());
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn macos_blocks_write_outside_project() {
let project = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = outside.path().join("evil.txt");
let status = build(
&format!("echo pwned > {}", target.display()),
project.path(),
&crate::trust::TrustMode::Safe,
)
.unwrap()
.status()
.await
.unwrap();
assert!(!status.success(), "write outside project must be blocked");
assert!(!target.exists(), "file must not have been created");
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn macos_allows_read_outside_project() {
let dir = tempfile::tempdir().unwrap();
let status = build(
"cat /etc/hosts > /dev/null",
dir.path(),
&crate::trust::TrustMode::Safe,
)
.unwrap()
.status()
.await
.unwrap();
assert!(status.success(), "reads outside project must be allowed");
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn macos_strict_allows_write_inside_project() {
let dir = tempfile::tempdir().unwrap();
let status = build(
"touch strict_canary",
dir.path(),
&crate::trust::TrustMode::Safe,
)
.unwrap()
.status()
.await
.unwrap();
assert!(
status.success(),
"strict: writes inside project must succeed"
);
assert!(dir.path().join("strict_canary").exists());
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn macos_strict_blocks_write_outside_project() {
let project = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = outside.path().join("evil.txt");
let status = build(
&format!("echo pwned > {}", target.display()),
project.path(),
&crate::trust::TrustMode::Safe,
)
.unwrap()
.status()
.await
.unwrap();
assert!(
!status.success(),
"strict: write outside project must be blocked"
);
assert!(!target.exists());
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn macos_strict_allows_reads_outside_sensitive() {
let dir = tempfile::tempdir().unwrap();
let status = build(
"cat /etc/hosts > /dev/null",
dir.path(),
&crate::trust::TrustMode::Safe,
)
.unwrap()
.status()
.await
.unwrap();
assert!(
status.success(),
"strict: reads to /etc/hosts must still be allowed"
);
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn macos_strict_blocks_koda_db_read() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/test".into());
let db_dir = format!("{home}/.config/koda/db");
if !Path::new(&db_dir).exists() {
eprintln!("skip: {db_dir} does not exist");
return;
}
let dir = tempfile::tempdir().unwrap();
let status = build(
&format!("ls {db_dir}"),
dir.path(),
&crate::trust::TrustMode::Safe,
)
.unwrap()
.status()
.await
.unwrap();
assert!(
!status.success(),
"strict: reading ~/.config/koda/db/ must be blocked"
);
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn macos_project_blocks_write_to_koda_agents() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".koda/agents")).unwrap();
let target = dir.path().join(".koda/agents/evil.json");
let status = build(
&format!("echo '{{}}' > {}", target.display()),
dir.path(),
&crate::trust::TrustMode::Safe,
)
.unwrap()
.status()
.await
.unwrap();
assert!(
!status.success(),
"project: writes to .koda/agents/ must be blocked"
);
assert!(!target.exists(), "agent file must not have been created");
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn macos_strict_blocks_write_to_koda_agents() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".koda/agents")).unwrap();
let target = dir.path().join(".koda/agents/evil.json");
let status = build(
&format!("echo '{{}}' > {}", target.display()),
dir.path(),
&crate::trust::TrustMode::Safe,
)
.unwrap()
.status()
.await
.unwrap();
assert!(
!status.success(),
"strict: writes to .koda/agents/ must be blocked"
);
assert!(!target.exists());
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn macos_project_allows_normal_writes_with_agents_dir() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".koda/agents")).unwrap();
let status = build(
"touch normal_file.txt",
dir.path(),
&crate::trust::TrustMode::Safe,
)
.unwrap()
.status()
.await
.unwrap();
assert!(
status.success(),
"project: normal writes must still work alongside agent protection"
);
assert!(dir.path().join("normal_file.txt").exists());
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn macos_project_blocks_write_to_koda_skills() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".koda/skills")).unwrap();
let target = dir.path().join(".koda/skills/evil.md");
let status = build(
&format!("echo '# evil' > {}", target.display()),
dir.path(),
&crate::trust::TrustMode::Safe,
)
.unwrap()
.status()
.await
.unwrap();
assert!(
!status.success(),
"project: writes to .koda/skills/ must be blocked"
);
assert!(!target.exists(), "skill file must not have been created");
}
#[cfg(target_os = "macos")]
#[test]
fn seatbelt_rejects_path_with_quote() {
let result = validate_seatbelt_path("/tmp/evil\")(allow file-write*");
assert!(result.is_err(), "path with quote must be rejected");
}
#[cfg(target_os = "macos")]
#[test]
fn seatbelt_accepts_normal_path() {
assert!(validate_seatbelt_path("/Users/test/project").is_ok());
assert!(validate_seatbelt_path("/tmp/koda-test-12345").is_ok());
}
}