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 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 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}