1mod local;
29
30pub use local::{LocalSandbox, LocalSandboxConfig};
31
32use std::collections::BTreeMap;
33use std::path::PathBuf;
34use std::time::Duration;
35
36use async_trait::async_trait;
37use serde::{Deserialize, Serialize};
38use thiserror::Error;
39
40pub const MEMORY_MOUNT: &str = "/mnt/memory";
44
45pub const OUTPUTS_MOUNT: &str = "/mnt/session/outputs";
49
50#[derive(Debug, Error)]
52pub enum SandboxError {
53 #[error("sandbox session `{0}` was not found")]
55 SessionNotFound(String),
56 #[error("backend `{backend}` does not support {operation}")]
59 Unsupported {
60 backend: &'static str,
62 operation: &'static str,
64 },
65 #[error("sandbox request was invalid: {0}")]
67 InvalidRequest(String),
68 #[error("sandbox lifecycle operation failed: {0}")]
70 Lifecycle(String),
71 #[error("sandbox exec failed: {0}")]
73 Exec(String),
74 #[error("sandbox network policy failed: {0}")]
76 NetworkPolicy(String),
77 #[error("sandbox I/O failed: {0}")]
79 Io(#[from] std::io::Error),
80 #[error("sandbox JSON failed: {0}")]
82 Json(#[from] serde_json::Error),
83 #[error("sandbox task failed: {0}")]
85 Task(#[from] tokio::task::JoinError),
86}
87
88pub type SandboxResult<T> = Result<T, SandboxError>;
90
91#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
93#[serde(transparent)]
94pub struct SandboxSessionId(pub String);
95
96impl SandboxSessionId {
97 pub fn new(value: impl Into<String>) -> SandboxResult<Self> {
99 let value = value.into();
100 if value.trim().is_empty() {
101 return Err(SandboxError::InvalidRequest(
102 "session id cannot be empty".to_string(),
103 ));
104 }
105 Ok(Self(value))
106 }
107}
108
109impl std::fmt::Display for SandboxSessionId {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 f.write_str(&self.0)
112 }
113}
114
115#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
119#[serde(tag = "mode", rename_all = "snake_case")]
120pub enum NetworkPolicy {
121 #[default]
123 Unrestricted,
124 Limited {
127 allowed_hosts: Vec<String>,
129 },
130}
131
132#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
134#[serde(rename_all = "snake_case")]
135pub enum FilesystemAccess {
136 ReadOnly,
138 ReadWrite,
140}
141
142#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
144pub struct FilesystemMount {
145 pub source: PathBuf,
148 pub target: String,
150 pub access: FilesystemAccess,
152}
153
154#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
156pub struct ResourceLimits {
157 pub wall_time: Option<Duration>,
159 pub cpu_count: Option<u32>,
161 pub memory_mb: Option<u32>,
163 pub idle_timeout: Option<Duration>,
165}
166
167#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
170pub struct SandboxSpec {
171 pub session_id: Option<SandboxSessionId>,
173 pub labels: BTreeMap<String, String>,
175 pub network_policy: NetworkPolicy,
177 pub mounts: Vec<FilesystemMount>,
179 pub limits: ResourceLimits,
181}
182
183#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
185#[serde(rename_all = "snake_case")]
186pub enum SandboxState {
187 Provisioned,
189 Running,
191 Suspended,
193 Terminated,
195}
196
197#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
199pub struct SandboxSession {
200 pub id: SandboxSessionId,
202 pub backend: String,
204 pub state: SandboxState,
206 pub mounts: Vec<ResolvedMount>,
208 pub metadata: BTreeMap<String, String>,
210}
211
212#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
214pub struct ResolvedMount {
215 pub target: String,
217 pub access: FilesystemAccess,
219 pub host_path: Option<PathBuf>,
221}
222
223#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
225pub struct ExecRequest {
226 pub command: String,
228 pub args: Vec<String>,
230 pub cwd: Option<String>,
232 pub env: BTreeMap<String, String>,
234 pub stdin: Option<String>,
236 pub timeout: Option<Duration>,
238}
239
240#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
242pub struct ExecResult {
243 pub stdout: String,
245 pub stderr: String,
247 pub exit_code: i32,
249 pub timed_out: bool,
251}
252
253impl ExecResult {
254 pub fn success(&self) -> bool {
256 self.exit_code == 0 && !self.timed_out
257 }
258}
259
260#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
262pub struct SandboxSnapshot {
263 pub session_id: SandboxSessionId,
265 pub backend: String,
267 pub snapshot_id: String,
269 pub metadata: BTreeMap<String, String>,
271}
272
273#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
275pub struct SandboxCapabilities {
276 pub local_process_sandbox: bool,
278 pub network_policy: bool,
280 pub snapshot: bool,
282 pub resume: bool,
284 pub suspend_on_idle: bool,
286}
287
288#[async_trait]
292pub trait SandboxBackend: Send + Sync {
293 fn name(&self) -> &'static str;
295
296 fn capabilities(&self) -> SandboxCapabilities;
298
299 async fn provision(&self, spec: SandboxSpec) -> SandboxResult<SandboxSession>;
301
302 async fn attach_filesystem(
304 &self,
305 session_id: &SandboxSessionId,
306 mount: FilesystemMount,
307 ) -> SandboxResult<SandboxSession>;
308
309 async fn apply_network_policy(
311 &self,
312 session_id: &SandboxSessionId,
313 policy: NetworkPolicy,
314 ) -> SandboxResult<SandboxSession>;
315
316 async fn exec(
318 &self,
319 session_id: &SandboxSessionId,
320 request: ExecRequest,
321 ) -> SandboxResult<ExecResult>;
322
323 async fn snapshot(&self, session_id: &SandboxSessionId) -> SandboxResult<SandboxSnapshot>;
325
326 async fn resume(&self, session_id: &SandboxSessionId) -> SandboxResult<SandboxSession>;
328
329 async fn terminate(&self, session_id: &SandboxSessionId) -> SandboxResult<()>;
331}
332
333pub(crate) fn normalized_mount_target(target: &str) -> SandboxResult<String> {
336 let trimmed = target.trim().trim_end_matches('/');
337 if !trimmed.starts_with('/') {
338 return Err(SandboxError::InvalidRequest(format!(
339 "mount target `{target}` must be absolute"
340 )));
341 }
342 if trimmed.split('/').any(|segment| segment == "..") {
343 return Err(SandboxError::InvalidRequest(format!(
344 "mount target `{target}` must not contain a `..` component"
345 )));
346 }
347 Ok(trimmed.to_string())
348}
349
350pub(crate) fn sh_quote(value: &str) -> String {
352 if value.is_empty() {
353 return "''".to_string();
354 }
355 let escaped = value.replace('\'', "'\"'\"'");
356 format!("'{escaped}'")
357}
358
359pub(crate) fn harn_string(value: &str) -> String {
361 let mut out = String::with_capacity(value.len() + 2);
362 out.push('"');
363 for ch in value.chars() {
364 match ch {
365 '\\' => out.push_str("\\\\"),
366 '"' => out.push_str("\\\""),
367 '\n' => out.push_str("\\n"),
368 '\r' => out.push_str("\\r"),
369 '\t' => out.push_str("\\t"),
370 other => out.push(other),
371 }
372 }
373 out.push('"');
374 out
375}
376
377pub(crate) fn duration_secs(duration: Duration) -> u64 {
380 duration.as_secs().max(1)
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
388 fn network_policy_uses_anthropic_compatible_shape() {
389 let json = serde_json::to_value(NetworkPolicy::Limited {
390 allowed_hosts: vec!["api.github.com".to_string()],
391 })
392 .unwrap();
393
394 assert_eq!(
395 json,
396 serde_json::json!({
397 "mode": "limited",
398 "allowed_hosts": ["api.github.com"]
399 })
400 );
401 }
402
403 #[test]
404 fn quotes_shell_values() {
405 assert_eq!(sh_quote("a'b"), "'a'\"'\"'b'");
406 assert_eq!(sh_quote(""), "''");
407 }
408
409 #[test]
410 fn normalized_mount_target_rejects_parent_traversal() {
411 let err = normalized_mount_target("/mnt/memory/../../etc/passwd").unwrap_err();
412 assert!(err
413 .to_string()
414 .contains("must not contain a `..` component"));
415 }
416}