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