1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
8#[serde(rename_all = "kebab-case")]
9pub enum FilesystemIsolationMode {
10 Off,
11 #[default]
12 WorkspaceOnly,
13 AllowList,
14}
15
16impl FilesystemIsolationMode {
17 #[must_use]
18 pub fn as_str(self) -> &'static str {
19 match self {
20 Self::Off => "off",
21 Self::WorkspaceOnly => "workspace-only",
22 Self::AllowList => "allow-list",
23 }
24 }
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
28pub struct SandboxConfig {
29 pub enabled: Option<bool>,
30 pub namespace_restrictions: Option<bool>,
31 pub network_isolation: Option<bool>,
32 pub filesystem_mode: Option<FilesystemIsolationMode>,
33 pub allowed_mounts: Vec<String>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
37pub struct SandboxRequest {
38 pub enabled: bool,
39 pub namespace_restrictions: bool,
40 pub network_isolation: bool,
41 pub filesystem_mode: FilesystemIsolationMode,
42 pub allowed_mounts: Vec<String>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
46pub struct ContainerEnvironment {
47 pub in_container: bool,
48 pub markers: Vec<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
52pub struct FeatureStatus {
53 pub supported: bool,
54 pub active: bool,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
58pub struct SandboxStatus {
59 pub enabled: bool,
60 pub requested: SandboxRequest,
61 pub sandbox: FeatureStatus,
62 pub namespace: FeatureStatus,
63 pub network: FeatureStatus,
64 pub filesystem_mode: FilesystemIsolationMode,
65 pub filesystem_active: bool,
66 pub allowed_mounts: Vec<String>,
67 pub in_container: bool,
68 pub container_markers: Vec<String>,
69 pub fallback_reason: Option<String>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct SandboxDetectionInputs<'a> {
74 pub env_pairs: Vec<(String, String)>,
75 pub dockerenv_exists: bool,
76 pub containerenv_exists: bool,
77 pub proc_1_cgroup: Option<&'a str>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct SandboxCommand {
82 pub program: String,
83 pub args: Vec<String>,
84 pub env: Vec<(String, String)>,
85}
86
87impl SandboxConfig {
88 #[must_use]
89 pub fn resolve_request(
90 &self,
91 enabled_override: Option<bool>,
92 namespace_override: Option<bool>,
93 network_override: Option<bool>,
94 filesystem_mode_override: Option<FilesystemIsolationMode>,
95 allowed_mounts_override: Option<Vec<String>>,
96 ) -> SandboxRequest {
97 SandboxRequest {
98 enabled: enabled_override.unwrap_or(self.enabled.unwrap_or(true)),
99 namespace_restrictions: namespace_override
100 .unwrap_or(self.namespace_restrictions.unwrap_or(true)),
101 network_isolation: network_override.unwrap_or(self.network_isolation.unwrap_or(false)),
102 filesystem_mode: filesystem_mode_override
103 .or(self.filesystem_mode)
104 .unwrap_or_default(),
105 allowed_mounts: allowed_mounts_override.unwrap_or_else(|| self.allowed_mounts.clone()),
106 }
107 }
108}
109
110#[must_use]
111pub fn detect_container_environment() -> ContainerEnvironment {
112 let proc_1_cgroup = fs::read_to_string("/proc/1/cgroup").ok();
113 detect_container_environment_from(SandboxDetectionInputs {
114 env_pairs: env::vars().collect(),
115 dockerenv_exists: Path::new("/.dockerenv").exists(),
116 containerenv_exists: Path::new("/run/.containerenv").exists(),
117 proc_1_cgroup: proc_1_cgroup.as_deref(),
118 })
119}
120
121#[must_use]
122pub fn detect_container_environment_from(
123 inputs: SandboxDetectionInputs<'_>,
124) -> ContainerEnvironment {
125 let mut markers = Vec::new();
126 if inputs.dockerenv_exists {
127 markers.push("/.dockerenv".to_string());
128 }
129 if inputs.containerenv_exists {
130 markers.push("/run/.containerenv".to_string());
131 }
132 for (key, value) in inputs.env_pairs {
133 let normalized = key.to_ascii_lowercase();
134 if matches!(
135 normalized.as_str(),
136 "container" | "docker" | "podman" | "kubernetes_service_host"
137 ) && !value.is_empty()
138 {
139 markers.push(format!("env:{key}={value}"));
140 }
141 }
142 if let Some(cgroup) = inputs.proc_1_cgroup {
143 for needle in ["docker", "containerd", "kubepods", "podman", "libpod"] {
144 if cgroup.contains(needle) {
145 markers.push(format!("/proc/1/cgroup:{needle}"));
146 }
147 }
148 }
149 markers.sort();
150 markers.dedup();
151 ContainerEnvironment {
152 in_container: !markers.is_empty(),
153 markers,
154 }
155}
156
157#[must_use]
158pub fn resolve_sandbox_status(config: &SandboxConfig, cwd: &Path) -> SandboxStatus {
159 let request = config.resolve_request(None, None, None, None, None);
160 resolve_sandbox_status_for_request(&request, cwd)
161}
162
163#[must_use]
164pub fn resolve_sandbox_status_for_request(request: &SandboxRequest, cwd: &Path) -> SandboxStatus {
165 let container = detect_container_environment();
166 let linux_supported = cfg!(target_os = "linux") && command_exists("unshare");
167 let macos_supported = cfg!(target_os = "macos") && command_exists("sandbox-exec");
168 let namespace_supported = linux_supported || macos_supported;
169 let network_supported = namespace_supported;
170 let filesystem_active =
171 request.enabled && request.filesystem_mode != FilesystemIsolationMode::Off;
172 let mut fallback_reasons = Vec::new();
173
174 if request.enabled && request.namespace_restrictions && !namespace_supported {
175 fallback_reasons.push(
176 "process isolation unavailable (requires Linux `unshare` or macOS `sandbox-exec`)"
177 .to_string(),
178 );
179 }
180 if request.enabled && request.network_isolation && !network_supported {
181 fallback_reasons.push(
182 "network isolation unavailable (requires Linux `unshare` or macOS `sandbox-exec`)"
183 .to_string(),
184 );
185 }
186 if request.enabled
187 && request.filesystem_mode == FilesystemIsolationMode::AllowList
188 && request.allowed_mounts.is_empty()
189 {
190 fallback_reasons
191 .push("filesystem allow-list requested without configured mounts".to_string());
192 }
193
194 let active = request.enabled
195 && (!request.namespace_restrictions || namespace_supported)
196 && (!request.network_isolation || network_supported);
197
198 let allowed_mounts = normalize_mounts(&request.allowed_mounts, cwd);
199
200 SandboxStatus {
201 enabled: request.enabled,
202 requested: request.clone(),
203 sandbox: FeatureStatus {
204 supported: namespace_supported,
205 active,
206 },
207 namespace: FeatureStatus {
208 supported: namespace_supported,
209 active: request.enabled && request.namespace_restrictions && namespace_supported,
210 },
211 network: FeatureStatus {
212 supported: network_supported,
213 active: request.enabled && request.network_isolation && network_supported,
214 },
215 filesystem_mode: request.filesystem_mode,
216 filesystem_active,
217 allowed_mounts,
218 in_container: container.in_container,
219 container_markers: container.markers,
220 fallback_reason: (!fallback_reasons.is_empty()).then(|| fallback_reasons.join("; ")),
221 }
222}
223
224#[must_use]
225pub fn build_sandbox_command(
226 command: &str,
227 cwd: &Path,
228 status: &SandboxStatus,
229) -> Option<SandboxCommand> {
230 if !status.enabled || (!status.namespace.active && !status.network.active) {
231 return None;
232 }
233
234 if cfg!(target_os = "linux") {
235 build_linux_sandbox_command(command, cwd, status)
236 } else if cfg!(target_os = "macos") {
237 build_macos_sandbox_command(command, cwd, status)
238 } else {
239 None
240 }
241}
242
243fn build_linux_sandbox_command(
244 command: &str,
245 cwd: &Path,
246 status: &SandboxStatus,
247) -> Option<SandboxCommand> {
248 if !command_exists("unshare") {
249 return None;
250 }
251
252 let mut args = vec![
253 "--user".to_string(),
254 "--map-root-user".to_string(),
255 "--mount".to_string(),
256 "--ipc".to_string(),
257 "--pid".to_string(),
258 "--uts".to_string(),
259 "--fork".to_string(),
260 ];
261 if status.network.active {
262 args.push("--net".to_string());
263 }
264 args.push("sh".to_string());
265 args.push("-lc".to_string());
266 args.push(command.to_string());
267
268 Some(SandboxCommand {
269 program: "unshare".to_string(),
270 args,
271 env: sandbox_env(cwd, status),
272 })
273}
274
275fn build_macos_sandbox_command(
276 command: &str,
277 cwd: &Path,
278 status: &SandboxStatus,
279) -> Option<SandboxCommand> {
280 if !command_exists("sandbox-exec") {
281 return None;
282 }
283
284 let profile = generate_seatbelt_profile(cwd, status);
285 let args = vec![
286 "-p".to_string(),
287 profile,
288 "sh".to_string(),
289 "-lc".to_string(),
290 command.to_string(),
291 ];
292
293 Some(SandboxCommand {
294 program: "sandbox-exec".to_string(),
295 args,
296 env: sandbox_env(cwd, status),
297 })
298}
299
300fn sandbox_env(cwd: &Path, status: &SandboxStatus) -> Vec<(String, String)> {
301 let sandbox_home = cwd.join(".sandbox-home");
302 let sandbox_tmp = cwd.join(".sandbox-tmp");
303 let mut env = vec![
304 ("HOME".to_string(), sandbox_home.display().to_string()),
305 ("TMPDIR".to_string(), sandbox_tmp.display().to_string()),
306 (
307 "CODINEER_SANDBOX_FILESYSTEM_MODE".to_string(),
308 status.filesystem_mode.as_str().to_string(),
309 ),
310 (
311 "CODINEER_SANDBOX_ALLOWED_MOUNTS".to_string(),
312 status.allowed_mounts.join(":"),
313 ),
314 ];
315 if let Ok(path) = env::var("PATH") {
316 env.push(("PATH".to_string(), path));
317 }
318 env
319}
320
321#[must_use]
322pub fn generate_seatbelt_profile(cwd: &Path, status: &SandboxStatus) -> String {
323 fn escape_seatbelt_path(path: &str) -> String {
324 path.replace('\\', "\\\\").replace('"', "\\\"")
325 }
326
327 let cwd_str = escape_seatbelt_path(&cwd.display().to_string());
328 let sandbox_home = cwd.join(".sandbox-home");
329 let sandbox_tmp = cwd.join(".sandbox-tmp");
330
331 let mut rules = vec![
332 "(version 1)".to_string(),
333 "(deny default)".to_string(),
334 "(allow process-exec*)".to_string(),
335 "(allow process-fork)".to_string(),
336 "(allow sysctl-read)".to_string(),
337 "(allow mach-lookup)".to_string(),
338 "(allow signal (target self))".to_string(),
339 "(allow ipc-posix-shm*)".to_string(),
340 "(allow file-read* (subpath \"/usr\"))".to_string(),
341 "(allow file-read* (subpath \"/bin\"))".to_string(),
342 "(allow file-read* (subpath \"/sbin\"))".to_string(),
343 "(allow file-read* (subpath \"/Library\"))".to_string(),
344 "(allow file-read* (subpath \"/System\"))".to_string(),
345 "(allow file-read* (subpath \"/private\"))".to_string(),
346 "(allow file-read* (subpath \"/dev\"))".to_string(),
347 "(allow file-read* (subpath \"/var\"))".to_string(),
348 "(allow file-read* (subpath \"/etc\"))".to_string(),
349 "(allow file-read* (subpath \"/opt\"))".to_string(),
350 "(allow file-read* (subpath \"/tmp\"))".to_string(),
351 "(allow file-read* (subpath \"/Applications\"))".to_string(),
352 ];
353
354 if let Some(home) = env::var_os("HOME") {
355 let home_str = home
356 .to_string_lossy()
357 .replace('\\', "\\\\")
358 .replace('"', "\\\"");
359 rules.push(format!(
360 "(allow file-read* (subpath \"{home_str}/.cargo\"))"
361 ));
362 rules.push(format!(
363 "(allow file-read* (subpath \"{home_str}/.rustup\"))"
364 ));
365 }
366
367 match status.filesystem_mode {
368 FilesystemIsolationMode::Off => {
369 rules.push("(allow file-read*)".to_string());
370 rules.push("(allow file-write*)".to_string());
371 }
372 FilesystemIsolationMode::WorkspaceOnly => {
373 let sh = escape_seatbelt_path(&sandbox_home.display().to_string());
374 let st = escape_seatbelt_path(&sandbox_tmp.display().to_string());
375 rules.push(format!("(allow file-read* (subpath \"{cwd_str}\"))"));
376 rules.push(format!("(allow file-write* (subpath \"{cwd_str}\"))"));
377 rules.push(format!("(allow file-write* (subpath \"{sh}\"))"));
378 rules.push(format!("(allow file-write* (subpath \"{st}\"))"));
379 }
380 FilesystemIsolationMode::AllowList => {
381 let sh = escape_seatbelt_path(&sandbox_home.display().to_string());
382 let st = escape_seatbelt_path(&sandbox_tmp.display().to_string());
383 rules.push(format!("(allow file-read* (subpath \"{cwd_str}\"))"));
384 rules.push(format!("(allow file-write* (subpath \"{sh}\"))"));
385 rules.push(format!("(allow file-write* (subpath \"{st}\"))"));
386 for mount in &status.allowed_mounts {
387 let escaped = escape_seatbelt_path(mount);
388 rules.push(format!("(allow file-read* (subpath \"{escaped}\"))"));
389 rules.push(format!("(allow file-write* (subpath \"{escaped}\"))"));
390 }
391 }
392 }
393
394 if status.network.active {
395 rules.push("(deny network*)".to_string());
396 } else {
397 rules.push("(allow network*)".to_string());
398 }
399
400 rules.join("\n")
401}
402
403fn normalize_mounts(mounts: &[String], cwd: &Path) -> Vec<String> {
404 let cwd = cwd.to_path_buf();
405 mounts
406 .iter()
407 .map(|mount| {
408 let path = PathBuf::from(mount);
409 if path.is_absolute() {
410 path
411 } else {
412 cwd.join(path)
413 }
414 })
415 .map(|path| path.display().to_string())
416 .collect()
417}
418
419fn command_exists(command: &str) -> bool {
420 env::var_os("PATH")
421 .is_some_and(|paths| env::split_paths(&paths).any(|path| path.join(command).exists()))
422}
423
424#[cfg(test)]
425#[cfg(unix)]
426mod tests {
427 use super::{
428 build_sandbox_command, detect_container_environment_from, generate_seatbelt_profile,
429 FilesystemIsolationMode, SandboxConfig, SandboxDetectionInputs,
430 };
431 use std::path::Path;
432
433 #[test]
434 fn detects_container_markers_from_multiple_sources() {
435 let detected = detect_container_environment_from(SandboxDetectionInputs {
436 env_pairs: vec![("container".to_string(), "docker".to_string())],
437 dockerenv_exists: true,
438 containerenv_exists: false,
439 proc_1_cgroup: Some("12:memory:/docker/abc"),
440 });
441
442 assert!(detected.in_container);
443 assert!(detected
444 .markers
445 .iter()
446 .any(|marker| marker == "/.dockerenv"));
447 assert!(detected
448 .markers
449 .iter()
450 .any(|marker| marker == "env:container=docker"));
451 assert!(detected
452 .markers
453 .iter()
454 .any(|marker| marker == "/proc/1/cgroup:docker"));
455 }
456
457 #[test]
458 fn resolves_request_with_overrides() {
459 let config = SandboxConfig {
460 enabled: Some(true),
461 namespace_restrictions: Some(true),
462 network_isolation: Some(false),
463 filesystem_mode: Some(FilesystemIsolationMode::WorkspaceOnly),
464 allowed_mounts: vec!["logs".to_string()],
465 };
466
467 let request = config.resolve_request(
468 Some(true),
469 Some(false),
470 Some(true),
471 Some(FilesystemIsolationMode::AllowList),
472 Some(vec!["tmp".to_string()]),
473 );
474
475 assert!(request.enabled);
476 assert!(!request.namespace_restrictions);
477 assert!(request.network_isolation);
478 assert_eq!(request.filesystem_mode, FilesystemIsolationMode::AllowList);
479 assert_eq!(request.allowed_mounts, vec!["tmp"]);
480 }
481
482 #[test]
483 fn builds_sandbox_command_for_current_platform() {
484 let config = SandboxConfig::default();
485 let status = super::resolve_sandbox_status_for_request(
486 &config.resolve_request(
487 Some(true),
488 Some(true),
489 Some(true),
490 Some(FilesystemIsolationMode::WorkspaceOnly),
491 None,
492 ),
493 Path::new("/workspace"),
494 );
495
496 if let Some(launcher) = build_sandbox_command("printf hi", Path::new("/workspace"), &status)
497 {
498 if cfg!(target_os = "linux") {
499 assert_eq!(launcher.program, "unshare");
500 assert!(launcher.args.iter().any(|arg| arg == "--mount"));
501 assert!(launcher.args.iter().any(|arg| arg == "--net") == status.network.active);
502 } else if cfg!(target_os = "macos") {
503 assert_eq!(launcher.program, "sandbox-exec");
504 assert!(launcher.args.iter().any(|arg| arg == "-p"));
505 }
506 }
507 }
508
509 #[test]
510 fn seatbelt_profile_denies_by_default() {
511 let config = SandboxConfig::default();
512 let status = super::resolve_sandbox_status_for_request(
513 &config.resolve_request(
514 Some(true),
515 Some(true),
516 Some(false),
517 Some(FilesystemIsolationMode::WorkspaceOnly),
518 None,
519 ),
520 Path::new("/workspace"),
521 );
522 let profile = generate_seatbelt_profile(Path::new("/workspace"), &status);
523 assert!(profile.contains("(deny default)"));
524 assert!(profile.contains("(allow file-write* (subpath \"/workspace\"))"));
525 assert!(profile.contains("(allow network*)"));
526 }
527
528 #[test]
529 fn seatbelt_profile_denies_network_when_isolated() {
530 let config = SandboxConfig::default();
531 let mut status = super::resolve_sandbox_status_for_request(
532 &config.resolve_request(
533 Some(true),
534 Some(true),
535 Some(true),
536 Some(FilesystemIsolationMode::WorkspaceOnly),
537 None,
538 ),
539 Path::new("/workspace"),
540 );
541 status.network.active = true;
542 let profile = generate_seatbelt_profile(Path::new("/workspace"), &status);
543 assert!(profile.contains("(deny network*)"));
544 assert!(!profile.contains("(allow network*)"));
545 }
546
547 #[test]
548 fn seatbelt_profile_allow_list_restricts_writes() {
549 let config = SandboxConfig::default();
550 let status = super::resolve_sandbox_status_for_request(
551 &config.resolve_request(
552 Some(true),
553 Some(true),
554 Some(false),
555 Some(FilesystemIsolationMode::AllowList),
556 Some(vec!["/extra/mount".to_string()]),
557 ),
558 Path::new("/workspace"),
559 );
560 let profile = generate_seatbelt_profile(Path::new("/workspace"), &status);
561 assert!(profile.contains("(allow file-write* (subpath \"/extra/mount\"))"));
562 assert!(!profile.contains("(allow file-write* (subpath \"/workspace\"))"));
563 }
564
565 #[test]
566 fn disabled_sandbox_returns_none() {
567 let config = SandboxConfig::default();
568 let status = super::resolve_sandbox_status_for_request(
569 &config.resolve_request(Some(false), None, None, None, None),
570 Path::new("/workspace"),
571 );
572 assert!(build_sandbox_command("echo hi", Path::new("/workspace"), &status).is_none());
573 }
574}