Skip to main content

sparrow/sandbox/
backends.rs

1use std::path::{Path, PathBuf};
2use std::process::Command as StdCommand;
3
4use super::{Command, ExecResult, FsNetPolicy, Limits, Sandbox};
5
6/// POSIX single-quote escaping. Wraps `s` in single quotes and escapes any embedded
7/// single quote by closing-quote, escaped-quote, reopening-quote: `'\''`.
8/// Safe against `;`, `|`, `&`, `$()`, backticks, newlines.
9fn 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
23// ─── Docker sandbox ─────────────────────────────────────────────────────────────
24
25pub 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
85// ─── SSH remote sandbox ─────────────────────────────────────────────────────────
86
87pub 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        // Quote every component to defeat shell-injection (`;`, `|`, `&`, `$()`, backticks).
111        // The remote sh sees a single argv string, so we must build it safely here.
112        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
140// ─── Cloud/HPC backends (modal, daytona, vercel, singularity) ───────────────────
141//
142// These are CLI-driven backends: when the vendor CLI is installed and
143// authenticated, we shell out to run the command remotely. When it is NOT
144// present we return an HONEST non-zero error (exit 127) — never a fake success.
145// The "remote VM" use case is fully covered today by `SshSandbox` and
146// `DockerSandbox`; these add vendor-managed environments on top.
147
148macro_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                    // Honest failure — not a fabricated success.
183                    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
220// Best-effort vendor CLI invocations; exact sub-commands are configurable by
221// installing the vendor CLI which defines them. Missing CLI → honest error.
222cli_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
227// ─── Worktree sandbox ───────────────────────────────────────────────────────────
228//
229// Runs commands inside a dedicated `git worktree` so mutations land on an
230// isolated branch and never touch the user's working copy. If the `repo_root`
231// is not a git repository the constructor returns an error — no fake success.
232
233use 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    /// Create a new git worktree under `parent_dir` checked out on `branch`.
243    /// `repo_root` must be a git repo. The worktree path is
244    /// `parent_dir/sparrow-<branch>`.
245    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}