pub mod docker;
pub mod e2b;
pub mod firecracker;
pub mod gvisor;
#[cfg(feature = "native-sandbox")]
pub mod native;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub use docker::{DockerConfig, DockerRunner};
pub use e2b::E2BSandbox;
pub use firecracker::{FirecrackerConfig, FirecrackerRunner};
pub use gvisor::{GVisorConfig, GVisorRunner};
#[cfg(feature = "native-sandbox")]
pub use native::{NativeConfig, NativeRunner};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SandboxTier {
None,
Docker,
GVisor,
Firecracker,
E2B,
}
impl SandboxTier {
pub fn enforce_production_guard(&self) -> Result<(), String> {
if !matches!(self, SandboxTier::None) {
return Ok(());
}
let is_prod = crate::env::is_production().map_err(|e| e.to_string())?;
if is_prod {
let allow = std::env::var("SYMBIONT_ALLOW_UNISOLATED")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if !allow {
return Err(
"SECURITY: SandboxTier::None is not permitted in production; \
set SYMBIONT_ALLOW_UNISOLATED=1 to override (not recommended)"
.to_string(),
);
}
tracing::error!(
"SandboxTier::None enabled in production via SYMBIONT_ALLOW_UNISOLATED=1 — \
agents run without host isolation"
);
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionResult {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub execution_time_ms: u64,
pub success: bool,
#[serde(default)]
pub stdout_truncated: bool,
#[serde(default)]
pub stderr_truncated: bool,
}
impl ExecutionResult {
pub fn success(stdout: String, execution_time_ms: u64) -> Self {
Self {
exit_code: 0,
stdout,
stderr: String::new(),
execution_time_ms,
success: true,
stdout_truncated: false,
stderr_truncated: false,
}
}
pub fn failure(exit_code: i32, stderr: String, execution_time_ms: u64) -> Self {
Self {
exit_code,
stdout: String::new(),
stderr,
execution_time_ms,
success: false,
stdout_truncated: false,
stderr_truncated: false,
}
}
pub fn error(error_message: String) -> Self {
Self {
exit_code: -1,
stdout: String::new(),
stderr: error_message,
execution_time_ms: 0,
success: false,
stdout_truncated: false,
stderr_truncated: false,
}
}
}
pub fn build_runner(
tier: SandboxTier,
profile: &SandboxRunnerProfile,
) -> Result<Box<dyn SandboxRunner>, anyhow::Error> {
match tier {
SandboxTier::None => Err(anyhow::anyhow!(
"SandboxTier::None has no runner; agents must use docker, gvisor, firecracker, or e2b"
)),
SandboxTier::Docker => {
let cfg = profile.docker.clone().unwrap_or_default();
Ok(Box::new(DockerRunner::new(cfg)?))
}
SandboxTier::GVisor => {
let cfg = profile.gvisor.clone().unwrap_or_default();
Ok(Box::new(GVisorRunner::new(cfg)?))
}
SandboxTier::Firecracker => {
let cfg = profile.firecracker.clone().ok_or_else(|| {
anyhow::anyhow!(
"Firecracker tier selected but [sandbox.firecracker] is not configured \
in symbiont.toml. Set kernel_image_path and rootfs_path."
)
})?;
Ok(Box::new(FirecrackerRunner::new(cfg)?))
}
SandboxTier::E2B => {
let api_key = std::env::var("E2B_API_KEY")
.map_err(|_| anyhow::anyhow!("E2B tier selected but E2B_API_KEY is not set"))?;
Ok(Box::new(E2BSandbox::new_with_default_endpoint(api_key)))
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SandboxRunnerProfile {
pub docker: Option<DockerConfig>,
pub gvisor: Option<GVisorConfig>,
pub firecracker: Option<FirecrackerConfig>,
}
#[async_trait]
pub trait SandboxRunner: Send + Sync {
async fn execute(
&self,
code: &str,
env: HashMap<String, String>,
) -> Result<ExecutionResult, anyhow::Error>;
}
#[cfg(test)]
mod tier_guard_tests {
use super::*;
fn scoped_env<F: FnOnce()>(vars: &[(&str, Option<&str>)], f: F) {
let saved: Vec<_> = vars
.iter()
.map(|(k, _)| (k.to_string(), std::env::var(k).ok()))
.collect();
for (k, v) in vars {
match v {
Some(val) => std::env::set_var(k, val),
None => std::env::remove_var(k),
}
}
f();
for (k, prior) in saved {
match prior {
Some(v) => std::env::set_var(k, v),
None => std::env::remove_var(k),
}
}
}
#[test]
#[serial_test::serial(sandbox_tier_env)]
fn non_none_tier_passes_guard() {
assert!(SandboxTier::Docker.enforce_production_guard().is_ok());
assert!(SandboxTier::GVisor.enforce_production_guard().is_ok());
assert!(SandboxTier::Firecracker.enforce_production_guard().is_ok());
assert!(SandboxTier::E2B.enforce_production_guard().is_ok());
}
#[test]
fn build_runner_rejects_none_tier() {
let profile = SandboxRunnerProfile::default();
match build_runner(SandboxTier::None, &profile) {
Ok(_) => panic!("None tier must not yield a runner"),
Err(e) => assert!(e.to_string().contains("has no runner"), "got: {}", e),
}
}
#[test]
fn build_runner_rejects_firecracker_without_config() {
let profile = SandboxRunnerProfile::default();
match build_runner(SandboxTier::Firecracker, &profile) {
Ok(_) => panic!("missing firecracker config must error"),
Err(e) => assert!(
e.to_string().contains("[sandbox.firecracker]"),
"error should point at config: {}",
e
),
}
}
#[test]
#[serial_test::serial(sandbox_tier_env)]
fn none_tier_outside_production_passes() {
scoped_env(&[("SYMBIONT_ENV", Some("development"))], || {
assert!(SandboxTier::None.enforce_production_guard().is_ok());
});
}
#[test]
#[serial_test::serial(sandbox_tier_env)]
fn none_tier_in_production_fails_without_override() {
scoped_env(
&[
("SYMBIONT_ENV", Some("production")),
("SYMBIONT_ALLOW_UNISOLATED", None),
],
|| {
let res = SandboxTier::None.enforce_production_guard();
assert!(res.is_err(), "production None must refuse, got {:?}", res);
},
);
}
#[test]
#[serial_test::serial(sandbox_tier_env)]
fn none_tier_in_production_with_override_passes() {
scoped_env(
&[
("SYMBIONT_ENV", Some("production")),
("SYMBIONT_ALLOW_UNISOLATED", Some("1")),
],
|| {
assert!(SandboxTier::None.enforce_production_guard().is_ok());
},
);
}
}