Skip to main content

a3s_code_core/
sandbox.rs

1//! Sandbox integration for bash tool execution.
2//!
3//! When a [`BashSandbox`] is provided via [`ToolContext::with_sandbox`], the
4//! `bash` built-in tool routes commands through that sandbox instead of
5//! `std::process::Command`. The workspace directory is mounted read-write
6//! at `/workspace` inside the sandbox.
7//!
8//! The concrete sandbox implementation is supplied by the host application
9//! (e.g., SafeClaw can provide an A3S Box–backed implementation after the
10//! user installs `a3s-box`). This crate defines only the trait contract.
11
12use async_trait::async_trait;
13use std::collections::HashMap;
14
15// ============================================================================
16// SandboxConfig
17// ============================================================================
18
19/// Configuration for routing `bash` tool execution through a sandbox.
20///
21/// Pass to [`SessionOptions::with_sandbox()`](crate::SessionOptions::with_sandbox)
22/// to activate. The host application is responsible for constructing the
23/// matching [`BashSandbox`] implementation.
24#[derive(Debug, Clone)]
25pub struct SandboxConfig {
26    /// OCI image reference (e.g., `"alpine:latest"`, `"ubuntu:22.04"`).
27    pub image: String,
28    /// Memory limit in megabytes (default: 512).
29    pub memory_mb: u32,
30    /// Number of vCPUs (default: 1).
31    pub cpus: u32,
32    /// Enable outbound networking (default: `false` — safer for agent workflows).
33    pub network: bool,
34    /// Additional environment variables to inject into the sandbox.
35    pub env: HashMap<String, String>,
36}
37
38impl Default for SandboxConfig {
39    fn default() -> Self {
40        Self {
41            image: "alpine:latest".into(),
42            memory_mb: 512,
43            cpus: 1,
44            network: false,
45            env: HashMap::new(),
46        }
47    }
48}
49
50// ============================================================================
51// SandboxOutput
52// ============================================================================
53
54/// Output from running a command inside a sandbox.
55pub struct SandboxOutput {
56    /// Standard output bytes decoded as UTF-8.
57    pub stdout: String,
58    /// Standard error bytes decoded as UTF-8.
59    pub stderr: String,
60    /// Process exit code (0 = success).
61    pub exit_code: i32,
62}
63
64// ============================================================================
65// BashSandbox trait
66// ============================================================================
67
68/// Abstraction over sandbox bash execution used by the `bash` built-in tool.
69///
70/// Implement this trait to provide a custom sandbox backend. The host
71/// application constructs the implementation and passes it to the session
72/// via [`ToolContext::with_sandbox`].
73#[async_trait]
74pub trait BashSandbox: Send + Sync {
75    /// Execute a shell command inside the sandbox.
76    ///
77    /// * `command` — the shell command string (passed as `bash -c <command>`).
78    /// * `guest_workspace` — the guest path where the workspace is mounted
79    ///   (e.g., `"/workspace"`).
80    async fn exec_command(
81        &self,
82        command: &str,
83        guest_workspace: &str,
84    ) -> anyhow::Result<SandboxOutput>;
85
86    /// Shut down the sandbox (best-effort, infallible from caller's perspective).
87    async fn shutdown(&self);
88}
89
90// ============================================================================
91// Tests
92// ============================================================================
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use std::sync::Arc;
98
99    #[test]
100    fn test_sandbox_config_default() {
101        let cfg = SandboxConfig::default();
102        assert_eq!(cfg.image, "alpine:latest");
103        assert_eq!(cfg.memory_mb, 512);
104        assert_eq!(cfg.cpus, 1);
105        assert!(!cfg.network);
106        assert!(cfg.env.is_empty());
107    }
108
109    #[test]
110    fn test_sandbox_config_custom() {
111        let cfg = SandboxConfig {
112            image: "ubuntu:22.04".into(),
113            memory_mb: 1024,
114            cpus: 2,
115            network: true,
116            env: [("FOO".into(), "bar".into())].into(),
117        };
118        assert_eq!(cfg.image, "ubuntu:22.04");
119        assert_eq!(cfg.memory_mb, 1024);
120        assert_eq!(cfg.cpus, 2);
121        assert!(cfg.network);
122        assert_eq!(cfg.env["FOO"], "bar");
123    }
124
125    #[test]
126    fn test_sandbox_config_clone() {
127        let cfg = SandboxConfig {
128            image: "python:3.12-slim".into(),
129            ..SandboxConfig::default()
130        };
131        let cloned = cfg.clone();
132        assert_eq!(cloned.image, "python:3.12-slim");
133        assert_eq!(cloned.memory_mb, cfg.memory_mb);
134    }
135
136    struct MockSandbox {
137        output: String,
138        exit_code: i32,
139    }
140
141    #[async_trait]
142    impl BashSandbox for MockSandbox {
143        async fn exec_command(
144            &self,
145            _command: &str,
146            _guest_workspace: &str,
147        ) -> anyhow::Result<SandboxOutput> {
148            Ok(SandboxOutput {
149                stdout: self.output.clone(),
150                stderr: String::new(),
151                exit_code: self.exit_code,
152            })
153        }
154
155        async fn shutdown(&self) {}
156    }
157
158    #[tokio::test]
159    async fn test_mock_sandbox_success() {
160        let sandbox = MockSandbox {
161            output: "hello sandbox\n".into(),
162            exit_code: 0,
163        };
164        let result = sandbox
165            .exec_command("echo hello sandbox", "/workspace")
166            .await
167            .unwrap();
168        assert_eq!(result.stdout, "hello sandbox\n");
169        assert_eq!(result.exit_code, 0);
170        assert!(result.stderr.is_empty());
171    }
172
173    #[tokio::test]
174    async fn test_mock_sandbox_nonzero_exit() {
175        let sandbox = MockSandbox {
176            output: String::new(),
177            exit_code: 127,
178        };
179        let result = sandbox
180            .exec_command("nonexistent_cmd", "/workspace")
181            .await
182            .unwrap();
183        assert_eq!(result.exit_code, 127);
184    }
185
186    #[tokio::test]
187    async fn test_bash_sandbox_is_arc_send_sync() {
188        let sandbox: Arc<dyn BashSandbox> = Arc::new(MockSandbox {
189            output: "ok".into(),
190            exit_code: 0,
191        });
192        let result = sandbox.exec_command("true", "/workspace").await.unwrap();
193        assert_eq!(result.exit_code, 0);
194    }
195}