Skip to main content

agent_sandbox/
lib.rs

1//! `agent-sandbox` — pluggable execution sandbox for tool calls.
2//!
3//! Reuses the shape of codex's `sandboxing` / `linux-sandbox` (`ExecSpec`,
4//! `ExecOutput`) but adds a provider abstraction so the same agent runtime can
5//! run under **Docker** (default), **Kata**, or **Cube** sandboxes.
6//!
7//! Each provider is a thin CLI runner: it shells out to the platform binary
8//! (`docker`, `docker --runtime=kata`, `cube`, …). This keeps the crate
9//! self-contained and runnable wherever the corresponding CLI is installed,
10//! while the [`Sandbox`] trait is the stable seam the rest of the platform
11//! depends on (see `docs/adr/0003-sandbox-providers.md`).
12
13use async_trait::async_trait;
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17/// Specification of a command to execute inside a sandbox.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ExecSpec {
20    /// Container/VM image to run (provider-specific default if `None`).
21    pub image: Option<String>,
22    /// The command and its arguments.
23    pub command: Vec<String>,
24    /// Working directory inside the sandbox.
25    pub workdir: Option<String>,
26    /// Environment variables.
27    pub env: Vec<(String, String)>,
28    /// Wall-clock timeout in milliseconds.
29    pub timeout_ms: u64,
30}
31
32impl ExecSpec {
33    pub fn command(command: Vec<String>) -> Self {
34        Self {
35            image: None,
36            command,
37            workdir: None,
38            env: Vec::new(),
39            timeout_ms: 60_000,
40        }
41    }
42}
43
44/// Result of an execution.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ExecOutput {
47    pub exit_code: i32,
48    pub stdout: String,
49    pub stderr: String,
50}
51
52/// Opaque handle to a spawned sandbox session.
53#[derive(Debug, Clone)]
54pub struct SandboxHandle {
55    pub id: String,
56}
57
58#[derive(Debug, Error)]
59pub enum SandboxError {
60    #[error("sandbox spawn failed: {0}")]
61    Spawn(String),
62    #[error("sandbox exec failed: {0}")]
63    Exec(String),
64    #[error("sandbox not configured: {0}")]
65    NotConfigured(String),
66    #[error("io error: {0}")]
67    Io(#[from] std::io::Error),
68}
69
70/// Stable sandbox contract used by `agent-core` and `agent-cloud`.
71#[async_trait]
72pub trait Sandbox: Send + Sync {
73    /// Spawn a persistent sandbox session and return a handle.
74    async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError>;
75    /// Execute `cmd` inside a previously spawned session.
76    async fn exec(
77        &self,
78        handle: &SandboxHandle,
79        cmd: &[String],
80    ) -> Result<ExecOutput, SandboxError>;
81    /// Tear down a sandbox session.
82    async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError>;
83}
84
85/// Which sandbox backend to use.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum SandboxProvider {
88    Docker,
89    Kata,
90    Cube,
91}
92
93impl SandboxProvider {
94    pub fn parse(s: &str) -> Option<Self> {
95        match s.to_ascii_lowercase().as_str() {
96            "docker" => Some(SandboxProvider::Docker),
97            "kata" => Some(SandboxProvider::Kata),
98            "cube" => Some(SandboxProvider::Cube),
99            _ => None,
100        }
101    }
102
103    pub fn as_str(&self) -> &'static str {
104        match self {
105            SandboxProvider::Docker => "docker",
106            SandboxProvider::Kata => "kata",
107            SandboxProvider::Cube => "cube",
108        }
109    }
110}
111
112/// Generic CLI-backed sandbox. `runner` is the binary (e.g. `docker`);
113/// `run_args` are the args inserted before the image (e.g. `run --rm`, or
114/// `run --runtime=kata --rm`).
115struct CliSandbox {
116    provider: SandboxProvider,
117    runner: String,
118    run_args: Vec<String>,
119    default_image: String,
120}
121
122impl CliSandbox {
123    fn new(
124        provider: SandboxProvider,
125        runner: &str,
126        run_args: Vec<String>,
127        default_image: &str,
128    ) -> Self {
129        Self {
130            provider,
131            runner: runner.to_string(),
132            run_args,
133            default_image: default_image.to_string(),
134        }
135    }
136
137    fn image_of(&self, spec: &ExecSpec) -> String {
138        spec.image
139            .clone()
140            .unwrap_or_else(|| self.default_image.clone())
141    }
142}
143
144#[async_trait]
145impl Sandbox for CliSandbox {
146    async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
147        let image = self.image_of(spec);
148        // Create a long-lived session container; real commands run via `exec`.
149        let mut cmd = tokio::process::Command::new(&self.runner);
150        cmd.args(&self.run_args).arg(&image).args(["sleep", "3600"]);
151        let out = cmd
152            .output()
153            .await
154            .map_err(|e| SandboxError::Spawn(e.to_string()))?;
155        if !out.status.success() {
156            return Err(SandboxError::Spawn(
157                String::from_utf8_lossy(&out.stderr).to_string(),
158            ));
159        }
160        let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
161        Ok(SandboxHandle { id })
162    }
163
164    async fn exec(
165        &self,
166        handle: &SandboxHandle,
167        cmd: &[String],
168    ) -> Result<ExecOutput, SandboxError> {
169        if cmd.is_empty() {
170            return Err(SandboxError::Exec("empty command".into()));
171        }
172        let joined = shell_join(cmd);
173        let mut command = tokio::process::Command::new(&self.runner);
174        command
175            .arg("exec")
176            .arg(&handle.id)
177            .args(["sh", "-c", &joined]);
178        let out = command
179            .output()
180            .await
181            .map_err(|e| SandboxError::Exec(e.to_string()))?;
182        Ok(ExecOutput {
183            exit_code: out.status.code().unwrap_or(-1),
184            stdout: String::from_utf8_lossy(&out.stdout).to_string(),
185            stderr: String::from_utf8_lossy(&out.stderr).to_string(),
186        })
187    }
188
189    async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError> {
190        let mut command = tokio::process::Command::new(&self.runner);
191        command.arg("rm").arg("-f").arg(&handle.id);
192        let out = command
193            .output()
194            .await
195            .map_err(|e| SandboxError::Exec(e.to_string()))?;
196        if !out.status.success() {
197            tracing::warn!(
198                provider = self.provider.as_str(),
199                stderr = %String::from_utf8_lossy(&out.stderr),
200                "sandbox destroy reported a non-zero status"
201            );
202        }
203        Ok(())
204    }
205}
206
207/// Docker sandbox — the **default** provider.
208pub struct DockerSandbox {
209    inner: CliSandbox,
210}
211
212impl DockerSandbox {
213    pub fn new() -> Self {
214        Self {
215            inner: CliSandbox::new(
216                SandboxProvider::Docker,
217                "docker",
218                vec!["run".into(), "--rm".into(), "-d".into()],
219                "alpine:latest",
220            ),
221        }
222    }
223}
224
225#[async_trait]
226impl Sandbox for DockerSandbox {
227    async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
228        self.inner.spawn(spec).await
229    }
230    async fn exec(
231        &self,
232        handle: &SandboxHandle,
233        cmd: &[String],
234    ) -> Result<ExecOutput, SandboxError> {
235        self.inner.exec(handle, cmd).await
236    }
237    async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError> {
238        self.inner.destroy(handle).await
239    }
240}
241
242/// Kata sandbox — Docker with the `kata` runtime.
243pub struct KataSandbox {
244    inner: CliSandbox,
245}
246
247impl KataSandbox {
248    pub fn new() -> Self {
249        Self {
250            inner: CliSandbox::new(
251                SandboxProvider::Kata,
252                "docker",
253                vec![
254                    "run".into(),
255                    "--runtime".into(),
256                    "kata".into(),
257                    "--rm".into(),
258                    "-d".into(),
259                ],
260                "alpine:latest",
261            ),
262        }
263    }
264}
265
266#[async_trait]
267impl Sandbox for KataSandbox {
268    async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
269        self.inner.spawn(spec).await
270    }
271    async fn exec(
272        &self,
273        handle: &SandboxHandle,
274        cmd: &[String],
275    ) -> Result<ExecOutput, SandboxError> {
276        self.inner.exec(handle, cmd).await
277    }
278    async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError> {
279        self.inner.destroy(handle).await
280    }
281}
282
283/// Cube sandbox — shells out to the `cube` CLI. The exact flags depend on the
284/// deployed Cube runtime; adjust `run_args` per environment.
285pub struct CubeSandbox {
286    inner: CliSandbox,
287}
288
289impl CubeSandbox {
290    pub fn new() -> Self {
291        Self {
292            inner: CliSandbox::new(
293                SandboxProvider::Cube,
294                "cube",
295                // Plausible `cube` invocation; align with your deployment.
296                vec!["sandbox".into(), "run".into(), "--rm".into()],
297                "cube-image:latest",
298            ),
299        }
300    }
301}
302
303#[async_trait]
304impl Sandbox for CubeSandbox {
305    async fn spawn(&self, spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
306        self.inner.spawn(spec).await
307    }
308    async fn exec(
309        &self,
310        handle: &SandboxHandle,
311        cmd: &[String],
312    ) -> Result<ExecOutput, SandboxError> {
313        self.inner.exec(handle, cmd).await
314    }
315    async fn destroy(&self, handle: SandboxHandle) -> Result<(), SandboxError> {
316        self.inner.destroy(handle).await
317    }
318}
319
320impl Default for DockerSandbox {
321    fn default() -> Self {
322        Self::new()
323    }
324}
325
326impl Default for KataSandbox {
327    fn default() -> Self {
328        Self::new()
329    }
330}
331
332impl Default for CubeSandbox {
333    fn default() -> Self {
334        Self::new()
335    }
336}
337
338/// The platform default sandbox: Docker.
339pub fn default_sandbox() -> Box<dyn Sandbox> {
340    Box::new(DockerSandbox::new())
341}
342
343/// Build a sandbox from a provider selector (config-driven).
344pub fn from_provider(provider: SandboxProvider) -> Box<dyn Sandbox> {
345    match provider {
346        SandboxProvider::Docker => Box::new(DockerSandbox::new()),
347        SandboxProvider::Kata => Box::new(KataSandbox::new()),
348        SandboxProvider::Cube => Box::new(CubeSandbox::new()),
349    }
350}
351
352fn shell_join(cmd: &[String]) -> String {
353    cmd.iter()
354        .map(|a| {
355            if a.contains(char::is_whitespace) || a.contains('"') || a.contains('\'') {
356                format!("'{}'", a.replace('\'', "'\\''"))
357            } else {
358                a.clone()
359            }
360        })
361        .collect::<Vec<_>>()
362        .join(" ")
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn default_is_docker() {
371        let s = default_sandbox();
372        // type-erased; just ensure construction does not panic
373        let _ = s;
374    }
375
376    #[test]
377    fn provider_parsing() {
378        assert_eq!(
379            SandboxProvider::parse("docker"),
380            Some(SandboxProvider::Docker)
381        );
382        assert_eq!(SandboxProvider::parse("KATA"), Some(SandboxProvider::Kata));
383        assert_eq!(SandboxProvider::parse("cube"), Some(SandboxProvider::Cube));
384        assert_eq!(SandboxProvider::parse("podman"), None);
385        let _ = uuid::Uuid::new_v4();
386    }
387
388    #[test]
389    fn shell_join_quotes() {
390        assert_eq!(
391            shell_join(&["echo".into(), "hello world".into()]),
392            "echo 'hello world'"
393        );
394    }
395
396    #[test]
397    fn exec_spec_command_defaults() {
398        let s = ExecSpec::command(vec!["echo".into(), "hi".into()]);
399        assert_eq!(s.command, vec!["echo", "hi"]);
400        assert_eq!(s.timeout_ms, 60_000);
401        assert!(s.image.is_none());
402        assert!(s.workdir.is_none());
403        assert!(s.env.is_empty());
404    }
405
406    #[test]
407    fn shell_join_empty_and_escapes() {
408        assert_eq!(shell_join(&[]), "");
409        // A token containing a double-quote is single-quoted (no inner escaping needed).
410        assert_eq!(shell_join(&["a\"b".into()]), "'a\"b'");
411        // A token containing a single-quote is escaped as '\''.
412        assert_eq!(shell_join(&["it's".into()]), "'it'\\''s'");
413    }
414
415    #[test]
416    fn provider_as_str_roundtrip() {
417        for p in [
418            SandboxProvider::Docker,
419            SandboxProvider::Kata,
420            SandboxProvider::Cube,
421        ] {
422            let s = p.as_str();
423            assert_eq!(SandboxProvider::parse(s), Some(p));
424        }
425    }
426
427    #[test]
428    fn provider_parse_is_case_insensitive_and_rejects_unknown() {
429        assert_eq!(
430            SandboxProvider::parse("DOCKER"),
431            Some(SandboxProvider::Docker)
432        );
433        assert_eq!(SandboxProvider::parse("Kata"), Some(SandboxProvider::Kata));
434        assert_eq!(SandboxProvider::parse("podman"), None);
435        assert_eq!(SandboxProvider::parse(""), None);
436    }
437
438    #[test]
439    fn from_provider_builds_all_variants() {
440        for p in [
441            SandboxProvider::Docker,
442            SandboxProvider::Kata,
443            SandboxProvider::Cube,
444        ] {
445            let _ = from_provider(p); // must construct without panicking
446        }
447        let _ = CubeSandbox::new();
448        let _ = KataSandbox::new();
449    }
450}