Skip to main content

lc_tools/sandbox/
mod.rs

1// lc-tools/src/sandbox/mod.rs
2//! Code Interpreter Sandbox for pluggable code execution.
3//!
4//! Provides a pluggable architecture for executing code. The [`CodeSandbox`] trait
5//! defines the interface for sandbox backends, and [`SandboxTool`] wraps any sandbox
6//! implementation as a [`BaseTool`] usable by agents.
7//!
8//! # Security
9//!
10//! [`SandboxTool`] is **disabled by default**: code does not run until
11//! [`SandboxTool::with_dangerously_allow`] is called. This is deliberate — the bundled
12//! [`LocalSandbox`] backend is a plain subprocess + timeout with **no OS-level
13//! isolation**. Treat it as *convenience*, not a security boundary: untrusted code
14//! must run in a real sandbox (container / VM / WASM).
15//!
16//! # Backends
17//!
18//! - **[`LocalSandbox`]**: the current only backend, a subprocess + timeout.
19//!
20//! > The former `WasmSandbox` / `E2BSandbox` were hollow shells with a complete interface but
21//! > a permanent "not implemented" body (review Q2). They were removed together with the
22//! > `sandbox-wasm` / `sandbox-e2b` features — a backend that promises but cannot deliver
23//! > damages trust the most; they will come back once actually implemented.
24
25mod local;
26
27pub use local::LocalSandbox;
28
29use async_trait::async_trait;
30use serde::{Deserialize, Serialize};
31
32use lc_core::tools::{BaseTool, ToolError};
33
34/// Supported programming languages for sandbox execution.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum Language {
38    /// Python.
39    Python,
40    /// JavaScript.
41    JavaScript,
42    /// Rust.
43    Rust,
44}
45
46impl std::fmt::Display for Language {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Language::Python => write!(f, "python"),
50            Language::JavaScript => write!(f, "javascript"),
51            Language::Rust => write!(f, "rust"),
52        }
53    }
54}
55
56/// Result of a sandboxed code execution.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct RunResult {
59    /// Standard output captured from the execution.
60    pub stdout: String,
61    /// Standard error captured from the execution.
62    pub stderr: String,
63    /// Process exit code (0 = success, non-zero = failure).
64    pub exit_code: i32,
65    /// Wall-clock execution time in milliseconds.
66    pub execution_time_ms: u64,
67}
68
69/// Errors that can occur during sandbox execution.
70#[derive(Debug, thiserror::Error)]
71#[non_exhaustive]
72pub enum SandboxError {
73    /// Execution exceeded the configured time limit.
74    #[error("execution timeout after {0}ms")]
75    Timeout(u64),
76
77    /// Runtime error during code execution.
78    #[error("sandbox error: {0}")]
79    Runtime(String),
80
81    /// The requested language is not supported by this sandbox backend.
82    #[error("language not supported: {0}")]
83    UnsupportedLanguage(String),
84}
85
86/// Trait for sandbox backends that execute code in an isolated environment.
87#[async_trait]
88pub trait CodeSandbox: Send + Sync {
89    /// Execute the given code in the specified language.
90    async fn run(
91        &self,
92        code: &str,
93        language: Language,
94        timeout_ms: u64,
95    ) -> Result<RunResult, SandboxError>;
96}
97
98/// Input JSON schema for [`SandboxTool`].
99#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
100struct SandboxInput {
101    /// Source code to execute.
102    code: String,
103}
104
105/// A [`BaseTool`] that executes code in a sandboxed environment.
106///
107/// **Disabled by default.** Call [`SandboxTool::with_dangerously_allow`] to enable
108/// execution. See the [module docs](self) for the security model.
109pub struct SandboxTool<S: CodeSandbox> {
110    sandbox: S,
111    language: Language,
112    timeout_ms: u64,
113    /// Whether code execution is allowed (default false, must explicitly opt in).
114    dangerously_allow: bool,
115}
116
117impl<S: CodeSandbox> SandboxTool<S> {
118    /// Create a new sandbox tool with the given backend and default language.
119    ///
120    /// Execution is **disabled** until
121    /// [`with_dangerously_allow`](Self::with_dangerously_allow) is called.
122    pub fn new(sandbox: S, language: Language) -> Self {
123        Self {
124            sandbox,
125            language,
126            timeout_ms: 30_000,
127            dangerously_allow: false,
128        }
129    }
130
131    /// Set the execution timeout in milliseconds.
132    pub fn with_timeout(mut self, ms: u64) -> Self {
133        self.timeout_ms = ms;
134        self
135    }
136
137    /// Explicitly enables code execution (disabled by default).
138    pub fn with_dangerously_allow(mut self, allow: bool) -> Self {
139        self.dangerously_allow = allow;
140        self
141    }
142}
143
144#[async_trait]
145impl<S: CodeSandbox + 'static> BaseTool for SandboxTool<S> {
146    fn name(&self) -> &str {
147        "code_interpreter"
148    }
149
150    fn description(&self) -> &str {
151        "Execute code in a sandboxed environment. \
152         Disabled by default for security; call .with_dangerously_allow(true) to enable. \
153         Input JSON: {\"code\": \"...\"}. \
154         Returns stdout, stderr, exit_code, and execution_time_ms. \
155         Supported languages depend on the sandbox backend."
156    }
157
158    async fn run(&self, input: String) -> Result<String, ToolError> {
159        let parsed: SandboxInput = serde_json::from_str(&input)
160            .map_err(|e| ToolError::InvalidInput(format!("JSON parse error: {}", e)))?;
161
162        if parsed.code.trim().is_empty() {
163            return Err(ToolError::InvalidInput(
164                "code must not be empty".to_string(),
165            ));
166        }
167
168        if !self.dangerously_allow {
169            return Err(ToolError::ExecutionFailed(
170                "SandboxTool is disabled by default for security. \
171                 Call .with_dangerously_allow(true) to enable execution."
172                    .to_string(),
173            ));
174        }
175
176        let result = self
177            .sandbox
178            .run(&parsed.code, self.language, self.timeout_ms)
179            .await
180            .map_err(|e| match e {
181                SandboxError::Timeout(ms) => ToolError::Timeout(ms / 1000),
182                SandboxError::Runtime(msg) => ToolError::ExecutionFailed(msg),
183                SandboxError::UnsupportedLanguage(lang) => {
184                    ToolError::InvalidInput(format!("unsupported language: {}", lang))
185                }
186            })?;
187
188        serde_json::to_string_pretty(&result)
189            .map_err(|e| ToolError::ExecutionFailed(format!("failed to serialize result: {}", e)))
190    }
191
192    fn args_schema(&self) -> Option<serde_json::Value> {
193        use schemars::schema_for;
194        // J9:schema 序列化失败不再 `.ok()` 占位空 `None`,改 panic(SandboxInput derive
195        // JsonSchema,schema 自描述必可序列化,真失败是内部错误)。
196        Some(
197            serde_json::to_value(schema_for!(SandboxInput))
198                .expect("[lc-tools] internal error: SandboxInput schema failed to serialize"),
199        )
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn test_language_display() {
209        assert_eq!(Language::Python.to_string(), "python");
210        assert_eq!(Language::JavaScript.to_string(), "javascript");
211        assert_eq!(Language::Rust.to_string(), "rust");
212    }
213
214    #[test]
215    fn test_language_serde() {
216        let json = serde_json::to_string(&Language::Python).unwrap();
217        assert_eq!(json, "\"python\"");
218
219        let lang: Language = serde_json::from_str("\"javascript\"").unwrap();
220        assert_eq!(lang, Language::JavaScript);
221    }
222
223    #[test]
224    fn test_run_result_serialization() {
225        let result = RunResult {
226            stdout: "hello\n".to_string(),
227            stderr: String::new(),
228            exit_code: 0,
229            execution_time_ms: 42,
230        };
231        let json = serde_json::to_string(&result).unwrap();
232        let parsed: RunResult = serde_json::from_str(&json).unwrap();
233        assert_eq!(parsed.stdout, "hello\n");
234        assert_eq!(parsed.exit_code, 0);
235        assert_eq!(parsed.execution_time_ms, 42);
236    }
237
238    #[test]
239    fn test_sandbox_error_display() {
240        let err = SandboxError::Timeout(5000);
241        assert!(err.to_string().contains("5000ms"));
242
243        let err = SandboxError::Runtime("crashed".to_string());
244        assert!(err.to_string().contains("crashed"));
245
246        let err = SandboxError::UnsupportedLanguage("brainfuck".to_string());
247        assert!(err.to_string().contains("brainfuck"));
248    }
249
250    struct MockSandbox;
251
252    #[async_trait]
253    impl CodeSandbox for MockSandbox {
254        async fn run(
255            &self,
256            code: &str,
257            _language: Language,
258            _timeout_ms: u64,
259        ) -> Result<RunResult, SandboxError> {
260            Ok(RunResult {
261                stdout: format!("executed: {}", code),
262                stderr: String::new(),
263                exit_code: 0,
264                execution_time_ms: 1,
265            })
266        }
267    }
268
269    #[tokio::test]
270    async fn test_sandbox_tool_name_and_description() {
271        let tool = SandboxTool::new(MockSandbox, Language::Python);
272        assert_eq!(tool.name(), "code_interpreter");
273        assert!(tool.description().contains("sandbox"));
274    }
275
276    #[tokio::test]
277    async fn test_sandbox_tool_args_schema() {
278        let tool = SandboxTool::new(MockSandbox, Language::Python);
279        let schema = tool.args_schema();
280        assert!(schema.is_some());
281        let schema = schema.unwrap();
282        assert!(schema["properties"]["code"].is_object());
283    }
284
285    #[tokio::test]
286    async fn test_sandbox_tool_disabled_by_default() {
287        // 0.20.0 S4 P-C1: execution is off until explicitly enabled.
288        let tool = SandboxTool::new(MockSandbox, Language::Python);
289        let result = tool.run(r#"{"code": "print(1+1)"}"#.to_string()).await;
290        assert!(result.is_err());
291        let err = result.unwrap_err().to_string();
292        assert!(
293            err.contains("disabled by default"),
294            "expected disabled-by-default gate, got: {}",
295            err
296        );
297    }
298
299    #[tokio::test]
300    async fn test_sandbox_tool_run_success() {
301        let tool = SandboxTool::new(MockSandbox, Language::Python).with_dangerously_allow(true);
302        let result = tool.run(r#"{"code": "print(1+1)"}"#.to_string()).await;
303        assert!(result.is_ok());
304        let output: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
305        assert_eq!(output["exit_code"], 0);
306        assert!(output["stdout"].as_str().unwrap().contains("executed"));
307    }
308
309    #[tokio::test]
310    async fn test_sandbox_tool_run_empty_code() {
311        let tool = SandboxTool::new(MockSandbox, Language::Python);
312        let result = tool.run(r#"{"code": "  "}"#.to_string()).await;
313        assert!(result.is_err());
314        let err = result.unwrap_err().to_string();
315        assert!(err.contains("empty"));
316    }
317
318    #[tokio::test]
319    async fn test_sandbox_tool_run_invalid_json() {
320        let tool = SandboxTool::new(MockSandbox, Language::Python);
321        let result = tool.run("not json".to_string()).await;
322        assert!(result.is_err());
323    }
324
325    struct TimeoutSandbox;
326
327    #[async_trait]
328    impl CodeSandbox for TimeoutSandbox {
329        async fn run(
330            &self,
331            _code: &str,
332            _language: Language,
333            timeout_ms: u64,
334        ) -> Result<RunResult, SandboxError> {
335            Err(SandboxError::Timeout(timeout_ms))
336        }
337    }
338
339    #[tokio::test]
340    async fn test_sandbox_tool_timeout_maps_to_tool_error() {
341        let tool = SandboxTool::new(TimeoutSandbox, Language::Python)
342            .with_timeout(5000)
343            .with_dangerously_allow(true);
344        let result = tool
345            .run(r#"{"code": "while True: pass"}"#.to_string())
346            .await;
347        assert!(result.is_err());
348        let err = result.unwrap_err();
349        match err {
350            ToolError::Timeout(secs) => assert_eq!(secs, 5),
351            other => panic!("expected Timeout error, got: {:?}", other),
352        }
353    }
354
355    struct UnsupportedSandbox;
356
357    #[async_trait]
358    impl CodeSandbox for UnsupportedSandbox {
359        async fn run(
360            &self,
361            _code: &str,
362            language: Language,
363            _timeout_ms: u64,
364        ) -> Result<RunResult, SandboxError> {
365            Err(SandboxError::UnsupportedLanguage(language.to_string()))
366        }
367    }
368
369    #[tokio::test]
370    async fn test_sandbox_tool_unsupported_language() {
371        let tool =
372            SandboxTool::new(UnsupportedSandbox, Language::Rust).with_dangerously_allow(true);
373        let result = tool.run(r#"{"code": "fn main(){}"}"#.to_string()).await;
374        assert!(result.is_err());
375    }
376}