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