1use std::path::{Path, PathBuf};
2use std::process::Command as StdCommand;
3
4use super::{Command, ExecResult, FsNetPolicy, Limits, Sandbox};
5
6fn sh_quote(s: &str) -> String {
10 let mut out = String::with_capacity(s.len() + 2);
11 out.push('\'');
12 for c in s.chars() {
13 if c == '\'' {
14 out.push_str("'\\''");
15 } else {
16 out.push(c);
17 }
18 }
19 out.push('\'');
20 out
21}
22
23pub struct DockerSandbox {
26 root: PathBuf,
27 image: String,
28 policy: FsNetPolicy,
29}
30
31impl DockerSandbox {
32 pub fn new(root: PathBuf, image: &str) -> Self {
33 Self {
34 root: root.clone(),
35 image: image.to_string(),
36 policy: FsNetPolicy {
37 allowed_paths: vec![root],
38 allow_network: false,
39 ..FsNetPolicy::default()
40 },
41 }
42 }
43}
44
45#[async_trait::async_trait]
46impl Sandbox for DockerSandbox {
47 async fn exec(&self, cmd: &Command, limits: &Limits) -> anyhow::Result<ExecResult> {
48 let workdir = cmd.workdir.to_string_lossy().to_string();
49 let mut args = vec![
50 "run".into(),
51 "--rm".into(),
52 "-v".into(),
53 format!("{}:/workspace", workdir),
54 "-w".into(),
55 "/workspace".into(),
56 format!("--memory={}m", limits.max_output_bytes / 1024 / 1024 + 128),
57 ];
58
59 if !self.policy.allow_network {
60 args.push("--network=none".into());
61 }
62
63 args.push(self.image.clone());
64 args.push(cmd.program.clone());
65 args.extend(cmd.args.clone());
66
67 let output = StdCommand::new("docker").args(&args).output()?;
68
69 Ok(ExecResult {
70 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
71 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
72 exit_code: output.status.code().unwrap_or(-1),
73 })
74 }
75
76 fn root(&self) -> &Path {
77 &self.root
78 }
79
80 fn policy(&self) -> &FsNetPolicy {
81 &self.policy
82 }
83}
84
85pub struct SshSandbox {
88 root: PathBuf,
89 host: String,
90 policy: FsNetPolicy,
91}
92
93impl SshSandbox {
94 pub fn new(root: PathBuf, host: &str) -> Self {
95 Self {
96 root,
97 host: host.to_string(),
98 policy: FsNetPolicy {
99 allowed_paths: vec![],
100 allow_network: true,
101 ..FsNetPolicy::default()
102 },
103 }
104 }
105}
106
107#[async_trait::async_trait]
108impl Sandbox for SshSandbox {
109 async fn exec(&self, cmd: &Command, _limits: &Limits) -> anyhow::Result<ExecResult> {
110 let quoted_args: Vec<String> = cmd.args.iter().map(|a| sh_quote(a)).collect();
113 let full_cmd = format!(
114 "cd {} && {} {}",
115 sh_quote(&cmd.workdir.to_string_lossy()),
116 sh_quote(&cmd.program),
117 quoted_args.join(" ")
118 );
119
120 let output = StdCommand::new("ssh")
121 .args([&self.host, &full_cmd])
122 .output()?;
123
124 Ok(ExecResult {
125 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
126 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
127 exit_code: output.status.code().unwrap_or(-1),
128 })
129 }
130
131 fn root(&self) -> &Path {
132 &self.root
133 }
134
135 fn policy(&self) -> &FsNetPolicy {
136 &self.policy
137 }
138}
139
140macro_rules! cli_sandbox {
149 ($name:ident, $label:expr, $bin:expr, $exec_args:expr) => {
150 pub struct $name {
151 root: PathBuf,
152 policy: FsNetPolicy,
153 }
154
155 impl $name {
156 pub fn new(root: PathBuf) -> Self {
157 Self {
158 root: root.clone(),
159 policy: FsNetPolicy {
160 allowed_paths: vec![root],
161 allow_network: true,
162 ..FsNetPolicy::default()
163 },
164 }
165 }
166
167 fn cli_available() -> bool {
168 StdCommand::new($bin)
169 .arg("--version")
170 .stdout(std::process::Stdio::null())
171 .stderr(std::process::Stdio::null())
172 .status()
173 .map(|s| s.success())
174 .unwrap_or(false)
175 }
176 }
177
178 #[async_trait::async_trait]
179 impl Sandbox for $name {
180 async fn exec(&self, cmd: &Command, _limits: &Limits) -> anyhow::Result<ExecResult> {
181 if !Self::cli_available() {
182 return Ok(ExecResult {
184 stdout: String::new(),
185 stderr: format!(
186 "{} sandbox unavailable: '{}' CLI not found or not authenticated. \
187 Install/login to it, or use sandbox=ssh / sandbox=docker which are \
188 fully supported.",
189 $label, $bin
190 ),
191 exit_code: 127,
192 });
193 }
194 let user_cmd = format!("{} {}", cmd.program, cmd.args.join(" "));
195 let mut args: Vec<String> =
196 $exec_args.iter().map(|s: &&str| s.to_string()).collect();
197 args.push(user_cmd);
198 let output = StdCommand::new($bin)
199 .args(&args)
200 .current_dir(&cmd.workdir)
201 .output()?;
202 Ok(ExecResult {
203 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
204 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
205 exit_code: output.status.code().unwrap_or(-1),
206 })
207 }
208
209 fn root(&self) -> &Path {
210 &self.root
211 }
212
213 fn policy(&self) -> &FsNetPolicy {
214 &self.policy
215 }
216 }
217 };
218}
219
220cli_sandbox!(ModalSandbox, "modal", "modal", ["run", "--"]);
223cli_sandbox!(DaytonaSandbox, "daytona", "daytona", ["exec", "--"]);
224cli_sandbox!(VercelSandbox, "vercel-sandbox", "vercel", ["exec", "--"]);
225cli_sandbox!(SingularitySandbox, "singularity", "singularity", ["exec"]);
226
227use crate::sandbox::{LocalSandbox, default_denied_paths};
234
235pub struct WorktreeSandbox {
236 inner: LocalSandbox,
237 worktree_path: PathBuf,
238 branch: String,
239}
240
241impl WorktreeSandbox {
242 pub fn create(repo_root: &Path, parent_dir: &Path, branch: &str) -> anyhow::Result<Self> {
246 if !repo_root.join(".git").exists() {
247 anyhow::bail!(
248 "WorktreeSandbox requires a git repo at {} (no .git/)",
249 repo_root.display()
250 );
251 }
252 std::fs::create_dir_all(parent_dir)?;
253 let worktree_path = parent_dir.join(format!("sparrow-{}", branch));
254
255 let status = StdCommand::new("git")
256 .args([
257 "-C",
258 &repo_root.to_string_lossy(),
259 "worktree",
260 "add",
261 "-B",
262 branch,
263 &worktree_path.to_string_lossy(),
264 ])
265 .status();
266 match status {
267 Ok(s) if s.success() => {}
268 Ok(s) => anyhow::bail!("git worktree add failed (exit {:?})", s.code()),
269 Err(e) => anyhow::bail!("git not available: {}", e),
270 }
271
272 let policy = FsNetPolicy {
273 allowed_paths: vec![worktree_path.clone()],
274 allow_network: true,
275 denied_paths: default_denied_paths(),
276 env_allowlist: Vec::new(),
277 };
278 let inner = LocalSandbox::new(worktree_path.clone()).with_policy(policy);
279 Ok(Self {
280 inner,
281 worktree_path,
282 branch: branch.to_string(),
283 })
284 }
285
286 pub fn branch(&self) -> &str {
287 &self.branch
288 }
289
290 pub fn path(&self) -> &Path {
291 &self.worktree_path
292 }
293}
294
295#[async_trait::async_trait]
296impl Sandbox for WorktreeSandbox {
297 async fn exec(&self, cmd: &Command, limits: &Limits) -> anyhow::Result<ExecResult> {
298 self.inner.exec(cmd, limits).await
299 }
300
301 fn root(&self) -> &Path {
302 self.inner.root()
303 }
304
305 fn policy(&self) -> &FsNetPolicy {
306 self.inner.policy()
307 }
308}