Skip to main content

a3s_code_core/
sandbox.rs

1//! Sandbox integration for bash tool execution.
2//!
3//! When a [`BashSandbox`] is provided via
4//! [`ToolContext::with_sandbox`](crate::tools::ToolContext::with_sandbox), the
5//! `bash` built-in tool routes commands through that sandbox instead of
6//! `std::process::Command`. The workspace directory is mounted read-write
7//! at `/workspace` inside the sandbox.
8//!
9//! The concrete sandbox implementation is supplied by the host application
10//! (e.g., SafeClaw can provide an A3S Box–backed implementation after the
11//! user installs `a3s-box`). This crate defines only the trait contract.
12
13use async_trait::async_trait;
14use std::collections::HashMap;
15use std::sync::Arc;
16
17use crate::workspace::CommandOutputObserver;
18
19pub mod srt;
20
21/// Workspace-relative directories whose contents can change the agent,
22/// repository, editor, or tool control plane.
23///
24/// Ordinary sandboxed commands and quiet workspace file mutations must not
25/// write these paths. An interactive host may expose an explicit, auditable
26/// escalation path instead.
27pub const PROTECTED_WORKSPACE_DIRECTORIES: &[&str] = &[
28    ".git", ".a3s", ".agents", ".codex", ".claude", ".vscode", ".idea",
29];
30
31/// Workspace-relative files that can change command discovery or repository
32/// behavior even though they are not contained in a protected directory.
33pub const PROTECTED_WORKSPACE_FILES: &[&str] = &[
34    ".gitmodules",
35    ".mcp.json",
36    ".ripgreprc",
37    ".bashrc",
38    ".bash_profile",
39    ".zshrc",
40    ".zprofile",
41    ".profile",
42];
43
44/// Return whether a workspace-relative path targets protected control
45/// metadata.
46///
47/// Both separators are recognized so policy decisions are stable before a
48/// platform-specific workspace resolver consumes the path. Boundary traversal
49/// is handled separately by the workspace guardrail and is never treated as a
50/// protected-path approval request.
51pub fn is_protected_workspace_path(path: &str) -> bool {
52    let normalized = path.replace('\\', "/");
53    let mut components = normalized
54        .split('/')
55        .filter(|component| !component.is_empty() && *component != ".");
56    let Some(first) = components.next() else {
57        return false;
58    };
59    if first == ".." || components.clone().any(|component| component == "..") {
60        return false;
61    }
62
63    PROTECTED_WORKSPACE_DIRECTORIES
64        .iter()
65        .any(|protected| first.eq_ignore_ascii_case(protected))
66        || PROTECTED_WORKSPACE_FILES
67            .iter()
68            .any(|protected| first.eq_ignore_ascii_case(protected))
69}
70
71/// Output from running a command inside a sandbox.
72pub struct SandboxOutput {
73    /// Standard output bytes decoded as UTF-8.
74    pub stdout: String,
75    /// Standard error bytes decoded as UTF-8.
76    pub stderr: String,
77    /// Process exit code (0 = success).
78    pub exit_code: i32,
79}
80
81/// Complete request passed to sandbox implementations that support the
82/// execution controls used by the built-in `bash` tool.
83///
84/// The legacy [`BashSandbox::exec_command`] method remains the minimum
85/// compatibility contract. New implementations should override
86/// [`BashSandbox::exec`] so command timeouts, streaming output, and explicit
87/// host-provided environment values are preserved inside the sandbox.
88#[derive(Clone)]
89pub struct SandboxCommandRequest {
90    pub command: String,
91    pub guest_workspace: String,
92    pub timeout_ms: u64,
93    pub output_observer: Option<Arc<dyn CommandOutputObserver>>,
94    pub env: Option<Arc<HashMap<String, String>>>,
95}
96
97impl std::fmt::Debug for SandboxCommandRequest {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        f.debug_struct("SandboxCommandRequest")
100            .field("command", &self.command)
101            .field("guest_workspace", &self.guest_workspace)
102            .field("timeout_ms", &self.timeout_ms)
103            .field("output_observer", &self.output_observer.is_some())
104            .field("env", &self.env.as_ref().map(|env| env.len()))
105            .finish()
106    }
107}
108
109/// Output from the extended sandbox execution contract.
110pub struct SandboxExecutionOutput {
111    pub stdout: String,
112    pub stderr: String,
113    pub exit_code: i32,
114    pub timed_out: bool,
115}
116
117impl From<SandboxOutput> for SandboxExecutionOutput {
118    fn from(output: SandboxOutput) -> Self {
119        Self {
120            stdout: output.stdout,
121            stderr: output.stderr,
122            exit_code: output.exit_code,
123            timed_out: false,
124        }
125    }
126}
127
128// ============================================================================
129// BashSandbox trait
130// ============================================================================
131
132/// Abstraction over sandbox bash execution used by the `bash` built-in tool.
133///
134/// Implement this trait to provide a custom sandbox backend. The host
135/// application constructs the implementation and passes it to the session
136/// via [`ToolContext::with_sandbox`](crate::tools::ToolContext::with_sandbox).
137#[async_trait]
138pub trait BashSandbox: Send + Sync {
139    /// Execute a shell command inside the sandbox.
140    ///
141    /// * `command` — the shell command string (passed as `bash -c <command>`).
142    /// * `guest_workspace` — the guest path where the workspace is mounted
143    ///   (e.g., `"/workspace"`).
144    async fn exec_command(
145        &self,
146        command: &str,
147        guest_workspace: &str,
148    ) -> anyhow::Result<SandboxOutput>;
149
150    /// Execute a command with the complete host tool contract.
151    ///
152    /// Existing implementations inherit a compatibility adapter that delegates
153    /// to [`Self::exec_command`]. Sandboxes that spawn a real process should
154    /// override this method so timeout and output-stream semantics are not
155    /// silently lost.
156    async fn exec(&self, request: SandboxCommandRequest) -> anyhow::Result<SandboxExecutionOutput> {
157        self.exec_command(&request.command, &request.guest_workspace)
158            .await
159            .map(Into::into)
160    }
161
162    /// Shut down the sandbox (best-effort, infallible from caller's perspective).
163    async fn shutdown(&self);
164}
165
166// ============================================================================
167// Tests
168// ============================================================================
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use std::sync::Arc;
174
175    struct MockSandbox {
176        output: String,
177        exit_code: i32,
178    }
179
180    #[async_trait]
181    impl BashSandbox for MockSandbox {
182        async fn exec_command(
183            &self,
184            _command: &str,
185            _guest_workspace: &str,
186        ) -> anyhow::Result<SandboxOutput> {
187            Ok(SandboxOutput {
188                stdout: self.output.clone(),
189                stderr: String::new(),
190                exit_code: self.exit_code,
191            })
192        }
193
194        async fn shutdown(&self) {}
195    }
196
197    #[tokio::test]
198    async fn test_mock_sandbox_success() {
199        let sandbox = MockSandbox {
200            output: "hello sandbox\n".into(),
201            exit_code: 0,
202        };
203        let result = sandbox
204            .exec_command("echo hello sandbox", "/workspace")
205            .await
206            .unwrap();
207        assert_eq!(result.stdout, "hello sandbox\n");
208        assert_eq!(result.exit_code, 0);
209        assert!(result.stderr.is_empty());
210    }
211
212    #[tokio::test]
213    async fn test_mock_sandbox_nonzero_exit() {
214        let sandbox = MockSandbox {
215            output: String::new(),
216            exit_code: 127,
217        };
218        let result = sandbox
219            .exec_command("nonexistent_cmd", "/workspace")
220            .await
221            .unwrap();
222        assert_eq!(result.exit_code, 127);
223    }
224
225    #[tokio::test]
226    async fn test_bash_sandbox_is_arc_send_sync() {
227        let sandbox: Arc<dyn BashSandbox> = Arc::new(MockSandbox {
228            output: "ok".into(),
229            exit_code: 0,
230        });
231        let result = sandbox.exec_command("true", "/workspace").await.unwrap();
232        assert_eq!(result.exit_code, 0);
233    }
234
235    #[test]
236    fn protected_workspace_paths_cover_control_metadata_cross_platform() {
237        for path in [
238            ".git/config",
239            "./.a3s/permissions.acl",
240            ".AGENTS/worker.acl",
241            ".codex\\config",
242            ".Claude/settings.json",
243            ".vscode/tasks.json",
244            ".idea/workspace.xml",
245            ".gitmodules",
246            ".MCP.JSON",
247        ] {
248            assert!(
249                is_protected_workspace_path(path),
250                "{path} should require explicit host authorization"
251            );
252        }
253        for path in [
254            "src/lib.rs",
255            "nested/.git/config",
256            "AGENTS.md",
257            "../.git/config",
258        ] {
259            assert!(
260                !is_protected_workspace_path(path),
261                "{path} should be handled by another boundary or remain ordinary"
262            );
263        }
264    }
265}