1mod 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum Language {
38 Python,
40 JavaScript,
42 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#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct RunResult {
59 pub stdout: String,
61 pub stderr: String,
63 pub exit_code: i32,
65 pub execution_time_ms: u64,
67}
68
69#[derive(Debug, thiserror::Error)]
71#[non_exhaustive]
72pub enum SandboxError {
73 #[error("execution timeout after {0}ms")]
75 Timeout(u64),
76
77 #[error("sandbox error: {0}")]
79 Runtime(String),
80
81 #[error("language not supported: {0}")]
83 UnsupportedLanguage(String),
84}
85
86#[async_trait]
88pub trait CodeSandbox: Send + Sync {
89 async fn run(
91 &self,
92 code: &str,
93 language: Language,
94 timeout_ms: u64,
95 ) -> Result<RunResult, SandboxError>;
96}
97
98#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
100struct SandboxInput {
101 code: String,
103}
104
105pub struct SandboxTool<S: CodeSandbox> {
110 sandbox: S,
111 language: Language,
112 timeout_ms: u64,
113 dangerously_allow: bool,
115}
116
117impl<S: CodeSandbox> SandboxTool<S> {
118 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 pub fn with_timeout(mut self, ms: u64) -> Self {
133 self.timeout_ms = ms;
134 self
135 }
136
137 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 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 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}