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..77c288b4 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,17 @@ 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.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). diff --git a/crates/tinysecurity-approval/src/binding.rs b/crates/tinysecurity-approval/src/binding.rs index 020b77bd..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,6 +126,7 @@ pub(crate) fn original( || request.call.arguments_json.len() > 32768 || request.call.checks.is_empty() || request.call.checks.len() > 128 + || 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 @@ -137,7 +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::LocalDiagnostic {} | Check::SkillScan { .. } => false, Check::Path { write, .. } => *write, Check::HostAction { external_effect, .. diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 82f2a51e..bde21352 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" @@ -1172,6 +1182,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()) diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 11bfd348..64c6a6e1 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -513,6 +513,33 @@ 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() -> Result<()> { + let (_, owner) = owner()?; + 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 { + 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()?; 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/names.rs b/crates/tinysecurity-bus/src/names.rs index 96b7c0db..7380ca9d 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. @@ -249,6 +251,7 @@ 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_SANDBOX_SCOPE2, diff --git a/crates/tinysecurity-bus/src/policy.rs b/crates/tinysecurity-bus/src/policy.rs index 5b34d141..8c3ba1cc 100644 --- a/crates/tinysecurity-bus/src/policy.rs +++ b/crates/tinysecurity-bus/src/policy.rs @@ -75,6 +75,22 @@ 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. 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 { + /// 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 +157,15 @@ 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, + /// 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 { /// Content-free descriptor. diff --git a/crates/tinysecurity-bus/src/policy_tests.rs b/crates/tinysecurity-bus/src/policy_tests.rs index d560df78..a1ae5c84 100644 --- a/crates/tinysecurity-bus/src/policy_tests.rs +++ b/crates/tinysecurity-bus/src/policy_tests.rs @@ -118,6 +118,31 @@ 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, + bundle_sha256: "a".repeat(64), + }; + let value = serde_json::to_value(&check)?; + assert_eq!( + value, + serde_json::json!({ + "kind":"skill_scan", + "verdict":wire, + "bundle_sha256":"a".repeat(64), + }) + ); + 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/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..eb22e695 100644 --- a/crates/tinysecurity-bus/src/secret_tests.rs +++ b/crates/tinysecurity-bus/src/secret_tests.rs @@ -41,3 +41,38 @@ 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(()) +} 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"), diff --git a/crates/tinysecurity-crypto/src/native_handles.rs b/crates/tinysecurity-crypto/src/native_handles.rs index 2ddefc81..48636937 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 { @@ -45,6 +48,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, @@ -143,6 +147,7 @@ struct Scope { registration: Registration, epoch: u64, rate: RateCounter, + active: bool, } struct State { scopes: BTreeMap, @@ -521,7 +526,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 @@ -539,18 +544,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( @@ -559,6 +553,7 @@ impl Registry { registration, epoch: 1, rate: RateCounter::new(now.monotonic), + active: true, }, ); } @@ -581,6 +576,80 @@ 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, a stale expected + /// generation, closed state, clock failure, and capacity beyond the fixed + /// 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) { + return Err(Error::NativeDenied); + } + if let Some(existing) = state.scopes.get(®istration.id) { + return if existing.active && existing.registration == registration { + Ok(()) + } else { + Err(Error::NativeDenied) + }; + } + if state.scopes.len() >= MAX_NATIVE_SCOPES { + return Err(Error::NativeCapacity); + } + state.scopes.insert( + registration.id.clone(), + Scope { + registration, + epoch: 1, + rate: RateCounter::new(now.monotonic), + active: true, + }, + ); + 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(registration) { + 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.active && &scope.registration == registration { + return Ok(()); + } + let epoch = scope.epoch.checked_add(1).ok_or(Error::NativeDenied)?; + scope.registration.clone_from(registration); + scope.epoch = epoch; + scope.rate = RateCounter::new(now.monotonic); + scope.active = true; + 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)?; @@ -654,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 { @@ -870,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); } @@ -885,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); @@ -1118,6 +1191,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..0e63a501 100644 --- a/crates/tinysecurity-crypto/src/native_handles_tests.rs +++ b/crates/tinysecurity-crypto/src/native_handles_tests.rs @@ -46,6 +46,127 @@ 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(), 1)?; + registry.register_scope(second, 1)?; + + 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, 1).is_err()); + assert!(registry.register_scope(registration("stale"), 2).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(()) +} + +#[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(()) +} + +#[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. + let replacement = registration("a"); + registry.replace_scope(&replacement, 1)?; + let next = reserve( + ®istry, + Purpose::ConfigUserMaster, + OperationKind::RetainPlaintext, + b"next-generation", + )?; + assert_eq!(next.binding().policy_ref, "policy"); + Ok(()) +} + fn reserve( reg: &Registry, purpose: Purpose, diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 2e7bb214..45baf06c 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, @@ -1183,29 +1184,77 @@ 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 { + 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; + 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, ) -> BusResult { + let _lifecycle = SECRET_SCOPE_LIFECYCLE + .get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await; let engine = self .approvals .as_ref() .ok_or_else(|| tinybus::Error::failed("approval unavailable"))?; - engine + let scope_id = request.scope.scope_id.clone(); + let receipt = engine .replace_scope(request) .await - .map_err(approval_failure) + .map_err(approval_failure)?; + 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, + self.secret_cipher.as_deref(), + )?; + } + Ok(receipt) } async fn retire_approval_scope2( &self, request: tinysecurity_bus::RetireApprovalScopeRequest2, ) -> BusResult { - self.approvals + 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"))? .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( @@ -1577,13 +1626,18 @@ 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.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) { @@ -2118,7 +2172,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_host_tool_tests.rs b/crates/tinysecurity-module/src/adapter_host_tool_tests.rs index 8f2a680c..c93cde7b 100644 --- a/crates/tinysecurity-module/src/adapter_host_tool_tests.rs +++ b/crates/tinysecurity-module/src/adapter_host_tool_tests.rs @@ -112,6 +112,52 @@ 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::HostAction { + permission: ToolPermission::None, + external_effect: false, + spend: false, + command_policy: None, + }, + 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()?; @@ -242,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(()) } diff --git a/crates/tinysecurity-module/src/adapter_secret.rs b/crates/tinysecurity-module/src/adapter_secret.rs index 1d43cc1f..5eac63ef 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}, @@ -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, - ToolCall, + RegisterSecretScopeRequest2, SecretConfig2, SecretOperation2, SecretRegistration2, + SecretRequest2, SecretResponse2, SecretScopeReceipt2, ToolCall, }; use zeroize::{Zeroize, Zeroizing}; static SECRET: OnceLock>> = OnceLock::new(); @@ -46,7 +46,7 @@ fn opaque() -> tinybus::Result { } struct Registered { descriptor: SecretRegistration2, - scope: ApprovalScope2, + scope: RwLock, broker: Arc, resolver: MasterResolver, } @@ -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(), + scope: RwLock::new(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,12 @@ 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.read().map_err(|_| denied())?.clone()) } pub(super) fn prepare_cipher( &self, @@ -364,11 +368,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) @@ -389,6 +402,188 @@ 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 Some(existing) = self.registered.get(®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())?; + 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.read().map_err(|_| denied())?.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: RwLock::new(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 = 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())?; + } + 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())? + .retain(|(id, _), _| !ids.contains(id)); + self.aliases + .lock() + .map_err(|_| denied())? + .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(); @@ -424,15 +619,18 @@ 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(); } } -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() }, @@ -548,6 +746,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, } @@ -560,9 +759,9 @@ 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), + request: call(self.reg, self.scope, binding), audit: self.audit.clone(), }) .await @@ -594,6 +793,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 = ( @@ -624,7 +824,7 @@ impl PolicyService { generation, }) .map_err(|_| denied())?; - let approval = call(reg, work.binding()); + let approval = call(®, ®_scope, work.binding()); let expires = work.binding().expires_at_ms(); pending.insert( key.clone(), @@ -640,7 +840,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(), @@ -666,6 +866,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(), @@ -690,7 +891,8 @@ impl PolicyService { work, &Verifier { engine: self.approvals.as_ref().ok_or_else(denied)?, - reg, + reg: ®, + scope: ®_scope, request_id: request.request_id, audit: request.audit, }, diff --git a/crates/tinysecurity-module/src/adapter_secret_cipher.rs b/crates/tinysecurity-module/src/adapter_secret_cipher.rs index 116d47f8..b34f3c59 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,18 @@ pub(super) struct CipherState { broker: Arc, pending: Mutex>, } +impl CipherState { + #[cfg(test)] + 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 +371,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 +410,26 @@ 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, + cipher: Option<&CipherState>, +) -> tinybus::Result<()> { + let Some(state) = cipher 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_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 f63c07ac..2ee92daa 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -1,9 +1,33 @@ +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>; + +/// 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 { @@ -659,6 +683,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 @@ -670,6 +695,353 @@ impl Control { if self.state.mode == 18 { return self.canceled_evaluation(&proxy).await; } + if self.state.mode == 22 { + 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: "9".repeat(32), + report_handle: false, + }; + let _: ApprovalScopeReceipt2 = proxy + .call( + names::methods::REGISTER_APPROVAL_SCOPE2, + (&approval_request,), + ) + .await?; + let registration = SecretRegistration2 { + registration_id: "1".repeat(32), + approval_scope_id: "f".repeat(32), + origin: Origin::Operator, + purpose: MasterPurpose2::ConfigUserMaster, + target: SecretBinding2 { + backend_id: "2".repeat(32), + slot: MasterSlotKind2::User, + }, + legacy: vec![SecretBinding2 { + backend_id: "7".repeat(32), + slot: MasterSlotKind2::File, + }], + }; + 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 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!( + 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 + .lock() + .map_err(|_| denied())? + .get(®istration.registration_id) + .ok_or_else(denied)?, + ) as usize; + assert_eq!( + proxy + .call_confidential::( + names::methods::REGISTER_SECRET_SCOPE2, + (®ister_request,), + ) + .await?, + receipt + ); + assert_eq!( + Arc::as_ptr( + secret + .dynamic + .lock() + .map_err(|_| denied())? + .get(®istration.registration_id) + .ok_or_else(denied)?, + ) as usize, + prior + ); + let mut conflicting = register_request; + conflicting.registration.target.backend_id = "3".repeat(32); + assert!( + proxy + .call_confidential::( + names::methods::REGISTER_SECRET_SCOPE2, + (&conflicting,), + ) + .await + .is_err() + ); + let base_scope = scope(); + assert!( + self.service + .retire_approval_scope2(RetireApprovalScopeRequest2 { + scope_id: base_scope.scope_id.clone(), + }) + .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 { + scope: ApprovalScope2 { + policy_ref: "registered-policy-next".into(), + ..base_scope + }, + grants: ApprovalGrants2::default(), + audit_stream_id: "a".repeat(32), + 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!( + 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), + subject: SubjectScope { + user: "runtime-user".into(), + workspace: "runtime-workspace".into(), + agent: "runtime-agent".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, + 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() + ); + 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 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 { + 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, + (RetireApprovalScopeRequest2 { + scope_id: "f".repeat(32), + },), + ) + .await?; + assert!( + self.service + .secret + .as_ref() + .ok_or_else(denied)? + .registration_id(&replacement_registration.registration_id) + .is_err() + ); + } let mut req = request(1); if self.state.mode == 17 { req.operation = SecretOperation2::ResolveAppMaster; @@ -924,6 +1296,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(); @@ -971,5 +1349,34 @@ 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; diff --git a/crates/tinysecurity-policy/src/engine.rs b/crates/tinysecurity-policy/src/engine.rs index 0c6c7788..a28b1d82 100644 --- a/crates/tinysecurity-policy/src/engine.rs +++ b/crates/tinysecurity-policy/src/engine.rs @@ -138,7 +138,9 @@ impl Policy { request.call.checks.iter().any(|c| { matches!( c, - Check::HostTool {} | Check::HostAction { .. } | Check::HostShell { .. } + Check::HostTool {} + | Check::HostAction { .. } + | Check::HostShell { .. } ) }) && request .call @@ -303,6 +305,22 @@ impl Policy { *declared_class, ) }), + Check::SkillScan { + verdict, + bundle_sha256, + } => { + if is_sha256(bundle_sha256) { + match verdict { + tinysecurity_bus::SkillScanVerdict::Pass => Ok(()), + tinysecurity_bus::SkillScanVerdict::Warn => { + Err(DenialReason::ApprovalUnavailable) + } + tinysecurity_bus::SkillScanVerdict::Block => Err(DenialReason::Content), + } + } else { + Err(DenialReason::InvalidContext) + } + } Check::Path { path, write } => path::check(settings, path, *write) .and_then(|()| hooks.map_or(Ok(()), |h| (h.path)(path, *write))) .and_then(|()| { @@ -349,6 +367,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 86754d71..6f08d5d8 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::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())?; @@ -56,6 +65,15 @@ fn verdict(policy: &Policy, request: &EvaluateRequest) -> Result { Ok(policy.evaluate(request)?.verdict) } +#[test] +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()); +} + #[test] fn host_tool_needs_approval_instead_of_being_unsupported() -> Result<()> { let policy = Policy::new(ModuleConfig::default())?; @@ -88,6 +106,124 @@ 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 (scan_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![ + non_effecting_host_action(), + Check::SkillScan { + verdict: scan_verdict, + bundle_sha256: "a".repeat(64), + }, + ], + ), + )?, + expected, + "{scan_verdict:?}" + ); + } + 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())?; + assert_eq!( + verdict( + &policy, + &host_call( + "run_workflow", + vec![ + non_effecting_host_action(), + Check::SkillScan { + verdict: SkillScanVerdict::Warn, + bundle_sha256: "b".repeat(64), + }, + Check::Unknown { + description: "unsupported operation".into(), + }, + ], + ), + )?, + Verdict::Deny { + reason: DenialReason::UnsupportedEffect, + } + ); + 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![ + non_effecting_host_action(), + 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 8ab24ddd..07c726f0 100644 --- a/crates/tinysecurity-policy/src/scoped.rs +++ b/crates/tinysecurity-policy/src/scoped.rs @@ -106,6 +106,14 @@ fn check_fields(h: &mut Sha256, check: &Check) { } Check::LocalDiagnostic {} => field(h, "local_diagnostic"), Check::HostTool {} => field(h, "host_tool"), + Check::SkillScan { + verdict, + bundle_sha256, + } => { + field(h, "skill_scan"); + field(h, &format!("{verdict:?}")); + field(h, bundle_sha256); + } Check::HostAction { permission, external_effect, diff --git a/docs/README.md b/docs/README.md index ef76e074..0c55fea7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,8 @@ 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. +- **[`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/runtime-secret-scopes-contract-1.29.md b/docs/specs/runtime-secret-scopes-contract-1.29.md new file mode 100644 index 00000000..5f49625e --- /dev/null +++ b/docs/specs/runtime-secret-scopes-contract-1.29.md @@ -0,0 +1,31 @@ +# 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. 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 +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. 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..830dd030 --- /dev/null +++ b/docs/specs/skill-scan-contract-1.29.md @@ -0,0 +1,67 @@ +# 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 +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 +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 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`. + +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. +- `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. +- Auto-approve-all, grants, and judge decisions cannot bypass a warning's + fresh human decision. +- A 1.29 host rejects a 1.28 module, and accepts later 1.x module minors.