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
4 changes: 2 additions & 2 deletions crates/tinysecurity-bus/src/device_frame_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ 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, (1, 30));
assert!(crate::is_compatible((1, 30)));
assert_eq!(crate::CONTRACT_VERSION, (1, 31));
assert!(crate::is_compatible((1, 31)));
assert!(crate::is_compatible((1, 31)));
assert!(!crate::is_compatible((1, 29)));
assert!(!crate::is_compatible((2, 30)));
Expand Down
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 @@ -85,7 +85,7 @@ fn refresh_and_close_are_distinct_transient_session_operations() {

#[test]
fn pairing_contract_is_versioned_after_the_frame_contract() {
assert_eq!(CONTRACT_VERSION, (1, 30));
assert_eq!(CONTRACT_VERSION, (1, 31));
}

#[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 @@ -65,7 +65,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) = (1, 30);
pub const CONTRACT_VERSION: (u32, u32) = (1, 31);
/// 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
21 changes: 16 additions & 5 deletions crates/tinysecurity-bus/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,18 @@ pub struct SandboxPlanRequest2 {
/// Require child-process denial.
pub deny_subprocesses: bool,
/// The command is an ordinary tool command of the scope's operator (contract 1.21),
/// so a process-level jail whose constraints are best effort (macOS Seatbelt) may run
/// it. Honoured only for a trusted host source outside `SaaS`; a plan for untrusted
/// code or mandatory isolation is refused when it sets this. Absent means the
/// executor must meet the kernel-isolation floor with exactly enforced constraints.
/// so a process-level jail whose constraints are best effort (such as macOS Seatbelt)
/// may provide trusted folder confinement. Honoured only for a trusted host source
/// outside `SaaS`; a plan for untrusted code or mandatory isolation is refused when it
/// sets this. Absent means the executor must meet the kernel-isolation floor with
/// exactly enforced constraints.
#[serde(default)]
pub accept_process_isolation: bool,
/// Additional credential files known to the trusted host. `TinySecurity` merges these
/// with native credential roots for an allow-default folder jail and returns the
/// canonical list in the artifact's `deny_read` field.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub credential_denials: Vec<std::path::PathBuf>,
/// Executor preference, workspace-less and host-run options (contract 1.22). Absent
/// means the module chooses the executor and the plan grants what is requested.
#[serde(default, skip_serializing_if = "SandboxPlanOptions2::is_default")]
Expand Down Expand Up @@ -278,7 +284,8 @@ impl SandboxPlanOptions2 {
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SandboxArtifact2 {
/// Minimum verified isolation required at actual launch.
/// Minimum verified isolation required at launch. `Process` can represent trusted
/// folder confinement such as Seatbelt; it never represents a kernel-isolation floor.
pub minimum_isolation: IsolationLevel2,
/// Opaque module-issued identity; a public fingerprint never supplies permission.
pub plan_id: String,
Expand Down Expand Up @@ -308,6 +315,10 @@ pub struct SandboxArtifact2 {
pub backend: SandboxBackend,
/// Canonical approved roots.
pub grants: Vec<PathGrant>,
/// Canonical credential paths to exclude from an allow-default folder jail. Hosts
/// pass this list directly to `TinyBox` `NativeJailConfig::deny_read`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deny_read: Vec<std::path::PathBuf>,
/// Exact admitted reach.
pub network: NetworkPolicy,
/// Bound exclusive mediation identity.
Expand Down
18 changes: 17 additions & 1 deletion crates/tinysecurity-bus/src/sandbox_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ fn diagnostic_operation(
limits: ResourceLimits2::default(),
deny_subprocesses: true,
accept_process_isolation: false,
credential_denials: vec![],
options: SandboxPlanOptions2::default(),
generation: 1,
ttl_ms: 1,
Expand All @@ -305,6 +306,7 @@ fn diagnostic_operation(
capability_id: "private".into(),
backend: SandboxBackend::Docker,
grants: vec![],
deny_read: vec![],
network: NetworkPolicy::Host,
proxy_id: Some("private".into()),
limits: ResourceLimits2::default(),
Expand Down Expand Up @@ -339,15 +341,26 @@ fn minimal_request() -> std::result::Result<serde_json::Value, Box<dyn std::erro
}

#[test]
fn a_request_without_options_keeps_the_1_21_wire_shape()
fn a_request_without_new_folder_fields_keeps_the_1_30_wire_shape()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let value = minimal_request()?;
assert!(value.get("options").is_none(), "{value}");
assert!(value.get("credential_denials").is_none(), "{value}");
let mut with_unknown = value.clone();
with_unknown["options"] = serde_json::json!({"unknown": true});
assert!(serde_json::from_value::<SandboxPlanRequest2>(with_unknown).is_err());
let back: SandboxPlanRequest2 = serde_json::from_value(value).required()?;
assert!(back.options.is_default());
assert_eq!(back.credential_denials, Vec::<std::path::PathBuf>::new());

let mut with_credentials = serde_json::to_value(&back).required()?;
with_credentials["credential_denials"] = serde_json::json!(["/home/user/.app/credentials"]);
let back: SandboxPlanRequest2 = serde_json::from_value(with_credentials.clone()).required()?;
assert_eq!(
back.credential_denials,
[std::path::PathBuf::from("/home/user/.app/credentials")]
);
assert_eq!(serde_json::to_value(back).required()?, with_credentials);
Ok(())
}

Expand Down Expand Up @@ -385,11 +398,14 @@ fn an_artifact_marks_a_host_run_and_a_workspace_less_plan_only_when_set()
let (_, mut artifact) = private_pair();
let value = serde_json::to_value(&artifact).required()?;
assert!(value.get("host_run").is_none() && value.get("no_workspace").is_none());
assert!(value.get("deny_read").is_none());
artifact.host_run = true;
artifact.no_workspace = true;
artifact.deny_read = vec!["/home/user/.ssh".into()];
let value = serde_json::to_value(&artifact).required()?;
assert_eq!(value["host_run"], true);
assert_eq!(value["no_workspace"], true);
assert_eq!(value["deny_read"], serde_json::json!(["/home/user/.ssh"]));
let back: SandboxArtifact2 = serde_json::from_value(value).required()?;
assert!(back.host_run && back.no_workspace);
Ok(())
Expand Down
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 @@ -91,10 +91,11 @@ fn new_members_are_advertised_and_named_by_contract_constants() {

#[test]
fn contract_bump_is_additive_for_older_hosts_and_modules() {
assert_eq!(CONTRACT_VERSION, (1, 30));
// A host built against 1.30 accepts this module and newer minors, not a 1.29 module.
assert!(is_compatible((1, 30)));
assert_eq!(CONTRACT_VERSION, (1, 31));
// A host built against 1.31 accepts this module and newer minors, not a 1.30 module.
assert!(is_compatible((1, 31)));
assert!(is_compatible((1, 32)));
assert!(!is_compatible((1, 30)));
assert!(!is_compatible((1, 29)));
assert!(!is_compatible((1, 28)));
assert!(!is_compatible((1, 26)));
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 @@ -343,7 +343,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, (1, 30));
assert_eq!(CONTRACT_VERSION, (1, 31));
for (mode, wire) in [
(RedactMode::Text, "text"),
(RedactMode::StructuredArguments, "structured_arguments"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ pub(super) fn request(scope: &SandboxScope2, sequence: u64) -> SandboxPlanReques
limits: ResourceLimits2::default(),
deny_subprocesses: false,
accept_process_isolation: false,
credential_denials: vec![],
options: tinysecurity_bus::SandboxPlanOptions2::default(),
generation: 1,
ttl_ms: 30_000,
Expand Down
1 change: 1 addition & 0 deletions crates/tinysecurity-module/src/adapter_sandbox_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ async fn legacy_plan_and_missing_registered_scope_never_authorize()
limits: tinysecurity_bus::ResourceLimits2::default(),
deny_subprocesses: false,
accept_process_isolation: false,
credential_denials: vec![],
options: tinysecurity_bus::SandboxPlanOptions2::default(),
generation: 1,
ttl_ms: 1000,
Expand Down
55 changes: 40 additions & 15 deletions crates/tinysecurity-sandbox/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ use std::{
};
use tinysecurity_bus::{
AccessTier, CallerContext, Check, ConstraintEnforcement2, Decision, DenialReason,
EvaluateRequest, IsolationLevel2, MAX_SANDBOX_SCOPES2, NetworkPolicy, Origin, ResourceLimits2,
SandboxArtifact2, SandboxBackend, SandboxConfig2, SandboxConstraint2, SandboxDenial2,
SandboxDeployment2, SandboxExecutor2, SandboxPlanRequest2, SandboxPlanResponse2, SandboxScope2,
SandboxSetting2, SandboxSource2, SourceTrust, SubjectScope, ToolCall, Verdict,
EvaluateRequest, IsolationLevel2, MAX_CREDENTIAL_ROOT_BYTES, MAX_CREDENTIAL_ROOTS,
MAX_SANDBOX_SCOPES2, NetworkPolicy, Origin, ResourceLimits2, SandboxArtifact2, SandboxBackend,
SandboxConfig2, SandboxConstraint2, SandboxDenial2, SandboxDeployment2, SandboxExecutor2,
SandboxPlanRequest2, SandboxPlanResponse2, SandboxScope2, SandboxSetting2, SandboxSource2,
SourceTrust, SubjectScope, ToolCall, Verdict,
};
use tinysecurity_policy::{PathPolicyRegistry, PathScope};
#[derive(Clone)]
Expand Down Expand Up @@ -77,6 +78,15 @@ fn well_formed(request: &SandboxPlanRequest2, facts: &SandboxScope2) -> bool {
&& request.operation_json.len() <= 65_536
&& request.grants.len() <= 128
&& request.grants.iter().all(|g| g.path.len() <= 4096)
&& request.credential_denials.len() <= MAX_CREDENTIAL_ROOTS
&& request.credential_denials.iter().all(|path| {
path.is_absolute()
&& path.as_os_str().len() <= MAX_CREDENTIAL_ROOT_BYTES
&& !path.to_string_lossy().contains('\0')
&& !path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
})
&& request.ttl_ms != 0
&& request.ttl_ms <= facts.max_ttl_ms
&& valid_limits(&request.limits)
Expand Down Expand Up @@ -114,6 +124,23 @@ fn suitable_for_untrusted(executor: &SandboxExecutor2) -> bool {
IsolationLevel2::Kernel | IsolationLevel2::MicroVm
)
}
fn minimum_isolation(
executor: &SandboxExecutor2,
constraints: &[SandboxConstraint2],
isolation: bool,
) -> IsolationLevel2 {
if !isolation {
IsolationLevel2::None
} else if suitable_for_untrusted(executor) && constraints.iter().all(|c| supports(executor, *c))
{
IsolationLevel2::Kernel
} else {
// Only an executor chosen for a request that accepted process isolation can be
// here: the kernel-level pass found nothing exact. This is trusted folder
// confinement (for example Seatbelt), not a kernel-isolation claim.
IsolationLevel2::Process
}
}
fn requirements(request: &SandboxPlanRequest2) -> Vec<SandboxConstraint2> {
let mut constraints = vec![SandboxConstraint2::Filesystem, SandboxConstraint2::Network];
for (required, constraint) in [
Expand Down Expand Up @@ -469,21 +496,17 @@ impl Engine {
|| facts.configured == SandboxSetting2::Enabled));
let constraints = requirements(request);
let executor = select(facts, request, &constraints, isolation)?;
let minimum_isolation = minimum_isolation(executor, &constraints, isolation);
let deny_read = if minimum_isolation == IsolationLevel2::Process {
grants::credential_denials(facts, request, &grants)?
} else {
Vec::new()
};
let expires_at_ms = now_ms
.checked_add(u64::from(request.ttl_ms))
.ok_or(SandboxDenial2::InvalidRequest)?;
let mut artifact = SandboxArtifact2 {
minimum_isolation: if !isolation {
IsolationLevel2::None
} else if suitable_for_untrusted(executor)
&& constraints.iter().all(|c| supports(executor, *c))
{
IsolationLevel2::Kernel
} else {
// Only an executor chosen for a request that accepted process isolation
// can be here: the kernel-level pass found nothing exact.
IsolationLevel2::Process
},
minimum_isolation,
approval_reservation_id: None,
plan_id: String::new(),
fingerprint: String::new(),
Expand All @@ -498,6 +521,7 @@ impl Engine {
capability_id: executor.capability_id.clone(),
backend: executor.backend.clone(),
grants,
deny_read,
network: request.network.clone(),
proxy_id: if request.network == NetworkPolicy::EgressProxy {
facts.proxy.as_ref().map(|p| p.proxy_id.clone())
Expand Down Expand Up @@ -554,6 +578,7 @@ impl Engine {
capability_id: HOST_EXECUTOR.into(),
backend: SandboxBackend::Noop,
grants: Vec::new(),
deny_read: Vec::new(),
network: NetworkPolicy::Host,
proxy_id: None,
limits: none,
Expand Down
1 change: 1 addition & 0 deletions crates/tinysecurity-sandbox/src/engine_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ pub(crate) fn fixture() -> std::result::Result<
limits: ResourceLimits2::default(),
deny_subprocesses: false,
accept_process_isolation: false,
credential_denials: vec![],
options: SandboxPlanOptions2::default(),
generation: 1,
ttl_ms: 1000,
Expand Down
65 changes: 64 additions & 1 deletion crates/tinysecurity-sandbox/src/grants.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
//! Canonical grant admission reuses the native owner filesystem policy.
use std::path::Path;
use tinysecurity_bus::{PathGrant, PathValidationResult, SandboxDenial2, SandboxScope2};
use tinysecurity_bus::{
MAX_CREDENTIAL_ROOT_BYTES, MAX_CREDENTIAL_ROOTS, PathGrant, PathValidationResult,
SandboxDenial2, SandboxPlanRequest2, SandboxScope2,
};
use tinysecurity_policy::{PathScope, protected_path, resolve_ancestor};
fn floor(root: &Path, scope: &SandboxScope2) -> bool {
tinysecurity_policy::protected_subtree(root, scope.paths.home_dir.as_deref())
Expand Down Expand Up @@ -37,6 +40,66 @@ pub(crate) fn admit(
result.dedup();
Ok(result)
}

/// Produce the canonical deny-read list for an allow-default folder jail.
///
/// Native credential roots are derived from the registered home using the same policy
/// vocabulary as path checks. Additional roots come from the trusted host's credential
/// stores (for example, an application profile file outside home). A workspace or
/// writable grant inside a denied store cannot be confined safely and denies the plan.
pub(crate) fn credential_denials(
scope: &SandboxScope2,
request: &SandboxPlanRequest2,
grants: &[PathGrant],
) -> std::result::Result<Vec<std::path::PathBuf>, SandboxDenial2> {
use std::collections::BTreeSet;

if request.credential_denials.len() > MAX_CREDENTIAL_ROOTS
|| request.credential_denials.iter().any(|path| {
!path.is_absolute()
|| path.as_os_str().len() > MAX_CREDENTIAL_ROOT_BYTES
|| path.to_string_lossy().contains('\0')
|| path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
})
{
return Err(SandboxDenial2::InvalidRequest);
}

let mut roots: BTreeSet<_> =
tinysecurity_policy::credential_roots(scope.paths.home_dir.as_deref())
.map_err(|_| SandboxDenial2::Floor)?
.into_iter()
.collect();
for path in &request.credential_denials {
let resolved = resolve_ancestor(path).map_err(|_| SandboxDenial2::Floor)?;
roots.insert(path.clone());
roots.insert(resolved);
if roots.len() > MAX_CREDENTIAL_ROOTS * 2 {
return Err(SandboxDenial2::InvalidRequest);
}
}

let mut granted =
vec![resolve_ancestor(&scope.paths.workspace_dir).map_err(|_| SandboxDenial2::Floor)?];
granted.extend(
grants
.iter()
.filter(|grant| grant.write)
.map(|grant| resolve_ancestor(Path::new(&grant.path)))
.collect::<std::io::Result<Vec<_>>>()
.map_err(|_| SandboxDenial2::Floor)?,
);
if roots
.iter()
.any(|root| granted.iter().any(|path| path.starts_with(root)))
{
return Err(SandboxDenial2::Floor);
}

Ok(roots.into_iter().collect())
}
#[cfg(test)]
#[path = "grants_tests.rs"]
mod tests;
Loading
Loading