| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385 |
- 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<bool>,
- pub namespace_restrictions: Option<bool>,
- pub network_isolation: Option<bool>,
- pub filesystem_mode: Option<FilesystemIsolationMode>,
- pub allowed_mounts: Vec<String>,
- }
- #[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<String>,
- }
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
- pub struct ContainerEnvironment {
- pub in_container: bool,
- pub markers: Vec<String>,
- }
- #[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<String>,
- pub in_container: bool,
- pub container_markers: Vec<String>,
- pub fallback_reason: Option<String>,
- }
- #[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<String>,
- pub env: Vec<(String, String)>,
- }
- impl SandboxConfig {
- #[must_use]
- pub fn resolve_request(
- &self,
- enabled_override: Option<bool>,
- namespace_override: Option<bool>,
- network_override: Option<bool>,
- filesystem_mode_override: Option<FilesystemIsolationMode>,
- allowed_mounts_override: Option<Vec<String>>,
- ) -> 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<LinuxSandboxCommand> {
- 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<String> {
- 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()))
- }
- /// 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<bool> = OnceLock::new();
- *RESULT.get_or_init(|| {
- if !command_exists("unshare") {
- return false;
- }
- std::process::Command::new("unshare")
- .args(["--user", "--map-root-user", "true"])
- .stdin(std::process::Stdio::null())
- .stdout(std::process::Stdio::null())
- .stderr(std::process::Stdio::null())
- .status()
- .map(|s| s.success())
- .unwrap_or(false)
- })
- }
- #[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);
- }
- }
- }
|