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
20pub 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 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 pub async fn exec(&self, command: &str, args: &[String]) -> Result<ExecResult> {
52 self.check_destroyed()?;
53
54 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 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 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 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 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 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 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 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 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 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 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
202fn 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 }
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 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 }
259 _ => {
260 }
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#[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}