use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Arc;
use crate::workspace::CommandOutputObserver;
pub mod srt;
pub const PROTECTED_WORKSPACE_DIRECTORIES: &[&str] = &[
".git", ".a3s", ".agents", ".codex", ".claude", ".vscode", ".idea",
];
pub const PROTECTED_WORKSPACE_FILES: &[&str] = &[
".gitmodules",
".mcp.json",
".ripgreprc",
".bashrc",
".bash_profile",
".zshrc",
".zprofile",
".profile",
];
pub fn is_protected_workspace_path(path: &str) -> bool {
let normalized = path.replace('\\', "/");
let mut components = normalized
.split('/')
.filter(|component| !component.is_empty() && *component != ".");
let Some(first) = components.next() else {
return false;
};
if first == ".." || components.clone().any(|component| component == "..") {
return false;
}
PROTECTED_WORKSPACE_DIRECTORIES
.iter()
.any(|protected| first.eq_ignore_ascii_case(protected))
|| PROTECTED_WORKSPACE_FILES
.iter()
.any(|protected| first.eq_ignore_ascii_case(protected))
}
pub struct SandboxOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
#[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()
}
}
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,
}
}
}
#[async_trait]
pub trait BashSandbox: Send + Sync {
async fn exec_command(
&self,
command: &str,
guest_workspace: &str,
) -> anyhow::Result<SandboxOutput>;
async fn exec(&self, request: SandboxCommandRequest) -> anyhow::Result<SandboxExecutionOutput> {
self.exec_command(&request.command, &request.guest_workspace)
.await
.map(Into::into)
}
async fn shutdown(&self);
}
#[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"
);
}
}
}