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;
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}