Skip to main content

adk_sandbox/
tool.rs

1//! [`SandboxTool`] — an [`adk_core::Tool`] implementation that delegates
2//! execution to a configured [`SandboxBackend`].
3//!
4//! The tool exposes code execution to LLM agents via the standard Tool trait.
5//! Errors from the backend are converted to structured JSON responses (never
6//! propagated as `ToolError`), so the agent always receives actionable
7//! information about what happened.
8
9use 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
22/// A tool that executes code in an isolated sandbox.
23///
24/// `SandboxTool` wraps a [`SandboxBackend`] and implements [`adk_core::Tool`],
25/// making sandbox execution available to LLM agents. The tool accepts
26/// `language`, `code`, optional `stdin`, and optional `timeout_secs` parameters.
27///
28/// # Error Handling
29///
30/// Backend errors are **never** propagated as `ToolError`. Instead, they are
31/// converted to structured JSON with a `"status"` field (`"timeout"`,
32/// `"memory_exceeded"`, or `"error"`). This lets the agent reason about
33/// failures without triggering exception handling.
34///
35/// # Example
36///
37/// ```rust,ignore
38/// use adk_sandbox::{SandboxTool, ProcessBackend};
39/// use std::sync::Arc;
40///
41/// let backend = Arc::new(ProcessBackend::default());
42/// let tool = SandboxTool::new(backend);
43/// assert_eq!(tool.name(), "sandbox_exec");
44/// ```
45pub struct SandboxTool {
46    backend: Arc<dyn SandboxBackend>,
47}
48
49impl SandboxTool {
50    /// Creates a new `SandboxTool` wrapping the given backend.
51    pub fn new(backend: Arc<dyn SandboxBackend>) -> Self {
52        Self { backend }
53    }
54}
55
56/// Default timeout in seconds when `timeout_secs` is not provided.
57const DEFAULT_TIMEOUT_SECS: u64 = 30;
58
59/// Smallest `timeout_secs` the tool accepts; the schema's `minimum`.
60const MIN_TIMEOUT_SECS: u64 = 1;
61
62/// Largest `timeout_secs` the tool accepts; the schema's `maximum`.
63const MAX_TIMEOUT_SECS: u64 = 300;
64
65/// Scopes required to execute this tool.
66const REQUIRED_SCOPES: &[&str] = &["code:execute"];
67
68/// Parses a `Language` from a JSON string value.
69fn 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
84/// Converts a [`SandboxError`] into a structured JSON value.
85///
86/// The returned JSON always contains a `"status"` field so the agent can
87/// distinguish between different failure modes.
88fn 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        // Parse language (required)
203        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        // Parse code (required)
218        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        // Parse stdin (optional)
228        let stdin = args.get("stdin").and_then(|v| v.as_str()).map(String::from);
229
230        // Parse timeout_secs (optional, default 30). The schema bounds are advisory to the
231        // model, so they are enforced here: an out-of-range value is clamped rather than
232        // trusted, and a non-numeric one falls back to the default.
233        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    // -- Mock backend ----------------------------------------------------------
270
271    /// A configurable mock backend for testing `SandboxTool`.
272    struct MockBackend {
273        /// When `Some`, `execute()` returns this error.
274        error: Option<SandboxError>,
275        /// When `error` is `None`, `execute()` returns this result.
276        result: ExecResult,
277        /// The timeout of the most recent request.
278        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    // -- Mock ToolContext -------------------------------------------------------
337
338    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    // -- Tests -----------------------------------------------------------------
405
406    #[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        // Must be Ok, not Err
461        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    /// Runs the tool with `timeout_secs` and returns the timeout the backend received.
531    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    /// The schema advertises `minimum: 1` and `maximum: 300`; a model that ignores them must
557    /// not get an unbounded or zero timeout.
558    #[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}