Skip to main content

orchestrator_runner/runner/
sandbox.rs

1use 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)] // Variants are platform-specific; not all used on every OS.
10pub(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)]
41/// Resource limits that can trigger sandbox spawn failures.
42pub enum SandboxResourceKind {
43    /// Memory limit exhaustion.
44    Memory,
45    /// CPU time limit exhaustion.
46    Cpu,
47    /// Process-count limit exhaustion.
48    Processes,
49    /// File-descriptor limit exhaustion.
50    OpenFiles,
51}
52
53impl SandboxResourceKind {
54    /// Returns the stable event payload label for the resource kind.
55    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)]
66/// Structured error emitted when sandbox backend selection or execution fails.
67pub struct SandboxBackendError {
68    /// Name of the execution profile that triggered the error.
69    pub execution_profile: String,
70    /// Label of the selected or attempted sandbox backend.
71    pub backend: &'static str,
72    /// Event type emitted to observability and callers.
73    pub event_type: &'static str,
74    /// Stable reason code for programmatic handling.
75    pub reason_code: &'static str,
76    /// Resource limit kind when the error was caused by resource exhaustion.
77    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
157/// Returns the effective sandbox backend label for an execution profile.
158pub fn sandbox_backend_label(execution_profile: &ResolvedExecutionProfile) -> &'static str {
159    select_sandbox_backend(execution_profile).label()
160}
161
162/// Validates that the current host can satisfy the requested execution profile.
163pub 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
201/// Returns non-fatal preflight issues for the execution profile's sandbox backend.
202pub 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}