1use std::collections::HashMap;
10use std::sync::Arc;
11use std::time::Duration;
12
13use async_trait::async_trait;
14use serde_json::{Value, json};
15
16use adk_core::ToolContext;
17
18use crate::backend::SandboxBackend;
19use crate::error::SandboxError;
20use crate::types::{ExecRequest, Language};
21
22pub struct SandboxTool {
46 backend: Arc<dyn SandboxBackend>,
47}
48
49impl SandboxTool {
50 pub fn new(backend: Arc<dyn SandboxBackend>) -> Self {
52 Self { backend }
53 }
54}
55
56const DEFAULT_TIMEOUT_SECS: u64 = 30;
58
59const REQUIRED_SCOPES: &[&str] = &["code:execute"];
61
62fn parse_language(value: &Value) -> Result<Language, String> {
64 let s = value.as_str().ok_or_else(|| "\"language\" must be a string".to_string())?;
65 match s {
66 "rust" => Ok(Language::Rust),
67 "python" => Ok(Language::Python),
68 "javascript" => Ok(Language::JavaScript),
69 "typescript" => Ok(Language::TypeScript),
70 "wasm" => Ok(Language::Wasm),
71 "command" => Ok(Language::Command),
72 other => Err(format!(
73 "unsupported language \"{other}\". Expected one of: rust, python, javascript, typescript, wasm, command"
74 )),
75 }
76}
77
78fn sandbox_error_to_json(err: &SandboxError) -> Value {
83 match err {
84 SandboxError::Timeout { timeout } => json!({
85 "status": "timeout",
86 "stderr": format!("execution timed out after {timeout:?}"),
87 "duration_ms": timeout.as_millis() as u64,
88 }),
89 SandboxError::MemoryExceeded { limit_mb } => json!({
90 "status": "memory_exceeded",
91 "stderr": format!("memory limit exceeded: {limit_mb} MB"),
92 }),
93 SandboxError::ExecutionFailed(msg) => json!({
94 "status": "error",
95 "stderr": msg,
96 }),
97 SandboxError::InvalidRequest(msg) => json!({
98 "status": "error",
99 "stderr": msg,
100 }),
101 SandboxError::BackendUnavailable(msg) => json!({
102 "status": "error",
103 "stderr": msg,
104 }),
105 SandboxError::EnforcerFailed { enforcer, message } => json!({
106 "status": "error",
107 "stderr": format!("enforcer '{enforcer}' failed: {message}"),
108 }),
109 SandboxError::EnforcerUnavailable { enforcer, message } => json!({
110 "status": "error",
111 "stderr": format!("enforcer '{enforcer}' unavailable: {message}"),
112 }),
113 SandboxError::PolicyViolation(msg) => json!({
114 "status": "error",
115 "stderr": format!("policy violation: {msg}"),
116 }),
117 #[cfg(feature = "workspace")]
118 SandboxError::ProvisionFailed { resource, reason, suggestion } => json!({
119 "status": "error",
120 "stderr": format!("provisioning failed for '{resource}': {reason}. {suggestion}"),
121 }),
122 #[cfg(feature = "workspace")]
123 SandboxError::SessionNotFound { handle } => json!({
124 "status": "error",
125 "stderr": format!("session '{handle}' not found. It may have been stopped or expired."),
126 }),
127 #[cfg(feature = "workspace")]
128 SandboxError::SnapshotNotFound { id } => json!({
129 "status": "error",
130 "stderr": format!("snapshot '{id}' not found. It may have been deleted or expired."),
131 }),
132 #[cfg(feature = "workspace")]
133 SandboxError::PathTraversal { path } => json!({
134 "status": "path_traversal",
135 "stderr": format!("path traversal rejected: '{path}' escapes workspace root. Use relative paths only."),
136 }),
137 #[cfg(feature = "workspace")]
138 SandboxError::DockerUnavailable { reason } => json!({
139 "status": "error",
140 "stderr": format!("Docker unavailable: {reason}. Ensure Docker daemon is running and accessible."),
141 }),
142 #[cfg(feature = "workspace")]
143 SandboxError::SessionTimeout { timeout } => json!({
144 "status": "timeout",
145 "stderr": format!("session timed out after {timeout:?}. Consider increasing session_timeout in SandboxConfig."),
146 "duration_ms": timeout.as_millis() as u64,
147 }),
148 }
149}
150
151#[async_trait]
152impl adk_core::Tool for SandboxTool {
153 fn name(&self) -> &str {
154 "sandbox_exec"
155 }
156
157 fn description(&self) -> &str {
158 "Execute code in an isolated sandbox. Supports multiple languages \
159 including rust, python, javascript, typescript, wasm, and shell commands."
160 }
161
162 fn required_scopes(&self) -> &[&str] {
163 REQUIRED_SCOPES
164 }
165
166 fn parameters_schema(&self) -> Option<Value> {
167 Some(json!({
168 "type": "object",
169 "properties": {
170 "language": {
171 "type": "string",
172 "enum": ["rust", "python", "javascript", "typescript", "wasm", "command"],
173 "description": "The programming language of the code to execute."
174 },
175 "code": {
176 "type": "string",
177 "description": "The source code or command to execute."
178 },
179 "stdin": {
180 "type": "string",
181 "description": "Optional standard input to feed to the process."
182 },
183 "timeout_secs": {
184 "type": "integer",
185 "description": "Maximum execution time in seconds.",
186 "default": DEFAULT_TIMEOUT_SECS,
187 "minimum": 1,
188 "maximum": 300
189 }
190 },
191 "required": ["language", "code"]
192 }))
193 }
194
195 async fn execute(&self, _ctx: Arc<dyn ToolContext>, args: Value) -> adk_core::Result<Value> {
196 let language = match args.get("language") {
198 Some(v) => match parse_language(v) {
199 Ok(lang) => lang,
200 Err(msg) => {
201 return Ok(json!({ "status": "error", "stderr": msg }));
202 }
203 },
204 None => {
205 return Ok(
206 json!({ "status": "error", "stderr": "missing required field \"language\"" }),
207 );
208 }
209 };
210
211 let code = match args.get("code").and_then(|v| v.as_str()) {
213 Some(c) => c.to_string(),
214 None => {
215 return Ok(
216 json!({ "status": "error", "stderr": "missing required field \"code\"" }),
217 );
218 }
219 };
220
221 let stdin = args.get("stdin").and_then(|v| v.as_str()).map(String::from);
223
224 let timeout_secs =
226 args.get("timeout_secs").and_then(|v| v.as_u64()).unwrap_or(DEFAULT_TIMEOUT_SECS);
227
228 let request = ExecRequest {
229 language,
230 code,
231 stdin,
232 timeout: Duration::from_secs(timeout_secs),
233 memory_limit_mb: None,
234 env: HashMap::new(),
235 };
236
237 match self.backend.execute(request).await {
238 Ok(result) => Ok(json!({
239 "status": "success",
240 "stdout": result.stdout,
241 "stderr": result.stderr,
242 "exit_code": result.exit_code,
243 "duration_ms": result.duration.as_millis() as u64,
244 })),
245 Err(err) => Ok(sandbox_error_to_json(&err)),
246 }
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use crate::backend::{BackendCapabilities, EnforcedLimits};
254 use crate::types::ExecResult;
255 use adk_core::{CallbackContext, Content, EventActions, ReadonlyContext, Tool};
256 use std::sync::Mutex;
257 use std::time::Duration;
258
259 struct MockBackend {
263 error: Option<SandboxError>,
265 result: ExecResult,
267 }
268
269 impl MockBackend {
270 fn success(stdout: &str, exit_code: i32) -> Self {
271 Self {
272 error: None,
273 result: ExecResult {
274 stdout: stdout.to_string(),
275 stderr: String::new(),
276 exit_code,
277 duration: Duration::from_millis(42),
278 },
279 }
280 }
281
282 fn failing(err: SandboxError) -> Self {
283 Self {
284 error: Some(err),
285 result: ExecResult {
286 stdout: String::new(),
287 stderr: String::new(),
288 exit_code: 0,
289 duration: Duration::ZERO,
290 },
291 }
292 }
293 }
294
295 #[async_trait]
296 impl SandboxBackend for MockBackend {
297 fn name(&self) -> &str {
298 "mock"
299 }
300
301 fn capabilities(&self) -> BackendCapabilities {
302 BackendCapabilities {
303 supported_languages: vec![Language::Python],
304 isolation_class: "mock".to_string(),
305 enforced_limits: EnforcedLimits {
306 timeout: true,
307 memory: false,
308 network_isolation: false,
309 filesystem_write_isolation: false,
310 filesystem_read_isolation: false,
311 environment_isolation: false,
312 },
313 }
314 }
315
316 async fn execute(&self, _request: ExecRequest) -> Result<ExecResult, SandboxError> {
317 if let Some(ref err) = self.error { Err(err.clone()) } else { Ok(self.result.clone()) }
318 }
319 }
320
321 struct MockToolContext {
324 content: Content,
325 actions: Mutex<EventActions>,
326 }
327
328 impl MockToolContext {
329 fn new() -> Self {
330 Self { content: Content::new("user"), actions: Mutex::new(EventActions::default()) }
331 }
332 }
333
334 #[async_trait]
335 impl ReadonlyContext for MockToolContext {
336 fn invocation_id(&self) -> &str {
337 "inv-1"
338 }
339 fn agent_name(&self) -> &str {
340 "test-agent"
341 }
342 fn user_id(&self) -> &str {
343 "user"
344 }
345 fn app_name(&self) -> &str {
346 "app"
347 }
348 fn session_id(&self) -> &str {
349 "session"
350 }
351 fn branch(&self) -> &str {
352 ""
353 }
354 fn user_content(&self) -> &Content {
355 &self.content
356 }
357 }
358
359 #[async_trait]
360 impl CallbackContext for MockToolContext {
361 fn artifacts(&self) -> Option<Arc<dyn adk_core::Artifacts>> {
362 None
363 }
364 }
365
366 #[async_trait]
367 impl ToolContext for MockToolContext {
368 fn function_call_id(&self) -> &str {
369 "call-1"
370 }
371 fn actions(&self) -> EventActions {
372 self.actions.lock().unwrap().clone()
373 }
374 fn set_actions(&self, actions: EventActions) {
375 *self.actions.lock().unwrap() = actions;
376 }
377 async fn search_memory(
378 &self,
379 _query: &str,
380 ) -> adk_core::Result<Vec<adk_core::MemoryEntry>> {
381 Ok(vec![])
382 }
383 }
384
385 fn ctx() -> Arc<dyn ToolContext> {
386 Arc::new(MockToolContext::new())
387 }
388
389 #[test]
392 fn test_name() {
393 let tool = SandboxTool::new(Arc::new(MockBackend::success("", 0)));
394 assert_eq!(tool.name(), "sandbox_exec");
395 }
396
397 #[test]
398 fn test_required_scopes() {
399 let tool = SandboxTool::new(Arc::new(MockBackend::success("", 0)));
400 assert_eq!(tool.required_scopes(), &["code:execute"]);
401 }
402
403 #[test]
404 fn test_parameters_schema_is_valid() {
405 let tool = SandboxTool::new(Arc::new(MockBackend::success("", 0)));
406 let schema = tool.parameters_schema().expect("schema should be Some");
407 assert_eq!(schema["type"], "object");
408 assert!(schema["properties"]["language"].is_object());
409 assert!(schema["properties"]["code"].is_object());
410 assert!(schema["properties"]["stdin"].is_object());
411 assert!(schema["properties"]["timeout_secs"].is_object());
412
413 let required = schema["required"].as_array().unwrap();
414 let required_strs: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
415 assert!(required_strs.contains(&"language"));
416 assert!(required_strs.contains(&"code"));
417 assert!(!required_strs.contains(&"stdin"));
418 assert!(!required_strs.contains(&"timeout_secs"));
419 }
420
421 #[tokio::test]
422 async fn test_successful_execution() {
423 let backend = Arc::new(MockBackend::success("hello\n", 0));
424 let tool = SandboxTool::new(backend);
425 let args = json!({ "language": "python", "code": "print('hello')" });
426
427 let result = tool.execute(ctx(), args).await.unwrap();
428
429 assert_eq!(result["status"], "success");
430 assert_eq!(result["stdout"], "hello\n");
431 assert_eq!(result["exit_code"], 0);
432 assert!(result["duration_ms"].is_number());
433 }
434
435 #[tokio::test]
436 async fn test_timeout_error_as_information() {
437 let backend = Arc::new(MockBackend::failing(SandboxError::Timeout {
438 timeout: Duration::from_secs(5),
439 }));
440 let tool = SandboxTool::new(backend);
441 let args = json!({ "language": "python", "code": "import time; time.sleep(100)" });
442
443 let result = tool.execute(ctx(), args).await;
444
445 assert!(result.is_ok());
447 let val = result.unwrap();
448 assert_eq!(val["status"], "timeout");
449 assert!(val["stderr"].as_str().unwrap().contains("timed out"));
450 assert!(val["duration_ms"].is_number());
451 }
452
453 #[tokio::test]
454 async fn test_memory_exceeded_error_as_information() {
455 let backend = Arc::new(MockBackend::failing(SandboxError::MemoryExceeded { limit_mb: 64 }));
456 let tool = SandboxTool::new(backend);
457 let args = json!({ "language": "wasm", "code": "(module)" });
458
459 let result = tool.execute(ctx(), args).await.unwrap();
460
461 assert_eq!(result["status"], "memory_exceeded");
462 assert!(result["stderr"].as_str().unwrap().contains("64 MB"));
463 }
464
465 #[tokio::test]
466 async fn test_execution_failed_error_as_information() {
467 let backend = Arc::new(MockBackend::failing(SandboxError::ExecutionFailed(
468 "spawn failed".to_string(),
469 )));
470 let tool = SandboxTool::new(backend);
471 let args = json!({ "language": "python", "code": "x" });
472
473 let result = tool.execute(ctx(), args).await.unwrap();
474
475 assert_eq!(result["status"], "error");
476 assert_eq!(result["stderr"], "spawn failed");
477 }
478
479 #[tokio::test]
480 async fn test_missing_language_field() {
481 let backend = Arc::new(MockBackend::success("", 0));
482 let tool = SandboxTool::new(backend);
483 let args = json!({ "code": "print('hi')" });
484
485 let result = tool.execute(ctx(), args).await.unwrap();
486
487 assert_eq!(result["status"], "error");
488 assert!(result["stderr"].as_str().unwrap().contains("language"));
489 }
490
491 #[tokio::test]
492 async fn test_missing_code_field() {
493 let backend = Arc::new(MockBackend::success("", 0));
494 let tool = SandboxTool::new(backend);
495 let args = json!({ "language": "python" });
496
497 let result = tool.execute(ctx(), args).await.unwrap();
498
499 assert_eq!(result["status"], "error");
500 assert!(result["stderr"].as_str().unwrap().contains("code"));
501 }
502
503 #[tokio::test]
504 async fn test_unsupported_language() {
505 let backend = Arc::new(MockBackend::success("", 0));
506 let tool = SandboxTool::new(backend);
507 let args = json!({ "language": "cobol", "code": "DISPLAY 'HI'" });
508
509 let result = tool.execute(ctx(), args).await.unwrap();
510
511 assert_eq!(result["status"], "error");
512 assert!(result["stderr"].as_str().unwrap().contains("cobol"));
513 }
514
515 #[tokio::test]
516 async fn test_custom_timeout() {
517 let backend = Arc::new(MockBackend::success("ok", 0));
521 let tool = SandboxTool::new(backend);
522 let args = json!({ "language": "python", "code": "print('ok')", "timeout_secs": 60 });
523
524 let result = tool.execute(ctx(), args).await.unwrap();
525 assert_eq!(result["status"], "success");
526 }
527
528 #[tokio::test]
529 async fn test_stdin_passed_through() {
530 let backend = Arc::new(MockBackend::success("echo", 0));
531 let tool = SandboxTool::new(backend);
532 let args = json!({
533 "language": "python",
534 "code": "import sys; print(sys.stdin.read())",
535 "stdin": "hello"
536 });
537
538 let result = tool.execute(ctx(), args).await.unwrap();
539 assert_eq!(result["status"], "success");
540 }
541
542 #[test]
543 fn test_parse_language_all_variants() {
544 assert_eq!(parse_language(&json!("rust")).unwrap(), Language::Rust);
545 assert_eq!(parse_language(&json!("python")).unwrap(), Language::Python);
546 assert_eq!(parse_language(&json!("javascript")).unwrap(), Language::JavaScript);
547 assert_eq!(parse_language(&json!("typescript")).unwrap(), Language::TypeScript);
548 assert_eq!(parse_language(&json!("wasm")).unwrap(), Language::Wasm);
549 assert_eq!(parse_language(&json!("command")).unwrap(), Language::Command);
550 assert!(parse_language(&json!("ruby")).is_err());
551 assert!(parse_language(&json!(42)).is_err());
552 }
553
554 #[test]
555 fn test_sandbox_error_to_json_variants() {
556 let timeout_json =
557 sandbox_error_to_json(&SandboxError::Timeout { timeout: Duration::from_secs(10) });
558 assert_eq!(timeout_json["status"], "timeout");
559
560 let mem_json = sandbox_error_to_json(&SandboxError::MemoryExceeded { limit_mb: 128 });
561 assert_eq!(mem_json["status"], "memory_exceeded");
562
563 let exec_json = sandbox_error_to_json(&SandboxError::ExecutionFailed("boom".into()));
564 assert_eq!(exec_json["status"], "error");
565
566 let invalid_json = sandbox_error_to_json(&SandboxError::InvalidRequest("bad".into()));
567 assert_eq!(invalid_json["status"], "error");
568
569 let unavail_json = sandbox_error_to_json(&SandboxError::BackendUnavailable("gone".into()));
570 assert_eq!(unavail_json["status"], "error");
571 }
572}