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