1use anyhow::{Context, Result};
6use std::collections::BTreeMap;
7use std::io::Read;
8#[cfg(any(target_os = "macos", target_os = "linux"))]
9use std::path::Path;
10use std::path::PathBuf;
11use std::process::{Command, Stdio};
12use std::time::Duration;
13
14const DEFAULT_OUTPUT_LIMIT: usize = 8 * 1024 * 1024;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum FilesystemAccess {
19 ReadOnly,
21 WorkspaceWrite,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum IsolationMode {
28 Required,
29 BestEffort,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct ProcessPolicy {
36 pub workspace_root: PathBuf,
37 pub filesystem: FilesystemAccess,
38 pub allow_network: bool,
39 pub timeout: Duration,
40 pub output_limit: usize,
41 pub isolation: IsolationMode,
42 pub environment: BTreeMap<String, String>,
43}
44
45impl ProcessPolicy {
46 pub fn inspection(workspace_root: impl Into<PathBuf>) -> Self {
47 Self {
48 workspace_root: workspace_root.into(),
49 filesystem: FilesystemAccess::ReadOnly,
50 allow_network: false,
51 timeout: Duration::from_secs(60),
52 output_limit: DEFAULT_OUTPUT_LIMIT,
53 isolation: IsolationMode::Required,
54 environment: minimal_environment(),
55 }
56 }
57
58 pub fn candidate_mutation(workspace_root: impl Into<PathBuf>) -> Self {
59 Self {
60 filesystem: FilesystemAccess::WorkspaceWrite,
61 ..Self::inspection(workspace_root)
62 }
63 }
64
65 pub fn with_network(mut self, allow: bool) -> Self {
66 self.allow_network = allow;
67 self
68 }
69
70 pub fn best_effort(mut self) -> Self {
71 self.isolation = IsolationMode::BestEffort;
72 self
73 }
74}
75
76#[derive(Debug, Clone)]
79pub struct ProcessSandbox {
80 program: String,
81 args: Vec<String>,
82 policy: ProcessPolicy,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct PreparedProcess {
87 pub program: String,
88 pub args: Vec<String>,
89 pub working_dir: PathBuf,
90 pub environment: BTreeMap<String, String>,
91 pub isolated: bool,
92}
93
94impl ProcessSandbox {
95 pub fn new(
96 program: impl Into<String>,
97 args: Vec<String>,
98 policy: ProcessPolicy,
99 ) -> Result<Self> {
100 let root = policy
101 .workspace_root
102 .canonicalize()
103 .with_context(|| format!("canonicalizing {}", policy.workspace_root.display()))?;
104 let mut policy = policy;
105 policy.workspace_root = root;
106 Ok(Self {
107 program: program.into(),
108 args,
109 policy,
110 })
111 }
112
113 pub fn prepare_invocation(&self) -> Result<PreparedProcess> {
114 let (program, args, isolated) = platform_command(&self.program, &self.args, &self.policy);
115 if !isolated && self.policy.isolation == IsolationMode::Required {
116 anyhow::bail!("no supported OS process sandbox is available");
117 }
118 Ok(PreparedProcess {
119 program,
120 args,
121 working_dir: self.policy.workspace_root.clone(),
122 environment: self.policy.environment.clone(),
123 isolated,
124 })
125 }
126
127 fn prepared(&self) -> Result<BasicSandbox> {
128 let PreparedProcess {
129 program,
130 args,
131 working_dir,
132 environment,
133 ..
134 } = self.prepare_invocation()?;
135 let mut command = BasicSandbox::new(program, args)
136 .with_working_dir(working_dir.to_string_lossy().into_owned())
137 .with_timeout(self.policy.timeout)
138 .with_output_limit(self.policy.output_limit)
139 .with_clean_environment();
140 for (key, value) in environment {
141 command = command.with_environment(key, value);
142 }
143 Ok(command)
144 }
145}
146
147impl SandboxedCommand for ProcessSandbox {
148 fn execute(&self) -> Result<CommandResult> {
149 self.prepared()?.execute()
150 }
151
152 fn display(&self) -> String {
153 BasicSandbox::new(self.program.clone(), self.args.clone()).display()
154 }
155
156 fn is_read_only(&self) -> bool {
157 self.policy.filesystem == FilesystemAccess::ReadOnly
158 }
159}
160
161#[derive(Debug, Clone)]
163pub struct CommandResult {
164 pub stdout: String,
166 pub stderr: String,
168 pub exit_code: Option<i32>,
170 pub timed_out: bool,
172 pub duration: Duration,
174}
175
176impl CommandResult {
177 pub fn success(&self) -> bool {
179 self.exit_code == Some(0) && !self.timed_out
180 }
181}
182
183pub trait SandboxedCommand: Send + Sync {
188 fn execute(&self) -> Result<CommandResult>;
190
191 fn display(&self) -> String;
193
194 fn is_read_only(&self) -> bool;
196}
197
198pub struct BasicSandbox {
203 program: String,
205 args: Vec<String>,
207 working_dir: Option<String>,
209 timeout: Option<Duration>,
211 output_limit: usize,
212 inherit_environment: bool,
213 environment: BTreeMap<String, String>,
214}
215
216impl BasicSandbox {
217 pub fn new(program: String, args: Vec<String>) -> Self {
219 Self {
220 program,
221 args,
222 working_dir: None,
223 timeout: Some(Duration::from_secs(60)), output_limit: DEFAULT_OUTPUT_LIMIT,
225 inherit_environment: true,
226 environment: BTreeMap::new(),
227 }
228 }
229
230 pub fn with_working_dir(mut self, dir: String) -> Self {
232 self.working_dir = Some(dir);
233 self
234 }
235
236 pub fn with_timeout(mut self, timeout: Duration) -> Self {
238 self.timeout = Some(timeout);
239 self
240 }
241
242 pub fn with_output_limit(mut self, bytes: usize) -> Self {
243 self.output_limit = bytes.max(1);
244 self
245 }
246
247 pub fn with_clean_environment(mut self) -> Self {
248 self.inherit_environment = false;
249 self
250 }
251
252 pub fn with_environment(mut self, key: String, value: String) -> Self {
253 self.environment.insert(key, value);
254 self
255 }
256}
257
258impl SandboxedCommand for BasicSandbox {
259 fn execute(&self) -> Result<CommandResult> {
260 let start = std::time::Instant::now();
261
262 let mut cmd = Command::new(&self.program);
263 cmd.args(&self.args)
264 .stdout(Stdio::piped())
265 .stderr(Stdio::piped());
266
267 if !self.inherit_environment {
268 cmd.env_clear();
269 }
270 cmd.envs(&self.environment);
271
272 if let Some(ref dir) = self.working_dir {
273 cmd.current_dir(dir);
274 }
275
276 let mut child = cmd.spawn()?;
277 let stdout = child.stdout.take().context("capturing process stdout")?;
278 let stderr = child.stderr.take().context("capturing process stderr")?;
279 let limit = self.output_limit;
280 let stdout_reader = std::thread::spawn(move || read_bounded(stdout, limit));
281 let stderr_reader = std::thread::spawn(move || read_bounded(stderr, limit));
282
283 if let Some(timeout) = self.timeout {
285 let deadline = start + timeout;
286 loop {
287 match child.try_wait() {
288 Ok(Some(_status)) => {
289 break;
291 }
292 Ok(None) => {
293 if std::time::Instant::now() >= deadline {
295 let _ = child.kill();
297 let _ = child.wait(); let duration = start.elapsed();
299 let stdout = join_reader(stdout_reader)?;
300 let captured_stderr = join_reader(stderr_reader)?;
301 return Ok(CommandResult {
302 stdout,
303 stderr: format!(
304 "Process killed after {}s timeout\n{}",
305 timeout.as_secs(),
306 captured_stderr
307 ),
308 exit_code: None,
309 timed_out: true,
310 duration,
311 });
312 }
313 std::thread::sleep(Duration::from_millis(50));
315 }
316 Err(e) => {
317 return Err(e.into());
318 }
319 }
320 }
321 }
322
323 let status = child.wait()?;
324 let duration = start.elapsed();
325
326 Ok(CommandResult {
327 stdout: join_reader(stdout_reader)?,
328 stderr: join_reader(stderr_reader)?,
329 exit_code: status.code(),
330 timed_out: false,
331 duration,
332 })
333 }
334
335 fn display(&self) -> String {
336 if self.args.is_empty() {
337 self.program.clone()
338 } else {
339 format!("{} {}", self.program, self.args.join(" "))
340 }
341 }
342
343 fn is_read_only(&self) -> bool {
344 let read_only_programs = [
351 "ls", "cat", "head", "tail", "grep", "rg", "which", "pwd", "whoami", "date",
352 "printenv", "file", "stat", "wc",
353 ];
354 read_only_programs.contains(&self.program.as_str())
355 }
356}
357
358fn read_bounded(mut reader: impl Read, limit: usize) -> std::io::Result<String> {
359 let mut retained = Vec::with_capacity(limit.min(64 * 1024));
360 let mut chunk = [0u8; 8192];
361 let mut truncated = false;
362 loop {
363 let count = reader.read(&mut chunk)?;
364 if count == 0 {
365 break;
366 }
367 let available = limit.saturating_sub(retained.len());
368 retained.extend_from_slice(&chunk[..count.min(available)]);
369 truncated |= count > available;
370 }
371 let mut value = String::from_utf8_lossy(&retained).into_owned();
372 if truncated {
373 value.push_str("\n[output truncated by sandbox]");
374 }
375 Ok(value)
376}
377
378fn join_reader(reader: std::thread::JoinHandle<std::io::Result<String>>) -> Result<String> {
379 reader
380 .join()
381 .map_err(|_| anyhow::anyhow!("process output reader panicked"))?
382 .map_err(Into::into)
383}
384
385fn minimal_environment() -> BTreeMap<String, String> {
386 let mut environment = BTreeMap::new();
387 for key in [
388 "PATH",
389 "LANG",
390 "LC_ALL",
391 "USER",
392 "SystemRoot",
393 "SYSTEMROOT",
394 "WINDIR",
395 "COMSPEC",
396 "PATHEXT",
397 ] {
398 if let Ok(value) = std::env::var(key) {
399 environment.insert(key.to_string(), value);
400 }
401 }
402 environment
403}
404
405#[cfg(target_os = "macos")]
406fn platform_command(
407 program: &str,
408 args: &[String],
409 policy: &ProcessPolicy,
410) -> (String, Vec<String>, bool) {
411 if !Path::new("/usr/bin/sandbox-exec").is_file() {
412 return (program.to_string(), args.to_vec(), false);
413 }
414 let root = policy
415 .workspace_root
416 .to_string_lossy()
417 .replace('\\', "\\\\")
418 .replace('"', "\\\"");
419 let write_rule = match policy.filesystem {
420 FilesystemAccess::ReadOnly => String::new(),
421 FilesystemAccess::WorkspaceWrite => {
422 format!("(allow file-write* (subpath \"{root}\"))")
423 }
424 };
425 let network_rule = if policy.allow_network {
426 "(allow network*)"
427 } else {
428 ""
429 };
430 let profile = format!(
435 "(version 1) (deny default) (allow process*) (allow sysctl-read) \
436 (allow file-read* (subpath \"{root}\")) \
437 (allow file-read* (subpath \"/System\")) \
438 (allow file-read* (subpath \"/usr\")) \
439 (allow file-read* (subpath \"/bin\")) \
440 (allow file-read* (subpath \"/sbin\")) \
441 (allow file-read* (subpath \"/Library\")) \
442 (allow file-read* (subpath \"/opt/homebrew\")) \
443 (allow file-read* (subpath \"/private/var/db/dyld\")) \
444 (allow file-read* (literal \"/dev/null\")) \
445 (allow file-write* (literal \"/dev/null\")) \
446 {write_rule} {network_rule}"
447 );
448 let mut wrapped = vec!["-p".into(), profile, "--".into(), program.into()];
449 wrapped.extend_from_slice(args);
450 ("/usr/bin/sandbox-exec".into(), wrapped, true)
451}
452
453#[cfg(target_os = "linux")]
454fn platform_command(
455 program: &str,
456 args: &[String],
457 policy: &ProcessPolicy,
458) -> (String, Vec<String>, bool) {
459 let Some(bwrap) = ["/usr/bin/bwrap", "/bin/bwrap"]
460 .into_iter()
461 .find(|path| Path::new(path).is_file())
462 else {
463 return (program.to_string(), args.to_vec(), false);
464 };
465 let root = policy.workspace_root.to_string_lossy().into_owned();
466 let mut wrapped = vec![
467 "--die-with-parent".into(),
468 "--new-session".into(),
469 "--ro-bind".into(),
470 "/".into(),
471 "/".into(),
472 "--dev".into(),
473 "/dev".into(),
474 ];
475 for sensitive in ["/home", "/root", "/Users"] {
478 if Path::new(sensitive).exists() && !policy.workspace_root.starts_with(sensitive) {
479 wrapped.extend(["--tmpfs".into(), sensitive.into()]);
480 }
481 }
482 wrapped.extend(["--tmpfs".into(), "/tmp".into()]);
483 let root_bind = if policy.filesystem == FilesystemAccess::WorkspaceWrite {
488 "--bind"
489 } else {
490 "--ro-bind"
491 };
492 wrapped.extend([root_bind.into(), root.clone(), root.clone()]);
493 if !policy.allow_network {
494 wrapped.push("--unshare-net".into());
495 }
496 wrapped.extend(["--chdir".into(), root, "--".into(), program.into()]);
497 wrapped.extend_from_slice(args);
498 (bwrap.into(), wrapped, true)
499}
500
501#[cfg(not(any(target_os = "macos", target_os = "linux")))]
502fn platform_command(
503 program: &str,
504 args: &[String],
505 _policy: &ProcessPolicy,
506) -> (String, Vec<String>, bool) {
507 (program.to_string(), args.to_vec(), false)
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 #[test]
515 fn test_basic_sandbox_echo() {
516 let sandbox = BasicSandbox::new("echo".to_string(), vec!["hello".to_string()]);
517 let result = sandbox.execute().unwrap();
518 assert!(result.success());
519 assert_eq!(result.stdout.trim(), "hello");
520 }
521
522 #[test]
523 fn test_display() {
524 let sandbox = BasicSandbox::new(
525 "cargo".to_string(),
526 vec!["build".to_string(), "--release".to_string()],
527 );
528 assert_eq!(sandbox.display(), "cargo build --release");
529 }
530
531 #[test]
532 fn test_is_read_only() {
533 let sandbox = BasicSandbox::new("ls".to_string(), vec!["-la".to_string()]);
534 assert!(sandbox.is_read_only());
535
536 let sandbox = BasicSandbox::new("rm".to_string(), vec!["file.txt".to_string()]);
537 assert!(!sandbox.is_read_only());
538 }
539
540 #[cfg(unix)]
545 #[test]
546 fn test_basic_sandbox_with_working_dir() {
547 let temp = std::env::temp_dir();
548 let sandbox = BasicSandbox::new("pwd".to_string(), vec![])
549 .with_working_dir(temp.to_string_lossy().to_string());
550 let result = sandbox.execute().unwrap();
551 assert!(result.success());
552 let output_path = std::path::PathBuf::from(result.stdout.trim());
555 let expected = std::fs::canonicalize(&temp).unwrap();
556 let actual = std::fs::canonicalize(&output_path).unwrap();
557 assert_eq!(
558 actual, expected,
559 "pwd should match the specified working dir"
560 );
561 }
562
563 #[cfg(windows)]
564 #[test]
565 fn test_basic_sandbox_with_working_dir() {
566 let unique = format!("perspt_test_{}", std::process::id());
570 let dir = std::env::temp_dir().join(&unique);
571 std::fs::create_dir_all(&dir).unwrap();
572 let sandbox =
573 BasicSandbox::new("cmd".to_string(), vec!["/C".to_string(), "cd".to_string()])
574 .with_working_dir(dir.to_string_lossy().to_string());
575 let result = sandbox.execute().unwrap();
576 let _ = std::fs::remove_dir(&dir);
577 assert!(result.success(), "cmd /C cd should succeed");
578 let output = result.stdout.trim();
579 assert!(
580 output.ends_with(&unique),
581 "working dir output should end with our unique dir name, got: {output}"
582 );
583 }
584
585 #[test]
586 fn test_basic_sandbox_captures_stderr() {
587 let sandbox = BasicSandbox::new(
588 "sh".to_string(),
589 vec!["-c".to_string(), "echo err >&2".to_string()],
590 );
591 let result = sandbox.execute().unwrap();
592 assert!(result.success());
593 assert!(
594 result.stderr.contains("err"),
595 "stderr should capture error output"
596 );
597 }
598
599 #[test]
600 fn test_basic_sandbox_nonzero_exit() {
601 let sandbox = BasicSandbox::new("false".to_string(), vec![]);
602 let result = sandbox.execute().unwrap();
603 assert!(!result.success());
604 assert_eq!(result.exit_code, Some(1));
605 assert!(!result.timed_out);
606 }
607
608 #[test]
609 fn test_basic_sandbox_timeout_fast_command_succeeds() {
610 let sandbox = BasicSandbox::new("echo".to_string(), vec!["fast".to_string()])
612 .with_timeout(Duration::from_secs(60));
613 let result = sandbox.execute().unwrap();
614 assert!(!result.timed_out);
615 assert!(result.success());
616 }
617
618 #[test]
619 fn test_display_no_args() {
620 let sandbox = BasicSandbox::new("pwd".to_string(), vec![]);
621 assert_eq!(sandbox.display(), "pwd");
622 }
623
624 #[test]
625 fn test_is_read_only_is_conservative() {
626 for (program, arg) in [("cargo", "check"), ("cargo", "test"), ("git", "status")] {
631 let sandbox = BasicSandbox::new(program.to_string(), vec![arg.to_string()]);
632 assert!(!sandbox.is_read_only(), "{program} {arg}");
633 }
634 let sandbox = BasicSandbox::new("catastrophe".to_string(), vec![]);
636 assert!(!sandbox.is_read_only());
637 }
638
639 #[test]
640 fn test_command_result_duration_nonzero() {
641 let sandbox = BasicSandbox::new("echo".to_string(), vec!["hi".to_string()]);
642 let result = sandbox.execute().unwrap();
643 assert!(result.duration.as_nanos() > 0);
645 }
646
647 #[test]
648 fn test_active_timeout_kills_process() {
649 let sandbox = BasicSandbox::new("sleep".to_string(), vec!["30".to_string()])
651 .with_timeout(Duration::from_millis(200));
652
653 let result = sandbox.execute().unwrap();
654 assert!(
655 result.timed_out,
656 "Process should have been killed by timeout"
657 );
658 assert!(!result.success());
659 assert!(
660 result.duration < Duration::from_secs(5),
661 "Should return quickly after kill, not wait 30s"
662 );
663 }
664
665 #[test]
666 fn output_is_drained_but_bounded() {
667 let sandbox = BasicSandbox::new(
668 "sh".to_string(),
669 vec!["-c".to_string(), "printf 1234567890".to_string()],
670 )
671 .with_output_limit(5);
672 let result = sandbox.execute().unwrap();
673 assert!(result.success());
674 assert!(result.stdout.starts_with("12345"));
675 assert!(result.stdout.contains("output truncated"));
676 }
677
678 #[test]
679 fn process_policy_keeps_network_separate_from_write_authority() {
680 let root = std::env::temp_dir();
681 let inspection = ProcessPolicy::inspection(&root);
682 assert_eq!(inspection.filesystem, FilesystemAccess::ReadOnly);
683 assert!(!inspection.allow_network);
684 let mutation = ProcessPolicy::candidate_mutation(&root).with_network(true);
685 assert_eq!(mutation.filesystem, FilesystemAccess::WorkspaceWrite);
686 assert!(mutation.allow_network);
687 }
688
689 #[cfg(target_os = "macos")]
690 #[test]
691 fn inspection_profile_does_not_grant_global_host_reads() {
692 let root = std::env::temp_dir().join(format!("perspt-sandbox-{}", std::process::id()));
693 std::fs::create_dir_all(&root).unwrap();
694 let sandbox = ProcessSandbox::new(
695 "rg",
696 vec!["needle".into(), ".".into()],
697 ProcessPolicy::inspection(&root),
698 )
699 .unwrap();
700 let prepared = sandbox.prepare_invocation().unwrap();
701 let profile = &prepared.args[1];
702 assert!(!profile.contains("(allow file-read*)"));
703 assert!(profile.contains(&format!(
704 "(allow file-read* (subpath \"{}\"))",
705 root.canonicalize().unwrap().display()
706 )));
707 assert!(!profile.contains("allow network*"));
708 std::fs::remove_dir_all(root).unwrap();
709 }
710
711 #[cfg(target_os = "linux")]
712 #[test]
713 fn linux_policy_mounts_a_private_device_tree() {
714 let root = std::env::temp_dir();
715 let sandbox = ProcessSandbox::new(
716 "cargo",
717 vec!["check".into()],
718 ProcessPolicy::candidate_mutation(root),
719 )
720 .unwrap();
721 let prepared = sandbox.prepare_invocation().unwrap();
722 assert!(prepared
723 .args
724 .windows(2)
725 .any(|pair| pair == ["--dev", "/dev"]));
726 }
727}