Skip to main content

perspt_sandbox/
command.rs

1//! Sandboxed Command Execution
2//!
3//! Provides a trait and implementation for executing commands with sandboxing.
4
5use 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/// Filesystem authority granted to a process.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum FilesystemAccess {
19    /// The process may inspect files but may not modify the workspace.
20    ReadOnly,
21    /// Writes are confined to the declared workspace root.
22    WorkspaceWrite,
23}
24
25/// Whether absence of an OS isolation backend is a hard error.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum IsolationMode {
28    Required,
29    BestEffort,
30}
31
32/// Auditable process policy. Network and filesystem authority are separate;
33/// neither is inferred from a command name.
34#[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/// Process runner backed by the host's OS sandbox (`sandbox-exec` on macOS,
77/// `bwrap` on Linux). It fails closed when isolation is required but absent.
78#[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/// Result of a sandboxed command execution
162#[derive(Debug, Clone)]
163pub struct CommandResult {
164    /// Standard output
165    pub stdout: String,
166    /// Standard error output
167    pub stderr: String,
168    /// Exit status
169    pub exit_code: Option<i32>,
170    /// Whether the command timed out
171    pub timed_out: bool,
172    /// Execution duration
173    pub duration: Duration,
174}
175
176impl CommandResult {
177    /// Check if the command succeeded
178    pub fn success(&self) -> bool {
179        self.exit_code == Some(0) && !self.timed_out
180    }
181}
182
183/// Trait for sandboxed command execution
184///
185/// This trait abstracts command execution to allow different sandboxing
186/// implementations (basic, Docker, Landlock, etc.)
187pub trait SandboxedCommand: Send + Sync {
188    /// Execute the command and return the result
189    fn execute(&self) -> Result<CommandResult>;
190
191    /// Get the command string for display
192    fn display(&self) -> String;
193
194    /// Check if the command is read-only (no side effects)
195    fn is_read_only(&self) -> bool;
196}
197
198/// Basic sandboxed command wrapper
199///
200/// Phase 1 implementation: Executes commands directly but with
201/// output capture and timeout support.
202pub struct BasicSandbox {
203    /// The program to execute
204    program: String,
205    /// Command arguments
206    args: Vec<String>,
207    /// Working directory
208    working_dir: Option<String>,
209    /// Timeout for execution
210    timeout: Option<Duration>,
211    output_limit: usize,
212    inherit_environment: bool,
213    environment: BTreeMap<String, String>,
214}
215
216impl BasicSandbox {
217    /// Create a new basic sandbox
218    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)), // Default 60s timeout
224            output_limit: DEFAULT_OUTPUT_LIMIT,
225            inherit_environment: true,
226            environment: BTreeMap::new(),
227        }
228    }
229
230    /// Set the working directory
231    pub fn with_working_dir(mut self, dir: String) -> Self {
232        self.working_dir = Some(dir);
233        self
234    }
235
236    /// Set the timeout
237    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        // Active timeout: poll child with a deadline, kill if exceeded
284        if let Some(timeout) = self.timeout {
285            let deadline = start + timeout;
286            loop {
287                match child.try_wait() {
288                    Ok(Some(_status)) => {
289                        // Process exited normally
290                        break;
291                    }
292                    Ok(None) => {
293                        // Still running — check deadline
294                        if std::time::Instant::now() >= deadline {
295                            // Kill the process
296                            let _ = child.kill();
297                            let _ = child.wait(); // reap zombie
298                            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                        // Brief sleep to avoid busy-waiting
314                        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        // Exact program-name match only. A prefix match over the rendered
345        // command line classified `catastrophe` as read-only (starts with
346        // `cat`) and ignored mutating flags entirely. Programs with mutating
347        // modes (`find -delete`, `git push`, `sed -i`) are excluded outright;
348        // the governed path uses `perspt_sdk::classify_tier`, which models
349        // those argument forms — this heuristic stays conservative instead.
350        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    // Inspection tools run with the user's identity, so a global read grant
431    // would expose credentials and unrelated repositories. System roots are
432    // needed for executables and dynamic libraries; task data is confined to
433    // the candidate root.
434    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    // Mask user-secret roots after binding the host read-only. The coding
476    // candidate is normally under /tmp and is rebound below.
477    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    // The tmpfs mounts above can mask the workspace root itself: candidate
484    // overlays live under /tmp, and a workspace may sit under /home. Re-bind
485    // the root after the masks so the sandboxed process always sees its own
486    // tree — read-only unless the policy grants workspace writes.
487    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    // =========================================================================
541    // Baseline regression tests — freeze pre-refactor behavior
542    // =========================================================================
543
544    #[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        // The working_dir setting should be respected; the pwd output
553        // should resolve to the same directory we specified.
554        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        // Use a uniquely-named subdirectory so we can verify the working dir
567        // by name alone, avoiding junction/symlink resolution mismatches
568        // (e.g. C:\Users\...\Temp junction → D:\tmp on CI runners).
569        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        // A fast command with a generous timeout should complete normally.
611        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        // Programs with mutating modes are never classified read-only here,
627        // whatever their subcommand: `cargo test` runs build scripts and
628        // `git status` takes index locks. Tiered classification lives in
629        // `perspt_sdk::classify_tier`.
630        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        // A prefix of a read-only name is not read-only.
635        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        // Duration should be non-zero (process was actually spawned)
644        assert!(result.duration.as_nanos() > 0);
645    }
646
647    #[test]
648    fn test_active_timeout_kills_process() {
649        // Start a long-running sleep and verify the sandbox kills it
650        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}