Skip to main content

evalbox_sandbox/
plan.rs

1//! Sandbox execution plan.
2//!
3//! A `Plan` describes everything needed to run a command in the sandbox:
4//! the command, environment, files, mounts, and resource limits.
5//!
6//! ## Example
7//!
8//! ```ignore
9//! use evalbox_sandbox::{Plan, Mount};
10//!
11//! let plan = Plan::new(["python", "main.py"])
12//!     .env("PYTHONPATH", "/work")
13//!     .file("main.py", b"print('hello')")
14//!     .timeout(Duration::from_secs(10))
15//!     .memory(256 * 1024 * 1024);
16//! ```
17//!
18//! ## Advanced Security Configuration
19//!
20//! ```ignore
21//! use evalbox_sandbox::{Plan, Syscalls, Landlock};
22//!
23//! let plan = Plan::new(["python3", "-c", "code"])
24//!     .syscalls(Syscalls::default().allow(libc::SYS_openat))
25//!     .landlock(Landlock::default().allow_read("/etc"))
26//!     .network(false);
27//! ```
28//!
29//! ## Defaults
30//!
31//! | Field | Default |
32//! |-------|---------|
33//! | `timeout` | 30 seconds |
34//! | `memory` | 256 MiB |
35//! | `max_pids` | 64 processes |
36//! | `max_output` | 16 MiB |
37//! | `network` | false (blocked) |
38//! | `cwd` | `/work` |
39
40use std::collections::{HashMap, HashSet};
41use std::path::PathBuf;
42use std::time::Duration;
43
44/// Seccomp user notification mode.
45///
46/// Controls how the supervisor handles intercepted syscalls from the sandboxed child.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum NotifyMode {
49    /// No seccomp notify filter installed. Zero overhead. Default.
50    #[default]
51    Disabled,
52    /// Supervisor logs syscalls and returns `SECCOMP_USER_NOTIF_FLAG_CONTINUE`.
53    /// Minimal overhead. For debugging/auditing.
54    Monitor,
55    /// Supervisor intercepts FS syscalls, translates paths via `VirtualFs`,
56    /// opens files at translated paths, injects fd via `SECCOMP_IOCTL_NOTIF_ADDFD`.
57    Virtualize,
58}
59
60/// Mount point configuration.
61///
62/// This is the canonical Mount type used throughout evalbox.
63#[derive(Debug, Clone)]
64pub struct Mount {
65    /// Path on the host filesystem.
66    pub source: PathBuf,
67    /// Path inside the sandbox.
68    pub target: PathBuf,
69    /// If false, mount is read-only (default).
70    pub writable: bool,
71    /// If true, executables can be run from this mount (for Landlock).
72    pub executable: bool,
73}
74
75impl Mount {
76    /// Read-only mount, same path inside/outside sandbox.
77    pub fn ro(path: impl Into<PathBuf>) -> Self {
78        let path = path.into();
79        Self {
80            source: path.clone(),
81            target: path,
82            writable: false,
83            executable: true,
84        }
85    }
86
87    /// Read-only mount without execute permission.
88    pub fn ro_noexec(path: impl Into<PathBuf>) -> Self {
89        let path = path.into();
90        Self {
91            source: path.clone(),
92            target: path,
93            writable: false,
94            executable: false,
95        }
96    }
97
98    /// Read-write mount, same path inside/outside sandbox.
99    pub fn rw(path: impl Into<PathBuf>) -> Self {
100        let path = path.into();
101        Self {
102            source: path.clone(),
103            target: path,
104            writable: true,
105            executable: true,
106        }
107    }
108
109    /// Mount with different host and sandbox paths (read-only by default).
110    pub fn bind(source: impl Into<PathBuf>, target: impl Into<PathBuf>) -> Self {
111        Self {
112            source: source.into(),
113            target: target.into(),
114            writable: false,
115            executable: true,
116        }
117    }
118
119    /// Make mount writable.
120    pub fn writable(mut self) -> Self {
121        self.writable = true;
122        self
123    }
124
125    /// Disable execute permission (for Landlock).
126    pub fn noexec(mut self) -> Self {
127        self.executable = false;
128        self
129    }
130}
131
132/// Syscall filtering configuration.
133///
134/// By default, a strict whitelist of ~40 safe syscalls is allowed.
135/// Use this to customize the allowed syscalls for specific use cases.
136///
137/// ## Example
138///
139/// ```ignore
140/// use evalbox_sandbox::Syscalls;
141///
142/// // Start with default whitelist, add specific syscalls
143/// let syscalls = Syscalls::default()
144///     .allow(libc::SYS_openat)
145///     .allow(libc::SYS_socket);
146///
147/// // Or deny specific syscalls (removes from whitelist)
148/// let syscalls = Syscalls::default()
149///     .deny(libc::SYS_clone);
150/// ```
151#[derive(Debug, Clone, Default)]
152pub struct Syscalls {
153    /// Additional syscalls to allow beyond the default whitelist.
154    pub allowed: HashSet<i64>,
155    /// Syscalls to deny (removes from whitelist).
156    pub denied: HashSet<i64>,
157}
158
159impl Syscalls {
160    /// Create a new Syscalls config (default whitelist).
161    pub fn new() -> Self {
162        Self::default()
163    }
164
165    /// Allow a specific syscall.
166    pub fn allow(mut self, syscall: i64) -> Self {
167        self.allowed.insert(syscall);
168        self.denied.remove(&syscall);
169        self
170    }
171
172    /// Deny a specific syscall (remove from whitelist).
173    pub fn deny(mut self, syscall: i64) -> Self {
174        self.denied.insert(syscall);
175        self.allowed.remove(&syscall);
176        self
177    }
178
179    /// Allow multiple syscalls.
180    pub fn allow_many(mut self, syscalls: impl IntoIterator<Item = i64>) -> Self {
181        for syscall in syscalls {
182            self.allowed.insert(syscall);
183            self.denied.remove(&syscall);
184        }
185        self
186    }
187
188    /// Deny multiple syscalls.
189    pub fn deny_many(mut self, syscalls: impl IntoIterator<Item = i64>) -> Self {
190        for syscall in syscalls {
191            self.denied.insert(syscall);
192            self.allowed.remove(&syscall);
193        }
194        self
195    }
196}
197
198/// Landlock filesystem and network access control configuration.
199///
200/// Landlock is a Linux security module (LSM) that provides fine-grained
201/// filesystem and network access control for unprivileged processes.
202///
203/// ## Example
204///
205/// ```ignore
206/// use evalbox_sandbox::Landlock;
207///
208/// let landlock = Landlock::default()
209///     .allow_read("/etc")
210///     .allow_read_write("/tmp/output")
211///     .allow_execute("/usr/bin");
212/// ```
213#[derive(Debug, Clone, Default)]
214pub struct Landlock {
215    /// Paths with read access.
216    pub read_paths: Vec<PathBuf>,
217    /// Paths with read-write access.
218    pub write_paths: Vec<PathBuf>,
219    /// Paths with execute access.
220    pub execute_paths: Vec<PathBuf>,
221}
222
223impl Landlock {
224    /// Create a new Landlock config.
225    pub fn new() -> Self {
226        Self::default()
227    }
228
229    /// Allow read access to a path.
230    pub fn allow_read(mut self, path: impl Into<PathBuf>) -> Self {
231        self.read_paths.push(path.into());
232        self
233    }
234
235    /// Allow read-write access to a path.
236    pub fn allow_read_write(mut self, path: impl Into<PathBuf>) -> Self {
237        self.write_paths.push(path.into());
238        self
239    }
240
241    /// Allow execute access to a path.
242    pub fn allow_execute(mut self, path: impl Into<PathBuf>) -> Self {
243        self.execute_paths.push(path.into());
244        self
245    }
246}
247
248/// File to write to workspace before execution.
249#[derive(Debug, Clone)]
250pub struct UserFile {
251    pub path: String,
252    pub content: Vec<u8>,
253    pub executable: bool,
254}
255
256impl UserFile {
257    pub fn new(path: impl Into<String>, content: impl Into<Vec<u8>>) -> Self {
258        Self {
259            path: path.into(),
260            content: content.into(),
261            executable: false,
262        }
263    }
264
265    pub fn executable(mut self) -> Self {
266        self.executable = true;
267        self
268    }
269}
270
271/// Complete sandbox execution plan.
272///
273/// This is the low-level API for full control over sandbox execution.
274/// Most users should use the high-level `evalbox` crate instead.
275///
276/// ## Example
277///
278/// ```ignore
279/// use evalbox_sandbox::{Plan, Mount, Executor};
280///
281/// let plan = Plan::new(["python3", "-c", "print('hello')"])
282///     .mount(Mount::ro("/usr/lib"))
283///     .timeout(Duration::from_secs(60))
284///     .memory(256 * 1024 * 1024)
285///     .network(false);
286///
287/// let output = Executor::run(plan)?;
288/// ```
289#[derive(Debug, Clone)]
290pub struct Plan {
291    pub cmd: Vec<String>,
292    /// Pre-resolved binary path. If set, sandbox uses this instead of resolving `cmd[0]`.
293    /// This allows evalbox to do binary resolution before calling sandbox.
294    pub binary_path: Option<PathBuf>,
295    pub env: HashMap<String, String>,
296    pub stdin: Option<Vec<u8>>,
297    pub cwd: String,
298    pub mounts: Vec<Mount>,
299    pub user_files: Vec<UserFile>,
300    pub workspace_size: u64,
301    pub timeout: Duration,
302    pub memory_limit: u64,
303    pub max_pids: u32,
304    pub max_output: u64,
305    pub network_blocked: bool,
306    /// Custom syscall filtering configuration.
307    pub syscalls: Option<Syscalls>,
308    /// Custom Landlock configuration.
309    pub landlock: Option<Landlock>,
310    /// Seccomp user notification mode.
311    pub notify_mode: NotifyMode,
312}
313
314impl Default for Plan {
315    fn default() -> Self {
316        Self {
317            cmd: Vec::new(),
318            binary_path: None,
319            env: default_env(),
320            stdin: None,
321            cwd: "/work".into(),
322            mounts: Vec::new(),
323            user_files: Vec::new(),
324            workspace_size: 64 * 1024 * 1024,
325            timeout: Duration::from_secs(30),
326            memory_limit: 256 * 1024 * 1024,
327            max_pids: 64,
328            max_output: 16 * 1024 * 1024,
329            network_blocked: true,
330            syscalls: None,
331            landlock: None,
332            notify_mode: NotifyMode::Disabled,
333        }
334    }
335}
336
337impl Plan {
338    pub fn new(cmd: impl IntoIterator<Item = impl Into<String>>) -> Self {
339        Self {
340            cmd: cmd.into_iter().map(Into::into).collect(),
341            ..Default::default()
342        }
343    }
344
345    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
346        self.env.insert(key.into(), value.into());
347        self
348    }
349
350    pub fn stdin(mut self, data: impl Into<Vec<u8>>) -> Self {
351        self.stdin = Some(data.into());
352        self
353    }
354
355    pub fn cwd(mut self, cwd: impl Into<String>) -> Self {
356        self.cwd = cwd.into();
357        self
358    }
359
360    pub fn mount(mut self, mount: Mount) -> Self {
361        self.mounts.push(mount);
362        self
363    }
364
365    /// Add multiple mounts from an iterator.
366    pub fn mounts(mut self, mounts: impl IntoIterator<Item = Mount>) -> Self {
367        self.mounts.extend(mounts);
368        self
369    }
370
371    /// Set pre-resolved binary path.
372    ///
373    /// When set, the sandbox uses this path directly instead of resolving `cmd[0]`.
374    /// This is used by evalbox to pre-resolve binaries before calling sandbox.
375    pub fn binary_path(mut self, path: impl Into<PathBuf>) -> Self {
376        self.binary_path = Some(path.into());
377        self
378    }
379
380    pub fn file(mut self, path: impl Into<String>, content: impl Into<Vec<u8>>) -> Self {
381        self.user_files.push(UserFile::new(path, content));
382        self
383    }
384
385    /// Add an executable binary to the workspace.
386    pub fn executable(mut self, path: impl Into<String>, content: impl Into<Vec<u8>>) -> Self {
387        self.user_files
388            .push(UserFile::new(path, content).executable());
389        self
390    }
391
392    pub fn timeout(mut self, timeout: Duration) -> Self {
393        self.timeout = timeout;
394        self
395    }
396
397    pub fn memory_limit(mut self, limit: u64) -> Self {
398        self.memory_limit = limit;
399        self
400    }
401
402    pub fn max_pids(mut self, max: u32) -> Self {
403        self.max_pids = max;
404        self
405    }
406
407    pub fn max_output(mut self, max: u64) -> Self {
408        self.max_output = max;
409        self
410    }
411
412    pub fn network_blocked(mut self, blocked: bool) -> Self {
413        self.network_blocked = blocked;
414        self
415    }
416
417    /// Enable or disable network access.
418    ///
419    /// This is the inverse of `network_blocked`: `network(true)` enables network,
420    /// `network(false)` blocks network (default).
421    pub fn network(mut self, enabled: bool) -> Self {
422        self.network_blocked = !enabled;
423        self
424    }
425
426    /// Set memory limit (alias for `memory_limit`).
427    pub fn memory(self, limit: u64) -> Self {
428        self.memory_limit(limit)
429    }
430
431    /// Set custom syscall filtering configuration.
432    pub fn syscalls(mut self, syscalls: Syscalls) -> Self {
433        self.syscalls = Some(syscalls);
434        self
435    }
436
437    /// Set custom Landlock configuration.
438    pub fn landlock(mut self, landlock: Landlock) -> Self {
439        self.landlock = Some(landlock);
440        self
441    }
442
443    /// Set the seccomp user notification mode.
444    ///
445    /// - `Disabled` (default): No notify filter, zero overhead.
446    /// - `Monitor`: Log intercepted syscalls for debugging.
447    /// - `Virtualize`: Full filesystem virtualization via path translation.
448    pub fn notify_mode(mut self, mode: NotifyMode) -> Self {
449        self.notify_mode = mode;
450        self
451    }
452
453    /// Execute this plan (convenience method).
454    ///
455    /// Equivalent to `Executor::run(self)`.
456    pub fn exec(self) -> Result<crate::Output, crate::ExecutorError> {
457        crate::Executor::run(self)
458    }
459}
460
461fn default_env() -> HashMap<String, String> {
462    // Default PATH covers common locations on FHS and NixOS systems.
463    // For NixOS, the caller (evalbox) should set PATH from SYSTEM_PATHS.
464    let default_path = if std::path::Path::new("/nix/store").exists() {
465        "/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin:/usr/bin:/bin"
466    } else {
467        "/usr/local/bin:/usr/bin:/bin"
468    };
469
470    HashMap::from([
471        ("PATH".into(), default_path.into()),
472        ("HOME".into(), "/home".into()),
473        ("USER".into(), "sandbox".into()),
474        ("LANG".into(), "C.UTF-8".into()),
475        ("LC_ALL".into(), "C.UTF-8".into()),
476    ])
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn plan_new() {
485        let plan = Plan::new(["echo", "hello"]);
486        assert_eq!(plan.cmd, vec!["echo", "hello"]);
487        assert!(plan.network_blocked);
488    }
489
490    #[test]
491    fn plan_builder() {
492        let plan = Plan::new(["python", "main.py"])
493            .env("PYTHONPATH", "/work")
494            .stdin(b"input".to_vec())
495            .timeout(Duration::from_secs(10))
496            .file("main.py", b"print('hello')");
497
498        assert_eq!(plan.env.get("PYTHONPATH"), Some(&"/work".into()));
499        assert_eq!(plan.stdin, Some(b"input".to_vec()));
500        assert_eq!(plan.timeout, Duration::from_secs(10));
501        assert_eq!(plan.user_files.len(), 1);
502    }
503
504    #[test]
505    fn plan_network_methods() {
506        let plan = Plan::new(["echo"]).network(true);
507        assert!(!plan.network_blocked);
508
509        let plan = Plan::new(["echo"]).network(false);
510        assert!(plan.network_blocked);
511    }
512
513    #[test]
514    fn plan_syscalls_config() {
515        let syscalls = Syscalls::default().allow(1).allow(2).deny(3);
516
517        assert!(syscalls.allowed.contains(&1));
518        assert!(syscalls.allowed.contains(&2));
519        assert!(syscalls.denied.contains(&3));
520    }
521
522    #[test]
523    fn plan_landlock_config() {
524        let landlock = Landlock::new().allow_read("/etc").allow_read_write("/tmp");
525
526        assert_eq!(landlock.read_paths.len(), 1);
527        assert_eq!(landlock.write_paths.len(), 1);
528    }
529}