|
@@ -0,0 +1,364 @@
|
|
|
|
|
+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") && 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<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()))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+#[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);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|