Skip to main content

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}