Skip to content
Merged
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
22 changes: 22 additions & 0 deletions MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions crates/tinysecurity-bus/src/device_frame_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
2 changes: 1 addition & 1 deletion crates/tinysecurity-bus/src/device_pairing_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion crates/tinysecurity-bus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions crates/tinysecurity-bus/src/provider_endpoint_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
10 changes: 10 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, 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.
Expand All @@ -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).
Expand Down
8 changes: 8 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,
filesystem_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,
"filesystem_only": true,
})
);
assert_eq!(
Expand All @@ -389,6 +391,12 @@ 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());
// A request that does not ask for a files-only plan keeps the 2.1 wire shape, so an
// older module still reads it; one that asks is refused by an older module.
assert!(!back.filesystem_only);
let files: SandboxPlanOptions2 =
serde_json::from_value(serde_json::json!({"filesystem_only": true})).required()?;
assert!(files.filesystem_only && !files.host_run);
Ok(())
}

Expand Down
2 changes: 1 addition & 1 deletion crates/tinysecurity-bus/src/secret_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<RegisterSecretScopeRequest2>(forged).is_err());
assert_eq!(crate::CONTRACT_VERSION, (2, 1));
assert_eq!(crate::CONTRACT_VERSION, (2, 2));
Ok(())
}
7 changes: 4 additions & 3 deletions crates/tinysecurity-bus/tests/scoped_wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down
2 changes: 1 addition & 1 deletion crates/tinysecurity-bus/tests/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
65 changes: 61 additions & 4 deletions crates/tinysecurity-sandbox/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,12 @@ fn minimum_isolation(
}
}
fn requirements(request: &SandboxPlanRequest2) -> Vec<SandboxConstraint2> {
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(),
Expand All @@ -168,6 +173,42 @@ fn requirements(request: &SandboxPlanRequest2) -> Vec<SandboxConstraint2> {
}
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)?;
Comment thread
senamakel marked this conversation as resolved.
Resolved
if request.options.host_run {
return Self::host_run(
request, facts, source, mandatory, generation, now_ms, &operation,
Expand All @@ -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));
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)]
Expand Down
Loading
Loading