diff --git a/MODULE.md b/MODULE.md index 94a25820..15524122 100644 --- a/MODULE.md +++ b/MODULE.md @@ -412,6 +412,26 @@ 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 (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`, +`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..3d14d835 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -1083,12 +1083,16 @@ impl Engine { } fn receipt( scopes: &ScopeTable, - scope_id: &str, + scope: &ApprovalScope2, generation: u64, outcome: ApprovalScopeOutcome2, + report_handle: bool, ) -> Result { Ok(ApprovalScopeReceipt2 { - scope_id: scope_id.into(), + scope_id: scope.scope_id.clone(), + 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)?, @@ -1105,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. @@ -1119,9 +1124,10 @@ impl Engine { { Self::receipt( &scopes, - &candidate.scope.scope_id, + &candidate.scope, candidate.scope.generation, ApprovalScopeOutcome2::Unchanged, + report, ) } else { Err(Error::Conflict) @@ -1146,10 +1152,15 @@ 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, + 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 @@ -1189,7 +1201,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,13 +1211,14 @@ impl Engine { } Self::receipt( &scopes, - &id, + &scope, generation, if same { ApprovalScopeOutcome2::Unchanged } else { ApprovalScopeOutcome2::Replaced }, + report, ) } /// Remove a runtime scope. Calls already holding it finish; every later call @@ -1240,9 +1253,10 @@ impl Engine { let scopes = self.scopes.read().map_err(|_| Error::Unavailable)?; Self::receipt( &scopes, - &request.scope_id, + &removed.scope, 0, ApprovalScopeOutcome2::Retired, + false, ) } fn correlation(audit: &AuditCorrelation2, generation: u64) -> Result<()> { 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 1b6ea15f..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. @@ -132,6 +136,15 @@ 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). + /// 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 d7f8662c..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,17 +162,35 @@ fn retire_and_receipt_wire_round_trip() -> TestResult { ); let receipt = ApprovalScopeReceipt2 { scope_id: "a".repeat(32), + scope_handle: Some(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 ); 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-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/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.rs b/crates/tinysecurity-module/src/adapter.rs index 57496ed7..892ea506 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", )); @@ -824,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/crates/tinysecurity-module/src/adapter_scope_tests.rs b/crates/tinysecurity-module/src/adapter_scope_tests.rs index 4f85c166..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 { @@ -164,6 +165,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, Some(handle)); // A granted tool is approved without a prompt, persisted and audited as the grant. let granted: EvaluateApprovalResponse2 = proxy @@ -220,6 +225,18 @@ async fn replace_then_retire(proxy: &tinybus::Proxy) -> TestResult { ) .await?; assert_eq!(replaced.outcome, ApprovalScopeOutcome2::Replaced); + assert_eq!( + replaced.scope_handle, + 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, @@ -241,6 +258,10 @@ async fn replace_then_retire(proxy: &tinybus::Proxy) -> TestResult { ) .await?; assert_eq!(retired.outcome, ApprovalScopeOutcome2::Retired); + assert!( + retired.scope_handle.is_none(), + "a retirement reports no handle" + ); assert!( proxy .call::( @@ -292,3 +313,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..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). @@ -62,7 +62,13 @@ 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. -- Contract 1.17 is additive: older hosts keep working, a 1.17 host refuses a 1.16 +- 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). +- 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