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_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}