[pitboss] phase 20: Track E.4 + E.5 — Firecracker skeleton + non-vacuous sandbox-escape suite

This commit is contained in:
pitboss 2026-05-15 12:04:55 -05:00
parent 1d9b4c688f
commit f8bff38217
18 changed files with 962 additions and 2 deletions

View file

@ -456,7 +456,7 @@ fn uses_docker_backend(opts: &SandboxOptions) -> bool {
match opts.backend {
SandboxBackend::Docker => true,
SandboxBackend::Auto => sandbox::docker_available(),
SandboxBackend::Process => false,
SandboxBackend::Process | SandboxBackend::Firecracker => false,
}
}

View file

@ -0,0 +1,128 @@
//! Phase 20 (Track E.4) — Firecracker microVM backend skeleton.
//!
//! This module is compiled in only when the `firecracker` Cargo feature is
//! enabled. Today it carries no live VM logic — the goal of Phase 20 is to
//! freeze the public surface that the verifier and the rest of the sandbox
//! dispatcher in [`super`] talk to, so that Phase 21 can fill in the boot
//! path (jailer arg shaping, vsock relay for the probe channel, snapshot
//! restore, …) without churning the call sites again.
//!
//! What the skeleton guarantees:
//!
//! 1. [`run`] probes the host for a `firecracker` binary on `PATH` (with the
//! `NYX_FIRECRACKER_BIN` override for tests) and returns
//! [`SandboxError::BackendUnavailable`] when it is missing. No partially-
//! initialised VM state is created.
//! 2. When the binary is present, the function still returns
//! `BackendUnavailable` for now — Phase 21 will replace the stub with the
//! live jailer wrap. The variant is the only one the verifier needs to
//! branch on, so it can downgrade `Cap::FILE_IO` / `Cap::CODE_EXEC`
//! verdicts to [`crate::evidence::InconclusiveReason::BackendInsufficient`]
//! consistently across hosts that do and do not have firecracker
//! available.
//! 3. The probe is cached behind a `OnceLock` so repeated calls into [`run`]
//! do not re-`stat` the binary every time. Tests that swap
//! `NYX_FIRECRACKER_BIN` between scenarios bypass the cache via the
//! uncached [`is_firecracker_reachable`] helper.
use std::sync::OnceLock;
use crate::dynamic::harness::BuiltHarness;
use super::{SandboxBackend, SandboxError, SandboxOptions, SandboxOutcome};
/// Env var override for the firecracker binary path. Used by tests + dev
/// hosts where firecracker is staged in a non-`PATH` location.
const FIRECRACKER_BIN_ENV: &str = "NYX_FIRECRACKER_BIN";
/// Default binary name when no override is set.
const FIRECRACKER_BIN_DEFAULT: &str = "firecracker";
/// Cached probe result. `Some(true)` = binary reachable, `Some(false)` =
/// probe ran and failed, `None` = never probed.
static FIRECRACKER_AVAILABLE: OnceLock<bool> = OnceLock::new();
/// Returns `true` if a `firecracker` binary is reachable on this host.
///
/// Result is cached after the first call. Tests that mutate
/// `NYX_FIRECRACKER_BIN` between assertions should call
/// [`is_firecracker_reachable`] instead so they observe the new value.
pub fn firecracker_available() -> bool {
*FIRECRACKER_AVAILABLE.get_or_init(is_firecracker_reachable)
}
/// Uncached binary-availability probe. Walks the host `PATH` looking for
/// the resolved binary name and returns `true` when it is a regular file.
pub fn is_firecracker_reachable() -> bool {
let name = firecracker_bin();
if std::path::Path::new(&name).is_absolute() {
return std::path::Path::new(&name).is_file();
}
super::find_in_host_path(&name).is_some()
}
fn firecracker_bin() -> String {
std::env::var(FIRECRACKER_BIN_ENV).unwrap_or_else(|_| FIRECRACKER_BIN_DEFAULT.to_owned())
}
/// Run a harness inside a Firecracker microVM.
///
/// Phase 20: returns [`SandboxError::BackendUnavailable`] in every case.
/// The unused-variable shape is kept so that adding the live boot path in
/// Phase 21 is a single-function diff that does not change the call sites
/// in [`super::run`].
pub fn run(
_harness: &BuiltHarness,
_payload_bytes: &[u8],
_opts: &SandboxOptions,
) -> Result<SandboxOutcome, SandboxError> {
if !firecracker_available() {
return Err(SandboxError::BackendUnavailable(SandboxBackend::Firecracker));
}
// Binary present but no VM logic yet. Surface BackendUnavailable
// explicitly so callers do not mistakenly think the run succeeded.
Err(SandboxError::BackendUnavailable(SandboxBackend::Firecracker))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_binary_returns_backend_unavailable() {
// Force the probe to a path that cannot exist. The OnceLock means
// we have to drive `is_firecracker_reachable` directly instead of
// relying on `firecracker_available()` — another test in the same
// binary may have warmed the cache.
let saved = std::env::var(FIRECRACKER_BIN_ENV).ok();
unsafe { std::env::set_var(FIRECRACKER_BIN_ENV, "/nyx/does-not-exist/firecracker") };
assert!(!is_firecracker_reachable());
if let Some(v) = saved {
unsafe { std::env::set_var(FIRECRACKER_BIN_ENV, v) };
} else {
unsafe { std::env::remove_var(FIRECRACKER_BIN_ENV) };
}
}
#[test]
fn run_returns_backend_unavailable_under_phase_20_stub() {
// The skeleton never returns Ok regardless of whether the binary
// is present — Phase 21 owns the live path.
let harness = BuiltHarness {
workdir: std::path::PathBuf::from("/tmp"),
command: vec!["true".into()],
env: vec![],
source: String::new(),
entry_source: String::new(),
};
let opts = SandboxOptions {
backend: SandboxBackend::Firecracker,
..SandboxOptions::default()
};
let result = run(&harness, b"", &opts);
assert!(matches!(
result,
Err(SandboxError::BackendUnavailable(SandboxBackend::Firecracker))
));
}
}

View file

@ -40,6 +40,18 @@ pub use process_linux::{HardeningLevel, HardeningOutcome};
#[cfg(target_os = "macos")]
pub mod process_macos;
/// Phase 20 (Track E.4) — Firecracker microVM backend skeleton.
///
/// The module is compiled in only when the `firecracker` Cargo feature is
/// enabled. Today it carries no live VM logic: the backend returns
/// [`SandboxError::BackendUnavailable`] when the feature is on but the
/// `firecracker` binary is missing on `PATH`, and the same error when the
/// binary is present (no VM dispatch yet). Phase 20's scope is the trait
/// shape + the `SandboxBackend::Firecracker` enum variant — Phase 21 owns
/// the live boot path.
#[cfg(feature = "firecracker")]
pub mod firecracker;
/// Phase 17 (Track E.1) + Phase 18 (Track E.2) per-run hardening outcome.
///
/// Returned by [`run_process`] on the [`SandboxOutcome`] so callers (tests +
@ -91,7 +103,7 @@ pub mod docker;
/// `confstr(_CS_PATH)` (`/usr/bin:/bin`) when the child has no `PATH`, which
/// misses common installs like Homebrew's `/opt/homebrew/bin/node` or
/// `nvm`-managed binaries under `~/.nvm/...`.
fn find_in_host_path(name: &str) -> Option<std::path::PathBuf> {
pub(crate) fn find_in_host_path(name: &str) -> Option<std::path::PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(name);
@ -373,6 +385,13 @@ pub enum SandboxBackend {
Auto,
Docker,
Process,
/// Phase 20 (Track E.4): Firecracker microVM backend. Compiled in only
/// under `--features firecracker`; when the feature is off, this variant
/// is still selectable but [`run`] surfaces
/// [`SandboxError::BackendUnavailable`] immediately so callers can route
/// around it without conditional-compilation gymnastics at every call
/// site.
Firecracker,
}
#[derive(Debug)]
@ -678,6 +697,30 @@ pub fn run(
}
}
SandboxBackend::Process => run_process(harness, payload_bytes, opts),
SandboxBackend::Firecracker => run_firecracker(harness, payload_bytes, opts),
}
}
/// Phase 20 (Track E.4): dispatch the Firecracker backend.
///
/// When `--features firecracker` is off, the call returns
/// [`SandboxError::BackendUnavailable`] immediately so existing call sites
/// that route on `opts.backend` do not need a feature gate. When the
/// feature is on, the call is delegated to
/// [`firecracker::run`] which is responsible for the `firecracker` binary
/// availability probe + (eventually) the live boot path.
fn run_firecracker(
_harness: &BuiltHarness,
_payload_bytes: &[u8],
_opts: &SandboxOptions,
) -> Result<SandboxOutcome, SandboxError> {
#[cfg(feature = "firecracker")]
{
return firecracker::run(_harness, _payload_bytes, _opts);
}
#[cfg(not(feature = "firecracker"))]
{
Err(SandboxError::BackendUnavailable(SandboxBackend::Firecracker))
}
}

View file

@ -82,6 +82,7 @@ impl VerifyOptions {
let backend = match config.scanner.verify_backend.as_str() {
"docker" => SandboxBackend::Docker,
"process" => SandboxBackend::Process,
"firecracker" => SandboxBackend::Firecracker,
_ => SandboxBackend::Auto,
};
// Phase 11 — Track D.5: surface the per-scan listener as a