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