From b81dc1a7a1f76e8de65522e18de8bb62ba1c0617 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:39:23 +0300 Subject: [PATCH 1/3] test(sandbox): plan matrix pins fail-closed untrusted tiers, SaaS isolation, host-network approval and credential floor Part of tinyhumansai/openhuman#7328 (phase 5). Documents that Firejail and Bubblewrap map to the namespace backend. Co-authored-by: Medulla --- crates/tinysecurity-bus/src/future.rs | 6 +- crates/tinysecurity-sandbox/src/engine.rs | 3 + .../src/plan_matrix_tests.rs | 181 ++++++++++++++++++ 3 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 crates/tinysecurity-sandbox/src/plan_matrix_tests.rs diff --git a/crates/tinysecurity-bus/src/future.rs b/crates/tinysecurity-bus/src/future.rs index 5ae0a18..6de58fa 100644 --- a/crates/tinysecurity-bus/src/future.rs +++ b/crates/tinysecurity-bus/src/future.rs @@ -60,11 +60,13 @@ pub enum SourceTrust { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SandboxBackend { - /// Namespace state. + /// Kernel namespace isolation. Legacy `firejail` and `bubblewrap` host + /// settings map here; the module never plans them as separate backends. Namespace, /// `MicroVm` state. MicroVm, - /// Jail state. + /// Path-jail executor. It is planned only when it reports enforced kernel + /// isolation, so an untrusted tier never receives a best-effort jail. Jail, /// Docker state. Docker, diff --git a/crates/tinysecurity-sandbox/src/engine.rs b/crates/tinysecurity-sandbox/src/engine.rs index bd4ccae..a02df6f 100644 --- a/crates/tinysecurity-sandbox/src/engine.rs +++ b/crates/tinysecurity-sandbox/src/engine.rs @@ -538,3 +538,6 @@ pub(crate) fn denied(generation: u64, reason: SandboxDenial2) -> SandboxPlanResp #[cfg(test)] #[path = "engine_tests.rs"] pub(crate) mod tests; +#[cfg(test)] +#[path = "plan_matrix_tests.rs"] +mod plan_matrix_tests; diff --git a/crates/tinysecurity-sandbox/src/plan_matrix_tests.rs b/crates/tinysecurity-sandbox/src/plan_matrix_tests.rs new file mode 100644 index 0000000..c22d604 --- /dev/null +++ b/crates/tinysecurity-sandbox/src/plan_matrix_tests.rs @@ -0,0 +1,181 @@ +//! Exhaustive plan matrix: trust x deployment x switches x backend x isolation. +use super::*; +use crate::engine::tests::fixture; +use tinysecurity_bus::{PathAccess, PathGrant, PathTrustedRoot}; + +type Res = std::result::Result<(), Box>; + +fn plan( + scope: SandboxScope2, + request: &SandboxPlanRequest2, +) -> std::result::Result> { + Ok(Engine::new(SandboxConfig2 { + scopes: vec![scope], + })? + .plan(request, 1, 100)?) +} + +const TRUSTS: [SourceTrust; 4] = [ + SourceTrust::TrustedHost, + SourceTrust::UntrustedMcp, + SourceTrust::UntrustedSkill, + SourceTrust::Downloaded, +]; +const SETTINGS: [SandboxSetting2; 2] = [SandboxSetting2::Enabled, SandboxSetting2::Disabled]; +const BACKENDS: [SandboxBackend; 5] = [ + SandboxBackend::Noop, + SandboxBackend::Namespace, + SandboxBackend::MicroVm, + SandboxBackend::Jail, + SandboxBackend::Docker, +]; +const LEVELS: [IsolationLevel2; 4] = [ + IsolationLevel2::None, + IsolationLevel2::Process, + IsolationLevel2::Kernel, + IsolationLevel2::MicroVm, +]; + +#[test] +fn plan_matrix_fails_closed_for_every_trust_deployment_switch_and_backend() -> Res { + let (_dir, base, request) = fixture()?; + let mut allowed = 0_u32; + for trust in TRUSTS { + for saas in [true, false] { + for (environment, agent_mode, configured) in SETTINGS.iter().flat_map(|e| { + SETTINGS + .iter() + .flat_map(move |a| SETTINGS.iter().map(move |c| (*e, *a, *c))) + }) { + for backend in BACKENDS { + for isolation in LEVELS { + let mut scope = base.clone(); + scope.deployment = if saas { + SandboxDeployment2::Saas + } else { + SandboxDeployment2::Local + }; + scope.origin = if saas { Origin::Saas } else { Origin::Operator }; + scope.sources[0].trust = trust.clone(); + scope.environment = environment; + scope.agent_mode = agent_mode; + scope.configured = configured; + scope.executors[0].backend = backend.clone(); + scope.executors[0].isolation = isolation; + let mandatory = saas || trust != SourceTrust::TrustedHost; + let requested = environment != SandboxSetting2::Disabled + && (agent_mode == SandboxSetting2::Enabled + || configured == SandboxSetting2::Enabled); + let isolated = mandatory || requested; + let kernel = matches!( + isolation, + IsolationLevel2::Kernel | IsolationLevel2::MicroVm + ); + let expect_allow = backend != SandboxBackend::Noop && (!isolated || kernel); + let response = plan(scope, &request)?; + let label = format!( + "{trust:?} saas={saas} env={environment:?} agent={agent_mode:?} cfg={configured:?} {backend:?} {isolation:?}" + ); + if expect_allow { + allowed += 1; + let artifact = response.artifact.ok_or(label.clone())?; + assert_eq!(response.decision.verdict, Verdict::Allow, "{label}"); + assert_ne!(artifact.backend, SandboxBackend::Noop, "{label}"); + assert_eq!( + artifact.minimum_isolation != IsolationLevel2::None, + isolated, + "{label}" + ); + } else { + assert!(response.artifact.is_none(), "{label}"); + assert_eq!( + response.denial, + Some(SandboxDenial2::Unsupported), + "{label}" + ); + } + } + } + } + } + } + assert!(allowed > 0, "matrix must exercise allowed plans"); + Ok(()) +} + +#[test] +fn saas_ignores_off_switches_for_every_trust() -> Res { + let (_dir, base, request) = fixture()?; + for trust in TRUSTS { + let mut scope = base.clone(); + scope.sources[0].trust = trust; + scope.environment = SandboxSetting2::Disabled; + scope.agent_mode = SandboxSetting2::Disabled; + scope.configured = SandboxSetting2::Disabled; + scope.executors[0].isolation = IsolationLevel2::Process; + assert_eq!( + plan(scope.clone(), &request)?.denial, + Some(SandboxDenial2::Unsupported) + ); + scope.executors[0].isolation = IsolationLevel2::Kernel; + let artifact = plan(scope, &request)?.artifact.ok_or("allowed")?; + assert_eq!(artifact.minimum_isolation, IsolationLevel2::Kernel); + } + Ok(()) +} + +#[test] +fn host_network_requires_approval_for_every_trust_and_deployment() -> Res { + let (_dir, base, mut request) = fixture()?; + request.network = NetworkPolicy::Host; + for trust in TRUSTS { + for saas in [true, false] { + let mut scope = base.clone(); + scope.network_ceiling = NetworkPolicy::Host; + scope.sources[0].trust = trust.clone(); + if !saas { + scope.deployment = SandboxDeployment2::Local; + scope.origin = Origin::Operator; + } + let response = plan(scope, &request)?; + assert_eq!(response.denial, Some(SandboxDenial2::Approval)); + assert!(response.artifact.is_none()); + } + } + Ok(()) +} + +#[test] +fn credential_store_floor_is_excluded_from_every_grant_and_trust() -> Res { + let (_dir, base, mut request) = fixture()?; + let home = base.paths.home_dir.clone().ok_or("home")?; + for name in [".ssh", ".aws", ".gnupg"] { + let dir = home.join(name); + std::fs::create_dir_all(&dir)?; + for trust in TRUSTS { + for write in [false, true] { + for raw in [dir.clone(), dir.join("key")] { + let mut scope = base.clone(); + scope.sources[0].trust = trust.clone(); + // A ceiling that names the credential store cannot widen the floor. + scope.grant_ceiling.push(PathGrant { + path: dir.to_str().ok_or("utf8")?.into(), + write: true, + }); + scope.paths.trusted_roots.push(PathTrustedRoot { + path: dir.clone(), + access: PathAccess::ReadWrite, + }); + request.grants = vec![PathGrant { + path: raw.to_str().ok_or("utf8")?.into(), + write, + }]; + let response = plan(scope, &request)?; + assert_eq!(response.denial, Some(SandboxDenial2::Floor), "{raw:?}"); + assert!(response.artifact.is_none()); + } + } + } + } + Ok(()) +} From 50619267df4ddfad07ac5adafdd4019cfcdffb92 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 16:46:31 +0300 Subject: [PATCH 2/3] chore: rustfmt module order and bump rustls for RUSTSEC-2026-0346..0349 Co-authored-by: Medulla --- Cargo.lock | 8 ++++---- crates/tinysecurity-sandbox/src/engine.rs | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8ef09d9..f34ac86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1262,9 +1262,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", @@ -1505,7 +1505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -2227,7 +2227,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/tinysecurity-sandbox/src/engine.rs b/crates/tinysecurity-sandbox/src/engine.rs index a02df6f..a8f608b 100644 --- a/crates/tinysecurity-sandbox/src/engine.rs +++ b/crates/tinysecurity-sandbox/src/engine.rs @@ -536,8 +536,8 @@ pub(crate) fn denied(generation: u64, reason: SandboxDenial2) -> SandboxPlanResp } } #[cfg(test)] -#[path = "engine_tests.rs"] -pub(crate) mod tests; -#[cfg(test)] #[path = "plan_matrix_tests.rs"] mod plan_matrix_tests; +#[cfg(test)] +#[path = "engine_tests.rs"] +pub(crate) mod tests; From a2986edf8dd66287834e727f3a74232abf1b209e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 17:27:44 +0300 Subject: [PATCH 3/3] refactor(sandbox): one suitable_for_untrusted predicate for select and validate Gives the planner the issue's is_suitable_for_untrusted_code check in one place so Noop and non-kernel executors can never back an untrusted or SaaS plan. Co-authored-by: Medulla --- crates/tinysecurity-sandbox/src/engine.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/crates/tinysecurity-sandbox/src/engine.rs b/crates/tinysecurity-sandbox/src/engine.rs index a8f608b..0982272 100644 --- a/crates/tinysecurity-sandbox/src/engine.rs +++ b/crates/tinysecurity-sandbox/src/engine.rs @@ -63,6 +63,15 @@ fn supports(executor: &SandboxExecutor2, constraint: SandboxConstraint2) -> bool .iter() .any(|s| s.constraint == constraint && s.enforcement == ConstraintEnforcement2::Enforced) } +/// A real backend (never `Noop`) reporting kernel or microVM isolation: the only +/// executors an untrusted tier or `SaaS` scope may ever be planned onto. +fn suitable_for_untrusted(executor: &SandboxExecutor2) -> bool { + executor.backend != SandboxBackend::Noop + && matches!( + executor.isolation, + IsolationLevel2::Kernel | IsolationLevel2::MicroVm + ) +} fn requirements(request: &SandboxPlanRequest2) -> Vec { let mut constraints = vec![SandboxConstraint2::Filesystem, SandboxConstraint2::Network]; for (required, constraint) in [ @@ -102,11 +111,7 @@ fn select<'a>( .into_iter() .find(|e| { e.available - && (!isolation - || matches!( - e.isolation, - IsolationLevel2::Kernel | IsolationLevel2::MicroVm - )) + && (!isolation || suitable_for_untrusted(e)) && e.backend != SandboxBackend::Noop && constraints.iter().all(|c| supports(e, *c)) && (request.network != NetworkPolicy::EgressProxy @@ -487,10 +492,7 @@ impl Engine { || executor.backend != artifact.backend || executor.backend == SandboxBackend::Noop || (artifact.minimum_isolation == IsolationLevel2::Kernel - && !matches!( - executor.isolation, - IsolationLevel2::Kernel | IsolationLevel2::MicroVm - )) + && !suitable_for_untrusted(executor)) || artifact.generation != generation || now_ms >= artifact.expires_at_ms || artifact.executor_id != executor.executor_id