orchestrator_runner/runner/
sandbox.rs1use super::profile::ResolvedExecutionProfile;
2use anyhow::Result;
3#[cfg(target_os = "linux")]
4use orchestrator_config::config::ExecutionFsMode;
5use orchestrator_config::config::{ExecutionNetworkMode, ExecutionProfileMode, RunnerConfig};
6use std::io;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9#[allow(dead_code)] pub(crate) enum SandboxBackend {
11 Host,
12 MacosSeatbelt,
13 LinuxNative,
14 Unavailable,
15}
16
17impl SandboxBackend {
18 pub(crate) fn label(self) -> &'static str {
19 match self {
20 Self::Host => "host",
21 Self::MacosSeatbelt => "macos_seatbelt",
22 Self::LinuxNative => "linux_native",
23 Self::Unavailable => "sandbox_unavailable",
24 }
25 }
26}
27
28#[derive(Debug, Clone)]
29pub(crate) struct LinuxSandboxSupport {
30 pub(crate) backend: SandboxBackend,
31 pub(crate) missing_requirements: Vec<String>,
32}
33
34impl LinuxSandboxSupport {
35 pub(crate) fn available(&self) -> bool {
36 self.backend == SandboxBackend::LinuxNative && self.missing_requirements.is_empty()
37 }
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum SandboxResourceKind {
43 Memory,
45 Cpu,
47 Processes,
49 OpenFiles,
51}
52
53impl SandboxResourceKind {
54 pub fn as_str(&self) -> &'static str {
56 match self {
57 Self::Memory => "memory",
58 Self::Cpu => "cpu",
59 Self::Processes => "processes",
60 Self::OpenFiles => "open_files",
61 }
62 }
63}
64
65#[derive(Debug)]
66pub struct SandboxBackendError {
68 pub execution_profile: String,
70 pub backend: &'static str,
72 pub event_type: &'static str,
74 pub reason_code: &'static str,
76 pub resource_kind: Option<SandboxResourceKind>,
78 message: String,
79}
80
81impl SandboxBackendError {
82 pub(crate) fn unsupported_network_allowlist(
83 execution_profile: &ResolvedExecutionProfile,
84 backend: SandboxBackend,
85 ) -> Self {
86 Self {
87 execution_profile: execution_profile.name.clone(),
88 backend: backend.label(),
89 event_type: "sandbox_network_blocked",
90 reason_code: "unsupported_backend_feature",
91 resource_kind: None,
92 message: format!(
93 "sandbox backend '{}' does not support network allowlists for execution profile '{}'",
94 backend.label(),
95 execution_profile.name
96 ),
97 }
98 }
99
100 pub(crate) fn backend_unavailable(
101 execution_profile: &ResolvedExecutionProfile,
102 backend: SandboxBackend,
103 detail: Option<&str>,
104 ) -> Self {
105 let suffix = detail
106 .filter(|value| !value.trim().is_empty())
107 .map(|value| format!(": {value}"))
108 .unwrap_or_default();
109 Self {
110 execution_profile: execution_profile.name.clone(),
111 backend: backend.label(),
112 event_type: "sandbox_denied",
113 reason_code: "sandbox_backend_unavailable",
114 resource_kind: None,
115 message: format!(
116 "sandbox backend '{}' is unavailable for execution profile '{}'{}",
117 backend.label(),
118 execution_profile.name,
119 suffix
120 ),
121 }
122 }
123
124 pub(crate) fn resource_exhausted(
125 execution_profile: &ResolvedExecutionProfile,
126 resource_kind: SandboxResourceKind,
127 source: &io::Error,
128 ) -> Self {
129 let reason_code = match resource_kind {
130 SandboxResourceKind::Memory => "memory_limit_exceeded",
131 SandboxResourceKind::Cpu => "cpu_limit_exceeded",
132 SandboxResourceKind::Processes => "processes_limit_exceeded",
133 SandboxResourceKind::OpenFiles => "open_files_limit_exceeded",
134 };
135 Self {
136 execution_profile: execution_profile.name.clone(),
137 backend: sandbox_backend_label(execution_profile),
138 event_type: "sandbox_resource_exceeded",
139 reason_code,
140 resource_kind: Some(resource_kind),
141 message: format!(
142 "sandbox process spawn failed under execution profile '{}': {}",
143 execution_profile.name, source
144 ),
145 }
146 }
147}
148
149impl std::fmt::Display for SandboxBackendError {
150 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151 f.write_str(&self.message)
152 }
153}
154
155impl std::error::Error for SandboxBackendError {}
156
157pub fn sandbox_backend_label(execution_profile: &ResolvedExecutionProfile) -> &'static str {
159 select_sandbox_backend(execution_profile).label()
160}
161
162pub fn validate_execution_profile_support(
164 execution_profile: &ResolvedExecutionProfile,
165) -> Result<()> {
166 if execution_profile.mode != ExecutionProfileMode::Sandbox {
167 return Ok(());
168 }
169 let backend = select_sandbox_backend(execution_profile);
170 match backend {
171 SandboxBackend::Host => Ok(()),
172 SandboxBackend::MacosSeatbelt => {
173 if execution_profile.network_mode == ExecutionNetworkMode::Allowlist {
174 return Err(SandboxBackendError::unsupported_network_allowlist(
175 execution_profile,
176 backend,
177 )
178 .into());
179 }
180 Ok(())
181 }
182 SandboxBackend::LinuxNative => {
183 let support = detect_linux_sandbox_support(execution_profile);
184 if support.available() {
185 Ok(())
186 } else {
187 Err(SandboxBackendError::backend_unavailable(
188 execution_profile,
189 support.backend,
190 Some(&support.missing_requirements.join(", ")),
191 )
192 .into())
193 }
194 }
195 SandboxBackend::Unavailable => {
196 Err(SandboxBackendError::backend_unavailable(execution_profile, backend, None).into())
197 }
198 }
199}
200
201pub fn sandbox_backend_preflight_issues(
203 execution_profile: &ResolvedExecutionProfile,
204) -> Vec<String> {
205 if execution_profile.mode != ExecutionProfileMode::Sandbox {
206 return Vec::new();
207 }
208 match select_sandbox_backend(execution_profile) {
209 SandboxBackend::LinuxNative => {
210 detect_linux_sandbox_support(execution_profile).missing_requirements
211 }
212 SandboxBackend::MacosSeatbelt
213 if execution_profile.network_mode == ExecutionNetworkMode::Allowlist =>
214 {
215 vec!["macos_seatbelt does not support network_mode=allowlist".to_string()]
216 }
217 SandboxBackend::Unavailable => {
218 vec!["sandbox backend is unavailable on this platform".to_string()]
219 }
220 _ => Vec::new(),
221 }
222}
223
224pub(crate) fn select_sandbox_backend(
225 execution_profile: &ResolvedExecutionProfile,
226) -> SandboxBackend {
227 match execution_profile.mode {
228 ExecutionProfileMode::Host => SandboxBackend::Host,
229 ExecutionProfileMode::Sandbox => {
230 #[cfg(target_os = "macos")]
231 {
232 SandboxBackend::MacosSeatbelt
233 }
234 #[cfg(target_os = "linux")]
235 {
236 SandboxBackend::LinuxNative
237 }
238 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
239 {
240 SandboxBackend::Unavailable
241 }
242 }
243 }
244}
245
246pub(crate) fn detect_linux_sandbox_support(
247 execution_profile: &ResolvedExecutionProfile,
248) -> LinuxSandboxSupport {
249 #[cfg(target_os = "linux")]
250 {
251 use super::sandbox_linux::command_exists;
252
253 let mut missing = Vec::new();
254 for binary in ["ip", "nft"] {
255 if !command_exists(binary) {
256 missing.push(format!("missing '{binary}' in PATH"));
257 }
258 }
259 if execution_profile.fs_mode != ExecutionFsMode::Inherit {
260 missing.push(
261 "linux_native currently requires fs_mode=inherit until a Linux filesystem backend is implemented"
262 .to_string(),
263 );
264 }
265 if nix::unistd::geteuid().as_raw() != 0 {
266 missing.push("linux_native requires the daemon to run as root".to_string());
267 }
268 LinuxSandboxSupport {
269 backend: SandboxBackend::LinuxNative,
270 missing_requirements: missing,
271 }
272 }
273 #[cfg(not(target_os = "linux"))]
274 {
275 let _ = execution_profile;
276 LinuxSandboxSupport {
277 backend: SandboxBackend::Unavailable,
278 missing_requirements: vec![
279 "linux_native backend is only available on Linux".to_string(),
280 ],
281 }
282 }
283}
284
285pub(crate) fn classify_sandbox_spawn_error(
286 execution_profile: &ResolvedExecutionProfile,
287 err: &io::Error,
288) -> Option<SandboxBackendError> {
289 if execution_profile.mode != ExecutionProfileMode::Sandbox {
290 return None;
291 }
292 let lower = err.to_string().to_lowercase();
293 if execution_profile.max_memory_mb.is_some()
294 && (lower.contains("cannot allocate memory")
295 || lower.contains("not enough space")
296 || lower.contains("not enough memory")
297 || lower.contains("memory"))
298 {
299 return Some(SandboxBackendError::resource_exhausted(
300 execution_profile,
301 SandboxResourceKind::Memory,
302 err,
303 ));
304 }
305 if execution_profile.max_processes.is_some()
306 && lower.contains("resource temporarily unavailable")
307 {
308 return Some(SandboxBackendError::resource_exhausted(
309 execution_profile,
310 SandboxResourceKind::Processes,
311 err,
312 ));
313 }
314 if execution_profile.max_open_files.is_some() && lower.contains("too many open files") {
315 return Some(SandboxBackendError::resource_exhausted(
316 execution_profile,
317 SandboxResourceKind::OpenFiles,
318 err,
319 ));
320 }
321 let mut configured_limits = Vec::new();
322 if execution_profile.max_memory_mb.is_some() {
323 configured_limits.push(SandboxResourceKind::Memory);
324 }
325 if execution_profile.max_processes.is_some() {
326 configured_limits.push(SandboxResourceKind::Processes);
327 }
328 if execution_profile.max_open_files.is_some() {
329 configured_limits.push(SandboxResourceKind::OpenFiles);
330 }
331 if execution_profile.max_cpu_seconds.is_some() {
332 configured_limits.push(SandboxResourceKind::Cpu);
333 }
334 if configured_limits.len() == 1 {
335 return Some(SandboxBackendError::resource_exhausted(
336 execution_profile,
337 configured_limits.remove(0),
338 err,
339 ));
340 }
341 None
342}
343
344pub(crate) fn build_command_for_profile(
345 runner: &RunnerConfig,
346 command: &str,
347 cwd: &std::path::Path,
348 execution_profile: &ResolvedExecutionProfile,
349) -> Result<tokio::process::Command> {
350 let mut cmd = match execution_profile.mode {
351 ExecutionProfileMode::Host => {
352 let mut cmd = tokio::process::Command::new(&runner.shell);
353 cmd.arg(&runner.shell_arg).arg(command);
354 cmd
355 }
356 ExecutionProfileMode::Sandbox => build_sandbox_command(runner, command, execution_profile)?,
357 };
358 cmd.current_dir(cwd);
359 Ok(cmd)
360}
361
362pub(crate) fn build_sandbox_command(
363 runner: &RunnerConfig,
364 command: &str,
365 execution_profile: &ResolvedExecutionProfile,
366) -> Result<tokio::process::Command> {
367 let backend = select_sandbox_backend(execution_profile);
368 match backend {
369 SandboxBackend::MacosSeatbelt => {
370 #[cfg(target_os = "macos")]
371 {
372 use super::sandbox_macos::build_macos_sandbox_profile;
373 let mut cmd = tokio::process::Command::new("/usr/bin/sandbox-exec");
374 cmd.arg("-p")
375 .arg(build_macos_sandbox_profile(execution_profile))
376 .arg(&runner.shell)
377 .arg(&runner.shell_arg)
378 .arg(command);
379 Ok(cmd)
380 }
381 #[cfg(not(target_os = "macos"))]
382 {
383 let _ = (runner, command);
384 Err(
385 SandboxBackendError::backend_unavailable(execution_profile, backend, None)
386 .into(),
387 )
388 }
389 }
390 SandboxBackend::LinuxNative => {
391 #[cfg(target_os = "linux")]
392 {
393 use super::sandbox_linux::build_linux_sandbox_command;
394 build_linux_sandbox_command(runner, command, execution_profile)
395 }
396 #[cfg(not(target_os = "linux"))]
397 {
398 let _ = (runner, command);
399 Err(
400 SandboxBackendError::backend_unavailable(execution_profile, backend, None)
401 .into(),
402 )
403 }
404 }
405 _ => Err(SandboxBackendError::backend_unavailable(execution_profile, backend, None).into()),
406 }
407}