sandbox.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  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") && command_exists("unshare");
  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. #[cfg(test)]
  261. mod tests {
  262. use super::{
  263. build_linux_sandbox_command, detect_container_environment_from, FilesystemIsolationMode,
  264. SandboxConfig, SandboxDetectionInputs,
  265. };
  266. use std::path::Path;
  267. #[test]
  268. fn detects_container_markers_from_multiple_sources() {
  269. let detected = detect_container_environment_from(SandboxDetectionInputs {
  270. env_pairs: vec![("container".to_string(), "docker".to_string())],
  271. dockerenv_exists: true,
  272. containerenv_exists: false,
  273. proc_1_cgroup: Some("12:memory:/docker/abc"),
  274. });
  275. assert!(detected.in_container);
  276. assert!(detected
  277. .markers
  278. .iter()
  279. .any(|marker| marker == "/.dockerenv"));
  280. assert!(detected
  281. .markers
  282. .iter()
  283. .any(|marker| marker == "env:container=docker"));
  284. assert!(detected
  285. .markers
  286. .iter()
  287. .any(|marker| marker == "/proc/1/cgroup:docker"));
  288. }
  289. #[test]
  290. fn resolves_request_with_overrides() {
  291. let config = SandboxConfig {
  292. enabled: Some(true),
  293. namespace_restrictions: Some(true),
  294. network_isolation: Some(false),
  295. filesystem_mode: Some(FilesystemIsolationMode::WorkspaceOnly),
  296. allowed_mounts: vec!["logs".to_string()],
  297. };
  298. let request = config.resolve_request(
  299. Some(true),
  300. Some(false),
  301. Some(true),
  302. Some(FilesystemIsolationMode::AllowList),
  303. Some(vec!["tmp".to_string()]),
  304. );
  305. assert!(request.enabled);
  306. assert!(!request.namespace_restrictions);
  307. assert!(request.network_isolation);
  308. assert_eq!(request.filesystem_mode, FilesystemIsolationMode::AllowList);
  309. assert_eq!(request.allowed_mounts, vec!["tmp"]);
  310. }
  311. #[test]
  312. fn builds_linux_launcher_with_network_flag_when_requested() {
  313. let config = SandboxConfig::default();
  314. let status = super::resolve_sandbox_status_for_request(
  315. &config.resolve_request(
  316. Some(true),
  317. Some(true),
  318. Some(true),
  319. Some(FilesystemIsolationMode::WorkspaceOnly),
  320. None,
  321. ),
  322. Path::new("/workspace"),
  323. );
  324. if let Some(launcher) =
  325. build_linux_sandbox_command("printf hi", Path::new("/workspace"), &status)
  326. {
  327. assert_eq!(launcher.program, "unshare");
  328. assert!(launcher.args.iter().any(|arg| arg == "--mount"));
  329. assert!(launcher.args.iter().any(|arg| arg == "--net") == status.network_active);
  330. }
  331. }
  332. }