diff --git a/MODULE.md b/MODULE.md index 720bcb1b..8262663c 100644 --- a/MODULE.md +++ b/MODULE.md @@ -337,6 +337,18 @@ refuses a 1.21 module, and a 1.21 module rejects a request that sets an option `deny_subprocesses` is `unsupported`. The plan is audited, staged and admitted exactly once like any other, and `ConsumeSandboxPlan2` re-derives it from the request, so a request that does not ask for the host run cannot reuse it. +- **A files-only jail.** `options.files_only` says the operator's own command accepts a + jail that confines only the filesystem and runs on the host's network (Linux Landlock, + which has no network rule and no process isolation). For a request for the host network + the plan then requires no network enforcement, and on the trusted-folder pass an executor + that enforces the filesystem exactly qualifies without process isolation; the artifact's + `minimum_isolation` is `process` and its constraints are the filesystem alone. A request + for no network still needs an executor that enforces it, an executor with a best-effort + filesystem never qualifies, and a kernel executor that meets the floor still wins. It is + honoured only with `accept_process_isolation` for a `trusted_host` source outside `SaaS`; + anything else that sets it is refused (`floor`). `ConsumeSandboxPlan2` re-derives the plan, + so a request that does not set it cannot reuse a files-only artifact. See + [the files-only spec](docs/specs/sandbox-plan-files-only.md). Latency: all three are in-memory decisions inside the existing `Plan2` and `ConsumeSandboxPlan2` calls (no new member, no I/O besides the audit append those calls diff --git a/crates/tinysecurity-bus/src/sandbox.rs b/crates/tinysecurity-bus/src/sandbox.rs index 079c0b5a..6fdf53cc 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, one per planning request 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,16 @@ pub struct SandboxPlanOptions2 { /// that sets it is refused (`floor`). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub host_run: bool, + /// The operator's own command accepts a jail that confines only the filesystem + /// (Linux Landlock) and runs on the host's network. A request for the host network + /// then needs no network enforcement, and on the trusted-folder pass an executor + /// that enforces the filesystem exactly qualifies without process isolation. + /// Honoured only with `accept_process_isolation` for a trusted host source outside + /// `SaaS`; any other request that sets it is refused (`floor`). A request for no + /// network still needs an executor that enforces it. An older module refuses a + /// request that sets it. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub files_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..8827bba0 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 bool { + request.options.files_only && request.network == NetworkPolicy::Host +} +/// Whether `executor` may run a trusted-folder (process-level) plan: it isolates the +/// process, or the request accepted a files-only jail and the executor enforces the +/// filesystem exactly (Linux Landlock). +fn qualifies_for_trusted_folder( + executor: &SandboxExecutor2, + request: &SandboxPlanRequest2, +) -> bool { + executor.isolation != IsolationLevel2::None + || (request.options.files_only && supports(executor, SandboxConstraint2::Filesystem)) +} fn requirements(request: &SandboxPlanRequest2) -> Vec { - let mut constraints = vec![SandboxConstraint2::Filesystem, SandboxConstraint2::Network]; + let mut constraints = vec![SandboxConstraint2::Filesystem]; + if !files_only_host_network(request) { + constraints.push(SandboxConstraint2::Network); + } for (required, constraint) in [ ( request.limits.cpu_millicores.is_some(), @@ -215,7 +233,7 @@ fn pick<'a>( e.available && (!isolation || if process_level { - e.isolation != IsolationLevel2::None + qualifies_for_trusted_folder(e, request) } else { suitable_for_untrusted(e) }) @@ -490,6 +508,11 @@ impl Engine { if request.accept_process_isolation && mandatory { return Err(SandboxDenial2::Floor); } + // A files-only jail is trusted folder confinement for the operator's own + // command, never for a plan that must be isolated. + if request.options.files_only && (mandatory || !request.accept_process_isolation) { + return Err(SandboxDenial2::Floor); + } let isolation = mandatory || (facts.environment != SandboxSetting2::Disabled && (facts.agent_mode == SandboxSetting2::Enabled @@ -824,7 +847,7 @@ impl Engine { && !suitable_for_untrusted(executor)) || (artifact.minimum_isolation == IsolationLevel2::Process && (!request.accept_process_isolation - || executor.isolation == IsolationLevel2::None)) + || !qualifies_for_trusted_folder(executor, request))) || artifact.generation != generation || now_ms >= artifact.expires_at_ms || artifact.executor_id != executor.executor_id @@ -897,6 +920,9 @@ pub(crate) fn denied(generation: u64, reason: SandboxDenial2) -> SandboxPlanResp #[path = "executor_choice_tests.rs"] mod executor_choice_tests; #[cfg(test)] +#[path = "files_only_tests.rs"] +mod files_only_tests; +#[cfg(test)] #[path = "operator_options_tests.rs"] mod operator_options_tests; #[cfg(test)] diff --git a/crates/tinysecurity-sandbox/src/files_only_tests.rs b/crates/tinysecurity-sandbox/src/files_only_tests.rs new file mode 100644 index 00000000..65b6f7b4 --- /dev/null +++ b/crates/tinysecurity-sandbox/src/files_only_tests.rs @@ -0,0 +1,168 @@ +//! Files-only plans: an operator command that accepts a jail confining only the +//! filesystem on the host's network, such as Landlock (contract 2.1 `files_only`). +use super::*; +use crate::engine::tests::fixture; +use tinysecurity_bus::ConstraintSupport2; + +type Res = std::result::Result<(), Box>; + +/// An operator's own trusted command on a local deployment, with the sandbox on and +/// the host network granted by the operator's configuration. +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(ConstraintEnforcement2::Enforced)]; + request.network = NetworkPolicy::Host; + request.accept_process_isolation = true; + Ok((dir, scope, request)) +} + +fn engine(scope: SandboxScope2) -> std::result::Result> { + Ok(Engine::new(SandboxConfig2 { + scopes: vec![scope], + })?) +} + +/// A Linux Landlock jail as `TinyBox` reports it: the filesystem only, no process +/// isolation, nothing for the network. +fn landlock(filesystem: ConstraintEnforcement2) -> SandboxExecutor2 { + SandboxExecutor2 { + executor_id: "landlock".into(), + capability_id: "landlock-abi-8".into(), + backend: SandboxBackend::Jail, + available: true, + isolation: IsolationLevel2::None, + constraints: vec![ConstraintSupport2 { + constraint: SandboxConstraint2::Filesystem, + enforcement: filesystem, + }], + } +} + +#[test] +fn files_only_plans_a_landlock_jail_for_an_operator_command_on_the_host_network() -> Res { + let (_dir, scope, mut request) = operator()?; + request.options.files_only = true; + let engine = engine(scope.clone())?; + let response = engine.plan(&request, 1, 100)?; + assert_eq!(response.denial, None); + let planned = response.artifact.ok_or("no artifact")?; + assert_eq!(planned.executor_id, "landlock"); + assert_eq!(planned.network, NetworkPolicy::Host); + assert_eq!(planned.constraints, vec![SandboxConstraint2::Filesystem]); + assert_eq!(planned.minimum_isolation, IsolationLevel2::Process); + assert_eq!(planned.grants, request.grants); + 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 accepted a files-only jail. + let mut stricter = request.clone(); + stricter.options.files_only = false; + assert!(!engine.validate(&stricter, &artifact, 1, 100, &scope.executors[0])?); + Ok(()) +} + +#[test] +fn without_files_only_the_same_landlock_jail_is_unsupported() -> Res { + let (_dir, scope, request) = operator()?; + assert_eq!( + engine(scope)?.plan(&request, 1, 100)?.denial, + Some(SandboxDenial2::Unsupported) + ); + Ok(()) +} + +#[test] +fn files_only_still_requires_network_enforcement_for_a_request_without_network() -> Res { + let (_dir, scope, mut request) = operator()?; + request.options.files_only = true; + request.network = NetworkPolicy::None; + assert_eq!( + engine(scope)?.plan(&request, 1, 100)?.denial, + Some(SandboxDenial2::Unsupported) + ); + Ok(()) +} + +#[test] +fn files_only_never_accepts_a_best_effort_filesystem_without_isolation() -> Res { + let (_dir, mut scope, mut request) = operator()?; + request.options.files_only = true; + scope.executors = vec![landlock(ConstraintEnforcement2::BestEffort)]; + assert_eq!( + engine(scope)?.plan(&request, 1, 100)?.denial, + Some(SandboxDenial2::Unsupported) + ); + Ok(()) +} + +#[test] +fn files_only_needs_a_request_that_accepts_process_isolation() -> Res { + let (_dir, scope, mut request) = operator()?; + request.options.files_only = true; + request.accept_process_isolation = false; + assert_eq!( + engine(scope)?.plan(&request, 1, 100)?.denial, + Some(SandboxDenial2::Floor) + ); + Ok(()) +} + +#[test] +fn files_only_never_runs_untrusted_or_saas_plans() -> Res { + let (_dir, scope, mut request) = operator()?; + request.options.files_only = true; + // Without process isolation, so the refusal is the files-only one. + request.accept_process_isolation = false; + for trust in [ + SourceTrust::UntrustedMcp, + SourceTrust::UntrustedSkill, + SourceTrust::Downloaded, + ] { + let mut scope = scope.clone(); + scope.sources[0].trust = trust; + assert_eq!( + engine(scope)?.plan(&request, 1, 100)?.denial, + Some(SandboxDenial2::Floor) + ); + } + 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!( + engine(scope)?.plan(&request, 1, 100)?.denial, + Some(SandboxDenial2::Floor) + ); + } + Ok(()) +} + +#[test] +fn a_kernel_jail_still_wins_over_a_files_only_one() -> Res { + let (_dir, scope, mut request) = operator()?; + request.options.files_only = true; + let mut both = scope; + let (_other, kernel, _) = fixture()?; + both.executors.push(kernel.executors[0].clone()); + let artifact = engine(both)? + .plan(&request, 1, 100)? + .artifact + .ok_or("no artifact")?; + assert_eq!(artifact.executor_id, "executor"); + assert_eq!(artifact.minimum_isolation, IsolationLevel2::Kernel); + Ok(()) +} diff --git a/docs/specs/sandbox-plan-files-only.md b/docs/specs/sandbox-plan-files-only.md new file mode 100644 index 00000000..51241750 --- /dev/null +++ b/docs/specs/sandbox-plan-files-only.md @@ -0,0 +1,48 @@ +# Sandbox Plan: files-only jails + +`SandboxPlanOptions2.files_only` lets the operator's own command run under a jail +that confines only the filesystem, on the host's network. It exists for Linux +Landlock where nothing stronger can start: inside a container that blocks user +namespaces, bubblewrap fails and Docker is absent, while Landlock still restricts +every path the command can reach. + +## Why an option + +`Plan2` requires every plan to enforce the filesystem and the network. Landlock +declares only the filesystem as enforced and no process isolation, so it never +qualifies, and a sandboxed shell in such a container is refused `unsupported`. +A shell already granted the host's network (`NetworkPolicy::Host` under the +scope's `operator_host_network`) gains nothing from a network rule, yet the +requirement still excludes the one jail that would confine its files. + +Dropping the requirement for every host-network request would silently move +every such agent onto a weaker jail. The option keeps that decision explicit +and per request: a host asks for it only for an agent its operator opted in. + +## Behaviour + +With `files_only` set: + +- A request for `NetworkPolicy::Host` requires only the filesystem (plus any + requested resource or subprocess bounds). A request for any other network + policy keeps the network requirement. +- On the trusted-folder pass (`accept_process_isolation`), an executor with no + process isolation qualifies when it enforces the filesystem exactly. A + best-effort filesystem never qualifies without isolation. +- A kernel executor that meets the floor with every requirement enforced is + still preferred. +- The artifact states `minimum_isolation: process` and lists only the + filesystem constraint. It never claims kernel isolation. + +It is refused (`floor`) when the request does not set +`accept_process_isolation`, when the source is not `trusted_host`, and when +the deployment or origin is `SaaS` or unlabelled. `ConsumeSandboxPlan2` and +`validate` re-derive the plan from the request, so a files-only artifact +cannot be reused by a request that does not set the option. + +## Compatibility + +The field defaults to off and is absent from the wire when off, so existing +hosts and their fingerprints are unchanged. A module older than this field +rejects a request that sets it (`deny_unknown_fields`), so a host that asks for +a files-only jail fails closed against an older module.