Skip to main content

agent_sandbox/
lib.rs

1pub mod config;
2pub mod error;
3pub mod exec;
4pub mod fs;
5pub mod runtime;
6pub mod toolbox;
7
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use agent_fetch::SafeClient;
12pub use agent_fetch::{DomainPattern, FetchPolicy, FetchRequest, FetchResponse};
13use tokio::sync::Mutex;
14
15use crate::config::SandboxConfig;
16use crate::error::{Result, SandboxError};
17use crate::fs::overlay::{FsChange, FsOverlay};
18use crate::runtime::{ExecResult, WasiRuntime};
19
20/// A sandboxed execution environment backed by WASM (Wasmtime + WASI).
21pub struct Sandbox {
22    runtime: WasiRuntime,
23    overlay: Arc<Mutex<Option<FsOverlay>>>,
24    config: SandboxConfig,
25    destroyed: Arc<std::sync::atomic::AtomicBool>,
26    fetch_client: Option<Arc<SafeClient>>,
27}
28
29impl Sandbox {
30    /// Create a new sandbox with the given configuration.
31    pub fn new(config: SandboxConfig) -> Result<Self> {
32        let overlay = FsOverlay::new(&config.work_dir)?;
33
34        let fetch_client = config
35            .fetch_policy
36            .as_ref()
37            .map(|policy| Arc::new(SafeClient::new(policy.clone())));
38
39        let runtime = WasiRuntime::new(config.clone(), fetch_client.clone())?;
40
41        Ok(Self {
42            runtime,
43            overlay: Arc::new(Mutex::new(Some(overlay))),
44            config,
45            destroyed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
46            fetch_client,
47        })
48    }
49
50    /// Execute a command inside the sandbox.
51    pub async fn exec(&self, command: &str, args: &[String]) -> Result<ExecResult> {
52        self.check_destroyed()?;
53
54        // Intercept curl commands and route through fetch
55        if command == "curl" {
56            return self.exec_curl(args).await;
57        }
58
59        if !toolbox::is_available(command) {
60            return Err(SandboxError::CommandNotFound(command.to_string()));
61        }
62
63        self.runtime.exec(command, args).await
64    }
65
66    /// Execute JavaScript code inside the sandbox using the built-in JS engine.
67    pub async fn exec_js(&self, code: &str) -> Result<ExecResult> {
68        self.exec("node", &["-e".to_string(), code.to_string()])
69            .await
70    }
71
72    /// Perform an HTTP fetch using the sandbox's safe client.
73    pub async fn fetch(&self, request: FetchRequest) -> Result<FetchResponse> {
74        self.check_destroyed()?;
75
76        let client = self
77            .fetch_client
78            .as_ref()
79            .ok_or(SandboxError::NetworkingDisabled)?;
80
81        client
82            .fetch(request)
83            .await
84            .map_err(|e| SandboxError::Fetch(e.to_string()))
85    }
86
87    /// Read a file from the sandbox's work directory.
88    pub async fn read_file(&self, path: &str) -> Result<Vec<u8>> {
89        self.check_destroyed()?;
90
91        let full_path = fs::validate_path(&self.config.work_dir, path)?;
92        let content = tokio::fs::read(&full_path).await?;
93        Ok(content)
94    }
95
96    /// Write a file to the sandbox's work directory.
97    pub async fn write_file(&self, path: &str, contents: &[u8]) -> Result<()> {
98        self.check_destroyed()?;
99
100        let full_path = fs::validate_path(&self.config.work_dir, path)?;
101
102        // Ensure parent directory exists
103        if let Some(parent) = full_path.parent() {
104            tokio::fs::create_dir_all(parent).await?;
105        }
106
107        tokio::fs::write(&full_path, contents).await?;
108        Ok(())
109    }
110
111    /// List entries in a directory within the sandbox.
112    pub async fn list_dir(&self, path: &str) -> Result<Vec<DirEntry>> {
113        self.check_destroyed()?;
114
115        let full_path = fs::validate_path(&self.config.work_dir, path)?;
116        let mut entries = Vec::new();
117
118        let mut rd = tokio::fs::read_dir(&full_path).await?;
119        while let Some(entry) = rd.next_entry().await? {
120            let metadata = entry.metadata().await?;
121            entries.push(DirEntry {
122                name: entry.file_name().to_string_lossy().to_string(),
123                is_dir: metadata.is_dir(),
124                is_file: metadata.is_file(),
125                size: metadata.len(),
126            });
127        }
128
129        entries.sort_by(|a, b| a.name.cmp(&b.name));
130        Ok(entries)
131    }
132
133    /// Get filesystem changes since the sandbox was created.
134    pub async fn diff(&self) -> Result<Vec<FsChange>> {
135        self.check_destroyed()?;
136
137        let overlay = self.overlay.lock().await;
138        match overlay.as_ref() {
139            Some(o) => o.diff(),
140            None => Err(SandboxError::Destroyed),
141        }
142    }
143
144    /// Destroy the sandbox, cleaning up temporary resources.
145    pub async fn destroy(&self) -> Result<()> {
146        self.destroyed
147            .store(true, std::sync::atomic::Ordering::SeqCst);
148        let mut overlay = self.overlay.lock().await;
149        *overlay = None;
150        Ok(())
151    }
152
153    fn check_destroyed(&self) -> Result<()> {
154        if self.destroyed.load(std::sync::atomic::Ordering::SeqCst) {
155            Err(SandboxError::Destroyed)
156        } else {
157            Ok(())
158        }
159    }
160
161    /// Intercept `curl` commands and route through the fetch client.
162    async fn exec_curl(&self, args: &[String]) -> Result<ExecResult> {
163        let client = self
164            .fetch_client
165            .as_ref()
166            .ok_or(SandboxError::NetworkingDisabled)?;
167
168        let (request, output_file) = parse_curl_args(args)?;
169
170        match client.fetch(request).await {
171            Ok(resp) => {
172                let body = resp.body.clone();
173
174                // If -o was specified, write to file
175                if let Some(out_path) = output_file {
176                    let full_path = fs::validate_path(&self.config.work_dir, &out_path)?;
177                    if let Some(parent) = full_path.parent() {
178                        tokio::fs::create_dir_all(parent).await?;
179                    }
180                    tokio::fs::write(&full_path, &body).await?;
181                }
182
183                let status_line = format!("HTTP {}\n", resp.status);
184                Ok(ExecResult {
185                    exit_code: 0,
186                    stdout: body,
187                    stderr: status_line.into_bytes(),
188                })
189            }
190            Err(e) => {
191                let err_msg = format!("curl: {}\n", e);
192                Ok(ExecResult {
193                    exit_code: 1,
194                    stdout: Vec::new(),
195                    stderr: err_msg.into_bytes(),
196                })
197            }
198        }
199    }
200}
201
202/// Parse curl-like arguments into a FetchRequest.
203fn parse_curl_args(args: &[String]) -> Result<(FetchRequest, Option<String>)> {
204    let mut url = None;
205    let mut method = "GET".to_string();
206    let mut headers = HashMap::new();
207    let mut body: Option<Vec<u8>> = None;
208    let mut output_file = None;
209
210    let mut i = 0;
211    while i < args.len() {
212        match args[i].as_str() {
213            "-X" | "--request" => {
214                i += 1;
215                if i < args.len() {
216                    method = args[i].clone();
217                }
218            }
219            "-H" | "--header" => {
220                i += 1;
221                if i < args.len()
222                    && let Some((k, v)) = args[i].split_once(':')
223                {
224                    headers.insert(k.trim().to_string(), v.trim().to_string());
225                }
226            }
227            "-d" | "--data" => {
228                i += 1;
229                if i < args.len() {
230                    body = Some(args[i].as_bytes().to_vec());
231                    if method == "GET" {
232                        method = "POST".to_string();
233                    }
234                }
235            }
236            "-o" | "--output" => {
237                i += 1;
238                if i < args.len() {
239                    output_file = Some(args[i].clone());
240                }
241            }
242            "-s" | "--silent" | "-S" | "--show-error" | "-L" | "--location" | "-f" | "--fail"
243            | "-v" | "--verbose" | "-k" | "--insecure" | "-I" | "--head" | "-N" | "--no-buffer"
244            | "-g" | "--globoff" => {
245                // Silently ignore common boolean flags
246            }
247            "--max-time" | "--connect-timeout" | "--retry" | "--retry-delay" | "--max-redirs"
248            | "-u" | "--user" | "-A" | "--user-agent" | "-e" | "--referer" | "-w"
249            | "--write-out" | "--max-filesize" => {
250                // Unknown flags that take a value — skip the next argument
251                i += 1;
252            }
253            arg if !arg.starts_with('-') && url.is_none() => {
254                url = Some(arg.to_string());
255            }
256            arg if arg.starts_with('-') => {
257                // Unknown flag — ignore (may cause issues with value-taking flags)
258            }
259            _ => {
260                // Non-flag argument after URL — ignore
261            }
262        }
263        i += 1;
264    }
265
266    let url = url.ok_or_else(|| SandboxError::Other("curl: no URL specified".into()))?;
267
268    Ok((
269        FetchRequest {
270            url,
271            method,
272            headers,
273            body,
274        },
275        output_file,
276    ))
277}
278
279/// A directory entry returned by `list_dir`.
280#[derive(Debug, Clone)]
281pub struct DirEntry {
282    pub name: String,
283    pub is_dir: bool,
284    pub is_file: bool,
285    pub size: u64,
286}