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::Result;
6use std::process::{Command, Stdio};
7use std::time::Duration;
8
9/// Result of a sandboxed command execution
10#[derive(Debug, Clone)]
11pub struct CommandResult {
12    /// Standard output
13    pub stdout: String,
14    /// Standard error output
15    pub stderr: String,
16    /// Exit status
17    pub exit_code: Option<i32>,
18    /// Whether the command timed out
19    pub timed_out: bool,
20    /// Execution duration
21    pub duration: Duration,
22}
23
24impl CommandResult {
25    /// Check if the command succeeded
26    pub fn success(&self) -> bool {
27        self.exit_code == Some(0) && !self.timed_out
28    }
29}
30
31/// Trait for sandboxed command execution
32///
33/// This trait abstracts command execution to allow different sandboxing
34/// implementations (basic, Docker, Landlock, etc.)
35pub trait SandboxedCommand: Send + Sync {
36    /// Execute the command and return the result
37    fn execute(&self) -> Result<CommandResult>;
38
39    /// Get the command string for display
40    fn display(&self) -> String;
41
42    /// Check if the command is read-only (no side effects)
43    fn is_read_only(&self) -> bool;
44}
45
46/// Basic sandboxed command wrapper
47///
48/// Phase 1 implementation: Executes commands directly but with
49/// output capture and timeout support.
50pub struct BasicSandbox {
51    /// The program to execute
52    program: String,
53    /// Command arguments
54    args: Vec<String>,
55    /// Working directory
56    working_dir: Option<String>,
57    /// Timeout for execution
58    timeout: Option<Duration>,
59}
60
61impl BasicSandbox {
62    /// Create a new basic sandbox
63    pub fn new(program: String, args: Vec<String>) -> Self {
64        Self {
65            program,
66            args,
67            working_dir: None,
68            timeout: Some(Duration::from_secs(60)), // Default 60s timeout
69        }
70    }
71
72    /// Set the working directory
73    pub fn with_working_dir(mut self, dir: String) -> Self {
74        self.working_dir = Some(dir);
75        self
76    }
77
78    /// Set the timeout
79    pub fn with_timeout(mut self, timeout: Duration) -> Self {
80        self.timeout = Some(timeout);
81        self
82    }
83
84    /// Parse a command string into program and args
85    pub fn from_command_string(cmd: &str) -> Result<Self> {
86        let parts = shell_words::split(cmd)?;
87        if parts.is_empty() {
88            anyhow::bail!("Empty command");
89        }
90
91        Ok(Self::new(parts[0].clone(), parts[1..].to_vec()))
92    }
93}
94
95impl SandboxedCommand for BasicSandbox {
96    fn execute(&self) -> Result<CommandResult> {
97        let start = std::time::Instant::now();
98
99        let mut cmd = Command::new(&self.program);
100        cmd.args(&self.args)
101            .stdout(Stdio::piped())
102            .stderr(Stdio::piped());
103
104        if let Some(ref dir) = self.working_dir {
105            cmd.current_dir(dir);
106        }
107
108        let mut child = cmd.spawn()?;
109
110        // Active timeout: poll child with a deadline, kill if exceeded
111        if let Some(timeout) = self.timeout {
112            let deadline = start + timeout;
113            loop {
114                match child.try_wait() {
115                    Ok(Some(_status)) => {
116                        // Process exited normally
117                        break;
118                    }
119                    Ok(None) => {
120                        // Still running — check deadline
121                        if std::time::Instant::now() >= deadline {
122                            // Kill the process
123                            let _ = child.kill();
124                            let _ = child.wait(); // reap zombie
125                            let duration = start.elapsed();
126                            return Ok(CommandResult {
127                                stdout: String::new(),
128                                stderr: format!(
129                                    "Process killed after {}s timeout",
130                                    timeout.as_secs()
131                                ),
132                                exit_code: None,
133                                timed_out: true,
134                                duration,
135                            });
136                        }
137                        // Brief sleep to avoid busy-waiting
138                        std::thread::sleep(Duration::from_millis(50));
139                    }
140                    Err(e) => {
141                        return Err(e.into());
142                    }
143                }
144            }
145        }
146
147        let output = child.wait_with_output()?;
148        let duration = start.elapsed();
149
150        Ok(CommandResult {
151            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
152            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
153            exit_code: output.status.code(),
154            timed_out: false,
155            duration,
156        })
157    }
158
159    fn display(&self) -> String {
160        if self.args.is_empty() {
161            self.program.clone()
162        } else {
163            format!("{} {}", self.program, self.args.join(" "))
164        }
165    }
166
167    fn is_read_only(&self) -> bool {
168        // Commands that are generally read-only
169        let read_only_programs = [
170            "ls",
171            "cat",
172            "head",
173            "tail",
174            "grep",
175            "find",
176            "which",
177            "echo",
178            "pwd",
179            "whoami",
180            "date",
181            "env",
182            "printenv",
183            "file",
184            "stat",
185            "cargo check",
186            "cargo build",
187            "cargo test",
188            "cargo clippy",
189            "git status",
190            "git log",
191            "git diff",
192            "git show",
193        ];
194
195        let full_cmd = self.display();
196        read_only_programs.iter().any(|p| full_cmd.starts_with(p))
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn test_basic_sandbox_echo() {
206        let sandbox = BasicSandbox::new("echo".to_string(), vec!["hello".to_string()]);
207        let result = sandbox.execute().unwrap();
208        assert!(result.success());
209        assert_eq!(result.stdout.trim(), "hello");
210    }
211
212    #[test]
213    fn test_from_command_string() {
214        let sandbox = BasicSandbox::from_command_string("ls -la /tmp").unwrap();
215        assert_eq!(sandbox.program, "ls");
216        assert_eq!(sandbox.args, vec!["-la", "/tmp"]);
217    }
218
219    #[test]
220    fn test_display() {
221        let sandbox = BasicSandbox::new(
222            "cargo".to_string(),
223            vec!["build".to_string(), "--release".to_string()],
224        );
225        assert_eq!(sandbox.display(), "cargo build --release");
226    }
227
228    #[test]
229    fn test_is_read_only() {
230        let sandbox = BasicSandbox::new("ls".to_string(), vec!["-la".to_string()]);
231        assert!(sandbox.is_read_only());
232
233        let sandbox = BasicSandbox::new("rm".to_string(), vec!["file.txt".to_string()]);
234        assert!(!sandbox.is_read_only());
235    }
236
237    // =========================================================================
238    // Baseline regression tests — freeze pre-refactor behavior
239    // =========================================================================
240
241    #[cfg(unix)]
242    #[test]
243    fn test_basic_sandbox_with_working_dir() {
244        let temp = std::env::temp_dir();
245        let sandbox = BasicSandbox::new("pwd".to_string(), vec![])
246            .with_working_dir(temp.to_string_lossy().to_string());
247        let result = sandbox.execute().unwrap();
248        assert!(result.success());
249        // The working_dir setting should be respected; the pwd output
250        // should resolve to the same directory we specified.
251        let output_path = std::path::PathBuf::from(result.stdout.trim());
252        let expected = std::fs::canonicalize(&temp).unwrap();
253        let actual = std::fs::canonicalize(&output_path).unwrap();
254        assert_eq!(
255            actual, expected,
256            "pwd should match the specified working dir"
257        );
258    }
259
260    #[cfg(windows)]
261    #[test]
262    fn test_basic_sandbox_with_working_dir() {
263        // Use a uniquely-named subdirectory so we can verify the working dir
264        // by name alone, avoiding junction/symlink resolution mismatches
265        // (e.g. C:\Users\...\Temp junction → D:\tmp on CI runners).
266        let unique = format!("perspt_test_{}", std::process::id());
267        let dir = std::env::temp_dir().join(&unique);
268        std::fs::create_dir_all(&dir).unwrap();
269        let sandbox =
270            BasicSandbox::new("cmd".to_string(), vec!["/C".to_string(), "cd".to_string()])
271                .with_working_dir(dir.to_string_lossy().to_string());
272        let result = sandbox.execute().unwrap();
273        let _ = std::fs::remove_dir(&dir);
274        assert!(result.success(), "cmd /C cd should succeed");
275        let output = result.stdout.trim();
276        assert!(
277            output.ends_with(&unique),
278            "working dir output should end with our unique dir name, got: {output}"
279        );
280    }
281
282    #[test]
283    fn test_basic_sandbox_captures_stderr() {
284        let sandbox = BasicSandbox::new(
285            "sh".to_string(),
286            vec!["-c".to_string(), "echo err >&2".to_string()],
287        );
288        let result = sandbox.execute().unwrap();
289        assert!(result.success());
290        assert!(
291            result.stderr.contains("err"),
292            "stderr should capture error output"
293        );
294    }
295
296    #[test]
297    fn test_basic_sandbox_nonzero_exit() {
298        let sandbox = BasicSandbox::new("false".to_string(), vec![]);
299        let result = sandbox.execute().unwrap();
300        assert!(!result.success());
301        assert_eq!(result.exit_code, Some(1));
302        assert!(!result.timed_out);
303    }
304
305    #[test]
306    fn test_basic_sandbox_timeout_fast_command_succeeds() {
307        // A fast command with a generous timeout should complete normally.
308        let sandbox = BasicSandbox::new("echo".to_string(), vec!["fast".to_string()])
309            .with_timeout(Duration::from_secs(60));
310        let result = sandbox.execute().unwrap();
311        assert!(!result.timed_out);
312        assert!(result.success());
313    }
314
315    #[test]
316    fn test_from_command_string_empty_rejected() {
317        let result = BasicSandbox::from_command_string("");
318        assert!(result.is_err(), "Empty command should be rejected");
319    }
320
321    #[test]
322    fn test_from_command_string_with_quotes() {
323        let sandbox = BasicSandbox::from_command_string(r#"echo "hello world""#).unwrap();
324        assert_eq!(sandbox.program, "echo");
325        assert_eq!(sandbox.args, vec!["hello world"]);
326    }
327
328    #[test]
329    fn test_display_no_args() {
330        let sandbox = BasicSandbox::new("pwd".to_string(), vec![]);
331        assert_eq!(sandbox.display(), "pwd");
332    }
333
334    #[test]
335    fn test_is_read_only_compound_commands() {
336        // cargo check should be read-only
337        let sandbox = BasicSandbox::new("cargo".to_string(), vec!["check".to_string()]);
338        assert!(sandbox.is_read_only());
339
340        // cargo test should be read-only
341        let sandbox = BasicSandbox::new("cargo".to_string(), vec!["test".to_string()]);
342        assert!(sandbox.is_read_only());
343
344        // git status should be read-only
345        let sandbox = BasicSandbox::new("git".to_string(), vec!["status".to_string()]);
346        assert!(sandbox.is_read_only());
347
348        // git push should NOT be read-only
349        let sandbox = BasicSandbox::new("git".to_string(), vec!["push".to_string()]);
350        assert!(!sandbox.is_read_only());
351    }
352
353    #[test]
354    fn test_command_result_duration_nonzero() {
355        let sandbox = BasicSandbox::new("echo".to_string(), vec!["hi".to_string()]);
356        let result = sandbox.execute().unwrap();
357        // Duration should be non-zero (process was actually spawned)
358        assert!(result.duration.as_nanos() > 0);
359    }
360
361    #[test]
362    fn test_active_timeout_kills_process() {
363        // Start a long-running sleep and verify the sandbox kills it
364        let sandbox = BasicSandbox::new("sleep".to_string(), vec!["30".to_string()])
365            .with_timeout(Duration::from_millis(200));
366
367        let result = sandbox.execute().unwrap();
368        assert!(
369            result.timed_out,
370            "Process should have been killed by timeout"
371        );
372        assert!(!result.success());
373        assert!(
374            result.duration < Duration::from_secs(5),
375            "Should return quickly after kill, not wait 30s"
376        );
377    }
378}