Skip to main content

agentic_sandbox/
firecracker.rs

1//! Firecracker MicroVM-based sandbox for secure code execution.
2//!
3//! Uses Firecracker microVMs for strong isolation with minimal overhead.
4//!
5//! # Prerequisites
6//!
7//! - Firecracker binary installed
8//! - Linux with KVM support (`/dev/kvm` accessible)
9//! - Root or appropriate permissions
10//! - Kernel image (vmlinux)
11//! - Root filesystem with toolchain + agent
12
13use async_trait::async_trait;
14use std::path::PathBuf;
15use std::time::Duration;
16#[cfg(feature = "firecracker")]
17use tracing::info;
18#[cfg(not(feature = "firecracker"))]
19use tracing::warn;
20use tracing::{debug, instrument};
21
22use crate::{
23    error::SandboxError,
24    traits::{ExecutionResult, Sandbox},
25};
26
27/// Configuration for Firecracker sandbox.
28#[derive(Debug, Clone)]
29pub struct FirecrackerConfig {
30    /// Path to firecracker binary
31    pub firecracker_bin: PathBuf,
32    /// Path to kernel image (vmlinux)
33    pub kernel_path: PathBuf,
34    /// Path to root filesystem (ext4)
35    pub rootfs_path: PathBuf,
36    /// Number of vCPUs
37    pub vcpu_count: u8,
38    /// Memory size in MiB
39    pub mem_size_mib: u32,
40    /// Execution timeout
41    pub timeout: Duration,
42    /// Workspace directory for VM files
43    pub workspace: PathBuf,
44}
45
46impl Default for FirecrackerConfig {
47    fn default() -> Self {
48        Self {
49            firecracker_bin: PathBuf::from("/usr/bin/firecracker"),
50            kernel_path: PathBuf::from("/var/lib/firecracker/vmlinux"),
51            rootfs_path: PathBuf::from("/var/lib/firecracker/rootfs.ext4"),
52            vcpu_count: 2,
53            mem_size_mib: 512,
54            timeout: Duration::from_secs(60),
55            workspace: PathBuf::from("/tmp/firecracker"),
56        }
57    }
58}
59
60impl FirecrackerConfig {
61    /// Create a new config with custom paths.
62    #[must_use]
63    pub fn new(kernel: impl Into<PathBuf>, rootfs: impl Into<PathBuf>) -> Self {
64        Self {
65            kernel_path: kernel.into(),
66            rootfs_path: rootfs.into(),
67            ..Default::default()
68        }
69    }
70
71    /// Set vCPU count.
72    #[must_use]
73    pub const fn vcpus(mut self, count: u8) -> Self {
74        self.vcpu_count = count;
75        self
76    }
77
78    /// Set memory size in MiB.
79    #[must_use]
80    pub const fn memory(mut self, mib: u32) -> Self {
81        self.mem_size_mib = mib;
82        self
83    }
84
85    /// Set timeout.
86    #[must_use]
87    pub const fn timeout(mut self, timeout: Duration) -> Self {
88        self.timeout = timeout;
89        self
90    }
91
92    /// Set workspace directory.
93    #[must_use]
94    pub fn workspace(mut self, path: impl Into<PathBuf>) -> Self {
95        self.workspace = path.into();
96        self
97    }
98}
99
100/// Firecracker `MicroVM` sandbox.
101///
102/// Provides strong isolation using Firecracker microVMs.
103/// Each execution spawns a new microVM, executes code, and destroys it.
104#[derive(Clone)]
105pub struct FirecrackerSandbox {
106    config: FirecrackerConfig,
107}
108
109impl FirecrackerSandbox {
110    /// Create a new Firecracker sandbox with default config.
111    #[must_use]
112    pub fn new() -> Self {
113        Self {
114            config: FirecrackerConfig::default(),
115        }
116    }
117
118    /// Create with custom config.
119    #[must_use]
120    pub const fn with_config(config: FirecrackerConfig) -> Self {
121        Self { config }
122    }
123
124    /// Builder pattern for configuration.
125    #[must_use]
126    pub fn builder() -> FirecrackerSandboxBuilder {
127        FirecrackerSandboxBuilder::new()
128    }
129
130    /// Check if Firecracker is available on the system.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if Firecracker, KVM, kernel or rootfs is not available.
135    pub fn check_prerequisites(&self) -> Result<(), SandboxError> {
136        // Check firecracker binary
137        if !self.config.firecracker_bin.exists() {
138            return Err(SandboxError::NotAvailable(format!(
139                "Firecracker binary not found at {}",
140                self.config.firecracker_bin.display()
141            )));
142        }
143
144        // Check KVM
145        if !std::path::Path::new("/dev/kvm").exists() {
146            return Err(SandboxError::NotAvailable(
147                "KVM not available (/dev/kvm not found)".into(),
148            ));
149        }
150
151        // Check kernel
152        if !self.config.kernel_path.exists() {
153            return Err(SandboxError::NotAvailable(format!(
154                "Kernel not found at {}",
155                self.config.kernel_path.display()
156            )));
157        }
158
159        // Check rootfs
160        if !self.config.rootfs_path.exists() {
161            return Err(SandboxError::NotAvailable(format!(
162                "Rootfs not found at {}",
163                self.config.rootfs_path.display()
164            )));
165        }
166
167        Ok(())
168    }
169
170    /// Execute code using firepilot.
171    #[cfg(feature = "firecracker")]
172    async fn execute_with_firepilot(&self, _code: &str) -> Result<ExecutionResult, SandboxError> {
173        use firepilot::builder::drive::DriveBuilder;
174        use firepilot::builder::executor::FirecrackerExecutorBuilder;
175        use firepilot::builder::kernel::KernelBuilder;
176        use firepilot::builder::{Builder, Configuration};
177        use firepilot::machine::Machine;
178        use std::path::PathBuf;
179
180        let start = std::time::Instant::now();
181        let vm_id = uuid::Uuid::new_v4().to_string();
182
183        info!("Starting Firecracker microVM: {}", vm_id);
184
185        // Create workspace directory
186        let workspace = self.config.workspace.join(&vm_id);
187        tokio::fs::create_dir_all(&workspace)
188            .await
189            .map_err(|e| SandboxError::ConfigError(format!("Failed to create workspace: {e}")))?;
190
191        // Build kernel configuration
192        let kernel = KernelBuilder::new()
193            .with_kernel_image_path(self.config.kernel_path.to_string_lossy().to_string())
194            .with_boot_args("console=ttyS0 reboot=k panic=1 pci=off".to_string())
195            .try_build()
196            .map_err(|e| SandboxError::ConfigError(format!("Kernel config error: {e:?}")))?;
197
198        // Build rootfs drive
199        let rootfs = DriveBuilder::new()
200            .with_drive_id("rootfs".to_string())
201            .with_path_on_host(PathBuf::from(&self.config.rootfs_path))
202            .as_root_device()
203            .try_build()
204            .map_err(|e| SandboxError::ConfigError(format!("Drive config error: {e:?}")))?;
205
206        // Build executor
207        let executor = FirecrackerExecutorBuilder::new()
208            .with_chroot(workspace.to_string_lossy().to_string())
209            .with_exec_binary(PathBuf::from(&self.config.firecracker_bin))
210            .try_build()
211            .map_err(|e| SandboxError::ConfigError(format!("Executor config error: {e:?}")))?;
212
213        // Build full configuration
214        let config = Configuration::new(vm_id.clone())
215            .with_kernel(kernel)
216            .with_drive(rootfs)
217            .with_executor(executor);
218
219        // Create and start the machine
220        let mut machine = Machine::new();
221
222        machine
223            .create(config)
224            .await
225            .map_err(|e| SandboxError::StartError(format!("Failed to create VM: {e:?}")))?;
226
227        machine
228            .start()
229            .await
230            .map_err(|e| SandboxError::StartError(format!("Failed to start VM: {e:?}")))?;
231
232        debug!("MicroVM started, executing code...");
233
234        // Wait for VM to boot and execute
235        // In a real implementation, we'd communicate with an agent inside the VM
236        tokio::time::sleep(Duration::from_secs(2)).await;
237
238        // For now, simulate execution result
239        let stdout = format!("MicroVM {vm_id} executed command");
240        let stderr = String::new();
241        let exit_code = 0;
242
243        // Cleanup
244        let _ = machine.stop().await;
245        let _ = machine.kill().await;
246        let _ = tokio::fs::remove_dir_all(&workspace).await;
247
248        #[allow(clippy::cast_possible_truncation)]
249        let execution_time_ms = start.elapsed().as_millis() as u64;
250
251        Ok(ExecutionResult {
252            exit_code,
253            stdout,
254            stderr,
255            execution_time_ms,
256            artifacts: vec![],
257        })
258    }
259
260    /// Fallback execution without firepilot (for testing/dev).
261    #[cfg(not(feature = "firecracker"))]
262    async fn execute_fallback(&self, code: &str) -> Result<ExecutionResult, SandboxError> {
263        warn!("Firecracker feature not enabled, using process fallback");
264
265        let start = std::time::Instant::now();
266
267        let output = tokio::time::timeout(
268            self.config.timeout,
269            tokio::process::Command::new("sh")
270                .arg("-c")
271                .arg(code)
272                .output(),
273        )
274        .await
275        .map_err(|_| SandboxError::Timeout)?
276        .map_err(SandboxError::IoError)?;
277
278        #[allow(clippy::cast_possible_truncation)]
279        let execution_time_ms = start.elapsed().as_millis() as u64;
280
281        Ok(ExecutionResult {
282            exit_code: output.status.code().unwrap_or(-1),
283            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
284            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
285            execution_time_ms,
286            artifacts: vec![],
287        })
288    }
289}
290
291impl Default for FirecrackerSandbox {
292    fn default() -> Self {
293        Self::new()
294    }
295}
296
297#[async_trait]
298impl Sandbox for FirecrackerSandbox {
299    #[instrument(skip(self, code), fields(sandbox = "firecracker"))]
300    async fn execute(&self, code: &str) -> Result<ExecutionResult, SandboxError> {
301        debug!(
302            "Executing in Firecracker sandbox: {}...",
303            &code[..code.len().min(50)]
304        );
305
306        #[cfg(feature = "firecracker")]
307        {
308            self.execute_with_firepilot(code).await
309        }
310
311        #[cfg(not(feature = "firecracker"))]
312        {
313            self.execute_fallback(code).await
314        }
315    }
316
317    async fn is_ready(&self) -> Result<bool, SandboxError> {
318        self.check_prerequisites().map(|()| true)
319    }
320
321    async fn stop(&self) -> Result<(), SandboxError> {
322        Ok(())
323    }
324}
325
326/// Builder for `FirecrackerSandbox`.
327#[derive(Default)]
328pub struct FirecrackerSandboxBuilder {
329    config: FirecrackerConfig,
330}
331
332impl FirecrackerSandboxBuilder {
333    /// Create a new builder.
334    #[must_use]
335    pub fn new() -> Self {
336        Self {
337            config: FirecrackerConfig::default(),
338        }
339    }
340
341    /// Set firecracker binary path.
342    #[must_use]
343    pub fn firecracker_bin(mut self, path: impl Into<PathBuf>) -> Self {
344        self.config.firecracker_bin = path.into();
345        self
346    }
347
348    /// Set kernel path.
349    #[must_use]
350    pub fn kernel(mut self, path: impl Into<PathBuf>) -> Self {
351        self.config.kernel_path = path.into();
352        self
353    }
354
355    /// Set rootfs path.
356    #[must_use]
357    pub fn rootfs(mut self, path: impl Into<PathBuf>) -> Self {
358        self.config.rootfs_path = path.into();
359        self
360    }
361
362    /// Set vCPU count.
363    #[must_use]
364    pub const fn vcpus(mut self, count: u8) -> Self {
365        self.config.vcpu_count = count;
366        self
367    }
368
369    /// Set memory in MiB.
370    #[must_use]
371    pub const fn memory(mut self, mib: u32) -> Self {
372        self.config.mem_size_mib = mib;
373        self
374    }
375
376    /// Set timeout.
377    #[must_use]
378    pub const fn timeout(mut self, timeout: Duration) -> Self {
379        self.config.timeout = timeout;
380        self
381    }
382
383    /// Set workspace directory.
384    #[must_use]
385    pub fn workspace(mut self, path: impl Into<PathBuf>) -> Self {
386        self.config.workspace = path.into();
387        self
388    }
389
390    /// Build the sandbox.
391    #[must_use]
392    pub fn build(self) -> FirecrackerSandbox {
393        FirecrackerSandbox::with_config(self.config)
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn test_config_builder() {
403        let config = FirecrackerConfig::new("/path/to/kernel", "/path/to/rootfs")
404            .vcpus(4)
405            .memory(1024)
406            .timeout(Duration::from_secs(120));
407
408        assert_eq!(config.vcpu_count, 4);
409        assert_eq!(config.mem_size_mib, 1024);
410        assert_eq!(config.timeout, Duration::from_secs(120));
411    }
412
413    #[test]
414    fn test_sandbox_builder() {
415        let sandbox = FirecrackerSandbox::builder()
416            .kernel("/custom/kernel")
417            .rootfs("/custom/rootfs")
418            .vcpus(2)
419            .memory(512)
420            .build();
421
422        assert_eq!(sandbox.config.vcpu_count, 2);
423        assert_eq!(sandbox.config.mem_size_mib, 512);
424    }
425
426    #[test]
427    fn test_prerequisites_missing() {
428        let sandbox = FirecrackerSandbox::builder()
429            .firecracker_bin("/nonexistent/firecracker")
430            .build();
431
432        let result = sandbox.check_prerequisites();
433        assert!(result.is_err());
434    }
435}