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}