1use async_trait::async_trait;
14use std::collections::HashMap;
15use std::sync::Arc;
16
17use crate::workspace::CommandOutputObserver;
18
19pub mod srt;
20
21pub const PROTECTED_WORKSPACE_DIRECTORIES: &[&str] = &[
28 ".git", ".a3s", ".agents", ".codex", ".claude", ".vscode", ".idea",
29];
30
31pub 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
44pub 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
71pub struct SandboxOutput {
73 pub stdout: String,
75 pub stderr: String,
77 pub exit_code: i32,
79}
80
81#[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
109pub 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#[async_trait]
138pub trait BashSandbox: Send + Sync {
139 async fn exec_command(
145 &self,
146 command: &str,
147 guest_workspace: &str,
148 ) -> anyhow::Result<SandboxOutput>;
149
150 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 async fn shutdown(&self);
164}
165
166#[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}