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 output = cmd.output()?;
109        let duration = start.elapsed();
110
111        // Check timeout (basic implementation - doesn't actually kill on timeout)
112        let timed_out = self.timeout.is_some_and(|t| duration > t);
113
114        Ok(CommandResult {
115            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
116            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
117            exit_code: output.status.code(),
118            timed_out,
119            duration,
120        })
121    }
122
123    fn display(&self) -> String {
124        if self.args.is_empty() {
125            self.program.clone()
126        } else {
127            format!("{} {}", self.program, self.args.join(" "))
128        }
129    }
130
131    fn is_read_only(&self) -> bool {
132        // Commands that are generally read-only
133        let read_only_programs = [
134            "ls",
135            "cat",
136            "head",
137            "tail",
138            "grep",
139            "find",
140            "which",
141            "echo",
142            "pwd",
143            "whoami",
144            "date",
145            "env",
146            "printenv",
147            "file",
148            "stat",
149            "cargo check",
150            "cargo build",
151            "cargo test",
152            "cargo clippy",
153            "git status",
154            "git log",
155            "git diff",
156            "git show",
157        ];
158
159        let full_cmd = self.display();
160        read_only_programs.iter().any(|p| full_cmd.starts_with(p))
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_basic_sandbox_echo() {
170        let sandbox = BasicSandbox::new("echo".to_string(), vec!["hello".to_string()]);
171        let result = sandbox.execute().unwrap();
172        assert!(result.success());
173        assert_eq!(result.stdout.trim(), "hello");
174    }
175
176    #[test]
177    fn test_from_command_string() {
178        let sandbox = BasicSandbox::from_command_string("ls -la /tmp").unwrap();
179        assert_eq!(sandbox.program, "ls");
180        assert_eq!(sandbox.args, vec!["-la", "/tmp"]);
181    }
182
183    #[test]
184    fn test_display() {
185        let sandbox = BasicSandbox::new(
186            "cargo".to_string(),
187            vec!["build".to_string(), "--release".to_string()],
188        );
189        assert_eq!(sandbox.display(), "cargo build --release");
190    }
191
192    #[test]
193    fn test_is_read_only() {
194        let sandbox = BasicSandbox::new("ls".to_string(), vec!["-la".to_string()]);
195        assert!(sandbox.is_read_only());
196
197        let sandbox = BasicSandbox::new("rm".to_string(), vec!["file.txt".to_string()]);
198        assert!(!sandbox.is_read_only());
199    }
200}