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_dirs(cwd: &Path) -> (std::path::PathBuf, std::path::PathBuf) {
301 let codineer_dir = crate::codineer_runtime_dir(cwd);
302 (
303 codineer_dir.join("sandbox-home"),
304 codineer_dir.join("sandbox-tmp"),
305 )
306}
307
308fn sandbox_env(cwd: &Path, status: &SandboxStatus) -> Vec<(String, String)> {
309 let (sandbox_home, sandbox_tmp) = sandbox_dirs(cwd);
310 let mut env = vec![
311 ("HOME".to_string(), sandbox_home.display().to_string()),
312 ("TMPDIR".to_string(), sandbox_tmp.display().to_string()),
313 (
314 "CODINEER_SANDBOX_FILESYSTEM_MODE".to_string(),
315 status.filesystem_mode.as_str().to_string(),
316 ),
317 (
318 "CODINEER_SANDBOX_ALLOWED_MOUNTS".to_string(),
319 status.allowed_mounts.join(":"),
320 ),
321 ];
322 if let Ok(path) = env::var("PATH") {
323 env.push(("PATH".to_string(), path));
324 }
325 env
326}
327
328#[must_use]
329pub fn generate_seatbelt_profile(cwd: &Path, status: &SandboxStatus) -> String {
330 fn escape_seatbelt_path(path: &str) -> String {
331 path.replace('\\', "\\\\").replace('"', "\\\"")
332 }
333
334 let cwd_str = escape_seatbelt_path(&cwd.display().to_string());
335 let (sandbox_home, sandbox_tmp) = sandbox_dirs(cwd);
336
337 let mut rules = vec![
338 "(version 1)".to_string(),
339 "(deny default)".to_string(),
340 "(allow process-exec*)".to_string(),
341 "(allow process-fork)".to_string(),
342 "(allow sysctl-read)".to_string(),
343 "(allow mach-lookup)".to_string(),
344 "(allow signal (target self))".to_string(),
345 "(allow ipc-posix-shm*)".to_string(),
346 "(allow file-read* (subpath \"/usr\"))".to_string(),
347 "(allow file-read* (subpath \"/bin\"))".to_string(),
348 "(allow file-read* (subpath \"/sbin\"))".to_string(),
349 "(allow file-read* (subpath \"/Library\"))".to_string(),
350 "(allow file-read* (subpath \"/System\"))".to_string(),
351 "(allow file-read* (subpath \"/private\"))".to_string(),
352 "(allow file-read* (subpath \"/dev\"))".to_string(),
353 "(allow file-read* (subpath \"/var\"))".to_string(),
354 "(allow file-read* (subpath \"/etc\"))".to_string(),
355 "(allow file-read* (subpath \"/opt\"))".to_string(),
356 "(allow file-read* (subpath \"/tmp\"))".to_string(),
357 "(allow file-read* (subpath \"/Applications\"))".to_string(),
358 ];
359
360 if let Some(home) = env::var_os("HOME") {
361 let home_str = home
362 .to_string_lossy()
363 .replace('\\', "\\\\")
364 .replace('"', "\\\"");
365 rules.push(format!(
366 "(allow file-read* (subpath \"{home_str}/.cargo\"))"
367 ));
368 rules.push(format!(
369 "(allow file-read* (subpath \"{home_str}/.rustup\"))"
370 ));
371 }
372
373 match status.filesystem_mode {
374 FilesystemIsolationMode::Off => {
375 rules.push("(allow file-read*)".to_string());
376 rules.push("(allow file-write*)".to_string());
377 }
378 FilesystemIsolationMode::WorkspaceOnly => {
379 let sh = escape_seatbelt_path(&sandbox_home.display().to_string());
380 let st = escape_seatbelt_path(&sandbox_tmp.display().to_string());
381 rules.push(format!("(allow file-read* (subpath \"{cwd_str}\"))"));
382 rules.push(format!("(allow file-write* (subpath \"{cwd_str}\"))"));
383 rules.push(format!("(allow file-write* (subpath \"{sh}\"))"));
384 rules.push(format!("(allow file-write* (subpath \"{st}\"))"));
385 }
386 FilesystemIsolationMode::AllowList => {
387 let sh = escape_seatbelt_path(&sandbox_home.display().to_string());
388 let st = escape_seatbelt_path(&sandbox_tmp.display().to_string());
389 rules.push(format!("(allow file-read* (subpath \"{cwd_str}\"))"));
390 rules.push(format!("(allow file-write* (subpath \"{sh}\"))"));
391 rules.push(format!("(allow file-write* (subpath \"{st}\"))"));
392 for mount in &status.allowed_mounts {
393 let escaped = escape_seatbelt_path(mount);
394 rules.push(format!("(allow file-read* (subpath \"{escaped}\"))"));
395 rules.push(format!("(allow file-write* (subpath \"{escaped}\"))"));
396 }
397 }
398 }
399
400 if status.network.active {
401 rules.push("(deny network*)".to_string());
402 } else {
403 rules.push("(allow network*)".to_string());
404 }
405
406 rules.join("\n")
407}
408
409fn normalize_mounts(mounts: &[String], cwd: &Path) -> Vec<String> {
410 let cwd = cwd.to_path_buf();
411 mounts
412 .iter()
413 .map(|mount| {
414 let path = PathBuf::from(mount);
415 if path.is_absolute() {
416 path
417 } else {
418 cwd.join(path)
419 }
420 })
421 .map(|path| path.display().to_string())
422 .collect()
423}
424
425fn command_exists(command: &str) -> bool {
426 env::var_os("PATH")
427 .is_some_and(|paths| env::split_paths(&paths).any(|path| path.join(command).exists()))
428}
429
430#[cfg(test)]
431#[cfg(unix)]
432mod tests {
433 use super::{
434 build_sandbox_command, detect_container_environment_from, generate_seatbelt_profile,
435 FilesystemIsolationMode, SandboxConfig, SandboxDetectionInputs,
436 };
437 use std::path::Path;
438
439 #[test]
440 fn detects_container_markers_from_multiple_sources() {
441 let detected = detect_container_environment_from(SandboxDetectionInputs {
442 env_pairs: vec![("container".to_string(), "docker".to_string())],
443 dockerenv_exists: true,
444 containerenv_exists: false,
445 proc_1_cgroup: Some("12:memory:/docker/abc"),
446 });
447
448 assert!(detected.in_container);
449 assert!(detected
450 .markers
451 .iter()
452 .any(|marker| marker == "/.dockerenv"));
453 assert!(detected
454 .markers
455 .iter()
456 .any(|marker| marker == "env:container=docker"));
457 assert!(detected
458 .markers
459 .iter()
460 .any(|marker| marker == "/proc/1/cgroup:docker"));
461 }
462
463 #[test]
464 fn resolves_request_with_overrides() {
465 let config = SandboxConfig {
466 enabled: Some(true),
467 namespace_restrictions: Some(true),
468 network_isolation: Some(false),
469 filesystem_mode: Some(FilesystemIsolationMode::WorkspaceOnly),
470 allowed_mounts: vec!["logs".to_string()],
471 };
472
473 let request = config.resolve_request(
474 Some(true),
475 Some(false),
476 Some(true),
477 Some(FilesystemIsolationMode::AllowList),
478 Some(vec!["tmp".to_string()]),
479 );
480
481 assert!(request.enabled);
482 assert!(!request.namespace_restrictions);
483 assert!(request.network_isolation);
484 assert_eq!(request.filesystem_mode, FilesystemIsolationMode::AllowList);
485 assert_eq!(request.allowed_mounts, vec!["tmp"]);
486 }
487
488 #[test]
489 fn builds_sandbox_command_for_current_platform() {
490 let config = SandboxConfig::default();
491 let status = super::resolve_sandbox_status_for_request(
492 &config.resolve_request(
493 Some(true),
494 Some(true),
495 Some(true),
496 Some(FilesystemIsolationMode::WorkspaceOnly),
497 None,
498 ),
499 Path::new("/workspace"),
500 );
501
502 if let Some(launcher) = build_sandbox_command("printf hi", Path::new("/workspace"), &status)
503 {
504 if cfg!(target_os = "linux") {
505 assert_eq!(launcher.program, "unshare");
506 assert!(launcher.args.iter().any(|arg| arg == "--mount"));
507 assert!(launcher.args.iter().any(|arg| arg == "--net") == status.network.active);
508 } else if cfg!(target_os = "macos") {
509 assert_eq!(launcher.program, "sandbox-exec");
510 assert!(launcher.args.iter().any(|arg| arg == "-p"));
511 }
512 }
513 }
514
515 #[test]
516 fn seatbelt_profile_denies_by_default() {
517 let config = SandboxConfig::default();
518 let status = super::resolve_sandbox_status_for_request(
519 &config.resolve_request(
520 Some(true),
521 Some(true),
522 Some(false),
523 Some(FilesystemIsolationMode::WorkspaceOnly),
524 None,
525 ),
526 Path::new("/workspace"),
527 );
528 let profile = generate_seatbelt_profile(Path::new("/workspace"), &status);
529 assert!(profile.contains("(deny default)"));
530 assert!(profile.contains("(allow file-write* (subpath \"/workspace\"))"));
531 assert!(profile.contains("(allow network*)"));
532 }
533
534 #[test]
535 fn seatbelt_profile_denies_network_when_isolated() {
536 let config = SandboxConfig::default();
537 let mut status = super::resolve_sandbox_status_for_request(
538 &config.resolve_request(
539 Some(true),
540 Some(true),
541 Some(true),
542 Some(FilesystemIsolationMode::WorkspaceOnly),
543 None,
544 ),
545 Path::new("/workspace"),
546 );
547 status.network.active = true;
548 let profile = generate_seatbelt_profile(Path::new("/workspace"), &status);
549 assert!(profile.contains("(deny network*)"));
550 assert!(!profile.contains("(allow network*)"));
551 }
552
553 #[test]
554 fn seatbelt_profile_allow_list_restricts_writes() {
555 let config = SandboxConfig::default();
556 let status = super::resolve_sandbox_status_for_request(
557 &config.resolve_request(
558 Some(true),
559 Some(true),
560 Some(false),
561 Some(FilesystemIsolationMode::AllowList),
562 Some(vec!["/extra/mount".to_string()]),
563 ),
564 Path::new("/workspace"),
565 );
566 let profile = generate_seatbelt_profile(Path::new("/workspace"), &status);
567 assert!(profile.contains("(allow file-write* (subpath \"/extra/mount\"))"));
568 assert!(!profile.contains("(allow file-write* (subpath \"/workspace\"))"));
569 }
570
571 #[test]
572 fn disabled_sandbox_returns_none() {
573 let config = SandboxConfig::default();
574 let status = super::resolve_sandbox_status_for_request(
575 &config.resolve_request(Some(false), None, None, None, None),
576 Path::new("/workspace"),
577 );
578 assert!(build_sandbox_command("echo hi", Path::new("/workspace"), &status).is_none());
579 }
580}