Skip to main content

liteforge/agents/
sandbox.rs

1//! Sandbox for safe code execution.
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::process::Output;
7use std::time::Duration;
8
9/// Result of code execution.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ExecutionResult {
12    /// Exit code (0 = success).
13    pub exit_code: i32,
14    /// Standard output.
15    pub stdout: String,
16    /// Standard error.
17    pub stderr: String,
18    /// Execution time in milliseconds.
19    pub execution_time_ms: u64,
20    /// Whether execution was killed due to timeout.
21    pub timed_out: bool,
22    /// Any files created during execution.
23    pub output_files: Vec<OutputFile>,
24}
25
26impl ExecutionResult {
27    /// Check if execution was successful.
28    pub fn success(&self) -> bool {
29        self.exit_code == 0 && !self.timed_out
30    }
31
32    /// Get combined output (stdout + stderr).
33    pub fn output(&self) -> String {
34        if self.stderr.is_empty() {
35            self.stdout.clone()
36        } else if self.stdout.is_empty() {
37            self.stderr.clone()
38        } else {
39            format!("{}\n{}", self.stdout, self.stderr)
40        }
41    }
42}
43
44/// An output file created during execution.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct OutputFile {
47    /// File path relative to working directory.
48    pub path: String,
49    /// File contents (if text) or base64-encoded (if binary).
50    pub content: String,
51    /// Whether content is base64-encoded.
52    pub is_binary: bool,
53}
54
55/// Configuration for sandbox execution.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct SandboxConfig {
58    /// Maximum execution time.
59    #[serde(with = "humantime_serde")]
60    pub timeout: Duration,
61    /// Maximum memory in bytes.
62    pub max_memory: usize,
63    /// Maximum CPU time in seconds.
64    pub max_cpu_time: u64,
65    /// Working directory.
66    pub working_dir: Option<String>,
67    /// Environment variables.
68    pub env: HashMap<String, String>,
69    /// Whether to allow network access.
70    pub allow_network: bool,
71    /// Whether to allow file system writes.
72    pub allow_fs_write: bool,
73    /// Allowed paths for reading (if restricted).
74    pub allowed_read_paths: Vec<String>,
75    /// Allowed paths for writing (if restricted).
76    pub allowed_write_paths: Vec<String>,
77}
78
79impl Default for SandboxConfig {
80    fn default() -> Self {
81        Self {
82            timeout: Duration::from_secs(30),
83            max_memory: 256 * 1024 * 1024, // 256 MB
84            max_cpu_time: 10,
85            working_dir: None,
86            env: HashMap::new(),
87            allow_network: false,
88            allow_fs_write: false,
89            allowed_read_paths: Vec::new(),
90            allowed_write_paths: Vec::new(),
91        }
92    }
93}
94
95impl SandboxConfig {
96    /// Create a new sandbox config.
97    pub fn new() -> Self {
98        Self::default()
99    }
100
101    /// Set timeout.
102    pub fn timeout(mut self, timeout: Duration) -> Self {
103        self.timeout = timeout;
104        self
105    }
106
107    /// Set max memory.
108    pub fn max_memory(mut self, bytes: usize) -> Self {
109        self.max_memory = bytes;
110        self
111    }
112
113    /// Set working directory.
114    pub fn working_dir(mut self, dir: impl Into<String>) -> Self {
115        self.working_dir = Some(dir.into());
116        self
117    }
118
119    /// Add environment variable.
120    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
121        self.env.insert(key.into(), value.into());
122        self
123    }
124
125    /// Allow network access.
126    pub fn allow_network(mut self, allow: bool) -> Self {
127        self.allow_network = allow;
128        self
129    }
130
131    /// Allow filesystem writes.
132    pub fn allow_fs_write(mut self, allow: bool) -> Self {
133        self.allow_fs_write = allow;
134        self
135    }
136}
137
138/// Supported programming languages.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140pub enum Language {
141    Python,
142    JavaScript,
143    TypeScript,
144    Ruby,
145    Rust,
146    Go,
147    Shell,
148}
149
150impl Language {
151    /// Get the file extension for this language.
152    pub fn extension(&self) -> &'static str {
153        match self {
154            Language::Python => "py",
155            Language::JavaScript => "js",
156            Language::TypeScript => "ts",
157            Language::Ruby => "rb",
158            Language::Rust => "rs",
159            Language::Go => "go",
160            Language::Shell => "sh",
161        }
162    }
163
164    /// Get the default interpreter/compiler command.
165    pub fn command(&self) -> &'static str {
166        match self {
167            Language::Python => "python3",
168            Language::JavaScript => "node",
169            Language::TypeScript => "npx ts-node",
170            Language::Ruby => "ruby",
171            Language::Rust => "rustc",
172            Language::Go => "go run",
173            Language::Shell => "bash",
174        }
175    }
176
177    /// Detect language from code content.
178    pub fn detect(code: &str) -> Option<Language> {
179        let code = code.trim();
180
181        // Check for shebangs
182        if code.starts_with("#!/usr/bin/env python") || code.starts_with("#!/usr/bin/python") {
183            return Some(Language::Python);
184        }
185        if code.starts_with("#!/usr/bin/env node") || code.starts_with("#!/usr/bin/node") {
186            return Some(Language::JavaScript);
187        }
188        if code.starts_with("#!/bin/bash") || code.starts_with("#!/bin/sh") {
189            return Some(Language::Shell);
190        }
191
192        // Heuristics based on syntax
193        if code.contains("def ") && code.contains(":") && !code.contains("{") {
194            return Some(Language::Python);
195        }
196        if code.contains("import ") && code.contains("from ") && code.contains(":") {
197            return Some(Language::Python);
198        }
199        if code.contains("function ") || code.contains("const ") || code.contains("let ") {
200            if code.contains(": string") || code.contains(": number") || code.contains(": boolean")
201            {
202                return Some(Language::TypeScript);
203            }
204            return Some(Language::JavaScript);
205        }
206        if code.contains("fn ") && code.contains("->") {
207            return Some(Language::Rust);
208        }
209        if code.contains("func ") && code.contains("package ") {
210            return Some(Language::Go);
211        }
212
213        None
214    }
215}
216
217/// Error from sandbox execution.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct SandboxError {
220    /// Error message.
221    pub message: String,
222    /// Error kind.
223    pub kind: SandboxErrorKind,
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227pub enum SandboxErrorKind {
228    /// Timeout exceeded.
229    Timeout,
230    /// Memory limit exceeded.
231    MemoryLimit,
232    /// Security violation.
233    SecurityViolation,
234    /// Language not supported.
235    UnsupportedLanguage,
236    /// Execution failed.
237    ExecutionFailed,
238    /// Configuration error.
239    ConfigError,
240}
241
242impl std::fmt::Display for SandboxError {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        write!(f, "{:?}: {}", self.kind, self.message)
245    }
246}
247
248impl std::error::Error for SandboxError {}
249
250impl SandboxError {
251    pub fn new(kind: SandboxErrorKind, message: impl Into<String>) -> Self {
252        Self {
253            kind,
254            message: message.into(),
255        }
256    }
257
258    pub fn timeout(message: impl Into<String>) -> Self {
259        Self::new(SandboxErrorKind::Timeout, message)
260    }
261
262    pub fn security(message: impl Into<String>) -> Self {
263        Self::new(SandboxErrorKind::SecurityViolation, message)
264    }
265
266    pub fn unsupported(message: impl Into<String>) -> Self {
267        Self::new(SandboxErrorKind::UnsupportedLanguage, message)
268    }
269
270    pub fn execution(message: impl Into<String>) -> Self {
271        Self::new(SandboxErrorKind::ExecutionFailed, message)
272    }
273}
274
275/// Trait for sandbox implementations.
276#[async_trait]
277pub trait Sandbox: Send + Sync {
278    /// Execute code in the sandbox.
279    async fn execute(
280        &self,
281        code: &str,
282        language: Language,
283        config: &SandboxConfig,
284    ) -> Result<ExecutionResult, SandboxError>;
285
286    /// Check if a language is supported.
287    fn supports_language(&self, language: Language) -> bool;
288
289    /// Get supported languages.
290    fn supported_languages(&self) -> Vec<Language>;
291}
292
293/// A simple process-based sandbox (not fully isolated).
294///
295/// WARNING: This is NOT a secure sandbox. For production use,
296/// consider using Docker, gVisor, or WASM-based isolation.
297#[derive(Debug, Clone, Default)]
298pub struct ProcessSandbox {
299    /// Base directory for temporary files.
300    pub temp_dir: Option<String>,
301}
302
303impl ProcessSandbox {
304    /// Create a new process sandbox.
305    pub fn new() -> Self {
306        Self::default()
307    }
308
309    /// Set the temp directory.
310    pub fn with_temp_dir(mut self, dir: impl Into<String>) -> Self {
311        self.temp_dir = Some(dir.into());
312        self
313    }
314
315    async fn run_command(
316        &self,
317        cmd: &str,
318        args: &[&str],
319        config: &SandboxConfig,
320    ) -> Result<Output, SandboxError> {
321        use std::process::Command;
322
323        let mut command = Command::new(cmd);
324        command.args(args);
325
326        // Set working directory
327        if let Some(ref dir) = config.working_dir {
328            command.current_dir(dir);
329        }
330
331        // Set environment
332        command.env_clear();
333        for (key, value) in &config.env {
334            command.env(key, value);
335        }
336
337        // Add minimal required env vars
338        command.env("PATH", "/usr/local/bin:/usr/bin:/bin");
339        command.env("HOME", "/tmp");
340
341        let output = command
342            .output()
343            .map_err(|e| SandboxError::execution(format!("Failed to execute: {}", e)))?;
344
345        Ok(output)
346    }
347}
348
349#[async_trait]
350impl Sandbox for ProcessSandbox {
351    async fn execute(
352        &self,
353        code: &str,
354        language: Language,
355        config: &SandboxConfig,
356    ) -> Result<ExecutionResult, SandboxError> {
357        use std::io::Write;
358        use std::time::Instant;
359
360        // Create temp file for the code
361        let temp_dir = self.temp_dir.as_deref().unwrap_or("/tmp");
362        let file_name = format!(
363            "{}/sandbox_code_{}.{}",
364            temp_dir,
365            std::process::id(),
366            language.extension()
367        );
368
369        // Write code to file
370        let mut file = std::fs::File::create(&file_name)
371            .map_err(|e| SandboxError::execution(format!("Failed to create temp file: {}", e)))?;
372        file.write_all(code.as_bytes())
373            .map_err(|e| SandboxError::execution(format!("Failed to write code: {}", e)))?;
374
375        let start = Instant::now();
376
377        // Execute based on language
378        let output = match language {
379            Language::Python => self.run_command("python3", &[&file_name], config).await?,
380            Language::JavaScript => self.run_command("node", &[&file_name], config).await?,
381            Language::Shell => self.run_command("bash", &[&file_name], config).await?,
382            Language::Ruby => self.run_command("ruby", &[&file_name], config).await?,
383            _ => {
384                // Clean up temp file
385                let _ = std::fs::remove_file(&file_name);
386                return Err(SandboxError::unsupported(format!(
387                    "{:?} is not supported by ProcessSandbox",
388                    language
389                )));
390            }
391        };
392
393        let execution_time = start.elapsed();
394
395        // Clean up temp file
396        let _ = std::fs::remove_file(&file_name);
397
398        Ok(ExecutionResult {
399            exit_code: output.status.code().unwrap_or(-1),
400            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
401            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
402            execution_time_ms: execution_time.as_millis() as u64,
403            timed_out: false,
404            output_files: Vec::new(),
405        })
406    }
407
408    fn supports_language(&self, language: Language) -> bool {
409        matches!(
410            language,
411            Language::Python | Language::JavaScript | Language::Shell | Language::Ruby
412        )
413    }
414
415    fn supported_languages(&self) -> Vec<Language> {
416        vec![
417            Language::Python,
418            Language::JavaScript,
419            Language::Shell,
420            Language::Ruby,
421        ]
422    }
423}
424
425/// A no-op sandbox that just returns mock results (for testing).
426#[derive(Debug, Clone, Default)]
427pub struct MockSandbox {
428    /// Predefined result to return.
429    pub result: Option<ExecutionResult>,
430}
431
432impl MockSandbox {
433    pub fn new() -> Self {
434        Self::default()
435    }
436
437    pub fn with_result(mut self, result: ExecutionResult) -> Self {
438        self.result = Some(result);
439        self
440    }
441}
442
443#[async_trait]
444impl Sandbox for MockSandbox {
445    async fn execute(
446        &self,
447        _code: &str,
448        _language: Language,
449        _config: &SandboxConfig,
450    ) -> Result<ExecutionResult, SandboxError> {
451        Ok(self.result.clone().unwrap_or(ExecutionResult {
452            exit_code: 0,
453            stdout: "Mock execution successful".to_string(),
454            stderr: String::new(),
455            execution_time_ms: 1,
456            timed_out: false,
457            output_files: Vec::new(),
458        }))
459    }
460
461    fn supports_language(&self, _language: Language) -> bool {
462        true
463    }
464
465    fn supported_languages(&self) -> Vec<Language> {
466        vec![
467            Language::Python,
468            Language::JavaScript,
469            Language::TypeScript,
470            Language::Ruby,
471            Language::Rust,
472            Language::Go,
473            Language::Shell,
474        ]
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn test_execution_result() {
484        let result = ExecutionResult {
485            exit_code: 0,
486            stdout: "Hello".to_string(),
487            stderr: String::new(),
488            execution_time_ms: 10,
489            timed_out: false,
490            output_files: Vec::new(),
491        };
492
493        assert!(result.success());
494        assert_eq!(result.output(), "Hello");
495    }
496
497    #[test]
498    fn test_execution_result_failed() {
499        let result = ExecutionResult {
500            exit_code: 1,
501            stdout: String::new(),
502            stderr: "Error".to_string(),
503            execution_time_ms: 10,
504            timed_out: false,
505            output_files: Vec::new(),
506        };
507
508        assert!(!result.success());
509        assert_eq!(result.output(), "Error");
510    }
511
512    #[test]
513    fn test_sandbox_config() {
514        let config = SandboxConfig::new()
515            .timeout(Duration::from_secs(60))
516            .max_memory(512 * 1024 * 1024)
517            .working_dir("/tmp/sandbox")
518            .env("FOO", "bar")
519            .allow_network(true);
520
521        assert_eq!(config.timeout, Duration::from_secs(60));
522        assert_eq!(config.max_memory, 512 * 1024 * 1024);
523        assert_eq!(config.working_dir, Some("/tmp/sandbox".to_string()));
524        assert_eq!(config.env.get("FOO"), Some(&"bar".to_string()));
525        assert!(config.allow_network);
526    }
527
528    #[test]
529    fn test_language_extension() {
530        assert_eq!(Language::Python.extension(), "py");
531        assert_eq!(Language::JavaScript.extension(), "js");
532        assert_eq!(Language::Rust.extension(), "rs");
533    }
534
535    #[test]
536    fn test_language_detection() {
537        assert_eq!(
538            Language::detect("def foo():\n    pass"),
539            Some(Language::Python)
540        );
541        assert_eq!(
542            Language::detect("function foo() { }"),
543            Some(Language::JavaScript)
544        );
545        assert_eq!(
546            Language::detect("fn main() -> () { }"),
547            Some(Language::Rust)
548        );
549        assert_eq!(
550            Language::detect("#!/bin/bash\necho hello"),
551            Some(Language::Shell)
552        );
553    }
554
555    #[tokio::test]
556    async fn test_mock_sandbox() {
557        let sandbox = MockSandbox::new().with_result(ExecutionResult {
558            exit_code: 0,
559            stdout: "42".to_string(),
560            stderr: String::new(),
561            execution_time_ms: 5,
562            timed_out: false,
563            output_files: Vec::new(),
564        });
565
566        let config = SandboxConfig::default();
567        let result = sandbox
568            .execute("print(42)", Language::Python, &config)
569            .await
570            .unwrap();
571
572        assert!(result.success());
573        assert_eq!(result.stdout, "42");
574    }
575
576    #[test]
577    fn test_sandbox_error() {
578        let err = SandboxError::timeout("Execution took too long");
579        assert_eq!(err.kind, SandboxErrorKind::Timeout);
580        assert!(err.to_string().contains("Execution took too long"));
581    }
582}