sandbox.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. use std::env;
  2. use std::fs;
  3. use std::path::{Path, PathBuf};
  4. use serde::{Deserialize, Serialize};
  5. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
  6. #[serde(rename_all = "kebab-case")]
  7. pub enum FilesystemIsolationMode {
  8. Off,
  9. #[default]
  10. WorkspaceOnly,
  11. AllowList,
  12. }
  13. impl FilesystemIsolationMode {
  14. #[must_use]
  15. pub fn as_str(self) -> &'static str {
  16. match self {
  17. Self::Off => "off",
  18. Self::WorkspaceOnly => "workspace-only",
  19. Self::AllowList => "allow-list",
  20. }
  21. }
  22. }
  23. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
  24. pub struct SandboxConfig {
  25. pub enabled: Option<bool>,
  26. pub namespace_restrictions: Option<bool>,
  27. pub network_isolation: Option<bool>,
  28. pub filesystem_mode: Option<FilesystemIsolationMode>,
  29. pub allowed_mounts: Vec<String>,
  30. }
  31. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
  32. pub struct SandboxRequest {
  33. pub enabled: bool,
  34. pub namespace_restrictions: bool,
  35. pub network_isolation: bool,
  36. pub filesystem_mode: FilesystemIsolationMode,
  37. pub allowed_mounts: Vec<String>,
  38. }
  39. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
  40. pub struct ContainerEnvironment {
  41. pub in_container: bool,
  42. pub markers: Vec<String>,
  43. }
  44. #[allow(clippy::struct_excessive_bools)]
  45. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
  46. pub struct SandboxStatus {
  47. pub enabled: bool,
  48. pub requested: SandboxRequest,
  49. pub supported: bool,
  50. pub active: bool,
  51. pub namespace_supported: bool,
  52. pub namespace_active: bool,
  53. pub network_supported: bool,
  54. pub network_active: bool,
  55. pub filesystem_mode: FilesystemIsolationMode,
  56. pub filesystem_active: bool,
  57. pub allowed_mounts: Vec<String>,
  58. pub in_container: bool,
  59. pub container_markers: Vec<String>,
  60. pub fallback_reason: Option<String>,
  61. }
  62. #[derive(Debug, Clone, PartialEq, Eq)]
  63. pub struct SandboxDetectionInputs<'a> {
  64. pub env_pairs: Vec<(String, String)>,
  65. pub dockerenv_exists: bool,
  66. pub containerenv_exists: bool,
  67. pub proc_1_cgroup: Option<&'a str>,
  68. }
  69. #[derive(Debug, Clone, PartialEq, Eq)]
  70. pub struct LinuxSandboxCommand {
  71. pub program: String,
  72. pub args: Vec<String>,
  73. pub env: Vec<(String, String)>,
  74. }
  75. impl SandboxConfig {
  76. #[must_use]
  77. pub fn resolve_request(
  78. &self,
  79. enabled_override: Option<bool>,
  80. namespace_override: Option<bool>,
  81. network_override: Option<bool>,
  82. filesystem_mode_override: Option<FilesystemIsolationMode>,
  83. allowed_mounts_override: Option<Vec<String>>,
  84. ) -> SandboxRequest {
  85. SandboxRequest {
  86. enabled: enabled_override.unwrap_or(self.enabled.unwrap_or(true)),
  87. namespace_restrictions: namespace_override
  88. .unwrap_or(self.namespace_restrictions.unwrap_or(true)),
  89. network_isolation: network_override.unwrap_or(self.network_isolation.unwrap_or(false)),
  90. filesystem_mode: filesystem_mode_override
  91. .or(self.filesystem_mode)
  92. .unwrap_or_default(),
  93. allowed_mounts: allowed_mounts_override.unwrap_or_else(|| self.allowed_mounts.clone()),
  94. }
  95. }
  96. }
  97. #[must_use]
  98. pub fn detect_container_environment() -> ContainerEnvironment {
  99. let proc_1_cgroup = fs::read_to_string("/proc/1/cgroup").ok();
  100. detect_container_environment_from(SandboxDetectionInputs {
  101. env_pairs: env::vars().collect(),
  102. dockerenv_exists: Path::new("/.dockerenv").exists(),
  103. containerenv_exists: Path::new("/run/.containerenv").exists(),
  104. proc_1_cgroup: proc_1_cgroup.as_deref(),
  105. })
  106. }
  107. #[must_use]
  108. pub fn detect_container_environment_from(
  109. inputs: SandboxDetectionInputs<'_>,
  110. ) -> ContainerEnvironment {
  111. let mut markers = Vec::new();
  112. if inputs.dockerenv_exists {
  113. markers.push("/.dockerenv".to_string());
  114. }
  115. if inputs.containerenv_exists {
  116. markers.push("/run/.containerenv".to_string());
  117. }
  118. for (key, value) in inputs.env_pairs {
  119. let normalized = key.to_ascii_lowercase();
  120. if matches!(
  121. normalized.as_str(),
  122. "container" | "docker" | "podman" | "kubernetes_service_host"
  123. ) && !value.is_empty()
  124. {
  125. markers.push(format!("env:{key}={value}"));
  126. }
  127. }
  128. if let Some(cgroup) = inputs.proc_1_cgroup {
  129. for needle in ["docker", "containerd", "kubepods", "podman", "libpod"] {
  130. if cgroup.contains(needle) {
  131. markers.push(format!("/proc/1/cgroup:{needle}"));
  132. }
  133. }
  134. }
  135. markers.sort();
  136. markers.dedup();
  137. ContainerEnvironment {
  138. in_container: !markers.is_empty(),
  139. markers,
  140. }
  141. }
  142. #[must_use]
  143. pub fn resolve_sandbox_status(config: &SandboxConfig, cwd: &Path) -> SandboxStatus {
  144. let request = config.resolve_request(None, None, None, None, None);
  145. resolve_sandbox_status_for_request(&request, cwd)
  146. }
  147. #[must_use]
  148. pub fn resolve_sandbox_status_for_request(request: &SandboxRequest, cwd: &Path) -> SandboxStatus {
  149. let container = detect_container_environment();
  150. let namespace_supported = cfg!(target_os = "linux") && unshare_user_namespace_works();
  151. let network_supported = namespace_supported;
  152. let filesystem_active =
  153. request.enabled && request.filesystem_mode != FilesystemIsolationMode::Off;
  154. let mut fallback_reasons = Vec::new();
  155. if request.enabled && request.namespace_restrictions && !namespace_supported {
  156. fallback_reasons
  157. .push("namespace isolation unavailable (requires Linux with `unshare`)".to_string());
  158. }
  159. if request.enabled && request.network_isolation && !network_supported {
  160. fallback_reasons
  161. .push("network isolation unavailable (requires Linux with `unshare`)".to_string());
  162. }
  163. if request.enabled
  164. && request.filesystem_mode == FilesystemIsolationMode::AllowList
  165. && request.allowed_mounts.is_empty()
  166. {
  167. fallback_reasons
  168. .push("filesystem allow-list requested without configured mounts".to_string());
  169. }
  170. let active = request.enabled
  171. && (!request.namespace_restrictions || namespace_supported)
  172. && (!request.network_isolation || network_supported);
  173. let allowed_mounts = normalize_mounts(&request.allowed_mounts, cwd);
  174. SandboxStatus {
  175. enabled: request.enabled,
  176. requested: request.clone(),
  177. supported: namespace_supported,
  178. active,
  179. namespace_supported,
  180. namespace_active: request.enabled && request.namespace_restrictions && namespace_supported,
  181. network_supported,
  182. network_active: request.enabled && request.network_isolation && network_supported,
  183. filesystem_mode: request.filesystem_mode,
  184. filesystem_active,
  185. allowed_mounts,
  186. in_container: container.in_container,
  187. container_markers: container.markers,
  188. fallback_reason: (!fallback_reasons.is_empty()).then(|| fallback_reasons.join("; ")),
  189. }
  190. }
  191. #[must_use]
  192. pub fn build_linux_sandbox_command(
  193. command: &str,
  194. cwd: &Path,
  195. status: &SandboxStatus,
  196. ) -> Option<LinuxSandboxCommand> {
  197. if !cfg!(target_os = "linux")
  198. || !status.enabled
  199. || (!status.namespace_active && !status.network_active)
  200. {
  201. return None;
  202. }
  203. let mut args = vec![
  204. "--user".to_string(),
  205. "--map-root-user".to_string(),
  206. "--mount".to_string(),
  207. "--ipc".to_string(),
  208. "--pid".to_string(),
  209. "--uts".to_string(),
  210. "--fork".to_string(),
  211. ];
  212. if status.network_active {
  213. args.push("--net".to_string());
  214. }
  215. args.push("sh".to_string());
  216. args.push("-lc".to_string());
  217. args.push(command.to_string());
  218. let sandbox_home = cwd.join(".sandbox-home");
  219. let sandbox_tmp = cwd.join(".sandbox-tmp");
  220. let mut env = vec![
  221. ("HOME".to_string(), sandbox_home.display().to_string()),
  222. ("TMPDIR".to_string(), sandbox_tmp.display().to_string()),
  223. (
  224. "CLAWD_SANDBOX_FILESYSTEM_MODE".to_string(),
  225. status.filesystem_mode.as_str().to_string(),
  226. ),
  227. (
  228. "CLAWD_SANDBOX_ALLOWED_MOUNTS".to_string(),
  229. status.allowed_mounts.join(":"),
  230. ),
  231. ];
  232. if let Ok(path) = env::var("PATH") {
  233. env.push(("PATH".to_string(), path));
  234. }
  235. Some(LinuxSandboxCommand {
  236. program: "unshare".to_string(),
  237. args,
  238. env,
  239. })
  240. }
  241. fn normalize_mounts(mounts: &[String], cwd: &Path) -> Vec<String> {
  242. let cwd = cwd.to_path_buf();
  243. mounts
  244. .iter()
  245. .map(|mount| {
  246. let path = PathBuf::from(mount);
  247. if path.is_absolute() {
  248. path
  249. } else {
  250. cwd.join(path)
  251. }
  252. })
  253. .map(|path| path.display().to_string())
  254. .collect()
  255. }
  256. fn command_exists(command: &str) -> bool {
  257. env::var_os("PATH")
  258. .is_some_and(|paths| env::split_paths(&paths).any(|path| path.join(command).exists()))
  259. }
  260. /// Check whether `unshare --user` actually works on this system.
  261. /// On some CI environments (e.g. GitHub Actions), the binary exists but
  262. /// user namespaces are restricted, causing silent failures.
  263. fn unshare_user_namespace_works() -> bool {
  264. use std::sync::OnceLock;
  265. static RESULT: OnceLock<bool> = OnceLock::new();
  266. *RESULT.get_or_init(|| {
  267. if !command_exists("unshare") {
  268. return false;
  269. }
  270. std::process::Command::new("unshare")
  271. .args(["--user", "--map-root-user", "true"])
  272. .stdin(std::process::Stdio::null())
  273. .stdout(std::process::Stdio::null())
  274. .stderr(std::process::Stdio::null())
  275. .status()
  276. .map(|s| s.success())
  277. .unwrap_or(false)
  278. })
  279. }
  280. #[cfg(test)]
  281. mod tests {
  282. use super::{
  283. build_linux_sandbox_command, detect_container_environment_from, FilesystemIsolationMode,
  284. SandboxConfig, SandboxDetectionInputs,
  285. };
  286. use std::path::Path;
  287. #[test]
  288. fn detects_container_markers_from_multiple_sources() {
  289. let detected = detect_container_environment_from(SandboxDetectionInputs {
  290. env_pairs: vec![("container".to_string(), "docker".to_string())],
  291. dockerenv_exists: true,
  292. containerenv_exists: false,
  293. proc_1_cgroup: Some("12:memory:/docker/abc"),
  294. });
  295. assert!(detected.in_container);
  296. assert!(detected
  297. .markers
  298. .iter()
  299. .any(|marker| marker == "/.dockerenv"));
  300. assert!(detected
  301. .markers
  302. .iter()
  303. .any(|marker| marker == "env:container=docker"));
  304. assert!(detected
  305. .markers
  306. .iter()
  307. .any(|marker| marker == "/proc/1/cgroup:docker"));
  308. }
  309. #[test]
  310. fn resolves_request_with_overrides() {
  311. let config = SandboxConfig {
  312. enabled: Some(true),
  313. namespace_restrictions: Some(true),
  314. network_isolation: Some(false),
  315. filesystem_mode: Some(FilesystemIsolationMode::WorkspaceOnly),
  316. allowed_mounts: vec!["logs".to_string()],
  317. };
  318. let request = config.resolve_request(
  319. Some(true),
  320. Some(false),
  321. Some(true),
  322. Some(FilesystemIsolationMode::AllowList),
  323. Some(vec!["tmp".to_string()]),
  324. );
  325. assert!(request.enabled);
  326. assert!(!request.namespace_restrictions);
  327. assert!(request.network_isolation);
  328. assert_eq!(request.filesystem_mode, FilesystemIsolationMode::AllowList);
  329. assert_eq!(request.allowed_mounts, vec!["tmp"]);
  330. }
  331. #[test]
  332. fn builds_linux_launcher_with_network_flag_when_requested() {
  333. let config = SandboxConfig::default();
  334. let status = super::resolve_sandbox_status_for_request(
  335. &config.resolve_request(
  336. Some(true),
  337. Some(true),
  338. Some(true),
  339. Some(FilesystemIsolationMode::WorkspaceOnly),
  340. None,
  341. ),
  342. Path::new("/workspace"),
  343. );
  344. if let Some(launcher) =
  345. build_linux_sandbox_command("printf hi", Path::new("/workspace"), &status)
  346. {
  347. assert_eq!(launcher.program, "unshare");
  348. assert!(launcher.args.iter().any(|arg| arg == "--mount"));
  349. assert!(launcher.args.iter().any(|arg| arg == "--net") == status.network_active);
  350. }
  351. }
  352. }