From c2b6cbbe7ed9c9049480eeede8608b8e5bf16409 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 15:08:11 +0300 Subject: [PATCH 1/7] feat(sandbox): runtime sandbox scopes for unbounded subjects (contract 1.19) Adds RegisterSandboxScope2 and RetireSandboxScope2 so a host serving many subjects plans sandboxes per subject without growing the init-only scope table. Runtime scopes audit on their own host-persisted stream under the stream-pin rules of runtime approval scopes (now a shared StreamPins), the table is bounded without evicting live scopes, and retirement discards every artifact the scope issued. Co-authored-by: Medulla --- MODULE.md | 36 ++ README.md | 2 +- crates/tinysecurity-bus/src/lib.rs | 4 +- crates/tinysecurity-bus/src/names.rs | 6 + crates/tinysecurity-bus/src/sandbox_scopes.rs | 83 ++++ .../src/sandbox_scopes_tests.rs | 133 +++++ crates/tinysecurity-bus/tests/scoped_wire.rs | 7 +- crates/tinysecurity-bus/tests/wire.rs | 9 +- crates/tinysecurity-module/src/adapter.rs | 41 +- .../src/adapter_sandbox.rs | 22 +- .../src/adapter_sandbox_scopes.rs | 267 ++++++++++ .../src/adapter_sandbox_scopes_tests.rs | 458 ++++++++++++++++++ .../src/adapter_sandbox_tests.rs | 3 + .../tinysecurity-module/src/adapter_tests.rs | 8 + .../src/approval_callbacks.rs | 85 +--- .../src/approval_callbacks_tests.rs | 4 +- crates/tinysecurity-module/src/lib.rs | 2 + crates/tinysecurity-module/src/stream_pins.rs | 97 ++++ .../src/stream_pins_tests.rs | 52 ++ crates/tinysecurity-sandbox/src/engine.rs | 259 ++++++---- crates/tinysecurity-sandbox/src/error/mod.rs | 12 + crates/tinysecurity-sandbox/src/lib.rs | 2 +- crates/tinysecurity-sandbox/src/registry.rs | 33 +- .../src/runtime_scopes_tests.rs | 221 +++++++++ 24 files changed, 1664 insertions(+), 182 deletions(-) create mode 100644 crates/tinysecurity-bus/src/sandbox_scopes.rs create mode 100644 crates/tinysecurity-bus/src/sandbox_scopes_tests.rs create mode 100644 crates/tinysecurity-module/src/adapter_sandbox_scopes.rs create mode 100644 crates/tinysecurity-module/src/adapter_sandbox_scopes_tests.rs create mode 100644 crates/tinysecurity-module/src/stream_pins.rs create mode 100644 crates/tinysecurity-module/src/stream_pins_tests.rs create mode 100644 crates/tinysecurity-sandbox/src/runtime_scopes_tests.rs diff --git a/MODULE.md b/MODULE.md index 1552412..1b3a92e 100644 --- a/MODULE.md +++ b/MODULE.md @@ -432,6 +432,42 @@ them. The tool-permission members (`Evaluate`, `Check` and their scoped and audi forms) still cannot return `Allow` once approvals are configured, because a tool call must go through the durable lifecycle. +## Runtime sandbox scopes (contract 1.19) + +Additive: a 1.18 host keeps working against a 1.19 module, and a 1.19 host refuses a +1.18 module (it requires the two scope members). + +`SandboxConfig2` fixes at most 128 immutable scopes at init, and each scope's subject +must equal the audit scope's subject. A host that serves an unbounded set of subjects +(profiles, tenants, derived agents) registers each subject's scope later, the same way +it registers runtime approval scopes: + +| Member | Effect | +| --- | --- | +| `RegisterSandboxScope2` | Adds a scope validated exactly like an init scope. An identical repeat on the same stream is `unchanged`; the same identity with other facts or another stream, or an initialized identity, is refused. | +| `RetireSandboxScope2` | Removes a runtime scope and discards every artifact it issued, pending or active, so none can be checked or consumed afterwards. Initialized scopes are never retired. | + +Both are private host-only members served to the authenticated init peer. They need a +configured sandbox engine (an empty `SandboxConfig2` is enough) and a configured audit: +every runtime scope carries a host-persisted `audit_stream_id` and records its plans and +consumes on the host `AuditSink2` under its own subject and stream, never in a native +journal. A scope's `approval_scope_id` must name a live approval scope of the same +subject. The receipt (`SandboxScopeReceipt2.scope_handle`, only when +`report_handle` is set, never on a retirement) carries the subject exactly as +`AuditSink2` receives it, so a host keys its ledgers by it. + +Bounds and eviction never widen policy. The table holds at most 65,536 live scopes; a +full table refuses a new registration (`capacity`) and never evicts a live scope, so a +subject's ceilings are never silently dropped or replaced. Retirement is the only way a +scope leaves, and the registry slot, artifacts and audit client go with it. A runtime +scope holds at most 8 staged or active artifacts (initialized scopes keep their share of +1024), so one subject cannot exhaust another's capacity. A live subject keeps its audit +stream and a retired one keeps it for an hour: registering it again with another stream +in that window is refused, so a plan still in flight for a retired scope cannot reach +another ledger. Several scope identities may share a subject; its stream is released +only when the last of them retires. Changing a live scope's facts is a retirement +followed by a new registration, which also invalidates its outstanding artifacts. + ## Per-call exact credentials: `RedactWith` (contract 1.16) Additive: a 1.15 host keeps working against a 1.16 module, and a 1.16 host diff --git a/README.md b/README.md index 4fc3150..f465f9e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Blocked original content denies; suspicious content denies with approval-unavailable in unbound policy methods. Scan verdicts never grant permission, and redaction never clears an existing scan finding. -Contract 1.18 returns the sanitized subject handle in `ApprovalScopeReceipt2` and keeps the path, command and egress members available when approvals are configured (see [MODULE.md](MODULE.md#subject-handles-and-unbound-members-contract-118)). Contract 1.17 adds runtime approval scopes (`RegisterApprovalScope2`, `ReplaceApprovalScope2`, `RetireApprovalScope2`), tool and flow grants, and `Check::HostTool` (see [MODULE.md](MODULE.md#runtime-approval-scopes-and-host-tools-contract-117)). Contract 1.16 adds `RedactWith`, redaction with exact credentials supplied for one call (see [MODULE.md](MODULE.md#per-call-exact-credentials-redactwith-contract-116)). Contract 1.15 adds `RedactMode::CredentialsOnly` and the `EgressPolicy` fields `open_public` and `preflight_transports` (see [MODULE.md](MODULE.md#credentials-only-redaction-and-open-public-egress-contract-115)). Contract 1.14 adds confidential `PasswordCrypt2`, `KeyringAccess2` and `PairingControl2` (see [MODULE.md](MODULE.md)). Contract 1.13 added `EvaluateScopedAudited` and `CheckScopedAudited` for registered policies under required audit (see [`docs/specs/scoped-policies.md`](docs/specs/scoped-policies.md)). +Contract 1.19 adds runtime sandbox scopes (`RegisterSandboxScope2`, `RetireSandboxScope2`), so a host that serves unbounded subjects can plan sandboxes per subject (see [MODULE.md](MODULE.md#runtime-sandbox-scopes-contract-119)). Contract 1.18 returns the sanitized subject handle in `ApprovalScopeReceipt2` and keeps the path, command and egress members available when approvals are configured (see [MODULE.md](MODULE.md#subject-handles-and-unbound-members-contract-118)). Contract 1.17 adds runtime approval scopes (`RegisterApprovalScope2`, `ReplaceApprovalScope2`, `RetireApprovalScope2`), tool and flow grants, and `Check::HostTool` (see [MODULE.md](MODULE.md#runtime-approval-scopes-and-host-tools-contract-117)). Contract 1.16 adds `RedactWith`, redaction with exact credentials supplied for one call (see [MODULE.md](MODULE.md#per-call-exact-credentials-redactwith-contract-116)). Contract 1.15 adds `RedactMode::CredentialsOnly` and the `EgressPolicy` fields `open_public` and `preflight_transports` (see [MODULE.md](MODULE.md#credentials-only-redaction-and-open-public-egress-contract-115)). Contract 1.14 adds confidential `PasswordCrypt2`, `KeyringAccess2` and `PairingControl2` (see [MODULE.md](MODULE.md)). Contract 1.13 added `EvaluateScopedAudited` and `CheckScopedAudited` for registered policies under required audit (see [`docs/specs/scoped-policies.md`](docs/specs/scoped-policies.md)). Contract 1.6 supplies `Audit2`, `EvaluateAudited2` and `CheckAudited2` with authenticated exact audit callbacks and a bounded [native journal](crates/tinysecurity-audit/README.md). diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index bf10756..fe1d911 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -12,6 +12,8 @@ mod device; pub use device::*; mod sandbox; pub use sandbox::*; +mod sandbox_scopes; +pub use sandbox_scopes::*; mod judge; pub use judge::*; mod approval; @@ -45,7 +47,7 @@ pub use redact::RedactorConfig; pub use scoped_policy::*; pub mod names; /// Current wire vocabulary version, independent of the artifact version. -pub const CONTRACT_VERSION: (u32, u32) = (1, 18); +pub const CONTRACT_VERSION: (u32, u32) = (1, 19); /// 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/names.rs b/crates/tinysecurity-bus/src/names.rs index 3baebe9..3a97322 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -113,6 +113,10 @@ pub mod methods { pub const PLAN: &str = "Plan"; /// Immutable-scoped declarative sandbox planning. pub const PLAN2: &str = "Plan2"; + /// Register a runtime sandbox scope for one subject (contract 1.19). + pub const REGISTER_SANDBOX_SCOPE2: &str = "RegisterSandboxScope2"; + /// Retire a runtime sandbox scope so later calls under it deny (contract 1.19). + pub const RETIRE_SANDBOX_SCOPE2: &str = "RetireSandboxScope2"; /// Exact human approval creation for immutable-ceiling sandbox host networking. pub const EVALUATE_SANDBOX_APPROVAL2: &str = "EvaluateSandboxApproval2"; /// Exact audited once-only execution admission for an issued declarative artifact. @@ -178,6 +182,8 @@ pub const METHODS: &[&str] = &[ methods::REGISTER_APPROVAL_SCOPE2, methods::REPLACE_APPROVAL_SCOPE2, methods::RETIRE_APPROVAL_SCOPE2, + methods::REGISTER_SANDBOX_SCOPE2, + methods::RETIRE_SANDBOX_SCOPE2, methods::IMPORT_APPROVAL_HISTORY2, methods::PARSE_REPLY, methods::POLICY_INFO, diff --git a/crates/tinysecurity-bus/src/sandbox_scopes.rs b/crates/tinysecurity-bus/src/sandbox_scopes.rs new file mode 100644 index 0000000..c549c8a --- /dev/null +++ b/crates/tinysecurity-bus/src/sandbox_scopes.rs @@ -0,0 +1,83 @@ +//! Runtime sandbox scopes (contract 1.19). +//! +//! `SandboxConfig2` fixes at most 128 immutable scopes at init, and each scope's +//! subject must equal the audit scope's subject. A host that serves an unbounded set +//! of subjects (profiles, tenants, derived agents) registers each subject's scope +//! after init with `RegisterSandboxScope2` and releases it with +//! `RetireSandboxScope2`, exactly like runtime approval scopes. Both are private +//! host-only members served to the authenticated init peer: the facts are a trusted +//! adapter claim, never model input. +use crate::SandboxScope2; +use serde::{Deserialize, Serialize}; + +/// Most live sandbox scopes one module keeps (initialized scopes included). +pub const MAX_SANDBOX_SCOPES2: usize = 65_536; + +/// Register one runtime sandbox scope. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RegisterSandboxScopeRequest2 { + /// Registration to add; validated exactly like an init scope. + pub scope: SandboxScope2, + /// Host-persisted random 32-hex audit stream of this subject. Runtime scopes + /// always forward audit to the host `AuditSink2` under this stream; they never + /// open a native journal. + pub audit_stream_id: String, + /// Ask for the sanitized subject handle in the receipt. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub report_handle: bool, +} + +/// Retire one runtime sandbox scope. Initialized scopes can never be retired. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RetireSandboxScopeRequest2 { + /// Registration to remove; later calls under this identity deny. + pub scope_id: String, +} + +/// What a sandbox scope change did. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SandboxScopeOutcome2 { + /// A new scope is live. + Created, + /// An identical registration already existed; nothing changed. + Unchanged, + /// The scope is gone and every artifact it issued was discarded. + Retired, +} + +/// Receipt of a sandbox scope change. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SandboxScopeReceipt2 { + /// Affected registration. + pub scope_id: String, + /// The sanitized subject handle the module sends to `AuditSink2` for this + /// registration. Present only when the registration asked for it + /// ([`RegisterSandboxScopeRequest2::report_handle`]) and never on a retirement. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope_handle: Option, + /// What happened. + pub outcome: SandboxScopeOutcome2, + /// Live scopes after the change. + pub live_scopes: u32, +} + +macro_rules! redacted_debug { + ($($ty:ty),+ $(,)?) => {$(impl std::fmt::Debug for $ty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(concat!(stringify!($ty), "([REDACTED])")) + } + })+}; +} +redacted_debug!( + RegisterSandboxScopeRequest2, + RetireSandboxScopeRequest2, + SandboxScopeReceipt2 +); + +#[cfg(test)] +#[path = "sandbox_scopes_tests.rs"] +mod tests; diff --git a/crates/tinysecurity-bus/src/sandbox_scopes_tests.rs b/crates/tinysecurity-bus/src/sandbox_scopes_tests.rs new file mode 100644 index 0000000..88ee25d --- /dev/null +++ b/crates/tinysecurity-bus/src/sandbox_scopes_tests.rs @@ -0,0 +1,133 @@ +use super::*; +use crate::{ + AccessTier, NetworkPolicy, Origin, PathPolicy, ResourceLimits2, SandboxDeployment2, + SandboxScope2, SandboxSetting2, SubjectScope, +}; + +type TestResult = std::result::Result<(), Box>; + +fn scope() -> SandboxScope2 { + SandboxScope2 { + approval_scope_id: None, + scope_id: "scope".into(), + subject: SubjectScope { + user: "u".into(), + workspace: "w".into(), + agent: "a".into(), + }, + origin: Origin::Saas, + tier: AccessTier::Full, + deployment: SandboxDeployment2::Saas, + environment: SandboxSetting2::Disabled, + agent_mode: SandboxSetting2::Disabled, + configured: SandboxSetting2::Disabled, + paths: PathPolicy { + enabled: true, + workspace_only: false, + workspace_dir: "/state".into(), + action_dir: "/action".into(), + home_dir: None, + forbidden_paths: vec![], + trusted_roots: vec![], + reserved_paths: vec![], + reserved_prefixes: vec![], + readonly_paths: vec![], + reserved_names: vec![], + }, + grant_ceiling: vec![], + network_ceiling: NetworkPolicy::None, + limits: ResourceLimits2::default(), + sources: vec![], + executors: vec![], + proxy: None, + max_ttl_ms: 1000, + } +} + +#[test] +fn registration_omits_handle_request_unless_asked_and_round_trips() -> TestResult { + let quiet = RegisterSandboxScopeRequest2 { + scope: scope(), + audit_stream_id: "a".repeat(32), + report_handle: false, + }; + let value = serde_json::to_value(&quiet)?; + assert!(value.get("report_handle").is_none()); + assert_eq!( + serde_json::from_value::(value)?, + quiet + ); + let asking = RegisterSandboxScopeRequest2 { + report_handle: true, + ..quiet + }; + let value = serde_json::to_value(&asking)?; + assert_eq!(value["report_handle"], true); + assert_eq!( + serde_json::from_value::(value)?, + asking + ); + Ok(()) +} + +#[test] +fn requests_and_receipts_reject_unknown_fields() { + assert!( + serde_json::from_str::(r#"{"scope_id":"s","extra":1}"#) + .is_err() + ); + assert!( + serde_json::from_str::( + r#"{"scope_id":"s","outcome":"created","live_scopes":1,"extra":1}"# + ) + .is_err() + ); +} + +#[test] +fn receipt_carries_the_handle_only_when_present() -> TestResult { + let bare: SandboxScopeReceipt2 = + serde_json::from_str(r#"{"scope_id":"s","outcome":"retired","live_scopes":0}"#)?; + assert_eq!(bare.scope_handle, None); + assert!(serde_json::to_value(&bare)?.get("scope_handle").is_none()); + let handled = SandboxScopeReceipt2 { + scope_handle: Some(SubjectScope { + user: "h".into(), + workspace: "h".into(), + agent: "h".into(), + }), + outcome: SandboxScopeOutcome2::Created, + ..bare + }; + let round: SandboxScopeReceipt2 = serde_json::from_value(serde_json::to_value(&handled)?)?; + assert_eq!(round, handled); + assert_eq!( + serde_json::to_value(SandboxScopeOutcome2::Unchanged)?, + "unchanged" + ); + Ok(()) +} + +#[test] +fn debug_output_never_prints_registered_facts() { + let request = RegisterSandboxScopeRequest2 { + scope: scope(), + audit_stream_id: "b".repeat(32), + report_handle: false, + }; + let text = format!( + "{request:?} {:?} {:?}", + RetireSandboxScopeRequest2 { + scope_id: "secret-scope".into() + }, + SandboxScopeReceipt2 { + scope_id: "secret-scope".into(), + scope_handle: None, + outcome: SandboxScopeOutcome2::Created, + live_scopes: 1, + } + ); + assert!(!text.contains("secret-scope") && !text.contains(&"b".repeat(32))); + assert!(text.contains("[REDACTED]")); + assert_eq!(MAX_SANDBOX_SCOPES2, 65_536); +} diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index 985718b..2893f79 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -82,10 +82,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, 18)); - // A host built against 1.18 accepts this module and newer minors, not a 1.17 module. - assert!(is_compatible((1, 18))); + assert_eq!(CONTRACT_VERSION, (1, 19)); + // A host built against 1.19 accepts this module and newer minors, not a 1.18 module. assert!(is_compatible((1, 19))); + assert!(is_compatible((1, 20))); + assert!(!is_compatible((1, 18))); assert!(!is_compatible((1, 17))); assert!(!is_compatible((1, 16))); assert!(!is_compatible((1, 15))); diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index cc0149a..5310ac4 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, 18)); + assert_eq!(CONTRACT_VERSION, (1, 19)); for (mode, wire) in [ (RedactMode::Text, "text"), (RedactMode::StructuredArguments, "structured_arguments"), @@ -400,5 +400,12 @@ fn redact_with_request_carries_literals_without_echoing_them() ); assert_eq!(names::methods::REDACT_WITH, "RedactWith"); assert!(names::METHODS.contains(&names::methods::REDACT_WITH)); + assert_eq!( + names::methods::REGISTER_SANDBOX_SCOPE2, + "RegisterSandboxScope2" + ); + assert_eq!(names::methods::RETIRE_SANDBOX_SCOPE2, "RetireSandboxScope2"); + assert!(names::METHODS.contains(&names::methods::REGISTER_SANDBOX_SCOPE2)); + assert!(names::METHODS.contains(&names::methods::RETIRE_SANDBOX_SCOPE2)); Ok(()) } diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 892ea50..f853ada 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -36,6 +36,7 @@ struct PolicyService { secret: Option>, secret_cipher: Option>, sandbox: Option>, + sandbox_runtime: Option>, approvals: Option>, policy: Arc>, audit: Option>, @@ -813,6 +814,19 @@ impl PolicyService { .await .map_err(approval_failure) } + // Private host-only configuration: the facts are a trusted-adapter claim. + async fn register_sandbox_scope2( + &self, + request: tinysecurity_bus::RegisterSandboxScopeRequest2, + ) -> BusResult { + self.register_sandbox_scope(request).await + } + async fn retire_sandbox_scope2( + &self, + request: tinysecurity_bus::RetireSandboxScopeRequest2, + ) -> BusResult { + self.retire_sandbox_scope(request).await + } async fn import_approval_history2( &self, request: tinysecurity_bus::ApprovalHistoryImport2, @@ -1171,6 +1185,7 @@ fn service_for( secret: None, secret_cipher: None, sandbox: None, + sandbox_runtime: None, approvals: None, policy, audit, @@ -1581,18 +1596,30 @@ async fn setup(connection: Connection, mut config: ModuleConfig) -> BusResult<() service.secret_cipher.clone_from(&secret_cipher); service.signals = Some(connection.clone()); service.sandbox_clock = device_clock; - service.sandbox = sandbox.as_ref().map(|(_, engine)| Arc::clone(engine)); + service.attach_sandbox( + sandbox.as_ref().map(|(_, engine)| engine), + audit_config, + (&connection, &binding), + )?; service.approvals = approvals.as_ref().map(|(_, engine)| Arc::clone(engine)); if let Some(engine) = &service.approvals { engine.replace_automatic(judge).map_err(approval_failure)?; } serve_policy(&connection, service, binding.clone()).await?; - device::activate(device.as_ref())?; - crypto::activate(crypto.as_ref())?; - secret::activate(secret)?; - cipher::activate(secret_cipher)?; + activate_confidential(device.as_ref(), crypto.as_ref(), secret, secret_cipher)?; activate_policy(policy, audit, binding, sandbox, approvals) } +fn activate_confidential( + device: Option<&Arc>, + crypto: Option<&Arc>, + secret: Option>, + secret_cipher: Option>, +) -> BusResult<()> { + device::activate(device)?; + crypto::activate(crypto)?; + secret::activate(secret)?; + cipher::activate(secret_cipher) +} async fn serve_policy( connection: &Connection, service: PolicyService, @@ -1642,7 +1669,7 @@ tinybus_module::module_export! { config = ModuleConfig, worker_threads = 1, provides = ["ai.tinyhumans.tinysecurity.Policy"], - methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "Audit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanToolDefinition"], + methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanToolDefinition"], signals = ["PolicyChanged"], requires = [], optional = ["ai.tinyhumans.tinysecurity.KeyringConsent2", "ai.tinyhumans.tinysecurity.SecretCipherConsumer2", "ai.tinyhumans.tinysecurity.SecretBackend2", "ai.tinyhumans.tinysecurity.AuditSink2", "ai.tinyhumans.tinysecurity.ApprovalStore2", "ai.tinyhumans.tinysecurity.ApprovalPrompt2"], @@ -1654,6 +1681,8 @@ mod tests; #[path = "adapter_sandbox.rs"] mod sandbox; +#[path = "adapter_sandbox_scopes.rs"] +mod sandbox_scopes; use sandbox::sandbox_configured; #[path = "adapter_device.rs"] diff --git a/crates/tinysecurity-module/src/adapter_sandbox.rs b/crates/tinysecurity-module/src/adapter_sandbox.rs index fd8cd96..10adace 100644 --- a/crates/tinysecurity-module/src/adapter_sandbox.rs +++ b/crates/tinysecurity-module/src/adapter_sandbox.rs @@ -196,10 +196,23 @@ impl PolicyService { artifact.approval_reservation_id = row.reservation_id; Some(artifact) } + /// The audit client that records a scope's plans: its own stream for a runtime + /// scope, the base client for an initialized one. + fn sandbox_audit_client( + &self, + scope_id: &str, + ) -> BusResult>> { + if let Some(runtime) = &self.sandbox_runtime + && let Some(client) = runtime.client(scope_id)? + { + return Ok(Some(client)); + } + Ok(self.audit.clone()) + } async fn audit_sandbox( &self, + owner: (&str, &tinysecurity_bus::SubjectScope), original: serde_json::Value, - subject: &tinysecurity_bus::SubjectScope, correlation: &tinysecurity_bus::AuditCorrelation2, generation: u64, redactor: tinysecurity::Redactor, @@ -214,7 +227,8 @@ impl PolicyService { receipt: None, }) }; - let Some(client) = &self.audit else { + let (scope_id, subject) = owner; + let Some(client) = self.sandbox_audit_client(scope_id)? else { return Ok(refused()); }; if client.scope() != subject || correlation.generation != generation { @@ -285,8 +299,8 @@ impl PolicyService { }; let evidence = self .audit_sandbox( + (&request.scope_id, &request.subject), serde_json::json!([request, response]), - &request.subject, &request.audit, generation, redactor, @@ -355,8 +369,8 @@ impl PolicyService { } response.audit = self .audit_sandbox( + (&request.request.scope_id, &request.subject), serde_json::json!([request,{"eligible":true}]), - &request.subject, &request.audit, generation, redactor, diff --git a/crates/tinysecurity-module/src/adapter_sandbox_scopes.rs b/crates/tinysecurity-module/src/adapter_sandbox_scopes.rs new file mode 100644 index 0000000..f5a915d --- /dev/null +++ b/crates/tinysecurity-module/src/adapter_sandbox_scopes.rs @@ -0,0 +1,267 @@ +//! Runtime sandbox scopes (contract 1.19): registration, retirement and the per-subject +//! audit streams their plans are recorded on. +use super::{Arc, BusResult, PolicyService, failure}; +use crate::{callbacks::AuditClient, callbacks::PeerBinding, stream_pins::StreamPins}; +use std::{collections::BTreeMap, sync::RwLock}; +use tinysecurity::sandbox::{Engine, Registration}; +use tinysecurity_bus::{ + AuditConfig2, RegisterSandboxScopeRequest2, RetireSandboxScopeRequest2, SandboxScopeOutcome2, + SandboxScopeReceipt2, SubjectScope, +}; + +/// Audit state of the scopes registered after init. Initialized scopes keep using the +/// base audit client; every runtime scope forwards to the host sink on its own stream +/// and never opens a native journal. +pub(super) struct SandboxRuntime { + connection: tinybus::Connection, + binding: PeerBinding, + base: AuditConfig2, + redactor: tinysecurity::Redactor, + /// One client per runtime scope identity. + clients: RwLock>>, + /// Stream of each subject while live and for a retention window after release. + pub(super) pins: StreamPins, + /// Registration and retirement are serialized so a failed step is ours to undo. + lifecycle: tokio::sync::Mutex<()>, +} +impl SandboxRuntime { + pub(super) fn new( + connection: tinybus::Connection, + binding: PeerBinding, + base: AuditConfig2, + redactor: tinysecurity::Redactor, + ) -> Self { + Self { + connection, + binding, + base, + redactor, + clients: RwLock::new(BTreeMap::new()), + pins: StreamPins::default(), + lifecycle: tokio::sync::Mutex::new(()), + } + } + /// The audit client of a runtime scope, if one is registered. + pub(super) fn client(&self, scope_id: &str) -> BusResult>> { + Ok(self + .clients + .read() + .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))? + .get(scope_id) + .cloned()) + } + fn build_client(&self, subject: &SubjectScope, stream_id: &str) -> BusResult { + let mut config = self.base.clone(); + config.scope = subject.clone(); + config.stream_id = stream_id.into(); + config.native_path = None; + AuditClient::new( + self.connection.clone(), + self.binding.clone(), + config, + self.redactor.clone(), + ) + } + fn holds_subject(clients: &BTreeMap>, subject: &SubjectScope) -> bool { + clients.values().any(|client| client.scope() == subject) + } +} + +/// The subject exactly as `AuditSink2` receives it: identifier-hashed. +fn audit_handle( + redactor: &tinysecurity::Redactor, + subject: &SubjectScope, +) -> BusResult { + let hash = |text: &str| { + redactor + .redact(&tinysecurity_bus::RedactRequest { + text: text.into(), + mode: tinysecurity_bus::RedactMode::IdentifierHash, + }) + .map(|result| result.text) + .map_err(|error| failure(&error)) + }; + Ok(SubjectScope { + user: hash(&subject.user)?, + workspace: hash(&subject.workspace)?, + agent: hash(&subject.agent)?, + }) +} + +fn refused(reason: &'static str) -> tinybus::Error { + tinybus::Error::failed(reason) +} + +impl PolicyService { + /// Install the sandbox engine; runtime registration additionally needs an audit to + /// record on, so without one only the initialized scopes exist. + pub(super) fn attach_sandbox( + &mut self, + engine: Option<&Arc>, + audit: Option, + peer: (&tinybus::Connection, &PeerBinding), + ) -> BusResult<()> { + self.sandbox = engine.cloned(); + self.sandbox_runtime = match (engine, audit) { + (Some(_), Some(base)) => { + let redactor = self.read()?.redactor(); + Some(Arc::new(SandboxRuntime::new( + peer.0.clone(), + peer.1.clone(), + base, + redactor, + ))) + } + _ => None, + }; + Ok(()) + } + fn sandbox_parts(&self) -> BusResult<(&Arc, &SandboxRuntime)> { + let engine = self + .sandbox + .as_ref() + .ok_or_else(|| refused("sandbox unavailable"))?; + let runtime = self + .sandbox_runtime + .as_deref() + .ok_or_else(|| refused("sandbox audit unavailable"))?; + Ok((engine, runtime)) + } + /// Whether the scope's approval link names a live approval scope of the same subject. + fn approval_link_holds(&self, request: &RegisterSandboxScopeRequest2) -> bool { + let Some(id) = &request.scope.approval_scope_id else { + return true; + }; + let Some(approvals) = &self.approvals else { + return false; + }; + match ( + approvals.durable_scope(id), + tinysecurity::approval::scope_handle(&request.scope.subject), + ) { + (Ok(registered), Ok(expected)) => registered == expected, + _ => false, + } + } + // Private host-only configuration: the facts are a trusted-adapter claim. + pub(super) async fn register_sandbox_scope( + &self, + request: RegisterSandboxScopeRequest2, + ) -> BusResult { + let (engine, runtime) = self.sandbox_parts()?; + if request.audit_stream_id.len() != 32 + || !request + .audit_stream_id + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + || !self.approval_link_holds(&request) + { + return Err(refused("invalid sandbox scope")); + } + let redactor = self.read()?.redactor(); + let handle = request + .report_handle + .then(|| audit_handle(&redactor, &request.scope.subject)) + .transpose()?; + let _lifecycle = runtime.lifecycle.lock().await; + let id = request.scope.scope_id.clone(); + let subject = request.scope.subject.clone(); + let live = |engine: &Engine| { + engine + .live_scopes() + .ok() + .and_then(|n| u32::try_from(n).ok()) + .ok_or_else(|| failure(&tinysecurity_bus::Error::Faulted)) + }; + let receipt = |outcome, live| SandboxScopeReceipt2 { + scope_id: id.clone(), + scope_handle: handle.clone(), + outcome, + live_scopes: live, + }; + if let Some(existing) = runtime.client(&id)? { + // The same facts on the same stream repeat idempotently; anything else + // under a live identity is a conflict, never a rebind. + if existing.stream_id() != request.audit_stream_id { + return Err(refused("conflicting sandbox scope")); + } + return match engine.register_scope(request.scope) { + Ok(Registration::Unchanged) => { + Ok(receipt(SandboxScopeOutcome2::Unchanged, live(engine)?)) + } + _ => Err(refused("conflicting sandbox scope")), + }; + } + if engine + .has_scope(&id) + .map_err(|_| refused("sandbox unavailable"))? + { + // An initialized identity is immutable and keeps its init audit stream. + return Err(refused("conflicting sandbox scope")); + } + runtime + .pins + .pin(&subject, &request.audit_stream_id) + .map_err(|_| refused("conflicting sandbox audit stream"))?; + let rollback = |clients: &BTreeMap>| { + if !SandboxRuntime::holds_subject(clients, &subject) { + let _ = runtime.pins.unpin(&subject); + } + }; + let client = match runtime.build_client(&subject, &request.audit_stream_id) { + Ok(client) => Arc::new(client), + Err(error) => { + if let Ok(clients) = runtime.clients.read() { + rollback(&clients); + } + return Err(error); + } + }; + let mut clients = runtime + .clients + .write() + .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))?; + match engine.register_scope(request.scope) { + Ok(Registration::Created) => { + clients.insert(id.clone(), client); + drop(clients); + Ok(receipt(SandboxScopeOutcome2::Created, live(engine)?)) + } + Ok(Registration::Unchanged) | Err(_) => { + rollback(&clients); + Err(refused("sandbox scope refused")) + } + } + } + pub(super) async fn retire_sandbox_scope( + &self, + request: RetireSandboxScopeRequest2, + ) -> BusResult { + let (engine, runtime) = self.sandbox_parts()?; + let _lifecycle = runtime.lifecycle.lock().await; + let mut clients = runtime + .clients + .write() + .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))?; + // Initialized scopes are replaced by reinitialization, never retired. + let subject = engine + .retire_scope(&request.scope_id) + .map_err(|_| refused("sandbox scope cannot be retired"))?; + clients.remove(&request.scope_id); + if !SandboxRuntime::holds_subject(&clients, &subject) { + let _ = runtime.pins.unpin(&subject); + } + drop(clients); + let live = engine + .live_scopes() + .ok() + .and_then(|n| u32::try_from(n).ok()) + .ok_or_else(|| failure(&tinysecurity_bus::Error::Faulted))?; + Ok(SandboxScopeReceipt2 { + scope_id: request.scope_id, + scope_handle: None, + outcome: SandboxScopeOutcome2::Retired, + live_scopes: live, + }) + } +} diff --git a/crates/tinysecurity-module/src/adapter_sandbox_scopes_tests.rs b/crates/tinysecurity-module/src/adapter_sandbox_scopes_tests.rs new file mode 100644 index 0000000..2033adf --- /dev/null +++ b/crates/tinysecurity-module/src/adapter_sandbox_scopes_tests.rs @@ -0,0 +1,458 @@ +//! Runtime sandbox scopes through the real bus, authenticated peer and audit sink. +use super::super::super::sandbox_scopes::SandboxRuntime; +use super::*; +use tinysecurity_bus::{ + RegisterSandboxScopeRequest2, RetireSandboxScopeRequest2, SandboxScope2, SandboxScopeOutcome2, + SubjectScope, +}; + +type TestResult = std::result::Result<(), Box>; + +const TENANT_STREAM: &str = "1111111111111111111111111111111d"; +const OTHER_STREAM: &str = "2222222222222222222222222222222e"; + +fn tenant(name: &str, base: &SandboxScope2) -> SandboxScope2 { + let mut scope = base.clone(); + scope.scope_id = format!("tenant-{name}"); + scope.approval_scope_id = None; + scope.subject = SubjectScope { + user: format!("user-{name}"), + workspace: "tenant-workspace".into(), + agent: "tenant-agent".into(), + }; + scope +} +fn registration(scope: &SandboxScope2, stream: &str, report: bool) -> RegisterSandboxScopeRequest2 { + RegisterSandboxScopeRequest2 { + scope: scope.clone(), + audit_stream_id: stream.into(), + report_handle: report, + } +} +async fn with_runtime() -> std::result::Result> { + let mut h = harness().await?; + h.service.sandbox_runtime = Some(Arc::new(runtime_for(&h)?)); + Ok(h) +} +fn runtime_for(h: &Harness) -> std::result::Result> { + runtime_with_timeout(h, 30_000) +} +fn runtime_with_timeout( + h: &Harness, + timeout_ms: u32, +) -> std::result::Result> { + let binding = crate::callbacks::PeerBinding::new( + h.host.unique_name().as_ref().map(tinybus::BusName::as_str), + )?; + Ok(SandboxRuntime::new( + h.module.clone(), + binding, + AuditConfig2 { + scope: h.facts.subject.clone(), + stream_id: "c".repeat(32), + timeout_ms, + required: true, + native_path: None, + max_bytes: 100_000, + max_archives: 3, + journal_max_bytes: 1_000_000, + journal_max_events: 10_000, + }, + tinysecurity::Redactor::new(&RedactorConfig::default())?, + )) +} +fn rows(h: &Harness) -> Vec { + h.sink + .rows + .lock() + .map(|rows| rows.clone()) + .unwrap_or_default() +} + +#[tokio::test] +async fn a_registered_subject_plans_and_audits_on_its_own_stream() -> TestResult { + let h = with_runtime().await?; + let scope = tenant("a", &h.facts); + let receipt = h + .service + .register_sandbox_scope2(registration(&scope, TENANT_STREAM, true)) + .await?; + assert_eq!(receipt.outcome, SandboxScopeOutcome2::Created); + assert_eq!(receipt.live_scopes, 2); + let plan = h.service.plan2(fixture::request(&scope, 1)).await?; + assert_eq!(plan.decision.verdict, tinysecurity_bus::Verdict::Allow); + assert!(plan.audit.required()?.committed); + let artifact = plan.artifact.required()?; + let consumed = h + .service + .consume_sandbox_plan2(ConsumeSandboxPlanRequest2 { + request: fixture::request(&scope, 1), + artifact, + subject: scope.subject.clone(), + audit: fixture::audit(2), + }) + .await?; + assert!(consumed.permitted); + let sent = rows(&h); + assert_eq!(sent.len(), 2); + for row in &sent { + assert_eq!(row.stream_id, TENANT_STREAM); + // The receipt names the subject exactly as the sink received it. + assert_eq!(Some(&row.scope), receipt.scope_handle.as_ref()); + } + // The initialized scope keeps the base stream beside it. + let init = h.service.plan2(fixture::request(&h.facts, 1)).await?; + assert!(init.audit.required()?.committed); + assert_eq!( + rows(&h).last().map(|row| row.stream_id.clone()), + Some("c".repeat(32)) + ); + Ok(()) +} + +#[tokio::test] +async fn the_handle_is_reported_only_to_a_registration_that_asks() -> TestResult { + let h = with_runtime().await?; + let quiet = tenant("quiet", &h.facts); + let receipt = h + .service + .register_sandbox_scope2(registration(&quiet, TENANT_STREAM, false)) + .await?; + assert_eq!(receipt.scope_handle, None); + let again = h + .service + .register_sandbox_scope2(registration(&quiet, TENANT_STREAM, true)) + .await?; + assert_eq!(again.outcome, SandboxScopeOutcome2::Unchanged); + assert!(again.scope_handle.is_some()); + let retired = h + .service + .retire_sandbox_scope2(RetireSandboxScopeRequest2 { + scope_id: quiet.scope_id.clone(), + }) + .await?; + assert_eq!(retired.outcome, SandboxScopeOutcome2::Retired); + assert_eq!(retired.scope_handle, None); + assert_eq!(retired.live_scopes, 1); + Ok(()) +} + +#[tokio::test] +async fn identity_stream_and_initialized_scopes_are_never_rebound() -> TestResult { + let h = with_runtime().await?; + let scope = tenant("a", &h.facts); + h.service + .register_sandbox_scope2(registration(&scope, TENANT_STREAM, false)) + .await?; + // Another stream for a live identity. + assert!( + h.service + .register_sandbox_scope2(registration(&scope, OTHER_STREAM, false)) + .await + .is_err() + ); + // Other facts for a live identity. + let mut widened = scope.clone(); + widened.max_ttl_ms += 1; + assert!( + h.service + .register_sandbox_scope2(registration(&widened, TENANT_STREAM, false)) + .await + .is_err() + ); + // The subject of a live scope keeps its stream for another identity too. + let mut sibling = scope.clone(); + sibling.scope_id = "tenant-sibling".into(); + assert!( + h.service + .register_sandbox_scope2(registration(&sibling, OTHER_STREAM, false)) + .await + .is_err() + ); + // An initialized identity can be neither registered again nor retired. + assert!( + h.service + .register_sandbox_scope2(registration(&h.facts, TENANT_STREAM, false)) + .await + .is_err() + ); + assert!( + h.service + .retire_sandbox_scope2(RetireSandboxScopeRequest2 { + scope_id: h.facts.scope_id.clone() + }) + .await + .is_err() + ); + assert!( + h.service + .retire_sandbox_scope2(RetireSandboxScopeRequest2 { + scope_id: "missing".into() + }) + .await + .is_err() + ); + // Malformed streams and facts never register. + for stream in ["short", &"z".repeat(32)] { + assert!( + h.service + .register_sandbox_scope2(registration(&tenant("b", &h.facts), stream, false)) + .await + .is_err() + ); + } + let mut empty = tenant("c", &h.facts); + empty.sources.clear(); + assert!( + h.service + .register_sandbox_scope2(registration(&empty, OTHER_STREAM, false)) + .await + .is_err() + ); + Ok(()) +} + +#[tokio::test] +async fn retirement_denies_new_plans_and_issued_artifacts_and_keeps_the_stream_pinned() -> TestResult +{ + let h = with_runtime().await?; + let scope = tenant("a", &h.facts); + h.service + .register_sandbox_scope2(registration(&scope, TENANT_STREAM, false)) + .await?; + let artifact = h + .service + .plan2(fixture::request(&scope, 1)) + .await? + .artifact + .required()?; + h.service + .retire_sandbox_scope2(RetireSandboxScopeRequest2 { + scope_id: scope.scope_id.clone(), + }) + .await?; + let denied = h.service.plan2(fixture::request(&scope, 2)).await?; + assert_eq!(denied.denial, Some(SandboxDenial2::InvalidContext)); + let consumed = h + .service + .consume_sandbox_plan2(ConsumeSandboxPlanRequest2 { + request: fixture::request(&scope, 1), + artifact, + subject: scope.subject.clone(), + audit: fixture::audit(3), + }) + .await?; + assert!(!consumed.permitted); + // Within the retention window the subject cannot move to another ledger. + assert!( + h.service + .register_sandbox_scope2(registration(&scope, OTHER_STREAM, false)) + .await + .is_err() + ); + // The same stream registers again and plans. + h.service + .register_sandbox_scope2(registration(&scope, TENANT_STREAM, false)) + .await?; + assert_eq!( + h.service + .plan2(fixture::request(&scope, 4)) + .await? + .decision + .verdict, + tinysecurity_bus::Verdict::Allow + ); + Ok(()) +} + +#[tokio::test] +async fn a_subject_is_released_for_another_stream_only_after_its_last_scope_retires() -> TestResult +{ + let mut h = harness().await?; + let mut runtime = runtime_for(&h)?; + runtime.pins.ttl = std::time::Duration::ZERO; + h.service.sandbox_runtime = Some(Arc::new(runtime)); + let first = tenant("a", &h.facts); + let mut second = first.clone(); + second.scope_id = "tenant-a-second".into(); + for scope in [&first, &second] { + h.service + .register_sandbox_scope2(registration(scope, TENANT_STREAM, false)) + .await?; + } + let retire = |id: &str| RetireSandboxScopeRequest2 { + scope_id: id.into(), + }; + h.service + .retire_sandbox_scope2(retire(&first.scope_id)) + .await?; + let mut elsewhere = first.clone(); + elsewhere.scope_id = "tenant-a-third".into(); + // The second scope still holds the subject, so its stream is still pinned. + assert!( + h.service + .register_sandbox_scope2(registration(&elsewhere, OTHER_STREAM, false)) + .await + .is_err() + ); + h.service + .retire_sandbox_scope2(retire(&second.scope_id)) + .await?; + h.service + .register_sandbox_scope2(registration(&elsewhere, OTHER_STREAM, false)) + .await?; + Ok(()) +} + +#[tokio::test] +async fn a_refused_registration_releases_the_subject_it_pinned() -> TestResult { + let mut h = harness().await?; + let mut runtime = runtime_for(&h)?; + runtime.pins.ttl = std::time::Duration::ZERO; + h.service.sandbox_runtime = Some(Arc::new(runtime)); + let mut broken = tenant("a", &h.facts); + broken.sources.clear(); + assert!( + h.service + .register_sandbox_scope2(registration(&broken, TENANT_STREAM, false)) + .await + .is_err() + ); + // Nothing was registered, so the subject is free to choose its stream. + h.service + .register_sandbox_scope2(registration(&tenant("a", &h.facts), OTHER_STREAM, false)) + .await?; + Ok(()) +} + +#[tokio::test] +async fn registration_needs_the_runtime_audit_and_a_live_approval_link() -> TestResult { + let mut h = harness().await?; + let scope = tenant("a", &h.facts); + // No runtime audit state (initialized without audit): nothing registers. + assert!( + h.service + .register_sandbox_scope2(registration(&scope, TENANT_STREAM, false)) + .await + .is_err() + ); + assert!( + h.service + .retire_sandbox_scope2(RetireSandboxScopeRequest2 { + scope_id: scope.scope_id.clone() + }) + .await + .is_err() + ); + h.service.sandbox_runtime = Some(Arc::new(runtime_for(&h)?)); + // An approval link with no approval engine can never be honoured. + let mut linked = scope.clone(); + linked.approval_scope_id = Some("a".repeat(32)); + assert!( + h.service + .register_sandbox_scope2(registration(&linked, TENANT_STREAM, false)) + .await + .is_err() + ); + // Without a sandbox engine the whole surface is unavailable. + h.service.sandbox = None; + assert!( + h.service + .register_sandbox_scope2(registration(&scope, TENANT_STREAM, false)) + .await + .is_err() + ); + Ok(()) +} + +#[tokio::test] +async fn an_approval_link_must_name_a_live_approval_scope_of_the_same_subject() -> TestResult { + let mut h = with_runtime().await?; + let _store = human_engine(&mut h).await?; + // The same subject as the approval scope, on a new identity: the link holds. + let mut own = h.facts.clone(); + own.scope_id = "own-runtime".into(); + h.service + .register_sandbox_scope2(registration(&own, "c".repeat(32).as_str(), false)) + .await?; + // Another subject cannot ride this approval scope. + let mut foreign = tenant("a", &h.facts); + foreign.approval_scope_id = Some("a".repeat(32)); + assert!( + h.service + .register_sandbox_scope2(registration(&foreign, TENANT_STREAM, false)) + .await + .is_err() + ); + // A link to an approval scope that does not exist is refused too. + let mut dangling = own.clone(); + dangling.scope_id = "dangling".into(); + dangling.approval_scope_id = Some("b".repeat(32)); + assert!( + h.service + .register_sandbox_scope2(registration(&dangling, "c".repeat(32).as_str(), false)) + .await + .is_err() + ); + Ok(()) +} + +#[tokio::test] +async fn attaching_a_sandbox_enables_runtime_scopes_only_with_an_audit() -> TestResult { + let mut h = harness().await?; + let engine = h.service.sandbox.clone().required()?; + let binding = crate::callbacks::PeerBinding::new( + h.host.unique_name().as_ref().map(tinybus::BusName::as_str), + )?; + let audit = AuditConfig2 { + scope: h.facts.subject.clone(), + stream_id: "c".repeat(32), + timeout_ms: 30_000, + required: true, + native_path: None, + max_bytes: 100_000, + max_archives: 3, + journal_max_bytes: 1_000_000, + journal_max_events: 10_000, + }; + h.service.sandbox_runtime = None; + h.service + .attach_sandbox(Some(&engine), None, (&h.module, &binding))?; + assert!(h.service.sandbox.is_some() && h.service.sandbox_runtime.is_none()); + h.service + .attach_sandbox(Some(&engine), Some(audit.clone()), (&h.module, &binding))?; + assert!(h.service.sandbox_runtime.is_some()); + h.service + .attach_sandbox(None, Some(audit), (&h.module, &binding))?; + assert!(h.service.sandbox.is_none() && h.service.sandbox_runtime.is_none()); + Ok(()) +} + +#[tokio::test] +async fn an_unusable_audit_base_refuses_registration_and_releases_the_pin() -> TestResult { + let mut h = harness().await?; + let mut runtime = runtime_for(&h)?; + runtime.pins.ttl = std::time::Duration::ZERO; + h.service.sandbox_runtime = Some(Arc::new(runtime)); + let scope = tenant("a", &h.facts); + h.service + .register_sandbox_scope2(registration(&scope, TENANT_STREAM, false)) + .await?; + // A runtime whose base audit is invalid (zero timeout) never admits a scope. + let mut broken = runtime_with_timeout(&h, 0)?; + broken.pins.ttl = std::time::Duration::ZERO; + h.service.sandbox_runtime = Some(Arc::new(broken)); + let second = tenant("b", &h.facts); + assert!( + h.service + .register_sandbox_scope2(registration(&second, TENANT_STREAM, false)) + .await + .is_err() + ); + assert_eq!( + h.service.sandbox.as_ref().required()?.live_scopes()?, + 2, + "the refused scope is not live" + ); + Ok(()) +} diff --git a/crates/tinysecurity-module/src/adapter_sandbox_tests.rs b/crates/tinysecurity-module/src/adapter_sandbox_tests.rs index 97dc4ea..0758641 100644 --- a/crates/tinysecurity-module/src/adapter_sandbox_tests.rs +++ b/crates/tinysecurity-module/src/adapter_sandbox_tests.rs @@ -635,3 +635,6 @@ async fn cancellation_during_plan_audit_reclaims_pending_artifact_capacity() assert!(engine.stage(candidate, 1000).is_err()); Ok(()) } + +#[path = "adapter_sandbox_scopes_tests.rs"] +mod scopes; diff --git a/crates/tinysecurity-module/src/adapter_tests.rs b/crates/tinysecurity-module/src/adapter_tests.rs index d2ee9fc..593d731 100644 --- a/crates/tinysecurity-module/src/adapter_tests.rs +++ b/crates/tinysecurity-module/src/adapter_tests.rs @@ -428,6 +428,7 @@ fn only_implemented_members_are_advertised() -> std::result::Result<(), Box)>>, /// Runtime subjects forward audit to the host sink under this base policy. runtime: RuntimeAudit, - /// The audit stream of each runtime subject. A live subject always keeps its - /// stream, and a released one keeps it for `pin_ttl` (far beyond any callback - /// deadline), so a call still in flight for a retired scope cannot land in - /// another scope's ledger when the subject registers again. Pins of subjects - /// released longer ago are reclaimed, so only live and recently retired subjects - /// count against `max_pins`. - streams: RwLock>, - pin_ttl: std::time::Duration, - max_pins: usize, -} -type StreamKey = (String, String, String); -struct StreamPin { - stream: String, - released: Option, + /// The audit stream of each runtime subject; see [`StreamPins`]. + pins: StreamPins, } struct RuntimeAudit { connection: tinybus::Connection, @@ -184,67 +169,9 @@ impl ApprovalClients { store, audit: RwLock::new(clients), runtime, - streams: RwLock::new(std::collections::BTreeMap::new()), - pin_ttl: STREAM_PIN_TTL, - max_pins: MAX_PINNED_STREAMS, + pins: StreamPins::default(), }) } - fn stream_key(scope: &ApprovalScope2) -> StreamKey { - ( - scope.subject.user.clone(), - scope.subject.workspace.clone(), - scope.subject.agent.clone(), - ) - } - /// Pin a subject's stream while it is live or recently released; refuse any - /// other stream for it until the pin has been released for `pin_ttl`. - fn pin_stream(&self, scope: &ApprovalScope2, stream_id: &str) -> Result<()> { - if stream_id.len() != 32 || !stream_id.bytes().all(|b| b.is_ascii_hexdigit()) { - return Err(Error::Invalid); - } - let key = Self::stream_key(scope); - let now = std::time::Instant::now(); - let ttl = self.pin_ttl; - let expired = |pin: &StreamPin| { - pin.released - .is_some_and(|at| now.saturating_duration_since(at) >= ttl) - }; - let mut streams = self.streams.write().map_err(|_| Error::Unavailable)?; - if let Some(pin) = streams.get_mut(&key) { - if pin.stream == stream_id { - pin.released = None; - return Ok(()); - } - if !expired(pin) { - return Err(Error::Conflict); - } - } else if streams.len() >= self.max_pins { - streams.retain(|_, pin| !expired(pin)); - if streams.len() >= self.max_pins { - return Err(Error::Unavailable); - } - } - streams.insert( - key, - StreamPin { - stream: stream_id.into(), - released: None, - }, - ); - Ok(()) - } - /// Start the retention clock of a released subject's pin. - fn unpin_stream(&self, scope: &ApprovalScope2) -> Result<()> { - if let Some(pin) = self - .streams - .write() - .map_err(|_| Error::Unavailable)? - .get_mut(&Self::stream_key(scope)) - { - pin.released = Some(std::time::Instant::now()); - } - Ok(()) - } /// Build the audit client of a runtime subject: its own persisted stream, always /// forwarded to the host sink so no extra native journal is opened. fn runtime_client(&self, scope: &ApprovalScope2, stream_id: &str) -> Result { @@ -314,7 +241,7 @@ impl Ports for ApprovalClients { } async fn admit_scope(&self, scope: &ApprovalScope2, audit_stream_id: &str) -> Result<()> { let handle = tinysecurity::approval::scope_handle(&scope.subject)?; - self.pin_stream(scope, audit_stream_id)?; + self.pins.pin(&scope.subject, audit_stream_id)?; let client = Arc::new(self.runtime_client(scope, audit_stream_id)?); self.store.admit(handle.clone())?; // The engine serializes lifecycles, so an existing client is stale (for @@ -330,7 +257,7 @@ impl Ports for ApprovalClients { .write() .map_err(|_| Error::Unavailable)? .retain(|(s, _)| s != &handle); - self.unpin_stream(scope)?; + self.pins.unpin(&scope.subject)?; self.store.release(&handle) } async fn native_audit(&self, row: ApprovalRow2) -> Result { diff --git a/crates/tinysecurity-module/src/approval_callbacks_tests.rs b/crates/tinysecurity-module/src/approval_callbacks_tests.rs index 7404b6a..6c866fe 100644 --- a/crates/tinysecurity-module/src/approval_callbacks_tests.rs +++ b/crates/tinysecurity-module/src/approval_callbacks_tests.rs @@ -919,7 +919,7 @@ async fn released_subjects_keep_their_stream_only_for_the_retention_window_and_p &native_audit_config(base.scopes[0].subject.clone(), false), &redactor, )?; - clients.max_pins = 2; + clients.pins.max = 2; let (first, second, third) = (runtime_scope(11), runtime_scope(12), runtime_scope(13)); clients.admit_scope(&first, &"d".repeat(32)).await?; clients.admit_scope(&second, &"d".repeat(32)).await?; @@ -930,7 +930,7 @@ async fn released_subjects_keep_their_stream_only_for_the_retention_window_and_p assert!(clients.admit_scope(&first, &"e".repeat(32)).await.is_err()); assert!(clients.admit_scope(&third, &"d".repeat(32)).await.is_err()); // Past the window the pin is reclaimed and the subject may use another stream. - clients.pin_ttl = std::time::Duration::ZERO; + clients.pins.ttl = std::time::Duration::ZERO; clients.admit_scope(&third, &"d".repeat(32)).await?; // The table is full of live pins again, so a reclaimed subject must wait. assert!(clients.admit_scope(&first, &"e".repeat(32)).await.is_err()); diff --git a/crates/tinysecurity-module/src/lib.rs b/crates/tinysecurity-module/src/lib.rs index f89deef..fd52880 100644 --- a/crates/tinysecurity-module/src/lib.rs +++ b/crates/tinysecurity-module/src/lib.rs @@ -9,3 +9,5 @@ mod callbacks; mod approval_callbacks; mod secret_callbacks; + +mod stream_pins; diff --git a/crates/tinysecurity-module/src/stream_pins.rs b/crates/tinysecurity-module/src/stream_pins.rs new file mode 100644 index 0000000..f45f47f --- /dev/null +++ b/crates/tinysecurity-module/src/stream_pins.rs @@ -0,0 +1,97 @@ +//! Audit stream pins of runtime subjects, shared by every runtime registration table. +use std::{collections::BTreeMap, sync::RwLock, time::Duration, time::Instant}; +use tinysecurity::approval::{Error, Result}; +use tinysecurity_bus::SubjectScope; + +/// Most live or recently released runtime subjects whose stream is remembered. +const MAX_PINNED_STREAMS: usize = 1 << 20; +/// How long a released subject keeps its stream: well past any callback deadline. +const STREAM_PIN_TTL: Duration = Duration::from_secs(3600); + +type StreamKey = (String, String, String); +struct StreamPin { + stream: String, + released: Option, +} + +/// The audit stream of each runtime subject. A live subject always keeps its stream, +/// and a released one keeps it for `ttl` (far beyond any callback deadline), so a +/// call still in flight for a retired scope cannot land in another scope's ledger +/// when the subject registers again. Pins of subjects released longer ago are +/// reclaimed, so only live and recently retired subjects count against `max`. +pub(crate) struct StreamPins { + streams: RwLock>, + pub(crate) ttl: Duration, + pub(crate) max: usize, +} +impl Default for StreamPins { + fn default() -> Self { + Self { + streams: RwLock::new(BTreeMap::new()), + ttl: STREAM_PIN_TTL, + max: MAX_PINNED_STREAMS, + } + } +} +impl StreamPins { + fn key(subject: &SubjectScope) -> StreamKey { + ( + subject.user.clone(), + subject.workspace.clone(), + subject.agent.clone(), + ) + } + /// Pin a subject's stream while it is live or recently released; refuse any + /// other stream for it until the pin has been released for `ttl`. + pub(crate) fn pin(&self, subject: &SubjectScope, stream_id: &str) -> Result<()> { + if stream_id.len() != 32 || !stream_id.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(Error::Invalid); + } + let key = Self::key(subject); + let now = Instant::now(); + let ttl = self.ttl; + let expired = |pin: &StreamPin| { + pin.released + .is_some_and(|at| now.saturating_duration_since(at) >= ttl) + }; + let mut streams = self.streams.write().map_err(|_| Error::Unavailable)?; + if let Some(pin) = streams.get_mut(&key) { + if pin.stream == stream_id { + pin.released = None; + return Ok(()); + } + if !expired(pin) { + return Err(Error::Conflict); + } + } else if streams.len() >= self.max { + streams.retain(|_, pin| !expired(pin)); + if streams.len() >= self.max { + return Err(Error::Unavailable); + } + } + streams.insert( + key, + StreamPin { + stream: stream_id.into(), + released: None, + }, + ); + Ok(()) + } + /// Start the retention clock of a released subject's pin. + pub(crate) fn unpin(&self, subject: &SubjectScope) -> Result<()> { + if let Some(pin) = self + .streams + .write() + .map_err(|_| Error::Unavailable)? + .get_mut(&Self::key(subject)) + { + pin.released = Some(Instant::now()); + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "stream_pins_tests.rs"] +mod tests; diff --git a/crates/tinysecurity-module/src/stream_pins_tests.rs b/crates/tinysecurity-module/src/stream_pins_tests.rs new file mode 100644 index 0000000..9f6498f --- /dev/null +++ b/crates/tinysecurity-module/src/stream_pins_tests.rs @@ -0,0 +1,52 @@ +use super::*; + +fn subject(name: &str) -> SubjectScope { + SubjectScope { + user: name.into(), + workspace: "w".into(), + agent: "a".into(), + } +} +const FIRST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const SECOND: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +#[test] +fn malformed_streams_never_pin() { + let pins = StreamPins::default(); + for stream in ["", "short", &"g".repeat(32), &"a".repeat(33)] { + assert!(matches!( + pins.pin(&subject("u"), stream), + Err(Error::Invalid) + )); + } +} + +#[test] +fn a_subject_keeps_one_stream_while_live_and_through_the_retention_window() { + let pins = StreamPins::default(); + let user = subject("u"); + assert!(pins.pin(&user, FIRST).is_ok()); + assert!(pins.pin(&user, FIRST).is_ok()); + assert!(matches!(pins.pin(&user, SECOND), Err(Error::Conflict))); + assert!(pins.unpin(&user).is_ok()); + assert!(matches!(pins.pin(&user, SECOND), Err(Error::Conflict))); + assert!(pins.pin(&user, FIRST).is_ok()); + // Other subjects are independent. + assert!(pins.pin(&subject("other"), SECOND).is_ok()); +} + +#[test] +fn a_released_pin_is_reclaimed_after_the_window_and_the_table_is_bounded() { + let mut pins = StreamPins::default(); + pins.max = 1; + let (first, second) = (subject("a"), subject("b")); + assert!(pins.pin(&first, FIRST).is_ok()); + assert!(matches!(pins.pin(&second, FIRST), Err(Error::Unavailable))); + assert!(pins.unpin(&first).is_ok()); + assert!(matches!(pins.pin(&second, FIRST), Err(Error::Unavailable))); + pins.ttl = Duration::ZERO; + assert!(pins.pin(&second, FIRST).is_ok()); + assert!(pins.pin(&first, SECOND).is_err()); + // Unpinning an unknown subject is harmless. + assert!(pins.unpin(&subject("unknown")).is_ok()); +} diff --git a/crates/tinysecurity-sandbox/src/engine.rs b/crates/tinysecurity-sandbox/src/engine.rs index 0982272..14bde1b 100644 --- a/crates/tinysecurity-sandbox/src/engine.rs +++ b/crates/tinysecurity-sandbox/src/engine.rs @@ -1,24 +1,38 @@ //! Bounded immutable registrations and exact declarative plan binding. use crate::{Error, Result, grants}; use sha2::{Digest, Sha256}; -use std::{fmt, sync::Arc}; +use std::{ + collections::BTreeMap, + fmt, + sync::{Arc, RwLock}, +}; use tinysecurity_bus::{ AccessTier, CallerContext, Check, ConstraintEnforcement2, Decision, DenialReason, - EvaluateRequest, IsolationLevel2, NetworkPolicy, Origin, ResourceLimits2, SandboxArtifact2, - SandboxBackend, SandboxConfig2, SandboxConstraint2, SandboxDenial2, SandboxDeployment2, - SandboxExecutor2, SandboxPlanRequest2, SandboxPlanResponse2, SandboxScope2, SandboxSetting2, - SourceTrust, SubjectScope, ToolCall, Verdict, + EvaluateRequest, IsolationLevel2, MAX_SANDBOX_SCOPES2, NetworkPolicy, Origin, ResourceLimits2, + SandboxArtifact2, SandboxBackend, SandboxConfig2, SandboxConstraint2, SandboxDenial2, + SandboxDeployment2, SandboxExecutor2, SandboxPlanRequest2, SandboxPlanResponse2, SandboxScope2, + SandboxSetting2, SourceTrust, SubjectScope, ToolCall, Verdict, }; use tinysecurity_policy::{PathPolicyRegistry, PathScope}; #[derive(Clone)] struct Scope { facts: SandboxScope2, paths: Arc, + /// Registered after init; only these can be retired. + runtime: bool, +} +/// Outcome of a runtime registration. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Registration { + /// A new scope is live. + Created, + /// An identical scope was already registered. + Unchanged, } /// Private planner with facts admitted only through trusted native initialization. #[derive(Clone)] pub struct Engine { - scopes: Vec, + scopes: Arc>>, issued: crate::registry::Registry, } impl fmt::Debug for Engine { @@ -128,6 +142,66 @@ fn select<'a>( SandboxDenial2::Unsupported }) } +/// Validate one registration and prepare its owner filesystem policy. +fn admit(facts: SandboxScope2, runtime: bool) -> Result { + if !id(&facts.scope_id) + || !id(&facts.subject.user) + || !id(&facts.subject.workspace) + || !id(&facts.subject.agent) + || facts.max_ttl_ms == 0 + || facts.max_ttl_ms > 300_000 + || !valid_limits(&facts.limits) + || facts.sources.is_empty() + || facts.sources.len() > 128 + || facts.executors.len() > 32 + || facts.grant_ceiling.len() > 128 + || facts.sources.iter().enumerate().any(|(i, s)| { + !id(&s.source_id) + || !id(&s.provenance) + || facts.sources[..i] + .iter() + .any(|p| p.source_id == s.source_id) + }) + || facts.executors.iter().enumerate().any(|(i, e)| { + !id(&e.executor_id) + || !id(&e.capability_id) + || e.constraints.len() > 8 + || facts.executors[..i] + .iter() + .any(|p| p.executor_id == e.executor_id) + || e.constraints.iter().enumerate().any(|(j, c)| { + e.constraints[..j] + .iter() + .any(|p| p.constraint == c.constraint) + }) + }) + || facts + .grant_ceiling + .iter() + .any(|g| g.path.len() > 4096 || !std::path::Path::new(&g.path).is_absolute()) + || facts.proxy.as_ref().is_some_and(|p| { + !id(&p.proxy_id) + || !facts + .executors + .iter() + .any(|e| e.executor_id == p.executor_id && e.capability_id == p.capability_id) + }) + { + return Err(Error::InvalidConfig); + } + let mut registry = PathPolicyRegistry::default(); + let policy_id = registry + .register(facts.paths.clone()) + .map_err(|_| Error::InvalidConfig)?; + let paths = registry + .get_owned(&policy_id) + .map_err(|_| Error::InvalidConfig)?; + Ok(Scope { + facts, + paths, + runtime, + }) +} impl Engine { /// Admit immutable facts supplied across the authenticated SDK initialization boundary. /// # Errors @@ -136,64 +210,79 @@ impl Engine { if config.scopes.len() > 128 { return Err(Error::InvalidConfig); } - let mut registry = PathPolicyRegistry::default(); - let mut scopes: Vec = Vec::new(); + let mut scopes = BTreeMap::new(); + let initial = config.scopes.len(); for facts in config.scopes { - if !id(&facts.scope_id) - || !id(&facts.subject.user) - || !id(&facts.subject.workspace) - || !id(&facts.subject.agent) - || facts.max_ttl_ms == 0 - || facts.max_ttl_ms > 300_000 - || !valid_limits(&facts.limits) - || facts.sources.is_empty() - || facts.sources.len() > 128 - || facts.executors.len() > 32 - || facts.grant_ceiling.len() > 128 - || scopes.iter().any(|s| s.facts.scope_id == facts.scope_id) - || facts.sources.iter().enumerate().any(|(i, s)| { - !id(&s.source_id) - || !id(&s.provenance) - || facts.sources[..i] - .iter() - .any(|p| p.source_id == s.source_id) - }) - || facts.executors.iter().enumerate().any(|(i, e)| { - !id(&e.executor_id) - || !id(&e.capability_id) - || e.constraints.len() > 8 - || facts.executors[..i] - .iter() - .any(|p| p.executor_id == e.executor_id) - || e.constraints.iter().enumerate().any(|(j, c)| { - e.constraints[..j] - .iter() - .any(|p| p.constraint == c.constraint) - }) - }) - || facts - .grant_ceiling - .iter() - .any(|g| g.path.len() > 4096 || !std::path::Path::new(&g.path).is_absolute()) - || facts.proxy.as_ref().is_some_and(|p| { - !id(&p.proxy_id) - || !facts.executors.iter().any(|e| { - e.executor_id == p.executor_id && e.capability_id == p.capability_id - }) - }) - { + let scope = admit(facts, false)?; + if scopes.insert(scope.facts.scope_id.clone(), scope).is_some() { return Err(Error::InvalidConfig); } - let policy_id = registry - .register(facts.paths.clone()) - .map_err(|_| Error::InvalidConfig)?; - let paths = registry - .get_owned(&policy_id) - .map_err(|_| Error::InvalidConfig)?; - scopes.push(Scope { facts, paths }); } - let issued = crate::registry::Registry::new(scopes.len()); - Ok(Self { scopes, issued }) + Ok(Self { + scopes: Arc::new(RwLock::new(scopes)), + issued: crate::registry::Registry::new(initial), + }) + } + fn table(&self) -> Result>> { + self.scopes.read().map_err(|_| Error::InvalidOperation) + } + /// Number of live scopes, initialized and runtime. + /// # Errors + /// Fails closed if the table is poisoned. + pub fn live_scopes(&self) -> Result { + Ok(self.table()?.len()) + } + /// Add a scope after init. An identical repeat is idempotent; reusing the identity + /// with different facts conflicts. A full table refuses: a live scope is never + /// evicted to make room, so policy is never silently dropped or replaced. + /// # Errors + /// `InvalidConfig` for malformed facts, `Conflict` for a changed identity and + /// `Capacity` when `MAX_SANDBOX_SCOPES2` scopes are live. + pub fn register_scope(&self, facts: SandboxScope2) -> Result { + let scope = admit(facts, true)?; + let mut table = self.scopes.write().map_err(|_| Error::InvalidOperation)?; + if let Some(existing) = table.get(&scope.facts.scope_id) { + return if existing.facts == scope.facts { + Ok(Registration::Unchanged) + } else { + Err(Error::Conflict) + }; + } + if table.len() >= MAX_SANDBOX_SCOPES2 { + return Err(Error::Capacity); + } + self.issued.mark_runtime(&scope.facts.scope_id); + table.insert(scope.facts.scope_id.clone(), scope); + Ok(Registration::Created) + } + /// Remove a runtime scope and discard every artifact it issued, pending or active. + /// Initialized scopes can never be retired. Returns the retired scope's subject. + /// # Errors + /// `Unknown` for a missing scope and `NotRetirable` for an initialized one. + pub fn retire_scope(&self, scope_id: &str) -> Result { + let mut table = self.scopes.write().map_err(|_| Error::InvalidOperation)?; + match table.get(scope_id) { + None => return Err(Error::Unknown), + Some(scope) if !scope.runtime => return Err(Error::NotRetirable), + Some(_) => {} + } + let removed = table.remove(scope_id).ok_or(Error::Unknown)?; + // Purged under the table lock that `stage` also holds, so no artifact of a + // retired scope can be staged after this point. + self.issued.purge_scope(scope_id); + Ok(removed.facts.subject) + } + /// Whether any scope, initialized or runtime, is registered under this identity. + /// # Errors + /// Fails closed if the table is poisoned. + pub fn has_scope(&self, scope_id: &str) -> Result { + Ok(self.table()?.contains_key(scope_id)) + } + /// Whether a runtime (not initialized) scope is registered under this identity. + /// # Errors + /// Fails closed if the table is poisoned. + pub fn is_runtime_scope(&self, scope_id: &str) -> Result { + Ok(self.table()?.get(scope_id).is_some_and(|s| s.runtime)) } /// Construct a bounded declarative plan at an injected trusted clock value. /// # Errors @@ -231,10 +320,9 @@ impl Engine { if request.generation != generation { return Err(SandboxDenial2::Stale); } - let scope = self - .scopes - .iter() - .find(|s| s.facts.scope_id == request.scope_id) + let table = self.table().map_err(|_| SandboxDenial2::InvalidContext)?; + let scope = table + .get(&request.scope_id) .ok_or(SandboxDenial2::InvalidContext)?; let facts = &scope.facts; let source = facts @@ -337,10 +425,9 @@ impl Engine { let artifact = self .resolve(request, generation, now_ms) .map_err(|_| Error::InvalidOperation)?; - let facts = &self - .scopes - .iter() - .find(|s| s.facts.scope_id == request.scope_id) + let table = self.table()?; + let facts = &table + .get(&request.scope_id) .ok_or(Error::InvalidOperation)? .facts; let approval_scope = facts @@ -405,6 +492,15 @@ impl Engine { /// # Errors /// Rejects entropy failure, full registry, expired candidate or invalid identity. pub fn stage(&self, artifact: SandboxArtifact2, now_ms: u64) -> Result { + // Held across the insert so `retire_scope` (which purges under the write lock) + // either removes this row or makes the scope check below fail. + let table = self.table()?; + if !table + .get(&artifact.scope_id) + .is_some_and(|scope| scope.facts.subject == artifact.subject) + { + return Err(Error::InvalidOperation); + } self.issued.stage(artifact, now_ms) } /// Remove a failed pending artifact; activated permissions cannot be removed by this port. @@ -458,21 +554,19 @@ impl Engine { generation: u64, now_ms: u64, ) -> Result { - let Some(executor) = self - .scopes - .iter() - .find(|scope| scope.facts.scope_id == artifact.scope_id) - .and_then(|scope| { - scope - .facts - .executors - .iter() - .find(|executor| executor.executor_id == artifact.executor_id) - }) - else { + // Cloned so no table lock is held across `validate`, which locks it again. + let executor = self.table()?.get(&artifact.scope_id).and_then(|scope| { + scope + .facts + .executors + .iter() + .find(|executor| executor.executor_id == artifact.executor_id) + .cloned() + }); + let Some(executor) = executor else { return Ok(false); }; - self.validate(request, artifact, generation, now_ms, executor) + self.validate(request, artifact, generation, now_ms, &executor) } /// Validate exact plan identity, live generation/time and executor revision before launch. /// This verifies the declarative artifact only; `TinyBox` must still recheck actual enforcement. @@ -541,5 +635,8 @@ pub(crate) fn denied(generation: u64, reason: SandboxDenial2) -> SandboxPlanResp #[path = "plan_matrix_tests.rs"] mod plan_matrix_tests; #[cfg(test)] +#[path = "runtime_scopes_tests.rs"] +mod runtime_scopes_tests; +#[cfg(test)] #[path = "engine_tests.rs"] pub(crate) mod tests; diff --git a/crates/tinysecurity-sandbox/src/error/mod.rs b/crates/tinysecurity-sandbox/src/error/mod.rs index 74bdaa4..80e8a3e 100644 --- a/crates/tinysecurity-sandbox/src/error/mod.rs +++ b/crates/tinysecurity-sandbox/src/error/mod.rs @@ -8,6 +8,18 @@ pub enum Error { /// Canonical operation encoding failed. #[error("invalid sandbox operation")] InvalidOperation, + /// A runtime registration reuses an identity with different facts. + #[error("conflicting sandbox scope")] + Conflict, + /// The bounded scope table is full; no live scope is evicted to make room. + #[error("sandbox scope capacity reached")] + Capacity, + /// No scope is registered under the identity. + #[error("unknown sandbox scope")] + Unknown, + /// An initialized scope is replaced by reinitialization, never retired. + #[error("sandbox scope cannot be retired")] + NotRetirable, } /// Sandbox engine result. pub type Result = std::result::Result; diff --git a/crates/tinysecurity-sandbox/src/lib.rs b/crates/tinysecurity-sandbox/src/lib.rs index ab3a3ef..6e6669f 100644 --- a/crates/tinysecurity-sandbox/src/lib.rs +++ b/crates/tinysecurity-sandbox/src/lib.rs @@ -11,5 +11,5 @@ mod engine; mod error; mod grants; mod registry; -pub use engine::Engine; +pub use engine::{Engine, Registration}; pub use error::{Error, Result}; diff --git a/crates/tinysecurity-sandbox/src/registry.rs b/crates/tinysecurity-sandbox/src/registry.rs index 916271a..feca847 100644 --- a/crates/tinysecurity-sandbox/src/registry.rs +++ b/crates/tinysecurity-sandbox/src/registry.rs @@ -1,6 +1,9 @@ //! Opaque module-owned pending/active/consumed artifacts; fingerprints are not authority. use crate::{Error, Result}; -use std::sync::{Arc, Mutex}; +use std::{ + collections::BTreeSet, + sync::{Arc, Mutex}, +}; use tinysecurity_bus::{SandboxArtifact2, SubjectScope}; #[derive(Clone, Debug)] struct Issued { @@ -14,7 +17,14 @@ struct State { rows: Vec, high_water_ms: u64, per_scope_limit: usize, + /// Scopes registered after init; each gets the smallest initialized share. + runtime: BTreeSet, } +/// Most staged or active artifacts across every scope. Initialized scopes share at +/// most 1024 of them, so runtime scopes cannot starve the initialized ones. +const MAX_ISSUED: usize = 16_384; +/// Artifacts one runtime scope may hold, the share of the busiest initialized layout. +const RUNTIME_SCOPE_LIMIT: usize = 8; impl State { fn advance(&mut self, now_ms: u64) -> Result<()> { if now_ms < self.high_water_ms { @@ -31,6 +41,19 @@ impl Registry { ..State::default() }))) } + pub(crate) fn mark_runtime(&self, scope_id: &str) { + if let Ok(mut state) = self.0.lock() { + state.runtime.insert(scope_id.into()); + } + } + /// Drop every artifact of a retired scope, activated ones included, so none of + /// them can be checked or consumed afterwards. + pub(crate) fn purge_scope(&self, scope_id: &str) { + if let Ok(mut state) = self.0.lock() { + state.runtime.remove(scope_id); + state.rows.retain(|row| row.artifact.scope_id != scope_id); + } + } pub(crate) fn stage( &self, mut artifact: SandboxArtifact2, @@ -41,10 +64,14 @@ impl Registry { } let mut state = self.0.lock().map_err(|_| Error::InvalidOperation)?; state.advance(now_ms)?; - let per_scope_limit = state.per_scope_limit; + let per_scope_limit = if state.runtime.contains(&artifact.scope_id) { + RUNTIME_SCOPE_LIMIT + } else { + state.per_scope_limit + }; let issued = &mut state.rows; issued.retain(|row| row.artifact.expires_at_ms > now_ms); - if issued.len() >= 1024 + if issued.len() >= MAX_ISSUED || issued .iter() .filter(|row| row.artifact.scope_id == artifact.scope_id) diff --git a/crates/tinysecurity-sandbox/src/runtime_scopes_tests.rs b/crates/tinysecurity-sandbox/src/runtime_scopes_tests.rs new file mode 100644 index 0000000..cf13c01 --- /dev/null +++ b/crates/tinysecurity-sandbox/src/runtime_scopes_tests.rs @@ -0,0 +1,221 @@ +use super::*; +use crate::engine::tests::fixture; + +type TestResult = std::result::Result<(), Box>; + +fn engine_with(init: Vec) -> std::result::Result { + Engine::new(SandboxConfig2 { scopes: init }) +} + +fn runtime_variant(base: &SandboxScope2, name: &str) -> SandboxScope2 { + let mut scope = base.clone(); + scope.scope_id = name.into(); + scope.subject.agent = format!("agent-{name}"); + scope +} + +fn request_for(base: &SandboxPlanRequest2, scope: &SandboxScope2) -> SandboxPlanRequest2 { + let mut request = base.clone(); + request.scope_id.clone_from(&scope.scope_id); + request.subject = scope.subject.clone(); + request +} + +#[test] +fn unregistered_scope_denies_until_registered_and_again_after_retirement() -> TestResult { + let (_dir, init, base) = fixture()?; + let engine = engine_with(vec![init.clone()])?; + let runtime = runtime_variant(&init, "tenant"); + let request = request_for(&base, &runtime); + let denied = engine.plan(&request, 1, 100)?; + assert_eq!(denied.denial, Some(SandboxDenial2::InvalidContext)); + assert_eq!( + engine.register_scope(runtime.clone())?, + Registration::Created + ); + assert_eq!(engine.live_scopes()?, 2); + assert!(engine.is_runtime_scope("tenant")?); + assert!(!engine.is_runtime_scope("scope")?); + assert_eq!( + engine.plan(&request, 1, 100)?.decision.verdict, + Verdict::Allow + ); + assert_eq!(engine.retire_scope("tenant")?, runtime.subject); + assert_eq!(engine.live_scopes()?, 1); + let after = engine.plan(&request, 1, 100)?; + assert_eq!(after.denial, Some(SandboxDenial2::InvalidContext)); + Ok(()) +} + +#[test] +fn registration_is_idempotent_and_never_rebinds_an_identity() -> TestResult { + let (_dir, init, _) = fixture()?; + let engine = engine_with(vec![init.clone()])?; + let runtime = runtime_variant(&init, "tenant"); + engine.register_scope(runtime.clone())?; + assert_eq!( + engine.register_scope(runtime.clone())?, + Registration::Unchanged + ); + let mut widened = runtime.clone(); + widened.network_ceiling = NetworkPolicy::Host; + assert_eq!(engine.register_scope(widened), Err(Error::Conflict)); + // An initialized identity is as immutable as a runtime one. + let mut init_clash = init.clone(); + init_clash.max_ttl_ms = 5; + assert_eq!(engine.register_scope(init_clash), Err(Error::Conflict)); + assert_eq!(engine.register_scope(init), Ok(Registration::Unchanged)); + assert_eq!(engine.live_scopes()?, 2); + Ok(()) +} + +#[test] +fn initialized_and_unknown_scopes_cannot_be_retired() -> TestResult { + let (_dir, init, _) = fixture()?; + let engine = engine_with(vec![init])?; + assert_eq!(engine.retire_scope("scope"), Err(Error::NotRetirable)); + assert_eq!(engine.retire_scope("missing"), Err(Error::Unknown)); + assert_eq!(engine.live_scopes()?, 1); + Ok(()) +} + +#[test] +fn malformed_runtime_facts_are_refused_like_initial_ones() -> TestResult { + let (_dir, init, _) = fixture()?; + let engine = engine_with(vec![])?; + let mut bad = runtime_variant(&init, "tenant"); + bad.sources.clear(); + assert_eq!(engine.register_scope(bad), Err(Error::InvalidConfig)); + let mut bad = runtime_variant(&init, "tenant"); + bad.max_ttl_ms = 300_001; + assert_eq!(engine.register_scope(bad), Err(Error::InvalidConfig)); + let mut bad = runtime_variant(&init, ""); + bad.scope_id.clear(); + assert_eq!(engine.register_scope(bad), Err(Error::InvalidConfig)); + assert_eq!(engine.live_scopes()?, 0); + Ok(()) +} + +#[test] +fn retirement_discards_pending_and_active_artifacts_for_good() -> TestResult { + let (_dir, init, base) = fixture()?; + let engine = engine_with(vec![init.clone()])?; + let runtime = runtime_variant(&init, "tenant"); + let request = request_for(&base, &runtime); + engine.register_scope(runtime.clone())?; + let plan = + |engine: &Engine| -> std::result::Result> { + Ok(engine + .plan(&request, 1, 100)? + .artifact + .ok_or("plan denied")?) + }; + let active = engine.stage(plan(&engine)?, 100)?; + engine.activate(&active, 1, 100)?; + let pending = engine.stage(plan(&engine)?, 100)?; + assert!( + engine + .check_issued(&active, &request.subject, 1, 100) + .is_ok() + ); + engine.retire_scope("tenant")?; + assert!( + engine + .check_issued(&active, &request.subject, 1, 100) + .is_err() + ); + assert!(engine.activate(&pending, 1, 100).is_err()); + assert!(engine.consume(&active, &request.subject, 1, 100).is_err()); + assert_eq!( + engine.validate_registered(&request, &active, 1, 100), + Ok(false) + ); + // A fresh registration under the same identity never revives old artifacts. + engine.register_scope(runtime)?; + assert!( + engine + .check_issued(&active, &request.subject, 1, 100) + .is_err() + ); + assert!(engine.activate(&pending, 1, 100).is_err()); + Ok(()) +} + +#[test] +fn a_candidate_cannot_be_staged_for_a_retired_or_unregistered_scope() -> TestResult { + let (_dir, init, base) = fixture()?; + let engine = engine_with(vec![init.clone()])?; + let runtime = runtime_variant(&init, "tenant"); + let request = request_for(&base, &runtime); + engine.register_scope(runtime)?; + let candidate = engine + .plan(&request, 1, 100)? + .artifact + .ok_or("plan denied")?; + engine.retire_scope("tenant")?; + assert!(engine.stage(candidate, 100).is_err()); + Ok(()) +} + +#[test] +fn a_runtime_scope_cannot_exhaust_another_scopes_artifact_capacity() -> TestResult { + let (_dir, init, base) = fixture()?; + let engine = engine_with(vec![init.clone()])?; + let first = runtime_variant(&init, "first"); + let second = runtime_variant(&init, "second"); + engine.register_scope(first.clone())?; + engine.register_scope(second.clone())?; + let candidate = engine + .plan(&request_for(&base, &first), 1, 100)? + .artifact + .ok_or("plan denied")?; + for _ in 0..8 { + engine.stage(candidate.clone(), 100)?; + } + assert!(engine.stage(candidate, 100).is_err()); + let other = engine + .plan(&request_for(&base, &second), 1, 100)? + .artifact + .ok_or("plan denied")?; + assert!(engine.stage(other, 100).is_ok()); + // The initialized scope keeps its own share. + let init_candidate = engine.plan(&base, 1, 100)?.artifact.ok_or("plan denied")?; + assert!(engine.stage(init_candidate, 100).is_ok()); + Ok(()) +} + +#[test] +fn a_full_table_refuses_a_new_scope_without_evicting_a_live_one() -> TestResult { + let (_dir, init, base) = fixture()?; + let engine = engine_with(vec![init.clone()])?; + for index in 1..MAX_SANDBOX_SCOPES2 { + engine.register_scope(runtime_variant(&init, &format!("t{index}")))?; + } + assert_eq!(engine.live_scopes()?, MAX_SANDBOX_SCOPES2); + let overflow = runtime_variant(&init, "overflow"); + assert_eq!( + engine.register_scope(overflow.clone()), + Err(Error::Capacity) + ); + // An identical repeat of a live scope still answers, and every scope still plans. + let live = runtime_variant(&init, "t1"); + assert_eq!( + engine.register_scope(live.clone())?, + Registration::Unchanged + ); + assert_eq!( + engine + .plan(&request_for(&base, &live), 1, 100)? + .decision + .verdict, + Verdict::Allow + ); + assert_eq!( + engine.plan(&request_for(&base, &overflow), 1, 100)?.denial, + Some(SandboxDenial2::InvalidContext) + ); + // Retiring frees exactly one slot. + engine.retire_scope("t1")?; + assert_eq!(engine.register_scope(overflow)?, Registration::Created); + Ok(()) +} From f53652bd0f474036882112f3ce8b690c4fa92406 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 15:09:40 +0300 Subject: [PATCH 2/7] test(module): initialize stream pins without field reassignment Co-authored-by: Medulla --- crates/tinysecurity-module/src/stream_pins_tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-module/src/stream_pins_tests.rs b/crates/tinysecurity-module/src/stream_pins_tests.rs index 9f6498f..03fd10c 100644 --- a/crates/tinysecurity-module/src/stream_pins_tests.rs +++ b/crates/tinysecurity-module/src/stream_pins_tests.rs @@ -37,8 +37,10 @@ fn a_subject_keeps_one_stream_while_live_and_through_the_retention_window() { #[test] fn a_released_pin_is_reclaimed_after_the_window_and_the_table_is_bounded() { - let mut pins = StreamPins::default(); - pins.max = 1; + let mut pins = StreamPins { + max: 1, + ..StreamPins::default() + }; let (first, second) = (subject("a"), subject("b")); assert!(pins.pin(&first, FIRST).is_ok()); assert!(matches!(pins.pin(&second, FIRST), Err(Error::Unavailable))); From 585409036e6a3bd23ff48cc2ca5458f804e873c0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 15:23:26 +0300 Subject: [PATCH 3/7] fix(sandbox): separate artifact pools for runtime and initialized scopes; retired runtime audits never fall back to the base stream Co-authored-by: Medulla --- MODULE.md | 7 ++- .../src/adapter_sandbox.rs | 28 ++++++--- .../src/adapter_sandbox_scopes_tests.rs | 60 +++++++++++++++++++ crates/tinysecurity-sandbox/src/engine.rs | 2 +- crates/tinysecurity-sandbox/src/registry.rs | 37 +++++++----- .../src/runtime_scopes_tests.rs | 35 +++++++++++ 6 files changed, 144 insertions(+), 25 deletions(-) diff --git a/MODULE.md b/MODULE.md index 1b3a92e..22b84a1 100644 --- a/MODULE.md +++ b/MODULE.md @@ -460,8 +460,11 @@ Bounds and eviction never widen policy. The table holds at most 65,536 live scop full table refuses a new registration (`capacity`) and never evicts a live scope, so a subject's ceilings are never silently dropped or replaced. Retirement is the only way a scope leaves, and the registry slot, artifacts and audit client go with it. A runtime -scope holds at most 8 staged or active artifacts (initialized scopes keep their share of -1024), so one subject cannot exhaust another's capacity. A live subject keeps its audit +scope holds at most 8 staged or active artifacts, runtime scopes share a pool of 15,360 and +initialized scopes keep their own 1024, so one subject cannot exhaust another's capacity and +busy runtime scopes cannot starve the initialized ones. A plan in flight keeps the audit +stream it started on if its scope is retired meanwhile, and a retired or unknown runtime +identity never falls back to the base stream. A live subject keeps its audit stream and a retired one keeps it for an hour: registering it again with another stream in that window is refused, so a plan still in flight for a retired scope cannot reach another ledger. Several scope identities may share a subject; its stream is released diff --git a/crates/tinysecurity-module/src/adapter_sandbox.rs b/crates/tinysecurity-module/src/adapter_sandbox.rs index 10adace..76bde65 100644 --- a/crates/tinysecurity-module/src/adapter_sandbox.rs +++ b/crates/tinysecurity-module/src/adapter_sandbox.rs @@ -197,9 +197,14 @@ impl PolicyService { Some(artifact) } /// The audit client that records a scope's plans: its own stream for a runtime - /// scope, the base client for an initialized one. + /// scope, the base client for an initialized one, and none for any other + /// identity. A retired or unknown runtime identity never falls back to the base + /// stream, so its events cannot land in another ledger. Callers resolve it once, + /// before planning, so a plan in flight keeps the stream it started on even if + /// its scope is retired meanwhile. fn sandbox_audit_client( &self, + engine: &tinysecurity::sandbox::Engine, scope_id: &str, ) -> BusResult>> { if let Some(runtime) = &self.sandbox_runtime @@ -207,11 +212,18 @@ impl PolicyService { { return Ok(Some(client)); } - Ok(self.audit.clone()) + let initialized = engine + .has_scope(scope_id) + .and_then(|known| Ok(known && !engine.is_runtime_scope(scope_id)?)) + .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))?; + Ok(initialized.then(|| self.audit.clone()).flatten()) } async fn audit_sandbox( &self, - owner: (&str, &tinysecurity_bus::SubjectScope), + owner: ( + Option>, + &tinysecurity_bus::SubjectScope, + ), original: serde_json::Value, correlation: &tinysecurity_bus::AuditCorrelation2, generation: u64, @@ -227,8 +239,8 @@ impl PolicyService { receipt: None, }) }; - let (scope_id, subject) = owner; - let Some(client) = self.sandbox_audit_client(scope_id)? else { + let (client, subject) = owner; + let Some(client) = client else { return Ok(refused()); }; if client.scope() != subject || correlation.generation != generation { @@ -264,6 +276,7 @@ impl PolicyService { let Some(engine) = self.sandbox.clone() else { return Ok(refused(generation)); }; + let audit_client = self.sandbox_audit_client(&engine, &request.scope_id)?; let original = request.clone(); let worker = Arc::clone(&engine); let now = self.wall_ms()?; @@ -299,7 +312,7 @@ impl PolicyService { }; let evidence = self .audit_sandbox( - (&request.scope_id, &request.subject), + (audit_client, &request.subject), serde_json::json!([request, response]), &request.audit, generation, @@ -346,6 +359,7 @@ impl PolicyService { let Some(engine) = &self.sandbox else { return Ok(response); }; + let audit_client = self.sandbox_audit_client(engine, &request.request.scope_id)?; if engine .check_issued( &request.artifact, @@ -369,7 +383,7 @@ impl PolicyService { } response.audit = self .audit_sandbox( - (&request.request.scope_id, &request.subject), + (audit_client, &request.subject), serde_json::json!([request,{"eligible":true}]), &request.audit, generation, diff --git a/crates/tinysecurity-module/src/adapter_sandbox_scopes_tests.rs b/crates/tinysecurity-module/src/adapter_sandbox_scopes_tests.rs index 2033adf..20bcdfe 100644 --- a/crates/tinysecurity-module/src/adapter_sandbox_scopes_tests.rs +++ b/crates/tinysecurity-module/src/adapter_sandbox_scopes_tests.rs @@ -456,3 +456,63 @@ async fn an_unusable_audit_base_refuses_registration_and_releases_the_pin() -> T ); Ok(()) } + +#[tokio::test] +async fn a_retired_runtime_identity_never_audits_on_the_base_stream() -> TestResult { + let h = with_runtime().await?; + // A runtime scope for the very subject the base audit client serves. + let mut own = h.facts.clone(); + own.scope_id = "own-runtime".into(); + own.approval_scope_id = None; + h.service + .register_sandbox_scope2(registration(&own, TENANT_STREAM, false)) + .await?; + h.service + .retire_sandbox_scope2(RetireSandboxScopeRequest2 { + scope_id: own.scope_id.clone(), + }) + .await?; + let before = rows(&h).len(); + let denied = h.service.plan2(fixture::request(&own, 1)).await?; + assert_eq!(denied.denial, Some(SandboxDenial2::InvalidContext)); + assert!(denied.audit.is_none_or(|audit| !audit.committed)); + assert_eq!(rows(&h).len(), before, "nothing reached any ledger"); + Ok(()) +} + +#[tokio::test] +async fn a_plan_in_flight_when_its_scope_retires_keeps_its_own_stream_and_never_activates() +-> TestResult { + let h = with_runtime().await?; + let mut own = h.facts.clone(); + own.scope_id = "own-runtime".into(); + own.approval_scope_id = None; + h.service + .register_sandbox_scope2(registration(&own, TENANT_STREAM, false)) + .await?; + h.sink.mode.store(4, Ordering::SeqCst); + let plan = h.service.plan2(fixture::request(&own, 1)); + let retire = async { + while rows(&h).is_empty() { + tokio::task::yield_now().await; + } + let retired = h + .service + .retire_sandbox_scope2(RetireSandboxScopeRequest2 { + scope_id: own.scope_id.clone(), + }) + .await; + h.sink.barrier.wait().await; + retired + }; + let (plan, retired) = tokio::join!(plan, retire); + retired?; + let plan = plan?; + assert!(plan.artifact.is_none()); + assert_ne!(plan.decision.verdict, tinysecurity_bus::Verdict::Allow); + let sent = rows(&h); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].stream_id, TENANT_STREAM); + assert!(sent.iter().all(|row| row.stream_id != "c".repeat(32))); + Ok(()) +} diff --git a/crates/tinysecurity-sandbox/src/engine.rs b/crates/tinysecurity-sandbox/src/engine.rs index 14bde1b..f1f8f31 100644 --- a/crates/tinysecurity-sandbox/src/engine.rs +++ b/crates/tinysecurity-sandbox/src/engine.rs @@ -220,7 +220,7 @@ impl Engine { } Ok(Self { scopes: Arc::new(RwLock::new(scopes)), - issued: crate::registry::Registry::new(initial), + issued: crate::registry::Registry::new(initial, crate::registry::MAX_RUNTIME_ISSUED), }) } fn table(&self) -> Result>> { diff --git a/crates/tinysecurity-sandbox/src/registry.rs b/crates/tinysecurity-sandbox/src/registry.rs index feca847..ab9cddf 100644 --- a/crates/tinysecurity-sandbox/src/registry.rs +++ b/crates/tinysecurity-sandbox/src/registry.rs @@ -19,10 +19,14 @@ struct State { per_scope_limit: usize, /// Scopes registered after init; each gets the smallest initialized share. runtime: BTreeSet, + /// Most artifacts the runtime scopes hold together. + runtime_pool_limit: usize, } -/// Most staged or active artifacts across every scope. Initialized scopes share at -/// most 1024 of them, so runtime scopes cannot starve the initialized ones. -const MAX_ISSUED: usize = 16_384; +/// Most staged or active artifacts across the initialized scopes, which share them. +const MAX_INITIALIZED_ISSUED: usize = 1024; +/// Most staged or active artifacts across every runtime scope. A pool of its own, so +/// busy runtime scopes can never starve the initialized ones (and the reverse). +pub(crate) const MAX_RUNTIME_ISSUED: usize = 15_360; /// Artifacts one runtime scope may hold, the share of the busiest initialized layout. const RUNTIME_SCOPE_LIMIT: usize = 8; impl State { @@ -35,12 +39,14 @@ impl State { } } impl Registry { - pub(crate) fn new(scopes: usize) -> Self { + pub(crate) fn new(scopes: usize, runtime_pool: usize) -> Self { Self(Arc::new(Mutex::new(State { per_scope_limit: 1024 / scopes.max(1), + runtime_pool_limit: runtime_pool, ..State::default() }))) } + pub(crate) fn mark_runtime(&self, scope_id: &str) { if let Ok(mut state) = self.0.lock() { state.runtime.insert(scope_id.into()); @@ -64,20 +70,21 @@ impl Registry { } let mut state = self.0.lock().map_err(|_| Error::InvalidOperation)?; state.advance(now_ms)?; - let per_scope_limit = if state.runtime.contains(&artifact.scope_id) { - RUNTIME_SCOPE_LIMIT + let runtime_scope = state.runtime.contains(&artifact.scope_id); + let (per_scope_limit, pool_limit) = if runtime_scope { + (RUNTIME_SCOPE_LIMIT, state.runtime_pool_limit) } else { - state.per_scope_limit + (state.per_scope_limit, MAX_INITIALIZED_ISSUED) }; - let issued = &mut state.rows; + let State { rows, runtime, .. } = &mut *state; + let issued = rows; issued.retain(|row| row.artifact.expires_at_ms > now_ms); - if issued.len() >= MAX_ISSUED - || issued - .iter() - .filter(|row| row.artifact.scope_id == artifact.scope_id) - .count() - >= per_scope_limit - { + let (mut pool, mut own) = (0usize, 0usize); + for row in issued.iter() { + pool += usize::from(runtime.contains(&row.artifact.scope_id) == runtime_scope); + own += usize::from(row.artifact.scope_id == artifact.scope_id); + } + if pool >= pool_limit || own >= per_scope_limit { return Err(Error::InvalidOperation); } for _ in 0..4 { diff --git a/crates/tinysecurity-sandbox/src/runtime_scopes_tests.rs b/crates/tinysecurity-sandbox/src/runtime_scopes_tests.rs index cf13c01..b454301 100644 --- a/crates/tinysecurity-sandbox/src/runtime_scopes_tests.rs +++ b/crates/tinysecurity-sandbox/src/runtime_scopes_tests.rs @@ -219,3 +219,38 @@ fn a_full_table_refuses_a_new_scope_without_evicting_a_live_one() -> TestResult assert_eq!(engine.register_scope(overflow)?, Registration::Created); Ok(()) } + +#[test] +fn saturated_runtime_scopes_never_starve_initialized_artifact_capacity() -> TestResult { + let (_dir, init, base) = fixture()?; + let mut engine = engine_with(vec![init.clone()])?; + // A small runtime pool: 4 scopes of 8 artifacts fill it. + engine.issued = crate::registry::Registry::new(1, 32); + for index in 0..4 { + let scope = runtime_variant(&init, &format!("t{index}")); + let request = request_for(&base, &scope); + engine.register_scope(scope)?; + let candidate = engine + .plan(&request, 1, 100)? + .artifact + .ok_or("plan denied")?; + for _ in 0..8 { + engine.stage(candidate.clone(), 100)?; + } + } + let extra = runtime_variant(&init, "extra"); + let extra_request = request_for(&base, &extra); + engine.register_scope(extra)?; + let extra_candidate = engine + .plan(&extra_request, 1, 100)? + .artifact + .ok_or("plan denied")?; + assert!(engine.stage(extra_candidate, 100).is_err()); + // The initialized scope still stages its own share, and only its own. + let init_candidate = engine.plan(&base, 1, 100)?.artifact.ok_or("plan denied")?; + for _ in 0..1024 { + engine.stage(init_candidate.clone(), 100)?; + } + assert!(engine.stage(init_candidate, 100).is_err()); + Ok(()) +} From ec3605cd81397e543b4803274f39d83b18de2b90 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 16:37:31 +0300 Subject: [PATCH 4/7] fix(sandbox): bind staged artifacts to the scope incarnation they were planned under Co-authored-by: Medulla --- .../src/adapter_sandbox.rs | 9 ++- .../src/adapter_sandbox_scopes_tests.rs | 36 +++++++++++ crates/tinysecurity-sandbox/src/engine.rs | 59 +++++++++++++++---- .../src/runtime_scopes_tests.rs | 26 ++++++++ 4 files changed, 118 insertions(+), 12 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_sandbox.rs b/crates/tinysecurity-module/src/adapter_sandbox.rs index 76bde65..b1c04c0 100644 --- a/crates/tinysecurity-module/src/adapter_sandbox.rs +++ b/crates/tinysecurity-module/src/adapter_sandbox.rs @@ -277,6 +277,11 @@ impl PolicyService { return Ok(refused(generation)); }; let audit_client = self.sandbox_audit_client(&engine, &request.scope_id)?; + // The registration this plan is for. If the scope is retired meanwhile, even + // if the same identity registers again, the candidate is refused at staging. + let incarnation = engine + .incarnation(&request.scope_id) + .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))?; let original = request.clone(); let worker = Arc::clone(&engine); let now = self.wall_ms()?; @@ -296,7 +301,9 @@ impl PolicyService { response.artifact = Some(artifact); } let staged = if let Some(artifact) = response.artifact.take() { - if let Ok(artifact) = engine.stage(artifact, self.wall_ms()?) { + if let Some(incarnation) = incarnation + && let Ok(artifact) = engine.stage_for(incarnation, artifact, self.wall_ms()?) + { response.artifact = Some(artifact.clone()); Some(artifact) } else { diff --git a/crates/tinysecurity-module/src/adapter_sandbox_scopes_tests.rs b/crates/tinysecurity-module/src/adapter_sandbox_scopes_tests.rs index 20bcdfe..bea4c0d 100644 --- a/crates/tinysecurity-module/src/adapter_sandbox_scopes_tests.rs +++ b/crates/tinysecurity-module/src/adapter_sandbox_scopes_tests.rs @@ -516,3 +516,39 @@ async fn a_plan_in_flight_when_its_scope_retires_keeps_its_own_stream_and_never_ assert!(sent.iter().all(|row| row.stream_id != "c".repeat(32))); Ok(()) } + +#[tokio::test] +async fn a_plan_in_flight_never_activates_under_a_scope_registered_again_meanwhile() -> TestResult { + let h = with_runtime().await?; + let mut own = h.facts.clone(); + own.scope_id = "own-runtime".into(); + own.approval_scope_id = None; + h.service + .register_sandbox_scope2(registration(&own, TENANT_STREAM, false)) + .await?; + h.sink.mode.store(4, Ordering::SeqCst); + let plan = h.service.plan2(fixture::request(&own, 1)); + let reregister = async { + while rows(&h).is_empty() { + tokio::task::yield_now().await; + } + h.service + .retire_sandbox_scope2(RetireSandboxScopeRequest2 { + scope_id: own.scope_id.clone(), + }) + .await?; + h.service + .register_sandbox_scope2(registration(&own, TENANT_STREAM, false)) + .await?; + h.sink.barrier.wait().await; + Ok::<_, tinybus::Error>(()) + }; + let (plan, reregistered) = tokio::join!(plan, reregister); + reregistered?; + let plan = plan?; + // The identity, subject and facts are the same, but the candidate was planned for + // the previous registration: it neither activates nor survives to be consumed. + assert!(plan.artifact.is_none()); + assert_ne!(plan.decision.verdict, tinysecurity_bus::Verdict::Allow); + Ok(()) +} diff --git a/crates/tinysecurity-sandbox/src/engine.rs b/crates/tinysecurity-sandbox/src/engine.rs index f1f8f31..a697b8a 100644 --- a/crates/tinysecurity-sandbox/src/engine.rs +++ b/crates/tinysecurity-sandbox/src/engine.rs @@ -4,7 +4,10 @@ use sha2::{Digest, Sha256}; use std::{ collections::BTreeMap, fmt, - sync::{Arc, RwLock}, + sync::{ + Arc, RwLock, + atomic::{AtomicU64, Ordering}, + }, }; use tinysecurity_bus::{ AccessTier, CallerContext, Check, ConstraintEnforcement2, Decision, DenialReason, @@ -20,6 +23,9 @@ struct Scope { paths: Arc, /// Registered after init; only these can be retired. runtime: bool, + /// Distinguishes this registration from any other under the same identity, so a + /// candidate planned for a retired scope can never be staged under its successor. + incarnation: u64, } /// Outcome of a runtime registration. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -33,6 +39,7 @@ pub enum Registration { #[derive(Clone)] pub struct Engine { scopes: Arc>>, + incarnations: Arc, issued: crate::registry::Registry, } impl fmt::Debug for Engine { @@ -200,6 +207,7 @@ fn admit(facts: SandboxScope2, runtime: bool) -> Result { facts, paths, runtime, + incarnation: 0, }) } impl Engine { @@ -212,14 +220,17 @@ impl Engine { } let mut scopes = BTreeMap::new(); let initial = config.scopes.len(); + let incarnations = Arc::new(AtomicU64::new(1)); for facts in config.scopes { - let scope = admit(facts, false)?; + let mut scope = admit(facts, false)?; + scope.incarnation = incarnations.fetch_add(1, Ordering::SeqCst); if scopes.insert(scope.facts.scope_id.clone(), scope).is_some() { return Err(Error::InvalidConfig); } } Ok(Self { scopes: Arc::new(RwLock::new(scopes)), + incarnations, issued: crate::registry::Registry::new(initial, crate::registry::MAX_RUNTIME_ISSUED), }) } @@ -239,7 +250,7 @@ impl Engine { /// `InvalidConfig` for malformed facts, `Conflict` for a changed identity and /// `Capacity` when `MAX_SANDBOX_SCOPES2` scopes are live. pub fn register_scope(&self, facts: SandboxScope2) -> Result { - let scope = admit(facts, true)?; + let mut scope = admit(facts, true)?; let mut table = self.scopes.write().map_err(|_| Error::InvalidOperation)?; if let Some(existing) = table.get(&scope.facts.scope_id) { return if existing.facts == scope.facts { @@ -252,6 +263,7 @@ impl Engine { return Err(Error::Capacity); } self.issued.mark_runtime(&scope.facts.scope_id); + scope.incarnation = self.incarnations.fetch_add(1, Ordering::SeqCst); table.insert(scope.facts.scope_id.clone(), scope); Ok(Registration::Created) } @@ -487,22 +499,47 @@ impl Engine { }; Ok((artifact, approval_scope, original)) } - /// Stage a candidate in the bounded native registry before its canonical audit. - /// This internal method grants no execution and must never be exposed on the bus. + /// The registration a scope identity currently has, to be captured before planning + /// and handed to [`Self::stage_for`]. `None` when no such scope is registered. /// # Errors - /// Rejects entropy failure, full registry, expired candidate or invalid identity. - pub fn stage(&self, artifact: SandboxArtifact2, now_ms: u64) -> Result { + /// Fails closed if the table is poisoned. + pub fn incarnation(&self, scope_id: &str) -> Result> { + Ok(self.table()?.get(scope_id).map(|scope| scope.incarnation)) + } + /// Stage a candidate in the bounded native registry before its canonical audit, + /// only while the scope it was planned under is still the registration the caller + /// captured. A scope that was retired, even if the same identity registered again + /// since, refuses the candidate. This internal method grants no execution and must + /// never be exposed on the bus. + /// # Errors + /// Rejects entropy failure, full registry, expired candidate, invalid identity and + /// a scope that is no longer the captured incarnation. + pub fn stage_for( + &self, + incarnation: u64, + artifact: SandboxArtifact2, + now_ms: u64, + ) -> Result { // Held across the insert so `retire_scope` (which purges under the write lock) // either removes this row or makes the scope check below fail. let table = self.table()?; - if !table - .get(&artifact.scope_id) - .is_some_and(|scope| scope.facts.subject == artifact.subject) - { + if !table.get(&artifact.scope_id).is_some_and(|scope| { + scope.facts.subject == artifact.subject && scope.incarnation == incarnation + }) { return Err(Error::InvalidOperation); } self.issued.stage(artifact, now_ms) } + /// [`Self::stage_for`] under the scope's current registration. For a caller that + /// plans and stages with no retirement possible between the two. + /// # Errors + /// As [`Self::stage_for`]. + pub fn stage(&self, artifact: SandboxArtifact2, now_ms: u64) -> Result { + let incarnation = self + .incarnation(&artifact.scope_id)? + .ok_or(Error::InvalidOperation)?; + self.stage_for(incarnation, artifact, now_ms) + } /// Remove a failed pending artifact; activated permissions cannot be removed by this port. pub fn discard(&self, artifact: &SandboxArtifact2) { self.issued.discard(artifact); diff --git a/crates/tinysecurity-sandbox/src/runtime_scopes_tests.rs b/crates/tinysecurity-sandbox/src/runtime_scopes_tests.rs index b454301..4030d3c 100644 --- a/crates/tinysecurity-sandbox/src/runtime_scopes_tests.rs +++ b/crates/tinysecurity-sandbox/src/runtime_scopes_tests.rs @@ -254,3 +254,29 @@ fn saturated_runtime_scopes_never_starve_initialized_artifact_capacity() -> Test assert!(engine.stage(init_candidate, 100).is_err()); Ok(()) } + +#[test] +fn a_candidate_is_bound_to_the_scope_incarnation_it_was_planned_under() -> TestResult { + let (_dir, init, base) = fixture()?; + let engine = engine_with(vec![init.clone()])?; + let runtime = runtime_variant(&init, "tenant"); + let request = request_for(&base, &runtime); + engine.register_scope(runtime.clone())?; + let first = engine.incarnation("tenant")?.ok_or("registered")?; + let candidate = engine + .plan(&request, 1, 100)? + .artifact + .ok_or("plan denied")?; + // Retired and registered again under the same identity, facts and subject. + engine.retire_scope("tenant")?; + assert_eq!(engine.incarnation("tenant")?, None); + engine.register_scope(runtime)?; + let second = engine.incarnation("tenant")?.ok_or("registered")?; + assert_ne!(first, second, "every registration is its own incarnation"); + assert!(engine.stage_for(first, candidate.clone(), 100).is_err()); + assert!(engine.stage_for(second, candidate, 100).is_ok()); + // Initialized scopes have an incarnation too, and nothing for an unknown identity. + assert!(engine.incarnation("scope")?.is_some()); + assert_eq!(engine.incarnation("missing")?, None); + Ok(()) +} From c0383aca60c70933d73ca51a1807a154ec57af5c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 16:52:08 +0300 Subject: [PATCH 5/7] docs(sandbox): runtime sandbox scopes specification and plan; simplify the initialized-scope audit lookup Co-authored-by: Medulla --- .../src/adapter_sandbox.rs | 6 +- docs/plans/runtime-sandbox-scopes.md | 21 +++++ docs/specs/runtime-sandbox-scopes.md | 79 +++++++++++++++++++ docs/specs/security-module.md | 6 +- 4 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 docs/plans/runtime-sandbox-scopes.md create mode 100644 docs/specs/runtime-sandbox-scopes.md diff --git a/crates/tinysecurity-module/src/adapter_sandbox.rs b/crates/tinysecurity-module/src/adapter_sandbox.rs index b1c04c0..61e61c4 100644 --- a/crates/tinysecurity-module/src/adapter_sandbox.rs +++ b/crates/tinysecurity-module/src/adapter_sandbox.rs @@ -216,7 +216,11 @@ impl PolicyService { .has_scope(scope_id) .and_then(|known| Ok(known && !engine.is_runtime_scope(scope_id)?)) .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))?; - Ok(initialized.then(|| self.audit.clone()).flatten()) + Ok(if initialized { + self.audit.clone() + } else { + None + }) } async fn audit_sandbox( &self, diff --git a/docs/plans/runtime-sandbox-scopes.md b/docs/plans/runtime-sandbox-scopes.md new file mode 100644 index 0000000..3b9159f --- /dev/null +++ b/docs/plans/runtime-sandbox-scopes.md @@ -0,0 +1,21 @@ +# Runtime sandbox scopes implementation sequence + +- Status: implemented +- Specification: [runtime sandbox scopes](../specs/runtime-sandbox-scopes.md) + +Each task adds the failing test first, then the smallest implementation, then runs +the owning crate suite. Final verification is the root list in +[`security-module.md`](security-module.md#delivery-and-validation-rules). + +1. **Contract** (`crates/tinysecurity-bus/src/sandbox_scopes.rs`, `names.rs`, `lib.rs`): + the two members, request and receipt types with redacting `Debug`, contract 1.19. +2. **Engine** (`crates/tinysecurity-sandbox/src/engine.rs`, `registry.rs`, `error`): a + runtime-mutable scope table, `register_scope`, `retire_scope`, incarnations and + `stage_for`, the two artifact pools and the per-scope quota, `purge_scope`. +3. **Module** (`crates/tinysecurity-module/src/adapter_sandbox_scopes.rs`, + `adapter_sandbox.rs`, `stream_pins.rs`, `approval_callbacks.rs`): serve the members, + build per-scope audit clients, share the stream pins with approvals, resolve the audit + client once per plan. +4. **Docs**: `MODULE.md`, README, `security-module.md`, this specification and plan. + +Completion checklist: all tasks landed; CI green on Linux, macOS and Windows. diff --git a/docs/specs/runtime-sandbox-scopes.md b/docs/specs/runtime-sandbox-scopes.md new file mode 100644 index 0000000..b80c55f --- /dev/null +++ b/docs/specs/runtime-sandbox-scopes.md @@ -0,0 +1,79 @@ +# Runtime sandbox scopes + +Status: implemented. Owner: TinySecurity. Contract vocabulary: 1.19 (extends 1.18). +Tracker: [OpenHuman #7328](https://github.com/tinyhumansai/openhuman/issues/7328), phase 5. + +The operator-facing description lives in [`MODULE.md`](../../MODULE.md#runtime-sandbox-scopes-contract-119). +This specification records the behavior and the reasons for it. It amends the +registry sizing in [`security-module.md`](security-module.md#sandbox-planning), which +describes the initialized scopes only. + +## Problem + +The host cannot call `Plan2` for the subjects it serves because the 1.18 engine: + +1. fixes at most 128 sandbox scopes at init, while the host serves an unbounded set + of subjects (profiles, tenants, derived agents); +2. requires every scope's subject to equal the one audit scope's subject, while the + host's audit initialization names a single bootstrap subject; +3. offers no way to release a scope, so a long-running host could only grow it. + +Approval scopes solved the same problem with runtime registration (contract 1.17). + +## Goals and non-goals + +Goals: register and retire a subject's sandbox scope after init, bounded, with each +subject planned and audited on its own host-persisted stream, without ever widening a +policy by evicting, replacing or reviving one. + +Non-goals: the module does not run a registry of executors (the host still declares +verified facts), does not replace a live scope in place (a changed fact is a +retirement and a new registration), and does not mint audit correlations. + +## Proposed behavior + +- `RegisterSandboxScope2` and `RetireSandboxScope2` are private host-only members + served to the authenticated init peer. +- A registration is validated exactly like an init scope. An identical repeat on the + same stream is `unchanged`; the same identity with other facts or another stream, or + an initialized identity, is refused. +- A runtime scope carries a host-persisted 32-hex `audit_stream_id` and forwards its + plan and consume events to the host `AuditSink2` under its own subject; it never opens + a native journal. Registration needs a configured sandbox engine and audit. +- A scope's `approval_scope_id` must name a live approval scope of the same subject. +- Retirement removes a runtime scope and discards every staged and active artifact it + issued. Initialized scopes are never retired. +- The receipt reports the identifier-hashed subject exactly as the sink receives it + (`scope_handle`), only when the registration asks, never on a retirement. + +## Invariants and constraints + +- At most 65,536 live scopes. A full table refuses a registration; a live scope is never + evicted to make room, so ceilings are never silently dropped or replaced. +- Artifact capacity is two pools: initialized scopes share 1,024 rows and keep their equal + per-scope quota; runtime scopes share 15,360 with at most 8 per scope. Neither pool can + starve the other, and a runtime subject cannot starve another. +- Retirement purges artifacts under the same table lock `stage` holds, so no artifact of a + retired scope can be staged afterwards, and every registration is its own incarnation: a + candidate planned for a retired scope is refused even if the same identity, subject and + facts register again (`Engine::stage_for`). +- A plan in flight keeps the audit client it started on when its scope is retired. A + retired or unknown runtime identity never falls back to the base stream; only identities + the engine knows as initialized use it. +- A live subject keeps its audit stream and a retired one keeps it for an hour; a + different stream for a re-registered subject is refused in that window. Several scope + identities may share a subject; its stream is released when the last of them retires. +- Registration and retirement are serialized; a failed step is undone. +- The contract is additive: a 1.18 host keeps working against a 1.19 module, and a 1.19 + host refuses a 1.18 module. + +## Acceptance criteria + +- Engine, module-over-bus tests cover registration, conflicts, capacity, pools, retire, + incarnation, in-flight retirement, stream pins and the approval link. +- Per-file coverage stays at or above 90 percent and clippy 1.99 is clean. + +## Open questions + +None blocking. Host network still needs an exact human approval per operation, which the +host has no surface for yet; mediated egress is the intended replacement. diff --git a/docs/specs/security-module.md b/docs/specs/security-module.md index 4c2e42e..2c2b89f 100644 --- a/docs/specs/security-module.md +++ b/docs/specs/security-module.md @@ -269,8 +269,10 @@ Retargeting a symlink requires a fresh human decision for the new resolved plan. Automatic judge approval, generic reusable grants and caller approval flags do not qualify. Exact sanitized Sandbox audit commits before an opaque artifact becomes active. Pending artifacts are discarded on every failed or cancelled publication path. -The 1024-row registry reserves an equal quota for each configured scope; consuming -an artifact removes its row, and missing identities always deny replay. +The initialized scopes share a 1024-row pool with an equal quota each; scopes +registered at runtime ([runtime sandbox scopes](runtime-sandbox-scopes.md)) hold at +most 8 rows each from a separate pool, so neither starves the other. Consuming an +artifact removes its row, and missing identities always deny replay. A fingerprint is correlation only. Consume validates the full registered artifact and owner, audits the exact phase, then rechecks expiry and generation before atomically reserving it once. Registry capacity and pending expiry are From 3e9d186d0e805a35c1269207ffd1f86581f11df9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 17:48:46 +0300 Subject: [PATCH 6/7] fix(sandbox): runtime artifact quota belongs to the subject, not each scope identity Co-authored-by: Medulla --- MODULE.md | 2 +- crates/tinysecurity-sandbox/src/registry.rs | 14 +++++++-- .../src/runtime_scopes_tests.rs | 29 +++++++++++++++++++ docs/specs/runtime-sandbox-scopes.md | 2 +- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/MODULE.md b/MODULE.md index 22b84a1..936f08c 100644 --- a/MODULE.md +++ b/MODULE.md @@ -460,7 +460,7 @@ Bounds and eviction never widen policy. The table holds at most 65,536 live scop full table refuses a new registration (`capacity`) and never evicts a live scope, so a subject's ceilings are never silently dropped or replaced. Retirement is the only way a scope leaves, and the registry slot, artifacts and audit client go with it. A runtime -scope holds at most 8 staged or active artifacts, runtime scopes share a pool of 15,360 and +subject holds at most 8 staged or active artifacts across all of its scope identities, runtime scopes share a pool of 15,360 and initialized scopes keep their own 1024, so one subject cannot exhaust another's capacity and busy runtime scopes cannot starve the initialized ones. A plan in flight keeps the audit stream it started on if its scope is retired meanwhile, and a retired or unknown runtime diff --git a/crates/tinysecurity-sandbox/src/registry.rs b/crates/tinysecurity-sandbox/src/registry.rs index ab9cddf..11369d7 100644 --- a/crates/tinysecurity-sandbox/src/registry.rs +++ b/crates/tinysecurity-sandbox/src/registry.rs @@ -27,7 +27,7 @@ const MAX_INITIALIZED_ISSUED: usize = 1024; /// Most staged or active artifacts across every runtime scope. A pool of its own, so /// busy runtime scopes can never starve the initialized ones (and the reverse). pub(crate) const MAX_RUNTIME_ISSUED: usize = 15_360; -/// Artifacts one runtime scope may hold, the share of the busiest initialized layout. +/// Artifacts one runtime subject may hold, the share of the busiest initialized layout. const RUNTIME_SCOPE_LIMIT: usize = 8; impl State { fn advance(&mut self, now_ms: u64) -> Result<()> { @@ -81,8 +81,16 @@ impl Registry { issued.retain(|row| row.artifact.expires_at_ms > now_ms); let (mut pool, mut own) = (0usize, 0usize); for row in issued.iter() { - pool += usize::from(runtime.contains(&row.artifact.scope_id) == runtime_scope); - own += usize::from(row.artifact.scope_id == artifact.scope_id); + let row_runtime = runtime.contains(&row.artifact.scope_id); + pool += usize::from(row_runtime == runtime_scope); + // An initialized scope has its own quota. A runtime quota belongs to the + // subject, however many scope identities it registered, so splitting one + // subject across identities never multiplies its share. + own += usize::from(if runtime_scope { + row_runtime && row.artifact.subject == artifact.subject + } else { + row.artifact.scope_id == artifact.scope_id + }); } if pool >= pool_limit || own >= per_scope_limit { return Err(Error::InvalidOperation); diff --git a/crates/tinysecurity-sandbox/src/runtime_scopes_tests.rs b/crates/tinysecurity-sandbox/src/runtime_scopes_tests.rs index 4030d3c..3adc6fb 100644 --- a/crates/tinysecurity-sandbox/src/runtime_scopes_tests.rs +++ b/crates/tinysecurity-sandbox/src/runtime_scopes_tests.rs @@ -280,3 +280,32 @@ fn a_candidate_is_bound_to_the_scope_incarnation_it_was_planned_under() -> TestR assert_eq!(engine.incarnation("missing")?, None); Ok(()) } + +#[test] +fn a_subject_split_across_scope_identities_keeps_one_runtime_quota() -> TestResult { + let (_dir, init, base) = fixture()?; + let engine = engine_with(vec![init.clone()])?; + let first = runtime_variant(&init, "first"); + let mut second = first.clone(); + second.scope_id = "second".into(); + engine.register_scope(first.clone())?; + engine.register_scope(second.clone())?; + let candidate = |scope: &SandboxScope2| -> std::result::Result> { + Ok(engine + .plan(&request_for(&base, scope), 1, 100)? + .artifact + .ok_or("plan denied")?) + }; + for _ in 0..4 { + engine.stage(candidate(&first)?, 100)?; + engine.stage(candidate(&second)?, 100)?; + } + // Eight rows across two identities of one subject: neither identity gets more. + assert!(engine.stage(candidate(&first)?, 100).is_err()); + assert!(engine.stage(candidate(&second)?, 100).is_err()); + // Another subject still has its own share. + let other = runtime_variant(&init, "other"); + engine.register_scope(other.clone())?; + assert!(engine.stage(candidate(&other)?, 100).is_ok()); + Ok(()) +} diff --git a/docs/specs/runtime-sandbox-scopes.md b/docs/specs/runtime-sandbox-scopes.md index b80c55f..0a98c4c 100644 --- a/docs/specs/runtime-sandbox-scopes.md +++ b/docs/specs/runtime-sandbox-scopes.md @@ -51,7 +51,7 @@ retirement and a new registration), and does not mint audit correlations. - At most 65,536 live scopes. A full table refuses a registration; a live scope is never evicted to make room, so ceilings are never silently dropped or replaced. - Artifact capacity is two pools: initialized scopes share 1,024 rows and keep their equal - per-scope quota; runtime scopes share 15,360 with at most 8 per scope. Neither pool can + per-scope quota; runtime scopes share 15,360 with at most 8 per subject (counted across every scope identity the subject registered). Neither pool can starve the other, and a runtime subject cannot starve another. - Retirement purges artifacts under the same table lock `stage` holds, so no artifact of a retired scope can be staged afterwards, and every registration is its own incarnation: a From 560d584a64a3b816076d17f32d8923220a4de332 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 18:47:45 +0300 Subject: [PATCH 7/7] docs(sandbox): describe the runtime artifact quota per subject Co-authored-by: Medulla --- docs/specs/security-module.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/security-module.md b/docs/specs/security-module.md index 2c2b89f..4011d0a 100644 --- a/docs/specs/security-module.md +++ b/docs/specs/security-module.md @@ -271,8 +271,8 @@ Exact sanitized Sandbox audit commits before an opaque artifact becomes active. Pending artifacts are discarded on every failed or cancelled publication path. The initialized scopes share a 1024-row pool with an equal quota each; scopes registered at runtime ([runtime sandbox scopes](runtime-sandbox-scopes.md)) hold at -most 8 rows each from a separate pool, so neither starves the other. Consuming an -artifact removes its row, and missing identities always deny replay. +most 8 rows per subject (across all of its scope identities) from a separate pool, +so neither starves the other. Consuming an artifact removes its row, and missing identities always deny replay. A fingerprint is correlation only. Consume validates the full registered artifact and owner, audits the exact phase, then rechecks expiry and generation before atomically reserving it once. Registry capacity and pending expiry are