a3s_code_core/sandbox.rs
1//! Sandbox integration for bash tool execution.
2//!
3//! Local A3S Code sessions install a [`BashSandbox`] automatically, and the
4//! `bash` built-in tool routes commands through it instead of
5//! `std::process::Command`. The A3S native backend keeps the canonical host
6//! workspace path while enforcing the platform isolation boundary around the
7//! child process.
8//!
9//! [`native::NativeBashSandbox`] is the A3S-owned fail-closed implementation
10//! used by default throughout A3S Code. Hosts can still supply another
11//! implementation through the trait contract when they own an equivalent
12//! isolation boundary. Non-local workspace backends retain their explicit
13//! command-runner contract.
14
15use async_trait::async_trait;
16use std::collections::HashMap;
17use std::sync::Arc;
18
19use crate::workspace::CommandOutputObserver;
20
21pub mod native;
22
23pub use a3s_sandbox::{
24 is_protected_workspace_path, PROTECTED_WORKSPACE_DIRECTORIES, PROTECTED_WORKSPACE_FILES,
25};
26
27/// Output from running a command inside a sandbox.
28pub struct SandboxOutput {
29 /// Standard output bytes decoded as UTF-8.
30 pub stdout: String,
31 /// Standard error bytes decoded as UTF-8.
32 pub stderr: String,
33 /// Process exit code (0 = success).
34 pub exit_code: i32,
35}
36
37/// Complete request passed to sandbox implementations that support the
38/// execution controls used by the built-in `bash` tool.
39///
40/// The legacy [`BashSandbox::exec_command`] method remains the minimum
41/// compatibility contract. New implementations should override
42/// [`BashSandbox::exec`] so command timeouts, streaming output, and explicit
43/// host-provided environment values are preserved inside the sandbox.
44#[derive(Clone)]
45pub struct SandboxCommandRequest {
46 pub command: String,
47 pub guest_workspace: String,
48 pub timeout_ms: u64,
49 pub output_observer: Option<Arc<dyn CommandOutputObserver>>,
50 pub env: Option<Arc<HashMap<String, String>>>,
51}
52
53impl std::fmt::Debug for SandboxCommandRequest {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.debug_struct("SandboxCommandRequest")
56 .field("command", &self.command)
57 .field("guest_workspace", &self.guest_workspace)
58 .field("timeout_ms", &self.timeout_ms)
59 .field("output_observer", &self.output_observer.is_some())
60 .field("env", &self.env.as_ref().map(|env| env.len()))
61 .finish()
62 }
63}
64
65/// Output from the extended sandbox execution contract.
66pub struct SandboxExecutionOutput {
67 pub stdout: String,
68 pub stderr: String,
69 pub exit_code: i32,
70 pub timed_out: bool,
71}
72
73impl From<SandboxOutput> for SandboxExecutionOutput {
74 fn from(output: SandboxOutput) -> Self {
75 Self {
76 stdout: output.stdout,
77 stderr: output.stderr,
78 exit_code: output.exit_code,
79 timed_out: false,
80 }
81 }
82}
83
84// ============================================================================
85// BashSandbox trait
86// ============================================================================
87
88/// Abstraction over sandbox bash execution used by the `bash` built-in tool.
89///
90/// Implement this trait to replace the native sandbox with a custom backend.
91/// The host application constructs the implementation and passes it to the
92/// session via [`crate::SessionOptions::with_sandbox_handle`].
93#[async_trait]
94pub trait BashSandbox: Send + Sync {
95 /// Execute a shell command inside the sandbox.
96 ///
97 /// * `command` — the shell command string (passed as `bash -c <command>`).
98 /// * `guest_workspace` — the workspace path expected by a custom guest
99 /// backend (for example, `"/workspace"`). The A3S native backend uses its
100 /// canonical host workspace instead.
101 async fn exec_command(
102 &self,
103 command: &str,
104 guest_workspace: &str,
105 ) -> anyhow::Result<SandboxOutput>;
106
107 /// Execute a command with the complete host tool contract.
108 ///
109 /// Existing implementations inherit a compatibility adapter that delegates
110 /// to [`Self::exec_command`]. Sandboxes that spawn a real process should
111 /// override this method so timeout and output-stream semantics are not
112 /// silently lost. The built-in `bash` tool also enforces `timeout_ms` by
113 /// dropping this future at the deadline, so implementations must terminate
114 /// or otherwise contain child processes when their execution future is
115 /// cancelled.
116 async fn exec(&self, request: SandboxCommandRequest) -> anyhow::Result<SandboxExecutionOutput> {
117 self.exec_command(&request.command, &request.guest_workspace)
118 .await
119 .map(Into::into)
120 }
121
122 /// Shut down the sandbox (best-effort, infallible from caller's perspective).
123 async fn shutdown(&self);
124}
125
126// ============================================================================
127// Tests
128// ============================================================================
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use std::sync::Arc;
134
135 struct MockSandbox {
136 output: String,
137 exit_code: i32,
138 }
139
140 #[async_trait]
141 impl BashSandbox for MockSandbox {
142 async fn exec_command(
143 &self,
144 _command: &str,
145 _guest_workspace: &str,
146 ) -> anyhow::Result<SandboxOutput> {
147 Ok(SandboxOutput {
148 stdout: self.output.clone(),
149 stderr: String::new(),
150 exit_code: self.exit_code,
151 })
152 }
153
154 async fn shutdown(&self) {}
155 }
156
157 #[tokio::test]
158 async fn test_mock_sandbox_success() {
159 let sandbox = MockSandbox {
160 output: "hello sandbox\n".into(),
161 exit_code: 0,
162 };
163 let result = sandbox
164 .exec_command("echo hello sandbox", "/workspace")
165 .await
166 .unwrap();
167 assert_eq!(result.stdout, "hello sandbox\n");
168 assert_eq!(result.exit_code, 0);
169 assert!(result.stderr.is_empty());
170 }
171
172 #[tokio::test]
173 async fn test_mock_sandbox_nonzero_exit() {
174 let sandbox = MockSandbox {
175 output: String::new(),
176 exit_code: 127,
177 };
178 let result = sandbox
179 .exec_command("nonexistent_cmd", "/workspace")
180 .await
181 .unwrap();
182 assert_eq!(result.exit_code, 127);
183 }
184
185 #[tokio::test]
186 async fn test_bash_sandbox_is_arc_send_sync() {
187 let sandbox: Arc<dyn BashSandbox> = Arc::new(MockSandbox {
188 output: "ok".into(),
189 exit_code: 0,
190 });
191 let result = sandbox.exec_command("true", "/workspace").await.unwrap();
192 assert_eq!(result.exit_code, 0);
193 }
194
195 #[test]
196 fn protected_workspace_paths_cover_control_metadata_cross_platform() {
197 for path in [
198 ".git/config",
199 "./.a3s/permissions.acl",
200 ".AGENTS/worker.acl",
201 ".codex\\config",
202 ".Claude/settings.json",
203 ".vscode/tasks.json",
204 ".idea/workspace.xml",
205 ".gitmodules",
206 ".MCP.JSON",
207 ] {
208 assert!(
209 is_protected_workspace_path(path),
210 "{path} should require explicit host authorization"
211 );
212 }
213 for path in [
214 "src/lib.rs",
215 "nested/.git/config",
216 "AGENTS.md",
217 "../.git/config",
218 ] {
219 assert!(
220 !is_protected_workspace_path(path),
221 "{path} should be handled by another boundary or remain ordinary"
222 );
223 }
224 }
225}