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