diff --git a/Cargo.lock b/Cargo.lock index 3d01356a..e727a598 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,9 +106,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -488,9 +488,9 @@ dependencies = [ [[package]] name = "bollard" -version = "0.20.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "227aa051deec8d16bd9c34605e7aaf153f240e35483dd42f6f78903847934738" +checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" dependencies = [ "base64 0.22.1", "bollard-stubs", @@ -584,9 +584,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -710,9 +710,9 @@ checksum = "5417da527aa9bf6a1e10a781231effd1edd3ee82f27d5f8529ac9b279babce96" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compact_str" @@ -1125,9 +1125,9 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "285743a676ccb6b3e116bc14cc69319b957867930ae9c4822f8e0f54509d7243" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ "block-buffer 0.12.0", "const-oid 0.10.2", @@ -2112,7 +2112,7 @@ checksum = "fe44f2bbd99fcb302e246e2d6bcf51aeda346d02a365f80296a07a8c711b6da6" dependencies = [ "argon2", "bcrypt-pbkdf", - "digest 0.11.1", + "digest 0.11.2", "ecdsa", "ed25519-dalek", "hex", @@ -2938,10 +2938,12 @@ dependencies = [ "rand_core 0.6.4", "rcgen", "regorus", + "reqwest", "russh", "rustls", "rustls-pemfile", "seccompiler", + "serde", "serde_json", "serde_yaml", "sha2 0.10.9", @@ -3887,7 +3889,7 @@ dependencies = [ "const-oid 0.10.2", "crypto-bigint 0.7.0-rc.18", "crypto-primes", - "digest 0.11.1", + "digest 0.11.2", "pkcs1 0.8.0-rc.4", "pkcs8 0.11.0-rc.11", "rand_core 0.10.0-rc-3", @@ -4361,7 +4363,7 @@ checksum = "3b167252f3c126be0d8926639c4c4706950f01445900c4b3db0fd7e89fcb750a" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.11.1", + "digest 0.11.2", ] [[package]] @@ -4383,7 +4385,7 @@ checksum = "7c5f3b1e2dc8aad28310d8410bd4d7e180eca65fca176c52ab00d364475d0024" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.11.1", + "digest 0.11.2", ] [[package]] @@ -4461,7 +4463,7 @@ version = "3.0.0-rc.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a96996ccff7dfa16f052bd995b4cecc72af22c35138738dc029f0ead6608d" dependencies = [ - "digest 0.11.1", + "digest 0.11.2", "rand_core 0.10.0-rc-3", ] @@ -5055,9 +5057,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -5381,9 +5383,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 8a0639a7..c6d76838 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -67,6 +67,10 @@ tracing-appender = { workspace = true } # Unix/Process nix = { workspace = true } +# VM-based sandbox (optional) — talks to BoxLite REST API via HTTP +reqwest = { workspace = true, optional = true } +serde = { workspace = true, optional = true } + [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -75,6 +79,10 @@ landlock = "0.4" seccompiler = "0.5" uuid = { version = "1", features = ["v4"] } +[features] +default = [] +boxlite = ["dep:reqwest", "dep:serde"] + [dev-dependencies] tempfile = "3" temp-env = "0.3" diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 754c3be0..6f3bc52e 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -18,6 +18,7 @@ mod policy; mod process; pub mod procfs; pub mod proxy; +pub mod runtime; mod sandbox; mod secrets; mod ssh; @@ -162,6 +163,7 @@ pub async fn run_sandbox( _health_check: bool, _health_port: u16, inference_routes: Option, + runtime_kind: runtime::RuntimeKind, ) -> Result { let (program, args) = command .split_first() @@ -252,11 +254,12 @@ pub async fn run_sandbox( (None, None) }; - // Create network namespace for proxy mode (Linux only) - // This must be created before the proxy AND SSH server so that SSH - // sessions can enter the namespace for network isolation. + // Create network namespace for proxy mode (Linux only). + // Skip when the runtime backend provides its own network isolation (e.g. BoxLite VM). #[cfg(target_os = "linux")] - let netns = if matches!(policy.network.mode, NetworkMode::Proxy) { + let netns = if matches!(policy.network.mode, NetworkMode::Proxy) + && !runtime_kind.provides_network_isolation() + { match NetworkNamespace::create() { Ok(ns) => { // Install bypass detection rules (iptables LOG + REJECT). @@ -541,32 +544,56 @@ pub async fn run_sandbox( } } - #[cfg(target_os = "linux")] - let mut handle = ProcessHandle::spawn( - program, - args, - workdir.as_deref(), - interactive, - &policy, - netns.as_ref(), - ca_file_paths.as_ref(), - &provider_env, - )?; - - #[cfg(not(target_os = "linux"))] - let mut handle = ProcessHandle::spawn( - program, - args, - workdir.as_deref(), - interactive, - &policy, - ca_file_paths.as_ref(), - &provider_env, - )?; + let mut handle = match runtime_kind { + runtime::RuntimeKind::Process => { + #[cfg(target_os = "linux")] + { + runtime::ProcessBackend::spawn( + program, + args, + workdir.as_deref(), + interactive, + &policy, + netns.as_ref(), + ca_file_paths.as_ref(), + &provider_env, + )? + } + #[cfg(not(target_os = "linux"))] + { + runtime::ProcessBackend::spawn( + program, + args, + workdir.as_deref(), + interactive, + &policy, + ca_file_paths.as_ref(), + &provider_env, + )? + } + } + #[cfg(feature = "boxlite")] + runtime::RuntimeKind::Boxlite => { + let backend = runtime::BoxliteBackend::new()?; + let spawn_config = runtime::SpawnConfig { + program: program.to_string(), + args: args.to_vec(), + workdir: workdir.clone(), + interactive, + env: provider_env.clone(), + image: None, // Uses default (alpine:latest) or could be derived from sandbox spec + }; + backend.spawn(&spawn_config).await? + } + }; // Store the entrypoint PID so the proxy can resolve TCP peer identity - entrypoint_pid.store(handle.pid(), Ordering::Release); - info!(pid = handle.pid(), "Process started"); + entrypoint_pid.store(handle.id(), Ordering::Release); + info!( + pid = handle.id(), + runtime = runtime_kind.name(), + "Process started" + ); // Spawn background policy poll task (gRPC mode only). if let (Some(id), Some(endpoint), Some(engine)) = diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 7e02459e..25993dc0 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -89,6 +89,13 @@ struct Args { /// Port for health check endpoint. #[arg(long, default_value = "8080")] health_port: u16, + + /// Runtime backend for process isolation. + /// + /// - "process" (default): Linux kernel isolation (Landlock, seccomp, netns) + /// - "boxlite": Hardware-level VM isolation via BoxLite (requires `boxlite` feature) + #[arg(long, default_value = "process", env = "OPENSHELL_RUNTIME")] + runtime: String, } #[tokio::main] @@ -170,7 +177,8 @@ async fn main() -> Result<()> { vec!["/bin/bash".to_string()] }; - info!(command = ?command, "Starting sandbox"); + let runtime_kind = openshell_sandbox::runtime::RuntimeKind::parse(&args.runtime)?; + info!(command = ?command, runtime = runtime_kind.name(), "Starting sandbox"); let exit_code = run_sandbox( command, @@ -188,6 +196,7 @@ async fn main() -> Result<()> { args.health_check, args.health_port, args.inference_routes, + runtime_kind, ) .await?; diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index b93d125a..e8703d09 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -466,6 +466,24 @@ pub struct ProcessStatus { } impl ProcessStatus { + /// Create a `ProcessStatus` from a raw exit code. + /// + /// Codes > 128 are interpreted as signal-killed (128 + signal number). + #[must_use] + pub const fn from_code(code: i32) -> Self { + if code > 128 { + Self { + code: None, + signal: Some(code - 128), + } + } else { + Self { + code: Some(code), + signal: None, + } + } + } + /// Get the exit code, or 128 + signal number if killed by signal. #[must_use] pub fn code(&self) -> i32 { @@ -644,4 +662,36 @@ mod tests { let stdout = String::from_utf8(output.stdout).expect("utf8"); assert!(stdout.contains("ANTHROPIC_API_KEY=openshell:resolve:env:ANTHROPIC_API_KEY")); } + + #[test] + fn from_code_normal_exit() { + let status = ProcessStatus::from_code(0); + assert_eq!(status.code(), 0); + assert!(status.success()); + assert_eq!(status.signal(), None); + } + + #[test] + fn from_code_error_exit() { + let status = ProcessStatus::from_code(1); + assert_eq!(status.code(), 1); + assert!(!status.success()); + assert_eq!(status.signal(), None); + } + + #[test] + fn from_code_signal_killed() { + // 137 = 128 + 9 (SIGKILL) + let status = ProcessStatus::from_code(137); + assert_eq!(status.code(), 137); + assert_eq!(status.signal(), Some(9)); + } + + #[test] + fn from_code_boundary_128() { + // 128 itself is a normal exit code (not signal-killed) + let status = ProcessStatus::from_code(128); + assert_eq!(status.code(), 128); + assert_eq!(status.signal(), None); + } } diff --git a/crates/openshell-sandbox/src/runtime/boxlite_backend.rs b/crates/openshell-sandbox/src/runtime/boxlite_backend.rs new file mode 100644 index 00000000..a5e6d7fa --- /dev/null +++ b/crates/openshell-sandbox/src/runtime/boxlite_backend.rs @@ -0,0 +1,460 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! BoxLite VM-based sandbox backend via REST API. +//! +//! Communicates with a running BoxLite server (`boxlite serve` or +//! `boxlite-server coordinator`) over HTTP to create and manage VMs. +//! +//! This backend provides: +//! - Hardware-level memory isolation (VM boundary) +//! - Independent kernel (guest cannot attack host kernel) +//! - Network isolation via VM boundary +//! - Cross-platform support (Linux + macOS ARM64) +//! +//! No `boxlite` library is linked — all interaction is via REST API, +//! avoiding native dependency conflicts (e.g., sqlite version mismatches). + +use miette::{IntoDiagnostic, Result}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use tracing::{debug, info, warn}; + +use super::{SandboxedProcess, SpawnConfig}; +use crate::process::ProcessStatus; + +/// Default BoxLite server URL. +const DEFAULT_BOXLITE_URL: &str = "http://127.0.0.1:8100"; + +/// Default API namespace. +const DEFAULT_NAMESPACE: &str = "default"; + +/// Environment variable for BoxLite server URL. +const BOXLITE_URL_ENV: &str = "BOXLITE_URL"; + +/// Default VM resources for sandbox workloads. +const DEFAULT_CPUS: u8 = 2; +const DEFAULT_MEMORY_MIB: u32 = 512; + +// ============================================================================ +// REST API request/response types (subset of BoxLite's OpenAPI schema) +// ============================================================================ + +#[derive(Debug, Serialize)] +struct CreateBoxRequest { + #[serde(skip_serializing_if = "Option::is_none")] + image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cpus: Option, + #[serde(skip_serializing_if = "Option::is_none")] + memory_mib: Option, + #[serde(skip_serializing_if = "Option::is_none")] + working_dir: Option, + #[serde(skip_serializing_if = "Option::is_none")] + env: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + auto_remove: Option, +} + +#[derive(Debug, Deserialize)] +struct BoxResponse { + box_id: String, + #[allow(dead_code)] + status: String, +} + +#[derive(Debug, Serialize)] +struct ExecRequest { + command: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + args: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + env: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + working_dir: Option, + #[serde(default)] + tty: bool, +} + +#[derive(Debug, Deserialize)] +struct ExecResponse { + execution_id: String, +} + +#[derive(Debug, Serialize)] +struct SignalRequest { + signal: i32, +} + +#[derive(Debug, Deserialize)] +struct ErrorResponse { + error: ErrorModel, +} + +#[derive(Debug, Deserialize)] +struct ErrorModel { + message: String, +} + +#[derive(Debug, Deserialize)] +struct SseExitData { + exit_code: i32, +} + +// ============================================================================ +// Backend implementation +// ============================================================================ + +/// BoxLite VM-based sandbox backend. +/// +/// Talks to a BoxLite REST server to create VMs and run commands. +pub struct BoxliteBackend { + client: Client, + base_url: String, + namespace: String, +} + +impl BoxliteBackend { + /// Create a new BoxLite REST backend. + /// + /// Reads the server URL from `BOXLITE_URL` env var, or defaults + /// to `http://127.0.0.1:8100`. + /// + /// # Errors + /// + /// Returns an error if the HTTP client fails to initialize. + pub fn new() -> Result { + let base_url = + std::env::var(BOXLITE_URL_ENV).unwrap_or_else(|_| DEFAULT_BOXLITE_URL.to_string()); + + let client = Client::builder() + .timeout(std::time::Duration::from_secs(300)) + .build() + .into_diagnostic()?; + + info!(url = %base_url, "BoxLite REST backend initialized"); + + Ok(Self { + client, + base_url, + namespace: DEFAULT_NAMESPACE.to_string(), + }) + } + + fn boxes_url(&self) -> String { + format!("{}/v1/{}/boxes", self.base_url, self.namespace) + } + + /// Spawn an agent process inside a BoxLite VM via REST API. + /// + /// 1. Creates a VM from the specified container image + /// 2. Executes the agent command inside the VM + /// 3. Returns a handle to wait for completion + /// + /// # Errors + /// + /// Returns an error if the BoxLite server is unreachable or rejects the request. + pub async fn spawn(&self, config: &SpawnConfig) -> Result { + let image = config.image.as_deref().unwrap_or("alpine:latest"); + + let env = if config.env.is_empty() { + None + } else { + Some(config.env.clone()) + }; + + // Step 1: Create box + let create_req = CreateBoxRequest { + image: Some(image.to_string()), + cpus: Some(DEFAULT_CPUS), + memory_mib: Some(DEFAULT_MEMORY_MIB), + working_dir: config.workdir.clone(), + env: env.clone(), + auto_remove: Some(true), + }; + + let resp = self + .client + .post(self.boxes_url()) + .json(&create_req) + .send() + .await + .into_diagnostic()?; + + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + let msg = serde_json::from_str::(&body) + .map(|e| e.error.message) + .unwrap_or(body); + return Err(miette::miette!( + "BoxLite create failed ({}): {}", + status, + msg + )); + } + + let box_resp: BoxResponse = resp.json().await.into_diagnostic()?; + let box_id = box_resp.box_id; + info!(box_id = %box_id, image = %image, "BoxLite VM created"); + + // Step 2: Execute command + let exec_req = ExecRequest { + command: config.program.clone(), + args: config.args.clone(), + env, + working_dir: config.workdir.clone(), + tty: config.interactive, + }; + + let exec_url = format!("{}/{}/exec", self.boxes_url(), box_id); + let resp = self + .client + .post(&exec_url) + .json(&exec_req) + .send() + .await + .into_diagnostic()?; + + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + let msg = serde_json::from_str::(&body) + .map(|e| e.error.message) + .unwrap_or(body); + return Err(miette::miette!("BoxLite exec failed ({}): {}", status, msg)); + } + + let exec_resp: ExecResponse = resp.json().await.into_diagnostic()?; + debug!( + box_id = %box_id, + execution_id = %exec_resp.execution_id, + "Command started in BoxLite VM" + ); + + Ok(SandboxedProcess::Boxlite(BoxliteProcess { + client: self.client.clone(), + boxes_url: self.boxes_url(), + box_id, + execution_id: exec_resp.execution_id, + })) + } +} + +/// Handle to a process running inside a BoxLite VM. +pub struct BoxliteProcess { + client: Client, + boxes_url: String, + box_id: String, + execution_id: String, +} + +impl BoxliteProcess { + /// Get the box identifier (hashed to u32 for PID-based API compat). + #[must_use] + pub fn id(&self) -> u32 { + let bytes = self.box_id.as_bytes(); + let mut hash: u32 = 0; + for &b in bytes { + hash = hash.wrapping_mul(31).wrapping_add(u32::from(b)); + } + // PIDs are always > 0 + if hash == 0 { 1 } else { hash } + } + + /// Wait for the VM process to exit by streaming SSE output. + /// + /// Connects to the BoxLite execution output SSE endpoint and waits + /// for the `exit` event containing the exit code. + /// + /// # Errors + /// + /// Returns an error if the SSE connection fails. + pub async fn wait(&mut self) -> std::io::Result { + let output_url = format!( + "{}/{}/executions/{}/output", + self.boxes_url, self.box_id, self.execution_id + ); + + let resp = self + .client + .get(&output_url) + .header("Accept", "text/event-stream") + .send() + .await + .map_err(|e| std::io::Error::other(e.to_string()))?; + + if !resp.status().is_success() { + return Err(std::io::Error::other(format!( + "BoxLite output stream failed: {}", + resp.status() + ))); + } + + // Parse SSE stream for the exit event + let body = resp + .text() + .await + .map_err(|e| std::io::Error::other(e.to_string()))?; + let exit_code = parse_sse_exit_code(&body).unwrap_or(-1); + + info!(box_id = %self.box_id, exit_code, "BoxLite VM process exited"); + + // Stop the VM + let stop_url = format!("{}/{}/stop", self.boxes_url, self.box_id); + if let Err(e) = self.client.post(&stop_url).send().await { + warn!(box_id = %self.box_id, error = %e, "Failed to stop BoxLite VM"); + } + + Ok(ProcessStatus::from_code(exit_code)) + } + + /// Send a signal to the process inside the VM. + /// + /// # Errors + /// + /// Returns an error if the signal cannot be sent. + pub fn signal(&self, sig: nix::sys::signal::Signal) -> Result<()> { + let sig_num = sig as i32; + let signal_url = format!( + "{}/{}/executions/{}/signal", + self.boxes_url, self.box_id, self.execution_id + ); + let client = self.client.clone(); + let box_id = self.box_id.clone(); + + let handle = tokio::runtime::Handle::current(); + handle.block_on(async { + let req = SignalRequest { signal: sig_num }; + if let Err(e) = client.post(&signal_url).json(&req).send().await { + warn!(box_id = %box_id, error = %e, "Failed to signal BoxLite VM"); + } + }); + Ok(()) + } + + /// Kill the VM and its processes. + /// + /// # Errors + /// + /// Returns an error if the VM cannot be stopped. + pub fn kill(&mut self) -> Result<()> { + let stop_url = format!("{}/{}/stop", self.boxes_url, self.box_id); + let client = self.client.clone(); + let box_id = self.box_id.clone(); + + let handle = tokio::runtime::Handle::current(); + handle.block_on(async { + if let Err(e) = client.post(&stop_url).send().await { + warn!(box_id = %box_id, error = %e, "Failed to stop BoxLite VM on kill"); + } + }); + Ok(()) + } +} + +/// Parse the exit code from an SSE response body. +/// +/// Looks for lines like: +/// ```text +/// event: exit +/// data: {"exit_code": 0} +/// ``` +fn parse_sse_exit_code(body: &str) -> Option { + let mut in_exit_event = false; + for line in body.lines() { + if line.starts_with("event:") { + let event_type = line.trim_start_matches("event:").trim(); + in_exit_event = event_type == "exit"; + } else if in_exit_event && line.starts_with("data:") { + let data = line.trim_start_matches("data:").trim(); + if let Ok(exit_data) = serde_json::from_str::(data) { + return Some(exit_data.exit_code); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_sse_exit_code_found() { + let body = "event: stdout\ndata: {\"data\":\"aGVsbG8=\"}\n\nevent: exit\ndata: {\"exit_code\": 0}\n\n"; + assert_eq!(parse_sse_exit_code(body), Some(0)); + } + + #[test] + fn parse_sse_exit_code_nonzero() { + let body = "event: exit\ndata: {\"exit_code\": 137}\n\n"; + assert_eq!(parse_sse_exit_code(body), Some(137)); + } + + #[test] + fn parse_sse_exit_code_missing() { + let body = "event: stdout\ndata: {\"data\":\"aGVsbG8=\"}\n\n"; + assert_eq!(parse_sse_exit_code(body), None); + } + + #[test] + fn parse_sse_exit_code_malformed() { + let body = "event: exit\ndata: not-json\n\n"; + assert_eq!(parse_sse_exit_code(body), None); + } + + #[test] + fn boxlite_process_id_is_nonzero() { + let process = BoxliteProcess { + client: Client::new(), + boxes_url: String::new(), + box_id: "abc123".to_string(), + execution_id: String::new(), + }; + assert_ne!(process.id(), 0); + } + + #[test] + fn boxlite_process_id_deterministic() { + let make = |id: &str| BoxliteProcess { + client: Client::new(), + boxes_url: String::new(), + box_id: id.to_string(), + execution_id: String::new(), + }; + assert_eq!(make("abc123").id(), make("abc123").id()); + assert_ne!(make("abc123").id(), make("xyz789").id()); + } + + #[test] + fn create_box_request_serialization() { + let req = CreateBoxRequest { + image: Some("python:3.11".into()), + cpus: Some(2), + memory_mib: Some(512), + working_dir: None, + env: None, + auto_remove: Some(true), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"image\":\"python:3.11\"")); + assert!(json.contains("\"cpus\":2")); + assert!(!json.contains("working_dir")); + } + + #[test] + fn exec_request_serialization() { + let req = ExecRequest { + command: "echo".into(), + args: vec!["hello".into()], + env: None, + working_dir: Some("/app".into()), + tty: false, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"command\":\"echo\"")); + assert!(json.contains("\"working_dir\":\"/app\"")); + } +} diff --git a/crates/openshell-sandbox/src/runtime/mod.rs b/crates/openshell-sandbox/src/runtime/mod.rs new file mode 100644 index 00000000..5b60093e --- /dev/null +++ b/crates/openshell-sandbox/src/runtime/mod.rs @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Runtime backend abstraction for sandboxed process execution. +//! +//! OpenShell supports multiple isolation backends: +//! +//! - **Process** (default): Uses Linux kernel primitives (Landlock, seccomp, +//! network namespaces) for isolation. Lightweight but Linux-only. +//! +//! - **BoxLite** (feature `boxlite`): Runs the agent inside a hardware-isolated +//! lightweight VM via libkrun (KVM on Linux, Hypervisor.framework on macOS). +//! Provides stronger isolation and cross-platform support. + +mod process_backend; + +#[cfg(feature = "boxlite")] +mod boxlite_backend; + +pub use process_backend::ProcessBackend; + +#[cfg(feature = "boxlite")] +pub use boxlite_backend::{BoxliteBackend, BoxliteProcess}; + +use crate::process::ProcessStatus; +use miette::Result; +use std::collections::HashMap; + +/// Configuration for spawning a sandboxed process. +#[derive(Debug, Clone)] +pub struct SpawnConfig { + pub program: String, + pub args: Vec, + pub workdir: Option, + pub interactive: bool, + pub env: HashMap, + /// Container image for VM-based backends. Ignored by the process backend. + pub image: Option, +} + +/// A running sandboxed process, abstracting over different isolation backends. +pub enum SandboxedProcess { + /// OS process with kernel-level isolation (Landlock, seccomp, netns). + Process(crate::ProcessHandle), + /// BoxLite VM with hardware-level isolation. + #[cfg(feature = "boxlite")] + Boxlite(BoxliteProcess), +} + +impl SandboxedProcess { + /// Get the process or VM identifier. + #[must_use] + pub fn id(&self) -> u32 { + match self { + Self::Process(h) => h.pid(), + #[cfg(feature = "boxlite")] + Self::Boxlite(b) => b.id(), + } + } + + /// Wait for the sandboxed process to exit. + /// + /// # Errors + /// + /// Returns an error if waiting fails. + pub async fn wait(&mut self) -> std::io::Result { + match self { + Self::Process(h) => h.wait().await, + #[cfg(feature = "boxlite")] + Self::Boxlite(b) => b.wait().await, + } + } + + /// Send a signal to the sandboxed process. + /// + /// # Errors + /// + /// Returns an error if the signal cannot be sent. + pub fn signal(&self, sig: nix::sys::signal::Signal) -> Result<()> { + match self { + Self::Process(h) => h.signal(sig), + #[cfg(feature = "boxlite")] + Self::Boxlite(b) => b.signal(sig), + } + } + + /// Kill the sandboxed process. + /// + /// # Errors + /// + /// Returns an error if the process cannot be killed. + pub fn kill(&mut self) -> Result<()> { + match self { + Self::Process(h) => h.kill(), + #[cfg(feature = "boxlite")] + Self::Boxlite(b) => b.kill(), + } + } +} + +/// Supported runtime backends. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RuntimeKind { + /// OS process with kernel-level isolation (Landlock, seccomp, netns). + #[default] + Process, + /// BoxLite VM with hardware-level isolation (KVM / Hypervisor.framework). + #[cfg(feature = "boxlite")] + Boxlite, +} + +impl RuntimeKind { + /// Parse a runtime kind from a string. + /// + /// # Errors + /// + /// Returns an error if the string is not a recognized runtime. + pub fn parse(s: &str) -> Result { + match s { + "process" | "linux" => Ok(Self::Process), + #[cfg(feature = "boxlite")] + "boxlite" | "vm" => Ok(Self::Boxlite), + #[cfg(not(feature = "boxlite"))] + "boxlite" | "vm" => Err(miette::miette!( + "BoxLite runtime requested but the 'boxlite' feature is not enabled. \ + Rebuild with `--features boxlite`." + )), + other => Err(miette::miette!("Unknown runtime backend: {other}")), + } + } + + /// Whether this backend provides its own network isolation, + /// making kernel-level network namespaces unnecessary. + #[must_use] + pub const fn provides_network_isolation(self) -> bool { + match self { + Self::Process => false, + #[cfg(feature = "boxlite")] + Self::Boxlite => true, + } + } + + /// Human-readable name. + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::Process => "process", + #[cfg(feature = "boxlite")] + Self::Boxlite => "boxlite", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_process_variants() { + assert_eq!(RuntimeKind::parse("process").unwrap(), RuntimeKind::Process); + assert_eq!(RuntimeKind::parse("linux").unwrap(), RuntimeKind::Process); + } + + #[test] + fn parse_unknown_returns_error() { + assert!(RuntimeKind::parse("unknown").is_err()); + } + + #[cfg(feature = "boxlite")] + #[test] + fn parse_boxlite_variants() { + assert_eq!(RuntimeKind::parse("boxlite").unwrap(), RuntimeKind::Boxlite); + assert_eq!(RuntimeKind::parse("vm").unwrap(), RuntimeKind::Boxlite); + } + + #[cfg(not(feature = "boxlite"))] + #[test] + fn parse_boxlite_without_feature_returns_error() { + let err = RuntimeKind::parse("boxlite").unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("feature"), "expected feature error: {msg}"); + } + + #[test] + fn default_is_process() { + assert_eq!(RuntimeKind::default(), RuntimeKind::Process); + } + + #[test] + fn process_does_not_provide_network_isolation() { + assert!(!RuntimeKind::Process.provides_network_isolation()); + } +} diff --git a/crates/openshell-sandbox/src/runtime/process_backend.rs b/crates/openshell-sandbox/src/runtime/process_backend.rs new file mode 100644 index 00000000..caa6fd0f --- /dev/null +++ b/crates/openshell-sandbox/src/runtime/process_backend.rs @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Process-based sandbox backend using Linux kernel isolation. +//! +//! This is the default backend. It spawns the agent as a direct OS process +//! and applies Landlock, seccomp, and network namespace isolation. + +use crate::ProcessHandle; +use crate::policy::SandboxPolicy; +#[cfg(target_os = "linux")] +use crate::sandbox::linux::netns::NetworkNamespace; +use miette::Result; +use std::collections::HashMap; +use std::path::PathBuf; + +use super::SandboxedProcess; + +/// Process-based sandbox backend. +/// +/// Delegates to [`ProcessHandle::spawn`] with kernel-level isolation. +pub struct ProcessBackend; + +impl ProcessBackend { + /// Spawn a sandboxed process using OS-level isolation. + /// + /// # Errors + /// + /// Returns an error if the process fails to start. + #[cfg(target_os = "linux")] + #[allow(clippy::too_many_arguments)] + pub fn spawn( + program: &str, + args: &[String], + workdir: Option<&str>, + interactive: bool, + policy: &SandboxPolicy, + netns: Option<&NetworkNamespace>, + ca_paths: Option<&(PathBuf, PathBuf)>, + provider_env: &HashMap, + ) -> Result { + let handle = ProcessHandle::spawn( + program, + args, + workdir, + interactive, + policy, + netns, + ca_paths, + provider_env, + )?; + Ok(SandboxedProcess::Process(handle)) + } + + /// Spawn a sandboxed process (non-Linux fallback). + /// + /// # Errors + /// + /// Returns an error if the process fails to start. + #[cfg(not(target_os = "linux"))] + pub fn spawn( + program: &str, + args: &[String], + workdir: Option<&str>, + interactive: bool, + policy: &SandboxPolicy, + ca_paths: Option<&(PathBuf, PathBuf)>, + provider_env: &HashMap, + ) -> Result { + let handle = ProcessHandle::spawn( + program, + args, + workdir, + interactive, + policy, + ca_paths, + provider_env, + )?; + Ok(SandboxedProcess::Process(handle)) + } +}