From 154ed4b8b8f81fe03119ee5eb53b4389ef7d5064 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 09:51:07 +0000 Subject: [PATCH 01/10] test(sandbox): cover trusted folder plan grants and credential denials Add a test asserting that a trusted folder plan carries pre-admitted grants and reports credential denials, including the workspace credential and the home .ssh directory, in its deny_read list. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/operator_options_tests.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/tinysecurity-sandbox/src/operator_options_tests.rs b/crates/tinysecurity-sandbox/src/operator_options_tests.rs index 4757bd35..c74dcd29 100644 --- a/crates/tinysecurity-sandbox/src/operator_options_tests.rs +++ b/crates/tinysecurity-sandbox/src/operator_options_tests.rs @@ -134,6 +134,37 @@ fn process_level_jail_runs_an_operator_command_only_when_the_request_accepts_it( Ok(()) } +#[test] +fn trusted_folder_plan_carries_pre_admitted_grants_and_credential_denials() -> Res { + let (home, mut scope, mut request) = operator()?; + scope.executors = vec![seatbelt(IsolationLevel2::Process)]; + request.accept_process_isolation = true; + + let ssh = home.path().join(".ssh"); + std::fs::create_dir(&ssh)?; + let app_credential = scope.paths.workspace_dir.join("credential.json"); + std::fs::write(&app_credential, "secret")?; + let mut wire_request = serde_json::to_value(&request)?; + wire_request["credential_denials"] = serde_json::json!([app_credential]); + let request: SandboxPlanRequest2 = serde_json::from_value(wire_request)?; + + let artifact = engine(scope.clone())? + .plan(&request, 1, 100)? + .artifact + .ok_or("trusted folder plan was denied")?; + + assert_eq!(artifact.minimum_isolation, IsolationLevel2::Process); + assert_eq!(artifact.grants.len(), 1); + assert!(artifact.grants[0].path.starts_with('/')); + let wire_artifact = serde_json::to_value(artifact)?; + let denied = wire_artifact["deny_read"] + .as_array() + .ok_or("folder plan did not return deny_read paths")?; + assert!(denied.contains(&serde_json::json!(app_credential))); + assert!(denied.contains(&serde_json::json!(ssh))); + Ok(()) +} + #[test] fn process_level_jail_never_runs_untrusted_or_saas_plans() -> Res { let (_dir, scope, mut request) = operator()?; From dc0c6962dbfb9f62d1d56ce8a7c14dc3db4f8132 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 09:53:23 +0000 Subject: [PATCH 02/10] feat(sandbox): add credential denials to folder jail plans Sandbox plans can now carry a list of credential paths that the host knows about, and artifacts return the canonical deny_read list for TinyBox NativeJailConfig. This lets an allow-default folder jail exclude native and host-supplied credential roots, so the contract version is bumped to 1.31. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/device_frame_tests.rs | 4 +- .../src/device_pairing_tests.rs | 2 +- crates/tinysecurity-bus/src/lib.rs | 2 +- crates/tinysecurity-bus/src/sandbox.rs | 18 +++-- crates/tinysecurity-bus/src/sandbox_tests.rs | 2 + crates/tinysecurity-bus/tests/scoped_wire.rs | 7 +- crates/tinysecurity-bus/tests/wire.rs | 2 +- .../sandbox_native_contract/fixture.rs | 1 + .../src/adapter_sandbox_tests.rs | 1 + crates/tinysecurity-sandbox/src/engine.rs | 40 ++++++++--- .../tinysecurity-sandbox/src/engine_tests.rs | 1 + crates/tinysecurity-sandbox/src/grants.rs | 69 ++++++++++++++++++- 12 files changed, 125 insertions(+), 24 deletions(-) diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index 0965607e..d78ee5b9 100644 --- a/crates/tinysecurity-bus/src/device_frame_tests.rs +++ b/crates/tinysecurity-bus/src/device_frame_tests.rs @@ -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))); diff --git a/crates/tinysecurity-bus/src/device_pairing_tests.rs b/crates/tinysecurity-bus/src/device_pairing_tests.rs index c46ebcd6..b741397b 100644 --- a/crates/tinysecurity-bus/src/device_pairing_tests.rs +++ b/crates/tinysecurity-bus/src/device_pairing_tests.rs @@ -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] diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 107fbf83..83c8d02d 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -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 diff --git a/crates/tinysecurity-bus/src/sandbox.rs b/crates/tinysecurity-bus/src/sandbox.rs index e2ce9245..791e9030 100644 --- a/crates/tinysecurity-bus/src/sandbox.rs +++ b/crates/tinysecurity-bus/src/sandbox.rs @@ -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, /// 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")] @@ -308,6 +314,10 @@ pub struct SandboxArtifact2 { pub backend: SandboxBackend, /// Canonical approved roots. pub grants: Vec, + /// 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, /// Exact admitted reach. pub network: NetworkPolicy, /// Bound exclusive mediation identity. diff --git a/crates/tinysecurity-bus/src/sandbox_tests.rs b/crates/tinysecurity-bus/src/sandbox_tests.rs index a02f0af0..ebaa8d8f 100644 --- a/crates/tinysecurity-bus/src/sandbox_tests.rs +++ b/crates/tinysecurity-bus/src/sandbox_tests.rs @@ -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, @@ -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(), diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index 25ebc13d..e6514509 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -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))); diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index 5bce67cf..70bb4821 100644 --- a/crates/tinysecurity-bus/tests/wire.rs +++ b/crates/tinysecurity-bus/tests/wire.rs @@ -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"), diff --git a/crates/tinysecurity-module/examples/sandbox_native_contract/fixture.rs b/crates/tinysecurity-module/examples/sandbox_native_contract/fixture.rs index 03726847..20ede352 100644 --- a/crates/tinysecurity-module/examples/sandbox_native_contract/fixture.rs +++ b/crates/tinysecurity-module/examples/sandbox_native_contract/fixture.rs @@ -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, diff --git a/crates/tinysecurity-module/src/adapter_sandbox_tests.rs b/crates/tinysecurity-module/src/adapter_sandbox_tests.rs index 39be7827..6e7c26af 100644 --- a/crates/tinysecurity-module/src/adapter_sandbox_tests.rs +++ b/crates/tinysecurity-module/src/adapter_sandbox_tests.rs @@ -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, diff --git a/crates/tinysecurity-sandbox/src/engine.rs b/crates/tinysecurity-sandbox/src/engine.rs index eb5d4e05..db776e68 100644 --- a/crates/tinysecurity-sandbox/src/engine.rs +++ b/crates/tinysecurity-sandbox/src/engine.rs @@ -77,6 +77,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() <= 64 + && request.credential_denials.iter().all(|path| { + path.is_absolute() + && path.as_os_str().len() <= 4096 + && !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) @@ -469,21 +478,28 @@ impl Engine { || facts.configured == SandboxSetting2::Enabled)); let constraints = requirements(request); let executor = select(facts, request, &constraints, isolation)?; + let 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. This is trusted folder + // confinement (for example Seatbelt), not a kernel-isolation claim. + IsolationLevel2::Process + }; + 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(), @@ -498,6 +514,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()) @@ -554,6 +571,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, diff --git a/crates/tinysecurity-sandbox/src/engine_tests.rs b/crates/tinysecurity-sandbox/src/engine_tests.rs index be13a06e..1e41b56e 100644 --- a/crates/tinysecurity-sandbox/src/engine_tests.rs +++ b/crates/tinysecurity-sandbox/src/engine_tests.rs @@ -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, diff --git a/crates/tinysecurity-sandbox/src/grants.rs b/crates/tinysecurity-sandbox/src/grants.rs index 51f133e4..04501613 100644 --- a/crates/tinysecurity-sandbox/src/grants.rs +++ b/crates/tinysecurity-sandbox/src/grants.rs @@ -1,6 +1,8 @@ //! Canonical grant admission reuses the native owner filesystem policy. use std::path::Path; -use tinysecurity_bus::{PathGrant, PathValidationResult, SandboxDenial2, SandboxScope2}; +use tinysecurity_bus::{ + 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()) @@ -37,6 +39,71 @@ 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, SandboxDenial2> { + use std::collections::BTreeSet; + + const MAX_DENIALS: usize = 64; + const MAX_PATH_BYTES: usize = 4096; + + if request.credential_denials.len() > MAX_DENIALS + || request.credential_denials.iter().any(|path| { + !path.is_absolute() + || path.as_os_str().len() > MAX_PATH_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_DENIALS * 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::>>() + .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; From 6f48400e68be746411586bc5c9e0f6feed3e742a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 09:53:34 +0000 Subject: [PATCH 03/10] test(sandbox): derive ssh path from scope home dir The trusted folder plan test now reads the home directory from the scope's paths instead of the temporary directory handle, so the assertion exercises the same path the operator plan actually uses. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-sandbox/src/operator_options_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-sandbox/src/operator_options_tests.rs b/crates/tinysecurity-sandbox/src/operator_options_tests.rs index c74dcd29..f4a36b03 100644 --- a/crates/tinysecurity-sandbox/src/operator_options_tests.rs +++ b/crates/tinysecurity-sandbox/src/operator_options_tests.rs @@ -136,11 +136,11 @@ fn process_level_jail_runs_an_operator_command_only_when_the_request_accepts_it( #[test] fn trusted_folder_plan_carries_pre_admitted_grants_and_credential_denials() -> Res { - let (home, mut scope, mut request) = operator()?; + let (_dir, mut scope, mut request) = operator()?; scope.executors = vec![seatbelt(IsolationLevel2::Process)]; request.accept_process_isolation = true; - let ssh = home.path().join(".ssh"); + let ssh = scope.paths.home_dir.as_ref().ok_or("home missing")?.join(".ssh"); std::fs::create_dir(&ssh)?; let app_credential = scope.paths.workspace_dir.join("credential.json"); std::fs::write(&app_credential, "secret")?; From 686fe66680c322dd7b67cd47b0e15b8ae6fb9a1f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 09:54:16 +0000 Subject: [PATCH 04/10] refactor(sandbox): use shared credential root limits Replace the local denial count and path byte constants in the sandbox engine and grant admission with the shared MAX_CREDENTIAL_ROOTS and MAX_CREDENTIAL_ROOT_BYTES limits so both paths enforce the same bounds. Add a test covering rejection of relative, oversized, and traversal credential denial paths. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-sandbox/src/engine.rs | 13 ++++---- crates/tinysecurity-sandbox/src/grants.rs | 28 +++++++--------- .../src/operator_options_tests.rs | 33 ++++++++++++++++++- docs/specs/sandbox-plan-contract-1.31.md | 25 ++++++++++++++ 4 files changed, 76 insertions(+), 23 deletions(-) create mode 100644 docs/specs/sandbox-plan-contract-1.31.md diff --git a/crates/tinysecurity-sandbox/src/engine.rs b/crates/tinysecurity-sandbox/src/engine.rs index db776e68..4c3a1d88 100644 --- a/crates/tinysecurity-sandbox/src/engine.rs +++ b/crates/tinysecurity-sandbox/src/engine.rs @@ -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)] @@ -77,10 +78,10 @@ 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() <= 64 + && request.credential_denials.len() <= MAX_CREDENTIAL_ROOTS && request.credential_denials.iter().all(|path| { path.is_absolute() - && path.as_os_str().len() <= 4096 + && path.as_os_str().len() <= MAX_CREDENTIAL_ROOT_BYTES && !path.to_string_lossy().contains('\0') && !path .components() diff --git a/crates/tinysecurity-sandbox/src/grants.rs b/crates/tinysecurity-sandbox/src/grants.rs index 04501613..3afbd813 100644 --- a/crates/tinysecurity-sandbox/src/grants.rs +++ b/crates/tinysecurity-sandbox/src/grants.rs @@ -1,7 +1,8 @@ //! Canonical grant admission reuses the native owner filesystem policy. use std::path::Path; use tinysecurity_bus::{ - PathGrant, PathValidationResult, SandboxDenial2, SandboxPlanRequest2, SandboxScope2, + 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 { @@ -53,13 +54,10 @@ pub(crate) fn credential_denials( ) -> std::result::Result, SandboxDenial2> { use std::collections::BTreeSet; - const MAX_DENIALS: usize = 64; - const MAX_PATH_BYTES: usize = 4096; - - if request.credential_denials.len() > MAX_DENIALS + if request.credential_denials.len() > MAX_CREDENTIAL_ROOTS || request.credential_denials.iter().any(|path| { !path.is_absolute() - || path.as_os_str().len() > MAX_PATH_BYTES + || path.as_os_str().len() > MAX_CREDENTIAL_ROOT_BYTES || path.to_string_lossy().contains('\0') || path .components() @@ -69,24 +67,22 @@ pub(crate) fn credential_denials( 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(); + 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_DENIALS * 2 { + 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)?, - ]; + let mut granted = + vec![resolve_ancestor(&scope.paths.workspace_dir).map_err(|_| SandboxDenial2::Floor)?]; granted.extend( grants .iter() diff --git a/crates/tinysecurity-sandbox/src/operator_options_tests.rs b/crates/tinysecurity-sandbox/src/operator_options_tests.rs index f4a36b03..008f677f 100644 --- a/crates/tinysecurity-sandbox/src/operator_options_tests.rs +++ b/crates/tinysecurity-sandbox/src/operator_options_tests.rs @@ -140,7 +140,12 @@ fn trusted_folder_plan_carries_pre_admitted_grants_and_credential_denials() -> R scope.executors = vec![seatbelt(IsolationLevel2::Process)]; request.accept_process_isolation = true; - let ssh = scope.paths.home_dir.as_ref().ok_or("home missing")?.join(".ssh"); + let ssh = scope + .paths + .home_dir + .as_ref() + .ok_or("home missing")? + .join(".ssh"); std::fs::create_dir(&ssh)?; let app_credential = scope.paths.workspace_dir.join("credential.json"); std::fs::write(&app_credential, "secret")?; @@ -165,6 +170,32 @@ fn trusted_folder_plan_carries_pre_admitted_grants_and_credential_denials() -> R Ok(()) } +#[test] +fn credential_denial_paths_must_be_bounded_absolute_and_traversal_free() -> Res { + let (_dir, scope, mut request) = operator()?; + request.credential_denials = vec![std::path::PathBuf::from("relative/credential")]; + assert_eq!( + engine(scope.clone())?.plan(&request, 1, 100)?.denial, + Some(SandboxDenial2::InvalidRequest) + ); + + request.credential_denials = vec![std::path::PathBuf::from("/tmp/../secret")]; + assert_eq!( + engine(scope.clone())?.plan(&request, 1, 100)?.denial, + Some(SandboxDenial2::InvalidRequest) + ); + + request.credential_denials = vec![std::path::PathBuf::from(format!( + "/tmp/{}", + "x".repeat(4096) + ))]; + assert_eq!( + engine(scope)?.plan(&request, 1, 100)?.denial, + Some(SandboxDenial2::InvalidRequest) + ); + Ok(()) +} + #[test] fn process_level_jail_never_runs_untrusted_or_saas_plans() -> Res { let (_dir, scope, mut request) = operator()?; diff --git a/docs/specs/sandbox-plan-contract-1.31.md b/docs/specs/sandbox-plan-contract-1.31.md new file mode 100644 index 00000000..d319dd66 --- /dev/null +++ b/docs/specs/sandbox-plan-contract-1.31.md @@ -0,0 +1,25 @@ +# Sandbox Plan contract 1.31 + +Contract 1.31 extends `SandboxPlanRequest2` with `credential_denials`, a bounded +list of additional absolute credential paths supplied by the trusted host. The +module combines these paths with the native roots derived from the registered +home. For a plan using process-level folder confinement, `SandboxArtifact2` +returns the canonical exclusion list as `deny_read`; the host passes it to +TinyBox without recomputing the credential floor. + +The request list is limited to 64 absolute paths of at most 4096 bytes each. +NUL and parent-directory components are refused. The module includes lexical +and resolved aliases in the returned exclusions. A workspace or writable grant +inside a denied credential store is refused because excluding that store would +also remove the execution location. + +`accept_process_isolation` applies only to a trusted host source outside SaaS +and permits a process-level jail such as macOS Seatbelt to provide folder +confinement. The resulting artifact continues to state +`minimum_isolation: process`; it never claims kernel isolation. Untrusted +sources and mandatory-isolation deployments still require an eligible kernel +or microVM executor. + +The artifact's `grants` are canonical, floor-checked, pre-admitted roots. Hosts +may use these roots directly at execution and must still revalidate TinyBox's +live capability and enforcement before launch. From 75630f8d1a100b20bd43514924463799ca77da74 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 09:56:36 +0000 Subject: [PATCH 05/10] test(sandbox): cover credential denials and deny_read wire shape Extend the sandbox wire-shape tests to assert that credential_denials and deny_read are omitted when unset and round-trip correctly when present, and rename the request test to reflect the 1.30 shape it now checks. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/sandbox_tests.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-bus/src/sandbox_tests.rs b/crates/tinysecurity-bus/src/sandbox_tests.rs index ebaa8d8f..16b4eec4 100644 --- a/crates/tinysecurity-bus/src/sandbox_tests.rs +++ b/crates/tinysecurity-bus/src/sandbox_tests.rs @@ -341,15 +341,26 @@ fn minimal_request() -> std::result::Result std::result::Result<(), Box> { 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::(with_unknown).is_err()); let back: SandboxPlanRequest2 = serde_json::from_value(value).required()?; assert!(back.options.is_default()); + assert!(back.credential_denials.is_empty()); + + 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(()) } @@ -387,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(()) From 3c84a40a32ee459011ad5bbd37e72f229169f1ab Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 09:56:54 +0000 Subject: [PATCH 06/10] docs(sandbox): wrap product names in backticks Doc comments now render `TinySecurity` and `TinyBox` as inline code so the product names stand out consistently. The corresponding test assertion was switched to `assert_eq!` against an empty array for a clearer failure message. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/sandbox.rs | 4 ++-- crates/tinysecurity-bus/src/sandbox_tests.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-bus/src/sandbox.rs b/crates/tinysecurity-bus/src/sandbox.rs index 791e9030..56eaf2fd 100644 --- a/crates/tinysecurity-bus/src/sandbox.rs +++ b/crates/tinysecurity-bus/src/sandbox.rs @@ -227,7 +227,7 @@ pub struct SandboxPlanRequest2 { /// exactly enforced constraints. #[serde(default)] pub accept_process_isolation: bool, - /// Additional credential files known to the trusted host. TinySecurity merges these + /// 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")] @@ -315,7 +315,7 @@ pub struct SandboxArtifact2 { /// Canonical approved roots. pub grants: Vec, /// Canonical credential paths to exclude from an allow-default folder jail. Hosts - /// pass this list directly to TinyBox `NativeJailConfig::deny_read`. + /// pass this list directly to `TinyBox` `NativeJailConfig::deny_read`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub deny_read: Vec, /// Exact admitted reach. diff --git a/crates/tinysecurity-bus/src/sandbox_tests.rs b/crates/tinysecurity-bus/src/sandbox_tests.rs index 16b4eec4..9de88926 100644 --- a/crates/tinysecurity-bus/src/sandbox_tests.rs +++ b/crates/tinysecurity-bus/src/sandbox_tests.rs @@ -351,7 +351,7 @@ fn a_request_without_new_folder_fields_keeps_the_1_30_wire_shape() assert!(serde_json::from_value::(with_unknown).is_err()); let back: SandboxPlanRequest2 = serde_json::from_value(value).required()?; assert!(back.options.is_default()); - assert!(back.credential_denials.is_empty()); + assert_eq!(back.credential_denials, []); let mut with_credentials = serde_json::to_value(&back).required()?; with_credentials["credential_denials"] = serde_json::json!(["/home/user/.app/credentials"]); From 2ade1148eb0bd5095a702400f70168969fa3ea46 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 09:57:07 +0000 Subject: [PATCH 07/10] test(sandbox): annotate empty vec type in credential denials assertion The assertion comparing credential_denials against an empty array literal now uses an explicitly typed empty Vec so the element type is inferred correctly. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/sandbox_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-bus/src/sandbox_tests.rs b/crates/tinysecurity-bus/src/sandbox_tests.rs index 9de88926..30c6ff77 100644 --- a/crates/tinysecurity-bus/src/sandbox_tests.rs +++ b/crates/tinysecurity-bus/src/sandbox_tests.rs @@ -351,7 +351,7 @@ fn a_request_without_new_folder_fields_keeps_the_1_30_wire_shape() assert!(serde_json::from_value::(with_unknown).is_err()); let back: SandboxPlanRequest2 = serde_json::from_value(value).required()?; assert!(back.options.is_default()); - assert_eq!(back.credential_denials, []); + assert_eq!(back.credential_denials, Vec::::new()); let mut with_credentials = serde_json::to_value(&back).required()?; with_credentials["credential_denials"] = serde_json::json!(["/home/user/.app/credentials"]); From 7907ec3f451da3bda166006ea2e914cf14802e78 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 09:57:22 +0000 Subject: [PATCH 08/10] refactor(sandbox): extract minimum isolation computation into a helper Moved the inline minimum isolation calculation out of the engine's plan path into a dedicated function so the same logic can be reused and tested on its own. Behaviour is unchanged. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-sandbox/src/engine.rs | 30 ++++++++++++++--------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/crates/tinysecurity-sandbox/src/engine.rs b/crates/tinysecurity-sandbox/src/engine.rs index 4c3a1d88..25d70981 100644 --- a/crates/tinysecurity-sandbox/src/engine.rs +++ b/crates/tinysecurity-sandbox/src/engine.rs @@ -124,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 { let mut constraints = vec![SandboxConstraint2::Filesystem, SandboxConstraint2::Network]; for (required, constraint) in [ @@ -479,18 +496,7 @@ impl Engine { || facts.configured == SandboxSetting2::Enabled)); let constraints = requirements(request); let executor = select(facts, request, &constraints, isolation)?; - let 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. This is trusted folder - // confinement (for example Seatbelt), not a kernel-isolation claim. - IsolationLevel2::Process - }; + let minimum_isolation = minimum_isolation(executor, &constraints, isolation); let deny_read = if minimum_isolation == IsolationLevel2::Process { grants::credential_denials(facts, request, &grants)? } else { From df454adf06762bbb02dfa9a727eac5dfc59c0085 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 09:59:27 +0000 Subject: [PATCH 09/10] docs(sandbox): clarify isolation floor and host tool filter Document that `Process` may represent trusted folder confinement such as Seatbelt and never a kernel-isolation floor, and record that OpenHuman's `SandboxMode::ReadOnly` is a host-side tool filter mapping to no sandbox backend rather than a confinement claim or `Plan2` artifact. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/sandbox.rs | 3 ++- docs/specs/sandbox-plan-contract-1.31.md | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-bus/src/sandbox.rs b/crates/tinysecurity-bus/src/sandbox.rs index 56eaf2fd..079c0b5a 100644 --- a/crates/tinysecurity-bus/src/sandbox.rs +++ b/crates/tinysecurity-bus/src/sandbox.rs @@ -284,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, diff --git a/docs/specs/sandbox-plan-contract-1.31.md b/docs/specs/sandbox-plan-contract-1.31.md index d319dd66..a265cac5 100644 --- a/docs/specs/sandbox-plan-contract-1.31.md +++ b/docs/specs/sandbox-plan-contract-1.31.md @@ -20,6 +20,12 @@ confinement. The resulting artifact continues to state sources and mandatory-isolation deployments still require an eligible kernel or microVM executor. +OpenHuman's `SandboxMode::ReadOnly` is a host-side tool filter that removes +write and execute tools. It maps to no sandbox backend, with network allowed; +it does not create a filesystem-confinement claim or a `Plan2` artifact. The +host must preserve that tool filter independently of TinySecurity's sandbox +planner. + The artifact's `grants` are canonical, floor-checked, pre-admitted roots. Hosts may use these roots directly at execution and must still revalidate TinyBox's live capability and enforcement before launch. From ab131a8bfee9a3115eee439381cf74d5475d072b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 10:18:24 +0000 Subject: [PATCH 10/10] test(sandbox): use is_absolute for grant path assertion Replace the string prefix check with a Path::is_absolute assertion so the test verifies absolute paths correctly on platforms where the separator differs. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-sandbox/src/operator_options_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-sandbox/src/operator_options_tests.rs b/crates/tinysecurity-sandbox/src/operator_options_tests.rs index 008f677f..888a5398 100644 --- a/crates/tinysecurity-sandbox/src/operator_options_tests.rs +++ b/crates/tinysecurity-sandbox/src/operator_options_tests.rs @@ -160,7 +160,7 @@ fn trusted_folder_plan_carries_pre_admitted_grants_and_credential_denials() -> R assert_eq!(artifact.minimum_isolation, IsolationLevel2::Process); assert_eq!(artifact.grants.len(), 1); - assert!(artifact.grants[0].path.starts_with('/')); + assert!(std::path::Path::new(&artifact.grants[0].path).is_absolute()); let wire_artifact = serde_json::to_value(artifact)?; let denied = wire_artifact["deny_read"] .as_array()