use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
pub mod windows;
#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]
pub struct SandboxPolicy {
pub writable_roots: Vec<PathBuf>,
pub allow_network: bool,
pub read_deny_subpaths: Vec<PathBuf>,
pub read_deny_globs: Vec<String>,
pub read_allow_subpaths: Vec<PathBuf>,
pub write_protect_subpaths: Vec<PathBuf>,
}
impl SandboxPolicy {
pub fn for_workspace(workspace: &Path) -> Self {
let mut writable_roots = vec![workspace.to_path_buf()];
for dir in temporary_directories() {
if !writable_roots.contains(&dir) {
writable_roots.push(dir);
}
}
Self {
writable_roots,
allow_network: false,
read_deny_subpaths: Vec::new(),
read_deny_globs: Vec::new(),
read_allow_subpaths: Vec::new(),
write_protect_subpaths: metadata_protect_subpaths(workspace),
}
}
pub fn with_read_rules(mut self, workspace: &Path, deny: &[String], allow: &[String]) -> Self {
let mut deny_subpaths: Vec<PathBuf> = Vec::new();
let mut deny_globs: Vec<String> = Vec::new();
for pattern in deny {
match resolve_deny(pattern, workspace) {
DenyResolution::Subpath(subpath) if !workspace.starts_with(&subpath) => {
deny_subpaths.push(subpath)
}
DenyResolution::Glob(glob) => deny_globs.push(glob),
_ => {}
}
}
self.read_allow_subpaths = allow
.iter()
.filter_map(|p| resolve_allow_subpath(p, workspace))
.filter(|a| {
!deny_subpaths.contains(a) && deny_subpaths.iter().any(|d| a.starts_with(d))
})
.collect();
self.read_deny_subpaths = deny_subpaths;
self.read_deny_globs = deny_globs;
self
}
pub fn with_write_deny_rules(mut self, workspace: &Path, deny: &[String]) -> Self {
for pattern in deny {
let DenyResolution::Subpath(subpath) = resolve_deny(pattern, workspace) else {
continue;
};
if subpath.starts_with(workspace)
&& subpath != workspace
&& !self.write_protect_subpaths.contains(&subpath)
{
self.write_protect_subpaths.push(subpath);
}
}
self
}
}
const GLOB_METACHARACTERS: &[char] = &['*', '?', '[', ']', '{', '}'];
enum DenyResolution {
Subpath(PathBuf),
Glob(String),
Nothing,
}
fn expand_rule_pattern(trimmed: &str, workspace: &Path) -> Option<PathBuf> {
let expanded = if trimmed == "~" {
PathBuf::from(std::env::var_os("HOME")?)
} else if let Some(rest) = trimmed.strip_prefix("~/") {
PathBuf::from(std::env::var_os("HOME")?).join(rest)
} else if Path::new(trimmed).is_absolute() {
PathBuf::from(trimmed)
} else {
workspace.join(trimmed.strip_prefix("./").unwrap_or(trimmed))
};
Some(crate::tools::utils::lexically_normalize(&expanded))
}
fn resolve_deny(pattern: &str, workspace: &Path) -> DenyResolution {
let trimmed = pattern.strip_suffix("/**").unwrap_or(pattern).trim();
if trimmed.is_empty() {
return DenyResolution::Nothing;
}
let Some(normalized) = expand_rule_pattern(trimmed, workspace) else {
return DenyResolution::Nothing;
};
let prefix = longest_glob_free_prefix(&normalized);
let resolved = canonicalize_existing_prefix(&prefix);
if prefix == normalized || widened_prefix_is_maskable(&resolved, workspace) {
return DenyResolution::Subpath(resolved);
}
match normalized.strip_prefix(&prefix) {
Ok(tail) => DenyResolution::Glob(resolved.join(tail).to_string_lossy().into_owned()),
Err(_) => DenyResolution::Nothing,
}
}
fn resolve_allow_subpath(pattern: &str, workspace: &Path) -> Option<PathBuf> {
let trimmed = pattern.trim();
if trimmed.is_empty() || trimmed.contains(GLOB_METACHARACTERS) {
return None;
}
let normalized = expand_rule_pattern(trimmed, workspace)?;
Some(canonicalize_existing_prefix(&normalized))
}
fn widened_prefix_is_maskable(prefix: &Path, workspace: &Path) -> bool {
prefix.starts_with(workspace)
|| std::env::var_os("HOME")
.is_some_and(|home| prefix.starts_with(canonicalize_existing_prefix(Path::new(&home))))
}
fn longest_glob_free_prefix(path: &Path) -> PathBuf {
let mut prefix = PathBuf::new();
for component in path.components() {
if let std::path::Component::Normal(part) = component {
if part.to_string_lossy().contains(GLOB_METACHARACTERS) {
break;
}
}
prefix.push(component);
}
prefix
}
pub(super) fn canonicalize_existing_prefix(path: &Path) -> PathBuf {
if let Ok(canonical) = std::fs::canonicalize(path) {
return canonical;
}
let mut trailing: Vec<&OsStr> = Vec::new();
let mut current = path;
while let Some(parent) = current.parent() {
if let Some(name) = current.file_name() {
trailing.push(name);
}
if let Ok(mut canonical) = std::fs::canonicalize(parent) {
canonical.extend(trailing.iter().rev());
return canonical;
}
current = parent;
}
path.to_path_buf()
}
pub(super) const GIT_METADATA_DIR: &str = ".git";
#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]
const METADATA_PROTECT_DIRS: &[&str] =
&[GIT_METADATA_DIR, ".sofos", ".agents", ".claude", ".codex"];
#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]
fn metadata_protect_subpaths(workspace: &Path) -> Vec<PathBuf> {
METADATA_PROTECT_DIRS
.iter()
.map(|name| workspace.join(name))
.collect()
}
fn temporary_directories() -> Vec<PathBuf> {
let mut candidates: Vec<PathBuf> = Vec::new();
#[cfg(unix)]
{
if let Some(tmpdir) = std::env::var_os("TMPDIR") {
if !tmpdir.is_empty() {
candidates.push(PathBuf::from(tmpdir));
}
}
candidates.push(PathBuf::from("/tmp"));
}
#[cfg(windows)]
{
for key in ["TEMP", "TMP", "LOCALAPPDATA"] {
if let Some(value) = std::env::var_os(key) {
if !value.is_empty() {
let mut path = PathBuf::from(value);
if key == "LOCALAPPDATA" {
path.push("Temp");
}
candidates.push(path);
}
}
}
}
let mut roots = Vec::new();
for candidate in candidates {
let resolved = std::fs::canonicalize(&candidate).unwrap_or(candidate);
if resolved.is_dir() && !roots.contains(&resolved) {
roots.push(resolved);
}
}
roots
}
#[cfg(target_os = "macos")]
pub fn is_available() -> bool {
Path::new(macos::SANDBOX_EXEC_PATH).is_file()
}
#[cfg(target_os = "linux")]
pub fn is_available() -> bool {
use std::sync::OnceLock;
static USABLE: OnceLock<bool> = OnceLock::new();
*USABLE.get_or_init(|| {
linux::resolved_bwrap().is_some()
&& linux::bwrap_can_unshare_namespaces()
&& linux::network_seccomp_program().is_some()
})
}
#[cfg(target_os = "linux")]
pub fn network_seccomp_program() -> Option<seccompiler::BpfProgram> {
linux::network_seccomp_program()
}
#[cfg(target_os = "linux")]
pub fn apply_network_seccomp(program: Option<&seccompiler::BpfProgram>) -> std::io::Result<()> {
match program {
Some(program) => seccompiler::apply_filter(program)
.map_err(|_| std::io::Error::from(std::io::ErrorKind::Other)),
None => Ok(()),
}
}
#[cfg(target_os = "windows")]
pub fn is_available() -> bool {
false
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
pub fn is_available() -> bool {
false
}
#[cfg(target_os = "macos")]
pub fn confined_invocation(
shell: &OsStr,
command: &str,
policy: &SandboxPolicy,
) -> Option<(OsString, Vec<OsString>)> {
let profile = macos::seatbelt_profile(policy)?;
let mut args: Vec<OsString> = vec![OsString::from("-p"), OsString::from(profile)];
args.push(shell.to_os_string());
args.push(OsString::from("-c"));
args.push(OsString::from(command));
Some((OsString::from(macos::SANDBOX_EXEC_PATH), args))
}
#[cfg(target_os = "linux")]
pub fn confined_invocation(
shell: &OsStr,
command: &str,
policy: &SandboxPolicy,
) -> Option<(OsString, Vec<OsString>)> {
if !policy.read_deny_globs.is_empty() {
return None;
}
let program = linux::resolved_bwrap()?;
let mut args = linux::bwrap_arguments(policy);
args.push(OsString::from("--"));
args.push(shell.to_os_string());
args.push(OsString::from("-c"));
args.push(OsString::from(command));
Some((program.as_os_str().to_os_string(), args))
}
#[cfg(target_os = "windows")]
pub fn confined_invocation(
_shell: &OsStr,
_command: &str,
_policy: &SandboxPolicy,
) -> Option<(OsString, Vec<OsString>)> {
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
pub fn confined_invocation(
_shell: &OsStr,
_command: &str,
_policy: &SandboxPolicy,
) -> Option<(OsString, Vec<OsString>)> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workspace_policy_write_protects_metadata_dirs() {
let dir = tempfile::tempdir().unwrap();
let policy = SandboxPolicy::for_workspace(dir.path());
for name in [".git", ".sofos", ".agents", ".claude", ".codex"] {
assert!(
policy
.write_protect_subpaths
.contains(&dir.path().join(name)),
"{name} must be write-protected"
);
}
}
#[cfg(unix)]
#[test]
fn workspace_policy_includes_workspace_and_closes_network_on_unix() {
let dir = tempfile::tempdir().unwrap();
let policy = SandboxPolicy::for_workspace(dir.path());
assert!(!policy.allow_network);
let canonical = std::fs::canonicalize(dir.path()).unwrap();
assert!(
policy.writable_roots.contains(&canonical)
|| policy.writable_roots.contains(&dir.path().to_path_buf())
);
}
#[test]
fn read_rules_keep_only_exceptions_inside_a_deny() {
let dir = tempfile::tempdir().unwrap();
let workspace = std::fs::canonicalize(dir.path()).unwrap();
let policy = SandboxPolicy::for_workspace(&workspace).with_read_rules(
&workspace,
&["./secret/**".to_string()],
&[
"./**".to_string(),
"./other".to_string(),
"./secret/ok.txt".to_string(),
],
);
let secret = workspace.join("secret");
assert_eq!(policy.read_deny_subpaths, vec![secret.clone()]);
assert_eq!(policy.read_allow_subpaths, vec![secret.join("ok.txt")]);
}
#[test]
fn write_deny_protects_only_in_workspace_subpaths() {
let dir = tempfile::tempdir().unwrap();
let workspace = std::fs::canonicalize(dir.path()).unwrap();
let policy = SandboxPolicy::for_workspace(&workspace).with_write_deny_rules(
&workspace,
&[
"./build/output".to_string(),
"/etc/passwd".to_string(),
"./".to_string(),
],
);
assert!(
policy
.write_protect_subpaths
.contains(&workspace.join("build/output")),
"an in-workspace write deny must be protected: {:?}",
policy.write_protect_subpaths
);
assert!(
!policy
.write_protect_subpaths
.iter()
.any(|p| p.ends_with("passwd")),
"an out-of-workspace write deny must not be added"
);
assert!(
!policy.write_protect_subpaths.contains(&workspace),
"the workspace root must never be write-protected"
);
}
#[test]
fn read_rule_normalizes_parent_segments() {
let dir = tempfile::tempdir().unwrap();
let workspace = std::fs::canonicalize(dir.path()).unwrap();
let policy = SandboxPolicy::for_workspace(&workspace).with_read_rules(
&workspace,
&["./secret/../private".to_string()],
&[],
);
assert_eq!(policy.read_deny_subpaths, vec![workspace.join("private")]);
}
#[test]
fn read_deny_with_inner_glob_falls_back_to_prefix() {
let dir = tempfile::tempdir().unwrap();
let workspace = std::fs::canonicalize(dir.path()).unwrap();
let policy = SandboxPolicy::for_workspace(&workspace).with_read_rules(
&workspace,
&["./config/*/secret.env".to_string()],
&[],
);
assert_eq!(policy.read_deny_subpaths, vec![workspace.join("config")]);
}
#[test]
fn read_deny_covering_the_workspace_is_dropped() {
let dir = tempfile::tempdir().unwrap();
let workspace = std::fs::canonicalize(dir.path()).unwrap();
let policy = SandboxPolicy::for_workspace(&workspace).with_read_rules(
&workspace,
&["*/secret".to_string()],
&[],
);
assert!(policy.read_deny_subpaths.is_empty());
}
#[test]
fn read_deny_with_glob_outside_workspace_and_home_is_kept_as_a_glob() {
let dir = tempfile::tempdir().unwrap();
let workspace = std::fs::canonicalize(dir.path()).unwrap();
let policy = SandboxPolicy::for_workspace(&workspace).with_read_rules(
&workspace,
&["/etc/*/passwd".to_string()],
&[],
);
assert!(
policy.read_deny_subpaths.is_empty(),
"a glob deny widening to a system directory must not mask it"
);
assert_eq!(policy.read_deny_globs.len(), 1, "the glob deny is kept");
let glob = &policy.read_deny_globs[0];
let sep = std::path::MAIN_SEPARATOR;
assert!(
glob.ends_with(&format!("{sep}*{sep}passwd")) && glob.contains("etc"),
"the kept glob keeps its pattern with the prefix canonicalized: {glob}"
);
}
#[cfg(unix)]
#[test]
fn read_deny_glob_through_symlink_to_outside_is_kept_as_a_glob() {
let ws_dir = tempfile::tempdir().unwrap();
let workspace = std::fs::canonicalize(ws_dir.path()).unwrap();
let outside_dir = tempfile::tempdir().unwrap();
let outside = std::fs::canonicalize(outside_dir.path()).unwrap();
std::os::unix::fs::symlink(&outside, workspace.join("config")).unwrap();
let policy = SandboxPolicy::for_workspace(&workspace).with_read_rules(
&workspace,
&["./config/*/secret.env".to_string()],
&[],
);
assert!(
policy.read_deny_subpaths.is_empty(),
"a glob deny whose prefix resolves outside workspace+home must not mask the resolved tree"
);
assert_eq!(policy.read_deny_globs.len(), 1, "the glob deny is kept");
let glob = &policy.read_deny_globs[0];
assert!(
glob.starts_with(&*outside.to_string_lossy()) && glob.ends_with("/*/secret.env"),
"the kept glob has its prefix canonicalized to the symlink target: {glob}"
);
}
#[cfg(unix)]
#[test]
fn read_rule_resolves_symlinked_prefix_for_missing_target() {
let dir = tempfile::tempdir().unwrap();
let workspace = std::fs::canonicalize(dir.path()).unwrap();
let real = workspace.join("real");
std::fs::create_dir(&real).unwrap();
let link = workspace.join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let policy = SandboxPolicy::for_workspace(&workspace).with_read_rules(
&workspace,
&[format!("{}/missing/**", link.display())],
&[],
);
assert_eq!(policy.read_deny_subpaths, vec![real.join("missing")]);
}
#[cfg(target_os = "linux")]
#[test]
fn linux_glob_read_deny_refuses_confinement() {
let dir = tempfile::tempdir().unwrap();
let mut policy = SandboxPolicy::for_workspace(dir.path());
policy.read_deny_globs = vec!["/etc/*/passwd".to_string()];
assert!(
confined_invocation(std::ffi::OsStr::new("/bin/sh"), "echo hi", &policy).is_none(),
"a glob read-deny must refuse confinement on Linux"
);
}
#[cfg(target_os = "windows")]
#[test]
fn workspace_policy_includes_workspace_on_windows() {
let dir = tempfile::tempdir().unwrap();
let policy = SandboxPolicy::for_workspace(dir.path());
assert!(!policy.allow_network);
let canonical =
std::fs::canonicalize(dir.path()).unwrap_or_else(|_| dir.path().to_path_buf());
assert!(
policy.writable_roots.contains(&canonical)
|| policy.writable_roots.contains(&dir.path().to_path_buf())
);
}
}