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
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions crates/tinysecurity-bus/src/future.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,13 @@ pub enum SourceTrust {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SandboxBackend {
/// Namespace state.
/// Kernel namespace isolation. Legacy `firejail` and `bubblewrap` host
/// settings map here; the module never plans them as separate backends.
Namespace,
/// `MicroVm` state.
MicroVm,
/// Jail state.
/// Path-jail executor. It is planned only when it reports enforced kernel
/// isolation, so an untrusted tier never receives a best-effort jail.
Comment on lines +68 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Qualify the jail isolation guarantee

This public documentation promises that every Jail plan reports kernel isolation, but Engine::resolve requests kernel isolation only when isolation is mandatory or enabled, and the new matrix explicitly expects trusted-local/all-disabled Jail executors reporting None or Process to be allowed. Either qualify this guarantee as applying only to isolation-required plans or enforce it for every Jail selection so the contract matches planner behavior.

AGENTS.md reference: AGENTS.md:L162-L163

Useful? React with 👍 / 👎.

Jail,
/// Docker state.
Docker,
Expand Down
23 changes: 14 additions & 9 deletions crates/tinysecurity-sandbox/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ fn supports(executor: &SandboxExecutor2, constraint: SandboxConstraint2) -> bool
.iter()
.any(|s| s.constraint == constraint && s.enforcement == ConstraintEnforcement2::Enforced)
}
/// A real backend (never `Noop`) reporting kernel or microVM isolation: the only
/// executors an untrusted tier or `SaaS` scope may ever be planned onto.
fn suitable_for_untrusted(executor: &SandboxExecutor2) -> bool {
executor.backend != SandboxBackend::Noop
&& matches!(
executor.isolation,
IsolationLevel2::Kernel | IsolationLevel2::MicroVm
)
}
fn requirements(request: &SandboxPlanRequest2) -> Vec<SandboxConstraint2> {
let mut constraints = vec![SandboxConstraint2::Filesystem, SandboxConstraint2::Network];
for (required, constraint) in [
Expand Down Expand Up @@ -102,11 +111,7 @@ fn select<'a>(
.into_iter()
.find(|e| {
e.available
&& (!isolation
|| matches!(
e.isolation,
IsolationLevel2::Kernel | IsolationLevel2::MicroVm
))
&& (!isolation || suitable_for_untrusted(e))
&& e.backend != SandboxBackend::Noop
&& constraints.iter().all(|c| supports(e, *c))
&& (request.network != NetworkPolicy::EgressProxy
Expand Down Expand Up @@ -487,10 +492,7 @@ impl Engine {
|| executor.backend != artifact.backend
|| executor.backend == SandboxBackend::Noop
|| (artifact.minimum_isolation == IsolationLevel2::Kernel
&& !matches!(
executor.isolation,
IsolationLevel2::Kernel | IsolationLevel2::MicroVm
))
&& !suitable_for_untrusted(executor))
|| artifact.generation != generation
|| now_ms >= artifact.expires_at_ms
|| artifact.executor_id != executor.executor_id
Expand Down Expand Up @@ -536,5 +538,8 @@ pub(crate) fn denied(generation: u64, reason: SandboxDenial2) -> SandboxPlanResp
}
}
#[cfg(test)]
#[path = "plan_matrix_tests.rs"]
mod plan_matrix_tests;
#[cfg(test)]
#[path = "engine_tests.rs"]
pub(crate) mod tests;
181 changes: 181 additions & 0 deletions crates/tinysecurity-sandbox/src/plan_matrix_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
//! Exhaustive plan matrix: trust x deployment x switches x backend x isolation.
use super::*;
Comment on lines +1 to +2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Start the sibling test file with the parent import

This new sibling test file begins with a module doc comment rather than use super::*;, contrary to the repository's explicit layout requirement for every *_tests.rs file. Move the import to the first line so the test module follows the mandated structure.

AGENTS.md reference: AGENTS.md:L117-L125

Useful? React with 👍 / 👎.

use crate::engine::tests::fixture;
use tinysecurity_bus::{PathAccess, PathGrant, PathTrustedRoot};

type Res = std::result::Result<(), Box<dyn std::error::Error>>;

fn plan(
scope: SandboxScope2,
request: &SandboxPlanRequest2,
) -> std::result::Result<SandboxPlanResponse2, Box<dyn std::error::Error>> {
Ok(Engine::new(SandboxConfig2 {
scopes: vec![scope],
})?
.plan(request, 1, 100)?)
}

const TRUSTS: [SourceTrust; 4] = [
SourceTrust::TrustedHost,
SourceTrust::UntrustedMcp,
SourceTrust::UntrustedSkill,
SourceTrust::Downloaded,
];
const SETTINGS: [SandboxSetting2; 2] = [SandboxSetting2::Enabled, SandboxSetting2::Disabled];
const BACKENDS: [SandboxBackend; 5] = [
SandboxBackend::Noop,
SandboxBackend::Namespace,
SandboxBackend::MicroVm,
SandboxBackend::Jail,
SandboxBackend::Docker,
];
const LEVELS: [IsolationLevel2; 4] = [
IsolationLevel2::None,
IsolationLevel2::Process,
IsolationLevel2::Kernel,
IsolationLevel2::MicroVm,
];

#[test]
fn plan_matrix_fails_closed_for_every_trust_deployment_switch_and_backend() -> Res {
let (_dir, base, request) = fixture()?;
let mut allowed = 0_u32;
for trust in TRUSTS {
for saas in [true, false] {
for (environment, agent_mode, configured) in SETTINGS.iter().flat_map(|e| {
SETTINGS
.iter()
.flat_map(move |a| SETTINGS.iter().map(move |c| (*e, *a, *c)))
}) {
for backend in BACKENDS {
for isolation in LEVELS {
let mut scope = base.clone();
scope.deployment = if saas {
SandboxDeployment2::Saas
} else {
SandboxDeployment2::Local
};
scope.origin = if saas { Origin::Saas } else { Origin::Operator };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Vary SaaS origin independently of deployment

This assigns origin from the same saas flag as deployment, so the matrix never covers deployment == Local with origin == SaaS (or the reverse), even though Engine::resolve treats them as independent mandatory-isolation predicates. If the facts.origin == Origin::Saas check were removed, a trusted local-deployment scope with SaaS origin, disabled switches, and process-only isolation could be allowed while this purportedly exhaustive fail-closed suite still passed; add origin as an independent matrix dimension.

Useful? React with 👍 / 👎.

scope.sources[0].trust = trust.clone();
scope.environment = environment;
scope.agent_mode = agent_mode;
scope.configured = configured;
scope.executors[0].backend = backend.clone();
scope.executors[0].isolation = isolation;
let mandatory = saas || trust != SourceTrust::TrustedHost;
let requested = environment != SandboxSetting2::Disabled
&& (agent_mode == SandboxSetting2::Enabled
|| configured == SandboxSetting2::Enabled);
let isolated = mandatory || requested;
let kernel = matches!(
isolation,
IsolationLevel2::Kernel | IsolationLevel2::MicroVm
);
let expect_allow = backend != SandboxBackend::Noop && (!isolated || kernel);
let response = plan(scope, &request)?;
let label = format!(
"{trust:?} saas={saas} env={environment:?} agent={agent_mode:?} cfg={configured:?} {backend:?} {isolation:?}"
);
if expect_allow {
allowed += 1;
let artifact = response.artifact.ok_or(label.clone())?;
assert_eq!(response.decision.verdict, Verdict::Allow, "{label}");
assert_ne!(artifact.backend, SandboxBackend::Noop, "{label}");
assert_eq!(
artifact.minimum_isolation != IsolationLevel2::None,
isolated,
"{label}"
);
} else {
assert!(response.artifact.is_none(), "{label}");
assert_eq!(
response.denial,
Some(SandboxDenial2::Unsupported),
"{label}"
);
Comment on lines +91 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Assert the deny verdict in the failure branch

For isolation-required combinations using Noop, None, or Process, this branch checks only the absent artifact and denial metadata, not response.decision.verdict. A regression in denied() that emits Verdict::Allow while retaining those fields would therefore leave the new fail-closed matrix green even though the public authorization decision permits the operation; assert the corresponding Verdict::Deny and reason here.

AGENTS.md reference: AGENTS.md:L18-L20

Useful? React with 👍 / 👎.

}
}
}
}
}
}
assert!(allowed > 0, "matrix must exercise allowed plans");
Ok(())
}

#[test]
fn saas_ignores_off_switches_for_every_trust() -> Res {
let (_dir, base, request) = fixture()?;
for trust in TRUSTS {
let mut scope = base.clone();
scope.sources[0].trust = trust;
scope.environment = SandboxSetting2::Disabled;
scope.agent_mode = SandboxSetting2::Disabled;
scope.configured = SandboxSetting2::Disabled;
scope.executors[0].isolation = IsolationLevel2::Process;
assert_eq!(
plan(scope.clone(), &request)?.denial,
Some(SandboxDenial2::Unsupported)
);
scope.executors[0].isolation = IsolationLevel2::Kernel;
let artifact = plan(scope, &request)?.artifact.ok_or("allowed")?;
assert_eq!(artifact.minimum_isolation, IsolationLevel2::Kernel);
}
Ok(())
}

#[test]
fn host_network_requires_approval_for_every_trust_and_deployment() -> Res {
let (_dir, base, mut request) = fixture()?;
request.network = NetworkPolicy::Host;
for trust in TRUSTS {
for saas in [true, false] {
let mut scope = base.clone();
scope.network_ceiling = NetworkPolicy::Host;
scope.sources[0].trust = trust.clone();
if !saas {
scope.deployment = SandboxDeployment2::Local;
scope.origin = Origin::Operator;
}
let response = plan(scope, &request)?;
assert_eq!(response.denial, Some(SandboxDenial2::Approval));
assert!(response.artifact.is_none());
}
}
Ok(())
}

#[test]
fn credential_store_floor_is_excluded_from_every_grant_and_trust() -> Res {
let (_dir, base, mut request) = fixture()?;
let home = base.paths.home_dir.clone().ok_or("home")?;
for name in [".ssh", ".aws", ".gnupg"] {
let dir = home.join(name);
std::fs::create_dir_all(&dir)?;
for trust in TRUSTS {
for write in [false, true] {
for raw in [dir.clone(), dir.join("key")] {
let mut scope = base.clone();
scope.sources[0].trust = trust.clone();
// A ceiling that names the credential store cannot widen the floor.
scope.grant_ceiling.push(PathGrant {
path: dir.to_str().ok_or("utf8")?.into(),
write: true,
});
scope.paths.trusted_roots.push(PathTrustedRoot {
path: dir.clone(),
access: PathAccess::ReadWrite,
});
request.grants = vec![PathGrant {
path: raw.to_str().ok_or("utf8")?.into(),
write,
}];
let response = plan(scope, &request)?;
assert_eq!(response.denial, Some(SandboxDenial2::Floor), "{raw:?}");
assert!(response.artifact.is_none());
}
}
}
}
Ok(())
}
Loading