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") && unshare_user_namespace_works(); 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 = working_unshare_mapping() .unwrap_or(UNSHARE_MAPPING_CANDIDATES[0]) .iter() .map(|arg| arg.to_string()) .collect(); // The candidates already carry the namespace flags, so the probe and the // launcher share a single argument shape; only the opt-in `--net` is // added here. 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())) } /// Candidate `unshare` user-namespace mapping options, in preference order. /// /// Most systems accept `--map-root-user` alone. Some hardened containers and /// seccomp profiles block unprivileged writes to `/proc/self/uid_map`; there, /// util-linux delegates to the setuid `newuidmap`/`newgidmap` helpers when /// `--map-auto` is also present. /// /// That fallback therefore depends on the setuid helpers (the `uidmap` /// package on Debian/Ubuntu) and on the current user having a range in /// `/etc/subuid` and `/etc/subgid`. When either is missing, `--map-auto` /// fails and the startup probe rejects the candidate, keeping the plain form. /// /// Each candidate is the **complete** static argument shape the launcher /// uses (see `build_linux_sandbox_command`): mapping flags followed by the /// namespace flags `--mount --ipc --pid --uts --fork`. The startup probe /// runs each candidate verbatim (plus a trivial program), so probe success /// implies launch success: on systems where the mapping works but the /// namespace flags are denied (e.g. AppArmor-restricted CI runners that /// block mount propagation in user namespaces), the probe fails and the /// sandbox stays disabled instead of activating a launcher that always /// errors. /// /// `--net` is intentionally absent: it is appended only when network /// isolation is active (the non-default path), and probing with it would /// disable the sandbox on hosts that block network-namespace creation (e.g. /// Docker's default seccomp profile) even when network isolation is never /// requested. const UNSHARE_MAPPING_CANDIDATES: &[&[&str]] = &[ &[ "--user", "--map-root-user", "--mount", "--ipc", "--pid", "--uts", "--fork", ], &[ "--user", "--map-root-user", "--map-auto", "--mount", "--ipc", "--pid", "--uts", "--fork", ], ]; /// Probe a candidate `unshare` mapping invocation with a trivial program. fn unshare_probe(args: &[&str]) -> bool { std::process::Command::new("unshare") .args(args) .arg("true") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .is_ok_and(|status| status.success()) } /// The first mapping option set that works on this machine, if any. /// /// Probes are cached for the process lifetime; a missing `unshare` binary or a /// kernel that refuses every mapping yields `None`. fn working_unshare_mapping() -> Option<&'static [&'static str]> { use std::sync::OnceLock; static MAPPING: OnceLock> = OnceLock::new(); *MAPPING.get_or_init(|| { UNSHARE_MAPPING_CANDIDATES .iter() .copied() .find(|args| unshare_probe(args)) }) } /// Check whether `unshare --user` actually works on this system. /// On some CI environments (e.g. GitHub Actions), the binary exists but /// user namespaces are restricted, causing silent failures. fn unshare_user_namespace_works() -> bool { use std::sync::OnceLock; static RESULT: OnceLock = OnceLock::new(); *RESULT.get_or_init(|| { if !command_exists("unshare") { return false; } working_unshare_mapping().is_some() }) } #[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 mapping_candidates_prefer_plain_root_mapping() { assert!(!super::UNSHARE_MAPPING_CANDIDATES.is_empty()); for candidate in super::UNSHARE_MAPPING_CANDIDATES { // Mapping flags. assert!(candidate.contains(&"--user")); assert!(candidate.contains(&"--map-root-user")); // Namespace flags the real launcher appends — the probe must // exercise the full invocation shape, not just mapping flags. assert!(candidate.contains(&"--mount")); assert!(candidate.contains(&"--ipc")); assert!(candidate.contains(&"--pid")); assert!(candidate.contains(&"--uts")); assert!(candidate.contains(&"--fork")); } // The plain form must be tried first; `--map-auto` is only a fallback // for kernels/containers that block unprivileged uid_map writes. assert_eq!( super::UNSHARE_MAPPING_CANDIDATES[0], &[ "--user", "--map-root-user", "--mount", "--ipc", "--pid", "--uts", "--fork", ] ); // The second candidate inserts `--map-auto` in the position util-linux // expects (after `--map-root-user`, before the namespace flags). assert_eq!( super::UNSHARE_MAPPING_CANDIDATES[1], &[ "--user", "--map-root-user", "--map-auto", "--mount", "--ipc", "--pid", "--uts", "--fork", ] ); } #[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); } } }