Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions crates/tinysecurity-bus/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
Expand Down
6 changes: 6 additions & 0 deletions crates/tinysecurity-bus/src/sandbox_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ fn plan_options_have_a_pinned_wire_form() -> std::result::Result<(), Box<dyn std
require_preferred_backend: true,
no_workspace: true,
host_run: true,
files_only: true,
};
assert_eq!(
serde_json::to_value(&options).required()?,
Expand All @@ -379,6 +380,7 @@ fn plan_options_have_a_pinned_wire_form() -> std::result::Result<(), Box<dyn std
"require_preferred_backend": true,
"no_workspace": true,
"host_run": true,
"files_only": true,
})
);
assert_eq!(
Expand All @@ -389,6 +391,10 @@ fn plan_options_have_a_pinned_wire_form() -> std::result::Result<(), Box<dyn std
let back: SandboxPlanOptions2 =
serde_json::from_value(serde_json::json!({"host_run": true})).required()?;
assert!(back.host_run && back.preferred_backend.is_none());
assert!(!back.files_only);
let back: SandboxPlanOptions2 =
serde_json::from_value(serde_json::json!({"files_only": true})).required()?;
assert!(back.files_only && !back.is_default());
Ok(())
}

Expand Down
32 changes: 29 additions & 3 deletions crates/tinysecurity-sandbox/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,26 @@ fn minimum_isolation(
IsolationLevel2::Process
}
}
/// Whether the request accepted a jail that confines only the filesystem on the host's
/// network: nothing would enforce a network rule for it, so none is required.
fn files_only_host_network(request: &SandboxPlanRequest2) -> 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<SandboxConstraint2> {
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(),
Expand Down Expand Up @@ -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)
})
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)]
Expand Down
168 changes: 168 additions & 0 deletions crates/tinysecurity-sandbox/src/files_only_tests.rs
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error>>;

/// 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<dyn std::error::Error>,
> {
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<Engine, Box<dyn std::error::Error>> {
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(())
}
48 changes: 48 additions & 0 deletions docs/specs/sandbox-plan-files-only.md
Original file line number Diff line number Diff line change
@@ -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.
Loading