From e8a008408dce7215973141d92747255dc8e41591 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 12 Nov 2026 07:12:55 +0000 Subject: [PATCH 01/11] feat(sandbox): add filesystem-only confinement option Introduce a `filesystem_only` flag to `SandboxPlanOptions2` that lets a plan require exact filesystem grant enforcement without a network rule, so a folder jail without network or process isolation can serve it. The contract version is bumped to 2.2 to reflect the new wire vocabulary. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/lib.rs | 2 +- crates/tinysecurity-bus/src/sandbox.rs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 493f0dcc..c1c5e381 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -69,7 +69,7 @@ pub use scoped_policy::*; pub use tool_trust::*; pub mod names; /// Current wire vocabulary version, independent of the artifact version. -pub const CONTRACT_VERSION: (u32, u32) = (2, 1); +pub const CONTRACT_VERSION: (u32, u32) = (2, 2); /// Whether a remote module's advertised version supplies this host's vocabulary. /// /// `version` is the provider version received by the host, not a host requirement diff --git a/crates/tinysecurity-bus/src/sandbox.rs b/crates/tinysecurity-bus/src/sandbox.rs index 079c0b5a..f4f09b68 100644 --- a/crates/tinysecurity-bus/src/sandbox.rs +++ b/crates/tinysecurity-bus/src/sandbox.rs @@ -272,6 +272,15 @@ pub struct SandboxPlanOptions2 { /// that sets it is refused (`floor`). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub host_run: bool, + /// Confine the spawn's files only (contract 2.2). The plan requires the executor to + /// enforce the filesystem grants exactly and does not ask it to enforce a network + /// rule, so a folder jail that declares no network enforcement and no process + /// isolation (Landlock) may serve it. Planned only for a trusted operator source of a + /// local deployment and origin that accepted process isolation and asks for the host + /// network its scope grants (`operator_host_network`); any other request that sets it + /// is refused (`floor`, or `invalid_request` for the network or a host run). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub filesystem_only: bool, } impl SandboxPlanOptions2 { /// Whether every option is off (the 1.21 shape of a request). From dd555fefe0dfdffaf11b04d4b7cb2b7ff0ada9ac Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 12 Nov 2026 07:13:06 +0000 Subject: [PATCH 02/11] test(bus): update contract version assertions to 2.2 Bump the expected contract version in the bus tests to (2, 2) and adjust the compatibility checks so 2.1 is now rejected while 2.2 is accepted, matching the new major-compatible contract. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/device_frame_tests.rs | 2 +- crates/tinysecurity-bus/src/device_pairing_tests.rs | 2 +- crates/tinysecurity-bus/src/secret_tests.rs | 2 +- crates/tinysecurity-bus/tests/scoped_wire.rs | 7 ++++--- crates/tinysecurity-bus/tests/wire.rs | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index c371a780..21d9ad9c 100644 --- a/crates/tinysecurity-bus/src/device_frame_tests.rs +++ b/crates/tinysecurity-bus/src/device_frame_tests.rs @@ -70,7 +70,7 @@ fn frame_operations_redact_both_payloads_without_changing_open_wire_shape() #[test] fn frame_member_name_is_stable_and_contract_version_is_bumped() { assert_eq!(crate::names::methods::DEVICE_FRAME2, "DeviceFrame2"); - assert_eq!(crate::CONTRACT_VERSION, (2, 1)); + assert_eq!(crate::CONTRACT_VERSION, (2, 2)); assert!(!crate::is_compatible((2, 0))); assert!(crate::is_compatible((2, 1))); assert!(!crate::is_compatible((1, 35))); diff --git a/crates/tinysecurity-bus/src/device_pairing_tests.rs b/crates/tinysecurity-bus/src/device_pairing_tests.rs index 11b54a1c..00d91c9b 100644 --- a/crates/tinysecurity-bus/src/device_pairing_tests.rs +++ b/crates/tinysecurity-bus/src/device_pairing_tests.rs @@ -86,7 +86,7 @@ fn refresh_and_close_are_distinct_transient_session_operations() { #[test] fn pairing_contract_is_versioned_after_the_frame_contract() { - assert_eq!(CONTRACT_VERSION, (2, 1)); + assert_eq!(CONTRACT_VERSION, (2, 2)); } #[test] diff --git a/crates/tinysecurity-bus/src/secret_tests.rs b/crates/tinysecurity-bus/src/secret_tests.rs index 50ddabb7..d89da94d 100644 --- a/crates/tinysecurity-bus/src/secret_tests.rs +++ b/crates/tinysecurity-bus/src/secret_tests.rs @@ -140,6 +140,6 @@ fn runtime_secret_scope_registration_carries_an_optional_isolated_cipher() .remove("registration_id"); forged["cipher"]["subject"] = serde_json::json!({ "user": "caller" }); assert!(serde_json::from_value::(forged).is_err()); - assert_eq!(crate::CONTRACT_VERSION, (2, 1)); + assert_eq!(crate::CONTRACT_VERSION, (2, 2)); Ok(()) } diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index cfb7fe37..9e85b284 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -92,10 +92,11 @@ fn new_members_are_advertised_and_named_by_contract_constants() { #[test] fn incompatible_execution_outcome_requires_a_new_contract_major() { - assert_eq!(CONTRACT_VERSION, (2, 1)); - // A 2.1 host accepts compatible 2.1+ modules, but older 1.x hosts reject this module. + assert_eq!(CONTRACT_VERSION, (2, 2)); + // A 2.2 host accepts compatible 2.2+ modules, but older 2.x and 1.x hosts reject this module. assert!(!is_compatible((2, 0))); - assert!(is_compatible((2, 1))); + assert!(!is_compatible((2, 1))); + assert!(is_compatible((2, 2))); assert!(!is_compatible((1, 36))); assert!(!is_compatible((1, 35))); assert!(!is_compatible((1, 34))); diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index 7fe5b688..4b46e8c3 100644 --- a/crates/tinysecurity-bus/tests/wire.rs +++ b/crates/tinysecurity-bus/tests/wire.rs @@ -346,7 +346,7 @@ fn redactor_init_is_additive_and_secrets_never_enter_public_info() serde_json::to_value(&config)?["redactor"]["secrets"][0], "literal-private" ); - assert_eq!(CONTRACT_VERSION, (2, 1)); + assert_eq!(CONTRACT_VERSION, (2, 2)); for (mode, wire) in [ (RedactMode::Text, "text"), (RedactMode::StructuredArguments, "structured_arguments"), From a120e1f64489df59d38ccc06911d8b00ab45e213 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 12 Nov 2026 07:13:50 +0000 Subject: [PATCH 03/11] test(sandbox): cover filesystem-only plan option wire format Added tests asserting that the filesystem_only plan option round-trips through the pinned 2.1 wire form and defaults to false when absent, so requests that don't ask for a files-only plan keep the older shape. Also registered the new test module in the sandbox engine. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/sandbox_tests.rs | 8 + crates/tinysecurity-sandbox/src/engine.rs | 3 + .../src/filesystem_only_tests.rs | 195 ++++++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 crates/tinysecurity-sandbox/src/filesystem_only_tests.rs diff --git a/crates/tinysecurity-bus/src/sandbox_tests.rs b/crates/tinysecurity-bus/src/sandbox_tests.rs index 30c6ff77..78dcb521 100644 --- a/crates/tinysecurity-bus/src/sandbox_tests.rs +++ b/crates/tinysecurity-bus/src/sandbox_tests.rs @@ -371,6 +371,7 @@ fn plan_options_have_a_pinned_wire_form() -> std::result::Result<(), Box std::result::Result<(), Box std::result::Result<(), Box>; + +/// A folder jail that enforces the filesystem exactly and declares nothing else: no +/// network enforcement and no process isolation, as TinyBox reports Landlock. +fn landlock() -> SandboxExecutor2 { + SandboxExecutor2 { + executor_id: "tinybox-jail".into(), + capability_id: "landlock|none|fs:enforced|net:unsupported".into(), + backend: SandboxBackend::Jail, + available: true, + isolation: IsolationLevel2::None, + constraints: vec![ConstraintSupport2 { + constraint: SandboxConstraint2::Filesystem, + enforcement: ConstraintEnforcement2::Enforced, + }], + } +} + +/// The builder's case: an operator's own agent on a local deployment, sandbox on, with +/// the host network its scope grants, offered only the Landlock jail. +fn operator() -> std::result::Result< + (tempfile::TempDir, SandboxScope2, SandboxPlanRequest2), + Box, +> { + let (dir, mut scope, mut request) = fixture()?; + scope.deployment = SandboxDeployment2::Local; + scope.origin = Origin::Operator; + scope.environment = SandboxSetting2::Enabled; + scope.agent_mode = SandboxSetting2::Enabled; + scope.sources[0].trust = SourceTrust::TrustedHost; + scope.network_ceiling = NetworkPolicy::Host; + scope.operator_host_network = true; + scope.executors = vec![landlock()]; + request.network = NetworkPolicy::Host; + request.accept_process_isolation = true; + request.options.filesystem_only = true; + Ok((dir, scope, request)) +} + +fn engine(scope: SandboxScope2) -> std::result::Result> { + Ok(Engine::new(SandboxConfig2 { + scopes: vec![scope], + })?) +} + +fn denial(scope: SandboxScope2, request: &SandboxPlanRequest2) -> std::result::Result, Box> { + Ok(engine(scope)?.plan(request, 1, 100)?.denial) +} + +#[test] +fn a_files_only_operator_plan_runs_in_a_folder_jail_with_the_host_network() -> Res { + let (_dir, scope, request) = operator()?; + let engine = engine(scope.clone())?; + let planned = engine + .plan(&request, 1, 100)? + .artifact + .ok_or("no artifact")?; + assert_eq!(planned.executor_id, "tinybox-jail"); + assert_eq!(planned.network, NetworkPolicy::Host); + // Folder confinement, never a kernel-isolation claim, and only the filesystem is + // asked of the executor. + assert_eq!(planned.minimum_isolation, IsolationLevel2::Process); + assert_eq!(planned.constraints, vec![SandboxConstraint2::Filesystem]); + assert!(!planned.grants.is_empty()); + let artifact = engine.stage(planned, 100)?; + engine.activate(&artifact, 1, 100)?; + assert!(engine.validate(&request, &artifact, 1, 100, &scope.executors[0])?); + // The artifact stays bound to the request that asked for files only. + let mut full = request.clone(); + full.options.filesystem_only = false; + assert!(!engine.validate(&full, &artifact, 1, 100, &scope.executors[0])?); + Ok(()) +} + +#[test] +fn without_the_option_the_same_plan_is_still_refused() -> Res { + let (_dir, scope, mut request) = operator()?; + request.options.filesystem_only = false; + assert_eq!(denial(scope, &request)?, Some(SandboxDenial2::Unsupported)); + Ok(()) +} + +#[test] +fn text_the_host_did_not_author_never_gets_a_files_only_plan() -> Res { + let (_dir, mut scope, request) = operator()?; + scope.sources[0].trust = SourceTrust::UntrustedSkill; + assert_eq!(denial(scope, &request)?, Some(SandboxDenial2::Floor)); + Ok(()) +} + +#[test] +fn saas_and_unlabelled_callers_never_get_a_files_only_plan() -> Res { + let (_dir, scope, request) = operator()?; + for (deployment, origin) in [ + (SandboxDeployment2::Saas, Origin::Operator), + (SandboxDeployment2::Local, Origin::Saas), + (SandboxDeployment2::Local, Origin::Unlabelled), + ] { + let mut scope = scope.clone(); + scope.deployment = deployment; + scope.origin = origin; + assert_eq!(denial(scope, &request)?, Some(SandboxDenial2::Floor)); + } + Ok(()) +} + +#[test] +fn a_files_only_plan_needs_a_request_that_accepted_process_isolation() -> Res { + let (_dir, scope, mut request) = operator()?; + request.accept_process_isolation = false; + assert_eq!(denial(scope, &request)?, Some(SandboxDenial2::Floor)); + Ok(()) +} + +#[test] +fn a_files_only_plan_cannot_promise_no_network() -> Res { + // Nothing would enforce it: a request for no network keeps the network requirement. + for network in [NetworkPolicy::None, NetworkPolicy::EgressProxy] { + let (_dir, scope, mut request) = operator()?; + request.network = network; + assert_eq!(denial(scope, &request)?, Some(SandboxDenial2::InvalidRequest)); + } + Ok(()) +} + +#[test] +fn a_files_only_plan_needs_the_scope_to_grant_the_host_network() -> Res { + let (_dir, mut scope, request) = operator()?; + scope.operator_host_network = false; + assert_eq!(denial(scope, &request)?, Some(SandboxDenial2::InvalidRequest)); + Ok(()) +} + +#[test] +fn a_files_only_plan_is_not_a_run_on_the_host() -> Res { + let (_dir, mut scope, mut request) = operator()?; + scope.environment = SandboxSetting2::Disabled; + request.options.host_run = true; + request.grants.clear(); + assert_eq!(denial(scope, &request)?, Some(SandboxDenial2::InvalidRequest)); + Ok(()) +} + +#[test] +fn a_best_effort_filesystem_never_serves_a_files_only_plan() -> Res { + let (_dir, mut scope, request) = operator()?; + let mut seatbelt = landlock(); + seatbelt.constraints[0].enforcement = ConstraintEnforcement2::BestEffort; + scope.executors = vec![seatbelt]; + assert_eq!(denial(scope, &request)?, Some(SandboxDenial2::Unsupported)); + Ok(()) +} + +#[test] +fn a_files_only_plan_still_requires_what_else_it_asks_for() -> Res { + let (_dir, scope, mut request) = operator()?; + request.deny_subprocesses = true; + assert_eq!(denial(scope, &request)?, Some(SandboxDenial2::Unsupported)); + Ok(()) +} + +#[test] +fn a_files_only_plan_never_runs_unconfined() -> Res { + let (_dir, mut scope, request) = operator()?; + let mut noop = landlock(); + noop.backend = SandboxBackend::Noop; + scope.executors = vec![noop]; + assert_eq!(denial(scope, &request)?, Some(SandboxDenial2::Unsupported)); + Ok(()) +} + +#[test] +fn a_kernel_jail_still_wins_when_one_is_available() -> Res { + let (_dir, mut scope, request) = operator()?; + let mut bwrap = landlock(); + bwrap.executor_id = "tinybox-bwrap".into(); + bwrap.capability_id = "bwrap|kernel".into(); + bwrap.isolation = IsolationLevel2::Kernel; + scope.executors = vec![landlock(), bwrap]; + let planned = engine(scope)? + .plan(&request, 1, 100)? + .artifact + .ok_or("no artifact")?; + assert_eq!(planned.executor_id, "tinybox-bwrap"); + assert_eq!(planned.minimum_isolation, IsolationLevel2::Kernel); + Ok(()) +} From d1eb74080a85cf06d28b23283f8bb6dcad42bec7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 12 Nov 2026 07:14:39 +0000 Subject: [PATCH 04/11] feat(sandbox): admit files-only plans on folder-jail executors Files-only requests are now validated against the operator source, host network policy and process-isolation acceptance before planning, and executors that enforce every requested constraint exactly can serve them even without process isolation. This lets folder jails such as Landlock run files-only plans while still rejecting host runs and untrusted sources. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-sandbox/src/engine.rs | 64 +++++++++++++++++-- .../src/filesystem_only_tests.rs | 20 ++++-- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/crates/tinysecurity-sandbox/src/engine.rs b/crates/tinysecurity-sandbox/src/engine.rs index 7487a84b..2879861a 100644 --- a/crates/tinysecurity-sandbox/src/engine.rs +++ b/crates/tinysecurity-sandbox/src/engine.rs @@ -142,7 +142,12 @@ fn minimum_isolation( } } fn requirements(request: &SandboxPlanRequest2) -> Vec { - let mut constraints = vec![SandboxConstraint2::Filesystem, SandboxConstraint2::Network]; + let mut constraints = vec![SandboxConstraint2::Filesystem]; + // A files-only plan (admitted by `filesystem_only_admitted`) runs with the host + // network its scope grants, so there is no network rule to ask the executor for. + if !request.options.filesystem_only { + constraints.push(SandboxConstraint2::Network); + } for (required, constraint) in [ ( request.limits.cpu_millicores.is_some(), @@ -168,6 +173,42 @@ fn requirements(request: &SandboxPlanRequest2) -> Vec { } constraints } +/// Whether `executor`, which claims no process isolation, may still serve a files-only +/// plan: it enforces every requested constraint exactly (the filesystem above all), so +/// it is a folder jail such as Landlock, not a launcher that confines nothing. +fn folder_jail_for_files_only( + request: &SandboxPlanRequest2, + executor: &SandboxExecutor2, + constraints: &[SandboxConstraint2], +) -> bool { + request.options.filesystem_only + && executor.backend != SandboxBackend::Noop + && constraints.contains(&SandboxConstraint2::Filesystem) + && constraints.iter().all(|c| supports(executor, *c)) +} +/// Admit a files-only request (contract 2.2), or say why not. Only a trusted operator +/// source of a local deployment and origin, whose request accepted process isolation, +/// may ask; and it must ask for the host network its scope grants, because a plan that +/// asks the executor for no network rule cannot promise less network than that. +fn filesystem_only_admitted( + request: &SandboxPlanRequest2, + facts: &SandboxScope2, + mandatory: bool, +) -> std::result::Result<(), SandboxDenial2> { + if !request.options.filesystem_only { + return Ok(()); + } + if mandatory || !request.accept_process_isolation { + return Err(SandboxDenial2::Floor); + } + if request.network != NetworkPolicy::Host + || !facts.operator_host_network + || facts.network_ceiling != NetworkPolicy::Host + { + return Err(SandboxDenial2::InvalidRequest); + } + Ok(()) +} /// Choose the executor. An executor that meets the kernel-isolation floor with every /// constraint exactly enforced always wins; only when the request accepted process /// isolation (an ordinary operator command) is a process-level jail with best-effort @@ -216,12 +257,13 @@ fn pick<'a>( && (!isolation || if process_level { e.isolation != IsolationLevel2::None + || folder_jail_for_files_only(request, e, constraints) } else { suitable_for_untrusted(e) }) && e.backend != SandboxBackend::Noop && constraints.iter().all(|c| { - if process_level { + if process_level && !request.options.filesystem_only { supports_best_effort(e, *c) } else { supports(e, *c) @@ -465,6 +507,11 @@ impl Engine { let mandatory = facts.deployment == SandboxDeployment2::Saas || matches!(facts.origin, Origin::Saas | Origin::Unlabelled) || source.trust != SourceTrust::TrustedHost; + // A run on the host confines nothing, so it cannot be a files-only plan. + if request.options.filesystem_only && request.options.host_run { + return Err(SandboxDenial2::InvalidRequest); + } + filesystem_only_admitted(request, facts, mandatory)?; if request.options.host_run { return Self::host_run( request, facts, source, mandatory, generation, now_ms, &operation, @@ -824,14 +871,17 @@ impl Engine { && !suitable_for_untrusted(executor)) || (artifact.minimum_isolation == IsolationLevel2::Process && (!request.accept_process_isolation - || executor.isolation == IsolationLevel2::None)) + || (executor.isolation == IsolationLevel2::None + && !folder_jail_for_files_only(request, executor, &artifact.constraints)))) || artifact.generation != generation || now_ms >= artifact.expires_at_ms || artifact.executor_id != executor.executor_id || artifact.capability_id != executor.capability_id || !executor.available || !artifact.constraints.iter().all(|c| { - if artifact.minimum_isolation == IsolationLevel2::Process { + if artifact.minimum_isolation == IsolationLevel2::Process + && !request.options.filesystem_only + { supports_best_effort(executor, *c) } else { supports(executor, *c) @@ -897,12 +947,12 @@ pub(crate) fn denied(generation: u64, reason: SandboxDenial2) -> SandboxPlanResp #[path = "executor_choice_tests.rs"] mod executor_choice_tests; #[cfg(test)] -#[path = "operator_options_tests.rs"] -mod operator_options_tests; -#[cfg(test)] #[path = "filesystem_only_tests.rs"] mod filesystem_only_tests; #[cfg(test)] +#[path = "operator_options_tests.rs"] +mod operator_options_tests; +#[cfg(test)] #[path = "plan_matrix_tests.rs"] mod plan_matrix_tests; #[cfg(test)] diff --git a/crates/tinysecurity-sandbox/src/filesystem_only_tests.rs b/crates/tinysecurity-sandbox/src/filesystem_only_tests.rs index 4db170b3..071d2676 100644 --- a/crates/tinysecurity-sandbox/src/filesystem_only_tests.rs +++ b/crates/tinysecurity-sandbox/src/filesystem_only_tests.rs @@ -51,7 +51,10 @@ fn engine(scope: SandboxScope2) -> std::result::Result std::result::Result, Box> { +fn denial( + scope: SandboxScope2, + request: &SandboxPlanRequest2, +) -> std::result::Result, Box> { Ok(engine(scope)?.plan(request, 1, 100)?.denial) } @@ -126,7 +129,10 @@ fn a_files_only_plan_cannot_promise_no_network() -> Res { for network in [NetworkPolicy::None, NetworkPolicy::EgressProxy] { let (_dir, scope, mut request) = operator()?; request.network = network; - assert_eq!(denial(scope, &request)?, Some(SandboxDenial2::InvalidRequest)); + assert_eq!( + denial(scope, &request)?, + Some(SandboxDenial2::InvalidRequest) + ); } Ok(()) } @@ -135,7 +141,10 @@ fn a_files_only_plan_cannot_promise_no_network() -> Res { fn a_files_only_plan_needs_the_scope_to_grant_the_host_network() -> Res { let (_dir, mut scope, request) = operator()?; scope.operator_host_network = false; - assert_eq!(denial(scope, &request)?, Some(SandboxDenial2::InvalidRequest)); + assert_eq!( + denial(scope, &request)?, + Some(SandboxDenial2::InvalidRequest) + ); Ok(()) } @@ -145,7 +154,10 @@ fn a_files_only_plan_is_not_a_run_on_the_host() -> Res { scope.environment = SandboxSetting2::Disabled; request.options.host_run = true; request.grants.clear(); - assert_eq!(denial(scope, &request)?, Some(SandboxDenial2::InvalidRequest)); + assert_eq!( + denial(scope, &request)?, + Some(SandboxDenial2::InvalidRequest) + ); Ok(()) } From 275db5e78269fe619c0f7262b75ab0cd435088db Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 12 Nov 2026 07:14:49 +0000 Subject: [PATCH 05/11] test(bus): update contract version assertions to 2.2 Adjust the device frame and provider endpoint tests to expect contract version 2.2, and tighten the compatibility check so 2.1 is no longer considered compatible while 2.2 is. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/device_frame_tests.rs | 3 ++- crates/tinysecurity-bus/src/provider_endpoint_tests.rs | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index 21d9ad9c..f036ac42 100644 --- a/crates/tinysecurity-bus/src/device_frame_tests.rs +++ b/crates/tinysecurity-bus/src/device_frame_tests.rs @@ -72,7 +72,8 @@ fn frame_member_name_is_stable_and_contract_version_is_bumped() { assert_eq!(crate::names::methods::DEVICE_FRAME2, "DeviceFrame2"); assert_eq!(crate::CONTRACT_VERSION, (2, 2)); assert!(!crate::is_compatible((2, 0))); - assert!(crate::is_compatible((2, 1))); + assert!(!crate::is_compatible((2, 1))); + assert!(crate::is_compatible((2, 2))); assert!(!crate::is_compatible((1, 35))); assert!(!crate::is_compatible((1, 36))); } diff --git a/crates/tinysecurity-bus/src/provider_endpoint_tests.rs b/crates/tinysecurity-bus/src/provider_endpoint_tests.rs index cc3d7a7b..dd02f3d6 100644 --- a/crates/tinysecurity-bus/src/provider_endpoint_tests.rs +++ b/crates/tinysecurity-bus/src/provider_endpoint_tests.rs @@ -66,8 +66,8 @@ fn provider_endpoint_locality_has_stable_wire_names_and_method() "ClassifyProviderEndpoint2" ); assert!(crate::names::METHODS.contains(&crate::names::methods::CLASSIFY_PROVIDER_ENDPOINT2)); - assert_eq!(crate::CONTRACT_VERSION, (2, 1)); - assert!(crate::is_compatible((2, 1))); + assert_eq!(crate::CONTRACT_VERSION, (2, 2)); + assert!(crate::is_compatible((2, 2))); assert!(!crate::is_compatible((1, 35))); Ok(()) } From 935977569f73974c18d162252dda6ea8e56de7e9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 12 Nov 2026 07:16:22 +0000 Subject: [PATCH 06/11] docs(module): document files-only plans for contract 2.2 Adds a section describing the new `filesystem_only` plan option, covering its additive wire compatibility, the constraints it asks of an executor, and the admission rules that restrict it to trusted operator sources of non-SaaS deployments. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- MODULE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/MODULE.md b/MODULE.md index 720bcb1b..16b2de6d 100644 --- a/MODULE.md +++ b/MODULE.md @@ -343,6 +343,27 @@ Latency: all three are in-memory decisions inside the existing `Plan2` and already make), so they keep the budget of those calls: p50 <= 1 ms, p99 <= 10 ms excluding the audit callback. +## Files-only plans (contract 2.2) + +Additive: `SandboxPlanOptions2.filesystem_only` defaults to off and is absent from the wire +when off, so a 2.1 host keeps its behaviour and fingerprints; a 2.1 module refuses a request +that sets it (`deny_unknown_fields`). A 2.2 host refuses a 2.1 module. + +A files-only plan asks the executor to enforce the filesystem grants and nothing about the +network: the constraints are `filesystem` (plus `subprocesses` or resource bounds when the +request asks for them), never `network`. So a folder jail that enforces the filesystem +exactly but declares no network enforcement and no process isolation (TinyBox's Landlock +jail) may serve it, in the process-level pass, with `minimum_isolation` `process` (folder +confinement, never a kernel claim). A best-effort filesystem and the `noop` backend never +serve it, and an executor that meets the kernel floor is still preferred. + +The module admits the option only for a trusted operator source of a non-`SaaS` deployment +and origin whose request set `accept_process_isolation` (else `floor`), asking for +`NetworkPolicy::Host` under a scope with `operator_host_network` and a `host` ceiling (else +`invalid_request`: a plan that asks no network rule of its executor cannot promise less +network). A host run cannot be files-only (`invalid_request`). `ConsumeSandboxPlan2` +re-derives the plan, so a request without the option cannot reuse a files-only artifact. + ## Host-recorded audit events (contract 1.21) Additive. `Audit2` accepts only the module's init-time scope and stream, which a host that From 656f4d00417b13cdcca9ddee5798fd6c820d6427 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 12 Nov 2026 07:16:53 +0000 Subject: [PATCH 07/11] chore(sandbox): allow excessive bools lint on plan options The SandboxPlanOptions2 struct carries independent wire flags that are each absent when off, so the clippy warning does not apply. Added an allow attribute with a comment explaining why. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/sandbox.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinysecurity-bus/src/sandbox.rs b/crates/tinysecurity-bus/src/sandbox.rs index f4f09b68..609f82c7 100644 --- a/crates/tinysecurity-bus/src/sandbox.rs +++ b/crates/tinysecurity-bus/src/sandbox.rs @@ -253,6 +253,7 @@ impl std::fmt::Debug for SandboxPlanRequest2 { /// request that sets one (`deny_unknown_fields`). #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] +#[allow(clippy::struct_excessive_bools)] // independent wire flags, each absent when off pub struct SandboxPlanOptions2 { /// The backend the owner's configuration prefers. The module plans onto an eligible /// executor of this backend first; it never makes an ineligible executor eligible. From 0b4c801c4669bed62a9fb290d37e28eb49bd7652 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 12 Nov 2026 07:18:11 +0000 Subject: [PATCH 08/11] test(tinysecurity-sandbox): tighten filesystem-only sandbox assertions Replace the empty-check on planned grants with an explicit comparison against an empty vector and mark `TinyBox` as code in the doc comment, keeping the filesystem-only test assertions precise and the docs consistent. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-sandbox/src/filesystem_only_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-sandbox/src/filesystem_only_tests.rs b/crates/tinysecurity-sandbox/src/filesystem_only_tests.rs index 071d2676..dbc46ea5 100644 --- a/crates/tinysecurity-sandbox/src/filesystem_only_tests.rs +++ b/crates/tinysecurity-sandbox/src/filesystem_only_tests.rs @@ -9,7 +9,7 @@ use tinysecurity_bus::ConstraintSupport2; type Res = std::result::Result<(), Box>; /// A folder jail that enforces the filesystem exactly and declares nothing else: no -/// network enforcement and no process isolation, as TinyBox reports Landlock. +/// network enforcement and no process isolation, as `TinyBox` reports Landlock. fn landlock() -> SandboxExecutor2 { SandboxExecutor2 { executor_id: "tinybox-jail".into(), @@ -72,7 +72,7 @@ fn a_files_only_operator_plan_runs_in_a_folder_jail_with_the_host_network() -> R // asked of the executor. assert_eq!(planned.minimum_isolation, IsolationLevel2::Process); assert_eq!(planned.constraints, vec![SandboxConstraint2::Filesystem]); - assert!(!planned.grants.is_empty()); + assert_ne!(planned.grants, Vec::new()); let artifact = engine.stage(planned, 100)?; engine.activate(&artifact, 1, 100)?; assert!(engine.validate(&request, &artifact, 1, 100, &scope.executors[0])?); From 37828cfd522550145e47c65105b87a8b0c096cac Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 12 Nov 2026 08:00:00 +0000 Subject: [PATCH 09/11] test(sandbox): cover files-only plan floor when isolation is off Add a test asserting that a files-only plan still uses folder confinement with credential denials when the operator setting or the agent and configured modes disable isolation, so the plan never degrades to an unconfined-looking result with an empty deny list. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/filesystem_only_tests.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/tinysecurity-sandbox/src/filesystem_only_tests.rs b/crates/tinysecurity-sandbox/src/filesystem_only_tests.rs index dbc46ea5..691571b1 100644 --- a/crates/tinysecurity-sandbox/src/filesystem_only_tests.rs +++ b/crates/tinysecurity-sandbox/src/filesystem_only_tests.rs @@ -205,3 +205,26 @@ fn a_kernel_jail_still_wins_when_one_is_available() -> Res { assert_eq!(planned.minimum_isolation, IsolationLevel2::Kernel); Ok(()) } + +#[test] +fn a_files_only_plan_keeps_the_process_floor_when_the_scope_turns_isolation_off() -> Res { + // With the operator's setting off, or neither the agent's mode nor the configuration + // asking for isolation, a files-only plan must still be folder confinement with its + // credential denials, never an unconfined-looking plan with an empty deny list. + let (_dir, scope, request) = operator()?; + let mut off = scope.clone(); + off.environment = SandboxSetting2::Disabled; + let mut unasked = scope; + unasked.agent_mode = SandboxSetting2::Disabled; + unasked.configured = SandboxSetting2::Disabled; + for scope in [off, unasked] { + let planned = engine(scope)? + .plan(&request, 1, 100)? + .artifact + .ok_or("no artifact")?; + assert_eq!(planned.executor_id, "tinybox-jail"); + assert_eq!(planned.minimum_isolation, IsolationLevel2::Process); + assert_ne!(planned.deny_read, Vec::::new()); + } + Ok(()) +} From 62acbde59e04185a5bd89f592f7cee22f7d66982 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 12 Nov 2026 08:01:22 +0000 Subject: [PATCH 10/11] fix(sandbox): keep process floor for files-only plans Files-only plans now force isolation to remain mandatory regardless of the scope's settings, so their credential denials stay in effect and relaxed constraints never reach a selection pass that requires no isolation. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-sandbox/src/engine.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/tinysecurity-sandbox/src/engine.rs b/crates/tinysecurity-sandbox/src/engine.rs index 2879861a..4ff8101f 100644 --- a/crates/tinysecurity-sandbox/src/engine.rs +++ b/crates/tinysecurity-sandbox/src/engine.rs @@ -537,7 +537,11 @@ impl Engine { if request.accept_process_isolation && mandatory { return Err(SandboxDenial2::Floor); } + // A files-only plan is admitted only as folder confinement: whatever the scope's + // settings say, it keeps the process floor (and so its credential denials), and + // the relaxed constraints never reach a selection pass that demands no isolation. let isolation = mandatory + || request.options.filesystem_only || (facts.environment != SandboxSetting2::Disabled && (facts.agent_mode == SandboxSetting2::Enabled || facts.configured == SandboxSetting2::Enabled)); From 16a02a808ce80894e8f0c1ebccea230eb7669d5d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 12 Nov 2026 08:01:31 +0000 Subject: [PATCH 11/11] Keep the process floor for files-only plans A files-only request under a scope whose sandbox setting is off planned with no isolation demanded, so a folder jail was selected with minimum_isolation none and no credential denials. Files-only plans now always take the isolation path: process floor, credential denials. Co-authored-by: Medulla --- MODULE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/MODULE.md b/MODULE.md index 16b2de6d..cef7f7c6 100644 --- a/MODULE.md +++ b/MODULE.md @@ -354,8 +354,9 @@ network: the constraints are `filesystem` (plus `subprocesses` or resource bound request asks for them), never `network`. So a folder jail that enforces the filesystem exactly but declares no network enforcement and no process isolation (TinyBox's Landlock jail) may serve it, in the process-level pass, with `minimum_isolation` `process` (folder -confinement, never a kernel claim). A best-effort filesystem and the `noop` backend never -serve it, and an executor that meets the kernel floor is still preferred. +confinement, never a kernel claim), whatever the scope's `environment`, `agent_mode` and +`configured` settings say, so its credential denials always apply. A best-effort filesystem +and the `noop` backend never serve it, and an executor that meets the kernel floor is still preferred. The module admits the option only for a trusted operator source of a non-`SaaS` deployment and origin whose request set `accept_process_isolation` (else `floor`), asking for