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        serde_json::to_value(schema_for!(SandboxInput)).ok()
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn test_language_display() {
204        assert_eq!(Language::Python.to_string(), "python");
205        assert_eq!(Language::JavaScript.to_string(), "javascript");
206        assert_eq!(Language::Rust.to_string(), "rust");
207    }
208
209    #[test]
210    fn test_language_serde() {
211        let json = serde_json::to_string(&Language::Python).unwrap();
212        assert_eq!(json, "\"python\"");
213
214        let lang: Language = serde_json::from_str("\"javascript\"").unwrap();
215        assert_eq!(lang, Language::JavaScript);
216    }
217
218    #[test]
219    fn test_run_result_serialization() {
220        let result = RunResult {
221            stdout: "hello\n".to_string(),
222            stderr: String::new(),
223            exit_code: 0,
224            execution_time_ms: 42,
225        };
226        let json = serde_json::to_string(&result).unwrap();
227        let parsed: RunResult = serde_json::from_str(&json).unwrap();
228        assert_eq!(parsed.stdout, "hello\n");
229        assert_eq!(parsed.exit_code, 0);
230        assert_eq!(parsed.execution_time_ms, 42);
231    }
232
233    #[test]
234    fn test_sandbox_error_display() {
235        let err = SandboxError::Timeout(5000);
236        assert!(err.to_string().contains("5000ms"));
237
238        let err = SandboxError::Runtime("crashed".to_string());
239        assert!(err.to_string().contains("crashed"));
240
241        let err = SandboxError::UnsupportedLanguage("brainfuck".to_string());
242        assert!(err.to_string().contains("brainfuck"));
243    }
244
245    struct MockSandbox;
246
247    #[async_trait]
248    impl CodeSandbox for MockSandbox {
249        async fn run(
250            &self,
251            code: &str,
252            _language: Language,
253            _timeout_ms: u64,
254        ) -> Result<RunResult, SandboxError> {
255            Ok(RunResult {
256                stdout: format!("executed: {}", code),
257                stderr: String::new(),
258                exit_code: 0,
259                execution_time_ms: 1,
260            })
261        }
262    }
263
264    #[tokio::test]
265    async fn test_sandbox_tool_name_and_description() {
266        let tool = SandboxTool::new(MockSandbox, Language::Python);
267        assert_eq!(tool.name(), "code_interpreter");
268        assert!(tool.description().contains("sandbox"));
269    }
270
271    #[tokio::test]
272    async fn test_sandbox_tool_args_schema() {
273        let tool = SandboxTool::new(MockSandbox, Language::Python);
274        let schema = tool.args_schema();
275        assert!(schema.is_some());
276        let schema = schema.unwrap();
277        assert!(schema["properties"]["code"].is_object());
278    }
279
280    #[tokio::test]
281    async fn test_sandbox_tool_disabled_by_default() {
282        // 0.20.0 S4 P-C1: execution is off until explicitly enabled.
283        let tool = SandboxTool::new(MockSandbox, Language::Python);
284        let result = tool.run(r#"{"code": "print(1+1)"}"#.to_string()).await;
285        assert!(result.is_err());
286        let err = result.unwrap_err().to_string();
287        assert!(
288            err.contains("disabled by default"),
289            "expected disabled-by-default gate, got: {}",
290            err
291        );
292    }
293
294    #[tokio::test]
295    async fn test_sandbox_tool_run_success() {
296        let tool = SandboxTool::new(MockSandbox, Language::Python).with_dangerously_allow(true);
297        let result = tool.run(r#"{"code": "print(1+1)"}"#.to_string()).await;
298        assert!(result.is_ok());
299        let output: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
300        assert_eq!(output["exit_code"], 0);
301        assert!(output["stdout"].as_str().unwrap().contains("executed"));
302    }
303
304    #[tokio::test]
305    async fn test_sandbox_tool_run_empty_code() {
306        let tool = SandboxTool::new(MockSandbox, Language::Python);
307        let result = tool.run(r#"{"code": "  "}"#.to_string()).await;
308        assert!(result.is_err());
309        let err = result.unwrap_err().to_string();
310        assert!(err.contains("empty"));
311    }
312
313    #[tokio::test]
314    async fn test_sandbox_tool_run_invalid_json() {
315        let tool = SandboxTool::new(MockSandbox, Language::Python);
316        let result = tool.run("not json".to_string()).await;
317        assert!(result.is_err());
318    }
319
320    struct TimeoutSandbox;
321
322    #[async_trait]
323    impl CodeSandbox for TimeoutSandbox {
324        async fn run(
325            &self,
326            _code: &str,
327            _language: Language,
328            timeout_ms: u64,
329        ) -> Result<RunResult, SandboxError> {
330            Err(SandboxError::Timeout(timeout_ms))
331        }
332    }
333
334    #[tokio::test]
335    async fn test_sandbox_tool_timeout_maps_to_tool_error() {
336        let tool = SandboxTool::new(TimeoutSandbox, Language::Python)
337            .with_timeout(5000)
338            .with_dangerously_allow(true);
339        let result = tool
340            .run(r#"{"code": "while True: pass"}"#.to_string())
341            .await;
342        assert!(result.is_err());
343        let err = result.unwrap_err();
344        match err {
345            ToolError::Timeout(secs) => assert_eq!(secs, 5),
346            other => panic!("expected Timeout error, got: {:?}", other),
347        }
348    }
349
350    struct UnsupportedSandbox;
351
352    #[async_trait]
353    impl CodeSandbox for UnsupportedSandbox {
354        async fn run(
355            &self,
356            _code: &str,
357            language: Language,
358            _timeout_ms: u64,
359        ) -> Result<RunResult, SandboxError> {
360            Err(SandboxError::UnsupportedLanguage(language.to_string()))
361        }
362    }
363
364    #[tokio::test]
365    async fn test_sandbox_tool_unsupported_language() {
366        let tool =
367            SandboxTool::new(UnsupportedSandbox, Language::Rust).with_dangerously_allow(true);
368        let result = tool.run(r#"{"code": "fn main(){}"}"#.to_string()).await;
369        assert!(result.is_err());
370    }
371}