From 882013865277d5d32c6b77da276893e934948412 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 11:40:01 +0300 Subject: [PATCH 1/3] feat(approval): scope receipts carry the subject handle; approvals keep path and command members available Contract 1.18. A host that serves many subjects keys its callback stores by the sanitized handle, which it now reads from the registration receipt. Configuring approvals no longer refuses ValidatePath, CheckPath, CheckCommand and the egress members: only a required audit does. Part of tinyhumansai/openhuman#7328 Co-authored-by: Medulla --- MODULE.md | 18 ++++ README.md | 2 +- crates/tinysecurity-approval/src/engine.rs | 23 ++-- .../tinysecurity-bus/src/approval_scopes.rs | 5 + .../src/approval_scopes_tests.rs | 6 ++ crates/tinysecurity-bus/src/lib.rs | 2 +- crates/tinysecurity-bus/tests/scoped_wire.rs | 7 +- crates/tinysecurity-bus/tests/wire.rs | 2 +- crates/tinysecurity-module/src/adapter.rs | 12 ++- .../src/adapter_scope_tests.rs | 100 ++++++++++++++++++ docs/specs/runtime-approval-scopes.md | 5 + 11 files changed, 161 insertions(+), 21 deletions(-) diff --git a/MODULE.md b/MODULE.md index 94a25820..f9351722 100644 --- a/MODULE.md +++ b/MODULE.md @@ -412,6 +412,24 @@ tools and `native_*`, and never beside `Unknown` or `PolicyChange`. Audit correlations stay host-allocated (`AuditCorrelation2.event_id` and the tail it tracks per stream); the module does not mint them. +### Subject handles and unbound members (contract 1.18) + +Additive: a 1.17 host keeps working against a 1.18 module, and a 1.18 host refuses a +1.17 module. + +`ApprovalScopeReceipt2.scope_handle` is the sanitized subject handle the module sends +in every host callback (`ApprovalStore2`, `AuditSink2`, `ApprovalPrompt2`) for that +registration. A host that serves many subjects keys its stores by it, so it reads the +handle from the receipt instead of reproducing the derivation. + +Configuring approvals no longer refuses the filesystem, command and egress members +(`ValidatePath`, `ValidateParent`, `CheckPath`, `CheckCommand`, `ScopedUrlGuard`, +`ScopedEgressCheck`, `ConsumeEgressAuthorization`). They are separate permissions that +never ask a human, so only a required audit (a durable event per permission) refuses +them. The tool-permission members (`Evaluate`, `Check` and their scoped and audited +forms) still cannot return `Allow` once approvals are configured, because a tool call +must go through the durable lifecycle. + ## Per-call exact credentials: `RedactWith` (contract 1.16) Additive: a 1.15 host keeps working against a 1.16 module, and a 1.16 host diff --git a/README.md b/README.md index 90867433..4fc31506 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Blocked original content denies; suspicious content denies with approval-unavailable in unbound policy methods. Scan verdicts never grant permission, and redaction never clears an existing scan finding. -Contract 1.17 adds runtime approval scopes (`RegisterApprovalScope2`, `ReplaceApprovalScope2`, `RetireApprovalScope2`), tool and flow grants, and `Check::HostTool` (see [MODULE.md](MODULE.md#runtime-approval-scopes-and-host-tools-contract-117)). Contract 1.16 adds `RedactWith`, redaction with exact credentials supplied for one call (see [MODULE.md](MODULE.md#per-call-exact-credentials-redactwith-contract-116)). Contract 1.15 adds `RedactMode::CredentialsOnly` and the `EgressPolicy` fields `open_public` and `preflight_transports` (see [MODULE.md](MODULE.md#credentials-only-redaction-and-open-public-egress-contract-115)). Contract 1.14 adds confidential `PasswordCrypt2`, `KeyringAccess2` and `PairingControl2` (see [MODULE.md](MODULE.md)). Contract 1.13 added `EvaluateScopedAudited` and `CheckScopedAudited` for registered policies under required audit (see [`docs/specs/scoped-policies.md`](docs/specs/scoped-policies.md)). +Contract 1.18 returns the sanitized subject handle in `ApprovalScopeReceipt2` and keeps the path, command and egress members available when approvals are configured (see [MODULE.md](MODULE.md#subject-handles-and-unbound-members-contract-118)). Contract 1.17 adds runtime approval scopes (`RegisterApprovalScope2`, `ReplaceApprovalScope2`, `RetireApprovalScope2`), tool and flow grants, and `Check::HostTool` (see [MODULE.md](MODULE.md#runtime-approval-scopes-and-host-tools-contract-117)). Contract 1.16 adds `RedactWith`, redaction with exact credentials supplied for one call (see [MODULE.md](MODULE.md#per-call-exact-credentials-redactwith-contract-116)). Contract 1.15 adds `RedactMode::CredentialsOnly` and the `EgressPolicy` fields `open_public` and `preflight_transports` (see [MODULE.md](MODULE.md#credentials-only-redaction-and-open-public-egress-contract-115)). Contract 1.14 adds confidential `PasswordCrypt2`, `KeyringAccess2` and `PairingControl2` (see [MODULE.md](MODULE.md)). Contract 1.13 added `EvaluateScopedAudited` and `CheckScopedAudited` for registered policies under required audit (see [`docs/specs/scoped-policies.md`](docs/specs/scoped-policies.md)). Contract 1.6 supplies `Audit2`, `EvaluateAudited2` and `CheckAudited2` with authenticated exact audit callbacks and a bounded [native journal](crates/tinysecurity-audit/README.md). diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 0cce5434..8dffdc95 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -1083,12 +1083,13 @@ impl Engine { } fn receipt( scopes: &ScopeTable, - scope_id: &str, + scope: &ApprovalScope2, generation: u64, outcome: ApprovalScopeOutcome2, ) -> Result { Ok(ApprovalScopeReceipt2 { - scope_id: scope_id.into(), + scope_id: scope.scope_id.clone(), + scope_handle: binding::scope_handle(&scope.subject)?, generation, outcome, live_scopes: u32::try_from(scopes.by_id.len()).map_err(|_| Error::Invalid)?, @@ -1119,7 +1120,7 @@ impl Engine { { Self::receipt( &scopes, - &candidate.scope.scope_id, + &candidate.scope, candidate.scope.generation, ApprovalScopeOutcome2::Unchanged, ) @@ -1146,10 +1147,9 @@ impl Engine { let created = { let mut scopes = self.scopes.write().map_err(|_| Error::Unavailable)?; self.current_generation(&candidate.scope).map(|()| { - let (id, generation) = - (candidate.scope.scope_id.clone(), candidate.scope.generation); + let (scope, generation) = (candidate.scope.clone(), candidate.scope.generation); scopes.insert(candidate); - Self::receipt(&scopes, &id, generation, ApprovalScopeOutcome2::Created) + Self::receipt(&scopes, &scope, generation, ApprovalScopeOutcome2::Created) }) }; match created { @@ -1189,7 +1189,7 @@ impl Engine { { return Err(Error::Denied); } - let (id, generation) = (candidate.scope.scope_id.clone(), candidate.scope.generation); + let (scope, generation) = (candidate.scope.clone(), candidate.scope.generation); let same = existing.same_posture(&candidate); if !same { // Keep the original stream owner: init scopes stay on the base stream. @@ -1199,7 +1199,7 @@ impl Engine { } Self::receipt( &scopes, - &id, + &scope, generation, if same { ApprovalScopeOutcome2::Unchanged @@ -1238,12 +1238,7 @@ impl Engine { return Err(error); } let scopes = self.scopes.read().map_err(|_| Error::Unavailable)?; - Self::receipt( - &scopes, - &request.scope_id, - 0, - ApprovalScopeOutcome2::Retired, - ) + Self::receipt(&scopes, &removed.scope, 0, ApprovalScopeOutcome2::Retired) } fn correlation(audit: &AuditCorrelation2, generation: u64) -> Result<()> { if !binding::hex(&audit.event_id, 32) || audit.generation != generation { diff --git a/crates/tinysecurity-bus/src/approval_scopes.rs b/crates/tinysecurity-bus/src/approval_scopes.rs index 1b6ea15f..39d61fb3 100644 --- a/crates/tinysecurity-bus/src/approval_scopes.rs +++ b/crates/tinysecurity-bus/src/approval_scopes.rs @@ -132,6 +132,11 @@ pub enum ApprovalScopeOutcome2 { pub struct ApprovalScopeReceipt2 { /// Affected registration. pub scope_id: String, + /// The sanitized subject handle the module uses for this registration in every + /// host callback (`ApprovalStore2`, `AuditSink2`, `ApprovalPrompt2`). A host that + /// serves many subjects keys its stores by it, so it learns the handle from the + /// receipt instead of reproducing the module's derivation (contract 1.18). + pub scope_handle: crate::SubjectScope, /// Generation the scope is bound to (0 once retired). pub generation: u64, /// What happened. diff --git a/crates/tinysecurity-bus/src/approval_scopes_tests.rs b/crates/tinysecurity-bus/src/approval_scopes_tests.rs index d7f8662c..360734aa 100644 --- a/crates/tinysecurity-bus/src/approval_scopes_tests.rs +++ b/crates/tinysecurity-bus/src/approval_scopes_tests.rs @@ -147,12 +147,18 @@ fn retire_and_receipt_wire_round_trip() -> TestResult { ); let receipt = ApprovalScopeReceipt2 { scope_id: "a".repeat(32), + scope_handle: SubjectScope { + user: "b".repeat(64), + workspace: "c".repeat(64), + agent: "d".repeat(64), + }, generation: 3, outcome: ApprovalScopeOutcome2::Replaced, live_scopes: 2, }; let value = serde_json::to_value(&receipt)?; assert_eq!(value["outcome"], "replaced"); + assert_eq!(value["scope_handle"]["agent"], "d".repeat(64)); assert_eq!( serde_json::from_value::(value)?, receipt diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index d6ab8ef4..bf107567 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -45,7 +45,7 @@ pub use redact::RedactorConfig; pub use scoped_policy::*; pub mod names; /// Current wire vocabulary version, independent of the artifact version. -pub const CONTRACT_VERSION: (u32, u32) = (1, 17); +pub const CONTRACT_VERSION: (u32, u32) = (1, 18); /// 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/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index a45dacda..985718b4 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -82,10 +82,11 @@ fn new_members_are_advertised_and_named_by_contract_constants() { #[test] fn contract_bump_is_additive_for_older_hosts_and_modules() { - assert_eq!(CONTRACT_VERSION, (1, 17)); - // A host built against 1.17 accepts this module and newer minors, not a 1.16 module. - assert!(is_compatible((1, 17))); + assert_eq!(CONTRACT_VERSION, (1, 18)); + // A host built against 1.18 accepts this module and newer minors, not a 1.17 module. assert!(is_compatible((1, 18))); + assert!(is_compatible((1, 19))); + assert!(!is_compatible((1, 17))); assert!(!is_compatible((1, 16))); assert!(!is_compatible((1, 15))); assert!(!is_compatible((1, 14))); diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index 18008e6b..cc0149a3 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, 17)); + assert_eq!(CONTRACT_VERSION, (1, 18)); for (mode, wire) in [ (RedactMode::Text, "text"), (RedactMode::StructuredArguments, "structured_arguments"), diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 57496ed7..bb08fe02 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -53,8 +53,18 @@ impl PolicyService { fn requires_bound_audit(&self) -> bool { self.approvals.is_some() || self.audit.as_ref().is_some_and(|client| client.required()) } + /// Whether the configured audit makes every permission carry its own durable event. + fn audit_required(&self) -> bool { + self.audit.as_ref().is_some_and(|client| client.required()) + } + /// Refuse a permission member that has no audit correlation when audit is required. + /// + /// Configured approvals alone do not refuse these members: the durable approval + /// lifecycle governs `EvaluateApproval2`, while filesystem, command and egress + /// questions are separate permissions that never request a human decision. A host + /// that configures approvals must keep validating paths and commands. fn require_unbound_permission(&self) -> BusResult<()> { - if self.requires_bound_audit() { + if self.audit_required() { return Err(tinybus::Error::failed( "required audit needs bound permission", )); diff --git a/crates/tinysecurity-module/src/adapter_scope_tests.rs b/crates/tinysecurity-module/src/adapter_scope_tests.rs index 4f85c166..48cc9f58 100644 --- a/crates/tinysecurity-module/src/adapter_scope_tests.rs +++ b/crates/tinysecurity-module/src/adapter_scope_tests.rs @@ -164,6 +164,10 @@ async fn runtime_scope_registers_grants_replaces_and_retires_through_the_authent .await?; assert_eq!(created.outcome, ApprovalScopeOutcome2::Created); assert_eq!(created.live_scopes, 2); + // The receipt names the handle the host callbacks will carry for this subject, + // so a host never has to reproduce the derivation. + let handle = tinysecurity::approval::scope_handle(&tenant_subject())?; + assert_eq!(created.scope_handle, handle); // A granted tool is approved without a prompt, persisted and audited as the grant. let granted: EvaluateApprovalResponse2 = proxy @@ -220,6 +224,10 @@ async fn replace_then_retire(proxy: &tinybus::Proxy) -> TestResult { ) .await?; assert_eq!(replaced.outcome, ApprovalScopeOutcome2::Replaced); + assert_eq!( + replaced.scope_handle, + tinysecurity::approval::scope_handle(&tenant_subject())? + ); let after: EvaluateApprovalResponse2 = proxy .call( names::methods::EVALUATE_APPROVAL2, @@ -241,6 +249,10 @@ async fn replace_then_retire(proxy: &tinybus::Proxy) -> TestResult { ) .await?; assert_eq!(retired.outcome, ApprovalScopeOutcome2::Retired); + assert_eq!( + retired.scope_handle, + tinysecurity::approval::scope_handle(&tenant_subject())? + ); assert!( proxy .call::( @@ -292,3 +304,91 @@ async fn approval_scope_members_fail_closed_without_an_approval_engine() -> Test ); Ok(()) } + +/// Configuring approvals must not take the filesystem and command members away: they +/// are separate permissions that never ask a human, so only a required audit (which +/// needs a durable event per permission) refuses them. +#[tokio::test] +async fn configured_approvals_keep_path_and_command_members_available() -> TestResult { + let bus = MemoryBus::new(); + let broker = Broker::new().spawn(bus.clone()); + let host = Connection::connect(bus.connect().await?).await?; + let module = Connection::connect(bus.connect().await?).await?; + let dir = tempfile::tempdir()?; + let path = dir.path().join("ledger"); + std::fs::write(&path, [])?; + let store = Arc::new(Mutex::new(store::DiskStore::open(&path)?)); + serve(&host, &module, &store, &Arc::new(AtomicBool::new(false))).await?; + let module_peer = module.unique_name().ok_or("module peer")?; + let proxy = host.proxy(module_peer.as_str(), names::OBJECT_PATH, names::INTERFACE)?; + + let root = tempfile::tempdir()?; + std::fs::write(root.path().join("file"), "x")?; + let id: PathPolicyId = proxy + .call( + names::methods::REGISTER_PATH_POLICY, + (super::super::fixture_policy(root.path()),), + ) + .await?; + let read: PathValidationResult = proxy + .call( + names::methods::VALIDATE_PATH, + (PathValidationRequest { + policy_id: id.clone(), + path: "file".into(), + },), + ) + .await?; + assert!(matches!(read, PathValidationResult::Allowed { .. })); + let outside: PathValidationResult = proxy + .call( + names::methods::VALIDATE_PATH, + (PathValidationRequest { + policy_id: id.clone(), + path: "../outside".into(), + },), + ) + .await?; + assert!(matches!(outside, PathValidationResult::Denied { .. })); + let parent: PathValidationResult = proxy + .call( + names::methods::VALIDATE_PARENT, + (PathValidationRequest { + policy_id: id.clone(), + path: "new-file".into(), + },), + ) + .await?; + assert!(matches!(parent, PathValidationResult::Allowed { .. })); + let checked: bool = proxy + .call( + names::methods::CHECK_PATH, + (PathCheckRequest { + policy_id: id, + path: root.path().join("file"), + kind: PathCheckKind::Protected, + },), + ) + .await?; + assert!(!checked); + + let command: CommandPolicyId = proxy + .call( + names::methods::REGISTER_COMMAND_POLICY, + (super::super::command_fixture()?,), + ) + .await?; + let decision: CommandDecision = proxy + .call( + names::methods::CHECK_COMMAND, + (CommandRequest { + policy_id: command, + command: "pwd".into(), + declared_class: None, + },), + ) + .await?; + assert_eq!(decision.gate, CommandGate::Allow); + broker.abort(); + Ok(()) +} diff --git a/docs/specs/runtime-approval-scopes.md b/docs/specs/runtime-approval-scopes.md index 87c08fab..7097d090 100644 --- a/docs/specs/runtime-approval-scopes.md +++ b/docs/specs/runtime-approval-scopes.md @@ -62,6 +62,11 @@ configuration re-registered on change), and does not treat a name as a wildcard. - Automatic approvals (grant or posture) are revalidated against the live scope before they are published, before and after the reservation write; replacing a scope revokes unconsumed approvals it no longer supports. +- The receipt of a registration, replacement or retirement carries `scope_handle`, the + sanitized subject the module sends in host callbacks (contract 1.18), so a multi-subject + host keys its stores without reproducing the derivation. +- Configuring approvals does not refuse the path, command and egress members; only a + required audit does (contract 1.18). - Contract 1.17 is additive: older hosts keep working, a 1.17 host refuses a 1.16 module. From 7869234cfa274028579c9cf77ede06a3784aaeab Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 12:47:25 +0300 Subject: [PATCH 2/3] fix(approval): report the subject handle only to a registration that asks for it A 1.17 host decodes receipts with deny_unknown_fields, so a new always-present field made the minor bump non-additive. The handle now travels only when the registration sets report_handle, and never on a retirement. Part of tinyhumansai/openhuman#7328 Co-authored-by: Medulla --- MODULE.md | 16 +++++----- crates/tinysecurity-approval/src/engine.rs | 25 +++++++++++++-- .../src/engine_scopes_tests.rs | 31 +++++++++++++++++++ .../tinysecurity-bus/src/approval_scopes.rs | 10 +++++- .../src/approval_scopes_tests.rs | 31 +++++++++++++++++-- .../approval_scope_native_contract.rs | 1 + .../src/adapter_scope_tests.rs | 19 +++++++++--- docs/specs/runtime-approval-scopes.md | 7 +++-- 8 files changed, 119 insertions(+), 21 deletions(-) diff --git a/MODULE.md b/MODULE.md index f9351722..15524122 100644 --- a/MODULE.md +++ b/MODULE.md @@ -414,13 +414,15 @@ tracks per stream); the module does not mint them. ### Subject handles and unbound members (contract 1.18) -Additive: a 1.17 host keeps working against a 1.18 module, and a 1.18 host refuses a -1.17 module. - -`ApprovalScopeReceipt2.scope_handle` is the sanitized subject handle the module sends -in every host callback (`ApprovalStore2`, `AuditSink2`, `ApprovalPrompt2`) for that -registration. A host that serves many subjects keys its stores by it, so it reads the -handle from the receipt instead of reproducing the derivation. +Additive: a 1.17 host keeps working against a 1.18 module (its wire shapes do not +change), and a 1.18 host refuses a 1.17 module. + +A registration (`RegisterApprovalScopeRequest2.report_handle`) can ask the receipt +(`ApprovalScopeReceipt2.scope_handle`) to carry the sanitized subject handle the module +sends in every host callback (`ApprovalStore2`, `AuditSink2`, `ApprovalPrompt2`) for +that registration. A host that serves many subjects keys its stores by it, so it reads +the handle from the receipt instead of reproducing the derivation. A request that does +not ask, and every retirement, gets the 1.17 receipt without the field. Configuring approvals no longer refuses the filesystem, command and egress members (`ValidatePath`, `ValidateParent`, `CheckPath`, `CheckCommand`, `ScopedUrlGuard`, diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 8dffdc95..3d14d835 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -1086,10 +1086,13 @@ impl Engine { scope: &ApprovalScope2, generation: u64, outcome: ApprovalScopeOutcome2, + report_handle: bool, ) -> Result { Ok(ApprovalScopeReceipt2 { scope_id: scope.scope_id.clone(), - scope_handle: binding::scope_handle(&scope.subject)?, + scope_handle: report_handle + .then(|| binding::scope_handle(&scope.subject)) + .transpose()?, generation, outcome, live_scopes: u32::try_from(scopes.by_id.len()).map_err(|_| Error::Invalid)?, @@ -1106,6 +1109,7 @@ impl Engine { request: RegisterApprovalScopeRequest2, ) -> Result { self.validate_runtime(&request)?; + let report = request.report_handle; let candidate = ScopeEntry::runtime(request); // Registration and retirement are serialized, so every check below still // holds when the entry is inserted and a failed admission is ours to undo. @@ -1123,6 +1127,7 @@ impl Engine { &candidate.scope, candidate.scope.generation, ApprovalScopeOutcome2::Unchanged, + report, ) } else { Err(Error::Conflict) @@ -1149,7 +1154,13 @@ impl Engine { self.current_generation(&candidate.scope).map(|()| { let (scope, generation) = (candidate.scope.clone(), candidate.scope.generation); scopes.insert(candidate); - Self::receipt(&scopes, &scope, generation, ApprovalScopeOutcome2::Created) + Self::receipt( + &scopes, + &scope, + generation, + ApprovalScopeOutcome2::Created, + report, + ) }) }; match created { @@ -1171,6 +1182,7 @@ impl Engine { request: RegisterApprovalScopeRequest2, ) -> Result { self.validate_runtime(&request)?; + let report = request.report_handle; let candidate = ScopeEntry::runtime(request); let mut scopes = self.scopes.write().map_err(|_| Error::Unavailable)?; // `advance_generation` takes this lock too, so the check is the linearization @@ -1206,6 +1218,7 @@ impl Engine { } else { ApprovalScopeOutcome2::Replaced }, + report, ) } /// Remove a runtime scope. Calls already holding it finish; every later call @@ -1238,7 +1251,13 @@ impl Engine { return Err(error); } let scopes = self.scopes.read().map_err(|_| Error::Unavailable)?; - Self::receipt(&scopes, &removed.scope, 0, ApprovalScopeOutcome2::Retired) + Self::receipt( + &scopes, + &removed.scope, + 0, + ApprovalScopeOutcome2::Retired, + false, + ) } fn correlation(audit: &AuditCorrelation2, generation: u64) -> Result<()> { if !binding::hex(&audit.event_id, 32) || audit.generation != generation { diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 22999873..43bd38d2 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -28,6 +28,7 @@ fn register(n: u64) -> RegisterApprovalScopeRequest2 { scope: scope(n), grants: ApprovalGrants2::default(), audit_stream_id: STREAM.into(), + report_handle: false, } } fn begin_for(n: u64, tool: &str, flow: Option<&str>, origin: Origin) -> EvaluateApprovalRequest2 { @@ -269,6 +270,7 @@ async fn init_scope_can_be_replaced_and_keeps_the_base_stream() -> Result<()> { scope: operator, grants: ApprovalGrants2::default(), audit_stream_id: STREAM.into(), + report_handle: false, })?; assert_eq!(receipt.outcome, ApprovalScopeOutcome2::Replaced); assert_eq!( @@ -985,3 +987,32 @@ async fn grant_revoked_while_the_approval_is_published_is_not_returned() -> Resu ); Ok(()) } + +#[tokio::test] +async fn a_receipt_reports_the_subject_handle_only_when_asked() -> Result<()> { + let (_fixture, owner) = owner()?; + let handle = binding::scope_handle(&subject(21))?; + let quiet = owner.register_scope(register(21)).await?; + assert_eq!(quiet.outcome, ApprovalScopeOutcome2::Created); + assert!(quiet.scope_handle.is_none(), "an older host never asked"); + + let mut asking = register(21); + asking.report_handle = true; + let same = owner.register_scope(asking.clone()).await?; + assert_eq!(same.outcome, ApprovalScopeOutcome2::Unchanged); + assert_eq!(same.scope_handle, Some(handle.clone())); + + asking.grants.tools = vec!["shell".into()]; + let replaced = owner.replace_scope(asking)?; + assert_eq!(replaced.outcome, ApprovalScopeOutcome2::Replaced); + assert_eq!(replaced.scope_handle, Some(handle)); + + let retired = owner + .retire_scope(RetireApprovalScopeRequest2 { + scope_id: register(21).scope.scope_id, + }) + .await?; + assert_eq!(retired.outcome, ApprovalScopeOutcome2::Retired); + assert!(retired.scope_handle.is_none(), "a retirement reports none"); + Ok(()) +} diff --git a/crates/tinysecurity-bus/src/approval_scopes.rs b/crates/tinysecurity-bus/src/approval_scopes.rs index 39d61fb3..e00b2341 100644 --- a/crates/tinysecurity-bus/src/approval_scopes.rs +++ b/crates/tinysecurity-bus/src/approval_scopes.rs @@ -102,6 +102,10 @@ pub struct RegisterApprovalScopeRequest2 { /// Host-persisted random 32-hex audit stream of this subject. Runtime scopes /// always forward to the host `AuditSink2`; they never open a native file. pub audit_stream_id: String, + /// Ask for the subject handle in the receipt (contract 1.18). A host built + /// against an older contract never sets it, so its receipts keep their shape. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub report_handle: bool, } /// Retire one runtime scope. Initialized scopes can be replaced but never retired. @@ -136,7 +140,11 @@ pub struct ApprovalScopeReceipt2 { /// host callback (`ApprovalStore2`, `AuditSink2`, `ApprovalPrompt2`). A host that /// serves many subjects keys its stores by it, so it learns the handle from the /// receipt instead of reproducing the module's derivation (contract 1.18). - pub scope_handle: crate::SubjectScope, + /// Present only when the registration asked for it + /// ([`RegisterApprovalScopeRequest2::report_handle`]) and never on a retirement, + /// so a receipt for an older host keeps its 1.17 shape. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope_handle: Option, /// Generation the scope is bound to (0 once retired). pub generation: u64, /// What happened. diff --git a/crates/tinysecurity-bus/src/approval_scopes_tests.rs b/crates/tinysecurity-bus/src/approval_scopes_tests.rs index 360734aa..5d734d1f 100644 --- a/crates/tinysecurity-bus/src/approval_scopes_tests.rs +++ b/crates/tinysecurity-bus/src/approval_scopes_tests.rs @@ -102,12 +102,27 @@ fn register_request_wire_is_strict_defaults_grants_and_redacts_debug() -> TestRe scope: scope(), grants: ApprovalGrants2::default(), audit_stream_id: "b".repeat(32), + report_handle: false, }; let mut value = serde_json::to_value(&request)?; + assert!( + value.get("report_handle").is_none(), + "a request that does not ask keeps its 1.17 wire shape" + ); assert_eq!( serde_json::from_value::(value.clone())?, request ); + let asking = RegisterApprovalScopeRequest2 { + report_handle: true, + ..request.clone() + }; + let asked = serde_json::to_value(&asking)?; + assert_eq!(asked["report_handle"], true); + assert_eq!( + serde_json::from_value::(asked)?, + asking + ); value.as_object_mut().ok_or("object")?.remove("grants"); assert_eq!( serde_json::from_value::(value.clone())?, @@ -147,11 +162,11 @@ fn retire_and_receipt_wire_round_trip() -> TestResult { ); let receipt = ApprovalScopeReceipt2 { scope_id: "a".repeat(32), - scope_handle: SubjectScope { + scope_handle: Some(SubjectScope { user: "b".repeat(64), workspace: "c".repeat(64), agent: "d".repeat(64), - }, + }), generation: 3, outcome: ApprovalScopeOutcome2::Replaced, live_scopes: 2, @@ -164,6 +179,18 @@ fn retire_and_receipt_wire_round_trip() -> TestResult { receipt ); assert_eq!(format!("{receipt:?}"), "ApprovalScopeReceipt2([REDACTED])"); + // An older host's receipt keeps its shape: no handle is written unless asked for, + // and a receipt without one still decodes. + let quiet = ApprovalScopeReceipt2 { + scope_handle: None, + ..receipt.clone() + }; + let quiet_value = serde_json::to_value(&quiet)?; + assert!(quiet_value.get("scope_handle").is_none()); + assert_eq!( + serde_json::from_value::(quiet_value)?, + quiet + ); for outcome in ["created", "unchanged", "replaced", "retired"] { assert!(serde_json::from_value::(outcome.into()).is_ok()); } diff --git a/crates/tinysecurity-module/examples/approval_scope_native_contract.rs b/crates/tinysecurity-module/examples/approval_scope_native_contract.rs index 8ddec72a..70280077 100644 --- a/crates/tinysecurity-module/examples/approval_scope_native_contract.rs +++ b/crates/tinysecurity-module/examples/approval_scope_native_contract.rs @@ -46,6 +46,7 @@ fn registration(grants: ApprovalGrants2) -> RegisterApprovalScopeRequest2 { }, grants, audit_stream_id: STREAM.into(), + report_handle: false, } } fn audit(sequence: u64) -> AuditCorrelation2 { diff --git a/crates/tinysecurity-module/src/adapter_scope_tests.rs b/crates/tinysecurity-module/src/adapter_scope_tests.rs index 48cc9f58..c02ef936 100644 --- a/crates/tinysecurity-module/src/adapter_scope_tests.rs +++ b/crates/tinysecurity-module/src/adapter_scope_tests.rs @@ -26,6 +26,7 @@ fn registration(grants: ApprovalGrants2) -> RegisterApprovalScopeRequest2 { }, grants, audit_stream_id: STREAM.into(), + report_handle: true, } } fn tenant_begin(instance: u64, sequence: u64, tool: &str) -> EvaluateApprovalRequest2 { @@ -167,7 +168,7 @@ async fn runtime_scope_registers_grants_replaces_and_retires_through_the_authent // The receipt names the handle the host callbacks will carry for this subject, // so a host never has to reproduce the derivation. let handle = tinysecurity::approval::scope_handle(&tenant_subject())?; - assert_eq!(created.scope_handle, handle); + assert_eq!(created.scope_handle, Some(handle)); // A granted tool is approved without a prompt, persisted and audited as the grant. let granted: EvaluateApprovalResponse2 = proxy @@ -226,8 +227,16 @@ async fn replace_then_retire(proxy: &tinybus::Proxy) -> TestResult { assert_eq!(replaced.outcome, ApprovalScopeOutcome2::Replaced); assert_eq!( replaced.scope_handle, - tinysecurity::approval::scope_handle(&tenant_subject())? + Some(tinysecurity::approval::scope_handle(&tenant_subject())?) ); + // A registration that does not ask (an older host) gets the 1.17 receipt. + let mut quiet = registration(ApprovalGrants2::default()); + quiet.report_handle = false; + let unchanged: ApprovalScopeReceipt2 = proxy + .call(names::methods::REPLACE_APPROVAL_SCOPE2, (quiet,)) + .await?; + assert_eq!(unchanged.outcome, ApprovalScopeOutcome2::Unchanged); + assert!(unchanged.scope_handle.is_none()); let after: EvaluateApprovalResponse2 = proxy .call( names::methods::EVALUATE_APPROVAL2, @@ -249,9 +258,9 @@ async fn replace_then_retire(proxy: &tinybus::Proxy) -> TestResult { ) .await?; assert_eq!(retired.outcome, ApprovalScopeOutcome2::Retired); - assert_eq!( - retired.scope_handle, - tinysecurity::approval::scope_handle(&tenant_subject())? + assert!( + retired.scope_handle.is_none(), + "a retirement reports no handle" ); assert!( proxy diff --git a/docs/specs/runtime-approval-scopes.md b/docs/specs/runtime-approval-scopes.md index 7097d090..0e3ad8e2 100644 --- a/docs/specs/runtime-approval-scopes.md +++ b/docs/specs/runtime-approval-scopes.md @@ -62,9 +62,10 @@ configuration re-registered on change), and does not treat a name as a wildcard. - Automatic approvals (grant or posture) are revalidated against the live scope before they are published, before and after the reservation write; replacing a scope revokes unconsumed approvals it no longer supports. -- The receipt of a registration, replacement or retirement carries `scope_handle`, the - sanitized subject the module sends in host callbacks (contract 1.18), so a multi-subject - host keys its stores without reproducing the derivation. +- A registration or replacement that sets `report_handle` gets `scope_handle` in its + receipt, the sanitized subject the module sends in host callbacks (contract 1.18), so a + multi-subject host keys its stores without reproducing the derivation. Without the flag + (an older host) and on a retirement the receipt keeps its 1.17 shape. - Configuring approvals does not refuse the path, command and egress members; only a required audit does (contract 1.18). - Contract 1.17 is additive: older hosts keep working, a 1.17 host refuses a 1.16 From 341154cd5cbc9ef7fd506aae5d9ddd6d663760c6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 13:35:25 +0300 Subject: [PATCH 3/3] fix(module): PolicyInfo reports the configured audit requirement; spec declares 1.18 Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 2 +- docs/specs/runtime-approval-scopes.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index bb08fe02..892ea506 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -834,7 +834,7 @@ impl PolicyService { async fn policy_info(&self) -> BusResult { let mut info = { self.read()?.info() }; info.callbacks.audit = self.audit.is_some(); - info.callbacks.audit_required = self.requires_bound_audit(); + info.callbacks.audit_required = self.audit_required(); info.callbacks.approvals = self.approvals.is_some(); info.callbacks.sandbox = self.sandbox.is_some(); info.callbacks.secret = self.secret.is_some(); diff --git a/docs/specs/runtime-approval-scopes.md b/docs/specs/runtime-approval-scopes.md index 0e3ad8e2..2b98ad4a 100644 --- a/docs/specs/runtime-approval-scopes.md +++ b/docs/specs/runtime-approval-scopes.md @@ -1,6 +1,6 @@ # Runtime approval scopes, tool grants and host tools -Status: implemented. Owner: TinySecurity. Contract vocabulary: 1.17 (extends 1.16). +Status: implemented. Owner: TinySecurity. Contract vocabulary: 1.18 (extends 1.17 and 1.16). Tracker: [OpenHuman #7328](https://github.com/tinyhumansai/openhuman/issues/7328), phase 4. The operator-facing description lives in [`MODULE.md`](../../MODULE.md#runtime-approval-scopes-and-host-tools-contract-117). @@ -68,7 +68,7 @@ configuration re-registered on change), and does not treat a name as a wildcard. (an older host) and on a retirement the receipt keeps its 1.17 shape. - Configuring approvals does not refuse the path, command and egress members; only a required audit does (contract 1.18). -- Contract 1.17 is additive: older hosts keep working, a 1.17 host refuses a 1.16 +- Contracts 1.17 and 1.18 are additive: older hosts keep working (1.18 reports the scope handle only on request), a 1.17 host refuses a 1.16 module. ## Acceptance criteria