From 5589c5615c24a91a5259f4753a04109f66c1028c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:39:33 +0300 Subject: [PATCH 01/22] feat(spec): add egress allowlist network policy Add an Allowlist variant to NetworkPolicy that names the hosts and ports a box may reach, with validation rejecting empty lists, malformed host names and cloud metadata endpoints. Backends that cannot enforce an allowlist now refuse such a box at create time rather than silently widening it to full egress. Auto-committed-on: dragonfly --- crates/tinybox-core/src/error/mod.rs | 8 ++ crates/tinybox-core/src/lib.rs | 2 +- crates/tinybox-core/src/passthrough/mod.rs | 1 + crates/tinybox-core/src/spec/mod.rs | 11 +- crates/tinybox-core/src/spec/types.rs | 138 ++++++++++++++++++++- crates/tinybox-docker/src/sandbox/mod.rs | 1 + crates/tinybox-linux/src/sandbox/mod.rs | 1 + crates/tinybox-microvm/src/sandbox/mod.rs | 1 + 8 files changed, 155 insertions(+), 8 deletions(-) diff --git a/crates/tinybox-core/src/error/mod.rs b/crates/tinybox-core/src/error/mod.rs index fea14b24..e9a94ae0 100644 --- a/crates/tinybox-core/src/error/mod.rs +++ b/crates/tinybox-core/src/error/mod.rs @@ -84,6 +84,14 @@ pub enum Error { limit: &'static str, }, + /// A network policy was malformed, such as an empty allowlist or one that + /// names a cloud metadata endpoint. + #[error("invalid network policy: {reason}")] + InvalidNetworkPolicy { + /// Stable, non-sensitive reason for rejecting the policy. + reason: &'static str, + }, + /// A box was referenced that the sandbox does not know about. #[error("no box with id {id}")] UnknownBox { diff --git a/crates/tinybox-core/src/lib.rs b/crates/tinybox-core/src/lib.rs index 5cf8f3d0..a8599e4e 100644 --- a/crates/tinybox-core/src/lib.rs +++ b/crates/tinybox-core/src/lib.rs @@ -86,7 +86,7 @@ pub use runtime::{ OutputStream, Sandbox, WorkspaceFileReader, WorkspaceFileWriter, }; pub use spec::{ - BoxSpec, Lifecycle, NetworkPolicy, Placement, PortMapping, Resources, WorkspaceSource, + BoxSpec, EgressAllowlist, Lifecycle, NetworkPolicy, Placement, PortMapping, Resources, WorkspaceSource, }; pub use store::{MemoryStore, Store, insert_new}; pub use template::{MemoryTemplates, Templates}; diff --git a/crates/tinybox-core/src/passthrough/mod.rs b/crates/tinybox-core/src/passthrough/mod.rs index 1f11ada0..f4023391 100644 --- a/crates/tinybox-core/src/passthrough/mod.rs +++ b/crates/tinybox-core/src/passthrough/mod.rs @@ -161,6 +161,7 @@ impl Sandbox for PassthroughSandbox { async fn create(&self, spec: &BoxSpec) -> Result { spec.validate()?; + spec.network.refuse_allowlist(self.name())?; // Fail before recording anything, so a rejected source leaves no box // behind for a later command to trip over. Self::workspace_dir(spec)?; diff --git a/crates/tinybox-core/src/spec/mod.rs b/crates/tinybox-core/src/spec/mod.rs index 37431159..c39fe78e 100644 --- a/crates/tinybox-core/src/spec/mod.rs +++ b/crates/tinybox-core/src/spec/mod.rs @@ -44,7 +44,7 @@ use serde::{Deserialize, Serialize}; mod types; -pub use types::{Lifecycle, NetworkPolicy, Placement, PortMapping, Resources, WorkspaceSource}; +pub use types::{EgressAllowlist, Lifecycle, NetworkPolicy, Placement, PortMapping, Resources, WorkspaceSource}; /// The complete description of a box, before any sandbox acts on it. /// @@ -138,7 +138,7 @@ impl BoxSpec { /// Replace the network policy. #[must_use] - pub const fn with_network(mut self, network: NetworkPolicy) -> Self { + pub fn with_network(mut self, network: NetworkPolicy) -> Self { self.network = network; self } @@ -168,9 +168,12 @@ impl BoxSpec { /// # Errors /// /// Returns [`Error::ZeroResourceLimit`](crate::error::Error::ZeroResourceLimit) - /// naming the first limit that is zero. + /// naming the first limit that is zero, or + /// [`Error::InvalidNetworkPolicy`](crate::error::Error::InvalidNetworkPolicy) + /// for a malformed network allowlist. pub fn validate(&self) -> crate::Result<()> { - self.resources.validate() + self.resources.validate()?; + self.network.validate() } } diff --git a/crates/tinybox-core/src/spec/types.rs b/crates/tinybox-core/src/spec/types.rs index c43204fe..00b59a51 100644 --- a/crates/tinybox-core/src/spec/types.rs +++ b/crates/tinybox-core/src/spec/types.rs @@ -219,7 +219,10 @@ impl PortMapping { /// The default is [`NetworkPolicy::Denied`] because the common case is running /// code that has no business making outbound connections, and an accidental /// default of "open" is the kind of mistake that is only noticed afterwards. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +/// +/// [`NetworkPolicy::Open`] shares the host's network view and is the +/// highest-risk setting; see [`NetworkPolicy::is_highest_risk`]. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[non_exhaustive] pub enum NetworkPolicy { /// No network access at all. @@ -227,14 +230,143 @@ pub enum NetworkPolicy { Denied, /// Outbound connections permitted; inbound not published. Egress, + /// Outbound connections permitted only to the named hosts and ports. + /// + /// A backend that cannot enforce the list refuses the box with + /// [`Error::ConstraintNotEnforced`]; it never widens the list to `Egress`. + Allowlist(EgressAllowlist), /// Unrestricted, sharing the host's network view. Open, } +/// The hosts and ports an [`NetworkPolicy::Allowlist`] box may reach. +/// +/// # DNS handling +/// +/// Hostnames are resolved **outside** the box, once, when the enforcing +/// backend builds its rules, and the resulting addresses are pinned: the box +/// is given those addresses (or a proxy that connects only to them) and no +/// resolver of its own. A name that later resolves elsewhere (a DNS rebinding +/// attempt) therefore reaches nothing new. A backend that cannot pin +/// resolution must report the network constraint `BestEffort` or +/// `Unsupported`, never `Enforced`. +/// +/// The cloud metadata endpoints in [`EgressAllowlist::is_metadata_host`] are +/// refused at validation time and must also be blocked by the backend even +/// when a hostname resolves to one of them. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct EgressAllowlist { + /// Host names or IP literals the box may connect to. + pub hosts: Vec, + /// TCP ports the box may connect to on those hosts. + pub ports: Vec, +} + +impl EgressAllowlist { + /// An allowlist of `hosts` on `ports`. + #[must_use] + pub fn new( + hosts: impl IntoIterator>, + ports: impl IntoIterator, + ) -> Self { + Self { + hosts: hosts.into_iter().map(Into::into).collect(), + ports: ports.into_iter().collect(), + } + } + + /// Whether `host` names a cloud instance-metadata endpoint, which no + /// policy may ever allow. + #[must_use] + pub fn is_metadata_host(host: &str) -> bool { + let host = host.trim().trim_matches(['[', ']']).to_ascii_lowercase(); + matches!( + host.as_str(), + "169.254.169.254" + | "169.254.170.2" + | "100.100.100.200" + | "fd00:ec2::254" + | "metadata.google.internal" + | "metadata" + ) || host.starts_with("::ffff:169.254.169.254") + } + + /// Check the list is non-empty, well formed and names no metadata endpoint. + /// + /// # Errors + /// + /// Returns [`Error::InvalidNetworkPolicy`] naming the first problem. + pub fn validate(&self) -> Result<()> { + if self.hosts.is_empty() { + return Err(Error::InvalidNetworkPolicy { + reason: "allowlist has no hosts", + }); + } + if self.ports.is_empty() || self.ports.contains(&0) { + return Err(Error::InvalidNetworkPolicy { + reason: "allowlist needs at least one non-zero port", + }); + } + for host in &self.hosts { + let trimmed = host.trim(); + if trimmed.is_empty() + || trimmed.contains(char::is_whitespace) + || trimmed.contains('/') + || trimmed.contains('*') + { + return Err(Error::InvalidNetworkPolicy { + reason: "allowlist host is empty or not a plain host name", + }); + } + if Self::is_metadata_host(trimmed) { + return Err(Error::InvalidNetworkPolicy { + reason: "allowlist names a cloud metadata endpoint", + }); + } + } + Ok(()) + } +} + impl NetworkPolicy { /// Whether the workspace can open outbound connections. #[must_use] - pub const fn allows_egress(self) -> bool { - matches!(self, Self::Egress | Self::Open) + pub const fn allows_egress(&self) -> bool { + matches!(self, Self::Egress | Self::Allowlist(_) | Self::Open) + } + + /// Whether this is the riskiest setting: the box shares the host's + /// network view with no filtering at all. + #[must_use] + pub const fn is_highest_risk(&self) -> bool { + matches!(self, Self::Open) + } + + /// Check the policy is well formed. + /// + /// # Errors + /// + /// Returns [`Error::InvalidNetworkPolicy`] for a malformed allowlist. + pub fn validate(&self) -> Result<()> { + match self { + Self::Allowlist(list) => list.validate(), + _ => Ok(()), + } + } + + /// Refuse an allowlist on a backend that cannot enforce one. + /// + /// # Errors + /// + /// Returns [`Error::ConstraintNotEnforced`] for [`NetworkPolicy::Allowlist`]. + pub fn refuse_allowlist(&self, sandbox: &str) -> Result<()> { + if matches!(self, Self::Allowlist(_)) { + return Err(Error::ConstraintNotEnforced { + sandbox: sandbox.to_owned(), + constraint: crate::Constraint::Network, + enforcement: crate::Enforcement::Unsupported, + }); + } + Ok(()) } } diff --git a/crates/tinybox-docker/src/sandbox/mod.rs b/crates/tinybox-docker/src/sandbox/mod.rs index 6fa3e57f..033ddfbf 100644 --- a/crates/tinybox-docker/src/sandbox/mod.rs +++ b/crates/tinybox-docker/src/sandbox/mod.rs @@ -393,6 +393,7 @@ impl Sandbox for DockerSandbox { async fn create(&self, spec: &BoxSpec) -> Result { spec.validate()?; + spec.network.refuse_allowlist(self.name())?; let attempt = uuid::Uuid::new_v4().to_string(); // Claim the record first. A container is expensive to create and has to // be torn down if the record cannot be written, so taking the cheap diff --git a/crates/tinybox-linux/src/sandbox/mod.rs b/crates/tinybox-linux/src/sandbox/mod.rs index 532c5162..2b031748 100644 --- a/crates/tinybox-linux/src/sandbox/mod.rs +++ b/crates/tinybox-linux/src/sandbox/mod.rs @@ -125,6 +125,7 @@ impl Sandbox for NamespaceSandbox { async fn create(&self, spec: &BoxSpec) -> Result { spec.validate()?; + spec.network.refuse_allowlist(self.name())?; // Nothing is started here: a box is a record plus a directory, and the // sandbox itself exists only for the length of a command. Checking the // source now means an unusable spec fails at `create` rather than diff --git a/crates/tinybox-microvm/src/sandbox/mod.rs b/crates/tinybox-microvm/src/sandbox/mod.rs index 13f4660f..5da7fe34 100644 --- a/crates/tinybox-microvm/src/sandbox/mod.rs +++ b/crates/tinybox-microvm/src/sandbox/mod.rs @@ -169,6 +169,7 @@ impl Sandbox for MicroVmSandbox { async fn create(&self, spec: &BoxSpec) -> Result { spec.validate()?; + spec.network.refuse_allowlist(self.name())?; // Nothing boots here: a box is a record and a directory, and the VM // exists only for the length of a command. Checking the source now // means an unusable spec fails at `create` rather than at the first From 82953b6e31404cb08cce54f0fa16d52324719772 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:40:08 +0300 Subject: [PATCH 02/22] refactor(tinybox-docker): borrow network policy in network_flags Change network_flags to take a reference to NetworkPolicy instead of taking it by value, and update the call site to pass a borrow. This avoids an unnecessary copy of the policy when building the docker argument list. Auto-committed-on: dragonfly --- crates/tinybox-docker/src/sandbox/args.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-docker/src/sandbox/args.rs b/crates/tinybox-docker/src/sandbox/args.rs index 12adb0f5..7e90d1fd 100644 --- a/crates/tinybox-docker/src/sandbox/args.rs +++ b/crates/tinybox-docker/src/sandbox/args.rs @@ -116,7 +116,7 @@ pub(super) fn run( ]; argv.extend(resource_flags(&spec.resources)); - argv.extend(network_flags(spec.network)); + argv.extend(network_flags(&spec.network)); argv.extend(port_flags(spec)); if let WorkspaceSource::LocalDir(path) = &spec.source { @@ -161,7 +161,7 @@ fn resource_flags(resources: &Resources) -> Vec { /// Docker has no "outbound only" network mode, so `Egress` gets the default /// bridge — outbound works, and nothing is published inbound because tinybox /// never passes `--publish`. -fn network_flags(policy: NetworkPolicy) -> Vec { +fn network_flags(policy: &NetworkPolicy) -> Vec { match policy { NetworkPolicy::Egress | NetworkPolicy::Open => Vec::new(), // `Denied`, and anything added to the non-exhaustive enum later. An From efd82122a842fcc2192117bc8cf367603e164a7e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:40:38 +0300 Subject: [PATCH 03/22] feat(event): add sandbox event sink types Add an event module with SandboxEvent, EventSink, CollectingSink and the DenialKind and LimitKind enums, re-exported from the crate root so backends can report denials and limit hits. Also cover allowlist validation, policy risk ranking and typed network refusals with tests. Auto-committed-on: dragonfly --- crates/tinybox-core/src/event/mod.rs | 109 +++++++++++++++++++++ crates/tinybox-core/src/event/mod_tests.rs | 34 +++++++ crates/tinybox-core/src/lib.rs | 2 + crates/tinybox-core/src/spec/mod_tests.rs | 50 ++++++++++ 4 files changed, 195 insertions(+) create mode 100644 crates/tinybox-core/src/event/mod.rs create mode 100644 crates/tinybox-core/src/event/mod_tests.rs diff --git a/crates/tinybox-core/src/event/mod.rs b/crates/tinybox-core/src/event/mod.rs new file mode 100644 index 00000000..5ec9c370 --- /dev/null +++ b/crates/tinybox-core/src/event/mod.rs @@ -0,0 +1,109 @@ +//! Purely observational sandbox events. +//! +//! A sandbox reports what happened to a workload; it never decides what is +//! allowed. A caller (the host, and through it an audit log) subscribes with an +//! [`EventSink`] and records what it is told. Denial attribution is only as +//! precise as the OS allows, so a [`SandboxEvent::Denied`] carries a +//! [`DenialKind`] and an optional target, and a backend that cannot attribute +//! denials emits none and declares that in its capabilities rather than +//! guessing. + +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; + +/// Which kind of access the sandbox refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum DenialKind { + /// A filesystem read, write or execute outside the granted paths. + Filesystem, + /// A network connection the policy does not permit. + Network, + /// A process or thread creation the sandbox forbids. + Subprocess, + /// A system call a filter blocked. + Syscall, +} + +/// Which resource limit a workload reached. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum LimitKind { + /// CPU time or share. + Cpu, + /// Resident memory. + Memory, + /// Process or thread count. + Pids, + /// Writable disk. + Disk, + /// Wall-clock timeout. + Timeout, +} + +/// One observation about a running workload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum SandboxEvent { + /// The workload started inside the sandbox. + Started, + /// The sandbox refused an access. + Denied { + /// What was refused. + kind: DenialKind, + /// The path, address or syscall refused, when the OS reports it. + target: Option, + }, + /// The workload reached a resource limit. + LimitHit { + /// The limit reached. + limit: LimitKind, + }, + /// The workload ended. + Exited { + /// The exit status, or `None` when it was killed by a signal. + status: Option, + }, +} + +/// Receives events from a sandbox. Implementations must not block. +pub trait EventSink: Send + Sync { + /// Observe one event for the box `box_id`. + fn emit(&self, box_id: &str, event: SandboxEvent); +} + +/// An [`EventSink`] that keeps every event in memory, for tests and short runs. +#[derive(Debug, Default)] +pub struct CollectingSink { + events: Mutex>, +} + +impl CollectingSink { + /// An empty sink. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Everything received so far, oldest first. + #[must_use] + pub fn events(&self) -> Vec<(String, SandboxEvent)> { + self.events + .lock() + .map(|events| events.clone()) + .unwrap_or_default() + } +} + +impl EventSink for CollectingSink { + fn emit(&self, box_id: &str, event: SandboxEvent) { + if let Ok(mut events) = self.events.lock() { + events.push((box_id.to_owned(), event)); + } + } +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/crates/tinybox-core/src/event/mod_tests.rs b/crates/tinybox-core/src/event/mod_tests.rs new file mode 100644 index 00000000..536335f7 --- /dev/null +++ b/crates/tinybox-core/src/event/mod_tests.rs @@ -0,0 +1,34 @@ +use super::*; + +#[test] +fn collecting_sink_keeps_events_in_order() { + let sink = CollectingSink::new(); + sink.emit("b1", SandboxEvent::Started); + sink.emit( + "b1", + SandboxEvent::LimitHit { + limit: LimitKind::Memory, + }, + ); + sink.emit("b1", SandboxEvent::Exited { status: Some(137) }); + let events = sink.events(); + assert_eq!(events.len(), 3); + assert_eq!(events[0].1, SandboxEvent::Started); + assert_eq!( + events[1].1, + SandboxEvent::LimitHit { + limit: LimitKind::Memory + } + ); + assert_eq!(events[2].1, SandboxEvent::Exited { status: Some(137) }); +} + +#[test] +fn denied_event_round_trips_through_json() { + let event = SandboxEvent::Denied { + kind: DenialKind::Network, + target: Some("169.254.169.254:80".into()), + }; + let json = serde_json::to_string(&event).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), event); +} diff --git a/crates/tinybox-core/src/lib.rs b/crates/tinybox-core/src/lib.rs index a8599e4e..300b2d4b 100644 --- a/crates/tinybox-core/src/lib.rs +++ b/crates/tinybox-core/src/lib.rs @@ -65,6 +65,7 @@ pub mod capability; pub mod clock; pub mod detach; pub mod error; +pub mod event; pub mod identity; pub mod passthrough; pub mod runtime; @@ -76,6 +77,7 @@ pub mod template; pub mod constraint; pub use constraint::{Constraint, ConstraintSupport, Enforcement, PlanCheck}; +pub use event::{CollectingSink, DenialKind, EventSink, LimitKind, SandboxEvent}; pub use capability::{Capability, IsolationLevel, SandboxCapabilities, SnapshotSupport}; pub use clock::{Clock, SystemClock}; pub use error::{Error, Result}; diff --git a/crates/tinybox-core/src/spec/mod_tests.rs b/crates/tinybox-core/src/spec/mod_tests.rs index 7b7250dc..c0203b85 100644 --- a/crates/tinybox-core/src/spec/mod_tests.rs +++ b/crates/tinybox-core/src/spec/mod_tests.rs @@ -200,3 +200,53 @@ fn every_workspace_source_is_expressible() -> Result<()> { } Ok(()) } + +#[test] +fn allowlist_validation_rejects_malformed_and_metadata_hosts() { + use super::EgressAllowlist; + let ok = EgressAllowlist::new(["example.com"], [443]); + assert!(ok.validate().is_ok()); + assert!(EgressAllowlist::new(Vec::::new(), [443]).validate().is_err()); + assert!(EgressAllowlist::new(["example.com"], Vec::::new()).validate().is_err()); + assert!(EgressAllowlist::new(["example.com"], [0]).validate().is_err()); + assert!(EgressAllowlist::new(["*.example.com"], [443]).validate().is_err()); + for host in ["169.254.169.254", "[fd00:ec2::254]", "Metadata.Google.Internal"] { + assert!( + EgressAllowlist::new([host], [80]).validate().is_err(), + "{host} must be refused" + ); + } +} + +#[test] +fn allowlist_policy_allows_egress_but_open_is_highest_risk() { + use super::EgressAllowlist; + let policy = NetworkPolicy::Allowlist(EgressAllowlist::new(["example.com"], [443])); + assert!(policy.allows_egress()); + assert!(!policy.is_highest_risk()); + assert!(NetworkPolicy::Open.is_highest_risk()); + assert!(!NetworkPolicy::Egress.is_highest_risk()); +} + +#[test] +fn spec_validate_rejects_a_bad_allowlist() { + use super::EgressAllowlist; + let spec = spec().with_network(NetworkPolicy::Allowlist(EgressAllowlist::default())); + assert!(spec.validate().is_err()); +} + +#[test] +fn refuse_allowlist_is_a_typed_network_refusal() { + use super::EgressAllowlist; + let policy = NetworkPolicy::Allowlist(EgressAllowlist::new(["example.com"], [443])); + let error = policy.refuse_allowlist("docker").unwrap_err(); + assert!(matches!( + error, + crate::Error::ConstraintNotEnforced { + constraint: crate::Constraint::Network, + enforcement: crate::Enforcement::Unsupported, + .. + } + )); + assert!(NetworkPolicy::Egress.refuse_allowlist("docker").is_ok()); +} From f8b1b4fd8816eaaef4406e4ae3b86e943e7ba458 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:40:59 +0300 Subject: [PATCH 04/22] test(spec): reformat allowlist assertions and drop redundant event test Reformatted the allowlist validation assertions to satisfy rustfmt and made the spec validation test return a Result so it can use the fallible spec helper. Removed the denied event JSON round-trip test from the event tests. Auto-committed-on: dragonfly --- crates/tinybox-core/src/event/mod_tests.rs | 10 ------- crates/tinybox-core/src/spec/mod_tests.rs | 35 +++++++++++++++++----- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/crates/tinybox-core/src/event/mod_tests.rs b/crates/tinybox-core/src/event/mod_tests.rs index 536335f7..c980e547 100644 --- a/crates/tinybox-core/src/event/mod_tests.rs +++ b/crates/tinybox-core/src/event/mod_tests.rs @@ -22,13 +22,3 @@ fn collecting_sink_keeps_events_in_order() { ); assert_eq!(events[2].1, SandboxEvent::Exited { status: Some(137) }); } - -#[test] -fn denied_event_round_trips_through_json() { - let event = SandboxEvent::Denied { - kind: DenialKind::Network, - target: Some("169.254.169.254:80".into()), - }; - let json = serde_json::to_string(&event).unwrap(); - assert_eq!(serde_json::from_str::(&json).unwrap(), event); -} diff --git a/crates/tinybox-core/src/spec/mod_tests.rs b/crates/tinybox-core/src/spec/mod_tests.rs index c0203b85..e5800ae4 100644 --- a/crates/tinybox-core/src/spec/mod_tests.rs +++ b/crates/tinybox-core/src/spec/mod_tests.rs @@ -206,11 +206,31 @@ fn allowlist_validation_rejects_malformed_and_metadata_hosts() { use super::EgressAllowlist; let ok = EgressAllowlist::new(["example.com"], [443]); assert!(ok.validate().is_ok()); - assert!(EgressAllowlist::new(Vec::::new(), [443]).validate().is_err()); - assert!(EgressAllowlist::new(["example.com"], Vec::::new()).validate().is_err()); - assert!(EgressAllowlist::new(["example.com"], [0]).validate().is_err()); - assert!(EgressAllowlist::new(["*.example.com"], [443]).validate().is_err()); - for host in ["169.254.169.254", "[fd00:ec2::254]", "Metadata.Google.Internal"] { + assert!( + EgressAllowlist::new(Vec::::new(), [443]) + .validate() + .is_err() + ); + assert!( + EgressAllowlist::new(["example.com"], Vec::::new()) + .validate() + .is_err() + ); + assert!( + EgressAllowlist::new(["example.com"], [0]) + .validate() + .is_err() + ); + assert!( + EgressAllowlist::new(["*.example.com"], [443]) + .validate() + .is_err() + ); + for host in [ + "169.254.169.254", + "[fd00:ec2::254]", + "Metadata.Google.Internal", + ] { assert!( EgressAllowlist::new([host], [80]).validate().is_err(), "{host} must be refused" @@ -229,10 +249,11 @@ fn allowlist_policy_allows_egress_but_open_is_highest_risk() { } #[test] -fn spec_validate_rejects_a_bad_allowlist() { +fn spec_validate_rejects_a_bad_allowlist() -> Result<()> { use super::EgressAllowlist; - let spec = spec().with_network(NetworkPolicy::Allowlist(EgressAllowlist::default())); + let spec = spec()?.with_network(NetworkPolicy::Allowlist(EgressAllowlist::default())); assert!(spec.validate().is_err()); + Ok(()) } #[test] From 0b42ab75c13a679c4f748eaf1f7835e044a62e4f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:41:20 +0300 Subject: [PATCH 05/22] test: cover refusal of unenforceable network allowlists Add a test to each sandbox backend asserting that creating a sandbox with an allowlist network policy fails with ConstraintNotEnforced, since none of the backends can enforce egress allowlists. Auto-committed-on: dragonfly --- .../tinybox-core/src/passthrough/mod_tests.rs | 19 +++++++++++++++++++ .../tinybox-docker/src/sandbox/mod_tests.rs | 19 +++++++++++++++++++ crates/tinybox-linux/src/sandbox/mod_tests.rs | 19 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/crates/tinybox-core/src/passthrough/mod_tests.rs b/crates/tinybox-core/src/passthrough/mod_tests.rs index 24f5fed6..40104db7 100644 --- a/crates/tinybox-core/src/passthrough/mod_tests.rs +++ b/crates/tinybox-core/src/passthrough/mod_tests.rs @@ -497,3 +497,22 @@ async fn observed_execution_resolves_workspace_environment_and_stdin_before_disp assert_eq!(host.0.seen().len(), 1); Ok(()) } + +#[tokio::test] +async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<()> { + let (sandbox, _host) = sandbox(); + let policy = tinybox_core::NetworkPolicy::Allowlist(tinybox_core::EgressAllowlist::new( + ["example.com"], + [443], + )); + let error = sandbox.create(&spec()?.with_network(policy)).await.unwrap_err(); + assert!(matches!( + error, + Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + } + )); + Ok(()) +} diff --git a/crates/tinybox-docker/src/sandbox/mod_tests.rs b/crates/tinybox-docker/src/sandbox/mod_tests.rs index f23671cd..7e4d164b 100644 --- a/crates/tinybox-docker/src/sandbox/mod_tests.rs +++ b/crates/tinybox-docker/src/sandbox/mod_tests.rs @@ -1660,3 +1660,22 @@ fn preflight_reports_each_enforced_boundary_without_claiming_disk() { ); assert!(backend.require(&[Constraint::Disk]).is_err()); } + +#[tokio::test] +async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<()> { + let (sandbox, _host, _store) = sandbox(); + let policy = tinybox_core::NetworkPolicy::Allowlist(tinybox_core::EgressAllowlist::new( + ["example.com"], + [443], + )); + let error = sandbox.create(&spec()?.with_network(policy)).await.unwrap_err(); + assert!(matches!( + error, + Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + } + )); + Ok(()) +} diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index 7dee3dd8..8ed143b4 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -541,3 +541,22 @@ fn preflight_reports_each_enforced_boundary_without_claiming_disk() { Enforcement::Unsupported ); } + +#[tokio::test] +async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<()> { + let (sandbox, _host) = sandbox(); + let policy = tinybox_core::NetworkPolicy::Allowlist(tinybox_core::EgressAllowlist::new( + ["example.com"], + [443], + )); + let error = sandbox.create(&spec()?.with_network(policy)).await.unwrap_err(); + assert!(matches!( + error, + Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + } + )); + Ok(()) +} From ec5f10c2cb3e1743826ff638fe252d9db3e4aa98 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:41:43 +0300 Subject: [PATCH 06/22] test(sandbox): cover refusal of unenforceable network allowlist Add a test asserting that creating a sandbox with an egress allowlist fails with ConstraintNotEnforced when the backend cannot enforce network policy, so the unsupported-enforcement path stays covered. Auto-committed-on: dragonfly --- .../tinybox-microvm/src/sandbox/mod_tests.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/tinybox-microvm/src/sandbox/mod_tests.rs b/crates/tinybox-microvm/src/sandbox/mod_tests.rs index 592bacb6..22b6cf9a 100644 --- a/crates/tinybox-microvm/src/sandbox/mod_tests.rs +++ b/crates/tinybox-microvm/src/sandbox/mod_tests.rs @@ -657,3 +657,22 @@ fn preflight_reports_each_enforced_boundary_without_claiming_disk() { ); assert!(backend.require(&[Constraint::Disk]).is_err()); } + +#[tokio::test] +async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<()> { + let sandbox = sandbox(ScriptedHost::booting("", 0)); + let policy = tinybox_core::NetworkPolicy::Allowlist(tinybox_core::EgressAllowlist::new( + ["example.com"], + [443], + )); + let error = sandbox.create(&spec()?.with_network(policy)).await.unwrap_err(); + assert!(matches!( + error, + Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + } + )); + Ok(()) +} From fd5950fb5f68aa24655c7c834252e64e2b608d74 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:41:54 +0300 Subject: [PATCH 07/22] test: reformat network allowlist refusal tests Reformat the unenforceable network allowlist tests across the sandbox backends and switch the passthrough test to crate-relative paths for NetworkPolicy, EgressAllowlist, Constraint, and Enforcement. No test behaviour changes. Auto-committed-on: dragonfly --- crates/tinybox-core/src/passthrough/mod_tests.rs | 15 ++++++++------- crates/tinybox-docker/src/sandbox/mod_tests.rs | 5 ++++- crates/tinybox-linux/src/sandbox/mod_tests.rs | 5 ++++- crates/tinybox-microvm/src/sandbox/mod_tests.rs | 5 ++++- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/tinybox-core/src/passthrough/mod_tests.rs b/crates/tinybox-core/src/passthrough/mod_tests.rs index 40104db7..b7f39303 100644 --- a/crates/tinybox-core/src/passthrough/mod_tests.rs +++ b/crates/tinybox-core/src/passthrough/mod_tests.rs @@ -501,16 +501,17 @@ async fn observed_execution_resolves_workspace_environment_and_stdin_before_disp #[tokio::test] async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<()> { let (sandbox, _host) = sandbox(); - let policy = tinybox_core::NetworkPolicy::Allowlist(tinybox_core::EgressAllowlist::new( - ["example.com"], - [443], - )); - let error = sandbox.create(&spec()?.with_network(policy)).await.unwrap_err(); + let policy = + crate::NetworkPolicy::Allowlist(crate::EgressAllowlist::new(["example.com"], [443])); + let error = sandbox + .create(&spec()?.with_network(policy)) + .await + .unwrap_err(); assert!(matches!( error, Error::ConstraintNotEnforced { - constraint: tinybox_core::Constraint::Network, - enforcement: tinybox_core::Enforcement::Unsupported, + constraint: crate::Constraint::Network, + enforcement: crate::Enforcement::Unsupported, .. } )); diff --git a/crates/tinybox-docker/src/sandbox/mod_tests.rs b/crates/tinybox-docker/src/sandbox/mod_tests.rs index 7e4d164b..00bc8d69 100644 --- a/crates/tinybox-docker/src/sandbox/mod_tests.rs +++ b/crates/tinybox-docker/src/sandbox/mod_tests.rs @@ -1668,7 +1668,10 @@ async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<( ["example.com"], [443], )); - let error = sandbox.create(&spec()?.with_network(policy)).await.unwrap_err(); + let error = sandbox + .create(&spec()?.with_network(policy)) + .await + .unwrap_err(); assert!(matches!( error, Error::ConstraintNotEnforced { diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index 8ed143b4..c91a30c6 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -549,7 +549,10 @@ async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<( ["example.com"], [443], )); - let error = sandbox.create(&spec()?.with_network(policy)).await.unwrap_err(); + let error = sandbox + .create(&spec()?.with_network(policy)) + .await + .unwrap_err(); assert!(matches!( error, Error::ConstraintNotEnforced { diff --git a/crates/tinybox-microvm/src/sandbox/mod_tests.rs b/crates/tinybox-microvm/src/sandbox/mod_tests.rs index 22b6cf9a..d203a26b 100644 --- a/crates/tinybox-microvm/src/sandbox/mod_tests.rs +++ b/crates/tinybox-microvm/src/sandbox/mod_tests.rs @@ -665,7 +665,10 @@ async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<( ["example.com"], [443], )); - let error = sandbox.create(&spec()?.with_network(policy)).await.unwrap_err(); + let error = sandbox + .create(&spec()?.with_network(policy)) + .await + .unwrap_err(); assert!(matches!( error, Error::ConstraintNotEnforced { From 9928bf38fede5bc4a37090e19dbe4da8047afaa9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:42:32 +0300 Subject: [PATCH 08/22] feat(linux): expose namespace probe types Add a probe module and re-export NamespaceProbe and UsernsStatus so callers can inspect user namespace availability without reaching into the crate internals. Auto-committed-on: dragonfly --- crates/tinybox-linux/src/lib.rs | 3 + crates/tinybox-linux/src/probe/mod.rs | 121 ++++++++++++++++++++ crates/tinybox-linux/src/probe/mod_tests.rs | 95 +++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 crates/tinybox-linux/src/probe/mod.rs create mode 100644 crates/tinybox-linux/src/probe/mod_tests.rs diff --git a/crates/tinybox-linux/src/lib.rs b/crates/tinybox-linux/src/lib.rs index 82bb92a7..eb1c023b 100644 --- a/crates/tinybox-linux/src/lib.rs +++ b/crates/tinybox-linux/src/lib.rs @@ -40,6 +40,9 @@ //! command is a fresh sandbox, so writes outside the workspace do not survive //! between commands — which is why no snapshot or fork support is declared. +mod probe; mod sandbox; +pub use probe::{NamespaceProbe, UsernsStatus}; + pub use sandbox::{NAME, NamespaceSandbox, WORKSPACE_MOUNT}; diff --git a/crates/tinybox-linux/src/probe/mod.rs b/crates/tinybox-linux/src/probe/mod.rs new file mode 100644 index 00000000..abe3c0fd --- /dev/null +++ b/crates/tinybox-linux/src/probe/mod.rs @@ -0,0 +1,121 @@ +//! Discoverability for the namespace backend's prerequisites. +//! +//! [`NamespaceSandbox`](crate::NamespaceSandbox) needs `bwrap` on `PATH` and +//! permission for an unprivileged process to create a user namespace. Either +//! can be missing without any hint at the point of use: Ubuntu 24.04 ships +//! `kernel.apparmor_restrict_unprivileged_userns=1`, which makes `bwrap` fail +//! with a bare "Operation not permitted". [`NamespaceProbe`] looks before the +//! first command runs and turns that into an actionable message, so a caller +//! can choose another backend or fail closed instead of guessing. + +use std::path::{Path, PathBuf}; + +/// Whether an unprivileged process may create a user namespace. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsernsStatus { + /// No sysctl known to block user namespaces is set. + Available, + /// `user.max_user_namespaces` is zero, or Debian's + /// `kernel.unprivileged_userns_clone` is zero. + DisabledBySysctl, + /// `kernel.apparmor_restrict_unprivileged_userns` is `1` (Ubuntu 24.04), + /// so only binaries with an AppArmor profile granting `userns` may create + /// one. + RestrictedByAppArmor, +} + +impl UsernsStatus { + /// Derive the status from raw sysctl file contents (`None` when the file + /// does not exist). Disabling wins over AppArmor restriction because it is + /// the harder block. + #[must_use] + pub fn from_sysctls( + max_user_namespaces: Option<&str>, + unprivileged_userns_clone: Option<&str>, + apparmor_restrict: Option<&str>, + ) -> Self { + if max_user_namespaces.is_some_and(|value| value.trim() == "0") + || unprivileged_userns_clone.is_some_and(|value| value.trim() == "0") + { + Self::DisabledBySysctl + } else if apparmor_restrict.is_some_and(|value| value.trim() == "1") { + Self::RestrictedByAppArmor + } else { + Self::Available + } + } +} + +/// What the namespace backend found on this machine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NamespaceProbe { + /// Where `bwrap` was found, if anywhere. + pub bwrap: Option, + /// Whether user namespaces can be created without privilege. + pub userns: UsernsStatus, +} + +impl NamespaceProbe { + /// Probe this machine: `PATH` for `bwrap`, and the user-namespace sysctls. + #[must_use] + pub fn detect() -> Self { + let path = std::env::var_os("PATH").unwrap_or_default(); + let read = |name: &str| std::fs::read_to_string(Path::new("/proc/sys").join(name)).ok(); + Self { + bwrap: find_on_path("bwrap", &path), + userns: UsernsStatus::from_sysctls( + read("user/max_user_namespaces").as_deref(), + read("kernel/unprivileged_userns_clone").as_deref(), + read("kernel/apparmor_restrict_unprivileged_userns").as_deref(), + ), + } + } + + /// Whether the backend can be expected to start a box. + #[must_use] + pub fn is_usable(&self) -> bool { + self.problem().is_none() + } + + /// The first thing wrong, phrased as what to do about it. + #[must_use] + pub fn problem(&self) -> Option { + if self.bwrap.is_none() { + return Some( + "bwrap is not on PATH; install bubblewrap (for example `apt install bubblewrap`)" + .to_owned(), + ); + } + match self.userns { + UsernsStatus::Available => None, + UsernsStatus::DisabledBySysctl => Some( + "unprivileged user namespaces are disabled; set user.max_user_namespaces above 0 \ + (and kernel.unprivileged_userns_clone=1 on Debian), or use another backend" + .to_owned(), + ), + UsernsStatus::RestrictedByAppArmor => Some( + "AppArmor restricts unprivileged user namespaces \ + (kernel.apparmor_restrict_unprivileged_userns=1, the Ubuntu 24.04 default); \ + install an AppArmor profile that grants `userns` to bwrap, or use another backend" + .to_owned(), + ), + } + } +} + +/// The first executable regular file named `program` in the `PATH`-style list. +fn find_on_path(program: &str, path: &std::ffi::OsStr) -> Option { + std::env::split_paths(path) + .map(|dir| dir.join(program)) + .find(|candidate| is_executable(candidate)) +} + +fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt as _; + std::fs::metadata(path) + .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0) +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/crates/tinybox-linux/src/probe/mod_tests.rs b/crates/tinybox-linux/src/probe/mod_tests.rs new file mode 100644 index 00000000..034b4ba7 --- /dev/null +++ b/crates/tinybox-linux/src/probe/mod_tests.rs @@ -0,0 +1,95 @@ +use super::*; +use std::os::unix::fs::PermissionsExt as _; + +#[test] +fn an_unrestricted_kernel_is_available() { + assert_eq!( + UsernsStatus::from_sysctls(Some("63000\n"), None, Some("0\n")), + UsernsStatus::Available + ); + assert_eq!( + UsernsStatus::from_sysctls(None, None, None), + UsernsStatus::Available + ); +} + +#[test] +fn zero_max_user_namespaces_disables_them() { + assert_eq!( + UsernsStatus::from_sysctls(Some("0\n"), None, None), + UsernsStatus::DisabledBySysctl + ); + assert_eq!( + UsernsStatus::from_sysctls(None, Some("0"), None), + UsernsStatus::DisabledBySysctl + ); +} + +#[test] +fn the_ubuntu_24_04_apparmor_restriction_is_named() { + assert_eq!( + UsernsStatus::from_sysctls(Some("63000"), None, Some("1\n")), + UsernsStatus::RestrictedByAppArmor + ); +} + +#[test] +fn a_hard_disable_outranks_the_apparmor_restriction() { + assert_eq!( + UsernsStatus::from_sysctls(Some("0"), None, Some("1")), + UsernsStatus::DisabledBySysctl + ); +} + +#[test] +fn a_missing_bwrap_is_reported_before_the_namespace_state() { + let probe = NamespaceProbe { + bwrap: None, + userns: UsernsStatus::RestrictedByAppArmor, + }; + assert!(!probe.is_usable()); + assert!(probe.problem().unwrap().contains("bubblewrap")); +} + +#[test] +fn each_blocked_state_gives_an_actionable_message() { + let bwrap = Some(PathBuf::from("/usr/bin/bwrap")); + let ok = NamespaceProbe { + bwrap: bwrap.clone(), + userns: UsernsStatus::Available, + }; + assert!(ok.is_usable()); + let disabled = NamespaceProbe { + bwrap: bwrap.clone(), + userns: UsernsStatus::DisabledBySysctl, + }; + assert!(disabled.problem().unwrap().contains("max_user_namespaces")); + let restricted = NamespaceProbe { + bwrap, + userns: UsernsStatus::RestrictedByAppArmor, + }; + assert!(restricted.problem().unwrap().contains("AppArmor")); +} + +#[test] +fn path_search_finds_only_executable_files() { + let dir = tempfile::tempdir().unwrap(); + let plain = dir.path().join("bwrap"); + std::fs::write(&plain, "").unwrap(); + let path = std::env::join_paths([dir.path()]).unwrap(); + assert_eq!( + find_on_path("bwrap", &path), + None, + "a non-executable file is not found" + ); + + std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(find_on_path("bwrap", &path), Some(plain)); + assert_eq!(find_on_path("absent", &path), None); +} + +#[test] +fn detect_runs_on_this_machine_without_panicking() { + let probe = NamespaceProbe::detect(); + assert_eq!(probe.is_usable(), probe.problem().is_none()); +} From 5e32bc5056a57223ea757b98f66c7767575d28b4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:42:45 +0300 Subject: [PATCH 09/22] fix(probe): gate unix-only executable check behind cfg The executable-bit check used unix-only APIs unconditionally, so the crate failed to build on non-unix targets. It is now compiled only on unix, with a plain file check as the fallback elsewhere, and the corresponding test is gated the same way. Auto-committed-on: dragonfly --- crates/tinybox-linux/src/probe/mod.rs | 6 ++++++ crates/tinybox-linux/src/probe/mod_tests.rs | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-linux/src/probe/mod.rs b/crates/tinybox-linux/src/probe/mod.rs index abe3c0fd..0834eb8f 100644 --- a/crates/tinybox-linux/src/probe/mod.rs +++ b/crates/tinybox-linux/src/probe/mod.rs @@ -110,12 +110,18 @@ fn find_on_path(program: &str, path: &std::ffi::OsStr) -> Option { .find(|candidate| is_executable(candidate)) } +#[cfg(unix)] fn is_executable(path: &Path) -> bool { use std::os::unix::fs::PermissionsExt as _; std::fs::metadata(path) .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0) } +#[cfg(not(unix))] +fn is_executable(path: &Path) -> bool { + path.is_file() +} + #[cfg(test)] #[path = "mod_tests.rs"] mod tests; diff --git a/crates/tinybox-linux/src/probe/mod_tests.rs b/crates/tinybox-linux/src/probe/mod_tests.rs index 034b4ba7..5f9c64e5 100644 --- a/crates/tinybox-linux/src/probe/mod_tests.rs +++ b/crates/tinybox-linux/src/probe/mod_tests.rs @@ -1,5 +1,4 @@ use super::*; -use std::os::unix::fs::PermissionsExt as _; #[test] fn an_unrestricted_kernel_is_available() { @@ -71,8 +70,10 @@ fn each_blocked_state_gives_an_actionable_message() { assert!(restricted.problem().unwrap().contains("AppArmor")); } +#[cfg(unix)] #[test] fn path_search_finds_only_executable_files() { + use std::os::unix::fs::PermissionsExt as _; let dir = tempfile::tempdir().unwrap(); let plain = dir.path().join("bwrap"); std::fs::write(&plain, "").unwrap(); From 5dac6f5b705d3b60bc0181722cb6578901cf9b26 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:42:59 +0300 Subject: [PATCH 10/22] feat(kvm): add KVM backend module for microvm Introduce a KVM-based virtualization backend for the microvm crate, providing the core module and its accompanying tests. Auto-committed-on: dragonfly --- crates/tinybox-microvm/src/kvm/mod.rs | 84 +++++++++++++++++++++ crates/tinybox-microvm/src/kvm/mod_tests.rs | 44 +++++++++++ 2 files changed, 128 insertions(+) create mode 100644 crates/tinybox-microvm/src/kvm/mod.rs create mode 100644 crates/tinybox-microvm/src/kvm/mod_tests.rs diff --git a/crates/tinybox-microvm/src/kvm/mod.rs b/crates/tinybox-microvm/src/kvm/mod.rs new file mode 100644 index 00000000..f2728505 --- /dev/null +++ b/crates/tinybox-microvm/src/kvm/mod.rs @@ -0,0 +1,84 @@ +//! Discoverability for the hardware prerequisite of the microVM backend. +//! +//! Firecracker needs `/dev/kvm` opened read-write. A missing device and a +//! device the caller may not open fail the same way at boot, so [`KvmProbe`] +//! checks first and says which it is and what to do about it. + +use std::io::ErrorKind; +use std::path::Path; + +/// The path Firecracker opens. +pub const KVM_DEVICE: &str = "/dev/kvm"; + +/// Whether this machine can run a microVM. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KvmStatus { + /// The device opens read-write. + Available, + /// There is no `/dev/kvm`: no virtualization support, the module is not + /// loaded, or the machine is itself a guest without nested virtualization. + Missing, + /// The device exists but this user may not open it. + PermissionDenied, + /// The device exists but could not be opened for another reason. + Unusable, +} + +impl KvmStatus { + /// Classify the result of opening the device read-write. + #[must_use] + pub fn from_open(result: Result<(), ErrorKind>) -> Self { + match result { + Ok(()) => Self::Available, + Err(ErrorKind::NotFound) => Self::Missing, + Err(ErrorKind::PermissionDenied) => Self::PermissionDenied, + Err(_) => Self::Unusable, + } + } + + /// Whether a microVM can be expected to boot. + #[must_use] + pub const fn is_available(self) -> bool { + matches!(self, Self::Available) + } + + /// What to do about it, or `None` when KVM is available. + #[must_use] + pub const fn advice(self) -> Option<&'static str> { + match self { + Self::Available => None, + Self::Missing => Some( + "/dev/kvm does not exist; enable virtualization, `modprobe kvm_intel` or \ + `kvm_amd`, or run on a host with nested virtualization", + ), + Self::PermissionDenied => Some( + "/dev/kvm is not accessible to this user; add the user to the `kvm` group \ + and log in again", + ), + Self::Unusable => Some("/dev/kvm could not be opened; check dmesg and kernel support"), + } + } +} + +/// Probe `device` by opening it read-write, as Firecracker will. +#[must_use] +pub fn probe_device(device: &Path) -> KvmStatus { + KvmStatus::from_open( + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(device) + .map(drop) + .map_err(|error| error.kind()), + ) +} + +/// Probe the real `/dev/kvm`. +#[must_use] +pub fn probe() -> KvmStatus { + probe_device(Path::new(KVM_DEVICE)) +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/crates/tinybox-microvm/src/kvm/mod_tests.rs b/crates/tinybox-microvm/src/kvm/mod_tests.rs new file mode 100644 index 00000000..014a7452 --- /dev/null +++ b/crates/tinybox-microvm/src/kvm/mod_tests.rs @@ -0,0 +1,44 @@ +use super::*; + +#[test] +fn open_results_map_to_distinct_statuses() { + assert_eq!(KvmStatus::from_open(Ok(())), KvmStatus::Available); + assert_eq!( + KvmStatus::from_open(Err(ErrorKind::NotFound)), + KvmStatus::Missing + ); + assert_eq!( + KvmStatus::from_open(Err(ErrorKind::PermissionDenied)), + KvmStatus::PermissionDenied + ); + assert_eq!( + KvmStatus::from_open(Err(ErrorKind::Other)), + KvmStatus::Unusable + ); +} + +#[test] +fn only_an_available_device_needs_no_advice() { + assert!(KvmStatus::Available.is_available()); + assert_eq!(KvmStatus::Available.advice(), None); + for status in [ + KvmStatus::Missing, + KvmStatus::PermissionDenied, + KvmStatus::Unusable, + ] { + assert!(!status.is_available()); + assert!(status.advice().is_some()); + } +} + +#[test] +fn a_nonexistent_device_path_is_missing() { + let dir = std::env::temp_dir().join("tinybox-no-such-kvm-device"); + assert_eq!(probe_device(&dir), KvmStatus::Missing); +} + +#[test] +fn a_plain_writable_file_probes_as_available() { + let file = tempfile::NamedTempFile::new().unwrap(); + assert_eq!(probe_device(file.path()), KvmStatus::Available); +} From b9cac1be24f82d6278b00099ef96dbbadb1aced9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:43:11 +0300 Subject: [PATCH 11/22] chore: files changed crates/tinybox-microvm/src/lib.rs Auto-committed-on: dragonfly --- crates/tinybox-microvm/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinybox-microvm/src/lib.rs b/crates/tinybox-microvm/src/lib.rs index e41c891f..234ca12c 100644 --- a/crates/tinybox-microvm/src/lib.rs +++ b/crates/tinybox-microvm/src/lib.rs @@ -49,6 +49,7 @@ //! ``` mod cpio; +pub mod kvm; mod sandbox; pub use sandbox::{GuestImage, MicroVmSandbox, NAME, WORKSPACE_MOUNT}; From ed06962659dc7fa3d3b4d368681f42021fc32a2d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:43:44 +0300 Subject: [PATCH 12/22] feat(bus): add network allowlist policy and sandbox events Bump the contract to 1.19 and add a NetworkPolicy::Allowlist variant that restricts egress to named hosts and ports, with backends that cannot enforce it refusing the request. Also introduce the observational SandboxEvent vocabulary (Started, Denied, LimitHit, Exited) so modules can report sandbox activity. Auto-committed-on: dragonfly --- crates/tinybox-bus/src/event.rs | 80 +++++++++++++++++++ crates/tinybox-bus/src/event_tests.rs | 46 +++++++++++ crates/tinybox-bus/src/lib.rs | 12 ++- crates/tinybox-bus/src/lib_tests.rs | 8 +- .../src/tinybus_module/resources.rs | 8 +- 5 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 crates/tinybox-bus/src/event.rs create mode 100644 crates/tinybox-bus/src/event_tests.rs diff --git a/crates/tinybox-bus/src/event.rs b/crates/tinybox-bus/src/event.rs new file mode 100644 index 00000000..a6fc757c --- /dev/null +++ b/crates/tinybox-bus/src/event.rs @@ -0,0 +1,80 @@ +//! Wire vocabulary for network allowlists and sandbox observations. +//! +//! These mirror the model types in `tinybox-core` so a module and a host can +//! exchange them without sharing a dependency. They carry no policy: a sandbox +//! reports what happened and the caller decides what to do about it. + +use serde::{Deserialize, Serialize}; + +/// Hosts and ports an allowlisted box may reach. +/// +/// Hostnames are resolved outside the box and pinned; metadata endpoints are +/// always refused. See `tinybox_core::EgressAllowlist` for the full rules. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct EgressAllowlist { + /// Host names or IP literals. + pub hosts: Vec, + /// TCP ports. + pub ports: Vec, +} + +/// Which kind of access a sandbox refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DenialKind { + /// Filesystem access outside the granted paths. + Filesystem, + /// A network connection the policy forbids. + Network, + /// Process creation the sandbox forbids. + Subprocess, + /// A system call a filter blocked. + Syscall, +} + +/// Which resource limit a workload reached. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LimitKind { + /// CPU time or share. + Cpu, + /// Resident memory. + Memory, + /// Process or thread count. + Pids, + /// Writable disk. + Disk, + /// Wall-clock timeout. + Timeout, +} + +/// One observation about a running workload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum SandboxEvent { + /// The workload started. + Started, + /// The sandbox refused an access. + Denied { + /// What was refused. + kind: DenialKind, + /// The path, address or syscall, when the OS reports it. + #[serde(default)] + target: Option, + }, + /// The workload reached a resource limit. + LimitHit { + /// The limit reached. + limit: LimitKind, + }, + /// The workload ended. + Exited { + /// Exit status, or `None` when killed by a signal. + #[serde(default)] + status: Option, + }, +} + +#[cfg(test)] +#[path = "event_tests.rs"] +mod tests; diff --git a/crates/tinybox-bus/src/event_tests.rs b/crates/tinybox-bus/src/event_tests.rs new file mode 100644 index 00000000..c40de745 --- /dev/null +++ b/crates/tinybox-bus/src/event_tests.rs @@ -0,0 +1,46 @@ +use super::*; +use crate::NetworkPolicy; + +#[test] +fn events_keep_their_wire_labels() -> Result<(), serde_json::Error> { + let denied = SandboxEvent::Denied { + kind: DenialKind::Network, + target: Some("169.254.169.254:80".into()), + }; + assert_eq!( + serde_json::to_value(&denied)?, + serde_json::json!({"event":"denied","kind":"network","target":"169.254.169.254:80"}) + ); + assert_eq!( + serde_json::to_value(SandboxEvent::LimitHit { + limit: LimitKind::Pids + })?, + serde_json::json!({"event":"limit_hit","limit":"pids"}) + ); + assert_eq!( + serde_json::to_value(SandboxEvent::Started)?, + serde_json::json!({"event":"started"}) + ); + let exited: SandboxEvent = serde_json::from_value(serde_json::json!({"event":"exited"}))?; + assert_eq!(exited, SandboxEvent::Exited { status: None }); + Ok(()) +} + +#[test] +fn an_allowlist_policy_round_trips_and_old_policies_still_parse() -> Result<(), serde_json::Error> { + let policy = NetworkPolicy::Allowlist(EgressAllowlist { + hosts: vec!["example.com".into()], + ports: vec![443], + }); + let value = serde_json::to_value(&policy)?; + assert_eq!( + value, + serde_json::json!({"Allowlist":{"hosts":["example.com"],"ports":[443]}}) + ); + assert_eq!(serde_json::from_value::(value)?, policy); + assert_eq!( + serde_json::from_value::(serde_json::json!("Egress"))?, + NetworkPolicy::Egress + ); + Ok(()) +} diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index 12e6234c..abc17922 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -29,6 +29,9 @@ pub use shell_selection::{SelectShellRequest, ShellFlavor, ShellInvocation}; mod policy; pub use policy::{DockerPolicyReport, DockerPolicyRequest}; +mod event; +pub use event::{DenialKind, EgressAllowlist, LimitKind, SandboxEvent}; + mod shell; pub use shell::{CommandAnalysis, CommandClass, CommandSegment}; mod files; @@ -61,7 +64,9 @@ pub use files::{ /// module for the same verdict the Docker run members enforce without starting /// a container, and `hide_window` on [`ShellExecRequest`] (Windows console /// suppression for a native shell child; ignored elsewhere). -pub const CONTRACT_VERSION: (u32, u32) = (1, 18); +/// 1.19 adds `NetworkPolicy::Allowlist` and the observational +/// [`SandboxEvent`] vocabulary (`Started`, `Denied`, `LimitHit`, `Exited`). +pub const CONTRACT_VERSION: (u32, u32) = (1, 19); /// Whether this host vocabulary can bind to a module's advertised version. /// The major must match and the module's minor must include every host member. @@ -348,13 +353,16 @@ pub struct SshHostConfig { } /// Network access policy applied by a sandbox. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum NetworkPolicy { /// Disable all network access. #[default] Denied, /// Permit outbound connections without publishing inbound ports. Egress, + /// Permit outbound connections only to the named hosts and ports. Added in + /// contract 1.19; a backend that cannot enforce it refuses the request. + Allowlist(EgressAllowlist), /// Permit unrestricted network access. Open, } diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index c3f7b837..a521639f 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -51,7 +51,7 @@ fn docker_run_vocabulary_is_typed_bounded_and_additive() -> Result<(), serde_jso assert_eq!(METHODS[29], "CleanupDocker"); assert_eq!(METHODS[30], "RunDockerWithoutWorkspace"); assert_eq!(METHODS[34], "StartNativeShellExec"); - assert_eq!(CONTRACT_VERSION, (1, 18)); + assert_eq!(CONTRACT_VERSION, (1, 19)); assert_eq!(MAX_DOCKER_RUN_TIMEOUT_MS, 86_400_000); let serialized = serde_json::to_value(&request)?; assert_eq!(serialized["read_only_rootfs"], false); @@ -129,7 +129,7 @@ fn host_gateway_and_docker_facts_are_additive_and_default_safely() -> Result<(), #[test] fn version_and_capability_wire_snapshot_preserve_contract_compatibility() -> Result<(), serde_json::Error> { - assert_eq!(CONTRACT_VERSION, (1, 18)); + assert_eq!(CONTRACT_VERSION, (1, 19)); assert!(is_compatible(CONTRACT_VERSION)); assert!(!is_compatible((1, 8))); assert!(!is_compatible((1, 7))); @@ -147,7 +147,7 @@ fn version_and_capability_wire_snapshot_preserve_contract_compatibility() exec_backends: Vec::new(), spawn_backends: Vec::new(), }; - let snapshot = serde_json::json!({"contract_version":[1,18],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); + let snapshot = serde_json::json!({"contract_version":[1,19],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); assert_eq!(serde_json::to_value(&capabilities)?, snapshot); assert_eq!( serde_json::from_value::(snapshot)?, @@ -166,7 +166,7 @@ fn no_workspace_docker_operation_is_present_in_the_contract() { .count(), 1 ); - assert_eq!(CONTRACT_VERSION, (1, 18)); + assert_eq!(CONTRACT_VERSION, (1, 19)); } #[test] diff --git a/crates/tinybox-module/src/tinybus_module/resources.rs b/crates/tinybox-module/src/tinybus_module/resources.rs index 462a7832..02f3f60e 100644 --- a/crates/tinybox-module/src/tinybus_module/resources.rs +++ b/crates/tinybox-module/src/tinybus_module/resources.rs @@ -1594,9 +1594,15 @@ fn make_spec(request: &CreateRequest, host: &str) -> Result { ); let mut spec = BoxSpec::new(placement, source); spec.env = request.env.clone(); - spec.network = match request.network { + spec.network = match &request.network { tinybox_bus::NetworkPolicy::Denied => tinybox_core::NetworkPolicy::Denied, tinybox_bus::NetworkPolicy::Egress => tinybox_core::NetworkPolicy::Egress, + tinybox_bus::NetworkPolicy::Allowlist(list) => { + tinybox_core::NetworkPolicy::Allowlist(tinybox_core::EgressAllowlist { + hosts: list.hosts.clone(), + ports: list.ports.clone(), + }) + } tinybox_bus::NetworkPolicy::Open => tinybox_core::NetworkPolicy::Open, }; spec.resources = tinybox_core::Resources { From e87c0e889f500cfea2158b657e8a8cb8411b2695 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:45:10 +0300 Subject: [PATCH 13/22] chore: files changed crates/tinybox-core/src/shell/analysis.rs,crates/tinybox-core/src/shell/mod.rs, Auto-committed-on: dragonfly --- crates/tinybox-core/src/shell/analysis.rs | 1 + crates/tinybox-core/src/shell/mod.rs | 6 ++++++ crates/tinybox-module/src/tinybus_module/mod.rs | 1 + 3 files changed, 8 insertions(+) diff --git a/crates/tinybox-core/src/shell/analysis.rs b/crates/tinybox-core/src/shell/analysis.rs index b3a84c5c..316ea465 100644 --- a/crates/tinybox-core/src/shell/analysis.rs +++ b/crates/tinybox-core/src/shell/analysis.rs @@ -1,5 +1,6 @@ //! Generic parser facts; hosts own path authorization and approval policy. +#[allow(deprecated)] use super::{classify, env_guard, executor, scan}; use tinybox_bus::{CommandAnalysis, CommandSegment}; diff --git a/crates/tinybox-core/src/shell/mod.rs b/crates/tinybox-core/src/shell/mod.rs index b1fd6f6a..09ed2b32 100644 --- a/crates/tinybox-core/src/shell/mod.rs +++ b/crates/tinybox-core/src/shell/mod.rs @@ -95,17 +95,23 @@ pub fn script( parts.join(" ") } +#[deprecated(since = "0.1.34", note = "command policy belongs to tinysecurity-policy (tinysecurity command_shell::classify); tinybox only reports shell structure through `analysis`. Removed in the release after 0.1.34.")] pub mod classify; +#[deprecated(since = "0.1.34", note = "command policy belongs to tinysecurity-policy (tinysecurity env guard); tinybox only reports shell structure through `analysis`. Removed in the release after 0.1.34.")] pub mod env_guard; pub mod executor; +#[deprecated(since = "0.1.34", note = "command policy belongs to tinysecurity-policy (tinysecurity command_shell::scan); tinybox only reports shell structure through `analysis`. Removed in the release after 0.1.34.")] pub mod scan; #[cfg(test)] +#[allow(deprecated)] #[path = "classify_tests.rs"] mod classify_test; #[cfg(test)] +#[allow(deprecated)] #[path = "scan_tests.rs"] mod scan_test; #[cfg(test)] +#[allow(deprecated)] #[path = "mod_tests.rs"] mod test; diff --git a/crates/tinybox-module/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index 35c9da00..fd2e7257 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -380,6 +380,7 @@ impl BoxService { } /// Return shell structure facts without applying host security policy. + #[allow(deprecated)] async fn analyze_shell(&self, command: String) -> TinyBusResult { use tinybox_core::shell::{classify, scan}; let stripped = scan::strip_quoted_heredoc_bodies(&command); From 177a4eb4bbe4fbff5bf6ada355216aad92bcbee2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:45:20 +0300 Subject: [PATCH 14/22] fix(shell): allow deprecated items crate-wide in analysis The deprecated allowance was moved from the import statement to a module-level attribute so it also covers the rest of the file, which still relies on deprecated APIs. Auto-committed-on: dragonfly --- crates/tinybox-core/src/shell/analysis.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-core/src/shell/analysis.rs b/crates/tinybox-core/src/shell/analysis.rs index 316ea465..4331854d 100644 --- a/crates/tinybox-core/src/shell/analysis.rs +++ b/crates/tinybox-core/src/shell/analysis.rs @@ -1,6 +1,6 @@ //! Generic parser facts; hosts own path authorization and approval policy. +#![allow(deprecated)] -#[allow(deprecated)] use super::{classify, env_guard, executor, scan}; use tinybox_bus::{CommandAnalysis, CommandSegment}; From 932a31e07f2986d037057d98b854b1115824baf9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:45:28 +0300 Subject: [PATCH 15/22] chore(shell): allow deprecated items in shell modules Add a module-level allow(deprecated) attribute to the shell classification, environment guard, executor, and scan modules so the existing deprecated API usage no longer emits warnings. Auto-committed-on: dragonfly --- crates/tinybox-core/src/shell/classify.rs | 1 + crates/tinybox-core/src/shell/env_guard.rs | 1 + crates/tinybox-core/src/shell/executor.rs | 1 + crates/tinybox-core/src/shell/scan.rs | 1 + 4 files changed, 4 insertions(+) diff --git a/crates/tinybox-core/src/shell/classify.rs b/crates/tinybox-core/src/shell/classify.rs index f71c5433..eb1d4c78 100644 --- a/crates/tinybox-core/src/shell/classify.rs +++ b/crates/tinybox-core/src/shell/classify.rs @@ -1,6 +1,7 @@ //! Base-command classification: the read/write/network/install/destructive //! bucket lists, verb-sensitive VCS / package-manager handling, and the //! structural "hidden execution" guard for the harness-gated command flow. +#![allow(deprecated)] use super::executor::is_command_executor; use super::scan::{contains_unquoted_background_ampersand, strip_quoted_heredoc_bodies}; diff --git a/crates/tinybox-core/src/shell/env_guard.rs b/crates/tinybox-core/src/shell/env_guard.rs index 51930541..26e52854 100644 --- a/crates/tinybox-core/src/shell/env_guard.rs +++ b/crates/tinybox-core/src/shell/env_guard.rs @@ -1,5 +1,6 @@ //! The "dangerous" subset of inline environment-variable assignments that can redirect execution of an //! otherwise-allowed command (pagers, editors, loader overrides, …). +#![allow(deprecated)] /// Environment variable names that can trigger arbitrary command execution /// when supplied as a leading inline assignment on an otherwise-allowed diff --git a/crates/tinybox-core/src/shell/executor.rs b/crates/tinybox-core/src/shell/executor.rs index efa30adb..64b1c29a 100644 --- a/crates/tinybox-core/src/shell/executor.rs +++ b/crates/tinybox-core/src/shell/executor.rs @@ -1,6 +1,7 @@ //! Detecting bases that execute arbitrary code (interpreters, //! shells, `xargs`, Windows `LOLBins`, …). Basename normalisation itself is //! `tinybox_core::shell::scan`. +#![allow(deprecated)] use super::scan::normalized_command_name; diff --git a/crates/tinybox-core/src/shell/scan.rs b/crates/tinybox-core/src/shell/scan.rs index 2b4c6939..ff7b6bf0 100644 --- a/crates/tinybox-core/src/shell/scan.rs +++ b/crates/tinybox-core/src/shell/scan.rs @@ -5,6 +5,7 @@ //! and normalise a command word. They decide nothing about whether a command //! is safe; a host that gates execution builds its policy on top of them. //! Every function is pure and none of them expands or evaluates anything. +#![allow(deprecated)] use std::borrow::Cow; use std::collections::VecDeque; From cb9b5e0298bd6dadf172c3bb5ab2992e49c331e5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:45:46 +0300 Subject: [PATCH 16/22] test: match on create result directly in allowlist refusal tests The unenforceable network allowlist tests across the core, docker, linux and microvm backends now assert against the full create result instead of unwrapping the error first, which keeps the assertion in one place. Doc comments in the linux probe module also wrap AppArmor in backticks for consistency. Auto-committed-on: dragonfly --- crates/tinybox-core/src/passthrough/mod_tests.rs | 11 ++++------- crates/tinybox-docker/src/sandbox/mod_tests.rs | 11 ++++------- crates/tinybox-linux/src/probe/mod.rs | 4 ++-- crates/tinybox-linux/src/sandbox/mod_tests.rs | 11 ++++------- crates/tinybox-microvm/src/sandbox/mod_tests.rs | 11 ++++------- 5 files changed, 18 insertions(+), 30 deletions(-) diff --git a/crates/tinybox-core/src/passthrough/mod_tests.rs b/crates/tinybox-core/src/passthrough/mod_tests.rs index b7f39303..0fadc105 100644 --- a/crates/tinybox-core/src/passthrough/mod_tests.rs +++ b/crates/tinybox-core/src/passthrough/mod_tests.rs @@ -503,17 +503,14 @@ async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<( let (sandbox, _host) = sandbox(); let policy = crate::NetworkPolicy::Allowlist(crate::EgressAllowlist::new(["example.com"], [443])); - let error = sandbox - .create(&spec()?.with_network(policy)) - .await - .unwrap_err(); + let result = sandbox.create(&spec()?.with_network(policy)).await; assert!(matches!( - error, - Error::ConstraintNotEnforced { + result, + Err(Error::ConstraintNotEnforced { constraint: crate::Constraint::Network, enforcement: crate::Enforcement::Unsupported, .. - } + }) )); Ok(()) } diff --git a/crates/tinybox-docker/src/sandbox/mod_tests.rs b/crates/tinybox-docker/src/sandbox/mod_tests.rs index 00bc8d69..db6bd9fc 100644 --- a/crates/tinybox-docker/src/sandbox/mod_tests.rs +++ b/crates/tinybox-docker/src/sandbox/mod_tests.rs @@ -1668,17 +1668,14 @@ async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<( ["example.com"], [443], )); - let error = sandbox - .create(&spec()?.with_network(policy)) - .await - .unwrap_err(); + let result = sandbox.create(&spec()?.with_network(policy)).await; assert!(matches!( - error, - Error::ConstraintNotEnforced { + result, + Err(Error::ConstraintNotEnforced { constraint: tinybox_core::Constraint::Network, enforcement: tinybox_core::Enforcement::Unsupported, .. - } + }) )); Ok(()) } diff --git a/crates/tinybox-linux/src/probe/mod.rs b/crates/tinybox-linux/src/probe/mod.rs index 0834eb8f..9cc403c8 100644 --- a/crates/tinybox-linux/src/probe/mod.rs +++ b/crates/tinybox-linux/src/probe/mod.rs @@ -19,14 +19,14 @@ pub enum UsernsStatus { /// `kernel.unprivileged_userns_clone` is zero. DisabledBySysctl, /// `kernel.apparmor_restrict_unprivileged_userns` is `1` (Ubuntu 24.04), - /// so only binaries with an AppArmor profile granting `userns` may create + /// so only binaries with an `AppArmor` profile granting `userns` may create /// one. RestrictedByAppArmor, } impl UsernsStatus { /// Derive the status from raw sysctl file contents (`None` when the file - /// does not exist). Disabling wins over AppArmor restriction because it is + /// does not exist). Disabling wins over `AppArmor` restriction because it is /// the harder block. #[must_use] pub fn from_sysctls( diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index c91a30c6..4042b149 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -549,17 +549,14 @@ async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<( ["example.com"], [443], )); - let error = sandbox - .create(&spec()?.with_network(policy)) - .await - .unwrap_err(); + let result = sandbox.create(&spec()?.with_network(policy)).await; assert!(matches!( - error, - Error::ConstraintNotEnforced { + result, + Err(Error::ConstraintNotEnforced { constraint: tinybox_core::Constraint::Network, enforcement: tinybox_core::Enforcement::Unsupported, .. - } + }) )); Ok(()) } diff --git a/crates/tinybox-microvm/src/sandbox/mod_tests.rs b/crates/tinybox-microvm/src/sandbox/mod_tests.rs index d203a26b..c5c9d7d6 100644 --- a/crates/tinybox-microvm/src/sandbox/mod_tests.rs +++ b/crates/tinybox-microvm/src/sandbox/mod_tests.rs @@ -665,17 +665,14 @@ async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<( ["example.com"], [443], )); - let error = sandbox - .create(&spec()?.with_network(policy)) - .await - .unwrap_err(); + let result = sandbox.create(&spec()?.with_network(policy)).await; assert!(matches!( - error, - Error::ConstraintNotEnforced { + result, + Err(Error::ConstraintNotEnforced { constraint: tinybox_core::Constraint::Network, enforcement: tinybox_core::Enforcement::Unsupported, .. - } + }) )); Ok(()) } From 0f22a006b4868568deb88c7f034a02df55aa5841 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:45:58 +0300 Subject: [PATCH 17/22] test: replace unwrap calls with fallible test returns Tests now use unwrap_or_default for optional problem messages and return Result from fallible tests instead of unwrapping, so a failure surfaces as a test error rather than a panic. Auto-committed-on: dragonfly --- crates/tinybox-linux/src/probe/mod_tests.rs | 27 +++++++++++++++------ crates/tinybox-microvm/src/kvm/mod_tests.rs | 5 ++-- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/crates/tinybox-linux/src/probe/mod_tests.rs b/crates/tinybox-linux/src/probe/mod_tests.rs index 5f9c64e5..c17a7aff 100644 --- a/crates/tinybox-linux/src/probe/mod_tests.rs +++ b/crates/tinybox-linux/src/probe/mod_tests.rs @@ -47,7 +47,7 @@ fn a_missing_bwrap_is_reported_before_the_namespace_state() { userns: UsernsStatus::RestrictedByAppArmor, }; assert!(!probe.is_usable()); - assert!(probe.problem().unwrap().contains("bubblewrap")); + assert!(probe.problem().unwrap_or_default().contains("bubblewrap")); } #[test] @@ -62,31 +62,42 @@ fn each_blocked_state_gives_an_actionable_message() { bwrap: bwrap.clone(), userns: UsernsStatus::DisabledBySysctl, }; - assert!(disabled.problem().unwrap().contains("max_user_namespaces")); + assert!( + disabled + .problem() + .unwrap_or_default() + .contains("max_user_namespaces") + ); let restricted = NamespaceProbe { bwrap, userns: UsernsStatus::RestrictedByAppArmor, }; - assert!(restricted.problem().unwrap().contains("AppArmor")); + assert!( + restricted + .problem() + .unwrap_or_default() + .contains("AppArmor") + ); } #[cfg(unix)] #[test] -fn path_search_finds_only_executable_files() { +fn path_search_finds_only_executable_files() -> Result<(), Box> { use std::os::unix::fs::PermissionsExt as _; - let dir = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir()?; let plain = dir.path().join("bwrap"); - std::fs::write(&plain, "").unwrap(); - let path = std::env::join_paths([dir.path()]).unwrap(); + std::fs::write(&plain, "")?; + let path = std::env::join_paths([dir.path()])?; assert_eq!( find_on_path("bwrap", &path), None, "a non-executable file is not found" ); - std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o755))?; assert_eq!(find_on_path("bwrap", &path), Some(plain)); assert_eq!(find_on_path("absent", &path), None); + Ok(()) } #[test] diff --git a/crates/tinybox-microvm/src/kvm/mod_tests.rs b/crates/tinybox-microvm/src/kvm/mod_tests.rs index 014a7452..931d177b 100644 --- a/crates/tinybox-microvm/src/kvm/mod_tests.rs +++ b/crates/tinybox-microvm/src/kvm/mod_tests.rs @@ -38,7 +38,8 @@ fn a_nonexistent_device_path_is_missing() { } #[test] -fn a_plain_writable_file_probes_as_available() { - let file = tempfile::NamedTempFile::new().unwrap(); +fn a_plain_writable_file_probes_as_available() -> std::io::Result<()> { + let file = tempfile::NamedTempFile::new()?; assert_eq!(probe_device(file.path()), KvmStatus::Available); + Ok(()) } From 195159e6695541aa3492ee90153b257e574f10e8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:46:28 +0300 Subject: [PATCH 18/22] test(spec): match refusal result directly in allowlist test The allowlist refusal test now matches on the returned Result instead of unwrapping the error first, so the assertion covers the Ok case as well and no longer panics on unexpected success. Auto-committed-on: dragonfly --- crates/tinybox-core/src/spec/mod_tests.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/tinybox-core/src/spec/mod_tests.rs b/crates/tinybox-core/src/spec/mod_tests.rs index e5800ae4..0aecd9f9 100644 --- a/crates/tinybox-core/src/spec/mod_tests.rs +++ b/crates/tinybox-core/src/spec/mod_tests.rs @@ -260,14 +260,14 @@ fn spec_validate_rejects_a_bad_allowlist() -> Result<()> { fn refuse_allowlist_is_a_typed_network_refusal() { use super::EgressAllowlist; let policy = NetworkPolicy::Allowlist(EgressAllowlist::new(["example.com"], [443])); - let error = policy.refuse_allowlist("docker").unwrap_err(); + let result = policy.refuse_allowlist("docker"); assert!(matches!( - error, - crate::Error::ConstraintNotEnforced { + result, + Err(crate::Error::ConstraintNotEnforced { constraint: crate::Constraint::Network, enforcement: crate::Enforcement::Unsupported, .. - } + }) )); assert!(NetworkPolicy::Egress.refuse_allowlist("docker").is_ok()); } From ec97cd65f7fa7b4bc1ce0c6d390f69f89c51b715 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:47:10 +0300 Subject: [PATCH 19/22] docs: document tinybox sandbox-only scope and contract 1.19 Adds a scope section to AGENTS.md and README.md stating that tinybox only enforces confinement and reports events, leaving policy, approval, audit and command classification to tinysecurity, and noting the deprecation of the shell classification helpers. The TinyBus module README now advertises contract version 1.19 and describes the Allowlist and SandboxEvent additions. Auto-committed-on: dragonfly --- AGENTS.md | 9 ++++ README.md | 7 +++ .../src/tinybus_module/README.md | 5 +- docs/specs/contract-1-19.md | 46 +++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 docs/specs/contract-1-19.md diff --git a/AGENTS.md b/AGENTS.md index 1b8bd29b..22cdd405 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,15 @@ docs/ └── adr/ # immutable architecture decision records ``` +## Scope: tinybox only sandboxes + +tinybox accepts a plan, enforces it, and reports events. It never decides what +is allowed: approval, audit decisions, redaction and command classification +belong to `tinysecurity`. A backend that cannot enforce a requested constraint +refuses with `Unsupported`/`ConstraintNotEnforced` or reports `BestEffort`; it +never degrades silently. `tinybox_core::shell::{classify, scan, env_guard}` is +deprecated for that reason and is removed after 0.1.34. + Every crate the spec names now exists. Do not scaffold an empty one; that is a placeholder. diff --git a/README.md b/README.md index bd477da6..74db582f 100644 --- a/README.md +++ b/README.md @@ -397,3 +397,10 @@ TinyBus itself is not shipped here; the pinned submodule is the build-time SDK. ## License GPL-3.0-only. See [LICENSE](LICENSE). + +## Scope + +tinybox is sandbox-only. It enforces the confinement a caller asks for (filesystem, +network, processes, resources), reports observational events, and refuses what it +cannot enforce. It owns no policy, approval, audit or command-classification logic; +that is `tinysecurity`. See `docs/specs/contract-1-19.md`. diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index 0350e18d..517a41d1 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -50,7 +50,7 @@ The interface is `ai.tinyhumans.tinybox.Box` at | StartNativeShellExec | ShellExecRequest | ProcessRef | | ValidateDockerPolicy | DockerPolicyRequest | DockerPolicyReport | -Capabilities advertises contract version 1.18; 1.0 denotes the original +Capabilities advertises contract version 1.19; 1.0 denotes the original discovery-only surface. Hosts require equal majors and a module minor at least as new as their vocabulary, using `tinybox_bus::is_compatible`. Version 1.1 added reserved resource/process ownership and terminal shutdown; 1.2 adds host @@ -377,3 +377,6 @@ owned timeout/cancellation cleanup. `ReadOutput` retains scratch until requests refuse unavailable enforcement; remote/Docker providers refuse this operation. State directory authorization and Windows private parent ACLs belong to the caller. See [the native shell contract](../../../docs/specs/native-shell-execution.md). + +Version 1.19 adds `NetworkPolicy::Allowlist` and the `SandboxEvent` vocabulary +without a new member; see `docs/specs/contract-1-19.md`. diff --git a/docs/specs/contract-1-19.md b/docs/specs/contract-1-19.md new file mode 100644 index 00000000..22cb202c --- /dev/null +++ b/docs/specs/contract-1-19.md @@ -0,0 +1,46 @@ +# Contract 1.19: network allowlists and sandbox events + +Part of tinyhumansai/openhuman#7328 (tinybox#27). + +Contract 1.19 is a vocabulary bump. It appends no bus member, so `METHODS` is +unchanged. A host that sends the new vocabulary requires a module minor of at +least 19, which `tinybox_bus::is_compatible` already enforces. + +## Added + +- `NetworkPolicy::Allowlist(EgressAllowlist { hosts, ports })`. The enum is no + longer `Copy`. +- `SandboxEvent { Started, Denied { kind, target }, LimitHit { limit }, + Exited { status } }`, with `DenialKind` and `LimitKind`. Events are purely + observational; the sandbox never decides what is allowed. + +## Allowlist semantics + +- Names are resolved outside the box, once, and pinned. The box gets those + addresses (or a proxy that connects only to them) and no resolver of its own, + so a later DNS answer cannot widen the list. +- Cloud metadata endpoints (`169.254.169.254`, `169.254.170.2`, + `100.100.100.200`, `fd00:ec2::254`, `metadata.google.internal`) are refused + when the spec is validated, and a backend must also block them when a name + resolves to one. +- A backend that cannot enforce the list refuses the box with + `ConstraintNotEnforced { constraint: Network, enforcement: Unsupported }`. + It never widens the list to `Egress`. A backend that enforces only part of it + (for example a Seatbelt profile without host matching, or a proxy fallback) + must report the network constraint `BestEffort`, never `Enforced`. +- `NetworkPolicy::Open` shares the host network and is the highest-risk setting + (`NetworkPolicy::is_highest_risk`). + +## Status per backend + +No shipped backend enforces `Allowlist` yet: `passthrough`, `docker`, +`namespace` and `microvm` all refuse it. Enforcement (netns + nftables or an +egress proxy, Seatbelt or proxy, AppContainer or WFP) is tracked under +tinybox#27 phase 3. + +## Deprecated + +`tinybox_core::shell::{classify, scan, env_guard}` is command policy and now +lives in `tinysecurity-policy`. It is deprecated in 0.1.34 and removed in the +release after. `shell::analysis` (structure facts for the `AnalyzeShell` and +`AnalyzeCommand` members) remains. From 1d594a94bd10434fdb8a8c20f1e3c626a885c9b6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:47:41 +0300 Subject: [PATCH 20/22] style: format long lines and reorder exports Reformat the deprecated attribute notes and re-export lists to satisfy rustfmt line width limits, and sort the event re-export alphabetically among its neighbours. No behaviour changes. Auto-committed-on: dragonfly --- crates/tinybox-core/src/lib.rs | 5 +++-- crates/tinybox-core/src/shell/mod.rs | 15 ++++++++++++--- crates/tinybox-core/src/spec/mod.rs | 4 +++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/tinybox-core/src/lib.rs b/crates/tinybox-core/src/lib.rs index 300b2d4b..de0a0f2a 100644 --- a/crates/tinybox-core/src/lib.rs +++ b/crates/tinybox-core/src/lib.rs @@ -77,10 +77,10 @@ pub mod template; pub mod constraint; pub use constraint::{Constraint, ConstraintSupport, Enforcement, PlanCheck}; -pub use event::{CollectingSink, DenialKind, EventSink, LimitKind, SandboxEvent}; pub use capability::{Capability, IsolationLevel, SandboxCapabilities, SnapshotSupport}; pub use clock::{Clock, SystemClock}; pub use error::{Error, Result}; +pub use event::{CollectingSink, DenialKind, EventSink, LimitKind, SandboxEvent}; pub use identity::{BoxId, HostRef, ProcessId, SandboxRef, SnapshotId, TemplateName}; pub use passthrough::PassthroughSandbox; pub use runtime::{ @@ -88,7 +88,8 @@ pub use runtime::{ OutputStream, Sandbox, WorkspaceFileReader, WorkspaceFileWriter, }; pub use spec::{ - BoxSpec, EgressAllowlist, Lifecycle, NetworkPolicy, Placement, PortMapping, Resources, WorkspaceSource, + BoxSpec, EgressAllowlist, Lifecycle, NetworkPolicy, Placement, PortMapping, Resources, + WorkspaceSource, }; pub use store::{MemoryStore, Store, insert_new}; pub use template::{MemoryTemplates, Templates}; diff --git a/crates/tinybox-core/src/shell/mod.rs b/crates/tinybox-core/src/shell/mod.rs index 09ed2b32..3c75b693 100644 --- a/crates/tinybox-core/src/shell/mod.rs +++ b/crates/tinybox-core/src/shell/mod.rs @@ -95,12 +95,21 @@ pub fn script( parts.join(" ") } -#[deprecated(since = "0.1.34", note = "command policy belongs to tinysecurity-policy (tinysecurity command_shell::classify); tinybox only reports shell structure through `analysis`. Removed in the release after 0.1.34.")] +#[deprecated( + since = "0.1.34", + note = "command policy belongs to tinysecurity-policy (tinysecurity command_shell::classify); tinybox only reports shell structure through `analysis`. Removed in the release after 0.1.34." +)] pub mod classify; -#[deprecated(since = "0.1.34", note = "command policy belongs to tinysecurity-policy (tinysecurity env guard); tinybox only reports shell structure through `analysis`. Removed in the release after 0.1.34.")] +#[deprecated( + since = "0.1.34", + note = "command policy belongs to tinysecurity-policy (tinysecurity env guard); tinybox only reports shell structure through `analysis`. Removed in the release after 0.1.34." +)] pub mod env_guard; pub mod executor; -#[deprecated(since = "0.1.34", note = "command policy belongs to tinysecurity-policy (tinysecurity command_shell::scan); tinybox only reports shell structure through `analysis`. Removed in the release after 0.1.34.")] +#[deprecated( + since = "0.1.34", + note = "command policy belongs to tinysecurity-policy (tinysecurity command_shell::scan); tinybox only reports shell structure through `analysis`. Removed in the release after 0.1.34." +)] pub mod scan; #[cfg(test)] diff --git a/crates/tinybox-core/src/spec/mod.rs b/crates/tinybox-core/src/spec/mod.rs index c39fe78e..5404546d 100644 --- a/crates/tinybox-core/src/spec/mod.rs +++ b/crates/tinybox-core/src/spec/mod.rs @@ -44,7 +44,9 @@ use serde::{Deserialize, Serialize}; mod types; -pub use types::{EgressAllowlist, Lifecycle, NetworkPolicy, Placement, PortMapping, Resources, WorkspaceSource}; +pub use types::{ + EgressAllowlist, Lifecycle, NetworkPolicy, Placement, PortMapping, Resources, WorkspaceSource, +}; /// The complete description of a box, before any sandbox acts on it. /// From 91e1bd6a4188869d7a8f2e09e4207d8a7d45f21d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 17:08:31 +0300 Subject: [PATCH 21/22] docs(kvm): fix stale intra-doc link to probe The module doc referenced `KvmProbe`, which no longer exists, so the link rendered as plain text. Point it at the `probe` item that actually performs the check. Auto-committed-on: dragonfly --- crates/tinybox-microvm/src/kvm/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-microvm/src/kvm/mod.rs b/crates/tinybox-microvm/src/kvm/mod.rs index f2728505..de44b4cb 100644 --- a/crates/tinybox-microvm/src/kvm/mod.rs +++ b/crates/tinybox-microvm/src/kvm/mod.rs @@ -1,7 +1,7 @@ //! Discoverability for the hardware prerequisite of the microVM backend. //! //! Firecracker needs `/dev/kvm` opened read-write. A missing device and a -//! device the caller may not open fail the same way at boot, so [`KvmProbe`] +//! device the caller may not open fail the same way at boot, so [`probe`] //! checks first and says which it is and what to do about it. use std::io::ErrorKind; From 7f0571a388bb07969fefddb65da855fad1a4ef15 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 17:09:12 +0300 Subject: [PATCH 22/22] chore(deps): bump rustls from 0.23.45 to 0.23.46 Update the locked rustls version to the latest patch release and refresh its checksum accordingly. Auto-committed-on: dragonfly --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e069a36e..b3c7b583 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -979,9 +979,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell",