From 0867aa0da1e1a7bcd30f3af70aa55a487bb636aa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:05:16 +0000 Subject: [PATCH 01/82] feat(native_handles): add register_scope for live registries Adds a register_scope method that appends one immutable trusted subject scope to an already-running registry without replacing it or altering existing subjects. Exact repeats are idempotent, conflicting reuse of an identity is refused, and the shared registration validation is factored into a helper so construction and registration enforce the same rules. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-crypto/src/native_handles.rs | 64 +++++++++++++++---- .../src/native_handles_tests.rs | 61 ++++++++++++++++++ 2 files changed, 113 insertions(+), 12 deletions(-) diff --git a/crates/tinysecurity-crypto/src/native_handles.rs b/crates/tinysecurity-crypto/src/native_handles.rs index 2ddefc81..88062a61 100644 --- a/crates/tinysecurity-crypto/src/native_handles.rs +++ b/crates/tinysecurity-crypto/src/native_handles.rs @@ -45,6 +45,7 @@ pub enum OperationKind { AppStore, } /// Immutable trusted owner registration. No field grants live authority. +#[derive(Clone, PartialEq, Eq)] pub struct Registration { /// Trusted native registration identity. pub id: String, @@ -539,18 +540,7 @@ impl Registry { let now = clock.now().map_err(|_| Error::NativeClock)?; let mut scopes = BTreeMap::new(); for registration in registrations { - let subjects = [®istration.subject, ®istration.durable_subject]; - if !text(®istration.id) - || !text(®istration.policy_ref) - || registration.purposes.is_empty() - || registration.purposes.len() > 3 - || registration.purposes.iter().collect::>().len() - != registration.purposes.len() - || subjects - .iter() - .any(|s| [&s.user, &s.workspace, &s.agent].iter().any(|v| !text(v))) - || scopes.contains_key(®istration.id) - { + if !valid_registration(®istration) || scopes.contains_key(®istration.id) { return Err(Error::NativeDenied); } scopes.insert( @@ -581,6 +571,41 @@ impl Registry { physical_gate: Arc::new(Mutex::new(())), }) } + /// Add one immutable trusted subject scope without replacing the live + /// registry or changing the authority of existing subjects. + /// + /// An exact repeat is idempotent. Reusing an identity with different + /// subject, policy, or purpose data is refused. The host must establish the + /// corresponding approval scope before registering this native authority. + /// + /// # Errors + /// Rejects invalid or conflicting registrations, closed state, clock + /// failure, and capacity beyond the fixed 128-scope ceiling. + pub fn register_scope(&self, registration: Registration) -> Result<()> { + let (mut state, now) = self.checked_lock()?; + if !valid_registration(®istration) { + return Err(Error::NativeDenied); + } + if let Some(existing) = state.scopes.get(®istration.id) { + return if existing.registration == registration { + Ok(()) + } else { + Err(Error::NativeDenied) + }; + } + if state.scopes.len() >= 128 { + return Err(Error::NativeCapacity); + } + state.scopes.insert( + registration.id.clone(), + Scope { + registration, + epoch: 1, + rate: RateCounter::new(now.monotonic), + }, + ); + Ok(()) + } fn lock(&self) -> Result> { let gate = self.physical_gate.lock().map_err(|_| Error::NativeDenied)?; let state = self.state.lock().map_err(|_| Error::NativeDenied)?; @@ -1118,6 +1143,21 @@ fn handle_identity(key: &[u8], bytes: &[u8]) -> [u8; 32] { fn text(value: &str) -> bool { !value.is_empty() && value.len() <= 256 && !value.chars().any(char::is_control) } +fn valid_registration(registration: &Registration) -> bool { + text(®istration.id) + && text(®istration.policy_ref) + && !registration.purposes.is_empty() + && registration.purposes.len() <= 3 + && registration.purposes.iter().collect::>().len() + == registration.purposes.len() + && [®istration.subject, ®istration.durable_subject] + .iter() + .all(|subject| { + [&subject.user, &subject.workspace, &subject.agent] + .iter() + .all(|v| text(v)) + }) +} fn opaque(value: &str, length: usize) -> bool { value.len() == length && value.bytes().all(|b| b.is_ascii_hexdigit()) } diff --git a/crates/tinysecurity-crypto/src/native_handles_tests.rs b/crates/tinysecurity-crypto/src/native_handles_tests.rs index 97150211..70b1c012 100644 --- a/crates/tinysecurity-crypto/src/native_handles_tests.rs +++ b/crates/tinysecurity-crypto/src/native_handles_tests.rs @@ -46,6 +46,67 @@ fn registry(limits: Limits) -> Result<(Registry, Arc)> { clock, )) } + +#[test] +fn a_live_registry_accepts_an_immutable_additional_subject_scope() -> TestResult { + let clock = Arc::new(Clock(AtomicU64::new(0))); + let registry = Registry::new( + vec![registration("first")], + ":1.1", + 1, + Limits::default(), + clock, + )?; + let mut second = registration("second"); + second.subject.user = "another-user".into(); + second.durable_subject.user = "another-durable-user".into(); + + registry.register_scope(second.clone())?; + registry.register_scope(second)?; + + let first = registry.reserve(Request { + registration: "first", + authenticated_peer: ":1.1", + purpose: Purpose::ConfigUserMaster, + kind: OperationKind::RetainPlaintext, + call_id: "first-call", + input: Zeroizing::new(b"first".to_vec()), + ttl_ms: 100, + generation: 1, + })?; + let second = registry.reserve(Request { + registration: "second", + authenticated_peer: ":1.1", + purpose: Purpose::ConfigUserMaster, + kind: OperationKind::RetainPlaintext, + call_id: "second-call", + input: Zeroizing::new(b"second".to_vec()), + ttl_ms: 100, + generation: 1, + })?; + assert_eq!(first.binding().subject().user, "user"); + assert_eq!(second.binding().subject().user, "another-user"); + + let mut conflicting = registration("second"); + conflicting.subject.user = "attacker-user".into(); + assert!(registry.register_scope(conflicting).is_err()); + assert!( + registry + .reserve(Request { + registration: "unknown", + authenticated_peer: ":1.1", + purpose: Purpose::ConfigUserMaster, + kind: OperationKind::RetainPlaintext, + call_id: "unknown-call", + input: Zeroizing::new(b"unknown".to_vec()), + ttl_ms: 100, + generation: 1, + }) + .is_err() + ); + Ok(()) +} + fn reserve( reg: &Registry, purpose: Purpose, From 1d91ba63dda4fe39bd07674dc299b176b187f371 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:10:16 +0000 Subject: [PATCH 02/82] test(crypto): cover scope registration generation checks Update the native handle tests to pass a generation argument to register_scope and add a case asserting that a stale generation is rejected. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-crypto/src/native_handles_tests.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-crypto/src/native_handles_tests.rs b/crates/tinysecurity-crypto/src/native_handles_tests.rs index 70b1c012..f16d2722 100644 --- a/crates/tinysecurity-crypto/src/native_handles_tests.rs +++ b/crates/tinysecurity-crypto/src/native_handles_tests.rs @@ -61,8 +61,8 @@ fn a_live_registry_accepts_an_immutable_additional_subject_scope() -> TestResult second.subject.user = "another-user".into(); second.durable_subject.user = "another-durable-user".into(); - registry.register_scope(second.clone())?; - registry.register_scope(second)?; + registry.register_scope(second.clone(), 1)?; + registry.register_scope(second, 1)?; let first = registry.reserve(Request { registration: "first", @@ -89,7 +89,8 @@ fn a_live_registry_accepts_an_immutable_additional_subject_scope() -> TestResult let mut conflicting = registration("second"); conflicting.subject.user = "attacker-user".into(); - assert!(registry.register_scope(conflicting).is_err()); + assert!(registry.register_scope(conflicting, 1).is_err()); + assert!(registry.register_scope(registration("stale"), 2).is_err()); assert!( registry .reserve(Request { From 59ab3d1cbce5d17d7f3547b811b5432e0276011c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:10:26 +0000 Subject: [PATCH 03/82] fix(tinysecurity-crypto): reject stale scope registrations register_scope now takes an expected generation and denies the registration when it does not match the registry's current generation, closing a race where a caller could register against state it had already observed as changed. The doc comment was updated to mention the new rejection case. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-crypto/src/native_handles.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-crypto/src/native_handles.rs b/crates/tinysecurity-crypto/src/native_handles.rs index 88062a61..c6d07059 100644 --- a/crates/tinysecurity-crypto/src/native_handles.rs +++ b/crates/tinysecurity-crypto/src/native_handles.rs @@ -579,11 +579,12 @@ impl Registry { /// corresponding approval scope before registering this native authority. /// /// # Errors - /// Rejects invalid or conflicting registrations, closed state, clock - /// failure, and capacity beyond the fixed 128-scope ceiling. - pub fn register_scope(&self, registration: Registration) -> Result<()> { + /// Rejects invalid or conflicting registrations, a stale expected + /// generation, closed state, clock failure, and capacity beyond the fixed + /// 128-scope ceiling. + pub fn register_scope(&self, registration: Registration, generation: u64) -> Result<()> { let (mut state, now) = self.checked_lock()?; - if !valid_registration(®istration) { + if state.generation != generation || !valid_registration(®istration) { return Err(Error::NativeDenied); } if let Some(existing) = state.scopes.get(®istration.id) { From 7994e67279faca37027411e24906fbd2fadfa4a5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:11:29 +0000 Subject: [PATCH 04/82] feat(bus): add runtime secret scope registration method Add a RegisterSecretScope2 method and the request and receipt types for registering a secret resolver against an already-live approval scope, and bump the contract version to 1.29. The request carries only the immutable registration so callers cannot forge a subject, which the new test covers. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/lib.rs | 2 +- crates/tinysecurity-bus/src/names.rs | 3 +++ crates/tinysecurity-bus/src/secret.rs | 16 ++++++++++++ crates/tinysecurity-bus/src/secret_tests.rs | 29 +++++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 0c13ca7f..633dab84 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -65,7 +65,7 @@ pub use scoped_policy::*; pub use tool_trust::*; pub mod names; /// Current wire vocabulary version, independent of the artifact version. -pub const CONTRACT_VERSION: (u32, u32) = (1, 28); +pub const CONTRACT_VERSION: (u32, u32) = (1, 29); /// 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 96b7c0db..6db7220c 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -67,6 +67,8 @@ pub mod methods { pub const REPLACE_APPROVAL_SCOPE2: &str = "ReplaceApprovalScope2"; /// Retire a scope so later calls under it deny (contract 1.17). pub const RETIRE_APPROVAL_SCOPE2: &str = "RetireApprovalScope2"; + /// Register a secret resolver for an already-live approval scope (contract 1.29). + pub const REGISTER_SECRET_SCOPE2: &str = "RegisterSecretScope2"; /// Evaluate member. pub const EVALUATE: &str = "Evaluate"; /// Check member. @@ -251,6 +253,7 @@ pub const METHODS: &[&str] = &[ methods::REGISTER_APPROVAL_SCOPE2, methods::REPLACE_APPROVAL_SCOPE2, methods::RETIRE_APPROVAL_SCOPE2, + methods::REGISTER_SECRET_SCOPE2, methods::REGISTER_SANDBOX_SCOPE2, methods::RETIRE_SANDBOX_SCOPE2, methods::IMPORT_APPROVAL_HISTORY2, diff --git a/crates/tinysecurity-bus/src/secret.rs b/crates/tinysecurity-bus/src/secret.rs index 48ed17b9..85320b0a 100644 --- a/crates/tinysecurity-bus/src/secret.rs +++ b/crates/tinysecurity-bus/src/secret.rs @@ -49,6 +49,22 @@ pub struct SecretRegistration2 { /// At most three compatible read-only historical entries. pub legacy: Vec, } +/// Host-only runtime registration for a secret scope whose approval scope is live. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RegisterSecretScopeRequest2 { + /// Immutable secret registration; the module resolves the approval scope itself. + pub registration: SecretRegistration2, +} +/// Safe confirmation of one active secret registration. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SecretScopeReceipt2 { + /// Opaque registration identity supplied by the trusted host. + pub registration_id: String, + /// Whether the registration is currently active. + pub active: bool, +} /// Bounded retention and reservation ceilings, matching the internal registry. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/crates/tinysecurity-bus/src/secret_tests.rs b/crates/tinysecurity-bus/src/secret_tests.rs index 666f8948..87e688af 100644 --- a/crates/tinysecurity-bus/src/secret_tests.rs +++ b/crates/tinysecurity-bus/src/secret_tests.rs @@ -41,3 +41,32 @@ fn caller_authority_overrides_and_plaintext_response_fields_are_rejected() ); Ok(()) } + +#[test] +fn runtime_secret_scope_registration_only_accepts_the_trusted_descriptor() +-> Result<(), Box> { + let request = RegisterSecretScopeRequest2 { + registration: SecretRegistration2 { + registration_id: "a".repeat(32), + approval_scope_id: "b".repeat(32), + origin: Origin::Operator, + purpose: MasterPurpose2::ConfigUserMaster, + target: SecretBinding2 { + backend_id: "c".repeat(32), + slot: MasterSlotKind2::User, + }, + legacy: vec![], + }, + }; + let value = serde_json::to_value(&request)?; + assert_eq!(serde_json::from_value::(value.clone())?, request); + let mut forged = value; + forged["subject"] = serde_json::json!({ "user": "caller" }); + assert!(serde_json::from_value::(forged).is_err()); + let receipt = SecretScopeReceipt2 { + registration_id: "a".repeat(32), + active: true, + }; + assert_eq!(serde_json::from_value::(serde_json::to_value(receipt.clone())?)?, receipt); + Ok(()) +} From 4cf86e8034402bb25acc0efa99d8c18bd4ca64b4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:12:31 +0000 Subject: [PATCH 05/82] feat(approval): add scope_snapshot accessor for live registrations Expose the current trusted scope snapshot for a single live registration so sibling module adapters can bind a secondary capability to the approval engine's canonical subject and generation. Unknown registrations or poisoned approval state are rejected. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 82f2a51e..15524a9b 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -1172,6 +1172,14 @@ impl Engine { let entry = self.entry(id)?; Ok(self.registered(&entry)) } + /// Return the current trusted scope snapshot for one live registration. + /// This is used by sibling module adapters that must bind a secondary + /// capability to the approval engine's canonical subject and generation. + /// # Errors + /// Rejects unknown registrations or poisoned approval state. + pub fn scope_snapshot(&self, id: &str) -> Result { + self.registration(id) + } fn validate_scope(scope: &ApprovalScope2) -> Result<()> { if scope.scope_id.len() != 32 || !scope.scope_id.bytes().all(|b| b.is_ascii_hexdigit()) From bef515360be084ea02621067cfb44421cb568587 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:12:57 +0000 Subject: [PATCH 06/82] feat(module): support dynamic secret registrations Secret registrations can now be added at runtime through a dynamic map alongside the immutable init snapshot, with lookups returning shared handles so callers no longer borrow from the static map. The native registry scope ceiling was raised from 128 to a named constant of 65536 to accommodate the larger registration volume. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-crypto/src/native_handles.rs | 9 ++-- .../tinysecurity-module/src/adapter_secret.rs | 41 ++++++++++++++----- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/crates/tinysecurity-crypto/src/native_handles.rs b/crates/tinysecurity-crypto/src/native_handles.rs index c6d07059..82bbf11b 100644 --- a/crates/tinysecurity-crypto/src/native_handles.rs +++ b/crates/tinysecurity-crypto/src/native_handles.rs @@ -20,6 +20,9 @@ use tinysecurity_bus::{ }; use zeroize::{Zeroize, Zeroizing}; +/// Maximum lifetime scope identities retained by one native secret registry. +pub const MAX_NATIVE_SCOPES: usize = 65_536; + /// Native purposes are distinct from device static and ephemeral keys. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum Purpose { @@ -522,7 +525,7 @@ impl Registry { clock: Arc, ) -> Result { if registrations.is_empty() - || registrations.len() > 128 + || registrations.len() > MAX_NATIVE_SCOPES || !peer.starts_with(':') || !text(peer) || generation == 0 @@ -581,7 +584,7 @@ impl Registry { /// # Errors /// Rejects invalid or conflicting registrations, a stale expected /// generation, closed state, clock failure, and capacity beyond the fixed - /// 128-scope ceiling. + /// fixed scope ceiling. pub fn register_scope(&self, registration: Registration, generation: u64) -> Result<()> { let (mut state, now) = self.checked_lock()?; if state.generation != generation || !valid_registration(®istration) { @@ -594,7 +597,7 @@ impl Registry { Err(Error::NativeDenied) }; } - if state.scopes.len() >= 128 { + if state.scopes.len() >= MAX_NATIVE_SCOPES { return Err(Error::NativeCapacity); } state.scopes.insert( diff --git a/crates/tinysecurity-module/src/adapter_secret.rs b/crates/tinysecurity-module/src/adapter_secret.rs index 1d43cc1f..21f5ab78 100644 --- a/crates/tinysecurity-module/src/adapter_secret.rs +++ b/crates/tinysecurity-module/src/adapter_secret.rs @@ -77,7 +77,9 @@ pub(super) struct SecretState { config: SecretConfig2, peer: String, registry: handles::Registry, - registered: BTreeMap, + registered: BTreeMap>, + dynamic: Mutex>>, + connection: tinybus::Connection, pending: Mutex>, aliases: Mutex>, clock: Arc, @@ -227,12 +229,12 @@ impl SecretState { .map_err(|_| denied())?; registered.insert( descriptor.registration_id.clone(), - Registered { + Arc::new(Registered { descriptor: descriptor.clone(), scope: scope.clone(), broker, resolver, - }, + }), ); } let clock: Arc = Arc::new(Clock(clock)); @@ -250,6 +252,8 @@ impl SecretState { peer: peer.into(), registry, registered, + dynamic: Mutex::new(BTreeMap::new()), + connection: connection.clone(), pending: Mutex::new(BTreeMap::new()), aliases: Mutex::new(BTreeMap::new()), clock, @@ -258,12 +262,15 @@ impl SecretState { pub(super) fn cipher_registry(&self) -> handles::Registry { self.registry.clone() } - pub(super) fn cipher_scope(&self, registration: &str) -> tinybus::Result<&ApprovalScope2> { - let reg = self.registered.get(registration).ok_or_else(denied)?; + pub(super) fn cipher_scope( + &self, + registration: &str, + ) -> tinybus::Result { + let reg = self.registration_id(registration)?; if reg.descriptor.purpose != MasterPurpose2::ConfigUserMaster { return Err(denied()); } - Ok(®.scope) + Ok(reg.scope.clone()) } pub(super) fn prepare_cipher( &self, @@ -364,11 +371,20 @@ impl SecretState { ) .map_err(|_| denied()) } - fn registration(&self, request: &SecretRequest2) -> tinybus::Result<&Registered> { - let reg = self - .registered - .get(&request.registration_id) - .ok_or_else(denied)?; + fn registration_id(&self, id: &str) -> tinybus::Result> { + if let Some(reg) = self.registered.get(id) { + // Static registrations remain owned by the immutable init snapshot. + return Ok(reg.clone()); + } + self.dynamic + .lock() + .map_err(|_| denied())? + .get(id) + .cloned() + .ok_or_else(denied) + } + fn registration(&self, request: &SecretRequest2) -> tinybus::Result> { + let reg = self.registration_id(&request.registration_id)?; if request.call_id.is_empty() || request.call_id.len() > 128 || request.call_id.chars().any(char::is_control) @@ -424,6 +440,9 @@ impl SecretState { if let Ok(mut aliases) = self.aliases.lock() { aliases.clear(); } + if let Ok(mut dynamic) = self.dynamic.lock() { + dynamic.clear(); + } super::cipher::close_pending(); } } From b81f2ca14c725c97666e4b67a1112b686c843a76 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:13:26 +0000 Subject: [PATCH 07/82] feat(secret): add dynamic secret scope registration and retirement Add register_scope to admit a new registration against a live approval scope snapshot, reusing an existing registration when the descriptor matches and denying conflicting ones. Add retire_approval_scope to revoke every registration bound to a scope and drop its pending and alias entries. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-module/src/adapter_secret.rs | 121 +++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/src/adapter_secret.rs b/crates/tinysecurity-module/src/adapter_secret.rs index 21f5ab78..f589d552 100644 --- a/crates/tinysecurity-module/src/adapter_secret.rs +++ b/crates/tinysecurity-module/src/adapter_secret.rs @@ -15,7 +15,7 @@ use tinysecurity_bus::{ AuditCorrelation2, CallerContext, Check, EvaluateApprovalRequest2, EvaluateRequest, EvaluateSecretRequest2, ExecuteSecretRequest2, MasterPurpose2, MasterSlotKind2, Origin, SecretConfig2, SecretOperation2, SecretRegistration2, SecretRequest2, SecretResponse2, - ToolCall, + RegisterSecretScopeRequest2, SecretScopeReceipt2, ToolCall, }; use zeroize::{Zeroize, Zeroizing}; static SECRET: OnceLock>> = OnceLock::new(); @@ -405,6 +405,125 @@ impl SecretState { } Ok(reg) } + pub(super) fn register_scope( + &self, + request: RegisterSecretScopeRequest2, + engine: &tinysecurity::approval::Engine, + ) -> tinybus::Result { + let descriptor = request.registration; + let scope = engine + .scope_snapshot(&descriptor.approval_scope_id) + .map_err(approval_failure)?; + let registration_id = descriptor.registration_id.clone(); + if let Ok(existing) = self.registration_id(®istration_id) { + return if existing.descriptor == descriptor { + Ok(SecretScopeReceipt2 { + registration_id, + active: true, + }) + } else { + Err(denied()) + }; + } + + let mut dynamic = self.dynamic.lock().map_err(|_| denied())?; + let mut inventory = Inventory::default(); + for existing in self + .registered + .values() + .chain(dynamic.values().map(Arc::as_ref)) + { + let mut live_scope = existing.scope.clone(); + live_scope.generation = scope.generation; + inventory.admit(&existing.descriptor, &live_scope, scope.generation)?; + } + inventory.admit(&descriptor, &scope, scope.generation)?; + + let native = handles::Registration { + id: registration_id.clone(), + subject: scope.subject.clone(), + durable_subject: engine + .durable_scope(&scope.scope_id) + .map_err(approval_failure)?, + policy_ref: engine + .durable_policy_ref(&scope.scope_id) + .map_err(approval_failure)?, + purposes: vec![purpose(descriptor.purpose)], + }; + let broker = Arc::new(StoreBroker::new( + self.connection.clone(), + &self.peer, + self.config.callback_timeout_ms, + )?); + let (target_slot, target_name) = slot(descriptor.target.slot, &scope.subject.user); + let target = Arc::new(CallbackStore::new( + broker.clone(), + descriptor.target.clone(), + target_name, + false, + )); + let legacy = descriptor + .legacy + .iter() + .map(|binding| { + let (source_slot, name) = slot(binding.slot, &scope.subject.user); + LegacySource::at_slot( + Arc::new(CallbackStore::new( + broker.clone(), + binding.clone(), + name, + true, + )), + source_slot, + ) + }) + .collect(); + let resolver = MasterResolver::new_with_sources( + target, + legacy, + &target_slot, + Arc::new(GuardedMint(broker.clone())), + ) + .map_err(|_| denied())?; + + self.registry + .register_scope(native, scope.generation) + .map_err(|_| denied())?; + dynamic.insert( + registration_id.clone(), + Arc::new(Registered { + descriptor, + scope, + broker, + resolver, + }), + ); + Ok(SecretScopeReceipt2 { + registration_id, + active: true, + }) + } + pub(super) fn retire_approval_scope(&self, scope_id: &str) -> tinybus::Result<()> { + let mut dynamic = self.dynamic.lock().map_err(|_| denied())?; + let ids = dynamic + .iter() + .filter(|(_, registration)| registration.descriptor.approval_scope_id == scope_id) + .map(|(id, _)| id.clone()) + .collect::>(); + for id in &ids { + self.registry.revoke_scope(id).map_err(|_| denied())?; + } + dynamic.retain(|id, _| !ids.contains(id)); + self.pending + .lock() + .map_err(|_| denied())? + .retain(|(id, _), _| !ids.contains(id)); + self.aliases + .lock() + .map_err(|_| denied())? + .retain(|_, alias| !ids.contains(&alias.registration)); + Ok(()) + } fn prune(&self) -> tinybus::Result<()> { let now = self.clock.now().map_err(|_| { self.close(); From 6694582628a0ac041a900bbea1bfbd37478587c6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:14:14 +0000 Subject: [PATCH 08/82] feat(module): register secret scopes and retire them with approval scopes Add a private host-only RegisterSecretScope2 method that resolves the approval scope inside the module so callers cannot supply it, and expose it through the module export list. Replacing or retiring an approval scope now also retires the matching secret scope, keeping the two in sync. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/secret_tests.rs | 10 +++++-- crates/tinysecurity-module/src/adapter.rs | 24 ++++++++++++++++- .../tinysecurity-module/src/adapter_secret.rs | 27 +++++++++++++------ 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/crates/tinysecurity-bus/src/secret_tests.rs b/crates/tinysecurity-bus/src/secret_tests.rs index 87e688af..eb22e695 100644 --- a/crates/tinysecurity-bus/src/secret_tests.rs +++ b/crates/tinysecurity-bus/src/secret_tests.rs @@ -59,7 +59,10 @@ fn runtime_secret_scope_registration_only_accepts_the_trusted_descriptor() }, }; let value = serde_json::to_value(&request)?; - assert_eq!(serde_json::from_value::(value.clone())?, request); + assert_eq!( + serde_json::from_value::(value.clone())?, + request + ); let mut forged = value; forged["subject"] = serde_json::json!({ "user": "caller" }); assert!(serde_json::from_value::(forged).is_err()); @@ -67,6 +70,9 @@ fn runtime_secret_scope_registration_only_accepts_the_trusted_descriptor() registration_id: "a".repeat(32), active: true, }; - assert_eq!(serde_json::from_value::(serde_json::to_value(receipt.clone())?)?, receipt); + assert_eq!( + serde_json::from_value::(serde_json::to_value(receipt.clone())?)?, + receipt + ); Ok(()) } diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 2e7bb214..e2dbe0bc 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1183,6 +1183,22 @@ impl PolicyService { .await .map_err(approval_failure) } + // Private host-only configuration: the approval scope is resolved inside + // the module and cannot be supplied by the caller. + async fn register_secret_scope2( + &self, + request: tinysecurity_bus::RegisterSecretScopeRequest2, + ) -> BusResult { + self.secret + .as_ref() + .ok_or_else(|| tinybus::Error::failed("secret authority unavailable"))? + .register_scope( + request, + self.approvals + .as_ref() + .ok_or_else(|| tinybus::Error::failed("approval unavailable"))?, + ) + } async fn replace_approval_scope2( &self, request: tinysecurity_bus::RegisterApprovalScopeRequest2, @@ -1191,6 +1207,9 @@ impl PolicyService { .approvals .as_ref() .ok_or_else(|| tinybus::Error::failed("approval unavailable"))?; + if let Some(secret) = &self.secret { + secret.retire_approval_scope(&request.scope.scope_id)?; + } engine .replace_scope(request) .await @@ -1200,6 +1219,9 @@ impl PolicyService { &self, request: tinysecurity_bus::RetireApprovalScopeRequest2, ) -> BusResult { + if let Some(secret) = &self.secret { + secret.retire_approval_scope(&request.scope_id)?; + } self.approvals .as_ref() .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? @@ -2118,7 +2140,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", "DeviceFrame2", "DevicePairing2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "GrantTool2", "RevokeTool2", "RevokeToolById2", "ListToolTrust2", "ImportTrustSource2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "CredentialRoots2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "AuthorizeBrowserDial", "ConsumeBrowserDial", "ValidateBrowserDial", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"], + methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "DeviceFrame2", "DevicePairing2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "GrantTool2", "RevokeTool2", "RevokeToolById2", "ListToolTrust2", "ImportTrustSource2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSecretScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "CredentialRoots2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "AuthorizeBrowserDial", "ConsumeBrowserDial", "ValidateBrowserDial", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"], signals = ["PolicyChanged"], requires = [], optional = ["ai.tinyhumans.tinysecurity.KeyringConsent2", "ai.tinyhumans.tinysecurity.SecretCipherConsumer2", "ai.tinyhumans.tinysecurity.SecretBackend2", "ai.tinyhumans.tinysecurity.DevicePairingHost2", "ai.tinyhumans.tinysecurity.AuditSink2", "ai.tinyhumans.tinysecurity.ApprovalStore2", "ai.tinyhumans.tinysecurity.ApprovalPrompt2"], diff --git a/crates/tinysecurity-module/src/adapter_secret.rs b/crates/tinysecurity-module/src/adapter_secret.rs index f589d552..d7efe5ed 100644 --- a/crates/tinysecurity-module/src/adapter_secret.rs +++ b/crates/tinysecurity-module/src/adapter_secret.rs @@ -14,8 +14,8 @@ use tinysecurity_bus::{ ApprovalActionRequest2, ApprovalConfig2, ApprovalRow2, ApprovalScope2, AuditConfig2, AuditCorrelation2, CallerContext, Check, EvaluateApprovalRequest2, EvaluateRequest, EvaluateSecretRequest2, ExecuteSecretRequest2, MasterPurpose2, MasterSlotKind2, Origin, - SecretConfig2, SecretOperation2, SecretRegistration2, SecretRequest2, SecretResponse2, - RegisterSecretScopeRequest2, SecretScopeReceipt2, ToolCall, + RegisterSecretScopeRequest2, SecretConfig2, SecretOperation2, SecretRegistration2, + SecretRequest2, SecretResponse2, SecretScopeReceipt2, ToolCall, }; use zeroize::{Zeroize, Zeroizing}; static SECRET: OnceLock>> = OnceLock::new(); @@ -262,10 +262,7 @@ impl SecretState { pub(super) fn cipher_registry(&self) -> handles::Registry { self.registry.clone() } - pub(super) fn cipher_scope( - &self, - registration: &str, - ) -> tinybus::Result { + pub(super) fn cipher_scope(&self, registration: &str) -> tinybus::Result { let reg = self.registration_id(registration)?; if reg.descriptor.purpose != MasterPurpose2::ConfigUserMaster { return Err(denied()); @@ -505,15 +502,29 @@ impl SecretState { } pub(super) fn retire_approval_scope(&self, scope_id: &str) -> tinybus::Result<()> { let mut dynamic = self.dynamic.lock().map_err(|_| denied())?; - let ids = dynamic + let ids = self + .registered .iter() .filter(|(_, registration)| registration.descriptor.approval_scope_id == scope_id) .map(|(id, _)| id.clone()) + .chain( + dynamic + .iter() + .filter(|(_, registration)| { + registration.descriptor.approval_scope_id == scope_id + }) + .map(|(id, _)| id.clone()), + ) .collect::>(); for id in &ids { self.registry.revoke_scope(id).map_err(|_| denied())?; } - dynamic.retain(|id, _| !ids.contains(id)); + let dynamic_ids = dynamic + .iter() + .filter(|(_, registration)| registration.descriptor.approval_scope_id == scope_id) + .map(|(id, _)| id.clone()) + .collect::>(); + dynamic.retain(|id, _| !dynamic_ids.contains(id)); self.pending .lock() .map_err(|_| denied())? From 1e3896ec63cad2c3283dad433e63fe747514dd4b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:14:34 +0000 Subject: [PATCH 09/82] refactor(adapter_secret): simplify secret state iteration and borrows Iterate dynamic values directly instead of mapping through Arc::as_ref, and pass references to the registry where ownership was previously moved. This removes redundant conversions and avoids unnecessary moves without changing behaviour. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter_secret.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_secret.rs b/crates/tinysecurity-module/src/adapter_secret.rs index d7efe5ed..e214f9d2 100644 --- a/crates/tinysecurity-module/src/adapter_secret.rs +++ b/crates/tinysecurity-module/src/adapter_secret.rs @@ -425,11 +425,7 @@ impl SecretState { let mut dynamic = self.dynamic.lock().map_err(|_| denied())?; let mut inventory = Inventory::default(); - for existing in self - .registered - .values() - .chain(dynamic.values().map(Arc::as_ref)) - { + for existing in self.registered.values().chain(dynamic.values()) { let mut live_scope = existing.scope.clone(); live_scope.generation = scope.generation; inventory.admit(&existing.descriptor, &live_scope, scope.generation)?; @@ -773,7 +769,7 @@ impl PolicyService { generation, }) .map_err(|_| denied())?; - let approval = call(reg, work.binding()); + let approval = call(®, work.binding()); let expires = work.binding().expires_at_ms(); pending.insert( key.clone(), @@ -839,7 +835,7 @@ impl PolicyService { work, &Verifier { engine: self.approvals.as_ref().ok_or_else(denied)?, - reg, + reg: ®, request_id: request.request_id, audit: request.audit, }, From 5c8eb2e5da2bc8e1bcd69e83e6e7595595e54c08 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:15:51 +0000 Subject: [PATCH 10/82] test(adapter): cover secret scope registration and retirement Extend the confidential master resolution fixture with a mode that registers a secret scope, verifies idempotent receipts and conflicting registration rejection, then retires the approval scope and confirms the registration is gone. The mode range is widened so the new path runs alongside the existing cases. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_secret_tests.rs | 82 ++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index f63c07ac..f79943d0 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -670,6 +670,63 @@ impl Control { if self.state.mode == 18 { return self.canceled_evaluation(&proxy).await; } + if self.state.mode == 22 { + let registration = SecretRegistration2 { + registration_id: "h".repeat(32), + approval_scope_id: "f".repeat(32), + origin: Origin::Operator, + purpose: MasterPurpose2::ConfigUserMaster, + target: SecretBinding2 { + backend_id: "i".repeat(32), + slot: MasterSlotKind2::User, + }, + legacy: vec![], + }; + let register_request = RegisterSecretScopeRequest2 { + registration: registration.clone(), + }; + let receipt: SecretScopeReceipt2 = proxy + .call_confidential(names::methods::REGISTER_SECRET_SCOPE2, (®ister_request,)) + .await?; + assert!(receipt.active); + assert_eq!(receipt.registration_id, registration.registration_id); + assert_eq!( + proxy + .call_confidential::( + names::methods::REGISTER_SECRET_SCOPE2, + (®ister_request,), + ) + .await?, + receipt + ); + let mut conflicting = register_request; + conflicting.registration.target.backend_id = "j".repeat(32); + assert!( + proxy + .call_confidential::( + names::methods::REGISTER_SECRET_SCOPE2, + (&conflicting,), + ) + .await + .is_err() + ); + let _: ApprovalScopeReceipt2 = proxy + .call( + names::methods::RETIRE_APPROVAL_SCOPE2, + (RetireApprovalScopeRequest2 { + scope_id: "f".repeat(32), + },), + ) + .await?; + assert!( + self.service + .secret + .as_ref() + .ok_or_else(denied)? + .registration_id(®istration.registration_id) + .is_err() + ); + } let mut req = request(1); if self.state.mode == 17 { req.operation = SecretOperation2::ResolveAppMaster; @@ -851,6 +908,29 @@ mod linked_fixture { ) .map_err(|_| denied())?, ); + if mode == 22 { + engine + .register_scope(RegisterApprovalScopeRequest2 { + scope: ApprovalScope2 { + scope_id: "f".repeat(32), + subject: SubjectScope { + user: "runtime-user".into(), + workspace: "runtime-workspace".into(), + agent: "runtime-agent".into(), + }, + policy_ref: "runtime-policy".into(), + generation: 1, + ceiling: AccessTier::Full, + require_approval: true, + auto_approve_all: false, + }, + grants: ApprovalGrants2::default(), + audit_stream_id: "g".repeat(32), + report_handle: false, + }) + .await + .map_err(approval_failure)?; + } let mut facade_config = super::config(); if mode == 17 { facade_config.registrations[0].purpose = MasterPurpose2::AppEncryptionMaster; @@ -924,7 +1004,7 @@ mod linked_fixture { #[tokio::test] async fn actual_sdk_confidential_master_resolution_is_human_only_exact_and_fail_closed() -> TestResult { - for mode in 0..=19 { + for mode in 0..=22 { let bus = MemoryBus::new(); let owner = Broker::new(); let broker = owner.spawn(bus.clone()); From 439fca3d480277940ba35c472f83a6d1cfa36292 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:16:22 +0000 Subject: [PATCH 11/82] test(adapter-secret): cover mode 22 denial in master resolution test The confidential master resolution test now asserts that mode 22 is denied instead of checking the set count, since that mode is expected to fail authorization rather than perform any sets. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter_secret_tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index f79943d0..87d5b01d 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -1028,7 +1028,11 @@ async fn actual_sdk_confidential_master_resolution_is_human_only_exact_and_fail_ .call("Exercise", ()) .await .map_err(|e| format!("mode {mode}: {e}"))?; - assert_eq!(result["sets"], u32::from(matches!(mode, 1 | 2 | 6 | 10))); + if mode == 22 { + assert_eq!(result["denied"], true); + } else { + assert_eq!(result["sets"], u32::from(matches!(mode, 1 | 2 | 6 | 10))); + } if matches!(mode, 0 | 17) { assert_eq!(result["loads"], 1); } From 521a9b6de0e8c49c848ada364c79373f97eebb18 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:16:37 +0000 Subject: [PATCH 12/82] test(adapter-secret): cover runtime secret registration approval scope Add a test asserting that runtime secret registration is bound to the approval scope and retirement, and narrow the existing mode sweep to the range the fixture still supports. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_secret_tests.rs | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index 87d5b01d..21ff84be 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -1004,7 +1004,7 @@ mod linked_fixture { #[tokio::test] async fn actual_sdk_confidential_master_resolution_is_human_only_exact_and_fail_closed() -> TestResult { - for mode in 0..=22 { + for mode in 0..=19 { let bus = MemoryBus::new(); let owner = Broker::new(); let broker = owner.spawn(bus.clone()); @@ -1055,5 +1055,35 @@ async fn actual_sdk_confidential_master_resolution_is_human_only_exact_and_fail_ Ok(()) } +#[tokio::test] +async fn runtime_secret_registration_is_bound_to_approval_scope_and_retirement() +-> TestResult { + let bus = MemoryBus::new(); + let owner = Broker::new(); + let broker = owner.spawn(bus.clone()); + let client = Connection::connect(bus.connect().await?).await?; + let host = tinybus::module::ModuleHost::new(owner); + let info = host.attach_linked_with_config( + linked_fixture::linked_module()?, + serde_json::json!({"mode":22}), + )?; + let peer = client + .name_owner(info.manifest.bus_name.as_str()) + .await? + .ok_or("fixture peer")?; + let proxy = client.proxy( + peer.as_str(), + "/fixture", + "ai.tinyhumans.tinysecurity.SecretFixture", + )?; + let result: serde_json::Value = proxy + .with_timeout(std::time::Duration::from_secs(10)) + .call("Exercise", ()) + .await?; + assert_eq!(result["denied"], true); + broker.abort(); + Ok(()) +} + #[path = "adapter_secret_cipher_fixture_tests.rs"] mod cipher_tests; From 3d4a58e03fe3ce50ba0400061956b3fa7f79c2d6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:17:02 +0000 Subject: [PATCH 13/82] test(adapter-secret): simplify mode 22 assertion and fix signature formatting The mode 22 special case in the confidential master resolution test was redundant, so the assertion now checks the sets value uniformly across all modes. The test function signature was also reformatted onto a single line. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter_secret_tests.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index 21ff84be..5f7ccbc4 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -1028,11 +1028,7 @@ async fn actual_sdk_confidential_master_resolution_is_human_only_exact_and_fail_ .call("Exercise", ()) .await .map_err(|e| format!("mode {mode}: {e}"))?; - if mode == 22 { - assert_eq!(result["denied"], true); - } else { - assert_eq!(result["sets"], u32::from(matches!(mode, 1 | 2 | 6 | 10))); - } + assert_eq!(result["sets"], u32::from(matches!(mode, 1 | 2 | 6 | 10))); if matches!(mode, 0 | 17) { assert_eq!(result["loads"], 1); } @@ -1056,8 +1052,7 @@ async fn actual_sdk_confidential_master_resolution_is_human_only_exact_and_fail_ } #[tokio::test] -async fn runtime_secret_registration_is_bound_to_approval_scope_and_retirement() --> TestResult { +async fn runtime_secret_registration_is_bound_to_approval_scope_and_retirement() -> TestResult { let bus = MemoryBus::new(); let owner = Broker::new(); let broker = owner.spawn(bus.clone()); From fce7bf3a4ba8d717b032c44b112004509c2b1a51 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:17:56 +0000 Subject: [PATCH 14/82] test(adapter-secret): add trace output to runtime secret exercise Add eprintln tracing around the secret scope registration, retirement, and approval setup steps in the runtime secret exercise so the flow can be followed when debugging test failures. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter_secret_tests.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index 5f7ccbc4..4c5b6e16 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -671,6 +671,7 @@ impl Control { return self.canceled_evaluation(&proxy).await; } if self.state.mode == 22 { + eprintln!("runtime secret exercise: register"); let registration = SecretRegistration2 { registration_id: "h".repeat(32), approval_scope_id: "f".repeat(32), @@ -688,6 +689,7 @@ impl Control { let receipt: SecretScopeReceipt2 = proxy .call_confidential(names::methods::REGISTER_SECRET_SCOPE2, (®ister_request,)) .await?; + eprintln!("runtime secret exercise: registered"); assert!(receipt.active); assert_eq!(receipt.registration_id, registration.registration_id); assert_eq!( @@ -718,6 +720,7 @@ impl Control { },), ) .await?; + eprintln!("runtime secret exercise: retired"); assert!( self.service .secret @@ -909,6 +912,7 @@ mod linked_fixture { .map_err(|_| denied())?, ); if mode == 22 { + eprintln!("runtime secret setup: register approval"); engine .register_scope(RegisterApprovalScopeRequest2 { scope: ApprovalScope2 { @@ -930,6 +934,7 @@ mod linked_fixture { }) .await .map_err(approval_failure)?; + eprintln!("runtime secret setup: approval registered"); } let mut facade_config = super::config(); if mode == 17 { From a432d474a13c730fbc3bf37c7b557f68a06329df Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:18:16 +0000 Subject: [PATCH 15/82] test(adapter-secret): register approval scope through proxy in runtime exercise The runtime secret exercise now registers the approval scope via the proxy call instead of pre-registering it in the linked fixture setup, so the registration flows through the same adapter path as the rest of the exercise. Stray eprintln tracing was dropped along the way. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_secret_tests.rs | 52 +++++++++---------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index 4c5b6e16..96f0b979 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -671,7 +671,30 @@ impl Control { return self.canceled_evaluation(&proxy).await; } if self.state.mode == 22 { - eprintln!("runtime secret exercise: register"); + let approval_request = RegisterApprovalScopeRequest2 { + scope: ApprovalScope2 { + scope_id: "f".repeat(32), + subject: SubjectScope { + user: "runtime-user".into(), + workspace: "runtime-workspace".into(), + agent: "runtime-agent".into(), + }, + policy_ref: "runtime-policy".into(), + generation: 1, + ceiling: AccessTier::Full, + require_approval: true, + auto_approve_all: false, + }, + grants: ApprovalGrants2::default(), + audit_stream_id: "g".repeat(32), + report_handle: false, + }; + let _: ApprovalScopeReceipt2 = proxy + .call( + names::methods::REGISTER_APPROVAL_SCOPE2, + (&approval_request,), + ) + .await?; let registration = SecretRegistration2 { registration_id: "h".repeat(32), approval_scope_id: "f".repeat(32), @@ -689,7 +712,6 @@ impl Control { let receipt: SecretScopeReceipt2 = proxy .call_confidential(names::methods::REGISTER_SECRET_SCOPE2, (®ister_request,)) .await?; - eprintln!("runtime secret exercise: registered"); assert!(receipt.active); assert_eq!(receipt.registration_id, registration.registration_id); assert_eq!( @@ -720,7 +742,6 @@ impl Control { },), ) .await?; - eprintln!("runtime secret exercise: retired"); assert!( self.service .secret @@ -911,31 +932,6 @@ mod linked_fixture { ) .map_err(|_| denied())?, ); - if mode == 22 { - eprintln!("runtime secret setup: register approval"); - engine - .register_scope(RegisterApprovalScopeRequest2 { - scope: ApprovalScope2 { - scope_id: "f".repeat(32), - subject: SubjectScope { - user: "runtime-user".into(), - workspace: "runtime-workspace".into(), - agent: "runtime-agent".into(), - }, - policy_ref: "runtime-policy".into(), - generation: 1, - ceiling: AccessTier::Full, - require_approval: true, - auto_approve_all: false, - }, - grants: ApprovalGrants2::default(), - audit_stream_id: "g".repeat(32), - report_handle: false, - }) - .await - .map_err(approval_failure)?; - eprintln!("runtime secret setup: approval registered"); - } let mut facade_config = super::config(); if mode == 17 { facade_config.registrations[0].purpose = MasterPurpose2::AppEncryptionMaster; From 540280318ea5b21ab60d66de139e11c47e455383 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:19:04 +0000 Subject: [PATCH 16/82] test(adapter-secret): use hex ids in secret scope fixtures Replace the letter-based placeholder identifiers in the secret scope tests with hex digits so the fixtures match the expected id format. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter_secret_tests.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index 96f0b979..383cb042 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -686,7 +686,7 @@ impl Control { auto_approve_all: false, }, grants: ApprovalGrants2::default(), - audit_stream_id: "g".repeat(32), + audit_stream_id: "9".repeat(32), report_handle: false, }; let _: ApprovalScopeReceipt2 = proxy @@ -696,12 +696,12 @@ impl Control { ) .await?; let registration = SecretRegistration2 { - registration_id: "h".repeat(32), + registration_id: "1".repeat(32), approval_scope_id: "f".repeat(32), origin: Origin::Operator, purpose: MasterPurpose2::ConfigUserMaster, target: SecretBinding2 { - backend_id: "i".repeat(32), + backend_id: "2".repeat(32), slot: MasterSlotKind2::User, }, legacy: vec![], @@ -724,7 +724,7 @@ impl Control { receipt ); let mut conflicting = register_request; - conflicting.registration.target.backend_id = "j".repeat(32); + conflicting.registration.target.backend_id = "3".repeat(32); assert!( proxy .call_confidential::( From 316aa9ae26bb1082969484d4d0532068ac230fb1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:19:39 +0000 Subject: [PATCH 17/82] fix(secret): check dynamic registrations before re-registering Re-registering a scope that already exists in the dynamic map now returns the existing receipt instead of falling through to inventory validation, which could reject a valid duplicate. Tests cover the unknown-registration rejection and confirm the stored entry is reused rather than replaced. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-module/src/adapter_secret.rs | 12 +++++- .../src/adapter_secret_tests.rs | 40 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/src/adapter_secret.rs b/crates/tinysecurity-module/src/adapter_secret.rs index e214f9d2..e9d21ce5 100644 --- a/crates/tinysecurity-module/src/adapter_secret.rs +++ b/crates/tinysecurity-module/src/adapter_secret.rs @@ -412,7 +412,7 @@ impl SecretState { .scope_snapshot(&descriptor.approval_scope_id) .map_err(approval_failure)?; let registration_id = descriptor.registration_id.clone(); - if let Ok(existing) = self.registration_id(®istration_id) { + if let Some(existing) = self.registered.get(®istration_id) { return if existing.descriptor == descriptor { Ok(SecretScopeReceipt2 { registration_id, @@ -424,6 +424,16 @@ impl SecretState { } let mut dynamic = self.dynamic.lock().map_err(|_| denied())?; + if let Some(existing) = dynamic.get(®istration_id) { + return if existing.descriptor == descriptor { + Ok(SecretScopeReceipt2 { + registration_id, + active: true, + }) + } else { + Err(denied()) + }; + } let mut inventory = Inventory::default(); for existing in self.registered.values().chain(dynamic.values()) { let mut live_scope = existing.scope.clone(); diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index 383cb042..ae8f4e06 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -709,11 +709,40 @@ impl Control { let register_request = RegisterSecretScopeRequest2 { registration: registration.clone(), }; + let unknown = RegisterSecretScopeRequest2 { + registration: SecretRegistration2 { + registration_id: "4".repeat(32), + approval_scope_id: "5".repeat(32), + origin: Origin::Operator, + purpose: MasterPurpose2::ConfigUserMaster, + target: SecretBinding2 { + backend_id: "6".repeat(32), + slot: MasterSlotKind2::User, + }, + legacy: vec![], + }, + }; + assert!(proxy + .call_confidential::( + names::methods::REGISTER_SECRET_SCOPE2, + (&unknown,), + ) + .await + .is_err()); let receipt: SecretScopeReceipt2 = proxy .call_confidential(names::methods::REGISTER_SECRET_SCOPE2, (®ister_request,)) .await?; assert!(receipt.active); assert_eq!(receipt.registration_id, registration.registration_id); + let secret = self.service.secret.as_ref().ok_or_else(denied)?; + let prior = Arc::as_ptr( + secret + .dynamic + .lock() + .map_err(|_| denied())? + .get(®istration.registration_id) + .ok_or_else(denied)?, + ); assert_eq!( proxy .call_confidential::( @@ -723,6 +752,17 @@ impl Control { .await?, receipt ); + assert_eq!( + Arc::as_ptr( + secret + .dynamic + .lock() + .map_err(|_| denied())? + .get(®istration.registration_id) + .ok_or_else(denied)?, + ), + prior + ); let mut conflicting = register_request; conflicting.registration.target.backend_id = "3".repeat(32); assert!( From 88c41f4bbce566ae2baadeca468534c3faf443c1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:20:04 +0000 Subject: [PATCH 18/82] test(bus): update contract version expectations to 1.29 The contract version was bumped to 1.29, so the wire and pairing tests now assert the new version and the compatibility boundary shifts accordingly. The scoped wire test also advertises the newly registered secret scope method. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/device_frame_tests.rs | 2 +- crates/tinysecurity-bus/src/device_pairing_tests.rs | 2 +- crates/tinysecurity-bus/tests/scoped_wire.rs | 11 ++++++----- crates/tinysecurity-bus/tests/wire.rs | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index 1d5dedb9..ec64d356 100644 --- a/crates/tinysecurity-bus/src/device_frame_tests.rs +++ b/crates/tinysecurity-bus/src/device_frame_tests.rs @@ -70,5 +70,5 @@ fn frame_operations_redact_both_payloads_without_changing_open_wire_shape() #[test] fn frame_member_name_is_stable_and_contract_version_is_bumped() { assert_eq!(crate::names::methods::DEVICE_FRAME2, "DeviceFrame2"); - assert_eq!(crate::CONTRACT_VERSION, (1, 28)); + assert_eq!(crate::CONTRACT_VERSION, (1, 29)); } diff --git a/crates/tinysecurity-bus/src/device_pairing_tests.rs b/crates/tinysecurity-bus/src/device_pairing_tests.rs index c417a301..ce8974fe 100644 --- a/crates/tinysecurity-bus/src/device_pairing_tests.rs +++ b/crates/tinysecurity-bus/src/device_pairing_tests.rs @@ -85,7 +85,7 @@ fn refresh_and_close_are_distinct_transient_session_operations() { #[test] fn pairing_contract_is_versioned_after_the_frame_contract() { - assert_eq!(CONTRACT_VERSION, (1, 28)); + assert_eq!(CONTRACT_VERSION, (1, 29)); } #[test] diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index 1b329b83..a861de47 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -1,4 +1,4 @@ -//! Registered subject policy wire shapes and the 1.28 compatibility boundary. +//! Registered subject policy wire shapes and the 1.29 compatibility boundary. use serde_json::json; use tinysecurity_bus::*; @@ -79,6 +79,7 @@ fn new_members_are_advertised_and_named_by_contract_constants() { names::methods::AUTHORIZE_BROWSER_DIAL, names::methods::CONSUME_BROWSER_DIAL, names::methods::VALIDATE_BROWSER_DIAL, + names::methods::REGISTER_SECRET_SCOPE2, ] { assert!(names::METHODS.contains(&name), "{name}"); } @@ -87,10 +88,10 @@ 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, 28)); - // A host built against 1.28 accepts this module and newer minors, not a 1.27 module. - assert!(is_compatible((1, 28))); - assert!(!is_compatible((1, 27))); + assert_eq!(CONTRACT_VERSION, (1, 29)); + // A host built against 1.29 accepts this module and newer minors, not a 1.28 module. + assert!(is_compatible((1, 29))); + assert!(!is_compatible((1, 28))); assert!(!is_compatible((1, 26))); assert!(!is_compatible((1, 25))); assert!(!is_compatible((1, 24))); diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index 361a1aa6..ac0a2e2f 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, 28)); + assert_eq!(CONTRACT_VERSION, (1, 29)); for (mode, wire) in [ (RedactMode::Text, "text"), (RedactMode::StructuredArguments, "structured_arguments"), From fff0b2a2ada993494cb784912685a8db8d96b851 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:20:21 +0000 Subject: [PATCH 19/82] test(adapter-secret): reformat assertion for readability Reformatted the assertion that checks an unknown secret scope registration fails, wrapping it in a block so the chained call reads more clearly. No behaviour change. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_secret_tests.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index ae8f4e06..f5d749c7 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -722,13 +722,15 @@ impl Control { legacy: vec![], }, }; - assert!(proxy - .call_confidential::( - names::methods::REGISTER_SECRET_SCOPE2, - (&unknown,), - ) - .await - .is_err()); + assert!( + proxy + .call_confidential::( + names::methods::REGISTER_SECRET_SCOPE2, + (&unknown,), + ) + .await + .is_err() + ); let receipt: SecretScopeReceipt2 = proxy .call_confidential(names::methods::REGISTER_SECRET_SCOPE2, (®ister_request,)) .await?; From 34561276e2f64d8e8f1f64cf4944fd34e17dbd61 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:20:40 +0000 Subject: [PATCH 20/82] test(adapter-secret): cast registration counts to usize Cast the registration count values to usize in the secret adapter tests so the assertions compare against the expected integer type. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter_secret_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index f5d749c7..1665c05a 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -744,7 +744,7 @@ impl Control { .map_err(|_| denied())? .get(®istration.registration_id) .ok_or_else(denied)?, - ); + ) as usize; assert_eq!( proxy .call_confidential::( @@ -762,7 +762,7 @@ impl Control { .map_err(|_| denied())? .get(®istration.registration_id) .ok_or_else(denied)?, - ), + ) as usize, prior ); let mut conflicting = register_request; From 37fb952c22b04435f6683605bc3289c93a268a0a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:21:02 +0000 Subject: [PATCH 21/82] docs: add runtime secret scopes contract for 1.29 Document the runtime secret scopes contract for the 1.29 release, specifying how scopes are resolved and enforced at runtime. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../runtime-secret-scopes-contract-1.29.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docs/specs/runtime-secret-scopes-contract-1.29.md diff --git a/docs/specs/runtime-secret-scopes-contract-1.29.md b/docs/specs/runtime-secret-scopes-contract-1.29.md new file mode 100644 index 00000000..a9d50c53 --- /dev/null +++ b/docs/specs/runtime-secret-scopes-contract-1.29.md @@ -0,0 +1,29 @@ +# Runtime secret-scope registration (contract 1.29) + +`RegisterSecretScope2` lets a trusted host adapter add a secret resolver after +the module has initialized. This supports tenant and embedded-agent approval +scopes without reinitializing the process-global secret registry. + +The host must register the corresponding approval scope first. The request +carries only the immutable `SecretRegistration2` descriptor and its approval +scope ID. The module resolves the live scope from its approval engine and +derives the native subject, durable subject, policy reference, and generation +from that canonical scope. A caller cannot supply or override those fields. + +Registration is bounded by the native registry's 65,536 lifetime scope +identities. An exact repeat is idempotent and preserves the existing resolver, +including its persistent failure state. Reusing a registration ID with a +different descriptor is denied. Native admission checks the expected approval +generation while holding the registry lock, so an in-flight generation change +cannot add stale authority. + +Retiring or replacing an approval scope first revokes native secret handles, +drops pending secret grants and aliases, and removes its runtime secret +registrations. Re-registering requires a live approval scope and an immutable +secret descriptor. Initialized secret registrations remain configured but +their old handles are invalidated by the same retirement boundary. + +The new member is host-only, uses the authenticated module connection, and +returns only the opaque registration ID and active status. It does not return +subjects, backend contents, keys, or plaintext. Older modules do not implement +this member and therefore do not satisfy contract 1.29. From 4144fadce7e6e95001336c2fd9160a8a494c63d0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:21:16 +0000 Subject: [PATCH 22/82] docs: document runtime secret scopes contract 1.29 Add a README entry describing the host-only RegisterSecretScope2 member introduced by contract 1.29, which binds a secret resolver to an already-live approval scope without reinitializing the process-global secret registry, and note that scope replacement or retirement revokes the associated handles. The docs index now links the new specification. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- README.md | 5 +++++ docs/README.md | 1 + 2 files changed, 6 insertions(+) diff --git a/README.md b/README.md index d1bb41e4..c5116af6 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ when a lease expires or policy generation changes. Sensitive and local-only privacy modes deny this uninspectable traffic; TinyComputer remains responsible for enforcing the proxy and dialing the approved socket. See the [browser-dial contract specification](docs/specs/browser-dial-contract-1.28.md). +Contract 1.29 adds the host-only `RegisterSecretScope2` member for adding a +secret resolver bound to an already-live approval scope, without reinitializing +the process-global secret registry. Approval-scope replacement or retirement +revokes the associated secret handles and runtime registrations; see the +[runtime secret-scope specification](docs/specs/runtime-secret-scopes-contract-1.29.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/docs/README.md b/docs/README.md index ef76e074..2edc9fa2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ docs/ - **[`specs/device-pairing-contract-1.25.md`](specs/device-pairing-contract-1.25.md)** — host-attested paired-device lifecycle contract and trust boundaries. - **[`plans/device-pairing-contract-1.25.md`](plans/device-pairing-contract-1.25.md)** — ordered implementation and verification steps for pairing lifecycle support. - **[`specs/browser-dial-contract-1.28.md`](specs/browser-dial-contract-1.28.md)** — destination-only, one-use socket authorization for opaque browser traffic. +- **[`specs/runtime-secret-scopes-contract-1.29.md`](specs/runtime-secret-scopes-contract-1.29.md)** — host-only runtime secret registration bound to live approval scopes. - **[`plans/`](plans/README.md)** — implementation-ordered, test-first steps for delivering an approved specification. Plans name exact files and verification commands, and are updated as the work progresses. From 6fe5572732205f525cdd5ccf4cdb18a343471499 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:21:52 +0000 Subject: [PATCH 23/82] test(adapter-secret): cover scope replacement and secret re-registration Extend the secret adapter test to replace an approval scope and register a replacement secret scope, then assert the original registration is gone and the replacement is retired. This exercises the replacement path that the existing retirement assertions did not cover. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_secret_tests.rs | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index 1665c05a..50f51f9a 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -776,6 +776,48 @@ impl Control { .await .is_err() ); + let replacement = RegisterApprovalScopeRequest2 { + scope: ApprovalScope2 { + scope_id: "f".repeat(32), + subject: SubjectScope { + user: "runtime-user".into(), + workspace: "runtime-workspace".into(), + agent: "runtime-agent".into(), + }, + policy_ref: "runtime-policy-next".into(), + generation: 1, + ceiling: AccessTier::Full, + require_approval: true, + auto_approve_all: false, + }, + grants: ApprovalGrants2::default(), + audit_stream_id: "9".repeat(32), + report_handle: false, + }; + let _: ApprovalScopeReceipt2 = proxy + .call(names::methods::REPLACE_APPROVAL_SCOPE2, (&replacement,)) + .await?; + assert!( + secret + .registration_id(®istration.registration_id) + .is_err() + ); + let replacement_registration = SecretRegistration2 { + registration_id: "7".repeat(32), + target: SecretBinding2 { + backend_id: "8".repeat(32), + slot: MasterSlotKind2::User, + }, + ..registration.clone() + }; + let _: SecretScopeReceipt2 = proxy + .call_confidential( + names::methods::REGISTER_SECRET_SCOPE2, + (&RegisterSecretScopeRequest2 { + registration: replacement_registration.clone(), + },), + ) + .await?; let _: ApprovalScopeReceipt2 = proxy .call( names::methods::RETIRE_APPROVAL_SCOPE2, @@ -789,7 +831,7 @@ impl Control { .secret .as_ref() .ok_or_else(denied)? - .registration_id(®istration.registration_id) + .registration_id(&replacement_registration.registration_id) .is_err() ); } From 685b4a7b244c9b0e84798ebab0a59571183f36c7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:25:02 +0000 Subject: [PATCH 24/82] chore(bus): reorder method name entries Move the register secret scope entry next to the other scope registration methods so the list stays grouped consistently. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/names.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-bus/src/names.rs b/crates/tinysecurity-bus/src/names.rs index 6db7220c..7380ca9d 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -251,9 +251,9 @@ pub const METHODS: &[&str] = &[ methods::LIST_TOOL_TRUST2, methods::IMPORT_TRUST_SOURCE2, methods::REGISTER_APPROVAL_SCOPE2, + methods::REGISTER_SECRET_SCOPE2, methods::REPLACE_APPROVAL_SCOPE2, methods::RETIRE_APPROVAL_SCOPE2, - methods::REGISTER_SECRET_SCOPE2, methods::REGISTER_SANDBOX_SCOPE2, methods::RETIRE_SANDBOX_SCOPE2, methods::IMPORT_APPROVAL_HISTORY2, From 4de953e4eac2fa4bc97fd334b4d08b8d41aed85c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:26:44 +0000 Subject: [PATCH 25/82] test(crypto): cover scope replacement fencing old reservations Add a test asserting that replacing a live scope updates the policy reference and invalidates reservations made under the previous scope, while new reservations pick up the updated policy. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/native_handles_tests.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/tinysecurity-crypto/src/native_handles_tests.rs b/crates/tinysecurity-crypto/src/native_handles_tests.rs index f16d2722..ba3e090c 100644 --- a/crates/tinysecurity-crypto/src/native_handles_tests.rs +++ b/crates/tinysecurity-crypto/src/native_handles_tests.rs @@ -108,6 +108,30 @@ fn a_live_registry_accepts_an_immutable_additional_subject_scope() -> TestResult Ok(()) } +#[test] +fn replacing_a_live_scope_updates_policy_and_fences_old_reservations() -> TestResult { + let (registry, _) = registry(Limits::default())?; + let old = reserve( + ®istry, + Purpose::ConfigUserMaster, + OperationKind::RetainPlaintext, + b"old", + )?; + let mut updated = registration("a"); + updated.policy_ref = "policy-next".into(); + registry.replace_scope(updated, 1)?; + assert!(old.validate().is_err()); + + let next = reserve( + ®istry, + Purpose::ConfigUserMaster, + OperationKind::RetainPlaintext, + b"new", + )?; + assert_eq!(next.binding().policy_ref(), "policy-next"); + Ok(()) +} + fn reserve( reg: &Registry, purpose: Purpose, From b3dee49eab09f3f6dcb690c30021230758f9b928 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:27:24 +0000 Subject: [PATCH 26/82] feat(native_handles): add replace_scope to update a live scope's policy Adds Registry::replace_scope, which swaps the policy reference of an existing trusted scope while keeping subject identities and native purposes immutable. The scope epoch is bumped before the new policy becomes visible so reservations and retained handles created under the prior policy are invalidated, and the test now reads the policy_ref field directly. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-crypto/src/native_handles.rs | 36 +++++++++++++++++++ .../src/native_handles_tests.rs | 2 +- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-crypto/src/native_handles.rs b/crates/tinysecurity-crypto/src/native_handles.rs index 82bbf11b..0b9abe1c 100644 --- a/crates/tinysecurity-crypto/src/native_handles.rs +++ b/crates/tinysecurity-crypto/src/native_handles.rs @@ -610,6 +610,42 @@ impl Registry { ); Ok(()) } + /// Replace only the policy reference of one live trusted scope. + /// Subject identities and native purposes are immutable. The epoch changes + /// before the new policy becomes visible, invalidating reservations and + /// retained handles created under the prior policy. + /// + /// # Errors + /// Rejects invalid registrations, stale generation, identity changes, + /// unknown scopes, closed state, clock failure, or epoch exhaustion. + pub fn replace_scope(&self, registration: Registration, generation: u64) -> Result<()> { + let (mut state, now) = self.checked_lock()?; + if state.generation != generation || !valid_registration(®istration) { + return Err(Error::NativeDenied); + } + let scope = state + .scopes + .get_mut(®istration.id) + .ok_or(Error::NativeDenied)?; + if scope.registration.subject != registration.subject + || scope.registration.durable_subject != registration.durable_subject + || scope.registration.purposes != registration.purposes + { + return Err(Error::NativeDenied); + } + if scope.registration == registration { + return Ok(()); + } + let epoch = scope.epoch.checked_add(1).ok_or(Error::NativeDenied)?; + scope.registration = registration.clone(); + scope.epoch = epoch; + scope.rate = RateCounter::new(now.monotonic); + state + .entries + .retain(|_, entry| entry.registration != registration.id); + state.recount(); + Ok(()) + } fn lock(&self) -> Result> { let gate = self.physical_gate.lock().map_err(|_| Error::NativeDenied)?; let state = self.state.lock().map_err(|_| Error::NativeDenied)?; diff --git a/crates/tinysecurity-crypto/src/native_handles_tests.rs b/crates/tinysecurity-crypto/src/native_handles_tests.rs index ba3e090c..ffb166ce 100644 --- a/crates/tinysecurity-crypto/src/native_handles_tests.rs +++ b/crates/tinysecurity-crypto/src/native_handles_tests.rs @@ -128,7 +128,7 @@ fn replacing_a_live_scope_updates_policy_and_fences_old_reservations() -> TestRe OperationKind::RetainPlaintext, b"new", )?; - assert_eq!(next.binding().policy_ref(), "policy-next"); + assert_eq!(next.binding().policy_ref, "policy-next"); Ok(()) } From 9788ae1395cbba822b9deb496cbf142a25e647a0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:27:59 +0000 Subject: [PATCH 27/82] fix(secret): guard approval scope with rwlock and refresh on change The registered approval scope is now stored behind an RwLock so it can be updated in place, and a new refresh_approval_scope method re-reads the engine snapshot, re-registers the handle and drops stale pending grants whenever a scope changes. Callers read the scope under the lock and pass it explicitly into evaluation, so grants are verified against the current scope rather than a stale clone. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-module/src/adapter_secret.rs | 73 ++++++++++++++++--- 1 file changed, 61 insertions(+), 12 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_secret.rs b/crates/tinysecurity-module/src/adapter_secret.rs index e9d21ce5..304a7081 100644 --- a/crates/tinysecurity-module/src/adapter_secret.rs +++ b/crates/tinysecurity-module/src/adapter_secret.rs @@ -4,7 +4,7 @@ use super::{PolicyService, approval_failure}; use crate::secret_callbacks::{CallbackStore, GuardedMint, LiveGrant, ResolveContext, StoreBroker}; use std::{ collections::{BTreeMap, BTreeSet}, - sync::{Arc, Mutex, MutexGuard, OnceLock}, + sync::{Arc, Mutex, MutexGuard, OnceLock, RwLock}, }; use tinysecurity::crypto::{ keyring::{LegacySource, MasterResolver, MasterSlot}, @@ -46,7 +46,7 @@ fn opaque() -> tinybus::Result { } struct Registered { descriptor: SecretRegistration2, - scope: ApprovalScope2, + scope: RwLock, broker: Arc, resolver: MasterResolver, } @@ -231,7 +231,7 @@ impl SecretState { descriptor.registration_id.clone(), Arc::new(Registered { descriptor: descriptor.clone(), - scope: scope.clone(), + scope: RwLock::new(scope.clone()), broker, resolver, }), @@ -267,7 +267,7 @@ impl SecretState { if reg.descriptor.purpose != MasterPurpose2::ConfigUserMaster { return Err(denied()); } - Ok(reg.scope.clone()) + Ok(reg.scope.read().map_err(|_| denied())?.clone()) } pub(super) fn prepare_cipher( &self, @@ -436,7 +436,7 @@ impl SecretState { } let mut inventory = Inventory::default(); for existing in self.registered.values().chain(dynamic.values()) { - let mut live_scope = existing.scope.clone(); + let mut live_scope = existing.scope.read().map_err(|_| denied())?.clone(); live_scope.generation = scope.generation; inventory.admit(&existing.descriptor, &live_scope, scope.generation)?; } @@ -496,7 +496,7 @@ impl SecretState { registration_id.clone(), Arc::new(Registered { descriptor, - scope, + scope: RwLock::new(scope), broker, resolver, }), @@ -541,6 +541,47 @@ impl SecretState { .retain(|_, alias| !ids.contains(&alias.registration)); Ok(()) } + pub(super) fn refresh_approval_scope( + &self, + scope_id: &str, + engine: &tinysecurity::approval::Engine, + ) -> tinybus::Result<()> { + let scope = engine.scope_snapshot(scope_id).map_err(approval_failure)?; + for registered in self.registered.values().filter(|registered| { + registered.descriptor.approval_scope_id == scope_id + }) { + let mut current = registered.scope.write().map_err(|_| denied())?; + if current.subject != scope.subject { + return Err(denied()); + } + self.registry + .replace_scope( + handles::Registration { + id: registered.descriptor.registration_id.clone(), + subject: scope.subject.clone(), + durable_subject: engine + .durable_scope(scope_id) + .map_err(approval_failure)?, + policy_ref: engine + .durable_policy_ref(scope_id) + .map_err(approval_failure)?, + purposes: vec![purpose(registered.descriptor.purpose)], + }, + scope.generation, + ) + .map_err(|_| denied())?; + *current = scope.clone(); + } + self.pending + .lock() + .map_err(|_| denied())? + .retain(|(registration, _), _| { + !self.registered.get(registration).is_some_and(|entry| { + entry.descriptor.approval_scope_id == scope_id + }) + }); + Ok(()) + } fn prune(&self) -> tinybus::Result<()> { let now = self.clock.now().map_err(|_| { self.close(); @@ -582,12 +623,16 @@ impl SecretState { super::cipher::close_pending(); } } -fn call(reg: &Registered, binding: &handles::Binding) -> EvaluateRequest { +fn call( + reg: &Registered, + scope: &ApprovalScope2, + binding: &handles::Binding, +) -> EvaluateRequest { EvaluateRequest { caller: CallerContext { - agent: reg.scope.subject.agent.clone(), + agent: scope.subject.agent.clone(), call_id: binding.safe_call_id(), - tier: reg.scope.ceiling.clone(), + tier: scope.ceiling.clone(), origin: reg.descriptor.origin.clone(), ..Default::default() }, @@ -703,6 +748,7 @@ pub(super) fn activate(state: Option>) -> tinybus::Result<()> { struct Verifier<'a> { engine: &'a tinysecurity::approval::Engine, reg: &'a Registered, + scope: &'a ApprovalScope2, request_id: String, audit: AuditCorrelation2, } @@ -715,7 +761,7 @@ impl handles::HumanGrantVerifier for Verifier<'_> { let (row, ack) = self .engine .consume_native_grant(ApprovalActionRequest2 { - scope_id: self.reg.scope.scope_id.clone(), + scope_id: self.scope.scope_id.clone(), request_id: self.request_id.clone(), request: call(self.reg, binding), audit: self.audit.clone(), @@ -749,6 +795,7 @@ impl PolicyService { let state = self.secret.as_ref().ok_or_else(denied)?; state.prune()?; let reg = state.registration(&request.request)?; + let reg_scope = reg.scope.read().map_err(|_| denied())?.clone(); let engine = self.approvals.as_ref().ok_or_else(denied)?; let generation = self.read()?.info().generation; let key = ( @@ -779,7 +826,7 @@ impl PolicyService { generation, }) .map_err(|_| denied())?; - let approval = call(®, work.binding()); + let approval = call(®, ®_scope, work.binding()); let expires = work.binding().expires_at_ms(); pending.insert( key.clone(), @@ -795,7 +842,7 @@ impl PolicyService { let row = engine .begin_native_grant( EvaluateApprovalRequest2 { - scope_id: reg.scope.scope_id.clone(), + scope_id: reg_scope.scope_id.clone(), request: approval, ttl_ms: None, summary: "native master authority".into(), @@ -821,6 +868,7 @@ impl PolicyService { let state = self.secret.as_ref().ok_or_else(denied)?; state.prune()?; let reg = state.registration(&request.request)?; + let reg_scope = reg.scope.read().map_err(|_| denied())?.clone(); let generation = self.read()?.info().generation; let key = ( request.request.registration_id.clone(), @@ -846,6 +894,7 @@ impl PolicyService { &Verifier { engine: self.approvals.as_ref().ok_or_else(denied)?, reg: ®, + scope: ®_scope, request_id: request.request_id, audit: request.audit, }, From 826b48ba4a7bd55382f7df16effd1ec016f4e9fa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:28:58 +0000 Subject: [PATCH 28/82] fix(module): refresh approval scope after replacing it The approval scope was retired before the scope replacement but never re-registered afterwards, leaving the secret store without a valid approval scope once the replacement succeeded. The scope id is now captured up front and the scope refreshed after the engine call, and the grant verifier passes the scope through when consuming a native grant. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 11 ++++++++--- crates/tinysecurity-module/src/adapter_secret.rs | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index e2dbe0bc..e285046b 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1207,13 +1207,18 @@ impl PolicyService { .approvals .as_ref() .ok_or_else(|| tinybus::Error::failed("approval unavailable"))?; + let scope_id = request.scope.scope_id.clone(); if let Some(secret) = &self.secret { - secret.retire_approval_scope(&request.scope.scope_id)?; + secret.retire_approval_scope(&scope_id)?; } - engine + let receipt = engine .replace_scope(request) .await - .map_err(approval_failure) + .map_err(approval_failure)?; + if let Some(secret) = &self.secret { + secret.refresh_approval_scope(&scope_id, engine)?; + } + Ok(receipt) } async fn retire_approval_scope2( &self, diff --git a/crates/tinysecurity-module/src/adapter_secret.rs b/crates/tinysecurity-module/src/adapter_secret.rs index 304a7081..816bce98 100644 --- a/crates/tinysecurity-module/src/adapter_secret.rs +++ b/crates/tinysecurity-module/src/adapter_secret.rs @@ -763,7 +763,7 @@ impl handles::HumanGrantVerifier for Verifier<'_> { .consume_native_grant(ApprovalActionRequest2 { scope_id: self.scope.scope_id.clone(), request_id: self.request_id.clone(), - request: call(self.reg, binding), + request: call(self.reg, self.scope, binding), audit: self.audit.clone(), }) .await From 84299f12c44660efffb576d054fc0f75d9d2586a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:29:11 +0000 Subject: [PATCH 29/82] style: reformat adapter_secret for rustfmt compliance Reformatted the filter closures and the call function signature to match rustfmt output. No behaviour change. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-module/src/adapter_secret.rs | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_secret.rs b/crates/tinysecurity-module/src/adapter_secret.rs index 816bce98..141fd4e6 100644 --- a/crates/tinysecurity-module/src/adapter_secret.rs +++ b/crates/tinysecurity-module/src/adapter_secret.rs @@ -547,9 +547,11 @@ impl SecretState { engine: &tinysecurity::approval::Engine, ) -> tinybus::Result<()> { let scope = engine.scope_snapshot(scope_id).map_err(approval_failure)?; - for registered in self.registered.values().filter(|registered| { - registered.descriptor.approval_scope_id == scope_id - }) { + for registered in self + .registered + .values() + .filter(|registered| registered.descriptor.approval_scope_id == scope_id) + { let mut current = registered.scope.write().map_err(|_| denied())?; if current.subject != scope.subject { return Err(denied()); @@ -576,9 +578,10 @@ impl SecretState { .lock() .map_err(|_| denied())? .retain(|(registration, _), _| { - !self.registered.get(registration).is_some_and(|entry| { - entry.descriptor.approval_scope_id == scope_id - }) + !self + .registered + .get(registration) + .is_some_and(|entry| entry.descriptor.approval_scope_id == scope_id) }); Ok(()) } @@ -623,11 +626,7 @@ impl SecretState { super::cipher::close_pending(); } } -fn call( - reg: &Registered, - scope: &ApprovalScope2, - binding: &handles::Binding, -) -> EvaluateRequest { +fn call(reg: &Registered, scope: &ApprovalScope2, binding: &handles::Binding) -> EvaluateRequest { EvaluateRequest { caller: CallerContext { agent: scope.subject.agent.clone(), From 1a6b62e25d78820180ed5cead7c295876ca64027 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:32:36 +0000 Subject: [PATCH 30/82] test(adapter-secret): cover re-registration with a reused policy ref Extend the approval generation test so that re-registering with the same policy reference is also rejected, confirming a retired dynamic secret ID cannot become live again when its descriptor is byte-for-byte identical. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_secret_tests.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index 50f51f9a..aad22a25 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -784,7 +784,11 @@ impl Control { workspace: "runtime-workspace".into(), agent: "runtime-agent".into(), }, - policy_ref: "runtime-policy-next".into(), + // Reusing the same policy reference still advances the + // approval generation. A retired dynamic secret ID must + // not become live again just because its descriptor is + // byte-for-byte identical. + policy_ref: "runtime-policy".into(), generation: 1, ceiling: AccessTier::Full, require_approval: true, @@ -802,6 +806,18 @@ impl Control { .registration_id(®istration.registration_id) .is_err() ); + assert!( + proxy + .call_confidential::( + names::methods::REGISTER_SECRET_SCOPE2, + (&RegisterSecretScopeRequest2 { + registration: registration.clone(), + },), + ) + .await + .is_err(), + "replacing the approval generation must require a new immutable secret ID" + ); let replacement_registration = SecretRegistration2 { registration_id: "7".repeat(32), target: SecretBinding2 { From 2f988568200a474dd6d635ac0fcd47f49cd579b6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:33:25 +0000 Subject: [PATCH 31/82] fix(native_handles): reject operations on deactivated scopes Scopes now carry an active flag that is cleared when a registration is revoked, and every lookup path checks it before granting access. This closes a window where a revoked registration could still be reused to authorize requests or bindings. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-crypto/src/native_handles.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-crypto/src/native_handles.rs b/crates/tinysecurity-crypto/src/native_handles.rs index 0b9abe1c..f565937d 100644 --- a/crates/tinysecurity-crypto/src/native_handles.rs +++ b/crates/tinysecurity-crypto/src/native_handles.rs @@ -147,6 +147,7 @@ struct Scope { registration: Registration, epoch: u64, rate: RateCounter, + active: bool, } struct State { scopes: BTreeMap, @@ -552,6 +553,7 @@ impl Registry { registration, epoch: 1, rate: RateCounter::new(now.monotonic), + active: true, }, ); } @@ -591,7 +593,7 @@ impl Registry { return Err(Error::NativeDenied); } if let Some(existing) = state.scopes.get(®istration.id) { - return if existing.registration == registration { + return if existing.active && existing.registration == registration { Ok(()) } else { Err(Error::NativeDenied) @@ -606,6 +608,7 @@ impl Registry { registration, epoch: 1, rate: RateCounter::new(now.monotonic), + active: true, }, ); Ok(()) @@ -633,13 +636,14 @@ impl Registry { { return Err(Error::NativeDenied); } - if scope.registration == registration { + if scope.active && scope.registration == registration { return Ok(()); } let epoch = scope.epoch.checked_add(1).ok_or(Error::NativeDenied)?; scope.registration = registration.clone(); scope.epoch = epoch; scope.rate = RateCounter::new(now.monotonic); + scope.active = true; state .entries .retain(|_, entry| entry.registration != registration.id); @@ -719,7 +723,7 @@ impl Registry { .scopes .get(request.registration) .ok_or(Error::NativeDenied)?; - if !scope.registration.purposes.contains(&request.purpose) { + if !scope.active || !scope.registration.purposes.contains(&request.purpose) { return Err(Error::NativeDenied); } let mut binding = Binding { @@ -935,7 +939,7 @@ impl Registry { || state .scopes .get(registration) - .is_none_or(|scope| scope.epoch != entry.epoch) + .is_none_or(|scope| !scope.active || scope.epoch != entry.epoch) { return Err(Error::NativeDenied); } @@ -950,7 +954,11 @@ impl Registry { .scopes .get_mut(registration) .ok_or(Error::NativeDenied)?; + if !scope.active { + return Err(Error::NativeDenied); + } scope.epoch = scope.epoch.checked_add(1).ok_or(Error::NativeDenied)?; + scope.active = false; state .entries .retain(|_, entry| entry.registration != registration); From f0a73548a06c9e9b81ff3f46cc74d5234eeedd94 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:33:37 +0000 Subject: [PATCH 32/82] test(crypto): cover revoked scope re-registration and replacement Adds a test asserting that a revoked scope id cannot be registered or used for reservations again, while the trusted replacement path can reactivate it after fencing the previous generation. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/native_handles_tests.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/tinysecurity-crypto/src/native_handles_tests.rs b/crates/tinysecurity-crypto/src/native_handles_tests.rs index ffb166ce..00240672 100644 --- a/crates/tinysecurity-crypto/src/native_handles_tests.rs +++ b/crates/tinysecurity-crypto/src/native_handles_tests.rs @@ -132,6 +132,38 @@ fn replacing_a_live_scope_updates_policy_and_fences_old_reservations() -> TestRe Ok(()) } +#[test] +fn a_revoked_scope_id_cannot_be_registered_again_but_can_be_replaced() -> TestResult { + let (registry, _) = registry(Limits::default())?; + registry.revoke_scope("a")?; + + assert!(registry.register_scope(registration("a"), 1).is_err()); + assert!(registry + .reserve(Request { + registration: "a", + authenticated_peer: ":1.1", + purpose: Purpose::ConfigUserMaster, + kind: OperationKind::RetainPlaintext, + call_id: "revoked-call", + input: Zeroizing::new(b"revoked".to_vec()), + ttl_ms: 100, + generation: 1, + }) + .is_err()); + + // The trusted approval-scope replacement path reactivates a configured + // native scope only after fencing the previous generation. + registry.replace_scope(registration("a"), 1)?; + let next = reserve( + ®istry, + Purpose::ConfigUserMaster, + OperationKind::RetainPlaintext, + b"next-generation", + )?; + assert_eq!(next.binding().epoch(), 3); + Ok(()) +} + fn reserve( reg: &Registry, purpose: Purpose, From 48ce3176f5d74f4c058130de1977717ec9e457d5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:33:50 +0000 Subject: [PATCH 33/82] test(crypto): reformat revoked scope assertions Reindented the reserve assertion in the revoked scope test to satisfy rustfmt and replaced the epoch check on the replacement binding with an assertion on its policy reference, so the test verifies the reactivated scope is bound to the expected policy rather than an internal counter. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/native_handles_tests.rs | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/crates/tinysecurity-crypto/src/native_handles_tests.rs b/crates/tinysecurity-crypto/src/native_handles_tests.rs index 00240672..8f795a22 100644 --- a/crates/tinysecurity-crypto/src/native_handles_tests.rs +++ b/crates/tinysecurity-crypto/src/native_handles_tests.rs @@ -138,18 +138,20 @@ fn a_revoked_scope_id_cannot_be_registered_again_but_can_be_replaced() -> TestRe registry.revoke_scope("a")?; assert!(registry.register_scope(registration("a"), 1).is_err()); - assert!(registry - .reserve(Request { - registration: "a", - authenticated_peer: ":1.1", - purpose: Purpose::ConfigUserMaster, - kind: OperationKind::RetainPlaintext, - call_id: "revoked-call", - input: Zeroizing::new(b"revoked".to_vec()), - ttl_ms: 100, - generation: 1, - }) - .is_err()); + assert!( + registry + .reserve(Request { + registration: "a", + authenticated_peer: ":1.1", + purpose: Purpose::ConfigUserMaster, + kind: OperationKind::RetainPlaintext, + call_id: "revoked-call", + input: Zeroizing::new(b"revoked".to_vec()), + ttl_ms: 100, + generation: 1, + }) + .is_err() + ); // The trusted approval-scope replacement path reactivates a configured // native scope only after fencing the previous generation. @@ -160,7 +162,7 @@ fn a_revoked_scope_id_cannot_be_registered_again_but_can_be_replaced() -> TestRe OperationKind::RetainPlaintext, b"next-generation", )?; - assert_eq!(next.binding().epoch(), 3); + assert_eq!(next.binding().policy_ref, "policy"); Ok(()) } From 8c7ec6e707ec9f8c5c2b048048b81c949dbf4a48 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:34:13 +0000 Subject: [PATCH 34/82] docs(specs): document tombstoning of revoked runtime secret ids Revoked runtime IDs now stay tombstoned for the life of the native registry and cannot be reactivated by re-registering the same descriptor, so a replacement requires a new ID. The contract spec is updated to state this rule alongside the existing admission checks. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- docs/specs/runtime-secret-scopes-contract-1.29.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/specs/runtime-secret-scopes-contract-1.29.md b/docs/specs/runtime-secret-scopes-contract-1.29.md index a9d50c53..5f49625e 100644 --- a/docs/specs/runtime-secret-scopes-contract-1.29.md +++ b/docs/specs/runtime-secret-scopes-contract-1.29.md @@ -13,9 +13,11 @@ from that canonical scope. A caller cannot supply or override those fields. Registration is bounded by the native registry's 65,536 lifetime scope identities. An exact repeat is idempotent and preserves the existing resolver, including its persistent failure state. Reusing a registration ID with a -different descriptor is denied. Native admission checks the expected approval -generation while holding the registry lock, so an in-flight generation change -cannot add stale authority. +different descriptor is denied. Revoked runtime IDs stay tombstoned for the +life of the native registry and cannot be reactivated by registering the same +descriptor again; a replacement needs a new ID. Native admission checks the +expected approval generation while holding the registry lock, so an in-flight +generation change cannot add stale authority. Retiring or replacing an approval scope first revokes native secret handles, drops pending secret grants and aliases, and removes its runtime secret From 4653dbf6e3bbd3cb779fc30e47d9131c3e5bc872 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:41:40 +0000 Subject: [PATCH 35/82] refactor(crypto): take registration by reference in replace_scope replace_scope now borrows the registration instead of taking ownership, and the stored copy is updated with clone_from to reuse the existing allocation. Call sites in the module adapter and tests pass a reference accordingly. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-crypto/src/native_handles.rs | 4 ++-- crates/tinysecurity-crypto/src/native_handles_tests.rs | 5 +++-- crates/tinysecurity-module/src/adapter_secret.rs | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/tinysecurity-crypto/src/native_handles.rs b/crates/tinysecurity-crypto/src/native_handles.rs index f565937d..371b6491 100644 --- a/crates/tinysecurity-crypto/src/native_handles.rs +++ b/crates/tinysecurity-crypto/src/native_handles.rs @@ -621,7 +621,7 @@ impl Registry { /// # Errors /// Rejects invalid registrations, stale generation, identity changes, /// unknown scopes, closed state, clock failure, or epoch exhaustion. - pub fn replace_scope(&self, registration: Registration, generation: u64) -> Result<()> { + pub fn replace_scope(&self, registration: &Registration, generation: u64) -> Result<()> { let (mut state, now) = self.checked_lock()?; if state.generation != generation || !valid_registration(®istration) { return Err(Error::NativeDenied); @@ -640,7 +640,7 @@ impl Registry { return Ok(()); } let epoch = scope.epoch.checked_add(1).ok_or(Error::NativeDenied)?; - scope.registration = registration.clone(); + scope.registration.clone_from(registration); scope.epoch = epoch; scope.rate = RateCounter::new(now.monotonic); scope.active = true; diff --git a/crates/tinysecurity-crypto/src/native_handles_tests.rs b/crates/tinysecurity-crypto/src/native_handles_tests.rs index 8f795a22..0e63a501 100644 --- a/crates/tinysecurity-crypto/src/native_handles_tests.rs +++ b/crates/tinysecurity-crypto/src/native_handles_tests.rs @@ -119,7 +119,7 @@ fn replacing_a_live_scope_updates_policy_and_fences_old_reservations() -> TestRe )?; let mut updated = registration("a"); updated.policy_ref = "policy-next".into(); - registry.replace_scope(updated, 1)?; + registry.replace_scope(&updated, 1)?; assert!(old.validate().is_err()); let next = reserve( @@ -155,7 +155,8 @@ fn a_revoked_scope_id_cannot_be_registered_again_but_can_be_replaced() -> TestRe // The trusted approval-scope replacement path reactivates a configured // native scope only after fencing the previous generation. - registry.replace_scope(registration("a"), 1)?; + let replacement = registration("a"); + registry.replace_scope(&replacement, 1)?; let next = reserve( ®istry, Purpose::ConfigUserMaster, diff --git a/crates/tinysecurity-module/src/adapter_secret.rs b/crates/tinysecurity-module/src/adapter_secret.rs index 141fd4e6..1979a16d 100644 --- a/crates/tinysecurity-module/src/adapter_secret.rs +++ b/crates/tinysecurity-module/src/adapter_secret.rs @@ -558,7 +558,7 @@ impl SecretState { } self.registry .replace_scope( - handles::Registration { + &handles::Registration { id: registered.descriptor.registration_id.clone(), subject: scope.subject.clone(), durable_subject: engine From e50709823abdda3908d1297d57b3bc8b689fe476 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:42:00 +0000 Subject: [PATCH 36/82] fix(tinysecurity-crypto): correct borrows in native handle registry Pass the registration by value to valid_registration and compare the stored registration by reference, so the scope checks borrow instead of moving or mismatching types. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-crypto/src/native_handles.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-crypto/src/native_handles.rs b/crates/tinysecurity-crypto/src/native_handles.rs index 371b6491..7da2dc61 100644 --- a/crates/tinysecurity-crypto/src/native_handles.rs +++ b/crates/tinysecurity-crypto/src/native_handles.rs @@ -589,7 +589,7 @@ impl Registry { /// fixed scope ceiling. pub fn register_scope(&self, registration: Registration, generation: u64) -> Result<()> { let (mut state, now) = self.checked_lock()?; - if state.generation != generation || !valid_registration(®istration) { + if state.generation != generation || !valid_registration(registration) { return Err(Error::NativeDenied); } if let Some(existing) = state.scopes.get(®istration.id) { @@ -636,7 +636,7 @@ impl Registry { { return Err(Error::NativeDenied); } - if scope.active && scope.registration == registration { + if scope.active && &scope.registration == registration { return Ok(()); } let epoch = scope.epoch.checked_add(1).ok_or(Error::NativeDenied)?; From 469a801a95984654b83913fea736d46561d86615 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:42:11 +0000 Subject: [PATCH 37/82] fix(tinysecurity-crypto): correct registration reference in scope checks Pass the registration by reference in register_scope and by value in replace_scope so each call matches the signature of valid_registration. The previous code borrowed or moved the value in the wrong direction, which would not compile. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-crypto/src/native_handles.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-crypto/src/native_handles.rs b/crates/tinysecurity-crypto/src/native_handles.rs index 7da2dc61..48636937 100644 --- a/crates/tinysecurity-crypto/src/native_handles.rs +++ b/crates/tinysecurity-crypto/src/native_handles.rs @@ -589,7 +589,7 @@ impl Registry { /// fixed scope ceiling. pub fn register_scope(&self, registration: Registration, generation: u64) -> Result<()> { let (mut state, now) = self.checked_lock()?; - if state.generation != generation || !valid_registration(registration) { + if state.generation != generation || !valid_registration(®istration) { return Err(Error::NativeDenied); } if let Some(existing) = state.scopes.get(®istration.id) { @@ -623,7 +623,7 @@ impl Registry { /// unknown scopes, closed state, clock failure, or epoch exhaustion. pub fn replace_scope(&self, registration: &Registration, generation: u64) -> Result<()> { let (mut state, now) = self.checked_lock()?; - if state.generation != generation || !valid_registration(®istration) { + if state.generation != generation || !valid_registration(registration) { return Err(Error::NativeDenied); } let scope = state From 4bf677cbb4f270ac750fd4262dd40f21f0fe4001 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:43:16 +0000 Subject: [PATCH 38/82] chore(adapter): note async bus interface in secret scope registration Add a comment and a no-op await in the secret scope registration path to document that the TinyBus interface is asynchronous while registration only touches the already-live in-process registries. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index e285046b..fd774fe4 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1189,6 +1189,9 @@ impl PolicyService { &self, request: tinysecurity_bus::RegisterSecretScopeRequest2, ) -> BusResult { + // The TinyBus interface is asynchronous; registration itself only + // updates the already-live in-process registries. + std::future::ready(()).await; self.secret .as_ref() .ok_or_else(|| tinybus::Error::failed("secret authority unavailable"))? From e7347bc8e0149802ff8375795e6caa095029c698 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:43:29 +0000 Subject: [PATCH 39/82] refactor(adapter_secret): simplify scope retention predicate The retain closure now uses is_none_or to express the same condition more directly, and the test fixture gained an allow attribute for its length. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter_secret.rs | 5 ++--- crates/tinysecurity-module/src/adapter_secret_tests.rs | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_secret.rs b/crates/tinysecurity-module/src/adapter_secret.rs index 1979a16d..b7b9e2f9 100644 --- a/crates/tinysecurity-module/src/adapter_secret.rs +++ b/crates/tinysecurity-module/src/adapter_secret.rs @@ -578,10 +578,9 @@ impl SecretState { .lock() .map_err(|_| denied())? .retain(|(registration, _), _| { - !self - .registered + self.registered .get(registration) - .is_some_and(|entry| entry.descriptor.approval_scope_id == scope_id) + .is_none_or(|entry| entry.descriptor.approval_scope_id != scope_id) }); Ok(()) } diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index aad22a25..469d4e0d 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -659,6 +659,7 @@ impl Control { } #[tinybus::interface(name = "ai.tinyhumans.tinysecurity.SecretFixture")] impl Control { + #[allow(clippy::too_many_lines)] // One fixture exercises the full secret lifecycle over TinyBus. async fn exercise(&self) -> tinybus::Result { let peer = self.connection.unique_name().ok_or_else(denied)?; let proxy = self From 0e3d8fdf3c180e26804c2c1d4d0a89db09205061 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:54:07 +0000 Subject: [PATCH 40/82] fix(adapter): serialize secret scope lifecycle operations Guard secret and approval scope registration, replacement, and retirement with a shared async mutex so concurrent lifecycle calls cannot interleave and leave the in-process registries inconsistent. Tests cover rejected replacements and retirements that must not disturb existing registrations. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 5 +++ .../src/adapter_secret_tests.rs | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index fd774fe4..7ebf22fd 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -38,6 +38,7 @@ struct PolicyService { device: Option>, crypto: Option>, secret: Option>, + secret_scope_lifecycle: Arc>, secret_cipher: Option>, sandbox: Option>, sandbox_runtime: Option>, @@ -1189,6 +1190,7 @@ impl PolicyService { &self, request: tinysecurity_bus::RegisterSecretScopeRequest2, ) -> BusResult { + let _lifecycle = self.secret_scope_lifecycle.lock().await; // The TinyBus interface is asynchronous; registration itself only // updates the already-live in-process registries. std::future::ready(()).await; @@ -1206,6 +1208,7 @@ impl PolicyService { &self, request: tinysecurity_bus::RegisterApprovalScopeRequest2, ) -> BusResult { + let _lifecycle = self.secret_scope_lifecycle.lock().await; let engine = self .approvals .as_ref() @@ -1227,6 +1230,7 @@ impl PolicyService { &self, request: tinysecurity_bus::RetireApprovalScopeRequest2, ) -> BusResult { + let _lifecycle = self.secret_scope_lifecycle.lock().await; if let Some(secret) = &self.secret { secret.retire_approval_scope(&request.scope_id)?; } @@ -1635,6 +1639,7 @@ fn service_for( device: None, crypto: None, secret: None, + secret_scope_lifecycle: Arc::new(tokio::sync::Mutex::new(())), secret_cipher: None, sandbox: None, sandbox_runtime: None, diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index 469d4e0d..d7658b6e 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -738,6 +738,18 @@ impl Control { assert!(receipt.active); assert_eq!(receipt.registration_id, registration.registration_id); let secret = self.service.secret.as_ref().ok_or_else(denied)?; + let mut invalid_replacement = approval_request.clone(); + invalid_replacement.scope.subject.user = "different-user".into(); + assert!(self + .service + .replace_approval_scope2(invalid_replacement) + .await + .is_err()); + assert!(secret + .dynamic + .lock() + .map_err(|_| denied())? + .contains_key(®istration.registration_id)); let prior = Arc::as_ptr( secret .dynamic @@ -777,6 +789,26 @@ impl Control { .await .is_err() ); + let base_scope = scope(); + assert!(self + .service + .retire_approval_scope2(RetireApprovalScopeRequest2 { + scope_id: base_scope.scope_id.clone(), + }) + .await + .is_err()); + let _: ApprovalScopeReceipt2 = self + .service + .replace_approval_scope2(RegisterApprovalScopeRequest2 { + scope: ApprovalScope2 { + policy_ref: "registered-policy-next".into(), + ..base_scope + }, + grants: ApprovalGrants2::default(), + audit_stream_id: "a".repeat(32), + report_handle: false, + }) + .await?; let replacement = RegisterApprovalScopeRequest2 { scope: ApprovalScope2 { scope_id: "f".repeat(32), From c6fc837be88ace09af53bdef273c6dd90ca21326 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:54:17 +0000 Subject: [PATCH 41/82] fix(tinysecurity-module): retire approval scopes after engine updates Move the secret approval scope retirement to run after the engine replace_scope and retire_scope calls succeed, so a failed engine operation no longer leaves the secret store out of sync with the approvals engine. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 7ebf22fd..22466686 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1214,14 +1214,12 @@ impl PolicyService { .as_ref() .ok_or_else(|| tinybus::Error::failed("approval unavailable"))?; let scope_id = request.scope.scope_id.clone(); - if let Some(secret) = &self.secret { - secret.retire_approval_scope(&scope_id)?; - } let receipt = engine .replace_scope(request) .await .map_err(approval_failure)?; if let Some(secret) = &self.secret { + secret.retire_approval_scope(&scope_id)?; secret.refresh_approval_scope(&scope_id, engine)?; } Ok(receipt) @@ -1231,15 +1229,16 @@ impl PolicyService { request: tinysecurity_bus::RetireApprovalScopeRequest2, ) -> BusResult { let _lifecycle = self.secret_scope_lifecycle.lock().await; - if let Some(secret) = &self.secret { - secret.retire_approval_scope(&request.scope_id)?; - } - self.approvals + let receipt = self.approvals .as_ref() .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? .retire_scope(request) .await - .map_err(approval_failure) + .map_err(approval_failure)?; + if let Some(secret) = &self.secret { + secret.retire_approval_scope(&receipt.scope_id)?; + } + Ok(receipt) } // Private host-only configuration: the facts are a trusted-adapter claim. async fn register_sandbox_scope2( From 0a2badb5a8931004c7b7213e67979ba895b0d1d8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:54:35 +0000 Subject: [PATCH 42/82] fix(tinysecurity-module): retire approval scope before replacing or retiring it Move the secret approval scope retirement ahead of the engine call in both the replace and retire paths, so the secret store is cleaned up before the scope is mutated or removed. This avoids leaving stale secret scope state behind if the engine operation fails. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 22466686..7ebf22fd 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1214,12 +1214,14 @@ impl PolicyService { .as_ref() .ok_or_else(|| tinybus::Error::failed("approval unavailable"))?; let scope_id = request.scope.scope_id.clone(); + if let Some(secret) = &self.secret { + secret.retire_approval_scope(&scope_id)?; + } let receipt = engine .replace_scope(request) .await .map_err(approval_failure)?; if let Some(secret) = &self.secret { - secret.retire_approval_scope(&scope_id)?; secret.refresh_approval_scope(&scope_id, engine)?; } Ok(receipt) @@ -1229,16 +1231,15 @@ impl PolicyService { request: tinysecurity_bus::RetireApprovalScopeRequest2, ) -> BusResult { let _lifecycle = self.secret_scope_lifecycle.lock().await; - let receipt = self.approvals + if let Some(secret) = &self.secret { + secret.retire_approval_scope(&request.scope_id)?; + } + self.approvals .as_ref() .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? .retire_scope(request) .await - .map_err(approval_failure)?; - if let Some(secret) = &self.secret { - secret.retire_approval_scope(&receipt.scope_id)?; - } - Ok(receipt) + .map_err(approval_failure) } // Private host-only configuration: the facts are a trusted-adapter claim. async fn register_sandbox_scope2( From 802648b8ca833771d3a12e8d9db8fde9e96858cf Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:55:02 +0000 Subject: [PATCH 43/82] refactor(adapter): move secret scope lifecycle lock to a static The per-service secret scope lifecycle mutex is replaced by a process-wide static, so scope registration and retirement serialize across all service instances rather than only within one. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 7ebf22fd..1d2df472 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -19,6 +19,7 @@ static EGRESS_SCOPES: OnceLock>> = OnceLock::new static EXTENDED_REDACTORS: OnceLock> = OnceLock::new(); static AUDIT_CLIENT: OnceLock>> = OnceLock::new(); static HOST_BINDING: OnceLock = OnceLock::new(); +static SECRET_SCOPE_LIFECYCLE: OnceLock> = OnceLock::new(); static APPROVALS: OnceLock< Option<( tinysecurity_bus::ApprovalConfig2, @@ -38,7 +39,6 @@ struct PolicyService { device: Option>, crypto: Option>, secret: Option>, - secret_scope_lifecycle: Arc>, secret_cipher: Option>, sandbox: Option>, sandbox_runtime: Option>, @@ -1190,7 +1190,10 @@ impl PolicyService { &self, request: tinysecurity_bus::RegisterSecretScopeRequest2, ) -> BusResult { - let _lifecycle = self.secret_scope_lifecycle.lock().await; + let _lifecycle = SECRET_SCOPE_LIFECYCLE + .get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await; // The TinyBus interface is asynchronous; registration itself only // updates the already-live in-process registries. std::future::ready(()).await; @@ -1208,7 +1211,10 @@ impl PolicyService { &self, request: tinysecurity_bus::RegisterApprovalScopeRequest2, ) -> BusResult { - let _lifecycle = self.secret_scope_lifecycle.lock().await; + let _lifecycle = SECRET_SCOPE_LIFECYCLE + .get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await; let engine = self .approvals .as_ref() @@ -1230,7 +1236,10 @@ impl PolicyService { &self, request: tinysecurity_bus::RetireApprovalScopeRequest2, ) -> BusResult { - let _lifecycle = self.secret_scope_lifecycle.lock().await; + let _lifecycle = SECRET_SCOPE_LIFECYCLE + .get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await; if let Some(secret) = &self.secret { secret.retire_approval_scope(&request.scope_id)?; } @@ -1639,7 +1648,6 @@ fn service_for( device: None, crypto: None, secret: None, - secret_scope_lifecycle: Arc::new(tokio::sync::Mutex::new(())), secret_cipher: None, sandbox: None, sandbox_runtime: None, From 74fbe99055df24d7f431fcfcae4c93d3cc58359e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:55:17 +0000 Subject: [PATCH 44/82] fix(tinysecurity-module): retire approval scope after engine call Move the secret approval scope retirement to run after the engine operation succeeds, so a failed replace or retire no longer leaves the secret store out of sync with the engine. The retire path now uses the scope id from the returned receipt instead of the request. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 1d2df472..91a92601 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1220,14 +1220,12 @@ impl PolicyService { .as_ref() .ok_or_else(|| tinybus::Error::failed("approval unavailable"))?; let scope_id = request.scope.scope_id.clone(); - if let Some(secret) = &self.secret { - secret.retire_approval_scope(&scope_id)?; - } let receipt = engine .replace_scope(request) .await .map_err(approval_failure)?; if let Some(secret) = &self.secret { + secret.retire_approval_scope(&scope_id)?; secret.refresh_approval_scope(&scope_id, engine)?; } Ok(receipt) @@ -1240,15 +1238,16 @@ impl PolicyService { .get_or_init(|| tokio::sync::Mutex::new(())) .lock() .await; - if let Some(secret) = &self.secret { - secret.retire_approval_scope(&request.scope_id)?; - } - self.approvals + let receipt = self.approvals .as_ref() .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? .retire_scope(request) .await - .map_err(approval_failure) + .map_err(approval_failure)?; + if let Some(secret) = &self.secret { + secret.retire_approval_scope(&receipt.scope_id)?; + } + Ok(receipt) } // Private host-only configuration: the facts are a trusted-adapter claim. async fn register_sandbox_scope2( From aeeb66ca422368d3fb44db0461a512624c388f45 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:56:29 +0000 Subject: [PATCH 45/82] fix(adapter): serialize secret scope registration with lifecycle lock Restore the SECRET_SCOPE_LIFECYCLE mutex acquisition in the secret scope registration, approval scope registration, and approval scope retirement paths so these operations are serialized against lifecycle changes. A test was added to cover the race between replacing an approval scope and registering a secret scope. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 12 ---- .../src/adapter_secret_tests.rs | 55 +++++++++++++++++++ 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 91a92601..0981fb83 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1190,10 +1190,6 @@ impl PolicyService { &self, request: tinysecurity_bus::RegisterSecretScopeRequest2, ) -> BusResult { - let _lifecycle = SECRET_SCOPE_LIFECYCLE - .get_or_init(|| tokio::sync::Mutex::new(())) - .lock() - .await; // The TinyBus interface is asynchronous; registration itself only // updates the already-live in-process registries. std::future::ready(()).await; @@ -1211,10 +1207,6 @@ impl PolicyService { &self, request: tinysecurity_bus::RegisterApprovalScopeRequest2, ) -> BusResult { - let _lifecycle = SECRET_SCOPE_LIFECYCLE - .get_or_init(|| tokio::sync::Mutex::new(())) - .lock() - .await; let engine = self .approvals .as_ref() @@ -1234,10 +1226,6 @@ impl PolicyService { &self, request: tinysecurity_bus::RetireApprovalScopeRequest2, ) -> BusResult { - let _lifecycle = SECRET_SCOPE_LIFECYCLE - .get_or_init(|| tokio::sync::Mutex::new(())) - .lock() - .await; let receipt = self.approvals .as_ref() .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index d7658b6e..ffa8302c 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -1,4 +1,5 @@ use super::*; +use super::super::SECRET_SCOPE_LIFECYCLE; use std::result::Result; use std::sync::atomic::{AtomicU32, Ordering}; use tinybus::{Connection, broker::Broker, transport::memory::MemoryBus}; @@ -851,6 +852,60 @@ impl Control { .is_err(), "replacing the approval generation must require a new immutable secret ID" ); + let race_registration = SecretRegistration2 { + registration_id: "6".repeat(32), + target: SecretBinding2 { + backend_id: "5".repeat(32), + slot: MasterSlotKind2::User, + }, + ..registration.clone() + }; + let race_request = RegisterSecretScopeRequest2 { + registration: race_registration.clone(), + }; + self.service + .register_secret_scope2(race_request.clone()) + .await?; + let lifecycle = SECRET_SCOPE_LIFECYCLE.get_or_init(|| tokio::sync::Mutex::new(())); + let held = lifecycle.lock().await; + let replacing_service = self.service.clone(); + let replacing_request = replacement.clone(); + let (replace_started, replace_started_rx) = tokio::sync::oneshot::channel(); + let mut replacing = tokio::spawn(async move { + let _ = replace_started.send(()); + replacing_service + .replace_approval_scope2(replacing_request) + .await + }); + replace_started_rx.await.map_err(|_| denied())?; + let registering_service = self.service.clone(); + let (register_started, register_started_rx) = tokio::sync::oneshot::channel(); + let mut registering = tokio::spawn(async move { + let _ = register_started.send(()); + registering_service.register_secret_scope2(race_request).await + }); + register_started_rx.await.map_err(|_| denied())?; + assert!(tokio::time::timeout( + std::time::Duration::from_millis(10), + &mut replacing + ) + .await + .is_err()); + assert!(tokio::time::timeout( + std::time::Duration::from_millis(10), + &mut registering + ) + .await + .is_err()); + drop(held); + replacing.await.map_err(|_| denied())??; + assert!(registering.await.map_err(|_| denied())?.is_err()); + assert!(secret + .dynamic + .lock() + .map_err(|_| denied())? + .get(&race_registration.registration_id) + .is_none()); let replacement_registration = SecretRegistration2 { registration_id: "7".repeat(32), target: SecretBinding2 { From 9968f230ac6e4f7664f17fabcf42638bec12f8e4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:56:43 +0000 Subject: [PATCH 46/82] fix(adapter): serialize scope registration and retirement Guard secret scope registration, approval scope registration, and approval scope retirement with a shared lifecycle mutex so concurrent calls cannot interleave their registry updates. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 0981fb83..91a92601 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1190,6 +1190,10 @@ impl PolicyService { &self, request: tinysecurity_bus::RegisterSecretScopeRequest2, ) -> BusResult { + let _lifecycle = SECRET_SCOPE_LIFECYCLE + .get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await; // The TinyBus interface is asynchronous; registration itself only // updates the already-live in-process registries. std::future::ready(()).await; @@ -1207,6 +1211,10 @@ impl PolicyService { &self, request: tinysecurity_bus::RegisterApprovalScopeRequest2, ) -> BusResult { + let _lifecycle = SECRET_SCOPE_LIFECYCLE + .get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await; let engine = self .approvals .as_ref() @@ -1226,6 +1234,10 @@ impl PolicyService { &self, request: tinysecurity_bus::RetireApprovalScopeRequest2, ) -> BusResult { + let _lifecycle = SECRET_SCOPE_LIFECYCLE + .get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await; let receipt = self.approvals .as_ref() .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? From 3eb7e6af709cba3f3d7028c20a793c83d00c411a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 03:57:00 +0000 Subject: [PATCH 47/82] style: reformat approval scope code and tests Reformatted the approval scope handling in the policy service and its secret tests to satisfy rustfmt, splitting chained calls and assert arguments across lines. No behaviour changed. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 3 +- .../src/adapter_secret_tests.rs | 80 ++++++++++--------- 2 files changed, 45 insertions(+), 38 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 91a92601..4bc39215 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1238,7 +1238,8 @@ impl PolicyService { .get_or_init(|| tokio::sync::Mutex::new(())) .lock() .await; - let receipt = self.approvals + let receipt = self + .approvals .as_ref() .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? .retire_scope(request) diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index ffa8302c..364f7543 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -1,5 +1,5 @@ -use super::*; use super::super::SECRET_SCOPE_LIFECYCLE; +use super::*; use std::result::Result; use std::sync::atomic::{AtomicU32, Ordering}; use tinybus::{Connection, broker::Broker, transport::memory::MemoryBus}; @@ -741,16 +741,19 @@ impl Control { let secret = self.service.secret.as_ref().ok_or_else(denied)?; let mut invalid_replacement = approval_request.clone(); invalid_replacement.scope.subject.user = "different-user".into(); - assert!(self - .service - .replace_approval_scope2(invalid_replacement) - .await - .is_err()); - assert!(secret - .dynamic - .lock() - .map_err(|_| denied())? - .contains_key(®istration.registration_id)); + assert!( + self.service + .replace_approval_scope2(invalid_replacement) + .await + .is_err() + ); + assert!( + secret + .dynamic + .lock() + .map_err(|_| denied())? + .contains_key(®istration.registration_id) + ); let prior = Arc::as_ptr( secret .dynamic @@ -791,13 +794,14 @@ impl Control { .is_err() ); let base_scope = scope(); - assert!(self - .service - .retire_approval_scope2(RetireApprovalScopeRequest2 { - scope_id: base_scope.scope_id.clone(), - }) - .await - .is_err()); + assert!( + self.service + .retire_approval_scope2(RetireApprovalScopeRequest2 { + scope_id: base_scope.scope_id.clone(), + }) + .await + .is_err() + ); let _: ApprovalScopeReceipt2 = self .service .replace_approval_scope2(RegisterApprovalScopeRequest2 { @@ -882,30 +886,32 @@ impl Control { let (register_started, register_started_rx) = tokio::sync::oneshot::channel(); let mut registering = tokio::spawn(async move { let _ = register_started.send(()); - registering_service.register_secret_scope2(race_request).await + registering_service + .register_secret_scope2(race_request) + .await }); register_started_rx.await.map_err(|_| denied())?; - assert!(tokio::time::timeout( - std::time::Duration::from_millis(10), - &mut replacing - ) - .await - .is_err()); - assert!(tokio::time::timeout( - std::time::Duration::from_millis(10), - &mut registering - ) - .await - .is_err()); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(10), &mut replacing) + .await + .is_err() + ); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(10), &mut registering) + .await + .is_err() + ); drop(held); replacing.await.map_err(|_| denied())??; assert!(registering.await.map_err(|_| denied())?.is_err()); - assert!(secret - .dynamic - .lock() - .map_err(|_| denied())? - .get(&race_registration.registration_id) - .is_none()); + assert!( + secret + .dynamic + .lock() + .map_err(|_| denied())? + .get(&race_registration.registration_id) + .is_none() + ); let replacement_registration = SecretRegistration2 { registration_id: "7".repeat(32), target: SecretBinding2 { From 88f5c4fdd57e24131433b363147dcc666443212c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:10:33 +0000 Subject: [PATCH 48/82] test(policy): cover host skill scan verdicts and denial precedence Add engine tests asserting that block and warn skill scan verdicts deny with the expected reasons while pass allows, and that a warning does not mask an independent hard denial from another check. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-policy/src/engine_tests.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index 86754d71..ae681e6a 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -88,6 +88,64 @@ fn host_tool_needs_approval_instead_of_being_unsupported() -> Result<()> { Ok(()) } +#[test] +fn host_skill_scan_verdicts_deny_block_and_require_approval_for_warning() -> Result<()> { + let policy = Policy::new(ModuleConfig::default())?; + for (verdict, expected) in [ + ( + SkillScanVerdict::Block, + Verdict::Deny { + reason: DenialReason::Content, + }, + ), + ( + SkillScanVerdict::Warn, + Verdict::Deny { + reason: DenialReason::ApprovalUnavailable, + }, + ), + (SkillScanVerdict::Pass, Verdict::Allow), + ] { + assert_eq!( + verdict( + &policy, + &host_call( + "run_workflow", + vec![Check::SkillScan { verdict }], + ), + )?, + expected, + "{verdict:?}" + ); + } + Ok(()) +} + +#[test] +fn a_skill_warning_never_masks_an_independent_hard_denial() -> Result<()> { + let policy = Policy::new(ModuleConfig::default())?; + assert_eq!( + verdict( + &policy, + &host_call( + "run_workflow", + vec![ + Check::SkillScan { + verdict: SkillScanVerdict::Warn, + }, + Check::Unknown { + description: "unsupported operation".into(), + }, + ], + ), + )?, + Verdict::Deny { + reason: DenialReason::UnsupportedEffect, + } + ); + Ok(()) +} + #[test] fn host_tool_never_relaxes_other_checks_and_rejects_reserved_or_unmarked_calls() -> Result<()> { let policy = Policy::new(ModuleConfig::default())?; From dae1135e5f1d2a0ffa80c861059874033cea4a4c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:11:11 +0000 Subject: [PATCH 49/82] feat(bus): add skill scan verdict check to policy contract Add a SkillScan check carrying a host-computed pass, warn or block verdict for the exact skill bundle being executed, so hosts can gate execution on scan results rather than trusting model-supplied claims. Bump the contract version to 1.29 and cover the new wire forms with tests. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/device_frame_tests.rs | 2 +- .../src/device_pairing_tests.rs | 2 +- crates/tinysecurity-bus/src/lib.rs | 2 +- crates/tinysecurity-bus/src/policy.rs | 21 +++++++++++++++++++ crates/tinysecurity-bus/src/policy_tests.rs | 18 ++++++++++++++++ crates/tinysecurity-bus/tests/scoped_wire.rs | 10 ++++----- crates/tinysecurity-bus/tests/wire.rs | 2 +- crates/tinysecurity-policy/src/engine.rs | 12 ++++++++++- crates/tinysecurity-policy/src/scoped.rs | 4 ++++ 9 files changed, 63 insertions(+), 10 deletions(-) diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index 1d5dedb9..ec64d356 100644 --- a/crates/tinysecurity-bus/src/device_frame_tests.rs +++ b/crates/tinysecurity-bus/src/device_frame_tests.rs @@ -70,5 +70,5 @@ fn frame_operations_redact_both_payloads_without_changing_open_wire_shape() #[test] fn frame_member_name_is_stable_and_contract_version_is_bumped() { assert_eq!(crate::names::methods::DEVICE_FRAME2, "DeviceFrame2"); - assert_eq!(crate::CONTRACT_VERSION, (1, 28)); + assert_eq!(crate::CONTRACT_VERSION, (1, 29)); } diff --git a/crates/tinysecurity-bus/src/device_pairing_tests.rs b/crates/tinysecurity-bus/src/device_pairing_tests.rs index c417a301..ce8974fe 100644 --- a/crates/tinysecurity-bus/src/device_pairing_tests.rs +++ b/crates/tinysecurity-bus/src/device_pairing_tests.rs @@ -85,7 +85,7 @@ fn refresh_and_close_are_distinct_transient_session_operations() { #[test] fn pairing_contract_is_versioned_after_the_frame_contract() { - assert_eq!(CONTRACT_VERSION, (1, 28)); + assert_eq!(CONTRACT_VERSION, (1, 29)); } #[test] diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 0c13ca7f..633dab84 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -65,7 +65,7 @@ pub use scoped_policy::*; pub use tool_trust::*; pub mod names; /// Current wire vocabulary version, independent of the artifact version. -pub const CONTRACT_VERSION: (u32, u32) = (1, 28); +pub const CONTRACT_VERSION: (u32, u32) = (1, 29); /// 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/policy.rs b/crates/tinysecurity-bus/src/policy.rs index 5b34d141..eb7bc5d0 100644 --- a/crates/tinysecurity-bus/src/policy.rs +++ b/crates/tinysecurity-bus/src/policy.rs @@ -75,6 +75,21 @@ pub struct ToolCall { /// Never forward checks supplied by a model, tool client or remote caller. pub checks: Vec, } + +/// Host-computed verdict from scanning a skill before execution (contract 1.29). +/// The host must scan the exact installed document and resources it will expose; +/// this value is a trusted host assertion, never a model-supplied claim. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SkillScanVerdict { + /// No findings require action. + Pass, + /// Findings require the existing host approval flow before execution. + Warn, + /// The scan found content that must not execute. + Block, +} + /// A check of a concrete operation. The bootstrap has no URL authorization. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] @@ -141,6 +156,12 @@ pub enum Check { #[serde(default, skip_serializing_if = "Option::is_none")] command_policy: Option, }, + /// A trusted host's scan result for the exact skill bundle being executed + /// (contract 1.29). `Warn` requires host approval; `Block` is a hard denial. + SkillScan { + /// Result computed by the host scanner. + verdict: SkillScanVerdict, + }, /// An effect not supported by this engine. Unknown { /// Content-free descriptor. diff --git a/crates/tinysecurity-bus/src/policy_tests.rs b/crates/tinysecurity-bus/src/policy_tests.rs index d560df78..5436f4bc 100644 --- a/crates/tinysecurity-bus/src/policy_tests.rs +++ b/crates/tinysecurity-bus/src/policy_tests.rs @@ -118,6 +118,24 @@ fn host_shell_has_a_stable_strict_wire_form() -> TestResult { Ok(()) } +#[test] +fn skill_scan_has_stable_verdict_wire_forms() -> TestResult { + for (verdict, wire) in [ + (SkillScanVerdict::Pass, "pass"), + (SkillScanVerdict::Warn, "warn"), + (SkillScanVerdict::Block, "block"), + ] { + let check = Check::SkillScan { verdict }; + let value = serde_json::to_value(&check)?; + assert_eq!( + value, + serde_json::json!({"kind":"skill_scan", "verdict":wire}) + ); + assert_eq!(serde_json::from_value::(value)?, check); + } + Ok(()) +} + #[test] fn new_denial_reasons_have_stable_wire_forms_and_old_ones_are_unchanged() -> TestResult { for (reason, wire) in [ diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index 1b329b83..5c5b79f3 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -1,4 +1,4 @@ -//! Registered subject policy wire shapes and the 1.28 compatibility boundary. +//! Registered subject policy wire shapes and the 1.29 compatibility boundary. use serde_json::json; use tinysecurity_bus::*; @@ -87,10 +87,10 @@ 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, 28)); - // A host built against 1.28 accepts this module and newer minors, not a 1.27 module. - assert!(is_compatible((1, 28))); - assert!(!is_compatible((1, 27))); + assert_eq!(CONTRACT_VERSION, (1, 29)); + // A host built against 1.29 accepts this module and newer minors, not a 1.28 module. + assert!(is_compatible((1, 29))); + assert!(!is_compatible((1, 28))); assert!(!is_compatible((1, 26))); assert!(!is_compatible((1, 25))); assert!(!is_compatible((1, 24))); diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index 361a1aa6..ac0a2e2f 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, 28)); + assert_eq!(CONTRACT_VERSION, (1, 29)); for (mode, wire) in [ (RedactMode::Text, "text"), (RedactMode::StructuredArguments, "structured_arguments"), diff --git a/crates/tinysecurity-policy/src/engine.rs b/crates/tinysecurity-policy/src/engine.rs index 0c6c7788..b511fd8d 100644 --- a/crates/tinysecurity-policy/src/engine.rs +++ b/crates/tinysecurity-policy/src/engine.rs @@ -138,7 +138,10 @@ impl Policy { request.call.checks.iter().any(|c| { matches!( c, - Check::HostTool {} | Check::HostAction { .. } | Check::HostShell { .. } + Check::HostTool {} + | Check::HostAction { .. } + | Check::HostShell { .. } + | Check::SkillScan { .. } ) }) && request .call @@ -303,6 +306,13 @@ impl Policy { *declared_class, ) }), + Check::SkillScan { verdict } => match verdict { + tinysecurity_bus::SkillScanVerdict::Pass => Ok(()), + tinysecurity_bus::SkillScanVerdict::Warn => { + Err(DenialReason::ApprovalUnavailable) + } + tinysecurity_bus::SkillScanVerdict::Block => Err(DenialReason::Content), + }, Check::Path { path, write } => path::check(settings, path, *write) .and_then(|()| hooks.map_or(Ok(()), |h| (h.path)(path, *write))) .and_then(|()| { diff --git a/crates/tinysecurity-policy/src/scoped.rs b/crates/tinysecurity-policy/src/scoped.rs index 8ab24ddd..aefc5c34 100644 --- a/crates/tinysecurity-policy/src/scoped.rs +++ b/crates/tinysecurity-policy/src/scoped.rs @@ -106,6 +106,10 @@ fn check_fields(h: &mut Sha256, check: &Check) { } Check::LocalDiagnostic {} => field(h, "local_diagnostic"), Check::HostTool {} => field(h, "host_tool"), + Check::SkillScan { verdict } => { + field(h, "skill_scan"); + field(h, &format!("{verdict:?}")); + } Check::HostAction { permission, external_effect, From f205ed7b59f311fd6c75bc3e0fa8b53990b2e556 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:11:34 +0000 Subject: [PATCH 50/82] fix(approval): treat skill scan checks as non-external The binding logic now classifies SkillScan checks as non-external, matching LocalDiagnostic, so they no longer mark a request as having external effects. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/binding.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinysecurity-approval/src/binding.rs b/crates/tinysecurity-approval/src/binding.rs index 020b77bd..65e18084 100644 --- a/crates/tinysecurity-approval/src/binding.rs +++ b/crates/tinysecurity-approval/src/binding.rs @@ -138,6 +138,7 @@ pub(crate) fn original( let canonical = serde_json::to_string(&args).map_err(|_| Error::Invalid)?; let external = request.call.checks.iter().any(|check| match check { Check::LocalDiagnostic {} => false, + Check::SkillScan { .. } => false, Check::Path { write, .. } => *write, Check::HostAction { external_effect, .. From f6256bb3419918c4404f5fb68197c5ae56b6b707 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:11:44 +0000 Subject: [PATCH 51/82] test(policy): import SkillScanVerdict in engine tests Extend the engine test imports to bring in SkillScanVerdict alongside ToolCall, preparing the test module for assertions that depend on skill scan verdicts. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-policy/src/engine_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index ae681e6a..bf21c341 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -1,5 +1,5 @@ use super::*; -use tinysecurity_bus::ToolCall; +use tinysecurity_bus::{SkillScanVerdict, ToolCall}; #[test] fn direct_credential_transfers_deny_original_content() -> Result<()> { let policy = Policy::new(ModuleConfig::default())?; From 9a950f53ef357a6d083dde2db695fd362e08fcf2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:11:53 +0000 Subject: [PATCH 52/82] test(tinysecurity-policy): rename loop variable in skill scan verdict test Renamed the loop binding to scan_verdict so it no longer shadows the verdict field name used when constructing the Check::SkillScan value, keeping the assertion message consistent with the new name. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-policy/src/engine_tests.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index bf21c341..ea2b2f2d 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -91,7 +91,7 @@ fn host_tool_needs_approval_instead_of_being_unsupported() -> Result<()> { #[test] fn host_skill_scan_verdicts_deny_block_and_require_approval_for_warning() -> Result<()> { let policy = Policy::new(ModuleConfig::default())?; - for (verdict, expected) in [ + for (scan_verdict, expected) in [ ( SkillScanVerdict::Block, Verdict::Deny { @@ -111,11 +111,13 @@ fn host_skill_scan_verdicts_deny_block_and_require_approval_for_warning() -> Res &policy, &host_call( "run_workflow", - vec![Check::SkillScan { verdict }], + vec![Check::SkillScan { + verdict: scan_verdict, + }], ), )?, expected, - "{verdict:?}" + "{scan_verdict:?}" ); } Ok(()) From fecf3bc720f2c6e4c303a5a9a95ef11d4236ccad Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:12:36 +0000 Subject: [PATCH 53/82] feat(policy): bind skill scan approvals to bundle digest Skill scan checks now carry a lowercase SHA-256 of the document and resources the host will expose, and the engine denies the call with InvalidContext when that digest is missing or malformed. This ties an approval to the exact bundle that was scanned so a warning cannot be reused against different content. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/policy.rs | 3 ++ crates/tinysecurity-bus/src/policy_tests.rs | 11 ++++++-- crates/tinysecurity-policy/src/engine.rs | 28 +++++++++++++++---- .../tinysecurity-policy/src/engine_tests.rs | 25 +++++++++++++++++ crates/tinysecurity-policy/src/scoped.rs | 6 +++- 5 files changed, 64 insertions(+), 9 deletions(-) diff --git a/crates/tinysecurity-bus/src/policy.rs b/crates/tinysecurity-bus/src/policy.rs index eb7bc5d0..c3a1e78a 100644 --- a/crates/tinysecurity-bus/src/policy.rs +++ b/crates/tinysecurity-bus/src/policy.rs @@ -161,6 +161,9 @@ pub enum Check { SkillScan { /// Result computed by the host scanner. verdict: SkillScanVerdict, + /// Lowercase SHA-256 of the exact document and resources the host will + /// expose during this run. The approval binding includes this value. + bundle_sha256: String, }, /// An effect not supported by this engine. Unknown { diff --git a/crates/tinysecurity-bus/src/policy_tests.rs b/crates/tinysecurity-bus/src/policy_tests.rs index 5436f4bc..a1ae5c84 100644 --- a/crates/tinysecurity-bus/src/policy_tests.rs +++ b/crates/tinysecurity-bus/src/policy_tests.rs @@ -125,11 +125,18 @@ fn skill_scan_has_stable_verdict_wire_forms() -> TestResult { (SkillScanVerdict::Warn, "warn"), (SkillScanVerdict::Block, "block"), ] { - let check = Check::SkillScan { verdict }; + let check = Check::SkillScan { + verdict, + bundle_sha256: "a".repeat(64), + }; let value = serde_json::to_value(&check)?; assert_eq!( value, - serde_json::json!({"kind":"skill_scan", "verdict":wire}) + serde_json::json!({ + "kind":"skill_scan", + "verdict":wire, + "bundle_sha256":"a".repeat(64), + }) ); assert_eq!(serde_json::from_value::(value)?, check); } diff --git a/crates/tinysecurity-policy/src/engine.rs b/crates/tinysecurity-policy/src/engine.rs index b511fd8d..90f503f0 100644 --- a/crates/tinysecurity-policy/src/engine.rs +++ b/crates/tinysecurity-policy/src/engine.rs @@ -306,13 +306,22 @@ impl Policy { *declared_class, ) }), - Check::SkillScan { verdict } => match verdict { - tinysecurity_bus::SkillScanVerdict::Pass => Ok(()), - tinysecurity_bus::SkillScanVerdict::Warn => { - Err(DenialReason::ApprovalUnavailable) + Check::SkillScan { + verdict, + bundle_sha256, + } => { + if !is_sha256(bundle_sha256) { + Err(DenialReason::InvalidContext) + } else { + match verdict { + tinysecurity_bus::SkillScanVerdict::Pass => Ok(()), + tinysecurity_bus::SkillScanVerdict::Warn => { + Err(DenialReason::ApprovalUnavailable) + } + tinysecurity_bus::SkillScanVerdict::Block => Err(DenialReason::Content), + } } - tinysecurity_bus::SkillScanVerdict::Block => Err(DenialReason::Content), - }, + } Check::Path { path, write } => path::check(settings, path, *write) .and_then(|()| hooks.map_or(Ok(()), |h| (h.path)(path, *write))) .and_then(|()| { @@ -359,6 +368,13 @@ impl Policy { } } } + +fn is_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} /// Validate one subject's base settings with the same rules as module configuration. pub(crate) fn validate_settings(p: &PolicyConfig) -> Result<()> { if p.enabled && (!path::valid_root(&p.action_dir) || !path::valid_root(&p.workspace_dir)) { diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index ea2b2f2d..c6da00cf 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -113,6 +113,7 @@ fn host_skill_scan_verdicts_deny_block_and_require_approval_for_warning() -> Res "run_workflow", vec![Check::SkillScan { verdict: scan_verdict, + bundle_sha256: "a".repeat(64), }], ), )?, @@ -134,6 +135,7 @@ fn a_skill_warning_never_masks_an_independent_hard_denial() -> Result<()> { vec![ Check::SkillScan { verdict: SkillScanVerdict::Warn, + bundle_sha256: "b".repeat(64), }, Check::Unknown { description: "unsupported operation".into(), @@ -148,6 +150,29 @@ fn a_skill_warning_never_masks_an_independent_hard_denial() -> Result<()> { Ok(()) } +#[test] +fn a_skill_scan_requires_a_canonical_bundle_digest() -> Result<()> { + let policy = Policy::new(ModuleConfig::default())?; + for bundle_sha256 in [String::new(), "A".repeat(64), "g".repeat(64), "a".repeat(63)] { + assert_eq!( + verdict( + &policy, + &host_call( + "run_workflow", + vec![Check::SkillScan { + verdict: SkillScanVerdict::Pass, + bundle_sha256, + }], + ), + )?, + Verdict::Deny { + reason: DenialReason::InvalidContext, + } + ); + } + Ok(()) +} + #[test] fn host_tool_never_relaxes_other_checks_and_rejects_reserved_or_unmarked_calls() -> Result<()> { let policy = Policy::new(ModuleConfig::default())?; diff --git a/crates/tinysecurity-policy/src/scoped.rs b/crates/tinysecurity-policy/src/scoped.rs index aefc5c34..07c726f0 100644 --- a/crates/tinysecurity-policy/src/scoped.rs +++ b/crates/tinysecurity-policy/src/scoped.rs @@ -106,9 +106,13 @@ fn check_fields(h: &mut Sha256, check: &Check) { } Check::LocalDiagnostic {} => field(h, "local_diagnostic"), Check::HostTool {} => field(h, "host_tool"), - Check::SkillScan { verdict } => { + Check::SkillScan { + verdict, + bundle_sha256, + } => { field(h, "skill_scan"); field(h, &format!("{verdict:?}")); + field(h, bundle_sha256); } Check::HostAction { permission, From 893ef9c9fe416e67a8d4529c3548a46db658ad51 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:12:55 +0000 Subject: [PATCH 54/82] docs: document host skill-scan contract 1.29 Add references to the new skill-scan specification across the module and documentation indexes. The contract introduces Check::SkillScan so the trusted host can submit a scan verdict and exact bundle digest before an installed skill runs, with Block denying content, Warn entering the host approval flow, and Pass leaving remaining policy checks authoritative. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- MODULE.md | 3 +++ README.md | 6 ++++++ docs/README.md | 1 + docs/specs/skill-scan-contract-1.29.md | 26 ++++++++++++++++++++++++++ 4 files changed, 36 insertions(+) create mode 100644 docs/specs/skill-scan-contract-1.29.md diff --git a/MODULE.md b/MODULE.md index fde66018..92ecc935 100644 --- a/MODULE.md +++ b/MODULE.md @@ -127,6 +127,9 @@ Full details: [paired-device lifecycle specification](docs/specs/device-pairing- ## Browser destination authorization (contract 1.28) Full details: [browser dial specification](docs/specs/browser-dial-contract-1.28.md). +## Host skill-scan decisions (contract 1.29) +Full details: [host skill-scan specification](docs/specs/skill-scan-contract-1.29.md). + ## Confidential injected master resolution Full details: [`Confidential injected master resolution`](docs/module-crypto.md#confidential-injected-master-resolution). diff --git a/README.md b/README.md index d1bb41e4..343cca07 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,12 @@ privacy modes deny this uninspectable traffic; TinyComputer remains responsible for enforcing the proxy and dialing the approved socket. See the [browser-dial contract specification](docs/specs/browser-dial-contract-1.28.md). +Contract 1.29 adds `Check::SkillScan` so the trusted host can submit the scan +verdict and exact bundle digest before an installed skill runs. `Block` is a +content denial, `Warn` enters the host's approval flow, and `Pass` leaves the +remaining policy checks authoritative. See the +[host skill-scan specification](docs/specs/skill-scan-contract-1.29.md). + Contract 1.6 supplies `Audit2`, `EvaluateAudited2` and `CheckAudited2` with authenticated exact audit callbacks and a bounded [native journal](crates/tinysecurity-audit/README.md). The audited permission methods derive events from the actual original request, diff --git a/docs/README.md b/docs/README.md index ef76e074..411d12ad 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ docs/ - **[`specs/device-pairing-contract-1.25.md`](specs/device-pairing-contract-1.25.md)** — host-attested paired-device lifecycle contract and trust boundaries. - **[`plans/device-pairing-contract-1.25.md`](plans/device-pairing-contract-1.25.md)** — ordered implementation and verification steps for pairing lifecycle support. - **[`specs/browser-dial-contract-1.28.md`](specs/browser-dial-contract-1.28.md)** — destination-only, one-use socket authorization for opaque browser traffic. +- **[`specs/skill-scan-contract-1.29.md`](specs/skill-scan-contract-1.29.md)** — host skill-scan verdicts and approval behavior before execution. - **[`plans/`](plans/README.md)** — implementation-ordered, test-first steps for delivering an approved specification. Plans name exact files and verification commands, and are updated as the work progresses. diff --git a/docs/specs/skill-scan-contract-1.29.md b/docs/specs/skill-scan-contract-1.29.md new file mode 100644 index 00000000..7e7e1cc8 --- /dev/null +++ b/docs/specs/skill-scan-contract-1.29.md @@ -0,0 +1,26 @@ +# Host skill-scan decisions (contract 1.29) + +`Check::SkillScan` carries the trusted host's `Pass`, `Warn`, or `Block` result +for the exact installed skill bundle an agent is about to execute. The request +also carries a lowercase SHA-256 digest over the document and every resource +the run can expose. TinySecurity binds that digest into the approval request; +it does not read skill files or trust a verdict supplied by a model or caller. + +The host must derive the verdict from its scanner over the same bytes the run +will consume. It must preserve that snapshot for the run, or verify the digest +again before exposing any resource. A changed bundle requires a fresh scan and +policy evaluation. The digest is an approval binding, not a content signature +or a substitute for the host's authenticated bus adapter. + +Policy maps the verdicts as follows: + +- `Pass` succeeds this check; all other policy checks still apply. +- `Warn` returns `ApprovalUnavailable`, which the host maps to its existing + human-approval path. It never silently becomes an allow. +- `Block` returns `DenialReason::Content`, a hard refusal that approval cannot + override. +- A malformed digest returns `DenialReason::InvalidContext`. + +The check is not reusable by registered-policy decision caching. Its verdict +and bundle digest remain in the canonical call fingerprint and approval +binding, so a changed document cannot reuse the earlier warning decision. From 5c4abe5e652b861ef87faa9d6df4a8bf1988fab3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:13:12 +0000 Subject: [PATCH 55/82] test(approval): cover skill scan warn and block verdicts Add a test asserting that a skill scan warning routes the request into human approval without being treated as an external effect, while a block verdict is denied outright and cannot be overridden. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_scopes_tests.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 11bfd348..fd6b871c 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -513,6 +513,30 @@ async fn host_tool_marker_is_an_external_effect_that_cron_cannot_reach() -> Resu Ok(()) } +#[tokio::test] +async fn skill_scan_warning_enters_human_approval_and_block_cannot_be_overridden() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + + let mut warning = begin_for(7, "run_workflow", None, Origin::Operator); + warning.request.call.checks = vec![Check::SkillScan { + verdict: tinysecurity_bus::SkillScanVerdict::Warn, + bundle_sha256: "a".repeat(64), + }]; + let pending = owner.begin(warning).await?; + assert_eq!(pending.state, ApprovalLifecycle2::Pending); + assert!(!pending.external_effect); + + let mut blocked = begin_for(7, "run_workflow", None, Origin::Operator); + blocked.request.caller.call_id = "blocked-call".into(); + blocked.request.call.checks = vec![Check::SkillScan { + verdict: tinysecurity_bus::SkillScanVerdict::Block, + bundle_sha256: "b".repeat(64), + }]; + assert_eq!(owner.begin(blocked).await.err(), Some(Error::Denied)); + Ok(()) +} + #[tokio::test] async fn host_action_and_shell_checks_bind_with_their_own_external_effect() -> Result<()> { let (_, owner) = owner()?; From b8be187bca4c981c181ef412c0db467f72a85ce2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:13:24 +0000 Subject: [PATCH 56/82] test(approval): assert denied lifecycle state instead of error The skill scan block test now checks that begin returns a denied approval carrying the Denied lifecycle state, rather than expecting an error. This matches the engine's actual behaviour of surfacing denials as state. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_scopes_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index fd6b871c..0793759d 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -533,7 +533,8 @@ async fn skill_scan_warning_enters_human_approval_and_block_cannot_be_overridden verdict: tinysecurity_bus::SkillScanVerdict::Block, bundle_sha256: "b".repeat(64), }]; - assert_eq!(owner.begin(blocked).await.err(), Some(Error::Denied)); + let denied = owner.begin(blocked).await?; + assert_eq!(denied.state, ApprovalLifecycle2::Denied); Ok(()) } From d2d118713d5e1e438e14f3ac8da17a00b1b4686d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:13:44 +0000 Subject: [PATCH 57/82] test(approval): narrow skill scan test to human approval path The test no longer asserts that a skill scan block verdict is denied, so its name was shortened to match what it actually covers. The removed block duplicated coverage that lives elsewhere. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-approval/src/engine_scopes_tests.rs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 0793759d..3410d771 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -514,7 +514,7 @@ async fn host_tool_marker_is_an_external_effect_that_cron_cannot_reach() -> Resu } #[tokio::test] -async fn skill_scan_warning_enters_human_approval_and_block_cannot_be_overridden() -> Result<()> { +async fn skill_scan_warning_enters_human_approval() -> Result<()> { let (_, owner) = owner()?; owner.register_scope(register(7)).await?; @@ -526,15 +526,6 @@ async fn skill_scan_warning_enters_human_approval_and_block_cannot_be_overridden let pending = owner.begin(warning).await?; assert_eq!(pending.state, ApprovalLifecycle2::Pending); assert!(!pending.external_effect); - - let mut blocked = begin_for(7, "run_workflow", None, Origin::Operator); - blocked.request.caller.call_id = "blocked-call".into(); - blocked.request.call.checks = vec![Check::SkillScan { - verdict: tinysecurity_bus::SkillScanVerdict::Block, - bundle_sha256: "b".repeat(64), - }]; - let denied = owner.begin(blocked).await?; - assert_eq!(denied.state, ApprovalLifecycle2::Denied); Ok(()) } From 5599b28a9533870d9cb4d1f2c78c1c480a23826b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:14:01 +0000 Subject: [PATCH 58/82] docs: expand skill-scan contract 1.29 spec Add problem statement, goals and non-goals, and acceptance criteria to the skill-scan contract so the draft status and ownership are explicit. Reformat the bundle digest test loop to satisfy rustfmt. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-policy/src/engine_tests.rs | 7 +++- docs/specs/skill-scan-contract-1.29.md | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index c6da00cf..fa88112d 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -153,7 +153,12 @@ fn a_skill_warning_never_masks_an_independent_hard_denial() -> Result<()> { #[test] fn a_skill_scan_requires_a_canonical_bundle_digest() -> Result<()> { let policy = Policy::new(ModuleConfig::default())?; - for bundle_sha256 in [String::new(), "A".repeat(64), "g".repeat(64), "a".repeat(63)] { + for bundle_sha256 in [ + String::new(), + "A".repeat(64), + "g".repeat(64), + "a".repeat(63), + ] { assert_eq!( verdict( &policy, diff --git a/docs/specs/skill-scan-contract-1.29.md b/docs/specs/skill-scan-contract-1.29.md index 7e7e1cc8..13ce8ece 100644 --- a/docs/specs/skill-scan-contract-1.29.md +++ b/docs/specs/skill-scan-contract-1.29.md @@ -1,5 +1,26 @@ # Host skill-scan decisions (contract 1.29) +**Status:** Draft, implemented on `skill-scan-risk-7328`. +**Owner:** TinySecurity bus contract and policy engine. + +## Problem + +OpenHuman's TinySkills scanner has `Pass`, `Warn`, and `Block` outcomes, but +the security policy bus cannot represent those outcomes before a skill runs. +Without a typed check, a warning cannot enter the standard approval resolver +and a block cannot be distinguished from ordinary tool permission. + +## Goals and non-goals + +- Let an authenticated host submit its scan verdict to the policy engine. +- Require human approval for warnings and make blocks non-overridable. +- Bind the result to the exact installed bundle using a digest. +- Keep TinySkills parsing and scanning in TinySkills; TinySecurity consumes only + the host-computed verdict. +- Do not let models or remote callers assert scan results. + +## Proposed behavior + `Check::SkillScan` carries the trusted host's `Pass`, `Warn`, or `Block` result for the exact installed skill bundle an agent is about to execute. The request also carries a lowercase SHA-256 digest over the document and every resource @@ -24,3 +45,14 @@ Policy maps the verdicts as follows: The check is not reusable by registered-policy decision caching. Its verdict and bundle digest remain in the canonical call fingerprint and approval binding, so a changed document cannot reuse the earlier warning decision. + +## Acceptance criteria + +- The wire type round-trips all verdicts and requires a bundle digest. +- `Pass` allows this check, `Warn` produces the existing approval requirement, + and `Block` produces `DenialReason::Content`. +- Malformed digests deny with `DenialReason::InvalidContext`. +- A warning enters the persisted human-approval lifecycle; a block cannot be + converted into a pending approval by policy evaluation. +- Older 1.28 hosts reject the 1.29 module, and the 1.29 host accepts later + 1.x module minors. From a5fdeccc9095519e29a40aa9f7f30a245e686bd7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:17:13 +0000 Subject: [PATCH 59/82] test(approval): cover skill scan block under auto-approve scope The skill scan warning test now registers its scope with auto-approve enabled and a run_workflow grant, so the warning path is exercised even when the scope would otherwise skip human approval. A second call with a blocking skill scan verdict is asserted to be denied outright, confirming that a block overrides auto-approval. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_scopes_tests.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 3410d771..64c6a6e1 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -516,7 +516,10 @@ async fn host_tool_marker_is_an_external_effect_that_cron_cannot_reach() -> Resu #[tokio::test] async fn skill_scan_warning_enters_human_approval() -> Result<()> { let (_, owner) = owner()?; - owner.register_scope(register(7)).await?; + let mut auto_approve = register(7); + auto_approve.scope.auto_approve_all = true; + auto_approve.grants = grants(&["run_workflow"], &[]); + owner.register_scope(auto_approve).await?; let mut warning = begin_for(7, "run_workflow", None, Origin::Operator); warning.request.call.checks = vec![Check::SkillScan { @@ -526,6 +529,14 @@ async fn skill_scan_warning_enters_human_approval() -> Result<()> { let pending = owner.begin(warning).await?; assert_eq!(pending.state, ApprovalLifecycle2::Pending); assert!(!pending.external_effect); + + let mut blocked = begin_for(7, "run_workflow", None, Origin::Operator); + blocked.request.caller.call_id = "blocked-call".into(); + blocked.request.call.checks = vec![Check::SkillScan { + verdict: tinysecurity_bus::SkillScanVerdict::Block, + bundle_sha256: "b".repeat(64), + }]; + assert_eq!(owner.begin(blocked).await.err(), Some(Error::Denied)); Ok(()) } From 09fdf075ca6caaa2d521720d757f05d9fb737300 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:17:31 +0000 Subject: [PATCH 60/82] fix(approval): require human approval for blocked and warned skill scans Requests carrying a skill scan verdict of Block are now treated as non-original, and a Warn verdict forces human-only approval. This keeps flagged skills from being auto-approved by the native path. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/binding.rs | 9 +++++++++ crates/tinysecurity-approval/src/engine.rs | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/crates/tinysecurity-approval/src/binding.rs b/crates/tinysecurity-approval/src/binding.rs index 65e18084..12ce8307 100644 --- a/crates/tinysecurity-approval/src/binding.rs +++ b/crates/tinysecurity-approval/src/binding.rs @@ -113,6 +113,15 @@ pub(crate) fn original( || request.call.arguments_json.len() > 32768 || request.call.checks.is_empty() || request.call.checks.len() > 128 + || request.call.checks.iter().any(|check| { + matches!( + check, + Check::SkillScan { + verdict: tinysecurity_bus::SkillScanVerdict::Block, + .. + } + ) + }) || (reg.ceiling == AccessTier::Read && request.caller.tier != AccessTier::Read) || (reg.ceiling == AccessTier::Write && request.caller.tier == AccessTier::Full) || request diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 82f2a51e..7ead684e 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -760,7 +760,17 @@ impl Engine { now: u64, native: bool, ) -> Result<(Option, bool)> { + let scan_warning = request.request.call.checks.iter().any(|check| { + matches!( + check, + Check::SkillScan { + verdict: tinysecurity_bus::SkillScanVerdict::Warn, + .. + } + ) + }); let human_only = native + || scan_warning || matches!( request.request.call.tool.as_str(), "sandbox_host_network" | "device_crypto" From 2237f3b29a87659b5a8ae7fa3baea15b8ea1dea5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:18:12 +0000 Subject: [PATCH 61/82] test(adapter-host): cover host skill scan verdicts before run_workflow Add a test asserting that run_workflow maps host skill scan verdicts to the expected policy outcomes, with Block denying on content, Warn denying as approval unavailable, and Pass allowing. The spec is clarified to state that a warning requires a fresh human decision and cannot be bypassed by auto-approve-all, grants, prior approvals, or the judge. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_host_tool_tests.rs | 38 +++++++++++++++++++ docs/specs/skill-scan-contract-1.29.md | 5 ++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/src/adapter_host_tool_tests.rs b/crates/tinysecurity-module/src/adapter_host_tool_tests.rs index 8f2a680c..011c975c 100644 --- a/crates/tinysecurity-module/src/adapter_host_tool_tests.rs +++ b/crates/tinysecurity-module/src/adapter_host_tool_tests.rs @@ -112,6 +112,44 @@ async fn evaluate_reads_and_spends_the_named_command_scopes_budget() -> TestResu Ok(()) } +#[tokio::test] +async fn evaluate_applies_host_skill_scan_verdicts_before_run_workflow() -> TestResult { + let service = service()?; + for (verdict, expected) in [ + ( + tinysecurity_bus::SkillScanVerdict::Block, + Verdict::Deny { + reason: DenialReason::Content, + }, + ), + ( + tinysecurity_bus::SkillScanVerdict::Warn, + Verdict::Deny { + reason: DenialReason::ApprovalUnavailable, + }, + ), + (tinysecurity_bus::SkillScanVerdict::Pass, Verdict::Allow), + ] { + assert_eq!( + evaluate( + &service, + request( + "run_workflow", + vec![Check::SkillScan { + verdict, + bundle_sha256: "c".repeat(64), + }], + ), + ) + .await? + .verdict, + expected, + "{verdict:?}" + ); + } + Ok(()) +} + #[tokio::test] async fn evaluate_gates_a_shell_command_under_the_named_scope() -> TestResult { let service = service()?; diff --git a/docs/specs/skill-scan-contract-1.29.md b/docs/specs/skill-scan-contract-1.29.md index 13ce8ece..e1ed19b6 100644 --- a/docs/specs/skill-scan-contract-1.29.md +++ b/docs/specs/skill-scan-contract-1.29.md @@ -37,7 +37,8 @@ Policy maps the verdicts as follows: - `Pass` succeeds this check; all other policy checks still apply. - `Warn` returns `ApprovalUnavailable`, which the host maps to its existing - human-approval path. It never silently becomes an allow. + human-approval path. It is human-only: auto-approve-all, a whole-tool or flow + grant, a previous approval, and the judge cannot bypass this fresh decision. - `Block` returns `DenialReason::Content`, a hard refusal that approval cannot override. - A malformed digest returns `DenialReason::InvalidContext`. @@ -54,5 +55,7 @@ binding, so a changed document cannot reuse the earlier warning decision. - Malformed digests deny with `DenialReason::InvalidContext`. - A warning enters the persisted human-approval lifecycle; a block cannot be converted into a pending approval by policy evaluation. +- Auto-approve-all, grants, and judge decisions cannot bypass a warning's + fresh human decision. - Older 1.28 hosts reject the 1.29 module, and the 1.29 host accepts later 1.x module minors. From 758a77927d23b81ba663d01f5d12a5085acff616 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:18:34 +0000 Subject: [PATCH 62/82] refactor(approval): extract blocked skill scan check and flatten conditionals Pulled the blocked skill scan predicate into a named helper and merged the matching arms in the external-effect check. Inverted the bundle hash guard in the policy engine so the success path reads first, with no change in behaviour. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/binding.rs | 26 ++++++++++++--------- crates/tinysecurity-policy/src/engine.rs | 6 ++--- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/crates/tinysecurity-approval/src/binding.rs b/crates/tinysecurity-approval/src/binding.rs index 12ce8307..ba2479f3 100644 --- a/crates/tinysecurity-approval/src/binding.rs +++ b/crates/tinysecurity-approval/src/binding.rs @@ -100,6 +100,19 @@ pub(crate) fn native_safe_call(request: &EvaluateRequest) -> Result { } Ok(safe_call) } + +fn contains_blocked_skill_scan(checks: &[Check]) -> bool { + checks.iter().any(|check| { + matches!( + check, + Check::SkillScan { + verdict: tinysecurity_bus::SkillScanVerdict::Block, + .. + } + ) + }) +} + pub(crate) fn original( reg: &ApprovalScope2, request: &EvaluateRequest, @@ -113,15 +126,7 @@ pub(crate) fn original( || request.call.arguments_json.len() > 32768 || request.call.checks.is_empty() || request.call.checks.len() > 128 - || request.call.checks.iter().any(|check| { - matches!( - check, - Check::SkillScan { - verdict: tinysecurity_bus::SkillScanVerdict::Block, - .. - } - ) - }) + || contains_blocked_skill_scan(&request.call.checks) || (reg.ceiling == AccessTier::Read && request.caller.tier != AccessTier::Read) || (reg.ceiling == AccessTier::Write && request.caller.tier == AccessTier::Full) || request @@ -146,8 +151,7 @@ pub(crate) fn original( args.sort_all_objects(); let canonical = serde_json::to_string(&args).map_err(|_| Error::Invalid)?; let external = request.call.checks.iter().any(|check| match check { - Check::LocalDiagnostic {} => false, - Check::SkillScan { .. } => false, + Check::LocalDiagnostic {} | Check::SkillScan { .. } => false, Check::Path { write, .. } => *write, Check::HostAction { external_effect, .. diff --git a/crates/tinysecurity-policy/src/engine.rs b/crates/tinysecurity-policy/src/engine.rs index 90f503f0..e8d9ab69 100644 --- a/crates/tinysecurity-policy/src/engine.rs +++ b/crates/tinysecurity-policy/src/engine.rs @@ -310,9 +310,7 @@ impl Policy { verdict, bundle_sha256, } => { - if !is_sha256(bundle_sha256) { - Err(DenialReason::InvalidContext) - } else { + if is_sha256(bundle_sha256) { match verdict { tinysecurity_bus::SkillScanVerdict::Pass => Ok(()), tinysecurity_bus::SkillScanVerdict::Warn => { @@ -320,6 +318,8 @@ impl Policy { } tinysecurity_bus::SkillScanVerdict::Block => Err(DenialReason::Content), } + } else { + Err(DenialReason::InvalidContext) } } Check::Path { path, write } => path::check(settings, path, *write) From e94c7236780efdbc62ae7128ca019008b28286e9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:26:40 +0000 Subject: [PATCH 63/82] test(policy): cover skill scan pass without effect check Add a test asserting that a passing skill scan alone cannot authorize a host tool call, which must still be denied as an unsupported effect. This locks in that skill scan verdicts never substitute for an explicit effect check. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-policy/src/engine_tests.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index fa88112d..8685447a 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -124,6 +124,27 @@ fn host_skill_scan_verdicts_deny_block_and_require_approval_for_warning() -> Res Ok(()) } +#[test] +fn skill_scan_pass_cannot_authorize_a_host_tool_without_an_effect_check() -> Result<()> { + let policy = Policy::new(ModuleConfig::default())?; + assert_eq!( + verdict( + &policy, + &host_call( + "run_workflow", + vec![Check::SkillScan { + verdict: SkillScanVerdict::Pass, + bundle_sha256: "a".repeat(64), + }], + ), + )?, + Verdict::Deny { + reason: DenialReason::UnsupportedEffect, + } + ); + Ok(()) +} + #[test] fn a_skill_warning_never_masks_an_independent_hard_denial() -> Result<()> { let policy = Policy::new(ModuleConfig::default())?; From 4adef47348662b163c65ddc86dcfcea521caee22 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:27:10 +0000 Subject: [PATCH 64/82] fix(policy): stop treating skill scan checks as host tool calls Skill scan checks were matched by the host tool call arm in the policy engine, so a request carrying only a skill scan was evaluated as a host tool invocation. The arm no longer matches skill scans, and the affected tests now pair the scan with a non-effecting host action so they still exercise the intended path. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_host_tool_tests.rs | 16 ++++++--- crates/tinysecurity-policy/src/engine.rs | 1 - .../tinysecurity-policy/src/engine_tests.rs | 34 ++++++++++++++----- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_host_tool_tests.rs b/crates/tinysecurity-module/src/adapter_host_tool_tests.rs index 011c975c..95a0fe0f 100644 --- a/crates/tinysecurity-module/src/adapter_host_tool_tests.rs +++ b/crates/tinysecurity-module/src/adapter_host_tool_tests.rs @@ -135,10 +135,18 @@ async fn evaluate_applies_host_skill_scan_verdicts_before_run_workflow() -> Test &service, request( "run_workflow", - vec![Check::SkillScan { - verdict, - bundle_sha256: "c".repeat(64), - }], + vec![ + Check::HostAction { + permission: ToolPermission::None, + external_effect: false, + spend: false, + command_policy: None, + }, + Check::SkillScan { + verdict, + bundle_sha256: "c".repeat(64), + }, + ], ), ) .await? diff --git a/crates/tinysecurity-policy/src/engine.rs b/crates/tinysecurity-policy/src/engine.rs index e8d9ab69..a28b1d82 100644 --- a/crates/tinysecurity-policy/src/engine.rs +++ b/crates/tinysecurity-policy/src/engine.rs @@ -141,7 +141,6 @@ impl Policy { Check::HostTool {} | Check::HostAction { .. } | Check::HostShell { .. } - | Check::SkillScan { .. } ) }) && request .call diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index 8685447a..ba936d66 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -1,5 +1,14 @@ use super::*; -use tinysecurity_bus::{SkillScanVerdict, ToolCall}; +use tinysecurity_bus::{SkillScanVerdict, ToolCall, ToolPermission}; + +fn non_effecting_host_action() -> Check { + Check::HostAction { + permission: ToolPermission::None, + external_effect: false, + spend: false, + command_policy: None, + } +} #[test] fn direct_credential_transfers_deny_original_content() -> Result<()> { let policy = Policy::new(ModuleConfig::default())?; @@ -111,10 +120,13 @@ fn host_skill_scan_verdicts_deny_block_and_require_approval_for_warning() -> Res &policy, &host_call( "run_workflow", - vec![Check::SkillScan { - verdict: scan_verdict, - bundle_sha256: "a".repeat(64), - }], + vec![ + non_effecting_host_action(), + Check::SkillScan { + verdict: scan_verdict, + bundle_sha256: "a".repeat(64), + }, + ], ), )?, expected, @@ -154,6 +166,7 @@ fn a_skill_warning_never_masks_an_independent_hard_denial() -> Result<()> { &host_call( "run_workflow", vec![ + non_effecting_host_action(), Check::SkillScan { verdict: SkillScanVerdict::Warn, bundle_sha256: "b".repeat(64), @@ -185,10 +198,13 @@ fn a_skill_scan_requires_a_canonical_bundle_digest() -> Result<()> { &policy, &host_call( "run_workflow", - vec![Check::SkillScan { - verdict: SkillScanVerdict::Pass, - bundle_sha256, - }], + vec![ + non_effecting_host_action(), + Check::SkillScan { + verdict: SkillScanVerdict::Pass, + bundle_sha256, + }, + ], ), )?, Verdict::Deny { From 2f8566479ece503dbf4a2fa4fab385558ad0627e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:29:16 +0000 Subject: [PATCH 65/82] docs: clarify skill scan is supplemental and additive for 1.28 hosts The skill-scan contract now states that a scan verdict is supplemental to the host-effect check and can never authorize a tool call on its own. It also documents that 1.29 is additive for older same-major hosts, which keep 1.28 behavior until their adapter sends the check, and corrects the acceptance criteria to describe a 1.29 host rejecting a 1.28 module. A test asserts that an older same-major host config is still accepted. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/policy.rs | 3 ++- crates/tinysecurity-policy/src/engine_tests.rs | 8 ++++++++ docs/specs/skill-scan-contract-1.29.md | 10 ++++++++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-bus/src/policy.rs b/crates/tinysecurity-bus/src/policy.rs index c3a1e78a..8c3ba1cc 100644 --- a/crates/tinysecurity-bus/src/policy.rs +++ b/crates/tinysecurity-bus/src/policy.rs @@ -78,7 +78,8 @@ pub struct ToolCall { /// Host-computed verdict from scanning a skill before execution (contract 1.29). /// The host must scan the exact installed document and resources it will expose; -/// this value is a trusted host assertion, never a model-supplied claim. +/// this value is a trusted host assertion, never a model-supplied claim. It is +/// supplemental to the host-effect check and cannot authorize a tool call alone. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SkillScanVerdict { diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index ba936d66..756ab7d2 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -65,6 +65,14 @@ fn verdict(policy: &Policy, request: &EvaluateRequest) -> Result { Ok(policy.evaluate(request)?.verdict) } +#[test] +fn additive_contract_minor_accepts_an_older_same_major_host() -> Result<()> { + let mut config = ModuleConfig::default(); + config.contract_version = (1, 28); + assert!(Policy::new(config).is_ok()); + Ok(()) +} + #[test] fn host_tool_needs_approval_instead_of_being_unsupported() -> Result<()> { let policy = Policy::new(ModuleConfig::default())?; diff --git a/docs/specs/skill-scan-contract-1.29.md b/docs/specs/skill-scan-contract-1.29.md index e1ed19b6..830dd030 100644 --- a/docs/specs/skill-scan-contract-1.29.md +++ b/docs/specs/skill-scan-contract-1.29.md @@ -26,6 +26,8 @@ for the exact installed skill bundle an agent is about to execute. The request also carries a lowercase SHA-256 digest over the document and every resource the run can expose. TinySecurity binds that digest into the approval request; it does not read skill files or trust a verdict supplied by a model or caller. +This check is supplemental: the host must include the normal host-effect check +in the same call, and a scan result alone never authorizes a tool. The host must derive the verdict from its scanner over the same bytes the run will consume. It must preserve that snapshot for the run, or verify the digest @@ -47,6 +49,11 @@ The check is not reusable by registered-policy decision caching. Its verdict and bundle digest remain in the canonical call fingerprint and approval binding, so a changed document cannot reuse the earlier warning decision. +Contract 1.29 is additive for older same-major hosts. Such hosts retain the +1.28 behavior and do not gain skill-scan enforcement until their host adapter +sends this check. OpenHuman pins an exact module artifact in its registry, so +the host adapter and a module artifact that supports 1.29 must ship together. + ## Acceptance criteria - The wire type round-trips all verdicts and requires a bundle digest. @@ -57,5 +64,4 @@ binding, so a changed document cannot reuse the earlier warning decision. converted into a pending approval by policy evaluation. - Auto-approve-all, grants, and judge decisions cannot bypass a warning's fresh human decision. -- Older 1.28 hosts reject the 1.29 module, and the 1.29 host accepts later - 1.x module minors. +- A 1.29 host rejects a 1.28 module, and accepts later 1.x module minors. From 8dfbb39585ee4ae98610cf3c9d03025377e4dd5a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:32:37 +0000 Subject: [PATCH 66/82] test(policy): simplify contract version test setup Replace the mutable default config and explicit Result return with struct update syntax so the test no longer needs to construct and mutate a binding or wrap its assertion in Ok. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-policy/src/engine_tests.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index 756ab7d2..6f08d5d8 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -66,11 +66,12 @@ fn verdict(policy: &Policy, request: &EvaluateRequest) -> Result { } #[test] -fn additive_contract_minor_accepts_an_older_same_major_host() -> Result<()> { - let mut config = ModuleConfig::default(); - config.contract_version = (1, 28); +fn additive_contract_minor_accepts_an_older_same_major_host() { + let config = ModuleConfig { + contract_version: (1, 28), + ..Default::default() + }; assert!(Policy::new(config).is_ok()); - Ok(()) } #[test] From 77218603ebf68feaf2c394004beb8b67d0243e0d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:35:31 +0000 Subject: [PATCH 67/82] test(adapter-host-tool): cover skill scan checks in admission-only detection Extend the mixed-request test to assert that a skill scan check alongside an acting check still counts as admission-only, while a skill scan on its own does not. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter_host_tool_tests.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/tinysecurity-module/src/adapter_host_tool_tests.rs b/crates/tinysecurity-module/src/adapter_host_tool_tests.rs index 95a0fe0f..c93cde7b 100644 --- a/crates/tinysecurity-module/src/adapter_host_tool_tests.rs +++ b/crates/tinysecurity-module/src/adapter_host_tool_tests.rs @@ -288,6 +288,14 @@ async fn a_mixed_request_is_not_an_admission_and_stays_audit_bound() -> TestResu assert!(!admission_only(&call.call.checks)); call.call.checks = vec![acting(false, &scope)]; assert!(admission_only(&call.call.checks)); + let scan = Check::SkillScan { + verdict: tinysecurity_bus::SkillScanVerdict::Pass, + bundle_sha256: "a".repeat(64), + }; + call.call.checks = vec![acting(false, &scope), scan.clone()]; + assert!(admission_only(&call.call.checks)); + call.call.checks = vec![scan]; + assert!(!admission_only(&call.call.checks)); Ok(()) } From d9d2590c6d1dc936560760ab96c89f1f7614040d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:36:06 +0000 Subject: [PATCH 68/82] fix(adapter): treat skill scans as admission-only checks The admission_only predicate now accepts SkillScan checks alongside host action and shell checks, and requires at least one host-tool or shell question to be present. This keeps skill scans from being rejected as non-admission checks under contract 1.20. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 7fb5e759..ae3fb889 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1575,10 +1575,14 @@ fn approval_failure(error: tinysecurity::approval::Error) -> tinybus::Error { /// Whether every check is a host-tool or shell admission question (contract 1.20). fn admission_only(checks: &[tinysecurity_bus::Check]) -> bool { use tinysecurity_bus::Check; - !checks.is_empty() - && checks - .iter() - .all(|check| matches!(check, Check::HostAction { .. } | Check::HostShell { .. })) + checks.iter().any(|check| { + matches!(check, Check::HostAction { .. } | Check::HostShell { .. }) + }) && checks.iter().all(|check| { + matches!( + check, + Check::HostAction { .. } | Check::HostShell { .. } | Check::SkillScan { .. } + ) + }) } fn deny_allow(decision: &mut Decision) { if matches!(decision.verdict, tinysecurity_bus::Verdict::Allow) { From 24ef88e2cc32f73cf4de33deba2583a5fa2291cb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:36:13 +0000 Subject: [PATCH 69/82] style: reformat admission_only check for readability Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index ae3fb889..dd044cb6 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1575,14 +1575,15 @@ fn approval_failure(error: tinysecurity::approval::Error) -> tinybus::Error { /// Whether every check is a host-tool or shell admission question (contract 1.20). fn admission_only(checks: &[tinysecurity_bus::Check]) -> bool { use tinysecurity_bus::Check; - checks.iter().any(|check| { - matches!(check, Check::HostAction { .. } | Check::HostShell { .. }) - }) && checks.iter().all(|check| { - matches!( - check, - Check::HostAction { .. } | Check::HostShell { .. } | Check::SkillScan { .. } - ) - }) + checks + .iter() + .any(|check| matches!(check, Check::HostAction { .. } | Check::HostShell { .. })) + && checks.iter().all(|check| { + matches!( + check, + Check::HostAction { .. } | Check::HostShell { .. } | Check::SkillScan { .. } + ) + }) } fn deny_allow(decision: &mut Decision) { if matches!(decision.verdict, tinysecurity_bus::Verdict::Allow) { From 0e8e631ae95755b84950b6f04c97c5992211b647 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:36:37 +0000 Subject: [PATCH 70/82] docs(tinysecurity-module): clarify admission_only doc comment Update the doc comment on admission_only to describe host-tool checks as admission-only with an optional supplemental scan, replacing the outdated contract version reference. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index dd044cb6..25e0c3aa 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1572,7 +1572,7 @@ fn permission_payload(mut original: serde_json::Value) -> BusResult { fn approval_failure(error: tinysecurity::approval::Error) -> tinybus::Error { tinybus::Error::failed(error.to_string()) } -/// Whether every check is a host-tool or shell admission question (contract 1.20). +/// Whether host-tool checks are admission-only, with an optional supplemental scan. fn admission_only(checks: &[tinysecurity_bus::Check]) -> bool { use tinysecurity_bus::Check; checks From e7f1b3254e3711ac379a83d4127c82e04a0b5698 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 05:24:34 +0000 Subject: [PATCH 71/82] test: isolate process-owned confidential fixtures in child processes Confidential authorities are process-owned and survive service-tree replacement, so running the actual bus fixtures in the same libtest process let Windows' linked module loader share owners between independent broker instances. The affected tests now re-execute themselves in a child process marked by an environment variable and skip the in-process run. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../adapter_secret_cipher_fixture_tests.rs | 6 ++++ .../src/adapter_secret_tests.rs | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs b/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs index 9dca7fc8..29da820c 100644 --- a/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs @@ -499,6 +499,12 @@ impl Control { #[tokio::test] async fn actual_confidential_cipher_dispatch_stages_only_at_fixed_slots_and_fails_closed() -> TestResult { + if isolate_process_owned_fixture( + "actual_confidential_cipher_dispatch_stages_only_at_fixed_slots_and_fails_closed", + "cipher", + )? { + return Ok(()); + } for mode in 20..=32 { let bus = MemoryBus::new(); let owner = Broker::new(); diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index 08159815..8e3b6fd1 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -4,6 +4,29 @@ use std::sync::atomic::{AtomicU32, Ordering}; use tinybus::{Connection, broker::Broker, transport::memory::MemoryBus}; use tinysecurity_bus::*; type TestResult = Result<(), Box>; + +/// The module's confidential authorities are process-owned and intentionally +/// survive service-tree replacement. Run actual bus fixtures in a child test +/// process so Windows' linked module loader cannot share those owners between +/// independent broker instances in the same libtest process. +fn isolate_process_owned_fixture( + test_filter: &str, + marker: &str, +) -> Result> { + const CHILD_MARKER: &str = "TINYSECURITY_PROCESS_OWNED_FIXTURE"; + if std::env::var(CHILD_MARKER).is_ok_and(|value| value == marker) { + return Ok(false); + } + let status = std::process::Command::new(std::env::current_exe()?) + .arg(test_filter) + .arg("--nocapture") + .env(CHILD_MARKER, marker) + .status()?; + if !status.success() { + return Err(format!("isolated fixture process exited with {status}").into()); + } + Ok(true) +} #[derive(Debug)] struct FixedClock(std::sync::atomic::AtomicU64); impl tinysecurity::approval::Clock for FixedClock { @@ -922,6 +945,12 @@ mod linked_fixture { #[tokio::test] async fn actual_sdk_confidential_master_resolution_is_human_only_exact_and_fail_closed() -> TestResult { + if isolate_process_owned_fixture( + "actual_sdk_confidential_master_resolution_is_human_only_exact_and_fail_closed", + "master", + )? { + return Ok(()); + } for mode in 0..=19 { let bus = MemoryBus::new(); let owner = Broker::new(); From fe5bf95febb2545841bcb3bab267dcf187d3867d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 05:39:31 +0000 Subject: [PATCH 72/82] test(adapter-secret): cover static registration and conflicting backend Extend the secret scope registration test to register a static registration and assert its receipt is active with the expected registration id. Also verify that re-registering the same scope with a different backend id is rejected, and seed the legacy binding list so the new path is exercised. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_secret_tests.rs | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index 364f7543..51f8eeb9 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -706,7 +706,10 @@ impl Control { backend_id: "2".repeat(32), slot: MasterSlotKind2::User, }, - legacy: vec![], + legacy: vec![SecretBinding2 { + backend_id: "7".repeat(32), + slot: MasterSlotKind2::File, + }], }; let register_request = RegisterSecretScopeRequest2 { registration: registration.clone(), @@ -739,6 +742,32 @@ impl Control { assert!(receipt.active); assert_eq!(receipt.registration_id, registration.registration_id); let secret = self.service.secret.as_ref().ok_or_else(denied)?; + let static_registration = config().registrations[0].clone(); + let static_request = RegisterSecretScopeRequest2 { + registration: static_registration.clone(), + }; + let static_receipt: SecretScopeReceipt2 = proxy + .call_confidential( + names::methods::REGISTER_SECRET_SCOPE2, + (&static_request,), + ) + .await?; + assert!(static_receipt.active); + assert_eq!( + static_receipt.registration_id, + static_registration.registration_id + ); + let mut conflicting_static = static_request; + conflicting_static.registration.target.backend_id = "f".repeat(32); + assert!( + proxy + .call_confidential::( + names::methods::REGISTER_SECRET_SCOPE2, + (&conflicting_static,), + ) + .await + .is_err() + ); let mut invalid_replacement = approval_request.clone(); invalid_replacement.scope.subject.user = "different-user".into(); assert!( From 4af2af27e9e68c081a23e813b1c54f44beb1f37c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 05:47:53 +0000 Subject: [PATCH 73/82] style: format runtime registration coverage tests Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter_secret_tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index 51f8eeb9..22eb8cae 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -747,10 +747,7 @@ impl Control { registration: static_registration.clone(), }; let static_receipt: SecretScopeReceipt2 = proxy - .call_confidential( - names::methods::REGISTER_SECRET_SCOPE2, - (&static_request,), - ) + .call_confidential(names::methods::REGISTER_SECRET_SCOPE2, (&static_request,)) .await?; assert!(static_receipt.active); assert_eq!( From 10453d7b18e16ce49793d0512dc835d097357d94 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 05:51:17 +0000 Subject: [PATCH 74/82] fix(secret): refresh cipher audit policy on scope replacement Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 1 + .../src/adapter_secret_cipher.rs | 51 +++++++++++++++---- .../src/adapter_secret_tests.rs | 9 ++++ 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 4bc39215..b325242c 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1227,6 +1227,7 @@ impl PolicyService { if let Some(secret) = &self.secret { secret.retire_approval_scope(&scope_id)?; secret.refresh_approval_scope(&scope_id, engine)?; + cipher::refresh_approval_scope(&scope_id, secret, engine)?; } Ok(receipt) } diff --git a/crates/tinysecurity-module/src/adapter_secret_cipher.rs b/crates/tinysecurity-module/src/adapter_secret_cipher.rs index 116d47f8..faa80ef6 100644 --- a/crates/tinysecurity-module/src/adapter_secret_cipher.rs +++ b/crates/tinysecurity-module/src/adapter_secret_cipher.rs @@ -103,8 +103,7 @@ struct Broker { secret: Arc, audit: Arc, subject: SubjectScope, - durable_subject: SubjectScope, - policy_ref: String, + audit_binding: Mutex<(SubjectScope, String)>, slots: Mutex>>, contexts: Mutex>>, } @@ -157,6 +156,8 @@ impl Broker { if !self.audit.required() || correlation.generation != generation { return Err(denied()); } + let (durable_subject, policy_ref) = + self.audit_binding.lock().map_err(|_| denied())?.clone(); let batch = AuditSinkRequest2 { scope: self.audit.scope().clone(), stream_id: self.audit.stream_id().into(), @@ -172,7 +173,7 @@ impl Broker { attribution: "native-config-cipher".into(), arguments_json: serde_json::to_string(&serde_json::json!({ "operation": operation, "output": output, "phase": phase, - "subject": self.durable_subject, "policy": self.policy_ref, + "subject": durable_subject, "policy": policy_ref, "purpose": "config_user_master", "consumer": "fixed-native" })) .map_err(|_| denied())?, @@ -304,6 +305,17 @@ pub(super) struct CipherState { broker: Arc, pending: Mutex>, } +impl CipherState { + pub(super) fn audit_policy_ref(&self) -> tinybus::Result { + Ok(self + .broker + .audit_binding + .lock() + .map_err(|_| denied())? + .1 + .clone()) + } +} pub(super) fn configured( config: Option, connection: &tinybus::Connection, @@ -358,12 +370,14 @@ pub(super) fn create( secret: secret.clone(), audit, subject: scope.subject.clone(), - durable_subject: engine - .durable_scope(&scope.scope_id) - .map_err(|_| denied())?, - policy_ref: engine - .durable_policy_ref(&scope.scope_id) - .map_err(|_| denied())?, + audit_binding: Mutex::new(( + engine + .durable_scope(&scope.scope_id) + .map_err(|_| denied())?, + engine + .durable_policy_ref(&scope.scope_id) + .map_err(|_| denied())?, + )), slots: Mutex::new(vec![None; usize::from(config.slots)]), contexts: Mutex::new(BTreeMap::new()), }); @@ -395,6 +409,25 @@ pub(super) fn close_pending() { } // Physical uncertainty is deliberately not cleared by close or generation. } +pub(super) fn refresh_approval_scope( + scope_id: &str, + secret: &SecretState, + engine: &tinysecurity::approval::Engine, +) -> tinybus::Result<()> { + let Some(Some(state)) = CIPHER.get() else { + return Ok(()); + }; + let scope = secret.cipher_scope(&state.config.registration_id)?; + if scope.scope_id != scope_id { + return Ok(()); + } + let binding = ( + engine.durable_scope(scope_id).map_err(|_| denied())?, + engine.durable_policy_ref(scope_id).map_err(|_| denied())?, + ); + *state.broker.audit_binding.lock().map_err(|_| denied())? = binding; + Ok(()) +} type Inventory<'a> = MutexGuard<'a, BTreeMap>; pub(super) fn prepare_generation() -> tinybus::Result>> { CIPHER diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index 22eb8cae..4d75ed59 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -840,6 +840,15 @@ impl Control { report_handle: false, }) .await?; + let cipher = self.service.secret_cipher.as_ref().ok_or_else(denied)?; + let approvals = self.service.approvals.as_ref().ok_or_else(denied)?; + assert_eq!( + cipher.audit_policy_ref()?, + approvals + .durable_policy_ref(&"a".repeat(32)) + .map_err(|_| denied())?, + "cipher audit policy follows the replaced approval scope", + ); let replacement = RegisterApprovalScopeRequest2 { scope: ApprovalScope2 { scope_id: "f".repeat(32), From 44499c42969e652f33d5adfcf26ee1536ba26395 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 05:57:50 +0000 Subject: [PATCH 75/82] chore(tinysecurity-module): gate audit_policy_ref behind cfg(test) The audit_policy_ref helper on CipherState is only used by tests, so it is now compiled under cfg(test) to avoid dead code in non-test builds. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter_secret_cipher.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinysecurity-module/src/adapter_secret_cipher.rs b/crates/tinysecurity-module/src/adapter_secret_cipher.rs index faa80ef6..e52d7ff9 100644 --- a/crates/tinysecurity-module/src/adapter_secret_cipher.rs +++ b/crates/tinysecurity-module/src/adapter_secret_cipher.rs @@ -306,6 +306,7 @@ pub(super) struct CipherState { pending: Mutex>, } impl CipherState { + #[cfg(test)] pub(super) fn audit_policy_ref(&self) -> tinybus::Result { Ok(self .broker From e71c68ce32485aed08cc8a6db1b1b6ccd4e5cd23 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 06:45:05 +0000 Subject: [PATCH 76/82] test(adapter): serialize secret scope tests with a shared lock The secret scope tests mutate process-wide state and could interleave when run concurrently, so they now take a dedicated test-only mutex for the duration of each test. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 2 ++ .../src/adapter_secret_cipher_fixture_tests.rs | 1 + crates/tinysecurity-module/src/adapter_secret_tests.rs | 4 +++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index b325242c..e422af06 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -20,6 +20,8 @@ static EXTENDED_REDACTORS: OnceLock> = Once static AUDIT_CLIENT: OnceLock>> = OnceLock::new(); static HOST_BINDING: OnceLock = OnceLock::new(); static SECRET_SCOPE_LIFECYCLE: OnceLock> = OnceLock::new(); +#[cfg(test)] +static SECRET_SCOPE_TESTS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); static APPROVALS: OnceLock< Option<( tinysecurity_bus::ApprovalConfig2, diff --git a/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs b/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs index 9dca7fc8..66b280ab 100644 --- a/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs @@ -499,6 +499,7 @@ impl Control { #[tokio::test] async fn actual_confidential_cipher_dispatch_stages_only_at_fixed_slots_and_fails_closed() -> TestResult { + let _test_lifecycle = SECRET_SCOPE_TESTS.lock().await; for mode in 20..=32 { let bus = MemoryBus::new(); let owner = Broker::new(); diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index 4d75ed59..20458853 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -1,4 +1,4 @@ -use super::super::SECRET_SCOPE_LIFECYCLE; +use super::super::{SECRET_SCOPE_LIFECYCLE, SECRET_SCOPE_TESTS}; use super::*; use std::result::Result; use std::sync::atomic::{AtomicU32, Ordering}; @@ -1234,6 +1234,7 @@ mod linked_fixture { #[tokio::test] async fn actual_sdk_confidential_master_resolution_is_human_only_exact_and_fail_closed() -> TestResult { + let _test_lifecycle = SECRET_SCOPE_TESTS.lock().await; for mode in 0..=19 { let bus = MemoryBus::new(); let owner = Broker::new(); @@ -1283,6 +1284,7 @@ async fn actual_sdk_confidential_master_resolution_is_human_only_exact_and_fail_ #[tokio::test] async fn runtime_secret_registration_is_bound_to_approval_scope_and_retirement() -> TestResult { + let _test_lifecycle = SECRET_SCOPE_TESTS.lock().await; let bus = MemoryBus::new(); let owner = Broker::new(); let broker = owner.spawn(bus.clone()); From ca5b4d036f66245acf5944b218f656db4f6b18c9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 06:46:40 +0000 Subject: [PATCH 77/82] test(adapter): isolate process-global secret scope tests Replace the shared test mutex with a helper that re-executes the binary to run each process-global test in its own child process, so tests that mutate global secret scope state no longer interfere with each other. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 2 -- .../adapter_secret_cipher_fixture_tests.rs | 6 ++++- .../src/adapter_secret_tests.rs | 22 +++++++++++++++++-- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index e422af06..b325242c 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -20,8 +20,6 @@ static EXTENDED_REDACTORS: OnceLock> = Once static AUDIT_CLIENT: OnceLock>> = OnceLock::new(); static HOST_BINDING: OnceLock = OnceLock::new(); static SECRET_SCOPE_LIFECYCLE: OnceLock> = OnceLock::new(); -#[cfg(test)] -static SECRET_SCOPE_TESTS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); static APPROVALS: OnceLock< Option<( tinysecurity_bus::ApprovalConfig2, diff --git a/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs b/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs index 66b280ab..bc8318ce 100644 --- a/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs @@ -499,7 +499,11 @@ impl Control { #[tokio::test] async fn actual_confidential_cipher_dispatch_stages_only_at_fixed_slots_and_fails_closed() -> TestResult { - let _test_lifecycle = SECRET_SCOPE_TESTS.lock().await; + if isolate_process_global_test( + "adapter::secret::tests::cipher_tests::actual_confidential_cipher_dispatch_stages_only_at_fixed_slots_and_fails_closed", + )? { + return Ok(()); + } for mode in 20..=32 { let bus = MemoryBus::new(); let owner = Broker::new(); diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index 20458853..5766b86d 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -1,10 +1,24 @@ -use super::super::{SECRET_SCOPE_LIFECYCLE, SECRET_SCOPE_TESTS}; +use super::super::SECRET_SCOPE_LIFECYCLE; use super::*; use std::result::Result; use std::sync::atomic::{AtomicU32, Ordering}; use tinybus::{Connection, broker::Broker, transport::memory::MemoryBus}; use tinysecurity_bus::*; type TestResult = Result<(), Box>; +fn isolate_process_global_test(name: &str) -> TestResult { + const CHILD: &str = "TINYSECURITY_PROCESS_GLOBAL_TEST_CHILD"; + if std::env::var_os(CHILD).is_some() { + return Ok(false); + } + let status = std::process::Command::new(std::env::current_exe()?) + .args(["--exact", name]) + .env(CHILD, "1") + .status()?; + if !status.success() { + return Err(format!("isolated test process failed: {name}").into()); + } + Ok(true) +} #[derive(Debug)] struct FixedClock(std::sync::atomic::AtomicU64); impl tinysecurity::approval::Clock for FixedClock { @@ -1284,7 +1298,11 @@ async fn actual_sdk_confidential_master_resolution_is_human_only_exact_and_fail_ #[tokio::test] async fn runtime_secret_registration_is_bound_to_approval_scope_and_retirement() -> TestResult { - let _test_lifecycle = SECRET_SCOPE_TESTS.lock().await; + if isolate_process_global_test( + "adapter::secret::tests::runtime_secret_registration_is_bound_to_approval_scope_and_retirement", + )? { + return Ok(()); + } let bus = MemoryBus::new(); let owner = Broker::new(); let broker = owner.spawn(bus.clone()); From 52276511bd5740ef956fc893f074e42198bf1b22 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 06:46:59 +0000 Subject: [PATCH 78/82] test(adapter-secret): drop redundant lock and inline result type The process-global isolation helper now spells out its full result type instead of relying on the TestResult alias, and the confidential master resolution test no longer takes the shared secret scope lock since the helper already isolates the process. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter_secret_tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index 5766b86d..e9ebf14b 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -5,7 +5,9 @@ use std::sync::atomic::{AtomicU32, Ordering}; use tinybus::{Connection, broker::Broker, transport::memory::MemoryBus}; use tinysecurity_bus::*; type TestResult = Result<(), Box>; -fn isolate_process_global_test(name: &str) -> TestResult { +fn isolate_process_global_test( + name: &str, +) -> std::result::Result> { const CHILD: &str = "TINYSECURITY_PROCESS_GLOBAL_TEST_CHILD"; if std::env::var_os(CHILD).is_some() { return Ok(false); @@ -1248,7 +1250,6 @@ mod linked_fixture { #[tokio::test] async fn actual_sdk_confidential_master_resolution_is_human_only_exact_and_fail_closed() -> TestResult { - let _test_lifecycle = SECRET_SCOPE_TESTS.lock().await; for mode in 0..=19 { let bus = MemoryBus::new(); let owner = Broker::new(); From 6ddd02acca8585298989d11813d2772bfb63480a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 06:49:31 +0000 Subject: [PATCH 79/82] fix(module): pass secret cipher state into approval scope refresh The approval scope refresh now receives the cipher state from the policy service instead of reading it from a thread-local, so the cipher is resolved from the owning service rather than ambient state. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 7 ++++++- crates/tinysecurity-module/src/adapter_secret_cipher.rs | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index b325242c..22c4ba59 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1227,7 +1227,12 @@ impl PolicyService { if let Some(secret) = &self.secret { secret.retire_approval_scope(&scope_id)?; secret.refresh_approval_scope(&scope_id, engine)?; - cipher::refresh_approval_scope(&scope_id, secret, engine)?; + cipher::refresh_approval_scope( + &scope_id, + secret, + engine, + self.secret_cipher.as_deref(), + )?; } Ok(receipt) } diff --git a/crates/tinysecurity-module/src/adapter_secret_cipher.rs b/crates/tinysecurity-module/src/adapter_secret_cipher.rs index e52d7ff9..b34f3c59 100644 --- a/crates/tinysecurity-module/src/adapter_secret_cipher.rs +++ b/crates/tinysecurity-module/src/adapter_secret_cipher.rs @@ -414,8 +414,9 @@ pub(super) fn refresh_approval_scope( scope_id: &str, secret: &SecretState, engine: &tinysecurity::approval::Engine, + cipher: Option<&CipherState>, ) -> tinybus::Result<()> { - let Some(Some(state)) = CIPHER.get() else { + let Some(state) = cipher else { return Ok(()); }; let scope = secret.cipher_scope(&state.config.registration_id)?; From 57cc91cbccd9fae0409ddbcc8b47d27e812b7c60 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 06:51:31 +0000 Subject: [PATCH 80/82] test: remove process isolation helper from secret adapter tests The isolate_process_global_test helper and its call sites were dropped from the secret adapter tests, so the affected cases now run in-process instead of re-executing themselves in a child process. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../adapter_secret_cipher_fixture_tests.rs | 5 ----- .../src/adapter_secret_tests.rs | 21 ------------------- 2 files changed, 26 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs b/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs index bc8318ce..9dca7fc8 100644 --- a/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs @@ -499,11 +499,6 @@ impl Control { #[tokio::test] async fn actual_confidential_cipher_dispatch_stages_only_at_fixed_slots_and_fails_closed() -> TestResult { - if isolate_process_global_test( - "adapter::secret::tests::cipher_tests::actual_confidential_cipher_dispatch_stages_only_at_fixed_slots_and_fails_closed", - )? { - return Ok(()); - } for mode in 20..=32 { let bus = MemoryBus::new(); let owner = Broker::new(); diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index e9ebf14b..4d75ed59 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -5,22 +5,6 @@ use std::sync::atomic::{AtomicU32, Ordering}; use tinybus::{Connection, broker::Broker, transport::memory::MemoryBus}; use tinysecurity_bus::*; type TestResult = Result<(), Box>; -fn isolate_process_global_test( - name: &str, -) -> std::result::Result> { - const CHILD: &str = "TINYSECURITY_PROCESS_GLOBAL_TEST_CHILD"; - if std::env::var_os(CHILD).is_some() { - return Ok(false); - } - let status = std::process::Command::new(std::env::current_exe()?) - .args(["--exact", name]) - .env(CHILD, "1") - .status()?; - if !status.success() { - return Err(format!("isolated test process failed: {name}").into()); - } - Ok(true) -} #[derive(Debug)] struct FixedClock(std::sync::atomic::AtomicU64); impl tinysecurity::approval::Clock for FixedClock { @@ -1299,11 +1283,6 @@ async fn actual_sdk_confidential_master_resolution_is_human_only_exact_and_fail_ #[tokio::test] async fn runtime_secret_registration_is_bound_to_approval_scope_and_retirement() -> TestResult { - if isolate_process_global_test( - "adapter::secret::tests::runtime_secret_registration_is_bound_to_approval_scope_and_retirement", - )? { - return Ok(()); - } let bus = MemoryBus::new(); let owner = Broker::new(); let broker = owner.spawn(bus.clone()); From f3913684472e91ba7dcf3638bf700b67a9b1f123 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 07:57:26 +0000 Subject: [PATCH 81/82] test(secret): cover orphaned pending scope cleanup Co-authored-by: Medulla --- .../src/adapter_secret_tests.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index ec58a705..2ee92daa 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -851,6 +851,41 @@ impl Control { .await .is_err() ); + // A stale pending call can outlive a registration only across a + // service-tree replacement. Replacing the scope must prune it + // before any caller can resume work under the new generation. + let reservation_request = request(88); + let reservation = secret + .registry + .reserve(handles::Request { + registration: &reservation_request.registration_id, + authenticated_peer: &secret.peer, + purpose: handles::Purpose::ConfigUserMaster, + kind: handles::OperationKind::NativeMasterGrant, + call_id: &reservation_request.call_id, + input: Zeroizing::new(vec![]), + ttl_ms: reservation_request.ttl_ms, + generation: 1, + }) + .map_err(|_| denied())?; + let stale_request = SecretRequest2 { + registration_id: "6".repeat(32), + call_id: "orphaned-pending-call".into(), + ttl_ms: 30_000, + operation: SecretOperation2::ResolveConfigMaster, + }; + secret.pending.lock().map_err(|_| denied())?.insert( + ( + stale_request.registration_id.clone(), + stale_request.call_id.clone(), + ), + Pending { + request: stale_request, + audit: audit(88), + work: reservation, + row: None, + }, + ); let _: ApprovalScopeReceipt2 = self .service .replace_approval_scope2(RegisterApprovalScopeRequest2 { @@ -863,6 +898,10 @@ impl Control { report_handle: false, }) .await?; + assert!( + secret.pending.lock().map_err(|_| denied())?.is_empty(), + "scope replacement prunes pending calls for unknown registrations" + ); let cipher = self.service.secret_cipher.as_ref().ok_or_else(denied)?; let approvals = self.service.approvals.as_ref().ok_or_else(denied)?; assert_eq!( From 6458de129eac8ba3de176a8667db10e855e04781 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 08:05:35 +0000 Subject: [PATCH 82/82] fix(secret): prune orphaned pending scope calls Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter_secret.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/src/adapter_secret.rs b/crates/tinysecurity-module/src/adapter_secret.rs index b7b9e2f9..5eac63ef 100644 --- a/crates/tinysecurity-module/src/adapter_secret.rs +++ b/crates/tinysecurity-module/src/adapter_secret.rs @@ -580,7 +580,7 @@ impl SecretState { .retain(|(registration, _), _| { self.registered .get(registration) - .is_none_or(|entry| entry.descriptor.approval_scope_id != scope_id) + .is_some_and(|entry| entry.descriptor.approval_scope_id != scope_id) }); Ok(()) }