mod local;
pub use local::{LocalSandbox, LocalSandboxConfig};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::Duration;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const MEMORY_MOUNT: &str = "/mnt/memory";
pub const OUTPUTS_MOUNT: &str = "/mnt/session/outputs";
#[derive(Debug, Error)]
pub enum SandboxError {
#[error("sandbox session `{0}` was not found")]
SessionNotFound(String),
#[error("backend `{backend}` does not support {operation}")]
Unsupported {
backend: &'static str,
operation: &'static str,
},
#[error("sandbox request was invalid: {0}")]
InvalidRequest(String),
#[error("sandbox lifecycle operation failed: {0}")]
Lifecycle(String),
#[error("sandbox exec failed: {0}")]
Exec(String),
#[error("sandbox network policy failed: {0}")]
NetworkPolicy(String),
#[error("sandbox I/O failed: {0}")]
Io(#[from] std::io::Error),
#[error("sandbox JSON failed: {0}")]
Json(#[from] serde_json::Error),
#[error("sandbox task failed: {0}")]
Task(#[from] tokio::task::JoinError),
}
pub type SandboxResult<T> = Result<T, SandboxError>;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(transparent)]
pub struct SandboxSessionId(pub String);
impl SandboxSessionId {
pub fn new(value: impl Into<String>) -> SandboxResult<Self> {
let value = value.into();
if value.trim().is_empty() {
return Err(SandboxError::InvalidRequest(
"session id cannot be empty".to_string(),
));
}
Ok(Self(value))
}
}
impl std::fmt::Display for SandboxSessionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum NetworkPolicy {
#[default]
Unrestricted,
Limited {
allowed_hosts: Vec<String>,
},
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FilesystemAccess {
ReadOnly,
ReadWrite,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct FilesystemMount {
pub source: PathBuf,
pub target: String,
pub access: FilesystemAccess,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ResourceLimits {
pub wall_time: Option<Duration>,
pub cpu_count: Option<u32>,
pub memory_mb: Option<u32>,
pub idle_timeout: Option<Duration>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SandboxSpec {
pub session_id: Option<SandboxSessionId>,
pub labels: BTreeMap<String, String>,
pub network_policy: NetworkPolicy,
pub mounts: Vec<FilesystemMount>,
pub limits: ResourceLimits,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SandboxState {
Provisioned,
Running,
Suspended,
Terminated,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct SandboxSession {
pub id: SandboxSessionId,
pub backend: String,
pub state: SandboxState,
pub mounts: Vec<ResolvedMount>,
pub metadata: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ResolvedMount {
pub target: String,
pub access: FilesystemAccess,
pub host_path: Option<PathBuf>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExecRequest {
pub command: String,
pub args: Vec<String>,
pub cwd: Option<String>,
pub env: BTreeMap<String, String>,
pub stdin: Option<String>,
pub timeout: Option<Duration>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExecResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub timed_out: bool,
}
impl ExecResult {
pub fn success(&self) -> bool {
self.exit_code == 0 && !self.timed_out
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct SandboxSnapshot {
pub session_id: SandboxSessionId,
pub backend: String,
pub snapshot_id: String,
pub metadata: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct SandboxCapabilities {
pub local_process_sandbox: bool,
pub network_policy: bool,
pub snapshot: bool,
pub resume: bool,
pub suspend_on_idle: bool,
}
#[async_trait]
pub trait SandboxBackend: Send + Sync {
fn name(&self) -> &'static str;
fn capabilities(&self) -> SandboxCapabilities;
async fn provision(&self, spec: SandboxSpec) -> SandboxResult<SandboxSession>;
async fn attach_filesystem(
&self,
session_id: &SandboxSessionId,
mount: FilesystemMount,
) -> SandboxResult<SandboxSession>;
async fn apply_network_policy(
&self,
session_id: &SandboxSessionId,
policy: NetworkPolicy,
) -> SandboxResult<SandboxSession>;
async fn exec(
&self,
session_id: &SandboxSessionId,
request: ExecRequest,
) -> SandboxResult<ExecResult>;
async fn snapshot(&self, session_id: &SandboxSessionId) -> SandboxResult<SandboxSnapshot>;
async fn resume(&self, session_id: &SandboxSessionId) -> SandboxResult<SandboxSession>;
async fn terminate(&self, session_id: &SandboxSessionId) -> SandboxResult<()>;
}
pub(crate) fn normalized_mount_target(target: &str) -> SandboxResult<String> {
let trimmed = target.trim().trim_end_matches('/');
if !trimmed.starts_with('/') {
return Err(SandboxError::InvalidRequest(format!(
"mount target `{target}` must be absolute"
)));
}
Ok(trimmed.to_string())
}
pub(crate) fn sh_quote(value: &str) -> String {
if value.is_empty() {
return "''".to_string();
}
let escaped = value.replace('\'', "'\"'\"'");
format!("'{escaped}'")
}
pub(crate) fn harn_string(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
other => out.push(other),
}
}
out.push('"');
out
}
pub(crate) fn duration_secs(duration: Duration) -> u64 {
duration.as_secs().max(1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn network_policy_uses_anthropic_compatible_shape() {
let json = serde_json::to_value(NetworkPolicy::Limited {
allowed_hosts: vec!["api.github.com".to_string()],
})
.unwrap();
assert_eq!(
json,
serde_json::json!({
"mode": "limited",
"allowed_hosts": ["api.github.com"]
})
);
}
#[test]
fn quotes_shell_values() {
assert_eq!(sh_quote("a'b"), "'a'\"'\"'b'");
assert_eq!(sh_quote(""), "''");
}
}