use std::env; use std::fs; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "kebab-case")] pub enum FilesystemIsolationMode { Off, #[default] WorkspaceOnly, AllowList, } impl FilesystemIsolationMode { #[must_use] pub fn as_str(self) -> &'static str { match self { Self::Off => "off", Self::WorkspaceOnly => "workspace-only", Self::AllowList => "allow-list", } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct SandboxConfig { pub enabled: Option, pub namespace_restrictions: Option, pub network_isolation: Option, pub filesystem_mode: Option, pub allowed_mounts: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct SandboxRequest { pub enabled: bool, pub namespace_restrictions: bool, pub network_isolation: bool, pub filesystem_mode: FilesystemIsolationMode, pub allowed_mounts: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct ContainerEnvironment { pub in_container: bool, pub markers: Vec, } #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct SandboxStatus { pub enabled: bool, pub requested: SandboxRequest, pub supported: bool, pub active: bool, pub namespace_supported: bool, pub namespace_active: bool, pub network_supported: bool, pub network_active: bool, pub filesystem_mode: FilesystemIsolationMode, pub filesystem_active: bool, pub allowed_mounts: Vec, pub in_container: bool, pub container_markers: Vec, pub fallback_reason: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct SandboxDetectionInputs<'a> { pub env_pairs: Vec<(String, String)>, pub dockerenv_exists: bool, pub containerenv_exists: bool, pub proc_1_cgroup: Option<&'a str>, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct LinuxSandboxCommand { pub program: String, pub args: Vec, pub env: Vec<(String, String)>, } impl SandboxConfig { #[must_use] pub fn resolve_request( &self, enabled_override: Option, namespace_override: Option, network_override: Option, filesystem_mode_override: Option, allowed_mounts_override: Option>, ) -> SandboxRequest { SandboxRequest { enabled: enabled_override.unwrap_or(self.enabled.unwrap_or(true)), namespace_restrictions: namespace_override .unwrap_or(self.namespace_restrictions.unwrap_or(true)), network_isolation: network_override.unwrap_or(self.network_isolation.unwrap_or(false)), filesystem_mode: filesystem_mode_override .or(self.filesystem_mode) .unwrap_or_default(), allowed_mounts: allowed_mounts_override.unwrap_or_else(|| self.allowed_mounts.clone()), } } } #[must_use] pub fn detect_container_environment() -> ContainerEnvironment { let proc_1_cgroup = fs::read_to_string("/proc/1/cgroup").ok(); detect_container_environment_from(SandboxDetectionInputs { env_pairs: env::vars().collect(), dockerenv_exists: Path::new("/.dockerenv").exists(), containerenv_exists: Path::new("/run/.containerenv").exists(), proc_1_cgroup: proc_1_cgroup.as_deref(), }) } #[must_use] pub fn detect_container_environment_from( inputs: SandboxDetectionInputs<'_>, ) -> ContainerEnvironment { let mut markers = Vec::new(); if inputs.dockerenv_exists { markers.push("/.dockerenv".to_string()); } if inputs.containerenv_exists { markers.push("/run/.containerenv".to_string()); } for (key, value) in inputs.env_pairs { let normalized = key.to_ascii_lowercase(); if matches!( normalized.as_str(), "container" | "docker" | "podman" | "kubernetes_service_host" ) && !value.is_empty() { markers.push(format!("env:{key}={value}")); } } if let Some(cgroup) = inputs.proc_1_cgroup { for needle in ["docker", "containerd", "kubepods", "podman", "libpod"] { if cgroup.contains(needle) { markers.push(format!("/proc/1/cgroup:{needle}")); } } } markers.sort(); markers.dedup(); ContainerEnvironment { in_container: !markers.is_empty(), markers, } } #[must_use] pub fn resolve_sandbox_status(config: &SandboxConfig, cwd: &Path) -> SandboxStatus { let request = config.resolve_request(None, None, None, None, None); resolve_sandbox_status_for_request(&request, cwd) } #[must_use] pub fn resolve_sandbox_status_for_request(request: &SandboxRequest, cwd: &Path) -> SandboxStatus { let container = detect_container_environment(); let namespace_supported = cfg!(target_os = "linux") && command_exists("unshare"); let network_supported = namespace_supported; let filesystem_active = request.enabled && request.filesystem_mode != FilesystemIsolationMode::Off; let mut fallback_reasons = Vec::new(); if request.enabled && request.namespace_restrictions && !namespace_supported { fallback_reasons .push("namespace isolation unavailable (requires Linux with `unshare`)".to_string()); } if request.enabled && request.network_isolation && !network_supported { fallback_reasons .push("network isolation unavailable (requires Linux with `unshare`)".to_string()); } if request.enabled && request.filesystem_mode == FilesystemIsolationMode::AllowList && request.allowed_mounts.is_empty() { fallback_reasons .push("filesystem allow-list requested without configured mounts".to_string()); } let active = request.enabled && (!request.namespace_restrictions || namespace_supported) && (!request.network_isolation || network_supported); let allowed_mounts = normalize_mounts(&request.allowed_mounts, cwd); SandboxStatus { enabled: request.enabled, requested: request.clone(), supported: namespace_supported, active, namespace_supported, namespace_active: request.enabled && request.namespace_restrictions && namespace_supported, network_supported, network_active: request.enabled && request.network_isolation && network_supported, filesystem_mode: request.filesystem_mode, filesystem_active, allowed_mounts, in_container: container.in_container, container_markers: container.markers, fallback_reason: (!fallback_reasons.is_empty()).then(|| fallback_reasons.join("; ")), } } #[must_use] pub fn build_linux_sandbox_command( command: &str, cwd: &Path, status: &SandboxStatus, ) -> Option { if !cfg!(target_os = "linux") || !status.enabled || (!status.namespace_active && !status.network_active) { return None; } let mut args = vec![ "--user".to_string(), "--map-root-user".to_string(), "--mount".to_string(), "--ipc".to_string(), "--pid".to_string(), "--uts".to_string(), "--fork".to_string(), ]; if status.network_active { args.push("--net".to_string()); } args.push("sh".to_string()); args.push("-lc".to_string()); args.push(command.to_string()); let sandbox_home = cwd.join(".sandbox-home"); let sandbox_tmp = cwd.join(".sandbox-tmp"); let mut env = vec![ ("HOME".to_string(), sandbox_home.display().to_string()), ("TMPDIR".to_string(), sandbox_tmp.display().to_string()), ( "CLAWD_SANDBOX_FILESYSTEM_MODE".to_string(), status.filesystem_mode.as_str().to_string(), ), ( "CLAWD_SANDBOX_ALLOWED_MOUNTS".to_string(), status.allowed_mounts.join(":"), ), ]; if let Ok(path) = env::var("PATH") { env.push(("PATH".to_string(), path)); } Some(LinuxSandboxCommand { program: "unshare".to_string(), args, env, }) } fn normalize_mounts(mounts: &[String], cwd: &Path) -> Vec { let cwd = cwd.to_path_buf(); mounts .iter() .map(|mount| { let path = PathBuf::from(mount); if path.is_absolute() { path } else { cwd.join(path) } }) .map(|path| path.display().to_string()) .collect() } fn command_exists(command: &str) -> bool { env::var_os("PATH") .is_some_and(|paths| env::split_paths(&paths).any(|path| path.join(command).exists())) } #[cfg(test)] mod tests { use super::{ build_linux_sandbox_command, detect_container_environment_from, FilesystemIsolationMode, SandboxConfig, SandboxDetectionInputs, }; use std::path::Path; #[test] fn detects_container_markers_from_multiple_sources() { let detected = detect_container_environment_from(SandboxDetectionInputs { env_pairs: vec![("container".to_string(), "docker".to_string())], dockerenv_exists: true, containerenv_exists: false, proc_1_cgroup: Some("12:memory:/docker/abc"), }); assert!(detected.in_container); assert!(detected .markers .iter() .any(|marker| marker == "/.dockerenv")); assert!(detected .markers .iter() .any(|marker| marker == "env:container=docker")); assert!(detected .markers .iter() .any(|marker| marker == "/proc/1/cgroup:docker")); } #[test] fn resolves_request_with_overrides() { let config = SandboxConfig { enabled: Some(true), namespace_restrictions: Some(true), network_isolation: Some(false), filesystem_mode: Some(FilesystemIsolationMode::WorkspaceOnly), allowed_mounts: vec!["logs".to_string()], }; let request = config.resolve_request( Some(true), Some(false), Some(true), Some(FilesystemIsolationMode::AllowList), Some(vec!["tmp".to_string()]), ); assert!(request.enabled); assert!(!request.namespace_restrictions); assert!(request.network_isolation); assert_eq!(request.filesystem_mode, FilesystemIsolationMode::AllowList); assert_eq!(request.allowed_mounts, vec!["tmp"]); } #[test] fn builds_linux_launcher_with_network_flag_when_requested() { let config = SandboxConfig::default(); let status = super::resolve_sandbox_status_for_request( &config.resolve_request( Some(true), Some(true), Some(true), Some(FilesystemIsolationMode::WorkspaceOnly), None, ), Path::new("/workspace"), ); if let Some(launcher) = build_linux_sandbox_command("printf hi", Path::new("/workspace"), &status) { assert_eq!(launcher.program, "unshare"); assert!(launcher.args.iter().any(|arg| arg == "--mount")); assert!(launcher.args.iter().any(|arg| arg == "--net") == status.network_active); } } }