diff --git a/MODULE.md b/MODULE.md index 720bcb1b..cef7f7c6 100644 --- a/MODULE.md +++ b/MODULE.md @@ -343,6 +343,28 @@ 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), 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 +`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 diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index c371a780..f036ac42 100644 --- a/crates/tinysecurity-bus/src/device_frame_tests.rs +++ b/crates/tinysecurity-bus/src/device_frame_tests.rs @@ -70,9 +70,10 @@ 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((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/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/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/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(()) } diff --git a/crates/tinysecurity-bus/src/sandbox.rs b/crates/tinysecurity-bus/src/sandbox.rs index 079c0b5a..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. @@ -272,6 +273,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). 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(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"), diff --git a/crates/tinysecurity-sandbox/src/engine.rs b/crates/tinysecurity-sandbox/src/engine.rs index 25d70981..4ff8101f 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, @@ -490,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)); @@ -824,14 +875,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,6 +951,9 @@ pub(crate) fn denied(generation: u64, reason: SandboxDenial2) -> SandboxPlanResp #[path = "executor_choice_tests.rs"] mod executor_choice_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)] diff --git a/crates/tinysecurity-sandbox/src/filesystem_only_tests.rs b/crates/tinysecurity-sandbox/src/filesystem_only_tests.rs new file mode 100644 index 00000000..691571b1 --- /dev/null +++ b/crates/tinysecurity-sandbox/src/filesystem_only_tests.rs @@ -0,0 +1,230 @@ +//! Files-only plans (contract 2.2): a trusted operator's command with the host network +//! its scope grants may be confined by a folder jail that enforces the filesystem and +//! nothing else (Landlock). Every other request keeps the network requirement and the +//! isolation floor it had. +use super::*; +use crate::engine::tests::fixture; +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. +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_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])?); + // 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(()) +} + +#[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(()) +}