#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use super::{CommandSpec, ExecEnv};
use crate::command_safety::SafetyLevel;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum SandboxPolicy {
#[serde(rename = "danger-full-access")]
DangerFullAccess,
#[serde(rename = "read-only")]
ReadOnly,
#[serde(rename = "external-sandbox")]
ExternalSandbox {
#[serde(default)]
network_access: bool,
},
#[serde(rename = "workspace-write")]
WorkspaceWrite {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
writable_roots: Vec<PathBuf>,
#[serde(default)]
network_access: bool,
#[serde(default)]
exclude_tmpdir: bool,
#[serde(default)]
exclude_slash_tmp: bool,
},
}
impl Default for SandboxPolicy {
fn default() -> Self {
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
exclude_tmpdir: false,
exclude_slash_tmp: false,
}
}
}
impl SandboxPolicy {
pub fn workspace_with_network() -> Self {
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: true,
exclude_tmpdir: false,
exclude_slash_tmp: false,
}
}
pub fn workspace_with_roots(roots: Vec<PathBuf>, network: bool) -> Self {
SandboxPolicy::WorkspaceWrite {
writable_roots: roots,
network_access: network,
exclude_tmpdir: false,
exclude_slash_tmp: false,
}
}
pub fn has_full_disk_read_access() -> bool {
true
}
pub fn has_full_disk_write_access(&self) -> bool {
matches!(
self,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
)
}
pub fn has_network_access(&self) -> bool {
match self {
SandboxPolicy::DangerFullAccess => true,
SandboxPolicy::ReadOnly => false,
SandboxPolicy::ExternalSandbox { network_access }
| SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access,
}
}
pub fn should_sandbox(&self) -> bool {
!matches!(
self,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
)
}
pub fn get_writable_roots(&self, cwd: &Path) -> Vec<WritableRoot> {
match self {
SandboxPolicy::DangerFullAccess
| SandboxPolicy::ExternalSandbox { .. }
| SandboxPolicy::ReadOnly => vec![],
SandboxPolicy::WorkspaceWrite {
writable_roots,
exclude_tmpdir,
exclude_slash_tmp,
..
} => {
let mut roots: Vec<PathBuf> = writable_roots.clone();
if let Ok(canonical_cwd) = cwd.canonicalize() {
roots.push(canonical_cwd);
} else {
roots.push(cwd.to_path_buf());
}
for root in roots.clone() {
roots.extend(resolve_git_worktree_writable_roots(&root));
}
if !exclude_slash_tmp && let Ok(tmp) = Path::new("/tmp").canonicalize() {
roots.push(tmp);
}
if !exclude_tmpdir
&& let Ok(tmpdir) = std::env::var("TMPDIR")
&& let Ok(canonical) = Path::new(&tmpdir).canonicalize()
{
roots.push(canonical);
}
roots
.into_iter()
.map(|root| {
let mut read_only_subpaths = Vec::new();
let codewhale_dir = root.join(".codewhale");
if codewhale_dir.is_dir() {
read_only_subpaths.push(codewhale_dir);
}
let deepseek_dir = root.join(".deepseek");
if deepseek_dir.is_dir() {
read_only_subpaths.push(deepseek_dir);
}
WritableRoot {
root,
read_only_subpaths,
}
})
.collect()
}
}
}
}
fn resolve_git_worktree_writable_roots(root: &Path) -> Vec<PathBuf> {
let Some(pointer) = resolve_gitdir_pointer(root) else {
return Vec::new();
};
let git_dir = pointer.git_dir;
let Some(common_dir) = resolve_git_common_dir(&git_dir) else {
return Vec::new();
};
if !git_dir.starts_with(common_dir.join("worktrees")) {
return Vec::new();
}
if !worktree_metadata_points_back_to_workspace(&git_dir, &pointer.git_file) {
return Vec::new();
}
vec![git_dir, common_dir]
}
#[derive(Debug)]
struct GitDirPointer {
git_dir: PathBuf,
git_file: PathBuf,
}
fn resolve_gitdir_pointer(root: &Path) -> Option<GitDirPointer> {
let search_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
for ancestor in search_root.ancestors() {
let git_file = ancestor.join(".git");
if !git_file.is_file() {
continue;
}
let contents = fs::read_to_string(&git_file).ok()?;
let value = contents
.lines()
.find_map(|line| line.strip_prefix("gitdir:"))?
.trim();
if value.is_empty() {
return None;
}
let path = PathBuf::from(value);
let resolved = if path.is_absolute() {
path
} else {
ancestor.join(path)
};
return Some(GitDirPointer {
git_dir: resolved.canonicalize().ok()?,
git_file: git_file.canonicalize().ok()?,
});
}
None
}
fn resolve_git_common_dir(git_dir: &Path) -> Option<PathBuf> {
let contents = fs::read_to_string(git_dir.join("commondir")).ok()?;
let value = contents.lines().next()?.trim();
if value.is_empty() {
return None;
}
let path = PathBuf::from(value);
let resolved = if path.is_absolute() {
path
} else {
git_dir.join(path)
};
resolved.canonicalize().ok()
}
fn worktree_metadata_points_back_to_workspace(git_dir: &Path, expected_git_file: &Path) -> bool {
let Some(actual_git_file) = resolve_gitdir_back_pointer(git_dir) else {
return false;
};
actual_git_file == expected_git_file
}
fn resolve_gitdir_back_pointer(git_dir: &Path) -> Option<PathBuf> {
let contents = fs::read_to_string(git_dir.join("gitdir")).ok()?;
let value = contents.lines().next()?.trim();
if value.is_empty() {
return None;
}
let path = PathBuf::from(value);
let resolved = if path.is_absolute() {
path
} else {
git_dir.join(path)
};
resolved.canonicalize().ok()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WritableRoot {
pub root: PathBuf,
pub read_only_subpaths: Vec<PathBuf>,
}
impl WritableRoot {
pub fn new(root: PathBuf) -> Self {
Self {
root,
read_only_subpaths: vec![],
}
}
pub fn with_exceptions(root: PathBuf, read_only: Vec<PathBuf>) -> Self {
Self {
root,
read_only_subpaths: read_only,
}
}
pub fn is_path_writable(&self, path: &Path) -> bool {
if !path.starts_with(&self.root) {
return false;
}
for subpath in &self.read_only_subpaths {
if path.starts_with(subpath) {
return false;
}
}
true
}
}
pub trait SandboxExecutor {
fn prepare(&self, spec: &CommandSpec) -> io::Result<ExecEnv>;
fn was_denied(&self, exit_code: i32, stderr: &str) -> bool;
fn denial_message(&self, stderr: &str) -> String;
fn sandbox_type(&self) -> super::SandboxType;
}
pub fn map_safety_level_to_behavior(
level: SafetyLevel,
default_policy: &SandboxPolicy,
) -> SandboxPolicyBehavior {
match level {
SafetyLevel::Safe | SafetyLevel::WorkspaceSafe => {
SandboxPolicyBehavior::Sandboxed(default_policy.clone())
}
SafetyLevel::RequiresApproval => SandboxPolicyBehavior::RequiresApproval,
SafetyLevel::Dangerous => SandboxPolicyBehavior::Blocked,
}
}
#[derive(Debug, Clone)]
pub enum SandboxPolicyBehavior {
Sandboxed(SandboxPolicy),
RequiresApproval,
Blocked,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_policy() {
let policy = SandboxPolicy::default();
assert!(matches!(policy, SandboxPolicy::WorkspaceWrite { .. }));
assert!(!policy.has_network_access());
assert!(policy.should_sandbox());
}
#[test]
fn test_full_access_policy() {
let policy = SandboxPolicy::DangerFullAccess;
assert!(policy.has_full_disk_write_access());
assert!(policy.has_network_access());
assert!(!policy.should_sandbox());
}
#[test]
fn test_read_only_policy() {
let policy = SandboxPolicy::ReadOnly;
assert!(!policy.has_full_disk_write_access());
assert!(!policy.has_network_access());
assert!(policy.should_sandbox());
}
#[test]
fn test_workspace_with_network() {
let policy = SandboxPolicy::workspace_with_network();
assert!(policy.has_network_access());
assert!(policy.should_sandbox());
}
#[test]
fn workspace_write_includes_git_worktree_metadata_roots() {
let tmp = tempfile::tempdir().expect("tempdir");
let common_git_dir = tmp.path().join("main-repo").join(".git");
let worktree_git_dir = common_git_dir.join("worktrees").join("feature");
let worktree = tmp.path().join("feature-worktree");
std::fs::create_dir_all(&worktree_git_dir).expect("mkdir gitdir");
std::fs::create_dir_all(&worktree).expect("mkdir worktree");
std::fs::write(
worktree.join(".git"),
format!("gitdir: {}\n", worktree_git_dir.display()),
)
.expect("write git pointer");
std::fs::write(worktree_git_dir.join("commondir"), "../..").expect("write commondir");
std::fs::write(
worktree_git_dir.join("gitdir"),
worktree.join(".git").display().to_string(),
)
.expect("write gitdir back pointer");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![worktree.clone()],
network_access: true,
exclude_tmpdir: true,
exclude_slash_tmp: true,
};
let root_paths: Vec<PathBuf> = policy
.get_writable_roots(&worktree)
.into_iter()
.map(|root| root.root)
.collect();
assert!(root_paths.contains(&worktree.canonicalize().expect("canonical worktree")));
assert!(root_paths.contains(&worktree_git_dir.canonicalize().expect("canonical gitdir")));
assert!(root_paths.contains(&common_git_dir.canonicalize().expect("canonical common git")));
}
#[test]
fn workspace_write_resolves_git_worktree_metadata_from_subdirectory() {
let tmp = tempfile::tempdir().expect("tempdir");
let common_git_dir = tmp.path().join("main-repo").join(".git");
let worktree_git_dir = common_git_dir.join("worktrees").join("feature");
let worktree = tmp.path().join("feature-worktree");
let nested = worktree.join("crates").join("cli");
std::fs::create_dir_all(&worktree_git_dir).expect("mkdir gitdir");
std::fs::create_dir_all(&nested).expect("mkdir nested worktree path");
std::fs::write(
worktree.join(".git"),
format!("gitdir: {}\n", worktree_git_dir.display()),
)
.expect("write git pointer");
std::fs::write(worktree_git_dir.join("commondir"), "../..").expect("write commondir");
std::fs::write(
worktree_git_dir.join("gitdir"),
worktree.join(".git").display().to_string(),
)
.expect("write gitdir back pointer");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: true,
exclude_tmpdir: true,
exclude_slash_tmp: true,
};
let root_paths: Vec<PathBuf> = policy
.get_writable_roots(&nested)
.into_iter()
.map(|root| root.root)
.collect();
assert!(root_paths.contains(&nested.canonicalize().expect("canonical nested cwd")));
assert!(root_paths.contains(&worktree_git_dir.canonicalize().expect("canonical gitdir")));
assert!(root_paths.contains(&common_git_dir.canonicalize().expect("canonical common git")));
}
#[test]
fn workspace_write_rejects_non_reciprocal_git_worktree_metadata() {
let tmp = tempfile::tempdir().expect("tempdir");
let common_git_dir = tmp.path().join("main-repo").join(".git");
let worktree_git_dir = common_git_dir.join("worktrees").join("feature");
let worktree = tmp.path().join("feature-worktree");
let other_worktree = tmp.path().join("other-worktree");
std::fs::create_dir_all(&worktree_git_dir).expect("mkdir gitdir");
std::fs::create_dir_all(&worktree).expect("mkdir worktree");
std::fs::create_dir_all(&other_worktree).expect("mkdir other worktree");
std::fs::write(
worktree.join(".git"),
format!("gitdir: {}\n", worktree_git_dir.display()),
)
.expect("write git pointer");
std::fs::write(worktree_git_dir.join("commondir"), "../..").expect("write commondir");
std::fs::write(
worktree_git_dir.join("gitdir"),
other_worktree.join(".git").display().to_string(),
)
.expect("write mismatched gitdir back pointer");
std::fs::write(
other_worktree.join(".git"),
"gitdir: /tmp/not-this-worktree\n",
)
.expect("write other git pointer");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![worktree.clone()],
network_access: true,
exclude_tmpdir: true,
exclude_slash_tmp: true,
};
let root_paths: Vec<PathBuf> = policy
.get_writable_roots(&worktree)
.into_iter()
.map(|root| root.root)
.collect();
assert!(root_paths.contains(&worktree.canonicalize().expect("canonical worktree")));
assert!(!root_paths.contains(&worktree_git_dir.canonicalize().expect("canonical gitdir")));
assert!(
!root_paths.contains(&common_git_dir.canonicalize().expect("canonical common git"))
);
}
#[test]
fn test_writable_root_basic() {
let root = WritableRoot::new(PathBuf::from("/project"));
assert!(root.is_path_writable(Path::new("/project/src/main.rs")));
assert!(!root.is_path_writable(Path::new("/other/file.txt")));
}
#[test]
fn test_writable_root_with_exceptions() {
let root = WritableRoot::with_exceptions(
PathBuf::from("/project"),
vec![PathBuf::from("/project/.deepseek")],
);
assert!(root.is_path_writable(Path::new("/project/src/main.rs")));
assert!(!root.is_path_writable(Path::new("/project/.deepseek/config")));
}
#[test]
fn test_safety_level_mapping() {
let default = SandboxPolicy::default();
assert!(matches!(
map_safety_level_to_behavior(SafetyLevel::Safe, &default),
SandboxPolicyBehavior::Sandboxed(_)
));
assert!(matches!(
map_safety_level_to_behavior(SafetyLevel::WorkspaceSafe, &default),
SandboxPolicyBehavior::Sandboxed(_)
));
assert!(matches!(
map_safety_level_to_behavior(SafetyLevel::RequiresApproval, &default),
SandboxPolicyBehavior::RequiresApproval
));
assert!(matches!(
map_safety_level_to_behavior(SafetyLevel::Dangerous, &default),
SandboxPolicyBehavior::Blocked
));
}
#[test]
fn test_policy_serialization() {
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![PathBuf::from("/extra")],
network_access: true,
exclude_tmpdir: false,
exclude_slash_tmp: false,
};
let json = serde_json::to_string(&policy).unwrap();
assert!(json.contains("workspace-write"));
let parsed: SandboxPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(policy, parsed);
}
}