Skip to main content

codineer_runtime/
sandbox.rs

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}