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/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", 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-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-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/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..c980e547 --- /dev/null +++ b/crates/tinybox-core/src/event/mod_tests.rs @@ -0,0 +1,24 @@ +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) }); +} diff --git a/crates/tinybox-core/src/lib.rs b/crates/tinybox-core/src/lib.rs index 5cf8f3d0..de0a0f2a 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; @@ -79,6 +80,7 @@ pub use constraint::{Constraint, ConstraintSupport, Enforcement, PlanCheck}; 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::{ @@ -86,7 +88,8 @@ 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/passthrough/mod_tests.rs b/crates/tinybox-core/src/passthrough/mod_tests.rs index 24f5fed6..0fadc105 100644 --- a/crates/tinybox-core/src/passthrough/mod_tests.rs +++ b/crates/tinybox-core/src/passthrough/mod_tests.rs @@ -497,3 +497,20 @@ 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 = + crate::NetworkPolicy::Allowlist(crate::EgressAllowlist::new(["example.com"], [443])); + let result = sandbox.create(&spec()?.with_network(policy)).await; + assert!(matches!( + result, + Err(Error::ConstraintNotEnforced { + constraint: crate::Constraint::Network, + enforcement: crate::Enforcement::Unsupported, + .. + }) + )); + Ok(()) +} diff --git a/crates/tinybox-core/src/shell/analysis.rs b/crates/tinybox-core/src/shell/analysis.rs index b3a84c5c..4331854d 100644 --- a/crates/tinybox-core/src/shell/analysis.rs +++ b/crates/tinybox-core/src/shell/analysis.rs @@ -1,4 +1,5 @@ //! 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/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/mod.rs b/crates/tinybox-core/src/shell/mod.rs index b1fd6f6a..3c75b693 100644 --- a/crates/tinybox-core/src/shell/mod.rs +++ b/crates/tinybox-core/src/shell/mod.rs @@ -95,17 +95,32 @@ 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-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; diff --git a/crates/tinybox-core/src/spec/mod.rs b/crates/tinybox-core/src/spec/mod.rs index 37431159..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::{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 +140,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 +170,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/mod_tests.rs b/crates/tinybox-core/src/spec/mod_tests.rs index 7b7250dc..0aecd9f9 100644 --- a/crates/tinybox-core/src/spec/mod_tests.rs +++ b/crates/tinybox-core/src/spec/mod_tests.rs @@ -200,3 +200,74 @@ 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() -> Result<()> { + use super::EgressAllowlist; + let spec = spec()?.with_network(NetworkPolicy::Allowlist(EgressAllowlist::default())); + assert!(spec.validate().is_err()); + Ok(()) +} + +#[test] +fn refuse_allowlist_is_a_typed_network_refusal() { + use super::EgressAllowlist; + let policy = NetworkPolicy::Allowlist(EgressAllowlist::new(["example.com"], [443])); + let result = policy.refuse_allowlist("docker"); + assert!(matches!( + result, + Err(crate::Error::ConstraintNotEnforced { + constraint: crate::Constraint::Network, + enforcement: crate::Enforcement::Unsupported, + .. + }) + )); + assert!(NetworkPolicy::Egress.refuse_allowlist("docker").is_ok()); +} 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/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 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-docker/src/sandbox/mod_tests.rs b/crates/tinybox-docker/src/sandbox/mod_tests.rs index f23671cd..db6bd9fc 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 result = sandbox.create(&spec()?.with_network(policy)).await; + assert!(matches!( + result, + Err(Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + }) + )); + Ok(()) +} 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..9cc403c8 --- /dev/null +++ b/crates/tinybox-linux/src/probe/mod.rs @@ -0,0 +1,127 @@ +//! 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)) +} + +#[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 new file mode 100644 index 00000000..c17a7aff --- /dev/null +++ b/crates/tinybox-linux/src/probe/mod_tests.rs @@ -0,0 +1,107 @@ +use super::*; + +#[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_or_default().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_or_default() + .contains("max_user_namespaces") + ); + let restricted = NamespaceProbe { + bwrap, + userns: UsernsStatus::RestrictedByAppArmor, + }; + assert!( + restricted + .problem() + .unwrap_or_default() + .contains("AppArmor") + ); +} + +#[cfg(unix)] +#[test] +fn path_search_finds_only_executable_files() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempfile::tempdir()?; + let plain = dir.path().join("bwrap"); + 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))?; + assert_eq!(find_on_path("bwrap", &path), Some(plain)); + assert_eq!(find_on_path("absent", &path), None); + Ok(()) +} + +#[test] +fn detect_runs_on_this_machine_without_panicking() { + let probe = NamespaceProbe::detect(); + assert_eq!(probe.is_usable(), probe.problem().is_none()); +} 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-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index 7dee3dd8..4042b149 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 result = sandbox.create(&spec()?.with_network(policy)).await; + assert!(matches!( + result, + Err(Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + }) + )); + Ok(()) +} diff --git a/crates/tinybox-microvm/src/kvm/mod.rs b/crates/tinybox-microvm/src/kvm/mod.rs new file mode 100644 index 00000000..de44b4cb --- /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 [`probe`] +//! 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..931d177b --- /dev/null +++ b/crates/tinybox-microvm/src/kvm/mod_tests.rs @@ -0,0 +1,45 @@ +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() -> std::io::Result<()> { + let file = tempfile::NamedTempFile::new()?; + assert_eq!(probe_device(file.path()), KvmStatus::Available); + Ok(()) +} 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}; 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 diff --git a/crates/tinybox-microvm/src/sandbox/mod_tests.rs b/crates/tinybox-microvm/src/sandbox/mod_tests.rs index 592bacb6..c5c9d7d6 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 result = sandbox.create(&spec()?.with_network(policy)).await; + assert!(matches!( + result, + Err(Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + }) + )); + Ok(()) +} 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/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); 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 { 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.