Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 21 additions & 19 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions crates/openshell-sandbox/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"
Expand Down
83 changes: 55 additions & 28 deletions crates/openshell-sandbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ mod policy;
mod process;
pub mod procfs;
pub mod proxy;
pub mod runtime;
mod sandbox;
mod secrets;
mod ssh;
Expand Down Expand Up @@ -162,6 +163,7 @@ pub async fn run_sandbox(
_health_check: bool,
_health_port: u16,
inference_routes: Option<String>,
runtime_kind: runtime::RuntimeKind,
) -> Result<i32> {
let (program, args) = command
.split_first()
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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)) =
Expand Down
11 changes: 10 additions & 1 deletion crates/openshell-sandbox/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand All @@ -188,6 +196,7 @@ async fn main() -> Result<()> {
args.health_check,
args.health_port,
args.inference_routes,
runtime_kind,
)
.await?;

Expand Down
50 changes: 50 additions & 0 deletions crates/openshell-sandbox/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
Loading
Loading