Skip to main content

wraith_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#[allow(clippy::struct_excessive_bools)]
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
53pub struct SandboxStatus {
54    pub enabled: bool,
55    pub requested: SandboxRequest,
56    pub supported: bool,
57    pub active: bool,
58    pub namespace_supported: bool,
59    pub namespace_active: bool,
60    pub network_supported: bool,
61    pub network_active: bool,
62    pub filesystem_mode: FilesystemIsolationMode,
63    pub filesystem_active: bool,
64    pub allowed_mounts: Vec<String>,
65    pub in_container: bool,
66    pub container_markers: Vec<String>,
67    pub fallback_reason: Option<String>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct SandboxDetectionInputs<'a> {
72    pub env_pairs: Vec<(String, String)>,
73    pub dockerenv_exists: bool,
74    pub containerenv_exists: bool,
75    pub proc_1_cgroup: Option<&'a str>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct LinuxSandboxCommand {
80    pub program: String,
81    pub args: Vec<String>,
82    pub env: Vec<(String, String)>,
83}
84
85impl SandboxConfig {
86    #[must_use]
87    pub fn resolve_request(
88        &self,
89        enabled_override: Option<bool>,
90        namespace_override: Option<bool>,
91        network_override: Option<bool>,
92        filesystem_mode_override: Option<FilesystemIsolationMode>,
93        allowed_mounts_override: Option<Vec<String>>,
94    ) -> SandboxRequest {
95        SandboxRequest {
96            enabled: enabled_override.unwrap_or(self.enabled.unwrap_or(true)),
97            namespace_restrictions: namespace_override
98                .unwrap_or(self.namespace_restrictions.unwrap_or(true)),
99            network_isolation: network_override.unwrap_or(self.network_isolation.unwrap_or(false)),
100            filesystem_mode: filesystem_mode_override
101                .or(self.filesystem_mode)
102                .unwrap_or_default(),
103            allowed_mounts: allowed_mounts_override.unwrap_or_else(|| self.allowed_mounts.clone()),
104        }
105    }
106}
107
108#[must_use]
109pub fn detect_container_environment() -> ContainerEnvironment {
110    let proc_1_cgroup = fs::read_to_string("/proc/1/cgroup").ok();
111    detect_container_environment_from(SandboxDetectionInputs {
112        env_pairs: env::vars().collect(),
113        dockerenv_exists: Path::new("/.dockerenv").exists(),
114        containerenv_exists: Path::new("/run/.containerenv").exists(),
115        proc_1_cgroup: proc_1_cgroup.as_deref(),
116    })
117}
118
119#[must_use]
120pub fn detect_container_environment_from(
121    inputs: SandboxDetectionInputs<'_>,
122) -> ContainerEnvironment {
123    let mut markers = Vec::new();
124    if inputs.dockerenv_exists {
125        markers.push("/.dockerenv".to_string());
126    }
127    if inputs.containerenv_exists {
128        markers.push("/run/.containerenv".to_string());
129    }
130    for (key, value) in inputs.env_pairs {
131        let normalized = key.to_ascii_lowercase();
132        if matches!(
133            normalized.as_str(),
134            "container" | "docker" | "podman" | "kubernetes_service_host"
135        ) && !value.is_empty()
136        {
137            markers.push(format!("env:{key}={value}"));
138        }
139    }
140    if let Some(cgroup) = inputs.proc_1_cgroup {
141        for needle in ["docker", "containerd", "kubepods", "podman", "libpod"] {
142            if cgroup.contains(needle) {
143                markers.push(format!("/proc/1/cgroup:{needle}"));
144            }
145        }
146    }
147    markers.sort();
148    markers.dedup();
149    ContainerEnvironment {
150        in_container: !markers.is_empty(),
151        markers,
152    }
153}
154
155#[must_use]
156pub fn resolve_sandbox_status(config: &SandboxConfig, cwd: &Path) -> SandboxStatus {
157    let request = config.resolve_request(None, None, None, None, None);
158    resolve_sandbox_status_for_request(&request, cwd)
159}
160
161#[must_use]
162pub fn resolve_sandbox_status_for_request(request: &SandboxRequest, cwd: &Path) -> SandboxStatus {
163    let container = detect_container_environment();
164    let namespace_supported = cfg!(target_os = "linux") && command_exists("unshare");
165    let network_supported = namespace_supported;
166    let filesystem_active =
167        request.enabled && request.filesystem_mode != FilesystemIsolationMode::Off;
168    let mut fallback_reasons = Vec::new();
169
170    if request.enabled && request.namespace_restrictions && !namespace_supported {
171        fallback_reasons
172            .push("namespace isolation unavailable (requires Linux with `unshare`)".to_string());
173    }
174    if request.enabled && request.network_isolation && !network_supported {
175        fallback_reasons
176            .push("network isolation unavailable (requires Linux with `unshare`)".to_string());
177    }
178    if request.enabled
179        && request.filesystem_mode == FilesystemIsolationMode::AllowList
180        && request.allowed_mounts.is_empty()
181    {
182        fallback_reasons
183            .push("filesystem allow-list requested without configured mounts".to_string());
184    }
185
186    let active = request.enabled
187        && (!request.namespace_restrictions || namespace_supported)
188        && (!request.network_isolation || network_supported);
189
190    let allowed_mounts = normalize_mounts(&request.allowed_mounts, cwd);
191
192    SandboxStatus {
193        enabled: request.enabled,
194        requested: request.clone(),
195        supported: namespace_supported,
196        active,
197        namespace_supported,
198        namespace_active: request.enabled && request.namespace_restrictions && namespace_supported,
199        network_supported,
200        network_active: request.enabled && request.network_isolation && network_supported,
201        filesystem_mode: request.filesystem_mode,
202        filesystem_active,
203        allowed_mounts,
204        in_container: container.in_container,
205        container_markers: container.markers,
206        fallback_reason: (!fallback_reasons.is_empty()).then(|| fallback_reasons.join("; ")),
207    }
208}
209
210#[must_use]
211pub fn build_linux_sandbox_command(
212    command: &str,
213    cwd: &Path,
214    status: &SandboxStatus,
215) -> Option<LinuxSandboxCommand> {
216    if !cfg!(target_os = "linux")
217        || !status.enabled
218        || (!status.namespace_active && !status.network_active)
219    {
220        return None;
221    }
222
223    let mut args = vec![
224        "--user".to_string(),
225        "--map-root-user".to_string(),
226        "--mount".to_string(),
227        "--ipc".to_string(),
228        "--pid".to_string(),
229        "--uts".to_string(),
230        "--fork".to_string(),
231    ];
232    if status.network_active {
233        args.push("--net".to_string());
234    }
235    args.push("sh".to_string());
236    args.push("-lc".to_string());
237    args.push(command.to_string());
238
239    let sandbox_home = cwd.join(".sandbox-home");
240    let sandbox_tmp = cwd.join(".sandbox-tmp");
241    let mut env = vec![
242        ("HOME".to_string(), sandbox_home.display().to_string()),
243        ("TMPDIR".to_string(), sandbox_tmp.display().to_string()),
244        (
245            "WRAITH_SANDBOX_FILESYSTEM_MODE".to_string(),
246            status.filesystem_mode.as_str().to_string(),
247        ),
248        (
249            "WRAITH_SANDBOX_ALLOWED_MOUNTS".to_string(),
250            status.allowed_mounts.join(":"),
251        ),
252    ];
253    if let Ok(path) = env::var("PATH") {
254        env.push(("PATH".to_string(), path));
255    }
256
257    Some(LinuxSandboxCommand {
258        program: "unshare".to_string(),
259        args,
260        env,
261    })
262}
263
264fn normalize_mounts(mounts: &[String], cwd: &Path) -> Vec<String> {
265    let cwd = cwd.to_path_buf();
266    mounts
267        .iter()
268        .map(|mount| {
269            let path = PathBuf::from(mount);
270            if path.is_absolute() {
271                path
272            } else {
273                cwd.join(path)
274            }
275        })
276        .map(|path| path.display().to_string())
277        .collect()
278}
279
280fn command_exists(command: &str) -> bool {
281    env::var_os("PATH")
282        .is_some_and(|paths| env::split_paths(&paths).any(|path| path.join(command).exists()))
283}
284
285#[cfg(test)]
286mod tests {
287    use super::{
288        build_linux_sandbox_command, detect_container_environment_from, FilesystemIsolationMode,
289        SandboxConfig, SandboxDetectionInputs,
290    };
291    use std::path::Path;
292
293    #[test]
294    fn detects_container_markers_from_multiple_sources() {
295        let detected = detect_container_environment_from(SandboxDetectionInputs {
296            env_pairs: vec![("container".to_string(), "docker".to_string())],
297            dockerenv_exists: true,
298            containerenv_exists: false,
299            proc_1_cgroup: Some("12:memory:/docker/abc"),
300        });
301
302        assert!(detected.in_container);
303        assert!(detected
304            .markers
305            .iter()
306            .any(|marker| marker == "/.dockerenv"));
307        assert!(detected
308            .markers
309            .iter()
310            .any(|marker| marker == "env:container=docker"));
311        assert!(detected
312            .markers
313            .iter()
314            .any(|marker| marker == "/proc/1/cgroup:docker"));
315    }
316
317    #[test]
318    fn resolves_request_with_overrides() {
319        let config = SandboxConfig {
320            enabled: Some(true),
321            namespace_restrictions: Some(true),
322            network_isolation: Some(false),
323            filesystem_mode: Some(FilesystemIsolationMode::WorkspaceOnly),
324            allowed_mounts: vec!["logs".to_string()],
325        };
326
327        let request = config.resolve_request(
328            Some(true),
329            Some(false),
330            Some(true),
331            Some(FilesystemIsolationMode::AllowList),
332            Some(vec!["tmp".to_string()]),
333        );
334
335        assert!(request.enabled);
336        assert!(!request.namespace_restrictions);
337        assert!(request.network_isolation);
338        assert_eq!(request.filesystem_mode, FilesystemIsolationMode::AllowList);
339        assert_eq!(request.allowed_mounts, vec!["tmp"]);
340    }
341
342    #[test]
343    fn builds_linux_launcher_with_network_flag_when_requested() {
344        let config = SandboxConfig::default();
345        let status = super::resolve_sandbox_status_for_request(
346            &config.resolve_request(
347                Some(true),
348                Some(true),
349                Some(true),
350                Some(FilesystemIsolationMode::WorkspaceOnly),
351                None,
352            ),
353            Path::new("/workspace"),
354        );
355
356        if let Some(launcher) =
357            build_linux_sandbox_command("printf hi", Path::new("/workspace"), &status)
358        {
359            assert_eq!(launcher.program, "unshare");
360            assert!(launcher.args.iter().any(|arg| arg == "--mount"));
361            assert!(launcher.args.iter().any(|arg| arg == "--net") == status.network_active);
362        }
363    }
364}