Skip to main content

lc_tools/sandbox/
mod.rs

1// lc-tools/src/sandbox/mod.rs
2//! Code Interpreter Sandbox for secure code execution.
3//!
4//! Provides a pluggable sandbox architecture for executing code in isolated environments.
5//! The [`CodeSandbox`] trait defines the interface for sandbox backends, and [`SandboxTool`]
6//! wraps any sandbox implementation as a [`BaseTool`] usable by agents.
7//!
8//! # Backends
9//!
10//! - **[`LocalSandbox`]**: Default backend using subprocess with timeout.
11//! - **`E2BSandbox`**: Cloud sandbox via E2B (behind `sandbox-e2b` feature gate).
12//! - **`WasmSandbox`**: WASM-based sandbox (behind `sandbox-wasm` feature gate).
13
14mod local;
15
16#[cfg(feature = "sandbox-e2b")]
17mod e2b;
18
19#[cfg(feature = "sandbox-wasm")]
20mod wasm;
21
22pub use local::LocalSandbox;
23
24#[cfg(feature = "sandbox-e2b")]
25pub use e2b::E2BSandbox;
26
27#[cfg(feature = "sandbox-wasm")]
28pub use wasm::WasmSandbox;
29
30use async_trait::async_trait;
31use serde::{Deserialize, Serialize};
32
33use lc_core::tools::{BaseTool, ToolError};
34
35/// Supported programming languages for sandbox execution.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum Language {
39    Python,
40    JavaScript,
41    Rust,
42}
43
44impl std::fmt::Display for Language {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            Language::Python => write!(f, "python"),
48            Language::JavaScript => write!(f, "javascript"),
49            Language::Rust => write!(f, "rust"),
50        }
51    }
52}
53
54/// Result of a sandboxed code execution.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct RunResult {
57    /// Standard output captured from the execution.
58    pub stdout: String,
59    /// Standard error captured from the execution.
60    pub stderr: String,
61    /// Process exit code (0 = success, non-zero = failure).
62    pub exit_code: i32,
63    /// Wall-clock execution time in milliseconds.
64    pub execution_time_ms: u64,
65}
66
67/// Errors that can occur during sandbox execution.
68#[derive(Debug, thiserror::Error)]
69pub enum SandboxError {
70    /// Execution exceeded the configured time limit.
71    #[error("execution timeout after {0}ms")]
72    Timeout(u64),
73
74    /// Runtime error during code execution.
75    #[error("sandbox error: {0}")]
76    Runtime(String),
77
78    /// The requested language is not supported by this sandbox backend.
79    #[error("language not supported: {0}")]
80    UnsupportedLanguage(String),
81}
82
83/// Trait for sandbox backends that execute code in an isolated environment.
84#[async_trait]
85pub trait CodeSandbox: Send + Sync {
86    /// Execute the given code in the specified language.
87    async fn run(
88        &self,
89        code: &str,
90        language: Language,
91        timeout_ms: u64,
92    ) -> Result<RunResult, SandboxError>;
93}
94
95/// Input JSON schema for [`SandboxTool`].
96#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
97struct SandboxInput {
98    /// Source code to execute.
99    code: String,
100}
101
102/// A [`BaseTool`] that executes code in a sandboxed environment.
103pub struct SandboxTool<S: CodeSandbox> {
104    sandbox: S,
105    language: Language,
106    timeout_ms: u64,
107}
108
109impl<S: CodeSandbox> SandboxTool<S> {
110    /// Create a new sandbox tool with the given backend and default language.
111    pub fn new(sandbox: S, language: Language) -> Self {
112        Self {
113            sandbox,
114            language,
115            timeout_ms: 30_000,
116        }
117    }
118
119    /// Set the execution timeout in milliseconds.
120    pub fn with_timeout(mut self, ms: u64) -> Self {
121        self.timeout_ms = ms;
122        self
123    }
124}
125
126#[async_trait]
127impl<S: CodeSandbox + 'static> BaseTool for SandboxTool<S> {
128    fn name(&self) -> &str {
129        "code_interpreter"
130    }
131
132    fn description(&self) -> &str {
133        "Execute code in a sandboxed environment. \
134         Input JSON: {\"code\": \"...\"}. \
135         Returns stdout, stderr, exit_code, and execution_time_ms. \
136         Supported languages depend on the sandbox backend."
137    }
138
139    async fn run(&self, input: String) -> Result<String, ToolError> {
140        let parsed: SandboxInput = serde_json::from_str(&input)
141            .map_err(|e| ToolError::InvalidInput(format!("JSON parse error: {}", e)))?;
142
143        if parsed.code.trim().is_empty() {
144            return Err(ToolError::InvalidInput(
145                "code must not be empty".to_string(),
146            ));
147        }
148
149        let result = self
150            .sandbox
151            .run(&parsed.code, self.language, self.timeout_ms)
152            .await
153            .map_err(|e| match e {
154                SandboxError::Timeout(ms) => ToolError::Timeout(ms / 1000),
155                SandboxError::Runtime(msg) => ToolError::ExecutionFailed(msg),
156                SandboxError::UnsupportedLanguage(lang) => {
157                    ToolError::InvalidInput(format!("unsupported language: {}", lang))
158                }
159            })?;
160
161        serde_json::to_string_pretty(&result)
162            .map_err(|e| ToolError::ExecutionFailed(format!("failed to serialize result: {}", e)))
163    }
164
165    fn args_schema(&self) -> Option<serde_json::Value> {
166        use schemars::schema_for;
167        serde_json::to_value(schema_for!(SandboxInput)).ok()
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_language_display() {
177        assert_eq!(Language::Python.to_string(), "python");
178        assert_eq!(Language::JavaScript.to_string(), "javascript");
179        assert_eq!(Language::Rust.to_string(), "rust");
180    }
181
182    #[test]
183    fn test_language_serde() {
184        let json = serde_json::to_string(&Language::Python).unwrap();
185        assert_eq!(json, "\"python\"");
186
187        let lang: Language = serde_json::from_str("\"javascript\"").unwrap();
188        assert_eq!(lang, Language::JavaScript);
189    }
190
191    #[test]
192    fn test_run_result_serialization() {
193        let result = RunResult {
194            stdout: "hello\n".to_string(),
195            stderr: String::new(),
196            exit_code: 0,
197            execution_time_ms: 42,
198        };
199        let json = serde_json::to_string(&result).unwrap();
200        let parsed: RunResult = serde_json::from_str(&json).unwrap();
201        assert_eq!(parsed.stdout, "hello\n");
202        assert_eq!(parsed.exit_code, 0);
203        assert_eq!(parsed.execution_time_ms, 42);
204    }
205
206    #[test]
207    fn test_sandbox_error_display() {
208        let err = SandboxError::Timeout(5000);
209        assert!(err.to_string().contains("5000ms"));
210
211        let err = SandboxError::Runtime("crashed".to_string());
212        assert!(err.to_string().contains("crashed"));
213
214        let err = SandboxError::UnsupportedLanguage("brainfuck".to_string());
215        assert!(err.to_string().contains("brainfuck"));
216    }
217
218    struct MockSandbox;
219
220    #[async_trait]
221    impl CodeSandbox for MockSandbox {
222        async fn run(
223            &self,
224            code: &str,
225            _language: Language,
226            _timeout_ms: u64,
227        ) -> Result<RunResult, SandboxError> {
228            Ok(RunResult {
229                stdout: format!("executed: {}", code),
230                stderr: String::new(),
231                exit_code: 0,
232                execution_time_ms: 1,
233            })
234        }
235    }
236
237    #[tokio::test]
238    async fn test_sandbox_tool_name_and_description() {
239        let tool = SandboxTool::new(MockSandbox, Language::Python);
240        assert_eq!(tool.name(), "code_interpreter");
241        assert!(tool.description().contains("sandbox"));
242    }
243
244    #[tokio::test]
245    async fn test_sandbox_tool_args_schema() {
246        let tool = SandboxTool::new(MockSandbox, Language::Python);
247        let schema = tool.args_schema();
248        assert!(schema.is_some());
249        let schema = schema.unwrap();
250        assert!(schema["properties"]["code"].is_object());
251    }
252
253    #[tokio::test]
254    async fn test_sandbox_tool_run_success() {
255        let tool = SandboxTool::new(MockSandbox, Language::Python);
256        let result = tool.run(r#"{"code": "print(1+1)"}"#.to_string()).await;
257        assert!(result.is_ok());
258        let output: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
259        assert_eq!(output["exit_code"], 0);
260        assert!(output["stdout"].as_str().unwrap().contains("executed"));
261    }
262
263    #[tokio::test]
264    async fn test_sandbox_tool_run_empty_code() {
265        let tool = SandboxTool::new(MockSandbox, Language::Python);
266        let result = tool.run(r#"{"code": "  "}"#.to_string()).await;
267        assert!(result.is_err());
268        let err = result.unwrap_err().to_string();
269        assert!(err.contains("empty"));
270    }
271
272    #[tokio::test]
273    async fn test_sandbox_tool_run_invalid_json() {
274        let tool = SandboxTool::new(MockSandbox, Language::Python);
275        let result = tool.run("not json".to_string()).await;
276        assert!(result.is_err());
277    }
278
279    struct TimeoutSandbox;
280
281    #[async_trait]
282    impl CodeSandbox for TimeoutSandbox {
283        async fn run(
284            &self,
285            _code: &str,
286            _language: Language,
287            timeout_ms: u64,
288        ) -> Result<RunResult, SandboxError> {
289            Err(SandboxError::Timeout(timeout_ms))
290        }
291    }
292
293    #[tokio::test]
294    async fn test_sandbox_tool_timeout_maps_to_tool_error() {
295        let tool = SandboxTool::new(TimeoutSandbox, Language::Python).with_timeout(5000);
296        let result = tool
297            .run(r#"{"code": "while True: pass"}"#.to_string())
298            .await;
299        assert!(result.is_err());
300        let err = result.unwrap_err();
301        match err {
302            ToolError::Timeout(secs) => assert_eq!(secs, 5),
303            other => panic!("expected Timeout error, got: {:?}", other),
304        }
305    }
306
307    struct UnsupportedSandbox;
308
309    #[async_trait]
310    impl CodeSandbox for UnsupportedSandbox {
311        async fn run(
312            &self,
313            _code: &str,
314            language: Language,
315            _timeout_ms: u64,
316        ) -> Result<RunResult, SandboxError> {
317            Err(SandboxError::UnsupportedLanguage(language.to_string()))
318        }
319    }
320
321    #[tokio::test]
322    async fn test_sandbox_tool_unsupported_language() {
323        let tool = SandboxTool::new(UnsupportedSandbox, Language::Rust);
324        let result = tool.run(r#"{"code": "fn main(){}"}"#.to_string()).await;
325        assert!(result.is_err());
326    }
327}