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}