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..079c0b5a 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")] @@ -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, @@ -308,6 +315,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..30c6ff77 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(), @@ -339,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_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"]); + 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(()) } @@ -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(()) 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..25d70981 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,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) @@ -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 { let mut constraints = vec![SandboxConstraint2::Filesystem, SandboxConstraint2::Network]; for (required, constraint) in [ @@ -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(), @@ -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()) @@ -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, 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..3afbd813 100644 --- a/crates/tinysecurity-sandbox/src/grants.rs +++ b/crates/tinysecurity-sandbox/src/grants.rs @@ -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()) @@ -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, 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::>>() + .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; diff --git a/crates/tinysecurity-sandbox/src/operator_options_tests.rs b/crates/tinysecurity-sandbox/src/operator_options_tests.rs index 4757bd35..888a5398 100644 --- a/crates/tinysecurity-sandbox/src/operator_options_tests.rs +++ b/crates/tinysecurity-sandbox/src/operator_options_tests.rs @@ -134,6 +134,68 @@ 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 (_dir, mut scope, mut request) = operator()?; + 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"); + 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!(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() + .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 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..a265cac5 --- /dev/null +++ b/docs/specs/sandbox-plan-contract-1.31.md @@ -0,0 +1,31 @@ +# 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. + +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.