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