1use std::path::PathBuf;
9use std::process::Command;
10use std::time::Duration;
11use thiserror::Error;
12
13#[derive(Error, Debug)]
15pub enum SandboxError {
16 #[error("Sandbox not available: {0}")]
18 NotAvailable(String),
19
20 #[error("Failed to spawn process: {0}")]
22 SpawnFailed(#[from] std::io::Error),
23
24 #[error("Profile error: {0}")]
26 ProfileError(String),
27
28 #[error("Execution error: {0}")]
30 ExecutionError(String),
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
35pub enum SandboxLevel {
36 None = 0,
38 Minimal = 1,
40 #[default]
42 Standard = 2,
43 Strict = 3,
45 Paranoid = 4,
47}
48
49#[derive(Debug, Clone)]
51pub struct SandboxConfig {
52 pub level: SandboxLevel,
54 pub max_memory_mb: u64,
56 pub max_cpu_seconds: u64,
58 pub max_file_descriptors: u64,
60 pub allowed_paths: Vec<PathBuf>,
62 pub readonly_paths: Vec<PathBuf>,
64 pub env_allowlist: Vec<String>,
66 pub network_allowed: bool,
68 pub work_dir: Option<PathBuf>,
70}
71
72impl Default for SandboxConfig {
73 fn default() -> Self {
74 Self {
75 level: SandboxLevel::Standard,
76 max_memory_mb: 512,
77 max_cpu_seconds: 60,
78 max_file_descriptors: 256,
79 allowed_paths: vec![],
80 readonly_paths: vec![],
81 env_allowlist: vec!["PATH".into(), "HOME".into(), "LANG".into(), "TERM".into()],
82 network_allowed: false,
83 work_dir: None,
84 }
85 }
86}
87
88#[derive(Debug, Clone)]
90pub struct SandboxOutput {
91 pub stdout: String,
93 pub stderr: String,
95 pub exit_code: i32,
97 pub duration: Duration,
99 pub killed: bool,
101 pub kill_reason: Option<String>,
103}
104
105pub fn execute_sandboxed(
117 command: &str,
118 args: &[&str],
119 config: &SandboxConfig,
120) -> Result<SandboxOutput, SandboxError> {
121 #[cfg(target_os = "linux")]
122 {
123 execute_sandboxed_linux(command, args, config)
124 }
125
126 #[cfg(target_os = "macos")]
127 {
128 execute_sandboxed_macos(command, args, config)
129 }
130
131 #[cfg(target_os = "windows")]
132 {
133 execute_sandboxed_windows(command, args, config)
134 }
135
136 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
137 {
138 Err(SandboxError::NotAvailable(
139 "No sandbox available for this platform".to_string(),
140 ))
141 }
142}
143
144#[cfg(target_os = "linux")]
146fn execute_sandboxed_linux(
147 command: &str,
148 args: &[&str],
149 config: &SandboxConfig,
150) -> Result<SandboxOutput, SandboxError> {
151 use std::time::Instant;
152
153 if !Command::new("which")
155 .arg("bwrap")
156 .output()?
157 .status
158 .success()
159 {
160 return Err(SandboxError::NotAvailable(
161 "bubblewrap (bwrap) not installed".to_string(),
162 ));
163 }
164
165 let mut bwrap = Command::new("bwrap");
166
167 bwrap
169 .arg("--unshare-pid")
170 .arg("--unshare-uts")
171 .arg("--die-with-parent");
172
173 match config.level {
175 SandboxLevel::None => {
176 bwrap.arg("--bind").arg("/").arg("/");
178 }
179 SandboxLevel::Minimal => {
180 bwrap.arg("--ro-bind").arg("/").arg("/");
181 }
182 SandboxLevel::Standard | SandboxLevel::Strict => {
183 bwrap
184 .arg("--ro-bind")
185 .arg("/usr")
186 .arg("/usr")
187 .arg("--ro-bind")
188 .arg("/lib")
189 .arg("/lib")
190 .arg("--ro-bind")
191 .arg("/bin")
192 .arg("/bin")
193 .arg("--ro-bind")
194 .arg("/sbin")
195 .arg("/sbin")
196 .arg("--symlink")
197 .arg("/usr/lib64")
198 .arg("/lib64")
199 .arg("--tmpfs")
200 .arg("/tmp")
201 .arg("--proc")
202 .arg("/proc")
203 .arg("--dev")
204 .arg("/dev");
205 }
206 SandboxLevel::Paranoid => {
207 bwrap
208 .arg("--tmpfs")
209 .arg("/")
210 .arg("--ro-bind")
211 .arg("/usr/bin")
212 .arg("/usr/bin")
213 .arg("--ro-bind")
214 .arg("/usr/lib")
215 .arg("/usr/lib")
216 .arg("--proc")
217 .arg("/proc")
218 .arg("--dev")
219 .arg("/dev");
220 }
221 }
222
223 if !config.network_allowed && config.level >= SandboxLevel::Strict {
225 bwrap.arg("--unshare-net");
226 }
227
228 for path in &config.allowed_paths {
230 bwrap.arg("--bind").arg(path).arg(path);
231 }
232
233 for path in &config.readonly_paths {
235 bwrap.arg("--ro-bind").arg(path).arg(path);
236 }
237
238 bwrap.arg("--clearenv");
240 for var in &config.env_allowlist {
241 if let Ok(val) = std::env::var(var) {
242 bwrap.arg("--setenv").arg(var).arg(val);
243 }
244 }
245
246 if let Some(work_dir) = &config.work_dir {
248 bwrap.arg("--chdir").arg(work_dir);
249 }
250
251 bwrap.arg("--").arg(command).args(args);
253
254 let start = Instant::now();
256 let output = bwrap.output()?;
257 let duration = start.elapsed();
258
259 Ok(SandboxOutput {
260 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
261 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
262 exit_code: output.status.code().unwrap_or(-1),
263 duration,
264 killed: !output.status.success() && output.status.code().is_none(),
265 kill_reason: None,
266 })
267}
268
269#[cfg(target_os = "macos")]
271fn execute_sandboxed_macos(
272 command: &str,
273 args: &[&str],
274 config: &SandboxConfig,
275) -> Result<SandboxOutput, SandboxError> {
276 use std::io::Write;
277 use std::time::Instant;
278 use tempfile::NamedTempFile;
279
280 let profile = generate_seatbelt_profile(config)?;
282
283 let mut profile_file = NamedTempFile::new()?;
285 profile_file.write_all(profile.as_bytes())?;
286 profile_file.flush()?;
287
288 let mut sandbox_cmd = Command::new("sandbox-exec");
290 sandbox_cmd
291 .arg("-f")
292 .arg(profile_file.path())
293 .arg(command)
294 .args(args);
295
296 sandbox_cmd.env_clear();
298 for var in &config.env_allowlist {
299 if let Ok(val) = std::env::var(var) {
300 sandbox_cmd.env(var, val);
301 }
302 }
303
304 if let Some(work_dir) = &config.work_dir {
306 sandbox_cmd.current_dir(work_dir);
307 }
308
309 let start = Instant::now();
311 let output = sandbox_cmd.output()?;
312 let duration = start.elapsed();
313
314 Ok(SandboxOutput {
315 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
316 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
317 exit_code: output.status.code().unwrap_or(-1),
318 duration,
319 killed: !output.status.success() && output.status.code().is_none(),
320 kill_reason: None,
321 })
322}
323
324#[cfg(target_os = "macos")]
326fn generate_seatbelt_profile(config: &SandboxConfig) -> Result<String, SandboxError> {
327 let mut profile = String::from("(version 1)\n");
328
329 match config.level {
330 SandboxLevel::None => {
331 profile.push_str("(allow default)\n");
332 return Ok(profile);
333 }
334 _ => {
335 profile.push_str("(deny default)\n");
336 }
337 }
338
339 profile.push_str(
341 r#"
342; Allow process execution
343(allow process-exec)
344(allow process-fork)
345
346; Allow reading system libraries and frameworks
347(allow file-read*
348 (subpath "/usr/lib")
349 (subpath "/usr/share")
350 (subpath "/System/Library/Frameworks")
351 (subpath "/System/Library/PrivateFrameworks")
352 (subpath "/Library/Frameworks")
353 (subpath "/private/var/db/dyld")
354 (literal "/dev/null")
355 (literal "/dev/zero")
356 (literal "/dev/urandom")
357 (literal "/dev/random")
358 (literal "/dev/tty"))
359
360; Allow reading standard paths
361(allow file-read*
362 (subpath "/usr/bin")
363 (subpath "/usr/sbin")
364 (subpath "/bin")
365 (subpath "/sbin")
366 (subpath "/opt/homebrew")
367 (subpath "/usr/local"))
368
369; Allow basic Mach and signal operations
370(allow mach-lookup)
371(allow signal (target self))
372(allow sysctl-read)
373"#,
374 );
375
376 for path in &config.allowed_paths {
378 let path_str = path.display();
379 profile.push_str(&format!(
380 "(allow file-read* file-write* (subpath \"{path_str}\"))\n"
381 ));
382 }
383
384 for path in &config.readonly_paths {
386 let path_str = path.display();
387 profile.push_str(&format!("(allow file-read* (subpath \"{path_str}\"))\n"));
388 }
389
390 profile.push_str(
392 r#"
393; Allow temp file operations
394(allow file-read* file-write*
395 (subpath "/private/tmp")
396 (subpath "/var/folders"))
397"#,
398 );
399
400 if config.network_allowed {
402 profile.push_str(
403 r#"
404; Allow network access
405(allow network*)
406"#,
407 );
408 } else if config.level < SandboxLevel::Strict {
409 profile.push_str(
410 r#"
411; Allow DNS lookup only
412(allow network-outbound (remote unix-socket (path-literal "/var/run/mDNSResponder")))
413"#,
414 );
415 }
416
417 if config.level < SandboxLevel::Paranoid {
419 profile.push_str(
420 r#"
421; Allow reading home directory
422(allow file-read* (subpath (param "HOME")))
423"#,
424 );
425 }
426
427 Ok(profile)
428}
429
430#[cfg(target_os = "windows")]
438fn execute_sandboxed_windows(
439 command: &str,
440 args: &[&str],
441 config: &SandboxConfig,
442) -> Result<SandboxOutput, SandboxError> {
443 use std::ffi::OsStr;
444 use std::os::windows::ffi::OsStrExt;
445 use std::os::windows::process::CommandExt;
446 use std::ptr;
447 use std::time::Instant;
448
449 use windows_sys::Win32::Foundation::{
450 CloseHandle, GetLastError, HANDLE, INVALID_HANDLE_VALUE, WAIT_OBJECT_0, WAIT_TIMEOUT,
451 };
452 use windows_sys::Win32::System::JobObjects::{
453 AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_ACTIVE_PROCESS,
454 JOB_OBJECT_LIMIT_JOB_MEMORY, JOB_OBJECT_LIMIT_JOB_TIME, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
455 JOBOBJECT_BASIC_LIMIT_INFORMATION, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
456 JobObjectBasicLimitInformation, JobObjectExtendedLimitInformation,
457 QueryInformationJobObject, SetInformationJobObject, TerminateJobObject,
458 };
459 use windows_sys::Win32::System::Threading::{
460 CREATE_SUSPENDED, GetExitCodeProcess, INFINITE, OpenProcess, PROCESS_ALL_ACCESS,
461 ResumeThread, WaitForSingleObject,
462 };
463
464 tracing::info!("Windows sandbox using Job Objects (limited filesystem/network isolation)");
465
466 if config.level >= SandboxLevel::Strict {
468 tracing::warn!(
469 "Windows Job Objects do not provide filesystem or network isolation. \
470 Consider using WSL2 for SandboxLevel::Strict or higher."
471 );
472 }
473
474 let job: HANDLE = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) };
476 if job == 0 || job == INVALID_HANDLE_VALUE {
477 return Err(SandboxError::ExecutionError(format!(
478 "Failed to create job object: {}",
479 unsafe { GetLastError() }
480 )));
481 }
482
483 struct JobGuard(HANDLE);
485 impl Drop for JobGuard {
486 fn drop(&mut self) {
487 unsafe { CloseHandle(self.0) };
488 }
489 }
490 let _job_guard = JobGuard(job);
491
492 let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
494
495 let memory_limit = config.max_memory_mb * 1024 * 1024;
497 info.JobMemoryLimit = memory_limit as usize;
498
499 let cpu_limit = config.max_cpu_seconds as i64 * 10_000_000;
501 info.BasicLimitInformation.PerJobUserTimeLimit = cpu_limit;
502
503 info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_JOB_MEMORY
505 | JOB_OBJECT_LIMIT_JOB_TIME
506 | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
507 | JOB_OBJECT_LIMIT_ACTIVE_PROCESS;
508 info.BasicLimitInformation.ActiveProcessLimit = 1;
509
510 let set_result = unsafe {
511 SetInformationJobObject(
512 job,
513 JobObjectExtendedLimitInformation,
514 &info as *const _ as *const std::ffi::c_void,
515 std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
516 )
517 };
518 if set_result == 0 {
519 return Err(SandboxError::ExecutionError(format!(
520 "Failed to set job object limits: {}",
521 unsafe { GetLastError() }
522 )));
523 }
524
525 let mut cmd = Command::new(command);
527 cmd.args(args);
528
529 cmd.env_clear();
531 for var in &config.env_allowlist {
532 if let Ok(val) = std::env::var(var) {
533 cmd.env(var, val);
534 }
535 }
536
537 if let Some(work_dir) = &config.work_dir {
539 cmd.current_dir(work_dir);
540 }
541
542 cmd.creation_flags(CREATE_SUSPENDED);
544
545 let start = Instant::now();
546
547 let child = cmd.spawn().map_err(SandboxError::SpawnFailed)?;
549 let pid = child.id();
550
551 let process_handle: HANDLE = unsafe { OpenProcess(PROCESS_ALL_ACCESS, 0, pid) };
553 if process_handle == 0 || process_handle == INVALID_HANDLE_VALUE {
554 return Err(SandboxError::ExecutionError(format!(
555 "Failed to open process handle: {}",
556 unsafe { GetLastError() }
557 )));
558 }
559
560 struct ProcessGuard(HANDLE);
561 impl Drop for ProcessGuard {
562 fn drop(&mut self) {
563 unsafe { CloseHandle(self.0) };
564 }
565 }
566 let _process_guard = ProcessGuard(process_handle);
567
568 let assign_result = unsafe { AssignProcessToJobObject(job, process_handle) };
570 if assign_result == 0 {
571 unsafe { TerminateJobObject(job, 1) };
573 return Err(SandboxError::ExecutionError(format!(
574 "Failed to assign process to job: {}",
575 unsafe { GetLastError() }
576 )));
577 }
578
579 drop(_process_guard);
594 drop(_job_guard);
595
596 let job: HANDLE = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) };
598 if job == 0 || job == INVALID_HANDLE_VALUE {
599 return Err(SandboxError::ExecutionError(
600 "Failed to create job object".to_string(),
601 ));
602 }
603 let _job_guard = JobGuard(job);
604
605 let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
607 info.JobMemoryLimit = (config.max_memory_mb * 1024 * 1024) as usize;
608 info.BasicLimitInformation.PerJobUserTimeLimit = config.max_cpu_seconds as i64 * 10_000_000;
609 info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_JOB_MEMORY
610 | JOB_OBJECT_LIMIT_JOB_TIME
611 | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
612
613 unsafe {
614 SetInformationJobObject(
615 job,
616 JobObjectExtendedLimitInformation,
617 &info as *const _ as *const std::ffi::c_void,
618 std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
619 );
620 }
621
622 let mut cmd = Command::new(command);
624 cmd.args(args);
625 cmd.env_clear();
626 for var in &config.env_allowlist {
627 if let Ok(val) = std::env::var(var) {
628 cmd.env(var, val);
629 }
630 }
631 if let Some(work_dir) = &config.work_dir {
632 cmd.current_dir(work_dir);
633 }
634
635 let start = Instant::now();
636 let mut child = cmd
637 .stdout(std::process::Stdio::piped())
638 .stderr(std::process::Stdio::piped())
639 .spawn()
640 .map_err(SandboxError::SpawnFailed)?;
641
642 let process_handle: HANDLE = unsafe { OpenProcess(PROCESS_ALL_ACCESS, 0, child.id()) };
644 if process_handle != 0 && process_handle != INVALID_HANDLE_VALUE {
645 unsafe { AssignProcessToJobObject(job, process_handle) };
646 unsafe { CloseHandle(process_handle) };
647 }
648
649 let timeout_ms = (config.max_cpu_seconds * 1000) as u32;
651 let process_handle: HANDLE = unsafe { OpenProcess(PROCESS_ALL_ACCESS, 0, child.id()) };
652
653 let mut killed = false;
654 let mut kill_reason = None;
655
656 if process_handle != 0 && process_handle != INVALID_HANDLE_VALUE {
657 let wait_result = unsafe { WaitForSingleObject(process_handle, timeout_ms.max(1000)) };
658
659 if wait_result == WAIT_TIMEOUT {
660 killed = true;
662 kill_reason = Some("CPU time limit exceeded".to_string());
663 unsafe { TerminateJobObject(job, 1) };
664 let _ = child.kill();
665 }
666 unsafe { CloseHandle(process_handle) };
667 }
668
669 let output = child
671 .wait_with_output()
672 .map_err(SandboxError::SpawnFailed)?;
673 let duration = start.elapsed();
674
675 if !killed && output.status.code().is_none() {
677 killed = true;
678 kill_reason = Some("Terminated by job object (possibly memory limit)".to_string());
679 }
680
681 Ok(SandboxOutput {
682 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
683 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
684 exit_code: output.status.code().unwrap_or(-1),
685 duration,
686 killed,
687 kill_reason,
688 })
689}
690
691#[must_use]
693pub fn is_sandbox_available() -> bool {
694 #[cfg(target_os = "linux")]
695 {
696 Command::new("which")
697 .arg("bwrap")
698 .output()
699 .map(|o| o.status.success())
700 .unwrap_or(false)
701 }
702
703 #[cfg(target_os = "macos")]
704 {
705 Command::new("which")
706 .arg("sandbox-exec")
707 .output()
708 .map(|o| o.status.success())
709 .unwrap_or(false)
710 }
711
712 #[cfg(target_os = "windows")]
713 {
714 true }
716
717 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
718 {
719 false
720 }
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726
727 #[test]
728 fn test_default_config() {
729 let config = SandboxConfig::default();
730 assert_eq!(config.level, SandboxLevel::Standard);
731 assert!(!config.network_allowed);
732 }
733
734 #[test]
735 fn test_sandbox_level_ordering() {
736 assert!(SandboxLevel::Paranoid > SandboxLevel::Strict);
737 assert!(SandboxLevel::Strict > SandboxLevel::Standard);
738 assert!(SandboxLevel::Standard > SandboxLevel::Minimal);
739 assert!(SandboxLevel::Minimal > SandboxLevel::None);
740 }
741
742 #[test]
743 fn test_sandbox_available() {
744 let _ = is_sandbox_available();
746 }
747
748 #[test]
749 #[ignore] #[cfg(any(target_os = "linux", target_os = "macos"))]
751 fn test_simple_command() {
752 if !is_sandbox_available() {
753 return; }
755
756 let config = SandboxConfig {
757 level: SandboxLevel::Minimal,
758 ..Default::default()
759 };
760
761 let result = execute_sandboxed("echo", &["hello"], &config);
762 assert!(
763 result.is_ok(),
764 "Sandbox execution failed: {:?}",
765 result.err()
766 );
767
768 let output = result.unwrap();
769 assert_eq!(output.stdout.trim(), "hello");
770 assert_eq!(output.exit_code, 0);
771 }
772}