Skip to main content

lago_core/
sandbox.rs

1use crate::error::LagoResult;
2use serde::{Deserialize, Serialize};
3use std::pin::Pin;
4
5/// Boxed future type alias for dyn-compatible async trait methods.
6type BoxFuture<'a, T> = Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
7
8/// Sandbox isolation tiers, ordered from least to most isolated.
9///
10/// Derives `PartialOrd`/`Ord` so comparisons like `tier >= SandboxTier::Process`
11/// work naturally for policy enforcement.
12#[derive(
13    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
14)]
15#[serde(rename_all = "snake_case")]
16pub enum SandboxTier {
17    /// No isolation — direct host access.
18    #[default]
19    None,
20    /// Basic restrictions (e.g. seccomp, pledge).
21    Basic,
22    /// Process-level isolation (e.g. bubblewrap, firejail).
23    Process,
24    /// Full container isolation (e.g. Apple Containers, Docker).
25    Container,
26}
27
28/// Configuration for a sandbox instance.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct SandboxConfig {
31    pub tier: SandboxTier,
32    #[serde(default)]
33    pub allowed_paths: Vec<String>,
34    #[serde(default)]
35    pub allowed_commands: Vec<String>,
36    #[serde(default = "default_true")]
37    pub network_access: bool,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub max_memory_mb: Option<u64>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub max_cpu_seconds: Option<u64>,
42}
43
44fn default_true() -> bool {
45    true
46}
47
48/// Request to execute a command inside a sandbox.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SandboxExecRequest {
51    pub command: String,
52    #[serde(default)]
53    pub args: Vec<String>,
54    #[serde(default)]
55    pub env: Vec<(String, String)>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub working_dir: Option<String>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub stdin: Option<String>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub timeout_ms: Option<u64>,
62}
63
64/// Result of a sandboxed command execution.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct SandboxExecResult {
67    pub exit_code: i32,
68    pub stdout: String,
69    pub stderr: String,
70    pub duration_ms: u64,
71}
72
73/// The primary trait for sandbox environments.
74///
75/// Uses boxed futures for dyn-compatibility (`Arc<dyn Sandbox>`),
76/// matching the `Journal` trait pattern.
77///
78/// lago-core defines this trait; runtime implementations (e.g. Arcan)
79/// provide platform-specific backends.
80pub trait Sandbox: Send + Sync {
81    /// The isolation tier of this sandbox.
82    fn tier(&self) -> SandboxTier;
83
84    /// The configuration this sandbox was created with.
85    fn config(&self) -> &SandboxConfig;
86
87    /// Execute a command inside the sandbox.
88    fn execute(&self, request: SandboxExecRequest) -> BoxFuture<'_, LagoResult<SandboxExecResult>>;
89
90    /// Read a file from inside the sandbox.
91    fn read_file(&self, path: &str) -> BoxFuture<'_, LagoResult<Vec<u8>>>;
92
93    /// Write a file inside the sandbox.
94    fn write_file(&self, path: &str, data: &[u8]) -> BoxFuture<'_, LagoResult<()>>;
95
96    /// Destroy the sandbox and clean up resources.
97    fn destroy(&self) -> BoxFuture<'_, LagoResult<()>>;
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn sandbox_tier_ordering() {
106        assert!(SandboxTier::None < SandboxTier::Basic);
107        assert!(SandboxTier::Basic < SandboxTier::Process);
108        assert!(SandboxTier::Process < SandboxTier::Container);
109    }
110
111    #[test]
112    fn sandbox_tier_serde_roundtrip() {
113        for tier in [
114            SandboxTier::None,
115            SandboxTier::Basic,
116            SandboxTier::Process,
117            SandboxTier::Container,
118        ] {
119            let json = serde_json::to_string(&tier).unwrap();
120            let back: SandboxTier = serde_json::from_str(&json).unwrap();
121            assert_eq!(back, tier);
122        }
123        assert_eq!(
124            serde_json::to_string(&SandboxTier::None).unwrap(),
125            "\"none\""
126        );
127        assert_eq!(
128            serde_json::to_string(&SandboxTier::Container).unwrap(),
129            "\"container\""
130        );
131    }
132
133    #[test]
134    fn sandbox_tier_default() {
135        assert_eq!(SandboxTier::default(), SandboxTier::None);
136    }
137
138    #[test]
139    fn sandbox_config_serde_roundtrip() {
140        let config = SandboxConfig {
141            tier: SandboxTier::Process,
142            allowed_paths: vec!["/tmp".to_string(), "/workspace".to_string()],
143            allowed_commands: vec!["cargo".to_string(), "rustc".to_string()],
144            network_access: false,
145            max_memory_mb: Some(512),
146            max_cpu_seconds: Some(60),
147        };
148        let json = serde_json::to_string(&config).unwrap();
149        let back: SandboxConfig = serde_json::from_str(&json).unwrap();
150        assert_eq!(back.tier, SandboxTier::Process);
151        assert_eq!(back.allowed_paths.len(), 2);
152        assert!(!back.network_access);
153        assert_eq!(back.max_memory_mb, Some(512));
154    }
155
156    #[test]
157    fn sandbox_config_defaults() {
158        let json = r#"{"tier": "basic"}"#;
159        let config: SandboxConfig = serde_json::from_str(json).unwrap();
160        assert_eq!(config.tier, SandboxTier::Basic);
161        assert!(config.allowed_paths.is_empty());
162        assert!(config.allowed_commands.is_empty());
163        assert!(config.network_access); // defaults to true
164        assert!(config.max_memory_mb.is_none());
165    }
166
167    #[test]
168    fn sandbox_exec_request_serde_roundtrip() {
169        let req = SandboxExecRequest {
170            command: "cargo".to_string(),
171            args: vec!["test".to_string()],
172            env: vec![("RUST_LOG".to_string(), "debug".to_string())],
173            working_dir: Some("/workspace".to_string()),
174            stdin: None,
175            timeout_ms: Some(30000),
176        };
177        let json = serde_json::to_string(&req).unwrap();
178        let back: SandboxExecRequest = serde_json::from_str(&json).unwrap();
179        assert_eq!(back.command, "cargo");
180        assert_eq!(back.args, vec!["test"]);
181        assert_eq!(back.timeout_ms, Some(30000));
182    }
183
184    #[test]
185    fn sandbox_exec_result_serde_roundtrip() {
186        let result = SandboxExecResult {
187            exit_code: 0,
188            stdout: "ok".to_string(),
189            stderr: String::new(),
190            duration_ms: 1234,
191        };
192        let json = serde_json::to_string(&result).unwrap();
193        let back: SandboxExecResult = serde_json::from_str(&json).unwrap();
194        assert_eq!(back.exit_code, 0);
195        assert_eq!(back.duration_ms, 1234);
196    }
197
198    #[test]
199    fn sandbox_tier_ge_comparison() {
200        // For policy enforcement: "sandbox tier must be at least Process"
201        let required = SandboxTier::Process;
202        assert!(SandboxTier::Process >= required);
203        assert!(SandboxTier::Container >= required);
204        assert!(SandboxTier::Basic < required);
205        assert!(SandboxTier::None < required);
206    }
207}