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"),
}
}
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
const CREDENTIAL_SUBDIRS: &[&str] = &[
".ssh", ".aws", ".gnupg", ".kube", ".azure", ".password-store", ".terraform.d", ".claude", ".android", ];
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
const CREDENTIAL_CONFIG_SUBDIRS: &[&str] = &[
"gcloud", "gh", "op", "helm", "netlify", "vercel", "fly", "doppler", "stripe", "heroku", ];
const CREDENTIAL_CONFIG_FULL_DENY: &[&str] = &[
"koda/db", ];
pub(crate) fn is_fully_denied(path: &Path) -> bool {
is_fully_denied_with_home(path, resolve_home_dir().as_deref())
}
fn resolve_home_dir() -> Option<String> {
std::env::var("HOME")
.ok()
.or_else(|| std::env::var("USERPROFILE").ok())
.filter(|s| !s.is_empty())
}
fn is_fully_denied_with_home(path: &Path, home: Option<&str>) -> bool {
if let Some(home) = home {
let home_path = Path::new(home);
if CREDENTIAL_CONFIG_FULL_DENY
.iter()
.any(|rel| path.starts_with(home_path.join(".config").join(rel)))
{
return true;
}
}
let components: Vec<&std::ffi::OsStr> = path
.components()
.filter_map(|c| match c {
std::path::Component::Normal(s) => Some(s),
_ => None,
})
.collect();
CREDENTIAL_CONFIG_FULL_DENY.iter().any(|rel| {
let mut needle: Vec<&str> = vec![".config"];
needle.extend(rel.split('/'));
components.windows(needle.len()).any(|window| {
window
.iter()
.zip(needle.iter())
.all(|(comp, want)| comp.to_str() == Some(*want))
})
})
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
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: write-protect credential dirs (reads allowed) ──\n");
for rel in CREDENTIAL_SUBDIRS {
let p = home_path(home, rel);
rules.push_str(&format!("(deny 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-write* (subpath \"{p}\"))\n"));
}
for rel in CREDENTIAL_FILES {
let p = home_path(home, rel);
rules.push_str(&format!("(deny file-write* (literal \"{p}\"))\n"));
}
rules.push_str("; ── strict: full deny for koda-internal secrets ─────────────\n");
for rel in CREDENTIAL_CONFIG_FULL_DENY {
let p = home_path(home, &format!(".config/{rel}"));
rules.push_str(&format!(
"(deny file-read* file-write* (subpath \"{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_CONFIG_FULL_DENY {
let p = format!("{home}/.config/{rel}");
if Path::new(&p).exists() {
cmd.args(["--tmpfs", &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());
}
#[test]
fn fully_denied_blocks_koda_db() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".into());
let koda_db = Path::new(&home).join(".config/koda/db");
assert!(
is_fully_denied(&koda_db),
"~/.config/koda/db must be fully denied"
);
assert!(is_fully_denied(&koda_db.join("koda.db")));
}
#[test]
fn fully_denied_allows_credential_dirs() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".into());
let home = Path::new(&home);
for rel in &[".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud"] {
assert!(
!is_fully_denied(&home.join(rel)),
"{rel} must NOT be fully denied (reads allowed)"
);
}
}
#[test]
fn fully_denied_allows_project_and_system_paths() {
assert!(!is_fully_denied(Path::new(
"/home/user/project/src/main.rs"
)));
assert!(!is_fully_denied(Path::new("/tmp/scratch.txt")));
assert!(!is_fully_denied(Path::new("/etc/hosts")));
}
#[test]
fn fully_denied_blocks_koda_db_when_home_is_none() {
for path in [
"/root/.config/koda/db",
"/root/.config/koda/db/koda.db",
"/home/runner/.config/koda/db/koda.db",
"/proc/self/root/.config/koda/db/koda.db",
".config/koda/db/koda.db", ] {
assert!(
is_fully_denied_with_home(Path::new(path), None),
"{path:?} must be denied even with HOME=None"
);
}
}
#[test]
fn fully_denied_no_home_still_allows_normal_paths() {
for path in [
"/home/user/project/src/main.rs",
"/tmp/scratch.txt",
"/etc/hosts",
"/home/user/.config/git/config", "/home/user/.config/koda/agents/foo.json", "/var/lib/koda-db-backups/2025.tar", ] {
assert!(
!is_fully_denied_with_home(Path::new(path), None),
"{path:?} must NOT be denied (no koda secrets)"
);
}
}
#[test]
fn fully_denied_uses_home_when_provided() {
let custom_home = "/srv/koda-runner";
assert!(is_fully_denied_with_home(
Path::new("/srv/koda-runner/.config/koda/db/koda.db"),
Some(custom_home),
));
assert!(!is_fully_denied_with_home(
Path::new("/srv/koda-runner/notes.md"),
Some(custom_home),
));
}
#[test]
fn resolve_home_dir_treats_empty_as_unset() {
assert!(is_fully_denied_with_home(
Path::new("/anywhere/.config/koda/db/koda.db"),
None, ));
}
#[cfg(target_os = "macos")]
#[test]
fn strict_profile_write_protects_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-write* (subpath \"{ssh}\"))")),
"strict profile must write-protect ~/.ssh"
);
assert!(
!rules.contains(&format!(
"(deny file-read* file-write* (subpath \"{ssh}\"))"
)),
"strict profile must NOT read-deny ~/.ssh (breaks ssh/git)"
);
}
#[cfg(target_os = "macos")]
#[test]
fn strict_profile_write_protects_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-write* (subpath \"{aws}\"))")),
"strict profile must write-protect ~/.aws"
);
assert!(
!rules.contains(&format!(
"(deny file-read* file-write* (subpath \"{aws}\"))"
)),
"strict profile must NOT read-deny ~/.aws (breaks aws CLI)"
);
}
#[cfg(target_os = "macos")]
#[test]
fn strict_profile_write_protects_gh_dir() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/test".into());
let rules = credential_deny_rules_macos(&home);
let gh = home_path(&home, ".config/gh");
assert!(
rules.contains(&format!("(deny file-write* (subpath \"{gh}\"))")),
"strict profile must write-protect ~/.config/gh"
);
assert!(
!rules.contains(&format!("(deny file-read* file-write* (subpath \"{gh}\"))")),
"strict profile must NOT read-deny ~/.config/gh (breaks gh CLI, #855)"
);
}
#[cfg(target_os = "macos")]
#[test]
fn strict_profile_write_protects_claude_dir() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/test".into());
let rules = credential_deny_rules_macos(&home);
let claude = home_path(&home, ".claude");
assert!(
rules.contains(&format!("(deny file-write* (subpath \"{claude}\"))")),
"strict profile must write-protect ~/.claude"
);
}
#[cfg(target_os = "macos")]
#[test]
fn strict_profile_write_protects_android_dir() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/test".into());
let rules = credential_deny_rules_macos(&home);
let android = home_path(&home, ".android");
assert!(
rules.contains(&format!("(deny file-write* (subpath \"{android}\"))")),
"strict profile must write-protect ~/.android"
);
}
#[cfg(target_os = "macos")]
#[test]
fn strict_profile_write_protects_netlify_dir() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/test".into());
let rules = credential_deny_rules_macos(&home);
let netlify = home_path(&home, ".config/netlify");
assert!(
rules.contains(&format!("(deny file-write* (subpath \"{netlify}\"))")),
"strict profile must write-protect ~/.config/netlify"
);
}
#[cfg(target_os = "macos")]
#[test]
fn strict_profile_write_protects_vercel_dir() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/test".into());
let rules = credential_deny_rules_macos(&home);
let vercel = home_path(&home, ".config/vercel");
assert!(
rules.contains(&format!("(deny file-write* (subpath \"{vercel}\"))")),
"strict profile must write-protect ~/.config/vercel"
);
}
#[cfg(target_os = "macos")]
#[test]
fn strict_profile_fully_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 fully deny ~/.config/koda/db (plaintext API keys, #847)"
);
}
#[cfg(target_os = "macos")]
#[test]
fn strict_profile_write_protects_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-write* (literal \"{netrc}\"))")),
"strict profile must write-protect ~/.netrc"
);
assert!(
!rules.contains(&format!(
"(deny file-read* file-write* (literal \"{netrc}\"))"
)),
"strict profile must NOT read-deny ~/.netrc (breaks curl/wget)"
);
}
#[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)"
);
let write_deny_pos = full.find("(deny file-write*").unwrap();
assert!(
write_deny_pos > allow_pos,
"write-deny rules must appear after the broad allow"
);
}
#[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_strict_allows_ssh_read() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/test".into());
let ssh_dir = format!("{home}/.ssh");
if !Path::new(&ssh_dir).exists() {
eprintln!("skip: {ssh_dir} does not exist");
return;
}
let dir = tempfile::tempdir().unwrap();
let status = build(
&format!("ls {ssh_dir}"),
dir.path(),
&crate::trust::TrustMode::Safe,
)
.unwrap()
.status()
.await
.unwrap();
assert!(
status.success(),
"strict: reading ~/.ssh/ must be allowed (CLI tools need credential access, #855)"
);
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn macos_strict_blocks_ssh_write() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/test".into());
let ssh_dir = format!("{home}/.ssh");
if !Path::new(&ssh_dir).exists() {
eprintln!("skip: {ssh_dir} does not exist");
return;
}
let dir = tempfile::tempdir().unwrap();
let canary = format!("{ssh_dir}/sandbox_canary_test");
let status = build(
&format!("touch {canary}"),
dir.path(),
&crate::trust::TrustMode::Safe,
)
.unwrap()
.status()
.await
.unwrap();
assert!(
!status.success(),
"strict: writing to ~/.ssh/ must be blocked"
);
assert!(!Path::new(&canary).exists());
}
#[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 = "linux")]
#[tokio::test]
async fn linux_strict_allows_ssh_read() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
let ssh_dir = format!("{home}/.ssh");
if !Path::new(&ssh_dir).exists() {
eprintln!("skip: {ssh_dir} does not exist");
return;
}
let dir = tempfile::tempdir().unwrap();
let status = build(
&format!("ls {ssh_dir}"),
dir.path(),
&crate::trust::TrustMode::Safe,
)
.unwrap()
.status()
.await
.unwrap();
assert!(
status.success(),
"linux strict: reading ~/.ssh/ must be allowed (CLI tools need credential access, #855)"
);
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn linux_strict_blocks_ssh_write() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
let ssh_dir = format!("{home}/.ssh");
if !Path::new(&ssh_dir).exists() {
eprintln!("skip: {ssh_dir} does not exist");
return;
}
let dir = tempfile::tempdir().unwrap();
let canary = format!("{ssh_dir}/bwrap_canary_test");
let status = build(
&format!("touch {canary}"),
dir.path(),
&crate::trust::TrustMode::Safe,
)
.unwrap()
.status()
.await
.unwrap();
assert!(
!status.success(),
"linux strict: writing to ~/.ssh/ must be blocked"
);
assert!(!Path::new(&canary).exists());
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn linux_strict_allows_aws_read() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
let aws_dir = format!("{home}/.aws");
if !Path::new(&aws_dir).exists() {
eprintln!("skip: {aws_dir} does not exist");
return;
}
let dir = tempfile::tempdir().unwrap();
let status = build(
&format!("ls {aws_dir}"),
dir.path(),
&crate::trust::TrustMode::Safe,
)
.unwrap()
.status()
.await
.unwrap();
assert!(
status.success(),
"linux strict: reading ~/.aws/ must be allowed (aws CLI needs credentials)"
);
}
#[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());
}
}