From dd6fe87b0fae3a2ea6339da1ac29b4755e162c3f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 02:02:42 +0300 Subject: [PATCH 01/12] feat(approval): runtime approval scopes, tool and flow grants, host-tool check (contract 1.17) Part of tinyhumansai/openhuman#7328 (phase 4 approvals). - RegisterApprovalScope2 / ReplaceApprovalScope2 / RetireApprovalScope2 add, change and remove subjects after init (SaaS tenants, derived agents); the ports admit the store scope and audit stream first, runtime scopes forward audit to the host sink. - ApprovalGrants2: exact tool and per-flow tool grants convert a required approval into an audited auto:tool-grant / auto:flow-grant row; never override denials, Cron external effects or human-only tools. - Check::HostTool lets the host request approval for arbitrary external-effect tools without relaxing path or command checks. - Native example approval_scope_native_contract runs it against the loaded cdylib. Co-authored-by: Medulla --- .github/workflows/ci.yml | 4 + MODULE.md | 42 ++ README.md | 2 +- crates/tinysecurity-approval/README.md | 4 + crates/tinysecurity-approval/src/binding.rs | 12 +- crates/tinysecurity-approval/src/engine.rs | 356 +++++++++++-- .../src/engine_scopes_tests.rs | 486 ++++++++++++++++++ .../tinysecurity-approval/src/engine_tests.rs | 26 + .../tinysecurity-bus/src/approval_scopes.rs | 161 ++++++ .../src/approval_scopes_tests.rs | 195 +++++++ crates/tinysecurity-bus/src/lib.rs | 4 +- crates/tinysecurity-bus/src/names.rs | 9 + crates/tinysecurity-bus/src/policy.rs | 5 + crates/tinysecurity-bus/tests/scoped_wire.rs | 7 +- crates/tinysecurity-bus/tests/wire.rs | 2 +- .../approval_scope_native_contract.rs | 390 ++++++++++++++ crates/tinysecurity-module/src/adapter.rs | 35 +- .../src/adapter_approval_tests.rs | 2 + .../src/adapter_scope_tests.rs | 294 +++++++++++ .../src/approval_callbacks.rs | 92 +++- .../src/approval_callbacks_tests.rs | 92 ++++ crates/tinysecurity-policy/src/engine.rs | 14 +- .../tinysecurity-policy/src/engine_tests.rs | 152 ++++++ crates/tinysecurity-policy/src/scoped.rs | 1 + 24 files changed, 2328 insertions(+), 59 deletions(-) create mode 100644 crates/tinysecurity-approval/src/engine_scopes_tests.rs create mode 100644 crates/tinysecurity-bus/src/approval_scopes.rs create mode 100644 crates/tinysecurity-bus/src/approval_scopes_tests.rs create mode 100644 crates/tinysecurity-module/examples/approval_scope_native_contract.rs create mode 100644 crates/tinysecurity-module/src/adapter_scope_tests.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6740d8d..b4be05c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -171,6 +171,8 @@ jobs: $verifier = (Resolve-Path 'target/debug/examples/verify_module.exe').Path cargo run --locked --package tinysecurity-module --example approval_native_contract -- $verifiedModule if ($LASTEXITCODE -ne 0) { throw 'native approval verifier failed in workspace' } + cargo run --locked --package tinysecurity-module --example approval_scope_native_contract -- $verifiedModule + if ($LASTEXITCODE -ne 0) { throw 'native approval scope verifier failed in workspace' } cargo run --locked --package tinysecurity-module --example judge_native_contract -- $verifiedModule if ($LASTEXITCODE -ne 0) { throw 'native judge verifier failed in workspace' } cargo run --locked --package tinysecurity-module --example secret_cipher_native_contract -- $verifiedModule @@ -224,6 +226,7 @@ jobs: cargo run -p tinysecurity-module --example verify_module -- "$module" cargo run -p tinysecurity-module --example audit_native_contract -- "$module" cargo run -p tinysecurity-module --example approval_native_contract -- "$module" + cargo run -p tinysecurity-module --example approval_scope_native_contract -- "$module" cargo run -p tinysecurity-module --example judge_native_contract -- "$module" cargo run -p tinysecurity-module --example sandbox_native_contract -- "$module" cargo run -p tinysecurity-module --example secret_cipher_native_contract -- "$module" @@ -239,6 +242,7 @@ jobs: (cd target && ./debug/examples/verify_module "$module") (cd target && ./debug/examples/audit_native_contract "$module") (cd target && ./debug/examples/approval_native_contract "$module") + (cd target && ./debug/examples/approval_scope_native_contract "$module") (cd target && ./debug/examples/judge_native_contract "$module") (cd target && ./debug/examples/sandbox_native_contract "$module") diff --git a/MODULE.md b/MODULE.md index 3dacfbf..6517f7f 100644 --- a/MODULE.md +++ b/MODULE.md @@ -364,6 +364,48 @@ verdict is the executor's risk. `PinnedDirect` stays the only pinned transport, a local capability never accepts a preflight transport. Both fields are omitted from the wire when unset, so existing policies serialize unchanged. +## Runtime approval scopes and host tools (contract 1.17) + +Additive: a 1.16 host keeps working against a 1.17 module, and a 1.17 host refuses +a 1.16 module (it requires the three scope members and `Check::HostTool`). + +`ApprovalConfig2` fixes at most 128 operator scopes at init. A host that serves +many subjects (SaaS tenants, derived embedded agents) registers each one later: + +| Member | Effect | +| --- | --- | +| `RegisterApprovalScope2` | Adds a scope. An identical repeat is `unchanged`; the same identity or the same subject with anything else is a conflict. A stale generation, bad grants or a malformed stream deny. | +| `ReplaceApprovalScope2` | Atomically changes posture, grants or `policy_ref` of a live scope (init scopes included). The subject and, for runtime scopes, the audit stream cannot change. Rows bound to another `policy_ref` stop being decidable. | +| `RetireApprovalScope2` | Removes a scope; every later call under it denies and the ports release the subject. | + +All three are private host-only members served to the authenticated init peer, +like the other registrations. The table holds at most 65,536 live scopes. The +ports must admit a subject (its store scope and audit stream) before it becomes +usable: runtime scopes carry a host-persisted `audit_stream_id` and always forward +audit to the host `AuditSink2`; they never open a native journal. The engine owns +the generation, so repeating a registration after a policy advance is `unchanged` +rather than a new scope. + +`ApprovalGrants2` rides on a registration: exact tool names, and exact tool names +inside one named flow. A grant only converts a required approval into an +automatic one (`auto:tool-grant`, `auto:flow-grant`), persisted and audited like +every other row. It applies to SaaS origins (the scope is the tenant's own), but +never overrides a denial, a Cron external effect, reviewed reuse or a human-only +tool (`HUMAN_ONLY_TOOLS2`, `native_*`), which registration also refuses to name. +Names are never wildcards. Name-wide permanence is therefore host configuration +re-registered with `ReplaceApprovalScope2`, not a persisted human row: durable +`Tool`/`Flow` choices stay exact-content grants. + +`Check::HostTool {}` lets the host request approval for a tool whose effect the +module cannot inspect (`gmail_send`, a Composio action). The module never +authorizes it by itself (alone it is `approval_unavailable`, which the approval +path treats as "needs a human"), and it never relaxes a `Path` or `Command` +check carried beside it. It is valid only for tool names outside the module's own +effect classes (`local_diagnostic`, `path_check`, `command_check`), the human-only +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. + ## 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 fa79a9b..9086743 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.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.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/README.md b/crates/tinysecurity-approval/README.md index cb7dbe6..fd76afd 100644 --- a/crates/tinysecurity-approval/README.md +++ b/crates/tinysecurity-approval/README.md @@ -5,6 +5,10 @@ `ConsumeApproval2`, `RecordExecution2`, `ListApprovals2`, `CancelApproval2`, `ExpireApproval2`, `PreauthorizeFlow2`, and `ImportApprovalHistory2`. `ParseReply` recognizes intent and never supplies human authority. +Contract 1.17 adds `RegisterApprovalScope2`, `ReplaceApprovalScope2` and +`RetireApprovalScope2` for subjects registered after init, tool and flow grants +(`auto:tool-grant`, `auto:flow-grant`) and `Check::HostTool`; see +[MODULE.md](../../MODULE.md#runtime-approval-scopes-and-host-tools-contract-117). ## Initialization and callbacks diff --git a/crates/tinysecurity-approval/src/binding.rs b/crates/tinysecurity-approval/src/binding.rs index 41ce46d..96ceb62 100644 --- a/crates/tinysecurity-approval/src/binding.rs +++ b/crates/tinysecurity-approval/src/binding.rs @@ -139,7 +139,10 @@ pub(crate) fn original( let external = request.call.checks.iter().any(|check| match check { Check::LocalDiagnostic {} => false, Check::Path { write, .. } => *write, - Check::Command { .. } | Check::PolicyChange { .. } | Check::Unknown { .. } => true, + Check::Command { .. } + | Check::PolicyChange { .. } + | Check::HostTool {} + | Check::Unknown { .. } => true, }); let mut value = serde_json::json!({"scope":reg.subject,"policy_ref":reg.policy_ref,"generation":reg.generation,"caller":request.caller,"tool":request.call.tool,"checks":request.call.checks,"arguments":args,"external_effect":external}); depth(&value, 0)?; @@ -273,7 +276,12 @@ fn lifecycle_valid(row: &ApprovalRow2) -> bool { && (row.decision_ticket_sha256.is_some() || matches!( row.attribution.as_deref(), - Some("auto:host-policy" | "exact-reviewed-grant") + Some( + "auto:host-policy" + | "auto:tool-grant" + | "auto:flow-grant" + | "exact-reviewed-grant" + ) ) || (row.attribution.as_deref() == Some("auto:jev") && row.choice == Some(ApprovalChoice2::Once) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 57c7af6..3f9bdbe 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -7,11 +7,13 @@ use std::sync::{ }; use tinysecurity_bus::{ ApprovalActionRequest2, ApprovalCasRequest2, ApprovalCasResponse2, ApprovalChoice2, - ApprovalConfig2, ApprovalDeliveryRequest2, ApprovalDeliveryResponse2, ApprovalHistoryImport2, - ApprovalLifecycle2, ApprovalLoadRequest2, ApprovalLoadResponse2, ApprovalRecordRequest2, - ApprovalRow2, ApprovalScope2, AuditCorrelation2, CallerContext, Check, DecideRequest2, - EvaluateApprovalRequest2, EvaluateRequest, HumanDecisionReceipt2, Origin, - PreauthorizeFlowRequest2, RecordExecutionRequest2, RedactMode, SubjectScope, ToolCall, + ApprovalConfig2, ApprovalDeliveryRequest2, ApprovalDeliveryResponse2, ApprovalGrants2, + ApprovalHistoryImport2, ApprovalLifecycle2, ApprovalLoadRequest2, ApprovalLoadResponse2, + ApprovalRecordRequest2, ApprovalRow2, ApprovalScope2, ApprovalScopeOutcome2, + ApprovalScopeReceipt2, AuditCorrelation2, CallerContext, Check, DecideRequest2, + EvaluateApprovalRequest2, EvaluateRequest, HUMAN_ONLY_TOOLS2, HumanDecisionReceipt2, + MAX_APPROVAL_SCOPES2, Origin, PreauthorizeFlowRequest2, RecordExecutionRequest2, RedactMode, + RegisterApprovalScopeRequest2, RetireApprovalScopeRequest2, SubjectScope, ToolCall, VerifyHumanRequest2, }; use tinysecurity_redact::Redactor; @@ -140,6 +142,19 @@ pub trait Ports: Send + Sync + std::fmt::Debug { async fn native_audit(&self, _row: ApprovalRow2) -> Result { Err(Error::Denied) } + /// Admit a runtime subject to the store and audit ports before it becomes usable. + /// The default refuses, so a port that cannot serve a new subject fails closed. + /// # Errors + /// Rejects subjects the ports cannot serve with an authenticated, bounded stream. + async fn admit_scope(&self, _scope: &ApprovalScope2, _audit_stream_id: &str) -> Result<()> { + Err(Error::Denied) + } + /// Forget a retired subject. The default has nothing to forget. + /// # Errors + /// Fails closed when the ports cannot release the subject. + async fn release_scope(&self, _scope: &ApprovalScope2) -> Result<()> { + Ok(()) + } } /// Epoch and monotonic clocks injected together for deterministic expiration. pub trait Clock: Send + Sync + std::fmt::Debug { @@ -167,6 +182,98 @@ impl Clock for SystemClock { u64::try_from(self.0.elapsed().as_millis()).unwrap_or(u64::MAX) } } +/// Live registrations by identity, with a subject index so admission stays linear. +#[derive(Default)] +struct ScopeTable { + by_id: std::collections::BTreeMap>, + subjects: std::collections::BTreeSet<(String, String, String)>, +} +impl ScopeTable { + fn key(scope: &ApprovalScope2) -> (String, String, String) { + let subject = &scope.subject; + ( + subject.user.clone(), + subject.workspace.clone(), + subject.agent.clone(), + ) + } + fn holds_subject(&self, scope: &ApprovalScope2) -> bool { + self.subjects.contains(&Self::key(scope)) + } + fn insert(&mut self, entry: ScopeEntry) -> bool { + let key = Self::key(&entry.scope); + let fresh = self.subjects.insert(key); + self.by_id + .insert(entry.scope.scope_id.clone(), Arc::new(entry)); + fresh + } + fn remove(&mut self, id: &str) -> Option> { + let removed = self.by_id.remove(id)?; + self.subjects.remove(&Self::key(&removed.scope)); + Some(removed) + } +} +/// One live registration with its host-configured automatic approvals. +struct ScopeEntry { + scope: ApprovalScope2, + tools: std::collections::BTreeSet, + flows: std::collections::BTreeSet<(String, String)>, + /// Runtime scopes own an audit stream; init scopes share the base stream. + audit_stream_id: Option, +} +impl ScopeEntry { + fn init(scope: ApprovalScope2) -> Self { + Self { + scope, + tools: std::collections::BTreeSet::new(), + flows: std::collections::BTreeSet::new(), + audit_stream_id: None, + } + } + fn runtime(request: RegisterApprovalScopeRequest2) -> Self { + let ApprovalGrants2 { tools, flows } = request.grants; + Self { + scope: request.scope, + tools: tools.into_iter().collect(), + flows: flows.into_iter().map(|g| (g.flow_id, g.tool)).collect(), + audit_stream_id: Some(request.audit_stream_id), + } + } + /// Host-configured grants only convert a required approval; denials and + /// automatic scopes were decided before, and reviewed reuse never reaches here. + fn grant_for( + &self, + request: &EvaluateApprovalRequest2, + rule: tinysecurity_auto::rules::Rule, + ) -> Option<&'static str> { + (rule == tinysecurity_auto::rules::Rule::Approval) + .then(|| { + self.grant( + &request.request.call.tool, + request.request.caller.flow.as_deref(), + ) + }) + .flatten() + } + /// Which host-configured grant, if any, approves this exact tool and flow. + /// Human-only tools never match, even if a grant names them. + fn grant(&self, tool: &str, flow: Option<&str>) -> Option<&'static str> { + if HUMAN_ONLY_TOOLS2.contains(&tool) || binding::is_native(tool) { + return None; + } + if self.tools.contains(tool) { + return Some("auto:tool-grant"); + } + flow.filter(|flow| self.flows.contains(&((*flow).to_owned(), tool.to_owned()))) + .map(|_| "auto:flow-grant") + } + /// Equal registration apart from the generation, which the engine owns. + fn same_posture(&self, other: &Self) -> bool { + let mut scope = other.scope.clone(); + scope.generation = self.scope.generation; + self.scope == scope && self.tools == other.tools && self.flows == other.flows + } +} struct NativeIntent { original: ApprovalRow2, decision: Option, @@ -174,7 +281,7 @@ struct NativeIntent { } /// Immutable subject engine. Host-instance ownership prevents orphan execution. pub struct Engine { - config: ApprovalConfig2, + scopes: RwLock, ports: Arc, clock: Arc, redactor: RwLock, @@ -209,33 +316,22 @@ impl Engine { { return Err(Error::Invalid); } - let mut ids = std::collections::BTreeSet::new(); - for scope in &config.scopes { - if scope.scope_id.len() != 32 - || !scope.scope_id.bytes().all(|b| b.is_ascii_hexdigit()) - || !ids.insert(&scope.scope_id) - || scope.policy_ref.is_empty() - || scope.policy_ref.len() > 1024 - || scope.generation == 0 - || [ - &scope.subject.user, - &scope.subject.workspace, - &scope.subject.agent, - ] - .iter() - .any(|s| s.is_empty() || s.len() > 1024) - { - return Err(Error::Invalid); - } - } let generation = config.scopes[0].generation; if config.scopes.iter().any(|s| s.generation != generation) { return Err(Error::Invalid); } + let mut entries = ScopeTable::default(); + for scope in config.scopes { + Self::validate_scope(&scope)?; + if entries.by_id.contains_key(&scope.scope_id) { + return Err(Error::Invalid); + } + entries.insert(ScopeEntry::init(scope)); + } let wall = clock.wall_ms().ok_or(Error::Unavailable)?; let time = (wall, clock.monotonic_ms(), wall, false); Ok(Self { - config, + scopes: RwLock::new(entries), ports, clock, redactor: RwLock::new(redactor), @@ -352,6 +448,7 @@ impl Engine { request: EvaluateApprovalRequest2, deadline: Option, ) -> Result { + let entry = self.entry(&request.scope_id)?; let reg = self.registration(&request.scope_id)?; let (bound, args_redacted, external_effect) = binding::original(®, &request.request, &self.redactor()?)?; @@ -383,7 +480,11 @@ impl Engine { let (reusable, human_only) = self .begin_reuse(&request, ®, &bound, &rows, now, deadline.is_some()) .await?; - let automatic = !human_only && rule == tinysecurity_auto::rules::Rule::Automatic; + let granted = (!human_only && reusable.is_none()) + .then(|| entry.grant_for(&request, rule)) + .flatten(); + let automatic = + !human_only && (rule == tinysecurity_auto::rules::Rule::Automatic || granted.is_some()); let judge = if !human_only && reusable.is_none() && !automatic { self.score(&request, &bound, external_effect).await? } else { @@ -428,13 +529,7 @@ impl Engine { ApprovalLifecycle2::Pending }, choice: approved.then_some(ApprovalChoice2::Once), - attribution: if reusable.is_some() { - Some("exact-reviewed-grant".into()) - } else if qualified { - Some("auto:jev".into()) - } else { - automatic.then(|| "auto:host-policy".into()) - }, + attribution: Self::attribution(reusable.is_some(), qualified, granted, automatic), decision_ticket_sha256: None, outcome: None, reservation_id: None, @@ -446,6 +541,24 @@ impl Engine { self.finish_native_begin(®, row, now, deadline.is_some()) .await } + /// Who approved a row that needed no human: reviewed reuse, the judge, a host + /// grant or the scope's own automatic posture. + fn attribution( + reused: bool, + qualified: bool, + granted: Option<&'static str>, + automatic: bool, + ) -> Option { + if reused { + Some("exact-reviewed-grant".into()) + } else if qualified { + Some("auto:jev".into()) + } else { + granted + .map(Into::into) + .or_else(|| automatic.then(|| "auto:host-policy".into())) + } + } fn begin_tool_label(&self, tool: &str, native: bool) -> Result { if native { Ok(tool.into()) @@ -887,16 +1000,179 @@ impl Engine { self.registration(id)?.policy_ref.as_bytes(), )) } + fn entry(&self, id: &str) -> Result> { + self.scopes + .read() + .map_err(|_| Error::Unavailable)? + .by_id + .get(id) + .cloned() + .ok_or(Error::Denied) + } fn registration(&self, id: &str) -> Result { - let mut reg = self - .config - .scopes + let mut reg = self.entry(id)?.scope.clone(); + reg.generation = self.generation.load(Ordering::SeqCst); + Ok(reg) + } + fn validate_scope(scope: &ApprovalScope2) -> Result<()> { + if scope.scope_id.len() != 32 + || !scope.scope_id.bytes().all(|b| b.is_ascii_hexdigit()) + || scope.policy_ref.is_empty() + || scope.policy_ref.len() > 1024 + || scope.generation == 0 + || [ + &scope.subject.user, + &scope.subject.workspace, + &scope.subject.agent, + ] .iter() - .find(|s| s.scope_id == id) + .any(|s| s.is_empty() || s.len() > 1024) + { + return Err(Error::Invalid); + } + Ok(()) + } + /// Validate a runtime request: scope shape, current generation, grants and stream. + fn validate_runtime(&self, request: &RegisterApprovalScopeRequest2) -> Result<()> { + Self::validate_scope(&request.scope)?; + if !request.grants.is_valid() || !binding::hex(&request.audit_stream_id, 32) { + return Err(Error::Invalid); + } + if request.scope.generation != self.generation.load(Ordering::SeqCst) { + return Err(Error::Denied); + } + Ok(()) + } + fn receipt( + scopes: &ScopeTable, + scope_id: &str, + generation: u64, + outcome: ApprovalScopeOutcome2, + ) -> Result { + Ok(ApprovalScopeReceipt2 { + scope_id: scope_id.into(), + generation, + outcome, + live_scopes: u32::try_from(scopes.by_id.len()).map_err(|_| Error::Invalid)?, + }) + } + /// Add a runtime subject after init. An identical repeat is idempotent; any other + /// reuse of the identity or of the subject conflicts. The ports admit the subject + /// (store and audit stream) before it becomes usable. + /// # Errors + /// Rejects invalid, stale-generation, conflicting or over-capacity registrations + /// and subjects the ports cannot admit. + pub async fn register_scope( + &self, + request: RegisterApprovalScopeRequest2, + ) -> Result { + self.validate_runtime(&request)?; + let candidate = ScopeEntry::runtime(request); + { + let scopes = self.scopes.read().map_err(|_| Error::Unavailable)?; + if let Some(existing) = scopes.by_id.get(&candidate.scope.scope_id) { + return if existing.same_posture(&candidate) + && existing.audit_stream_id == candidate.audit_stream_id + { + Self::receipt( + &scopes, + &candidate.scope.scope_id, + candidate.scope.generation, + ApprovalScopeOutcome2::Unchanged, + ) + } else { + Err(Error::Conflict) + }; + } + if scopes.by_id.len() >= MAX_APPROVAL_SCOPES2 { + return Err(Error::Unavailable); + } + if scopes.holds_subject(&candidate.scope) { + return Err(Error::Conflict); + } + } + let stream = candidate.audit_stream_id.clone().unwrap_or_default(); + self.ports.admit_scope(&candidate.scope, &stream).await?; + let mut scopes = self.scopes.write().map_err(|_| Error::Unavailable)?; + // The ports awaited with no lock held, so re-check what another call may have won. + if scopes.by_id.len() >= MAX_APPROVAL_SCOPES2 { + return Err(Error::Unavailable); + } + if scopes.by_id.contains_key(&candidate.scope.scope_id) + || scopes.holds_subject(&candidate.scope) + { + return Err(Error::Conflict); + } + let (id, generation) = (candidate.scope.scope_id.clone(), candidate.scope.generation); + scopes.insert(candidate); + Self::receipt(&scopes, &id, generation, ApprovalScopeOutcome2::Created) + } + /// Atomically replace the posture, grants or policy reference of a live scope. + /// The subject and, for runtime scopes, the audit stream cannot change. Pending + /// rows bound to a different `policy_ref` stop being decidable, which is the + /// intended invalidation. + /// # Errors + /// Rejects unknown scopes, a changed subject or stream, and invalid input. + pub fn replace_scope( + &self, + request: RegisterApprovalScopeRequest2, + ) -> Result { + self.validate_runtime(&request)?; + let candidate = ScopeEntry::runtime(request); + let mut scopes = self.scopes.write().map_err(|_| Error::Unavailable)?; + let existing = scopes + .by_id + .get(&candidate.scope.scope_id) .cloned() .ok_or(Error::Denied)?; - reg.generation = self.generation.load(Ordering::SeqCst); - Ok(reg) + if existing.scope.subject != candidate.scope.subject + || existing + .audit_stream_id + .as_ref() + .is_some_and(|stream| Some(stream) != candidate.audit_stream_id.as_ref()) + { + return Err(Error::Denied); + } + let (id, generation) = (candidate.scope.scope_id.clone(), candidate.scope.generation); + let same = existing.same_posture(&candidate); + if !same { + // Keep the original stream owner: init scopes stay on the base stream. + let mut next = candidate; + next.audit_stream_id.clone_from(&existing.audit_stream_id); + scopes.insert(next); + } + Self::receipt( + &scopes, + &id, + generation, + if same { + ApprovalScopeOutcome2::Unchanged + } else { + ApprovalScopeOutcome2::Replaced + }, + ) + } + /// Remove a scope. Calls already holding it finish; every later call denies. + /// # Errors + /// Rejects unknown scopes and ports that cannot release the subject. + pub async fn retire_scope( + &self, + request: RetireApprovalScopeRequest2, + ) -> Result { + let removed = self + .scopes + .write() + .map_err(|_| Error::Unavailable)? + .remove(&request.scope_id) + .ok_or(Error::Denied)?; + self.ports.release_scope(&removed.scope).await?; + let scopes = self.scopes.read().map_err(|_| Error::Unavailable)?; + Self::receipt( + &scopes, + &request.scope_id, + 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-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs new file mode 100644 index 0000000..11ea6dc --- /dev/null +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -0,0 +1,486 @@ +use super::*; + +const STREAM: &str = "5555555555555555555555555555555a"; + +fn id(n: u64) -> String { + format!("{n:032x}") +} +fn subject(n: u64) -> SubjectScope { + SubjectScope { + user: format!("user-{n}"), + workspace: "workspace".into(), + agent: format!("agent-{n}"), + } +} +fn scope(n: u64) -> ApprovalScope2 { + ApprovalScope2 { + scope_id: id(n), + subject: subject(n), + policy_ref: "runtime-policy-v1".into(), + generation: 1, + ceiling: AccessTier::Full, + require_approval: true, + auto_approve_all: false, + } +} +fn register(n: u64) -> RegisterApprovalScopeRequest2 { + RegisterApprovalScopeRequest2 { + scope: scope(n), + grants: ApprovalGrants2::default(), + audit_stream_id: STREAM.into(), + } +} +fn begin_for(n: u64, tool: &str, flow: Option<&str>, origin: Origin) -> EvaluateApprovalRequest2 { + let mut req = request(); + req.scope_id = id(n); + req.request.caller.agent = format!("agent-{n}"); + req.request.caller.flow = flow.map(Into::into); + req.request.caller.origin = origin; + req.request.call.tool = tool.into(); + req +} +fn owner() -> Result<(Arc, Engine)> { + let fixture = Arc::new(Fixture::default()); + let clock = Arc::new(TestClock::default()); + clock.wall.store(1000, Ordering::SeqCst); + let owner = engine(fixture.clone(), clock)?; + Ok((fixture, owner)) +} +fn grants(tools: &[&str], flows: &[(&str, &str)]) -> ApprovalGrants2 { + ApprovalGrants2 { + tools: tools.iter().map(|t| (*t).into()).collect(), + flows: flows + .iter() + .map(|(flow, tool)| FlowToolGrant2 { + flow_id: (*flow).into(), + tool: (*tool).into(), + }) + .collect(), + } +} +use tinysecurity_bus::FlowToolGrant2; + +#[tokio::test] +async fn unregistered_scope_denies_until_registered_then_serves_its_own_subject() -> Result<()> { + let (fixture, owner) = owner()?; + assert!( + owner + .begin(begin_for(7, "local_diagnostic", None, Origin::Operator)) + .await + .is_err() + ); + let receipt = owner.register_scope(register(7)).await?; + assert_eq!(receipt.outcome, ApprovalScopeOutcome2::Created); + assert_eq!( + ( + receipt.scope_id.as_str(), + receipt.generation, + receipt.live_scopes + ), + (id(7).as_str(), 1, 2) + ); + assert_eq!( + *fixture.admitted.lock().map_err(|_| Error::Unavailable)?, + vec![(id(7), STREAM.to_owned())] + ); + let row = owner + .begin(begin_for(7, "local_diagnostic", None, Origin::Operator)) + .await?; + assert_eq!(row.state, ApprovalLifecycle2::Pending); + assert_eq!(row.binding.scope, binding::scope_handle(&subject(7))?); + Ok(()) +} + +#[tokio::test] +async fn init_scope_keeps_working_beside_registered_ones() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + let row = owner.begin(request()).await?; + assert_eq!( + row.binding.scope, + binding::scope_handle(&config().scopes[0].subject)? + ); + Ok(()) +} + +#[tokio::test] +async fn identical_registration_is_idempotent_and_any_other_reuse_conflicts() -> Result<()> { + let (fixture, owner) = owner()?; + owner.register_scope(register(7)).await?; + let again = owner.register_scope(register(7)).await?; + assert_eq!(again.outcome, ApprovalScopeOutcome2::Unchanged); + // Only one admission reached the ports. + assert_eq!( + fixture + .admitted + .lock() + .map_err(|_| Error::Unavailable)? + .len(), + 1 + ); + let mut changed = register(7); + changed.scope.auto_approve_all = true; + assert_eq!( + owner.register_scope(changed).await.err(), + Some(Error::Conflict) + ); + let mut other_stream = register(7); + other_stream.audit_stream_id = "6".repeat(32); + assert_eq!( + owner.register_scope(other_stream).await.err(), + Some(Error::Conflict) + ); + let mut same_subject = register(7); + same_subject.scope.scope_id = id(8); + assert_eq!( + owner.register_scope(same_subject).await.err(), + Some(Error::Conflict) + ); + // Reusing the operator's init subject under a new identity conflicts as well. + let mut operator = register(9); + operator.scope.subject = config().scopes[0].subject.clone(); + assert_eq!( + owner.register_scope(operator).await.err(), + Some(Error::Conflict) + ); + let mut init_id = register(9); + init_id.scope.scope_id = "a".repeat(32); + assert_eq!( + owner.register_scope(init_id).await.err(), + Some(Error::Conflict) + ); + Ok(()) +} + +#[tokio::test] +async fn registration_validates_scope_generation_grants_stream_and_ports() -> Result<()> { + let (fixture, owner) = owner()?; + let mut bad_scope = register(7); + bad_scope.scope.policy_ref.clear(); + assert_eq!( + owner.register_scope(bad_scope).await.err(), + Some(Error::Invalid) + ); + let mut bad_stream = register(7); + bad_stream.audit_stream_id = "short".into(); + assert_eq!( + owner.register_scope(bad_stream).await.err(), + Some(Error::Invalid) + ); + let mut human_only = register(7); + human_only.grants = grants(&["device_crypto"], &[]); + assert_eq!( + owner.register_scope(human_only).await.err(), + Some(Error::Invalid) + ); + let mut stale = register(7); + stale.scope.generation = 2; + assert_eq!(owner.register_scope(stale).await.err(), Some(Error::Denied)); + fixture.admit_fails.store(1, Ordering::SeqCst); + assert_eq!( + owner.register_scope(register(7)).await.err(), + Some(Error::Unavailable) + ); + // A scope the ports refused never becomes usable. + assert!( + owner + .begin(begin_for(7, "local_diagnostic", None, Origin::Operator)) + .await + .is_err() + ); + fixture.admit_fails.store(0, Ordering::SeqCst); + owner.register_scope(register(7)).await?; + owner.advance_generation(2)?; + let mut old = register(8); + old.scope.generation = 1; + assert_eq!(owner.register_scope(old).await.err(), Some(Error::Denied)); + // The engine owns the generation: an identical repeat after an advance is unchanged. + let mut repeat = register(7); + repeat.scope.generation = 2; + assert_eq!( + owner.register_scope(repeat).await?.outcome, + ApprovalScopeOutcome2::Unchanged + ); + Ok(()) +} + +#[tokio::test] +async fn scope_table_is_bounded() -> Result<()> { + let (_, owner) = owner()?; + for n in 1..MAX_APPROVAL_SCOPES2 as u64 { + owner.register_scope(register(n + 100)).await?; + } + assert_eq!( + owner.register_scope(register(1)).await.err(), + Some(Error::Unavailable) + ); + owner + .retire_scope(RetireApprovalScopeRequest2 { scope_id: id(101) }) + .await?; + assert_eq!( + owner.register_scope(register(1)).await?.outcome, + ApprovalScopeOutcome2::Created + ); + Ok(()) +} + +#[tokio::test] +async fn replace_changes_posture_atomically_and_keeps_subject_and_stream() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + let pending = owner + .begin(begin_for(7, "local_diagnostic", None, Origin::Operator)) + .await?; + assert_eq!(pending.state, ApprovalLifecycle2::Pending); + let mut auto = register(7); + auto.scope.auto_approve_all = true; + assert_eq!( + owner.replace_scope(auto.clone())?.outcome, + ApprovalScopeOutcome2::Replaced + ); + assert_eq!( + owner.replace_scope(auto)?.outcome, + ApprovalScopeOutcome2::Unchanged + ); + let approved = owner + .begin(begin_for(7, "other_tool", None, Origin::Operator)) + .await?; + assert_eq!(approved.state, ApprovalLifecycle2::Approved); + assert_eq!(approved.attribution.as_deref(), Some("auto:host-policy")); + let mut moved = register(7); + moved.scope.subject = subject(99); + assert_eq!(owner.replace_scope(moved).err(), Some(Error::Denied)); + let mut other_stream = register(7); + other_stream.audit_stream_id = "6".repeat(32); + assert_eq!(owner.replace_scope(other_stream).err(), Some(Error::Denied)); + assert_eq!(owner.replace_scope(register(40)).err(), Some(Error::Denied)); + let mut invalid = register(7); + invalid.grants = grants(&["policy_change"], &[]); + assert_eq!(owner.replace_scope(invalid).err(), Some(Error::Invalid)); + Ok(()) +} + +#[tokio::test] +async fn init_scope_can_be_replaced_and_keeps_the_base_stream() -> Result<()> { + let (_, owner) = owner()?; + let mut operator = config().scopes[0].clone(); + operator.auto_approve_all = true; + let receipt = owner.replace_scope(RegisterApprovalScopeRequest2 { + scope: operator, + grants: ApprovalGrants2::default(), + audit_stream_id: STREAM.into(), + })?; + assert_eq!(receipt.outcome, ApprovalScopeOutcome2::Replaced); + assert_eq!( + owner.begin(request()).await?.state, + ApprovalLifecycle2::Approved + ); + Ok(()) +} + +#[tokio::test] +async fn retire_denies_later_calls_releases_the_subject_and_reports_once() -> Result<()> { + let (fixture, owner) = owner()?; + owner.register_scope(register(7)).await?; + let receipt = owner + .retire_scope(RetireApprovalScopeRequest2 { scope_id: id(7) }) + .await?; + assert_eq!( + (receipt.outcome, receipt.generation, receipt.live_scopes), + (ApprovalScopeOutcome2::Retired, 0, 1) + ); + assert_eq!( + *fixture.released.lock().map_err(|_| Error::Unavailable)?, + vec![id(7)] + ); + assert!( + owner + .begin(begin_for(7, "local_diagnostic", None, Origin::Operator)) + .await + .is_err() + ); + assert_eq!( + owner + .retire_scope(RetireApprovalScopeRequest2 { scope_id: id(7) }) + .await + .err(), + Some(Error::Denied) + ); + // The identity and subject are free again. + owner.register_scope(register(7)).await?; + fixture.release_fails.store(1, Ordering::SeqCst); + assert_eq!( + owner + .retire_scope(RetireApprovalScopeRequest2 { scope_id: id(7) }) + .await + .err(), + Some(Error::Unavailable) + ); + // A failed release still leaves the scope unusable. + assert!( + owner + .begin(begin_for(7, "local_diagnostic", None, Origin::Operator)) + .await + .is_err() + ); + Ok(()) +} + +#[tokio::test] +async fn tool_grant_approves_only_that_tool_with_a_persisted_audited_row() -> Result<()> { + let (fixture, owner) = owner()?; + let mut req = register(7); + req.grants = grants(&["shell"], &[]); + owner.register_scope(req).await?; + let row = owner + .begin(begin_for(7, "shell", None, Origin::Operator)) + .await?; + assert_eq!(row.state, ApprovalLifecycle2::Approved); + assert_eq!(row.attribution.as_deref(), Some("auto:tool-grant")); + assert_eq!(row.choice, Some(ApprovalChoice2::Once)); + assert!(row.decision_ticket_sha256.is_none()); + assert!( + fixture + .audits + .lock() + .map_err(|_| Error::Unavailable)? + .iter() + .any(|a| a.request_id == row.request_id), + "a granted row is audited" + ); + // The row survives a restart: the same call resolves to it and reserves once. + assert_eq!( + owner + .begin(begin_for(7, "shell", None, Origin::Operator)) + .await? + .request_id, + row.request_id + ); + let mut act = action(&row, 3); + act.scope_id = id(7); + act.request = begin_for(7, "shell", None, Origin::Operator).request; + assert_eq!( + owner.consume(act).await?.state, + ApprovalLifecycle2::Reserved + ); + let other = owner + .begin(begin_for(7, "http_request", None, Origin::Operator)) + .await?; + assert_eq!(other.state, ApprovalLifecycle2::Pending); + Ok(()) +} + +#[tokio::test] +async fn flow_grant_is_scoped_to_that_flow_and_tool() -> Result<()> { + let (_, owner) = owner()?; + let mut req = register(7); + req.grants = grants(&[], &[("flow-1", "shell")]); + owner.register_scope(req).await?; + let granted = owner + .begin(begin_for(7, "shell", Some("flow-1"), Origin::Operator)) + .await?; + assert_eq!(granted.state, ApprovalLifecycle2::Approved); + assert_eq!(granted.attribution.as_deref(), Some("auto:flow-grant")); + for (tool, flow) in [ + ("shell", Some("flow-2")), + ("shell", None), + ("http_request", Some("flow-1")), + ] { + let row = owner + .begin(begin_for(7, tool, flow, Origin::Operator)) + .await?; + assert_eq!(row.state, ApprovalLifecycle2::Pending, "{tool} {flow:?}"); + } + Ok(()) +} + +#[tokio::test] +async fn grants_reach_saas_but_never_override_cron_external_denial_or_human_only_tools() +-> Result<()> { + let (_, owner) = owner()?; + let mut req = register(7); + req.grants = grants(&["shell", "local_diagnostic"], &[]); + owner.register_scope(req).await?; + let saas = owner + .begin(begin_for(7, "shell", None, Origin::Saas)) + .await?; + assert_eq!(saas.state, ApprovalLifecycle2::Approved); + let mut cron = begin_for(7, "shell", None, Origin::Cron); + cron.request.call.checks = vec![Check::Command { + command: "x".into(), + }]; + assert!(owner.begin(cron).await.is_err()); + // Even if a grant somehow named a human-only tool, the entry refuses to match it. + let entry = ScopeEntry { + scope: scope(7), + tools: [ + "device_crypto", + "native_secret_grant", + "sandbox_host_network", + "policy_change", + ] + .into_iter() + .map(Into::into) + .collect(), + flows: [("f".to_owned(), "device_crypto".to_owned())].into(), + audit_stream_id: None, + }; + for tool in HUMAN_ONLY_TOOLS2 { + assert_eq!(entry.grant(tool, Some("f")), None); + } + assert_eq!(entry.grant("native_x", None), None); + Ok(()) +} + +#[tokio::test] +async fn default_ports_refuse_runtime_scopes_and_release_nothing() -> Result<()> { + #[derive(Debug)] + struct Bare; + #[async_trait::async_trait] + impl Ports for Bare { + async fn load(&self, _: ApprovalLoadRequest2) -> Result { + Err(Error::Unavailable) + } + async fn cas(&self, _: ApprovalCasRequest2) -> Result { + Err(Error::Unavailable) + } + async fn prompt(&self, _: ApprovalDeliveryRequest2) -> Result { + Err(Error::Unavailable) + } + async fn human(&self, _: VerifyHumanRequest2) -> Result { + Err(Error::Unavailable) + } + async fn audit(&self, _: ApprovalRow2) -> Result<()> { + Err(Error::Unavailable) + } + } + let clock = Arc::new(TestClock::default()); + clock.wall.store(1000, Ordering::SeqCst); + let owner = Engine::new( + config(), + Arc::new(Bare), + clock, + Redactor::new(&RedactorConfig::default()).map_err(|_| Error::Invalid)?, + )?; + assert_eq!( + owner.register_scope(register(7)).await.err(), + Some(Error::Denied) + ); + assert!(Bare.release_scope(&scope(7)).await.is_ok()); + Ok(()) +} + +#[tokio::test] +async fn host_tool_marker_is_an_external_effect_that_cron_cannot_reach() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + let mut host_tool = begin_for(7, "gmail_send", None, Origin::Operator); + host_tool.request.call.checks = vec![Check::HostTool {}]; + let row = owner.begin(host_tool.clone()).await?; + assert!(row.external_effect); + assert_eq!(row.state, ApprovalLifecycle2::Pending); + host_tool.request.caller.origin = Origin::Cron; + host_tool.request.caller.call_id = "cron-call".into(); + assert!(owner.begin(host_tool).await.is_err()); + Ok(()) +} diff --git a/crates/tinysecurity-approval/src/engine_tests.rs b/crates/tinysecurity-approval/src/engine_tests.rs index 3ac2b2f..d530fd2 100644 --- a/crates/tinysecurity-approval/src/engine_tests.rs +++ b/crates/tinysecurity-approval/src/engine_tests.rs @@ -43,9 +43,33 @@ struct Fixture { audit_receipts: Mutex>, barrier: Mutex>>, native_receipt_mode: AtomicU64, + admitted: Mutex>, + released: Mutex>, + admit_fails: AtomicU64, + release_fails: AtomicU64, } #[async_trait::async_trait] impl Ports for Fixture { + async fn admit_scope(&self, scope: &ApprovalScope2, audit_stream_id: &str) -> Result<()> { + if self.admit_fails.load(Ordering::SeqCst) != 0 { + return Err(Error::Unavailable); + } + self.admitted + .lock() + .map_err(|_| Error::Unavailable)? + .push((scope.scope_id.clone(), audit_stream_id.into())); + Ok(()) + } + async fn release_scope(&self, scope: &ApprovalScope2) -> Result<()> { + if self.release_fails.load(Ordering::SeqCst) != 0 { + return Err(Error::Unavailable); + } + self.released + .lock() + .map_err(|_| Error::Unavailable)? + .push(scope.scope_id.clone()); + Ok(()) + } async fn native_audit(&self, row: ApprovalRow2) -> Result { self.audit(row.clone()).await?; let mode = self.native_receipt_mode.load(Ordering::SeqCst); @@ -2324,3 +2348,5 @@ async fn app_io_rows_cannot_supply_reusable_or_flow_authority() -> Result<()> { ); Ok(()) } +#[path = "engine_scopes_tests.rs"] +mod engine_scopes_tests; diff --git a/crates/tinysecurity-bus/src/approval_scopes.rs b/crates/tinysecurity-bus/src/approval_scopes.rs new file mode 100644 index 0000000..927405c --- /dev/null +++ b/crates/tinysecurity-bus/src/approval_scopes.rs @@ -0,0 +1,161 @@ +//! Runtime approval scopes (contract 1.17). +//! +//! `ApprovalConfig2` fixes at most 128 operator scopes at init. A multi-tenant host +//! (`SaaS` tenants, derived embedded agents, per-thread and flow origins) has an +//! unbounded set of subjects, so it registers each one after init with +//! `RegisterApprovalScope2`, changes its posture with `ReplaceApprovalScope2` and +//! releases it with `RetireApprovalScope2`. All three are private host-only members: +//! the subject is a trusted-adapter claim, never model input. +//! +//! A scope may also carry [`ApprovalGrants2`], the host-configured "always allow +//! this tool" and "always allow this tool in this flow" lists. They only convert a +//! `RequireApproval` into an automatic approval row (attribution `auto:tool-grant` +//! or `auto:flow-grant`, persisted and audited like any other row); they never +//! override a denial, a Cron external effect or a human-only tool. +use crate::ApprovalScope2; +use serde::{Deserialize, Serialize}; + +/// Most live scopes one module keeps (init scopes included). +pub const MAX_APPROVAL_SCOPES2: usize = 65_536; +/// Most whole-tool grants one scope carries. +pub const MAX_TOOL_GRANTS2: usize = 1024; +/// Most flow-and-tool grants one scope carries. +pub const MAX_FLOW_GRANTS2: usize = 4096; +/// Longest tool or flow name in a grant, in bytes. +pub const MAX_GRANT_NAME_BYTES2: usize = 256; +/// Tool identities that always need a fresh human decision and can never be granted. +pub const HUMAN_ONLY_TOOLS2: &[&str] = &[ + "sandbox_host_network", + "device_crypto", + "policy_change", + "native_secret_grant", + "native_app_io_grant", +]; + +/// Tool names that name an effect class of the module itself; a host tool cannot use them. +pub const EFFECT_CLASS_TOOLS2: &[&str] = &["local_diagnostic", "path_check", "command_check"]; + +/// Whether `tool` may name a host-registered external-effect tool (`Check::HostTool`). +#[must_use] +pub fn is_host_tool_name2(tool: &str) -> bool { + !tool.trim().is_empty() + && tool.len() <= MAX_GRANT_NAME_BYTES2 + && !tool.chars().any(char::is_control) + && !tool.starts_with("native_") + && !HUMAN_ONLY_TOOLS2.contains(&tool) + && !EFFECT_CLASS_TOOLS2.contains(&tool) +} + +/// One tool trusted inside one flow. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FlowToolGrant2 { + /// Exact flow identity as the host passes it in `CallerContext::flow`. + pub flow_id: String, + /// Exact tool name trusted in that flow. + pub tool: String, +} + +/// Host-configured automatic approvals of one scope; empty grants nothing. +#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovalGrants2 { + /// Exact tool names approved for every call in this scope. + #[serde(default)] + pub tools: Vec, + /// Exact tool names approved only inside the named flow. + #[serde(default)] + pub flows: Vec, +} + +impl ApprovalGrants2 { + /// Whether the grants respect every bound and name no human-only tool. + #[must_use] + pub fn is_valid(&self) -> bool { + let name = |s: &str| { + !s.trim().is_empty() + && s.len() <= MAX_GRANT_NAME_BYTES2 + && !s.chars().any(char::is_control) + && !HUMAN_ONLY_TOOLS2.contains(&s) + && !s.starts_with("native_") + }; + self.tools.len() <= MAX_TOOL_GRANTS2 + && self.flows.len() <= MAX_FLOW_GRANTS2 + && self.tools.iter().all(|t| name(t)) + && self.flows.iter().all(|g| { + name(&g.tool) + && !g.flow_id.trim().is_empty() + && g.flow_id.len() <= MAX_GRANT_NAME_BYTES2 + }) + } +} + +/// Register or replace one runtime scope. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RegisterApprovalScopeRequest2 { + /// Registration to add; validated exactly like an init scope. Its generation + /// must equal the module's current generation. + pub scope: ApprovalScope2, + /// Automatic approvals configured for this scope. + #[serde(default)] + pub grants: ApprovalGrants2, + /// 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, +} + +/// Retire one runtime or init scope. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RetireApprovalScopeRequest2 { + /// Registration to remove; later calls under this identity deny. + pub scope_id: String, +} + +/// What a scope change did. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalScopeOutcome2 { + /// A new scope is live. + Created, + /// An identical registration already existed; nothing changed. + Unchanged, + /// The scope's posture, grants or policy reference changed atomically. + Replaced, + /// The scope is gone. + Retired, +} + +/// Receipt of a scope change. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovalScopeReceipt2 { + /// Affected registration. + pub scope_id: String, + /// Generation the scope is bound to (0 once retired). + pub generation: u64, + /// What happened. + pub outcome: ApprovalScopeOutcome2, + /// Live scopes after the change. + pub live_scopes: u32, +} + +macro_rules! redacted_debug { + ($($ty:ty),+ $(,)?) => {$(impl std::fmt::Debug for $ty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(concat!(stringify!($ty), "([REDACTED])")) + } + })+}; +} +redacted_debug!( + FlowToolGrant2, + ApprovalGrants2, + RegisterApprovalScopeRequest2, + RetireApprovalScopeRequest2, + ApprovalScopeReceipt2 +); + +#[cfg(test)] +#[path = "approval_scopes_tests.rs"] +mod tests; diff --git a/crates/tinysecurity-bus/src/approval_scopes_tests.rs b/crates/tinysecurity-bus/src/approval_scopes_tests.rs new file mode 100644 index 0000000..ea62839 --- /dev/null +++ b/crates/tinysecurity-bus/src/approval_scopes_tests.rs @@ -0,0 +1,195 @@ +use super::*; +use crate::{AccessTier, SubjectScope}; + +type TestResult = std::result::Result<(), Box>; + +fn scope() -> ApprovalScope2 { + ApprovalScope2 { + scope_id: "a".repeat(32), + subject: SubjectScope { + user: "u".into(), + workspace: "w".into(), + agent: "a".into(), + }, + policy_ref: "p".into(), + generation: 1, + ceiling: AccessTier::Full, + require_approval: true, + auto_approve_all: false, + } +} + +#[test] +fn empty_and_ordinary_grants_are_valid() { + assert!(ApprovalGrants2::default().is_valid()); + let grants = ApprovalGrants2 { + tools: vec!["shell".into(), "http_request".into()], + flows: vec![FlowToolGrant2 { + flow_id: "flow-1".into(), + tool: "shell".into(), + }], + }; + assert!(grants.is_valid()); +} + +#[test] +fn grants_reject_human_only_blank_control_oversized_and_overfull_names() { + let bad = |g: ApprovalGrants2| !g.is_valid(); + for tool in [ + "sandbox_host_network", + "device_crypto", + "policy_change", + "native_secret_grant", + "native_anything", + " ", + "a\nb", + ] { + assert!(bad(ApprovalGrants2 { + tools: vec![tool.into()], + flows: vec![], + })); + assert!(bad(ApprovalGrants2 { + tools: vec![], + flows: vec![FlowToolGrant2 { + flow_id: "f".into(), + tool: tool.into(), + }], + })); + } + assert!(bad(ApprovalGrants2 { + tools: vec!["x".repeat(MAX_GRANT_NAME_BYTES2 + 1)], + flows: vec![], + })); + assert!(bad(ApprovalGrants2 { + tools: vec![], + flows: vec![FlowToolGrant2 { + flow_id: " ".into(), + tool: "shell".into(), + }], + })); + assert!(bad(ApprovalGrants2 { + tools: vec![], + flows: vec![FlowToolGrant2 { + flow_id: "f".repeat(MAX_GRANT_NAME_BYTES2 + 1), + tool: "shell".into(), + }], + })); + assert!(bad(ApprovalGrants2 { + tools: (0..=MAX_TOOL_GRANTS2).map(|i| format!("t{i}")).collect(), + flows: vec![], + })); + assert!(bad(ApprovalGrants2 { + tools: vec![], + flows: (0..=MAX_FLOW_GRANTS2) + .map(|i| FlowToolGrant2 { + flow_id: "f".into(), + tool: format!("t{i}"), + }) + .collect(), + })); +} + +#[test] +fn register_request_wire_is_strict_defaults_grants_and_redacts_debug() -> TestResult { + let request = RegisterApprovalScopeRequest2 { + scope: scope(), + grants: ApprovalGrants2::default(), + audit_stream_id: "b".repeat(32), + }; + let mut value = serde_json::to_value(&request)?; + assert_eq!( + serde_json::from_value::(value.clone())?, + request + ); + value.as_object_mut().ok_or("object")?.remove("grants"); + assert_eq!( + serde_json::from_value::(value.clone())?, + request + ); + value["extra"] = true.into(); + assert!(serde_json::from_value::(value).is_err()); + assert_eq!( + format!("{request:?}"), + "RegisterApprovalScopeRequest2([REDACTED])" + ); + assert_eq!( + format!( + "{:?}", + FlowToolGrant2 { + flow_id: "f".into(), + tool: "t".into() + } + ), + "FlowToolGrant2([REDACTED])" + ); + Ok(()) +} + +#[test] +fn retire_and_receipt_wire_round_trip() -> TestResult { + let retire = RetireApprovalScopeRequest2 { + scope_id: "a".repeat(32), + }; + assert_eq!( + serde_json::from_value::(serde_json::to_value(&retire)?)?, + retire + ); + assert_eq!( + format!("{retire:?}"), + "RetireApprovalScopeRequest2([REDACTED])" + ); + let receipt = ApprovalScopeReceipt2 { + scope_id: "a".repeat(32), + generation: 3, + outcome: ApprovalScopeOutcome2::Replaced, + live_scopes: 2, + }; + let value = serde_json::to_value(&receipt)?; + assert_eq!(value["outcome"], "replaced"); + assert_eq!( + serde_json::from_value::(value)?, + receipt + ); + assert_eq!(format!("{receipt:?}"), "ApprovalScopeReceipt2([REDACTED])"); + for outcome in ["created", "unchanged", "replaced", "retired"] { + assert!(serde_json::from_value::(outcome.into()).is_ok()); + } + Ok(()) +} + +#[test] +fn host_tool_names_exclude_effect_classes_human_only_native_blank_and_oversized() { + for ok in ["shell", "gmail_send", "composio_GITHUB_CREATE_ISSUE"] { + assert!(is_host_tool_name2(ok), "{ok}"); + } + for bad in [ + "", + " ", + "local_diagnostic", + "path_check", + "command_check", + "policy_change", + "device_crypto", + "sandbox_host_network", + "native_anything", + "a\tb", + ] { + assert!(!is_host_tool_name2(bad), "{bad:?}"); + } + assert!(!is_host_tool_name2(&"x".repeat(MAX_GRANT_NAME_BYTES2 + 1))); +} + +#[test] +fn host_tool_check_has_a_stable_strict_wire_form() -> TestResult { + let check = crate::Check::HostTool {}; + let value = serde_json::to_value(&check)?; + assert_eq!(value, serde_json::json!({"kind": "host_tool"})); + assert_eq!(serde_json::from_value::(value)?, check); + assert!( + serde_json::from_value::( + serde_json::json!({"kind": "host_tool", "tool": "shell"}) + ) + .is_err() + ); + Ok(()) +} diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 56a2154..d6ab8ef 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -16,6 +16,8 @@ mod judge; pub use judge::*; mod approval; pub use approval::*; +mod approval_scopes; +pub use approval_scopes::*; mod audit; mod audited; mod callbacks; @@ -43,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, 16); +pub const CONTRACT_VERSION: (u32, u32) = (1, 17); /// 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 bb78fb0..3baebe9 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -41,6 +41,12 @@ pub mod methods { pub const EXPIRE_APPROVAL2: &str = "ExpireApproval2"; /// Bounded exact already-reviewed flow grant validation. pub const PREAUTHORIZE_FLOW2: &str = "PreauthorizeFlow2"; + /// Register a runtime approval scope and its tool and flow grants (contract 1.17). + pub const REGISTER_APPROVAL_SCOPE2: &str = "RegisterApprovalScope2"; + /// Atomically replace one scope's posture, grants or policy reference (contract 1.17). + 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"; /// Evaluate member. pub const EVALUATE: &str = "Evaluate"; /// Check member. @@ -169,6 +175,9 @@ pub const METHODS: &[&str] = &[ methods::CANCEL_APPROVAL2, methods::EXPIRE_APPROVAL2, methods::PREAUTHORIZE_FLOW2, + methods::REGISTER_APPROVAL_SCOPE2, + methods::REPLACE_APPROVAL_SCOPE2, + methods::RETIRE_APPROVAL_SCOPE2, methods::IMPORT_APPROVAL_HISTORY2, methods::PARSE_REPLY, methods::POLICY_INFO, diff --git a/crates/tinysecurity-bus/src/policy.rs b/crates/tinysecurity-bus/src/policy.rs index 74bb601..89fa820 100644 --- a/crates/tinysecurity-bus/src/policy.rs +++ b/crates/tinysecurity-bus/src/policy.rs @@ -87,6 +87,11 @@ pub enum Check { /// SHA-256 over the previous id and the exact replacement. digest: String, }, + /// A host-registered tool with an external effect the module cannot inspect + /// beyond its other checks (contract 1.17). Alone it always needs approval; it + /// never relaxes a path or command check carried beside it, and it is valid only + /// for tool names outside the reserved effect classes. + HostTool {}, /// An effect not supported by this engine. Unknown { /// Content-free descriptor. diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index 96f99b9..a45dacd 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, 16)); - // A host built against 1.16 accepts this module and newer minors, not a 1.15 module. - assert!(is_compatible((1, 16))); + 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!(is_compatible((1, 18))); + assert!(!is_compatible((1, 16))); assert!(!is_compatible((1, 15))); assert!(!is_compatible((1, 14))); assert!(!is_compatible((1, 13))); diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index 3e754e8..18008e6 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, 16)); + assert_eq!(CONTRACT_VERSION, (1, 17)); 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 new file mode 100644 index 0000000..8ddec72 --- /dev/null +++ b/crates/tinysecurity-module/examples/approval_scope_native_contract.rs @@ -0,0 +1,390 @@ +//! Actual loaded cdylib: runtime approval scopes, tool grants and host-tool checks. +//! +//! A tenant is registered after init with its own audit stream. The host serves the +//! store, prompt and audit callbacks for that tenant only, so every success below +//! went through the runtime scope rather than the init scope. +#[path = "approval_native_contract/store.rs"] +mod store; +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; +use tinybus::{Connection, broker::Broker, module::ModuleHost, transport::memory::MemoryBus}; +use tinysecurity_bus::{ + AccessTier, ApprovalCasRequest2, ApprovalCasResponse2, ApprovalChoice2, ApprovalConfig2, + ApprovalDeliveryRequest2, ApprovalDeliveryResponse2, ApprovalGrants2, ApprovalLifecycle2, + ApprovalLoadRequest2, ApprovalLoadResponse2, ApprovalRow2, ApprovalScope2, + ApprovalScopeOutcome2, ApprovalScopeReceipt2, AuditConfig2, AuditCorrelation2, + AuditEventReceipt2, AuditSinkRequest2, AuditSinkResponse2, CallerContext, Check, + DecideRequest2, EvaluateApprovalRequest2, EvaluateApprovalResponse2, EvaluateRequest, + HumanDecisionReceipt2, ModuleConfig, RegisterApprovalScopeRequest2, + RetireApprovalScopeRequest2, SubjectScope, ToolCall, Verdict, VerifyHumanRequest2, names, +}; + +type Failure = Box; + +const TENANT: &str = "7777777777777777777777777777777b"; +const STREAM: &str = "8888888888888888888888888888888c"; + +fn subject(agent: &str) -> SubjectScope { + SubjectScope { + user: "user".into(), + workspace: "workspace".into(), + agent: agent.into(), + } +} +fn registration(grants: ApprovalGrants2) -> RegisterApprovalScopeRequest2 { + RegisterApprovalScopeRequest2 { + scope: ApprovalScope2 { + scope_id: TENANT.into(), + subject: subject("tenant-agent"), + policy_ref: "tenant-policy".into(), + generation: 1, + ceiling: AccessTier::Full, + require_approval: true, + auto_approve_all: false, + }, + grants, + audit_stream_id: STREAM.into(), + } +} +fn audit(sequence: u64) -> AuditCorrelation2 { + AuditCorrelation2 { + event_id: format!("{sequence:032x}"), + expected_tail: sequence - 1, + generation: 1, + } +} +fn begin(instance: u64, sequence: u64, tool: &str) -> EvaluateApprovalRequest2 { + EvaluateApprovalRequest2 { + scope_id: TENANT.into(), + request: EvaluateRequest { + caller: CallerContext { + agent: "tenant-agent".into(), + call_id: format!("execution-{instance}"), + tier: AccessTier::Full, + ..Default::default() + }, + call: ToolCall { + tool: tool.into(), + arguments_json: r#"{"token":"private-secret"}"#.into(), + checks: vec![Check::HostTool {}], + }, + }, + ttl_ms: None, + summary: "bearer private-secret".into(), + audit: audit(sequence), + } +} + +/// Tenant-only store and prompt callbacks backed by the persistent fixture ledger. +#[derive(Clone)] +struct Host { + scope: SubjectScope, + store: Arc>, +} +fn locked( + store: &Arc>, + work: impl FnOnce(&mut store::DiskStore) -> tinybus::Result, +) -> tinybus::Result { + let mut guard = store + .lock() + .map_err(|_| tinybus::Error::failed("fixture state unavailable"))?; + work(&mut guard) +} +#[tinybus::interface(name = "ai.tinyhumans.tinysecurity.ApprovalStore2")] +impl Host { + async fn load(&self, request: ApprovalLoadRequest2) -> tinybus::Result { + if request.scope != self.scope { + return Err(tinybus::Error::failed("fixture wrong scope")); + } + std::future::ready(locked(&self.store, |s| s.load(&request))).await + } + async fn cas(&self, request: ApprovalCasRequest2) -> tinybus::Result { + if request.scope != self.scope { + return Err(tinybus::Error::failed("fixture wrong scope")); + } + std::future::ready(locked(&self.store, |s| s.cas(request))).await + } +} +#[derive(Clone)] +struct Prompt(Host); +#[tinybus::interface(name = "ai.tinyhumans.tinysecurity.ApprovalPrompt2")] +impl Prompt { + async fn prompt( + &self, + request: ApprovalDeliveryRequest2, + ) -> tinybus::Result { + if request.row.binding.scope != self.0.scope { + return Err(tinybus::Error::failed("fixture wrong scope")); + } + std::future::ready(Ok(ApprovalDeliveryResponse2 { + request_id: request.row.request_id, + nonce: request.row.nonce, + delivered: true, + })) + .await + } + async fn verify_human( + &self, + request: VerifyHumanRequest2, + ) -> tinybus::Result { + if request.row.binding.scope != self.0.scope { + return Err(tinybus::Error::failed("fixture wrong scope")); + } + std::future::ready(locked(&self.0.store, |s| s.human(&request))).await + } +} +#[derive(Clone)] +struct Sink; +#[tinybus::interface(name = "ai.tinyhumans.tinysecurity.AuditSink2")] +impl Sink { + async fn append(&self, request: AuditSinkRequest2) -> tinybus::Result { + std::future::ready(Ok(AuditSinkResponse2 { + scope: request.scope, + stream_id: request.stream_id, + new_tail: request.events[0].sequence, + events: request + .events + .into_iter() + .map(|e| AuditEventReceipt2 { + event_id: e.event_id, + sequence: e.sequence, + }) + .collect(), + })) + .await + } +} + +async fn module_peer(client: &Connection) -> tinybus::Result { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Ok(Some(peer)) = client.name_owner(names::INTERFACE).await { + return Ok(peer); + } + tokio::task::yield_now().await; + } + }) + .await + .map_err(|_| tinybus::Error::failed("native approval initialization unavailable"))? +} + +#[tokio::main] +async fn main() -> std::result::Result<(), Failure> { + let library = std::path::PathBuf::from( + std::env::args() + .nth(1) + .ok_or("usage: approval_scope_native_contract ")?, + ) + .canonicalize()?; + let name = library + .file_name() + .and_then(|n| n.to_str()) + .ok_or("native library encoding")?; + std::fs::write( + library + .parent() + .ok_or("native library parent")? + .join("modules.toml"), + format!( + "\"{name}\" = \"{}\"\n", + tinybus::module::sha256_file(&library)? + ), + )?; + let directory = tempfile::tempdir()?; + let journal = directory.path().join("approvals.journal"); + std::fs::write(&journal, [])?; + + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let lifecycle = ModuleHost::new(broker); + let client = Connection::connect(bus.connect().await?).await?; + let operator = ApprovalScope2 { + scope_id: "a".repeat(32), + subject: subject("operator-agent"), + policy_ref: "operator-policy".into(), + generation: 1, + ceiling: AccessTier::Full, + require_approval: true, + auto_approve_all: false, + }; + let configuration = ModuleConfig { + trusted_host: client.unique_name().map(|n| n.as_str().to_owned()), + approvals: Some(ApprovalConfig2 { + timeout_ms: 30_000, + scopes: vec![operator.clone()], + }), + audit: Some(AuditConfig2 { + scope: operator.subject, + stream_id: "c".repeat(32), + timeout_ms: 30_000, + required: true, + native_path: None, + max_bytes: 100_000, + max_archives: 3, + journal_max_bytes: 1_000_000, + journal_max_events: 10_000, + }), + ..Default::default() + }; + lifecycle.load_file_with_config(&library, serde_json::to_value(configuration)?)?; + let module = module_peer(&client).await?; + let store = Arc::new(Mutex::new(store::DiskStore::open(&journal)?)); + let host = Host { + scope: tinysecurity::approval::scope_handle(&subject("tenant-agent"))?, + store: store.clone(), + }; + client + .serve_at( + tinysecurity_bus::callback_names2::OBJECT_PATH.parse()?, + host.clone(), + ) + .await?; + client + .serve_at( + tinysecurity_bus::callback_names2::OBJECT_PATH.parse()?, + Prompt(host), + ) + .await?; + client + .serve_at( + tinysecurity_bus::callback_names2::OBJECT_PATH.parse()?, + Sink, + ) + .await?; + let proxy = client + .proxy(module.as_str(), names::OBJECT_PATH, names::INTERFACE)? + .with_timeout(Duration::from_secs(10)); + assert!(proxy.attestation().await?.is_some()); + + scenario(&bus, &module, &proxy, &store).await?; + assert!(!String::from_utf8_lossy(&std::fs::read(&journal)?).contains("private-secret")); + lifecycle.shutdown(Duration::from_secs(2)).await; + broker_task.abort(); + println!( + "native approval scopes: runtime registration, tool grant, tenant prompt, replace and retire passed" + ); + Ok(()) +} + +async fn scenario( + bus: &MemoryBus, + module: &tinybus::BusName, + proxy: &tinybus::Proxy, + store: &Arc>, +) -> std::result::Result<(), Failure> { + let grants = || ApprovalGrants2 { + tools: vec!["shell".into()], + flows: vec![], + }; + // Unregistered identity and a non-host peer both deny. + assert!( + proxy + .call::( + names::methods::EVALUATE_APPROVAL2, + (begin(1, 1, "shell"),) + ) + .await + .is_err() + ); + let stranger = Connection::connect(bus.connect().await?).await?; + assert!( + stranger + .proxy(module.as_str(), names::OBJECT_PATH, names::INTERFACE)? + .call::( + names::methods::REGISTER_APPROVAL_SCOPE2, + (registration(grants()),) + ) + .await + .is_err() + ); + let created: ApprovalScopeReceipt2 = proxy + .call( + names::methods::REGISTER_APPROVAL_SCOPE2, + (registration(grants()),), + ) + .await?; + assert_eq!(created.outcome, ApprovalScopeOutcome2::Created); + let again: ApprovalScopeReceipt2 = proxy + .call( + names::methods::REGISTER_APPROVAL_SCOPE2, + (registration(grants()),), + ) + .await?; + assert_eq!(again.outcome, ApprovalScopeOutcome2::Unchanged); + + let granted: EvaluateApprovalResponse2 = proxy + .call(names::methods::EVALUATE_APPROVAL2, (begin(2, 3, "shell"),)) + .await?; + assert_eq!(granted.decision.verdict, Verdict::Allow); + assert_eq!( + granted.record.attribution.as_deref(), + Some("auto:tool-grant") + ); + let pending: EvaluateApprovalResponse2 = proxy + .call( + names::methods::EVALUATE_APPROVAL2, + (begin(3, 5, "gmail_send"),), + ) + .await?; + assert!(matches!( + pending.decision.verdict, + Verdict::RequireApproval { .. } + )); + let ticket = locked(store, |s| { + s.human_action(pending.record.clone(), ApprovalChoice2::Once) + })?; + let decided: ApprovalRow2 = proxy + .call( + names::methods::DECIDE2, + (DecideRequest2 { + scope_id: TENANT.into(), + request_id: pending.record.request_id.clone(), + decision_ticket: ticket, + audit: audit(6), + },), + ) + .await?; + assert_eq!(decided.state, ApprovalLifecycle2::Approved); + + replace_then_retire(proxy).await +} + +async fn replace_then_retire(proxy: &tinybus::Proxy) -> std::result::Result<(), Failure> { + let replaced: ApprovalScopeReceipt2 = proxy + .call( + names::methods::REPLACE_APPROVAL_SCOPE2, + (registration(ApprovalGrants2::default()),), + ) + .await?; + assert_eq!(replaced.outcome, ApprovalScopeOutcome2::Replaced); + let after: EvaluateApprovalResponse2 = proxy + .call(names::methods::EVALUATE_APPROVAL2, (begin(4, 8, "shell"),)) + .await?; + assert!(matches!( + after.decision.verdict, + Verdict::RequireApproval { .. } + )); + + let retired: ApprovalScopeReceipt2 = proxy + .call( + names::methods::RETIRE_APPROVAL_SCOPE2, + (RetireApprovalScopeRequest2 { + scope_id: TENANT.into(), + },), + ) + .await?; + assert_eq!(retired.outcome, ApprovalScopeOutcome2::Retired); + assert!( + proxy + .call::( + names::methods::EVALUATE_APPROVAL2, + (begin(5, 10, "shell"),) + ) + .await + .is_err() + ); + Ok(()) +} diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 8a1b40b..57496ed 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -770,6 +770,39 @@ impl PolicyService { .await .map_err(approval_failure) } + // Private host-only configuration: the subject is a trusted-adapter claim. + async fn register_approval_scope2( + &self, + request: tinysecurity_bus::RegisterApprovalScopeRequest2, + ) -> BusResult { + self.approvals + .as_ref() + .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? + .register_scope(request) + .await + .map_err(approval_failure) + } + async fn replace_approval_scope2( + &self, + request: tinysecurity_bus::RegisterApprovalScopeRequest2, + ) -> BusResult { + let engine = self + .approvals + .as_ref() + .ok_or_else(|| tinybus::Error::failed("approval unavailable"))?; + std::future::ready(engine.replace_scope(request).map_err(approval_failure)).await + } + async fn retire_approval_scope2( + &self, + request: tinysecurity_bus::RetireApprovalScopeRequest2, + ) -> BusResult { + self.approvals + .as_ref() + .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? + .retire_scope(request) + .await + .map_err(approval_failure) + } async fn import_approval_history2( &self, request: tinysecurity_bus::ApprovalHistoryImport2, @@ -1599,7 +1632,7 @@ tinybus_module::module_export! { config = ModuleConfig, worker_threads = 1, provides = ["ai.tinyhumans.tinysecurity.Policy"], - methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "Audit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanToolDefinition"], + methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "Audit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanToolDefinition"], signals = ["PolicyChanged"], requires = [], optional = ["ai.tinyhumans.tinysecurity.KeyringConsent2", "ai.tinyhumans.tinysecurity.SecretCipherConsumer2", "ai.tinyhumans.tinysecurity.SecretBackend2", "ai.tinyhumans.tinysecurity.AuditSink2", "ai.tinyhumans.tinysecurity.ApprovalStore2", "ai.tinyhumans.tinysecurity.ApprovalPrompt2"], diff --git a/crates/tinysecurity-module/src/adapter_approval_tests.rs b/crates/tinysecurity-module/src/adapter_approval_tests.rs index 80d3ddf..7a54f85 100644 --- a/crates/tinysecurity-module/src/adapter_approval_tests.rs +++ b/crates/tinysecurity-module/src/adapter_approval_tests.rs @@ -977,3 +977,5 @@ async fn automatic_approval_cannot_authorize_a_widening_policy_change() broker.abort(); Ok(()) } +#[path = "adapter_scope_tests.rs"] +mod adapter_scope_tests; diff --git a/crates/tinysecurity-module/src/adapter_scope_tests.rs b/crates/tinysecurity-module/src/adapter_scope_tests.rs new file mode 100644 index 0000000..4f85c16 --- /dev/null +++ b/crates/tinysecurity-module/src/adapter_scope_tests.rs @@ -0,0 +1,294 @@ +//! Runtime approval scopes through the real bus, authenticated peer and store callbacks. +use super::*; + +type TestResult = std::result::Result<(), Box>; + +const TENANT: &str = "7777777777777777777777777777777b"; +const STREAM: &str = "8888888888888888888888888888888c"; + +fn tenant_subject() -> SubjectScope { + SubjectScope { + user: "tenant-user".into(), + workspace: "tenant-workspace".into(), + agent: "tenant-agent".into(), + } +} +fn registration(grants: ApprovalGrants2) -> RegisterApprovalScopeRequest2 { + RegisterApprovalScopeRequest2 { + scope: ApprovalScope2 { + scope_id: TENANT.into(), + subject: tenant_subject(), + policy_ref: "tenant-policy".into(), + generation: 1, + ceiling: AccessTier::Full, + require_approval: true, + auto_approve_all: false, + }, + grants, + audit_stream_id: STREAM.into(), + } +} +fn tenant_begin(instance: u64, sequence: u64, tool: &str) -> EvaluateApprovalRequest2 { + let mut request = begin(instance, sequence); + request.scope_id = TENANT.into(); + request.request.caller.agent = "tenant-agent".into(); + request.request.call.tool = tool.into(); + if tool != "local_diagnostic" { + request.request.call.checks = vec![Check::HostTool {}]; + } + request +} +fn tool_grants(tool: &str) -> ApprovalGrants2 { + ApprovalGrants2 { + tools: vec![tool.into()], + flows: vec![], + } +} + +/// Serve the module and host callbacks for the tenant subject only; the init scope +/// exists but the fixture store refuses it, so every success below used the runtime scope. +async fn serve( + host: &Connection, + module: &Connection, + store: &Arc>, + sink: &Arc, +) -> std::result::Result<(), Box> { + let (config, mut audit_config) = settings(); + audit_config.scope = tenant_subject(); + let host_peer = host.unique_name().ok_or("host peer")?; + let module_peer = module.unique_name().ok_or("module peer")?; + let redactor = tinysecurity::Redactor::new(&RedactorConfig { secrets: vec![] })?; + let clients = crate::approval_callbacks::ApprovalClients::new( + module, + Some(host_peer.as_str()), + &config, + &audit_config, + &redactor, + )?; + let clock = Arc::new(TestClock(AtomicU64::new(1000))); + let engine = Arc::new(tinysecurity::approval::Engine::new( + config, + Arc::new(clients), + clock, + redactor, + )?); + let mut service = service_for( + Arc::new(RwLock::new(Policy::new(ModuleConfig::default())?)), + None, + ); + service.approvals = Some(engine); + service.signals = Some(module.clone()); + module + .serve_at( + names::OBJECT_PATH.parse()?, + crate::callbacks::Authenticated::new( + service, + crate::callbacks::PeerBinding::new(Some(host_peer.as_str()))?, + ), + ) + .await?; + let scope = tinysecurity::approval::scope_handle(&tenant_subject())?; + for interface in [ + callback_names2::APPROVAL_STORE, + callback_names2::APPROVAL_PROMPT, + ] { + host.serve_at( + callback_names2::OBJECT_PATH.parse()?, + callback::Callback { + module: module_peer.clone(), + host: host.clone(), + interface: interface.parse()?, + scope: scope.clone(), + store: store.clone(), + reentrant: Arc::new(AtomicBool::new(false)), + prompt_failure: Arc::new(AtomicBool::new(false)), + }, + ) + .await?; + } + host.serve_at( + callback_names2::OBJECT_PATH.parse()?, + crate::callbacks::Authenticated::new( + Sink { + fail: Arc::clone(sink), + }, + crate::callbacks::PeerBinding::new(Some(module_peer.as_str()))?, + ), + ) + .await?; + Ok(()) +} + +#[tokio::test] +async fn runtime_scope_registers_grants_replaces_and_retires_through_the_authenticated_bus() +-> 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 stranger = 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)?; + + // Unregistered identity and any non-host peer deny. + assert!( + proxy + .call::( + names::methods::EVALUATE_APPROVAL2, + (tenant_begin(1, 1, "local_diagnostic"),) + ) + .await + .is_err() + ); + let outsider = stranger.proxy(module_peer.as_str(), names::OBJECT_PATH, names::INTERFACE)?; + assert!( + outsider + .call::( + names::methods::REGISTER_APPROVAL_SCOPE2, + (registration(tool_grants("shell")),) + ) + .await + .is_err() + ); + + let created: ApprovalScopeReceipt2 = proxy + .call( + names::methods::REGISTER_APPROVAL_SCOPE2, + (registration(tool_grants("shell")),), + ) + .await?; + assert_eq!(created.outcome, ApprovalScopeOutcome2::Created); + assert_eq!(created.live_scopes, 2); + + // A granted tool is approved without a prompt, persisted and audited as the grant. + let granted: EvaluateApprovalResponse2 = proxy + .call( + names::methods::EVALUATE_APPROVAL2, + (tenant_begin(2, 3, "shell"),), + ) + .await?; + assert_eq!(granted.decision.verdict, Verdict::Allow); + assert_eq!( + granted.record.attribution.as_deref(), + Some("auto:tool-grant") + ); + // Any other tool still needs the tenant's own human. + let pending: EvaluateApprovalResponse2 = proxy + .call( + names::methods::EVALUATE_APPROVAL2, + (tenant_begin(3, 5, "local_diagnostic"),), + ) + .await?; + assert!(matches!( + pending.decision.verdict, + Verdict::RequireApproval { .. } + )); + let ticket = store + .lock() + .map_err(|_| "fixture lock")? + .human_action(pending.record.clone(), ApprovalChoice2::Once)?; + let decided: ApprovalRow2 = proxy + .call( + names::methods::DECIDE2, + (DecideRequest2 { + scope_id: TENANT.into(), + request_id: pending.record.request_id.clone(), + decision_ticket: ticket, + audit: audit(6), + },), + ) + .await?; + assert_eq!(decided.state, ApprovalLifecycle2::Approved); + + replace_then_retire(&proxy).await?; + assert!(!String::from_utf8_lossy(&std::fs::read(path)?).contains("private-secret")); + broker.abort(); + Ok(()) +} + +async fn replace_then_retire(proxy: &tinybus::Proxy) -> TestResult { + // Replacing the grants takes effect for the next call. + let replaced: ApprovalScopeReceipt2 = proxy + .call( + names::methods::REPLACE_APPROVAL_SCOPE2, + (registration(ApprovalGrants2::default()),), + ) + .await?; + assert_eq!(replaced.outcome, ApprovalScopeOutcome2::Replaced); + let after: EvaluateApprovalResponse2 = proxy + .call( + names::methods::EVALUATE_APPROVAL2, + (tenant_begin(4, 8, "shell"),), + ) + .await?; + assert!(matches!( + after.decision.verdict, + Verdict::RequireApproval { .. } + )); + + // Retiring closes the identity for good. + let retired: ApprovalScopeReceipt2 = proxy + .call( + names::methods::RETIRE_APPROVAL_SCOPE2, + (RetireApprovalScopeRequest2 { + scope_id: TENANT.into(), + },), + ) + .await?; + assert_eq!(retired.outcome, ApprovalScopeOutcome2::Retired); + assert!( + proxy + .call::( + names::methods::EVALUATE_APPROVAL2, + (tenant_begin(5, 10, "shell"),) + ) + .await + .is_err() + ); + assert!( + proxy + .call::( + names::methods::RETIRE_APPROVAL_SCOPE2, + (RetireApprovalScopeRequest2 { + scope_id: TENANT.into(), + },) + ) + .await + .is_err() + ); + Ok(()) +} + +#[tokio::test] +async fn approval_scope_members_fail_closed_without_an_approval_engine() -> TestResult { + let service = service_for( + Arc::new(RwLock::new(Policy::new(ModuleConfig::default())?)), + None, + ); + assert!( + service + .register_approval_scope2(registration(ApprovalGrants2::default())) + .await + .is_err() + ); + assert!( + service + .replace_approval_scope2(registration(ApprovalGrants2::default())) + .await + .is_err() + ); + assert!( + service + .retire_approval_scope2(RetireApprovalScopeRequest2 { + scope_id: TENANT.into() + }) + .await + .is_err() + ); + Ok(()) +} diff --git a/crates/tinysecurity-module/src/approval_callbacks.rs b/crates/tinysecurity-module/src/approval_callbacks.rs index c12c00d..9d6f29c 100644 --- a/crates/tinysecurity-module/src/approval_callbacks.rs +++ b/crates/tinysecurity-module/src/approval_callbacks.rs @@ -1,19 +1,19 @@ //! Bounded exact approval callback clients, fixed to the trusted unique init peer. use crate::callbacks::{AuditClient, PeerBinding}; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use tinysecurity::approval::{Error, NativeAuditAcknowledgement, Ports, Result}; use tinysecurity_bus::{ ApprovalCasRequest2, ApprovalCasResponse2, ApprovalConfig2, ApprovalDeliveryRequest2, ApprovalDeliveryResponse2, ApprovalLifecycle2, ApprovalLoadRequest2, ApprovalLoadResponse2, - ApprovalRow2, AuditConfig2, AuditEvent2, AuditKind2, AuditSinkRequest2, AuditSinkResponse2, - HumanDecisionReceipt2, SubjectScope, VerifyHumanRequest2, callback_names2, + ApprovalRow2, ApprovalScope2, AuditConfig2, AuditEvent2, AuditKind2, AuditSinkRequest2, + AuditSinkResponse2, HumanDecisionReceipt2, SubjectScope, VerifyHumanRequest2, callback_names2, }; /// Captured registered callback identity; no model-provided destination or scope. pub(crate) struct ApprovalStoreClient { connection: tinybus::Connection, peer: tinybus::BusName, timeout_ms: u32, - scopes: Vec, + scopes: RwLock>, } impl std::fmt::Debug for ApprovalStoreClient { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -45,16 +45,39 @@ impl ApprovalStoreClient { connection, peer, timeout_ms: config.timeout_ms, - scopes, + scopes: RwLock::new(scopes), }) } fn scope(&self, scope: &SubjectScope) -> Result<()> { - if self.scopes.contains(scope) { + if self + .scopes + .read() + .map_err(|_| Error::Unavailable)? + .contains(scope) + { Ok(()) } else { Err(Error::Denied) } } + /// Admit a runtime subject, bounded like the init set plus the engine's own cap. + fn admit(&self, handle: SubjectScope) -> Result<()> { + let mut scopes = self.scopes.write().map_err(|_| Error::Unavailable)?; + if !scopes.contains(&handle) { + if scopes.len() >= tinysecurity_bus::MAX_APPROVAL_SCOPES2 { + return Err(Error::Unavailable); + } + scopes.push(handle); + } + Ok(()) + } + fn release(&self, handle: &SubjectScope) -> Result<()> { + self.scopes + .write() + .map_err(|_| Error::Unavailable)? + .retain(|scope| scope != handle); + Ok(()) + } fn proxy(&self, interface: &str) -> Result { self.connection .proxy(self.peer.as_str(), callback_names2::OBJECT_PATH, interface) @@ -92,7 +115,15 @@ impl ApprovalStoreClient { /// Real authenticated store, delivery, human-ticket verification and audit ports. pub(crate) struct ApprovalClients { store: ApprovalStoreClient, - audit: Vec<(SubjectScope, Arc)>, + audit: RwLock)>>, + /// Runtime subjects forward audit to the host sink under this base policy. + runtime: RuntimeAudit, +} +struct RuntimeAudit { + connection: tinybus::Connection, + binding: PeerBinding, + base: AuditConfig2, + redactor: tinysecurity::Redactor, } impl std::fmt::Debug for ApprovalClients { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -109,6 +140,12 @@ impl ApprovalClients { ) -> tinybus::Result { let store = ApprovalStoreClient::new(connection.clone(), peer, config)?; let binding = PeerBinding::new(peer)?; + let runtime = RuntimeAudit { + connection: connection.clone(), + binding: binding.clone(), + base: audit.clone(), + redactor: redactor.clone(), + }; let mut clients = Vec::new(); for registration in &config.scopes { let scope = tinysecurity::approval::scope_handle(®istration.subject) @@ -127,9 +164,25 @@ impl ApprovalClients { } Ok(Self { store, - audit: clients, + audit: RwLock::new(clients), + runtime, }) } + /// Build the audit client of a runtime subject: its own persisted stream, always + /// forwarded to the host sink so no extra native journal is opened. + fn runtime_client(&self, scope: &ApprovalScope2, stream_id: &str) -> Result { + let mut config = self.runtime.base.clone(); + config.scope = scope.subject.clone(); + config.stream_id = stream_id.into(); + config.native_path = None; + AuditClient::new( + self.runtime.connection.clone(), + self.runtime.binding.clone(), + config, + self.runtime.redactor.clone(), + ) + .map_err(|_| Error::Invalid) + } } #[async_trait::async_trait] impl Ports for ApprovalClients { @@ -182,6 +235,24 @@ impl Ports for ApprovalClients { async fn audit(&self, row: ApprovalRow2) -> Result<()> { self.append_row(row, false).await.map(|_| ()) } + async fn admit_scope(&self, scope: &ApprovalScope2, audit_stream_id: &str) -> Result<()> { + let handle = tinysecurity::approval::scope_handle(&scope.subject)?; + let client = Arc::new(self.runtime_client(scope, audit_stream_id)?); + self.store.admit(handle.clone())?; + let mut audit = self.audit.write().map_err(|_| Error::Unavailable)?; + if !audit.iter().any(|(s, _)| s == &handle) { + audit.push((handle, client)); + } + Ok(()) + } + async fn release_scope(&self, scope: &ApprovalScope2) -> Result<()> { + let handle = tinysecurity::approval::scope_handle(&scope.subject)?; + self.audit + .write() + .map_err(|_| Error::Unavailable)? + .retain(|(s, _)| s != &handle); + self.store.release(&handle) + } async fn native_audit(&self, row: ApprovalRow2) -> Result { let (receipt, expected_scope) = self.append_row(row.clone(), true).await?; NativeAuditAcknowledgement::from_verified_sink(row, expected_scope, receipt) @@ -193,10 +264,13 @@ impl ApprovalClients { row: ApprovalRow2, required: bool, ) -> Result<(AuditSinkResponse2, SubjectScope)> { - let (_, client) = self + let client = self .audit + .read() + .map_err(|_| Error::Unavailable)? .iter() .find(|(s, _)| s == &row.binding.scope) + .map(|(_, client)| Arc::clone(client)) .ok_or(Error::Denied)?; if required && !client.required() { return Err(Error::Denied); diff --git a/crates/tinysecurity-module/src/approval_callbacks_tests.rs b/crates/tinysecurity-module/src/approval_callbacks_tests.rs index 22e434d..d0d23f4 100644 --- a/crates/tinysecurity-module/src/approval_callbacks_tests.rs +++ b/crates/tinysecurity-module/src/approval_callbacks_tests.rs @@ -804,3 +804,95 @@ async fn actual_registry_engine_and_linked_callbacks_compose_exact_human_once_au broker.abort(); Ok(()) } + +fn runtime_scope(n: u64) -> ApprovalScope2 { + ApprovalScope2 { + scope_id: format!("{n:032x}"), + subject: SubjectScope { + user: format!("user-{n}"), + workspace: "workspace".into(), + agent: format!("agent-{n}"), + }, + policy_ref: "runtime".into(), + generation: 1, + ceiling: AccessTier::Full, + require_approval: true, + auto_approve_all: false, + } +} + +#[tokio::test] +async fn store_client_admits_releases_and_bounds_runtime_subjects() +-> std::result::Result<(), Box> { + let bus = MemoryBus::new(); + let broker = Broker::new().spawn(bus.clone()); + let host = tinybus::Connection::connect(bus.connect().await?).await?; + let module = tinybus::Connection::connect(bus.connect().await?).await?; + let peer = host.unique_name().ok_or("host peer")?; + let client = ApprovalStoreClient::new(module, Some(peer.as_str()), &config())?; + let handle = tinysecurity::approval::scope_handle(&runtime_scope(1).subject)?; + assert!(client.scope(&handle).is_err()); + client.admit(handle.clone())?; + client.admit(handle.clone())?; + assert!(client.scope(&handle).is_ok()); + assert_eq!(client.scopes.read().map_err(|_| "lock")?.len(), 2); + client.release(&handle)?; + assert!(client.scope(&handle).is_err()); + // Filling to the cap refuses the next new subject, and re-admitting a held one stays fine. + for n in 0..(tinysecurity_bus::MAX_APPROVAL_SCOPES2 - 1) { + client.admit(tinysecurity::approval::scope_handle(&SubjectScope { + user: format!("u{n}"), + workspace: "w".into(), + agent: "a".into(), + })?)?; + } + assert!(client.admit(handle).is_err()); + broker.abort(); + Ok(()) +} + +#[tokio::test] +async fn clients_admit_a_runtime_subject_with_its_own_stream_and_release_it() +-> std::result::Result<(), Box> { + let bus = MemoryBus::new(); + let broker = Broker::new().spawn(bus.clone()); + let host = tinybus::Connection::connect(bus.connect().await?).await?; + let module = tinybus::Connection::connect(bus.connect().await?).await?; + let peer = host.unique_name().ok_or("host peer")?; + let base = config(); + let redactor = + tinysecurity::Redactor::new(&tinysecurity_bus::RedactorConfig { secrets: vec![] })?; + let clients = ApprovalClients::new( + &module, + Some(peer.as_str()), + &base, + &native_audit_config(base.scopes[0].subject.clone(), false), + &redactor, + )?; + let scope = runtime_scope(2); + let handle = tinysecurity::approval::scope_handle(&scope.subject)?; + // A malformed stream never admits the subject. + assert!(clients.admit_scope(&scope, "short").await.is_err()); + assert!(clients.store.scope(&handle).is_err()); + clients.admit_scope(&scope, &"d".repeat(32)).await?; + clients.admit_scope(&scope, &"d".repeat(32)).await?; + assert!(clients.store.scope(&handle).is_ok()); + { + let held = clients.audit.read().map_err(|_| "lock")?; + assert_eq!(held.iter().filter(|(s, _)| s == &handle).count(), 1); + let (_, client) = held.iter().find(|(s, _)| s == &handle).ok_or("client")?; + assert_eq!(client.stream_id(), "d".repeat(32)); + } + clients.release_scope(&scope).await?; + assert!(clients.store.scope(&handle).is_err()); + assert!( + clients + .audit + .read() + .map_err(|_| "lock")? + .iter() + .all(|(s, _)| s != &handle) + ); + broker.abort(); + Ok(()) +} diff --git a/crates/tinysecurity-policy/src/engine.rs b/crates/tinysecurity-policy/src/engine.rs index b08beb5..25c3351 100644 --- a/crates/tinysecurity-policy/src/engine.rs +++ b/crates/tinysecurity-policy/src/engine.rs @@ -115,6 +115,16 @@ impl Policy { .checks .iter() .all(|c| matches!(c, Check::PolicyChange { .. })), + tool if tinysecurity_bus::is_host_tool_name2(tool) => { + request + .call + .checks + .iter() + .any(|c| matches!(c, Check::HostTool {})) + && request.call.checks.iter().all(|c| { + !matches!(c, Check::Unknown { .. } | Check::PolicyChange { .. }) + }) + } _ => false, }; if !matching { @@ -179,7 +189,9 @@ impl Policy { let result = match check { Check::LocalDiagnostic {} => Ok(()), Check::Unknown { .. } => Err(DenialReason::UnsupportedEffect), - Check::PolicyChange { .. } => Err(DenialReason::ApprovalUnavailable), + Check::HostTool {} | Check::PolicyChange { .. } => { + Err(DenialReason::ApprovalUnavailable) + } Check::Path { path, write } => path::check(settings, path, *write) .and_then(|()| hooks.map_or(Ok(()), |h| (h.path)(path, *write))) .and_then(|()| { diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index 280a385..b4b2797 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -37,3 +37,155 @@ fn direct_credential_transfers_deny_original_content() -> Result<()> { } Ok(()) } + +fn host_call(tool: &str, checks: Vec) -> EvaluateRequest { + EvaluateRequest { + caller: CallerContext { + agent: "agent".into(), + call_id: "call".into(), + ..Default::default() + }, + call: ToolCall { + tool: tool.into(), + arguments_json: r#"{"to":"someone"}"#.into(), + checks, + }, + } +} +fn verdict(policy: &Policy, request: &EvaluateRequest) -> Result { + Ok(policy.evaluate(request)?.verdict) +} + +#[test] +fn host_tool_needs_approval_instead_of_being_unsupported() -> Result<()> { + let policy = Policy::new(ModuleConfig::default())?; + let needs_approval = Verdict::Deny { + reason: DenialReason::ApprovalUnavailable, + }; + assert_eq!( + verdict(&policy, &host_call("gmail_send", vec![Check::HostTool {}]))?, + needs_approval + ); + // The marker beside a real check keeps that check authoritative: the default + // policy refuses the command before approval is ever considered. + assert_eq!( + verdict( + &policy, + &host_call( + "shell", + vec![ + Check::Command { + command: "git status".into() + }, + Check::HostTool {} + ] + ) + )?, + Verdict::Deny { + reason: DenialReason::Command + } + ); + Ok(()) +} + +#[test] +fn host_tool_never_relaxes_other_checks_and_rejects_reserved_or_unmarked_calls() -> Result<()> { + let policy = Policy::new(ModuleConfig::default())?; + let unsupported = Verdict::Deny { + reason: DenialReason::UnsupportedEffect, + }; + // An unknown tool without the marker, or with the marker plus an unknown effect. + assert_eq!( + verdict( + &policy, + &host_call("gmail_send", vec![Check::LocalDiagnostic {}]) + )?, + unsupported + ); + assert_eq!( + verdict( + &policy, + &host_call( + "gmail_send", + vec![ + Check::HostTool {}, + Check::Unknown { + description: "x".into() + } + ] + ) + )?, + unsupported + ); + assert_eq!( + verdict( + &policy, + &host_call( + "gmail_send", + vec![ + Check::HostTool {}, + Check::PolicyChange { digest: "d".into() } + ] + ) + )?, + unsupported + ); + assert_eq!( + verdict(&policy, &host_call("gmail_send", vec![]))?, + unsupported + ); + // Reserved identities cannot be claimed by a host tool. + for tool in [ + "local_diagnostic", + "path_check", + "command_check", + "policy_change", + "device_crypto", + "sandbox_host_network", + "native_secret_grant", + "", + ] { + assert_eq!( + verdict(&policy, &host_call(tool, vec![Check::HostTool {}]))?, + unsupported, + "{tool}" + ); + } + // A path inside the always-forbidden floor stays a hard denial beside the marker. + let hard = verdict( + &policy, + &host_call( + "file_read", + vec![ + Check::Path { + path: "/root/.ssh/id_rsa".into(), + write: false, + }, + Check::HostTool {}, + ], + ), + )?; + assert_ne!( + hard, + Verdict::Deny { + reason: DenialReason::ApprovalUnavailable + } + ); + assert!(matches!(hard, Verdict::Deny { .. })); + // Batch checks report the marker as needing approval, in order. + let response = policy.check(&CheckRequest { + caller: CallerContext { + agent: "agent".into(), + call_id: "call".into(), + ..Default::default() + }, + checks: vec![Check::HostTool {}], + })?; + assert_eq!( + response.decisions[0].verdict, + Verdict::Deny { + reason: DenialReason::ApprovalUnavailable + } + ); + Ok(()) +} diff --git a/crates/tinysecurity-policy/src/scoped.rs b/crates/tinysecurity-policy/src/scoped.rs index 65070e2..3ea9f00 100644 --- a/crates/tinysecurity-policy/src/scoped.rs +++ b/crates/tinysecurity-policy/src/scoped.rs @@ -104,6 +104,7 @@ fn check_fields(h: &mut Sha256, check: &Check) { field(h, command); } Check::LocalDiagnostic {} => field(h, "local_diagnostic"), + Check::HostTool {} => field(h, "host_tool"), Check::PolicyChange { digest } => { field(h, "policy_change"); field(h, digest); From af0724533fca636b4bbee1970205e624fb6ceb40 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 02:13:47 +0300 Subject: [PATCH 02/12] fix(approval): reject duplicate subjects at init as well as at runtime Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 4 +++- .../src/engine_scopes_tests.rs | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 3f9bdbe..5a34fe7 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -323,7 +323,9 @@ impl Engine { let mut entries = ScopeTable::default(); for scope in config.scopes { Self::validate_scope(&scope)?; - if entries.by_id.contains_key(&scope.scope_id) { + // One identity and one subject per registration: the audit and store + // ports key by subject, and retiring one scope must not orphan another. + if entries.by_id.contains_key(&scope.scope_id) || entries.holds_subject(&scope) { return Err(Error::Invalid); } entries.insert(ScopeEntry::init(scope)); diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 11ea6dc..d9a15d5 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -484,3 +484,24 @@ async fn host_tool_marker_is_an_external_effect_that_cron_cannot_reach() -> Resu assert!(owner.begin(host_tool).await.is_err()); Ok(()) } + +#[test] +fn init_rejects_duplicate_identities_and_duplicate_subjects() -> Result<()> { + let build = |cfg: ApprovalConfig2| { + Engine::new( + cfg, + Arc::new(Fixture::default()), + Arc::new(TestClock::default()), + Redactor::new(&RedactorConfig::default()).map_err(|_| Error::Invalid)?, + ) + }; + let mut same_identity = config(); + same_identity.scopes.push(same_identity.scopes[0].clone()); + assert_eq!(build(same_identity).err(), Some(Error::Invalid)); + let mut same_subject = config(); + let mut twin = same_subject.scopes[0].clone(); + twin.scope_id = id(9); + same_subject.scopes.push(twin); + assert_eq!(build(same_subject).err(), Some(Error::Invalid)); + Ok(()) +} From 29807372ffb8d51c7013b963cead4dacb0acdfc1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 02:14:10 +0300 Subject: [PATCH 03/12] test(approval): drop needless Result from the init duplicate test Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_scopes_tests.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index d9a15d5..216249c 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -486,7 +486,7 @@ async fn host_tool_marker_is_an_external_effect_that_cron_cannot_reach() -> Resu } #[test] -fn init_rejects_duplicate_identities_and_duplicate_subjects() -> Result<()> { +fn init_rejects_duplicate_identities_and_duplicate_subjects() { let build = |cfg: ApprovalConfig2| { Engine::new( cfg, @@ -503,5 +503,4 @@ fn init_rejects_duplicate_identities_and_duplicate_subjects() -> Result<()> { twin.scope_id = id(9); same_subject.scopes.push(twin); assert_eq!(build(same_subject).err(), Some(Error::Invalid)); - Ok(()) } From da36cb491c6897a7077b710db5d2c6bdd912504b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 03:10:52 +0300 Subject: [PATCH 04/12] fix(approval): serialize scope lifecycles, snapshot scopes, order host-tool checks Address review of #22: - registration and retirement hold a lifecycle lock across the ports' awaits; a failed admission is undone and a stale audit client never keeps its stream - begin reads posture, policy reference and grants from one scope snapshot - Check::HostTool is evaluated after every concrete check so it never masks a later hard denial - add the runtime approval scopes specification and plan Co-authored-by: Medulla --- crates/tinysecurity-approval/Cargo.toml | 2 + crates/tinysecurity-approval/src/engine.rs | 37 +++-- .../src/engine_scopes_tests.rs | 126 ++++++++++++++++++ .../tinysecurity-approval/src/engine_tests.rs | 12 ++ .../src/approval_callbacks.rs | 7 +- .../src/approval_callbacks_tests.rs | 2 + crates/tinysecurity-policy/src/engine.rs | 14 +- .../tinysecurity-policy/src/engine_tests.rs | 28 ++++ docs/plans/runtime-approval-scopes.md | 26 ++++ docs/specs/runtime-approval-scopes.md | 71 ++++++++++ 10 files changed, 305 insertions(+), 20 deletions(-) create mode 100644 docs/plans/runtime-approval-scopes.md create mode 100644 docs/specs/runtime-approval-scopes.md diff --git a/crates/tinysecurity-approval/Cargo.toml b/crates/tinysecurity-approval/Cargo.toml index 83730d1..892db95 100644 --- a/crates/tinysecurity-approval/Cargo.toml +++ b/crates/tinysecurity-approval/Cargo.toml @@ -16,6 +16,8 @@ sha2.workspace = true getrandom.workspace = true thiserror.workspace = true async-trait.workspace = true +# Serializes scope registration and retirement across the ports' awaits. +tokio = { workspace = true, features = ["sync"] } [dev-dependencies] # Typed fake judge responses test durable approval integration without HTTP. tinyinference-decisions.workspace = true diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 5a34fe7..d3262a5 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -282,6 +282,9 @@ struct NativeIntent { /// Immutable subject engine. Host-instance ownership prevents orphan execution. pub struct Engine { scopes: RwLock, + /// Held across the ports' awaits so registration and retirement of subjects + /// never interleave; replacement is atomic under the table lock alone. + lifecycle: tokio::sync::Mutex<()>, ports: Arc, clock: Arc, redactor: RwLock, @@ -334,6 +337,7 @@ impl Engine { let time = (wall, clock.monotonic_ms(), wall, false); Ok(Self { scopes: RwLock::new(entries), + lifecycle: tokio::sync::Mutex::new(()), ports, clock, redactor: RwLock::new(redactor), @@ -450,8 +454,9 @@ impl Engine { request: EvaluateApprovalRequest2, deadline: Option, ) -> Result { + // One snapshot: posture, policy reference and grants never mix replacements. let entry = self.entry(&request.scope_id)?; - let reg = self.registration(&request.scope_id)?; + let reg = self.registered(&entry); let (bound, args_redacted, external_effect) = binding::original(®, &request.request, &self.redactor()?)?; let rule = tinysecurity_auto::rules::evaluate(®, &bound.origin, external_effect); @@ -1011,10 +1016,15 @@ impl Engine { .cloned() .ok_or(Error::Denied) } - fn registration(&self, id: &str) -> Result { - let mut reg = self.entry(id)?.scope.clone(); + /// The registration of one snapshot, bound to the engine's current generation. + fn registered(&self, entry: &ScopeEntry) -> ApprovalScope2 { + let mut reg = entry.scope.clone(); reg.generation = self.generation.load(Ordering::SeqCst); - Ok(reg) + reg + } + fn registration(&self, id: &str) -> Result { + let entry = self.entry(id)?; + Ok(self.registered(&entry)) } fn validate_scope(scope: &ApprovalScope2) -> Result<()> { if scope.scope_id.len() != 32 @@ -1070,6 +1080,9 @@ impl Engine { ) -> Result { self.validate_runtime(&request)?; 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. + let _lifecycle = self.lifecycle.lock().await; { let scopes = self.scopes.read().map_err(|_| Error::Unavailable)?; if let Some(existing) = scopes.by_id.get(&candidate.scope.scope_id) { @@ -1094,17 +1107,12 @@ impl Engine { } } let stream = candidate.audit_stream_id.clone().unwrap_or_default(); - self.ports.admit_scope(&candidate.scope, &stream).await?; - let mut scopes = self.scopes.write().map_err(|_| Error::Unavailable)?; - // The ports awaited with no lock held, so re-check what another call may have won. - if scopes.by_id.len() >= MAX_APPROVAL_SCOPES2 { - return Err(Error::Unavailable); - } - if scopes.by_id.contains_key(&candidate.scope.scope_id) - || scopes.holds_subject(&candidate.scope) - { - return Err(Error::Conflict); + if let Err(error) = self.ports.admit_scope(&candidate.scope, &stream).await { + // No live scope holds this subject (checked above), so undoing is safe. + let _ = self.ports.release_scope(&candidate.scope).await; + return Err(error); } + let mut scopes = self.scopes.write().map_err(|_| Error::Unavailable)?; let (id, generation) = (candidate.scope.scope_id.clone(), candidate.scope.generation); scopes.insert(candidate); Self::receipt(&scopes, &id, generation, ApprovalScopeOutcome2::Created) @@ -1161,6 +1169,7 @@ impl Engine { &self, request: RetireApprovalScopeRequest2, ) -> Result { + let _lifecycle = self.lifecycle.lock().await; let removed = self .scopes .write() diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 216249c..1831c37 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -504,3 +504,129 @@ fn init_rejects_duplicate_identities_and_duplicate_subjects() { same_subject.scopes.push(twin); assert_eq!(build(same_subject).err(), Some(Error::Invalid)); } + +#[tokio::test] +async fn racing_registrations_of_one_subject_admit_exactly_one() -> Result<()> { + let (fixture, owner) = owner()?; + let owner = Arc::new(owner); + fixture.admit_pause.store(1, Ordering::SeqCst); + let first = tokio::spawn({ + let owner = owner.clone(); + async move { owner.register_scope(register(7)).await } + }); + fixture.ports_entered.notified().await; + // Same subject, another identity and stream, while the first is mid-admission. + let mut rival = register(7); + rival.scope.scope_id = id(8); + rival.audit_stream_id = "6".repeat(32); + let second = tokio::spawn({ + let owner = owner.clone(); + async move { owner.register_scope(rival).await } + }); + tokio::task::yield_now().await; + assert!( + !second.is_finished(), + "the rival waits for the first lifecycle" + ); + fixture.ports_proceed.notify_one(); + assert_eq!( + first.await.map_err(|_| Error::Unavailable)??.outcome, + ApprovalScopeOutcome2::Created + ); + assert_eq!( + second.await.map_err(|_| Error::Unavailable)?.err(), + Some(Error::Conflict) + ); + // The ports only ever saw the winner, never the loser's stream. + assert_eq!( + *fixture.admitted.lock().map_err(|_| Error::Unavailable)?, + vec![(id(7), STREAM.to_owned())] + ); + assert!( + fixture + .released + .lock() + .map_err(|_| Error::Unavailable)? + .is_empty() + ); + Ok(()) +} + +#[tokio::test] +async fn failed_admission_is_undone_and_leaves_no_scope() -> Result<()> { + let (fixture, owner) = owner()?; + fixture.admit_fails.store(1, Ordering::SeqCst); + assert_eq!( + owner.register_scope(register(7)).await.err(), + Some(Error::Unavailable) + ); + // The failed attempt asked the ports to forget the subject, and none is live. + fixture.admit_fails.store(0, Ordering::SeqCst); + assert_eq!( + *fixture.released.lock().map_err(|_| Error::Unavailable)?, + vec![id(7)] + ); + assert!( + owner + .begin(begin_for(7, "local_diagnostic", None, Origin::Operator)) + .await + .is_err() + ); + assert_eq!( + owner.register_scope(register(7)).await?.outcome, + ApprovalScopeOutcome2::Created + ); + Ok(()) +} + +#[tokio::test] +async fn retirement_finishes_releasing_before_the_subject_can_register_again() -> Result<()> { + let (fixture, owner) = owner()?; + let owner = Arc::new(owner); + owner.register_scope(register(7)).await?; + fixture.release_pause.store(1, Ordering::SeqCst); + let retire = tokio::spawn({ + let owner = owner.clone(); + async move { + owner + .retire_scope(RetireApprovalScopeRequest2 { scope_id: id(7) }) + .await + } + }); + fixture.ports_entered.notified().await; + let mut again = register(7); + again.scope.scope_id = id(8); + let registered = tokio::spawn({ + let owner = owner.clone(); + async move { owner.register_scope(again).await } + }); + tokio::task::yield_now().await; + assert!( + !registered.is_finished(), + "re-registration waits for the release" + ); + fixture.ports_proceed.notify_one(); + assert_eq!( + retire.await.map_err(|_| Error::Unavailable)??.outcome, + ApprovalScopeOutcome2::Retired + ); + assert_eq!( + registered.await.map_err(|_| Error::Unavailable)??.outcome, + ApprovalScopeOutcome2::Created + ); + // Release happened first, then the replacement was admitted and stays live. + assert_eq!( + *fixture.released.lock().map_err(|_| Error::Unavailable)?, + vec![id(7)] + ); + assert_eq!( + fixture + .admitted + .lock() + .map_err(|_| Error::Unavailable)? + .last() + .map(|a| a.0.clone()), + Some(id(8)) + ); + Ok(()) +} diff --git a/crates/tinysecurity-approval/src/engine_tests.rs b/crates/tinysecurity-approval/src/engine_tests.rs index d530fd2..9f39f18 100644 --- a/crates/tinysecurity-approval/src/engine_tests.rs +++ b/crates/tinysecurity-approval/src/engine_tests.rs @@ -47,10 +47,18 @@ struct Fixture { released: Mutex>, admit_fails: AtomicU64, release_fails: AtomicU64, + admit_pause: AtomicU64, + release_pause: AtomicU64, + ports_entered: tokio::sync::Notify, + ports_proceed: tokio::sync::Notify, } #[async_trait::async_trait] impl Ports for Fixture { async fn admit_scope(&self, scope: &ApprovalScope2, audit_stream_id: &str) -> Result<()> { + if self.admit_pause.swap(0, Ordering::SeqCst) != 0 { + self.ports_entered.notify_one(); + self.ports_proceed.notified().await; + } if self.admit_fails.load(Ordering::SeqCst) != 0 { return Err(Error::Unavailable); } @@ -61,6 +69,10 @@ impl Ports for Fixture { Ok(()) } async fn release_scope(&self, scope: &ApprovalScope2) -> Result<()> { + if self.release_pause.swap(0, Ordering::SeqCst) != 0 { + self.ports_entered.notify_one(); + self.ports_proceed.notified().await; + } if self.release_fails.load(Ordering::SeqCst) != 0 { return Err(Error::Unavailable); } diff --git a/crates/tinysecurity-module/src/approval_callbacks.rs b/crates/tinysecurity-module/src/approval_callbacks.rs index 9d6f29c..0ee510c 100644 --- a/crates/tinysecurity-module/src/approval_callbacks.rs +++ b/crates/tinysecurity-module/src/approval_callbacks.rs @@ -239,10 +239,11 @@ impl Ports for ApprovalClients { let handle = tinysecurity::approval::scope_handle(&scope.subject)?; let client = Arc::new(self.runtime_client(scope, audit_stream_id)?); self.store.admit(handle.clone())?; + // The engine serializes lifecycles, so an existing client is stale (for + // example after a failed release) and must not keep another stream. let mut audit = self.audit.write().map_err(|_| Error::Unavailable)?; - if !audit.iter().any(|(s, _)| s == &handle) { - audit.push((handle, client)); - } + audit.retain(|(s, _)| s != &handle); + audit.push((handle, client)); Ok(()) } async fn release_scope(&self, scope: &ApprovalScope2) -> Result<()> { diff --git a/crates/tinysecurity-module/src/approval_callbacks_tests.rs b/crates/tinysecurity-module/src/approval_callbacks_tests.rs index d0d23f4..3efee70 100644 --- a/crates/tinysecurity-module/src/approval_callbacks_tests.rs +++ b/crates/tinysecurity-module/src/approval_callbacks_tests.rs @@ -875,6 +875,8 @@ async fn clients_admit_a_runtime_subject_with_its_own_stream_and_release_it() assert!(clients.admit_scope(&scope, "short").await.is_err()); assert!(clients.store.scope(&handle).is_err()); clients.admit_scope(&scope, &"d".repeat(32)).await?; + // A stale client (say after a failed release) never keeps the old stream. + clients.admit_scope(&scope, &"e".repeat(32)).await?; clients.admit_scope(&scope, &"d".repeat(32)).await?; assert!(clients.store.scope(&handle).is_ok()); { diff --git a/crates/tinysecurity-policy/src/engine.rs b/crates/tinysecurity-policy/src/engine.rs index 25c3351..3077ca6 100644 --- a/crates/tinysecurity-policy/src/engine.rs +++ b/crates/tinysecurity-policy/src/engine.rs @@ -139,13 +139,21 @@ impl Policy { if let Some(reason) = scan_denial(&scan.verdict) { return Ok(self.denied(reason)); } + // The host-tool marker only says "a human must approve". Every concrete + // check runs first, so a later hard denial is never masked by the marker. + let mut marked = None; for check in &request.call.checks { let decision = self.one(settings, hooks, &request.caller, check); - if decision.verdict != Verdict::Allow { - return Ok(decision); + if decision.verdict == Verdict::Allow { + continue; } + if matches!(check, Check::HostTool {}) { + marked.get_or_insert(decision); + continue; + } + return Ok(decision); } - Ok(self.allowed()) + Ok(marked.unwrap_or_else(|| self.allowed())) } /// Evaluate an ordered batch against a single active generation. /// # Errors diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index b4b2797..cb75688 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -189,3 +189,31 @@ fn host_tool_never_relaxes_other_checks_and_rejects_reserved_or_unmarked_calls() ); Ok(()) } + +#[test] +fn host_tool_marker_never_masks_a_later_hard_denial() -> Result<()> { + let policy = Policy::new(ModuleConfig::default())?; + let hard = |checks| verdict(&policy, &host_call("file_read", checks)); + let floor = Check::Path { + path: "/root/.ssh/id_rsa".into(), + write: false, + }; + let marker_first = hard(vec![Check::HostTool {}, floor.clone()])?; + let marker_last = hard(vec![floor, Check::HostTool {}])?; + assert_eq!(marker_first, marker_last); + assert!(matches!(marker_first, Verdict::Deny { .. })); + assert_ne!( + marker_first, + Verdict::Deny { + reason: DenialReason::ApprovalUnavailable + } + ); + // Without a concrete denial the marker still asks for approval, wherever it is. + assert_eq!( + hard(vec![Check::HostTool {}, Check::LocalDiagnostic {}])?, + Verdict::Deny { + reason: DenialReason::ApprovalUnavailable + } + ); + Ok(()) +} diff --git a/docs/plans/runtime-approval-scopes.md b/docs/plans/runtime-approval-scopes.md new file mode 100644 index 0000000..2d220ba --- /dev/null +++ b/docs/plans/runtime-approval-scopes.md @@ -0,0 +1,26 @@ +# Runtime approval scopes implementation sequence + +- Status: implemented +- Specification: [runtime approval scopes](../specs/runtime-approval-scopes.md) + +Each task adds the failing test first, then the smallest implementation, then runs +the owning crate suite. Final verification is the root list in +[`security-module.md`](security-module.md#delivery-and-validation-rules). + +1. **Contract** (`crates/tinysecurity-bus/src/approval_scopes.rs`, `names.rs`, + `policy.rs`, `lib.rs`): the three members, `ApprovalGrants2`, limits, + `is_host_tool_name2`, `Check::HostTool`, contract 1.17. +2. **Engine** (`crates/tinysecurity-approval/src/engine.rs`, `binding.rs`): scope + table with a subject index, `register_scope`, `replace_scope`, `retire_scope`, + the lifecycle lock, grants and attribution, `Ports::admit_scope` and + `release_scope`. +3. **Policy** (`crates/tinysecurity-policy/src/engine.rs`, `scoped.rs`): accept host + tools, evaluate the marker after the concrete checks. +4. **Module** (`crates/tinysecurity-module/src/adapter.rs`, `approval_callbacks.rs`): + serve the members; `ApprovalClients` admits and releases subjects and their audit + streams. +5. **Native evidence** (`examples/approval_scope_native_contract.rs`, CI workflow): + the flow against the loaded cdylib on all three platforms. +6. **Docs**: `MODULE.md`, README, approval README, this specification and plan. + +Completion checklist: all tasks landed; CI green on Linux, macOS and Windows. diff --git a/docs/specs/runtime-approval-scopes.md b/docs/specs/runtime-approval-scopes.md new file mode 100644 index 0000000..d47eb04 --- /dev/null +++ b/docs/specs/runtime-approval-scopes.md @@ -0,0 +1,71 @@ +# Runtime approval scopes, tool grants and host tools + +Status: implemented. Owner: TinySecurity. Contract vocabulary: 1.17 (extends 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). +This specification records the behavior and the reasons for it. + +## Problem + +OpenHuman's approval gate cannot move onto the module because the 1.16 engine: + +1. fixes at most 128 approval scopes at init, while the host serves an unbounded set + of subjects (SaaS tenants, derived embedded agents); +2. accepts only the module's own effect-class tool names (`local_diagnostic`, + `path_check`, `command_check`), while the host asks approval for arbitrary tools; +3. has only exact-content human grants, while the host keeps a configured + "always allow this tool" list and per-flow tool trust. + +## Goals and non-goals + +Goals: register, change and retire subjects after init; express the host's tool and +per-flow allowlists as audited, persisted rows; let the host request approval for any +named external-effect tool without weakening path or command policy. + +Non-goals: the module does not mint audit correlations (the host allocates event ids +and tracks each stream's tail), does not persist grants itself (they are host +configuration re-registered on change), and does not treat a name as a wildcard. + +## Proposed behavior + +- `RegisterApprovalScope2`, `ReplaceApprovalScope2`, `RetireApprovalScope2` are + private host-only members served to the authenticated init peer. +- A scope has one identity and one subject. Identical re-registration is + `unchanged`; any other reuse of the identity or the subject is a conflict. +- A registration carries a generation equal to the module's current generation, a + host-persisted 32-hex audit stream, and optional `ApprovalGrants2`. +- The ports admit the subject (store scope, audit client) before it is usable. + Runtime scopes forward audit to the host `AuditSink2`. +- Registration and retirement are serialized; a failed admission is undone; + retirement finishes releasing before the subject can register again. +- `ReplaceApprovalScope2` swaps posture, grants and `policy_ref` atomically. The + subject and a runtime scope's stream cannot change. +- Grants convert a required approval into an automatic row attributed + `auto:tool-grant` or `auto:flow-grant`. They never apply to reviewed reuse, + denials, Cron external effects or human-only tools. +- `Check::HostTool {}` is "a human must approve". Every concrete check beside it + runs first and wins; it is invalid for reserved names and beside `Unknown` or + `PolicyChange`. + +## Invariants and constraints + +- At most 65,536 live scopes; at most 1,024 tool and 4,096 flow grants per scope. +- Grant names are exact, bounded, free of control characters and never name a + human-only tool or `native_*`. +- The engine owns the generation; a repeated registration after an advance is + `unchanged`. +- Initialization rejects duplicate identities and duplicate subjects. +- Contract 1.17 is additive: older hosts keep working, a 1.17 host refuses a 1.16 + module. + +## Acceptance criteria + +- Engine, policy, module-over-bus and loaded-cdylib tests cover registration, + conflicts, capacity, replace, retire, races, grants and the host-tool marker. +- Per-file coverage stays at or above 90 percent and clippy 1.99 is clean. + +## Open questions + +None blocking. Name-wide grants that must survive a restart are the host's +configuration to re-register; a durable name-wide row is deliberately not offered. From abb5b74b8af27cdae2ac98ca113e5d3a8d26f045 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 04:17:41 +0300 Subject: [PATCH 05/12] fix(approval): recheck generation around admission, reject control chars in flow ids Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 18 ++++- .../src/engine_scopes_tests.rs | 71 +++++++++++++++++++ .../tinysecurity-bus/src/approval_scopes.rs | 15 ++-- .../src/approval_scopes_tests.rs | 7 ++ 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index d3262a5..e487a9f 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -1050,10 +1050,15 @@ impl Engine { if !request.grants.is_valid() || !binding::hex(&request.audit_stream_id, 32) { return Err(Error::Invalid); } - if request.scope.generation != self.generation.load(Ordering::SeqCst) { - return Err(Error::Denied); + self.current_generation(&request.scope) + } + /// Deny a registration made against a generation that is no longer current. + fn current_generation(&self, scope: &ApprovalScope2) -> Result<()> { + if scope.generation == self.generation.load(Ordering::SeqCst) { + Ok(()) + } else { + Err(Error::Denied) } - Ok(()) } fn receipt( scopes: &ScopeTable, @@ -1083,6 +1088,8 @@ impl Engine { // Registration and retirement are serialized, so every check below still // holds when the entry is inserted and a failed admission is ours to undo. let _lifecycle = self.lifecycle.lock().await; + // The generation may have advanced while waiting for the lifecycle. + self.current_generation(&candidate.scope)?; { let scopes = self.scopes.read().map_err(|_| Error::Unavailable)?; if let Some(existing) = scopes.by_id.get(&candidate.scope.scope_id) { @@ -1112,6 +1119,11 @@ impl Engine { let _ = self.ports.release_scope(&candidate.scope).await; return Err(error); } + // ...or while the ports admitted the subject; a stale scope never goes live. + if let Err(error) = self.current_generation(&candidate.scope) { + let _ = self.ports.release_scope(&candidate.scope).await; + return Err(error); + } let mut scopes = self.scopes.write().map_err(|_| Error::Unavailable)?; let (id, generation) = (candidate.scope.scope_id.clone(), candidate.scope.generation); scopes.insert(candidate); diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 1831c37..676d025 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -630,3 +630,74 @@ async fn retirement_finishes_releasing_before_the_subject_can_register_again() - ); Ok(()) } + +#[tokio::test] +async fn generation_advance_during_admission_never_publishes_the_stale_scope() -> Result<()> { + let (fixture, owner) = owner()?; + let owner = Arc::new(owner); + fixture.admit_pause.store(1, Ordering::SeqCst); + let racing = tokio::spawn({ + let owner = owner.clone(); + async move { owner.register_scope(register(7)).await } + }); + fixture.ports_entered.notified().await; + owner.advance_generation(2)?; + fixture.ports_proceed.notify_one(); + assert_eq!( + racing.await.map_err(|_| Error::Unavailable)?.err(), + Some(Error::Denied) + ); + // The admitted port state was undone and the scope is not usable. + assert_eq!( + *fixture.released.lock().map_err(|_| Error::Unavailable)?, + vec![id(7)] + ); + assert!( + owner + .begin(begin_for(7, "local_diagnostic", None, Origin::Operator)) + .await + .is_err() + ); + // A registration made against the new generation succeeds. + let mut fresh = register(7); + fresh.scope.generation = 2; + assert_eq!( + owner.register_scope(fresh).await?.outcome, + ApprovalScopeOutcome2::Created + ); + Ok(()) +} + +#[tokio::test] +async fn generation_advance_while_waiting_for_the_lifecycle_denies_the_registration() -> Result<()> +{ + let (fixture, owner) = owner()?; + let owner = Arc::new(owner); + fixture.admit_pause.store(1, Ordering::SeqCst); + let holder = tokio::spawn({ + let owner = owner.clone(); + async move { owner.register_scope(register(7)).await } + }); + fixture.ports_entered.notified().await; + let waiting = tokio::spawn({ + let owner = owner.clone(); + async move { owner.register_scope(register(8)).await } + }); + tokio::task::yield_now().await; + owner.advance_generation(2)?; + fixture.ports_proceed.notify_one(); + let _ = holder.await.map_err(|_| Error::Unavailable)?; + assert_eq!( + waiting.await.map_err(|_| Error::Unavailable)?.err(), + Some(Error::Denied) + ); + assert_eq!( + fixture + .admitted + .lock() + .map_err(|_| Error::Unavailable)? + .len(), + 1 + ); + Ok(()) +} diff --git a/crates/tinysecurity-bus/src/approval_scopes.rs b/crates/tinysecurity-bus/src/approval_scopes.rs index 927405c..65eb012 100644 --- a/crates/tinysecurity-bus/src/approval_scopes.rs +++ b/crates/tinysecurity-bus/src/approval_scopes.rs @@ -72,21 +72,20 @@ impl ApprovalGrants2 { /// Whether the grants respect every bound and name no human-only tool. #[must_use] pub fn is_valid(&self) -> bool { - let name = |s: &str| { + let plain = |s: &str| { !s.trim().is_empty() && s.len() <= MAX_GRANT_NAME_BYTES2 && !s.chars().any(char::is_control) - && !HUMAN_ONLY_TOOLS2.contains(&s) - && !s.starts_with("native_") }; + let name = + |s: &str| plain(s) && !HUMAN_ONLY_TOOLS2.contains(&s) && !s.starts_with("native_"); self.tools.len() <= MAX_TOOL_GRANTS2 && self.flows.len() <= MAX_FLOW_GRANTS2 && self.tools.iter().all(|t| name(t)) - && self.flows.iter().all(|g| { - name(&g.tool) - && !g.flow_id.trim().is_empty() - && g.flow_id.len() <= MAX_GRANT_NAME_BYTES2 - }) + && self + .flows + .iter() + .all(|g| name(&g.tool) && plain(&g.flow_id)) } } diff --git a/crates/tinysecurity-bus/src/approval_scopes_tests.rs b/crates/tinysecurity-bus/src/approval_scopes_tests.rs index ea62839..d7f8662 100644 --- a/crates/tinysecurity-bus/src/approval_scopes_tests.rs +++ b/crates/tinysecurity-bus/src/approval_scopes_tests.rs @@ -74,6 +74,13 @@ fn grants_reject_human_only_blank_control_oversized_and_overfull_names() { tool: "shell".into(), }], })); + assert!(bad(ApprovalGrants2 { + tools: vec![], + flows: vec![FlowToolGrant2 { + flow_id: "flow\nadmin".into(), + tool: "shell".into(), + }], + })); assert!(bad(ApprovalGrants2 { tools: (0..=MAX_TOOL_GRANTS2).map(|i| format!("t{i}")).collect(), flows: vec![], From 5e5d77c77055701aeefd88917d612f6c21afb19b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 04:55:59 +0300 Subject: [PATCH 06/12] fix(approval): a revoked grant neither publishes nor leaves a consumable approval Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 27 ++++++++++ .../src/engine_scopes_tests.rs | 49 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index e487a9f..ec1aef8 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -504,6 +504,10 @@ impl Engine { if reg.generation != self.generation.load(Ordering::SeqCst) { return Err(Error::Denied); } + // A grant revoked while the ports were awaited must not publish an approval. + if granted.is_some() && !Arc::ptr_eq(&entry, &self.entry(&request.scope_id)?) { + return Err(Error::Denied); + } let now = self.now()?; if reusable .as_ref() @@ -1452,6 +1456,28 @@ impl Engine { acknowledgement, }) } + /// A row approved by a host grant is only consumable while a grant still covers + /// the call; replacing the scope without it revokes unconsumed approvals. + fn grant_still_holds( + &self, + request: &ApprovalActionRequest2, + row: &ApprovalRow2, + ) -> Result<()> { + if !matches!( + row.attribution.as_deref(), + Some("auto:tool-grant" | "auto:flow-grant") + ) { + return Ok(()); + } + let entry = self.entry(&request.scope_id)?; + entry + .grant( + &request.request.call.tool, + request.request.caller.flow.as_deref(), + ) + .map(|_| ()) + .ok_or(Error::Denied) + } async fn consume_inner( &self, request: ApprovalActionRequest2, @@ -1476,6 +1502,7 @@ impl Engine { if row.state != ApprovalLifecycle2::Approved || row.host_instance != self.instance { return Err(Error::Denied); } + self.grant_still_holds(&request, &row)?; Self::correlation(&request.audit, reg.generation)?; let previous = row.version; row.version = previous.checked_add(1).ok_or(Error::Invalid)?; diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 676d025..0b69ac1 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -701,3 +701,52 @@ async fn generation_advance_while_waiting_for_the_lifecycle_denies_the_registrat ); Ok(()) } + +#[tokio::test] +async fn revoking_a_grant_blocks_consumption_of_its_unconsumed_approval() -> Result<()> { + let (_, owner) = owner()?; + let mut req = register(7); + req.grants = grants(&["shell"], &[]); + owner.register_scope(req).await?; + let call = begin_for(7, "shell", None, Origin::Operator); + let row = owner.begin(call.clone()).await?; + assert_eq!(row.state, ApprovalLifecycle2::Approved); + // The grant is removed before the approved row is consumed. + owner.replace_scope(register(7))?; + let mut act = action(&row, 3); + act.scope_id = id(7); + act.request = call.request; + assert_eq!(owner.consume(act).await.err(), Some(Error::Denied)); + Ok(()) +} + +#[tokio::test] +async fn grant_revoked_while_the_ports_await_never_publishes_an_approval() -> Result<()> { + let (fixture, owner) = owner()?; + let owner = Arc::new(owner); + let mut req = register(7); + req.grants = grants(&["shell"], &[]); + owner.register_scope(req).await?; + fixture.pause_load.store(1, Ordering::SeqCst); + let racing = tokio::spawn({ + let owner = owner.clone(); + async move { + owner + .begin(begin_for(7, "shell", None, Origin::Operator)) + .await + } + }); + fixture.loaded.notified().await; + owner.replace_scope(register(7))?; + fixture.release_load.notify_one(); + assert_eq!( + racing.await.map_err(|_| Error::Unavailable)?.err(), + Some(Error::Denied) + ); + // Asking again under the replaced scope needs a human. + let again = owner + .begin(begin_for(7, "shell", None, Origin::Operator)) + .await?; + assert_eq!(again.state, ApprovalLifecycle2::Pending); + Ok(()) +} From af70171b4ece2af8584319f17bc3d964cb3a3e22 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 05:27:31 +0300 Subject: [PATCH 07/12] fix(approval): revalidate posture-derived approvals, linearize replacement with generation advance Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 68 +++++++++++----- .../src/engine_scopes_tests.rs | 78 +++++++++++++++++++ 2 files changed, 125 insertions(+), 21 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index ec1aef8..a4c36d3 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -504,8 +504,9 @@ impl Engine { if reg.generation != self.generation.load(Ordering::SeqCst) { return Err(Error::Denied); } - // A grant revoked while the ports were awaited must not publish an approval. - if granted.is_some() && !Arc::ptr_eq(&entry, &self.entry(&request.scope_id)?) { + // A grant or posture replaced while the ports were awaited must not publish + // an automatic approval from the obsolete snapshot. + if automatic && !Arc::ptr_eq(&entry, &self.entry(&request.scope_id)?) { return Err(Error::Denied); } let now = self.now()?; @@ -1123,15 +1124,25 @@ impl Engine { let _ = self.ports.release_scope(&candidate.scope).await; return Err(error); } - // ...or while the ports admitted the subject; a stale scope never goes live. - if let Err(error) = self.current_generation(&candidate.scope) { - let _ = self.ports.release_scope(&candidate.scope).await; - return Err(error); + // Checked under the table lock that `advance_generation` also takes, so a + // stale scope never goes live after the ports admitted its subject. + let admitted = candidate.scope.clone(); + 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); + scopes.insert(candidate); + Self::receipt(&scopes, &id, generation, ApprovalScopeOutcome2::Created) + }) + }; + match created { + Ok(receipt) => receipt, + Err(error) => { + let _ = self.ports.release_scope(&admitted).await; + Err(error) + } } - let mut scopes = self.scopes.write().map_err(|_| Error::Unavailable)?; - let (id, generation) = (candidate.scope.scope_id.clone(), candidate.scope.generation); - scopes.insert(candidate); - Self::receipt(&scopes, &id, generation, ApprovalScopeOutcome2::Created) } /// Atomically replace the posture, grants or policy reference of a live scope. /// The subject and, for runtime scopes, the audit stream cannot change. Pending @@ -1146,6 +1157,9 @@ impl Engine { self.validate_runtime(&request)?; 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 + // point: a replacement is installed before an advance or denied after it. + self.current_generation(&candidate.scope)?; let existing = scopes .by_id .get(&candidate.scope.scope_id) @@ -1456,27 +1470,37 @@ impl Engine { acknowledgement, }) } - /// A row approved by a host grant is only consumable while a grant still covers - /// the call; replacing the scope without it revokes unconsumed approvals. + /// A row approved without a human is only consumable while the scope still + /// approves it: a grant that covers the call, or the posture that made it + /// automatic. Replacing the scope revokes unconsumed approvals. fn grant_still_holds( &self, request: &ApprovalActionRequest2, row: &ApprovalRow2, ) -> Result<()> { - if !matches!( - row.attribution.as_deref(), - Some("auto:tool-grant" | "auto:flow-grant") - ) { - return Ok(()); - } + let posture = match row.attribution.as_deref() { + Some("auto:tool-grant" | "auto:flow-grant") => false, + Some("auto:host-policy") => true, + _ => return Ok(()), + }; let entry = self.entry(&request.scope_id)?; - entry + let granted = entry .grant( &request.request.call.tool, request.request.caller.flow.as_deref(), ) - .map(|_| ()) - .ok_or(Error::Denied) + .is_some(); + let automatic = posture + && tinysecurity_auto::rules::evaluate( + &entry.scope, + &row.binding.origin, + row.external_effect, + ) == tinysecurity_auto::rules::Rule::Automatic; + if granted || automatic { + Ok(()) + } else { + Err(Error::Denied) + } } async fn consume_inner( &self, @@ -1593,6 +1617,8 @@ impl Engine { } let mut creations = self.creations.lock().map_err(|_| Error::Unavailable)?; let mut intents = self.native_intents.lock().map_err(|_| Error::Unavailable)?; + // Registrations and replacements validate their generation under this lock. + let _table = self.scopes.write().map_err(|_| Error::Unavailable)?; self.generation .compare_exchange(previous, next, Ordering::SeqCst, Ordering::SeqCst) .map_err(|_| Error::Conflict)?; diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 0b69ac1..f6422ce 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -750,3 +750,81 @@ async fn grant_revoked_while_the_ports_await_never_publishes_an_approval() -> Re assert_eq!(again.state, ApprovalLifecycle2::Pending); Ok(()) } + +fn automatic_scope(n: u64) -> RegisterApprovalScopeRequest2 { + let mut req = register(n); + req.scope.auto_approve_all = true; + req +} + +#[tokio::test] +async fn tightening_posture_blocks_consumption_of_an_unconsumed_automatic_approval() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(automatic_scope(7)).await?; + let call = begin_for(7, "shell", None, Origin::Operator); + let row = owner.begin(call.clone()).await?; + assert_eq!(row.attribution.as_deref(), Some("auto:host-policy")); + owner.replace_scope(register(7))?; + let mut act = action(&row, 3); + act.scope_id = id(7); + act.request = call.request; + assert_eq!(owner.consume(act).await.err(), Some(Error::Denied)); + Ok(()) +} + +#[tokio::test] +async fn posture_tightened_while_the_ports_await_never_publishes_an_automatic_row() -> Result<()> { + let (fixture, owner) = owner()?; + let owner = Arc::new(owner); + owner.register_scope(automatic_scope(7)).await?; + fixture.pause_load.store(1, Ordering::SeqCst); + let racing = tokio::spawn({ + let owner = owner.clone(); + async move { + owner + .begin(begin_for(7, "shell", None, Origin::Operator)) + .await + } + }); + fixture.loaded.notified().await; + owner.replace_scope(register(7))?; + fixture.release_load.notify_one(); + assert_eq!( + racing.await.map_err(|_| Error::Unavailable)?.err(), + Some(Error::Denied) + ); + Ok(()) +} + +#[tokio::test] +async fn replacement_and_generation_advance_serialize_on_the_scope_table() -> Result<()> { + let (_, owner) = owner()?; + let owner = Arc::new(owner); + owner.register_scope(register(7)).await?; + let held = owner.scopes.write().map_err(|_| Error::Unavailable)?; + let advancing = std::thread::spawn({ + let owner = owner.clone(); + move || owner.advance_generation(2) + }); + std::thread::sleep(std::time::Duration::from_millis(100)); + assert!(!advancing.is_finished(), "advance waits for the table lock"); + drop(held); + advancing.join().map_err(|_| Error::Unavailable)??; + // A replacement validated against the old generation is denied after the advance, + // and the live scope is untouched. + let mut stale = register(7); + stale.scope.auto_approve_all = true; + assert_eq!( + owner.replace_scope(stale.clone()).err(), + Some(Error::Denied) + ); + let mut call = begin_for(7, "shell", None, Origin::Operator); + call.audit.generation = 2; + assert_eq!(owner.begin(call).await?.state, ApprovalLifecycle2::Pending); + stale.scope.generation = 2; + assert_eq!( + owner.replace_scope(stale)?.outcome, + ApprovalScopeOutcome2::Replaced + ); + Ok(()) +} From 47664a4e2ca72bdd432ef2eb3a7ed460e17934ba Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 06:18:11 +0300 Subject: [PATCH 08/12] fix(approval): judge the live scope when reserving execution Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 7 +++- .../src/engine_scopes_tests.rs | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index a4c36d3..795326f 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -1527,13 +1527,18 @@ impl Engine { return Err(Error::Denied); } self.grant_still_holds(&request, &row)?; - Self::correlation(&request.audit, reg.generation)?; + // The loads and audit above awaited; judge the live scope, not the snapshot. + let live = self.registration(&request.scope_id)?; + Self::current(&live, &row)?; + Self::correlation(&request.audit, live.generation)?; let previous = row.version; row.version = previous.checked_add(1).ok_or(Error::Invalid)?; row.state = ApprovalLifecycle2::Reserved; row.reservation_id = Some(binding::opaque()?); row.audit = request.audit; self.persist(Some(previous), &row).await?; + // A replacement racing the CAS burns the reservation instead of authorizing it. + Self::current(&self.registration(&request.scope_id)?, &row)?; self.live(&row)?; if native { self.native_ack(&row, true)?; diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index f6422ce..976464f 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -828,3 +828,35 @@ async fn replacement_and_generation_advance_serialize_on_the_scope_table() -> Re ); Ok(()) } + +#[tokio::test] +async fn policy_replaced_while_consume_awaits_never_reserves_the_old_approval() -> Result<()> { + let (fixture, owner) = owner()?; + let owner = Arc::new(owner); + owner.register_scope(automatic_scope(7)).await?; + let call = begin_for(7, "shell", None, Origin::Operator); + let row = owner.begin(call.clone()).await?; + assert_eq!(row.state, ApprovalLifecycle2::Approved); + fixture.pause_load.store(1, Ordering::SeqCst); + let mut act = action(&row, 3); + act.scope_id = id(7); + act.request = call.request; + let racing = tokio::spawn({ + let owner = owner.clone(); + async move { owner.consume(act).await } + }); + fixture.loaded.notified().await; + // Same posture and grants, new policy reference: the approval belongs to the old one. + let mut next = automatic_scope(7); + next.scope.policy_ref = "runtime-policy-v2".into(); + assert_eq!( + owner.replace_scope(next)?.outcome, + ApprovalScopeOutcome2::Replaced + ); + fixture.release_load.notify_one(); + assert_eq!( + racing.await.map_err(|_| Error::Unavailable)?.err(), + Some(Error::Denied) + ); + Ok(()) +} From 2a0f90b90e05d272c2a9b31a60745781ef351f0d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 06:59:21 +0300 Subject: [PATCH 09/12] fix(approval): burn reservations of revoked grants, pin each subject's audit stream Co-authored-by: Medulla --- MODULE.md | 4 +- crates/tinysecurity-approval/src/engine.rs | 6 ++- .../src/engine_scopes_tests.rs | 43 +++++++++++++++++++ .../src/approval_callbacks.rs | 29 +++++++++++++ .../src/approval_callbacks_tests.rs | 6 ++- docs/specs/runtime-approval-scopes.md | 5 +++ 6 files changed, 88 insertions(+), 5 deletions(-) diff --git a/MODULE.md b/MODULE.md index 6517f7f..ad7ee24 100644 --- a/MODULE.md +++ b/MODULE.md @@ -384,7 +384,9 @@ ports must admit a subject (its store scope and audit stream) before it becomes usable: runtime scopes carry a host-persisted `audit_stream_id` and always forward audit to the host `AuditSink2`; they never open a native journal. The engine owns the generation, so repeating a registration after a policy advance is `unchanged` -rather than a new scope. +rather than a new scope. A subject keeps its audit stream for the module's lifetime: +registering it again with another stream is refused, so a call still in flight for a +retired scope can never reach another scope's ledger. `ApprovalGrants2` rides on a registration: exact tool names, and exact tool names inside one named flow. A grant only converts a required approval into an diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 795326f..0d144ca 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -1535,10 +1535,12 @@ impl Engine { row.version = previous.checked_add(1).ok_or(Error::Invalid)?; row.state = ApprovalLifecycle2::Reserved; row.reservation_id = Some(binding::opaque()?); - row.audit = request.audit; + row.audit = request.audit.clone(); self.persist(Some(previous), &row).await?; - // A replacement racing the CAS burns the reservation instead of authorizing it. + // A replacement racing the CAS burns the reservation instead of authorizing it, + // whether it changed the policy reference, a grant or the automatic posture. Self::current(&self.registration(&request.scope_id)?, &row)?; + self.grant_still_holds(&request, &row)?; self.live(&row)?; if native { self.native_ack(&row, true)?; diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 976464f..4d390a2 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -860,3 +860,46 @@ async fn policy_replaced_while_consume_awaits_never_reserves_the_old_approval() ); Ok(()) } + +#[tokio::test] +async fn grant_revoked_while_the_reservation_is_written_burns_it() -> Result<()> { + let (fixture, owner) = owner()?; + let owner = Arc::new(owner); + let mut req = register(7); + req.grants = grants(&["shell"], &[]); + owner.register_scope(req).await?; + let call = begin_for(7, "shell", None, Origin::Operator); + let row = owner.begin(call.clone()).await?; + assert_eq!(row.state, ApprovalLifecycle2::Approved); + // The grant disappears after the checks but before the write commits. + let before = fixture.cas_calls.load(Ordering::SeqCst); + fixture + .barrier + .lock() + .map_err(|_| Error::Unavailable)? + .replace(Arc::new(tokio::sync::Barrier::new(2))); + let mut act = action(&row, 3); + act.scope_id = id(7); + act.request = call.request; + let racing = tokio::spawn({ + let owner = owner.clone(); + async move { owner.consume(act).await } + }); + // The CAS is parked on the barrier; replace the scope, then let the write finish. + while fixture.cas_calls.load(Ordering::SeqCst) == before { + tokio::task::yield_now().await; + } + owner.replace_scope(register(7))?; + let barrier = fixture + .barrier + .lock() + .map_err(|_| Error::Unavailable)? + .clone() + .ok_or(Error::Unavailable)?; + barrier.wait().await; + assert_eq!( + racing.await.map_err(|_| Error::Unavailable)?.err(), + Some(Error::Denied) + ); + Ok(()) +} diff --git a/crates/tinysecurity-module/src/approval_callbacks.rs b/crates/tinysecurity-module/src/approval_callbacks.rs index 0ee510c..7af7de4 100644 --- a/crates/tinysecurity-module/src/approval_callbacks.rs +++ b/crates/tinysecurity-module/src/approval_callbacks.rs @@ -8,6 +8,8 @@ use tinysecurity_bus::{ ApprovalRow2, ApprovalScope2, AuditConfig2, AuditEvent2, AuditKind2, AuditSinkRequest2, AuditSinkResponse2, HumanDecisionReceipt2, SubjectScope, VerifyHumanRequest2, callback_names2, }; +/// Most distinct runtime subjects whose stream the clients remember. +const MAX_PINNED_STREAMS: usize = 1 << 20; /// Captured registered callback identity; no model-provided destination or scope. pub(crate) struct ApprovalStoreClient { connection: tinybus::Connection, @@ -118,6 +120,10 @@ pub(crate) struct ApprovalClients { audit: RwLock)>>, /// Runtime subjects forward audit to the host sink under this base policy. runtime: RuntimeAudit, + /// The one audit stream each runtime subject ever used. A subject keeps its + /// stream for the module's lifetime, so a call still in flight for a retired + /// scope cannot land in another scope's ledger after the subject registers again. + streams: RwLock>, } struct RuntimeAudit { connection: tinybus::Connection, @@ -166,8 +172,30 @@ impl ApprovalClients { store, audit: RwLock::new(clients), runtime, + streams: RwLock::new(std::collections::BTreeMap::new()), }) } + /// Remember a subject's stream on first use and refuse any other one afterwards. + fn pin_stream(&self, scope: &ApprovalScope2, stream_id: &str) -> Result<()> { + if stream_id.len() != 32 || !stream_id.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(Error::Invalid); + } + let key = ( + scope.subject.user.clone(), + scope.subject.workspace.clone(), + scope.subject.agent.clone(), + ); + let mut streams = self.streams.write().map_err(|_| Error::Unavailable)?; + match streams.get(&key) { + Some(pinned) if pinned != stream_id => Err(Error::Conflict), + Some(_) => Ok(()), + None if streams.len() >= MAX_PINNED_STREAMS => Err(Error::Unavailable), + None => { + streams.insert(key, stream_id.into()); + Ok(()) + } + } + } /// Build the audit client of a runtime subject: its own persisted stream, always /// forwarded to the host sink so no extra native journal is opened. fn runtime_client(&self, scope: &ApprovalScope2, stream_id: &str) -> Result { @@ -237,6 +265,7 @@ impl Ports for ApprovalClients { } async fn admit_scope(&self, scope: &ApprovalScope2, audit_stream_id: &str) -> Result<()> { let handle = tinysecurity::approval::scope_handle(&scope.subject)?; + self.pin_stream(scope, audit_stream_id)?; let client = Arc::new(self.runtime_client(scope, audit_stream_id)?); self.store.admit(handle.clone())?; // The engine serializes lifecycles, so an existing client is stale (for diff --git a/crates/tinysecurity-module/src/approval_callbacks_tests.rs b/crates/tinysecurity-module/src/approval_callbacks_tests.rs index 3efee70..7bdf5d3 100644 --- a/crates/tinysecurity-module/src/approval_callbacks_tests.rs +++ b/crates/tinysecurity-module/src/approval_callbacks_tests.rs @@ -875,8 +875,9 @@ async fn clients_admit_a_runtime_subject_with_its_own_stream_and_release_it() assert!(clients.admit_scope(&scope, "short").await.is_err()); assert!(clients.store.scope(&handle).is_err()); clients.admit_scope(&scope, &"d".repeat(32)).await?; - // A stale client (say after a failed release) never keeps the old stream. - clients.admit_scope(&scope, &"e".repeat(32)).await?; + // A subject keeps its stream: no other one is accepted, released or not, so an + // in-flight call of a retired scope cannot reach another ledger. + assert!(clients.admit_scope(&scope, &"e".repeat(32)).await.is_err()); clients.admit_scope(&scope, &"d".repeat(32)).await?; assert!(clients.store.scope(&handle).is_ok()); { @@ -886,6 +887,7 @@ async fn clients_admit_a_runtime_subject_with_its_own_stream_and_release_it() assert_eq!(client.stream_id(), "d".repeat(32)); } clients.release_scope(&scope).await?; + assert!(clients.admit_scope(&scope, &"e".repeat(32)).await.is_err()); assert!(clients.store.scope(&handle).is_err()); assert!( clients diff --git a/docs/specs/runtime-approval-scopes.md b/docs/specs/runtime-approval-scopes.md index d47eb04..8730438 100644 --- a/docs/specs/runtime-approval-scopes.md +++ b/docs/specs/runtime-approval-scopes.md @@ -56,6 +56,11 @@ configuration re-registered on change), and does not treat a name as a wildcard. - The engine owns the generation; a repeated registration after an advance is `unchanged`. - Initialization rejects duplicate identities and duplicate subjects. +- A subject keeps one audit stream for the module's lifetime; a different stream for a + re-registered subject is refused. +- 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 module. From dbdb6cf7817a8e69df27e0ec486582263fb5bd20 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 07:54:56 +0300 Subject: [PATCH 10/12] fix(policy,approval): defer every approval requirement behind hard denials, seed init stream pins Co-authored-by: Medulla --- .../src/approval_callbacks.rs | 22 +++++++++---- .../src/approval_callbacks_tests.rs | 27 ++++++++++++++++ crates/tinysecurity-policy/src/engine.rs | 24 +++++++------- .../tinysecurity-policy/src/engine_tests.rs | 31 +++++++++++++++++++ 4 files changed, 87 insertions(+), 17 deletions(-) diff --git a/crates/tinysecurity-module/src/approval_callbacks.rs b/crates/tinysecurity-module/src/approval_callbacks.rs index 7af7de4..4a95812 100644 --- a/crates/tinysecurity-module/src/approval_callbacks.rs +++ b/crates/tinysecurity-module/src/approval_callbacks.rs @@ -168,23 +168,33 @@ impl ApprovalClients { )?; clients.push((scope, Arc::new(client))); } + // Initialized subjects keep their base stream as well, so a retired one + // cannot come back on another stream. + let streams = config + .scopes + .iter() + .map(|scope| (Self::stream_key(scope), audit.stream_id.clone())) + .collect(); Ok(Self { store, audit: RwLock::new(clients), runtime, - streams: RwLock::new(std::collections::BTreeMap::new()), + streams: RwLock::new(streams), }) } + fn stream_key(scope: &ApprovalScope2) -> (String, String, String) { + ( + scope.subject.user.clone(), + scope.subject.workspace.clone(), + scope.subject.agent.clone(), + ) + } /// Remember a subject's stream on first use and refuse any other one afterwards. fn pin_stream(&self, scope: &ApprovalScope2, stream_id: &str) -> Result<()> { if stream_id.len() != 32 || !stream_id.bytes().all(|b| b.is_ascii_hexdigit()) { return Err(Error::Invalid); } - let key = ( - scope.subject.user.clone(), - scope.subject.workspace.clone(), - scope.subject.agent.clone(), - ); + let key = Self::stream_key(scope); let mut streams = self.streams.write().map_err(|_| Error::Unavailable)?; match streams.get(&key) { Some(pinned) if pinned != stream_id => Err(Error::Conflict), diff --git a/crates/tinysecurity-module/src/approval_callbacks_tests.rs b/crates/tinysecurity-module/src/approval_callbacks_tests.rs index 7bdf5d3..201d029 100644 --- a/crates/tinysecurity-module/src/approval_callbacks_tests.rs +++ b/crates/tinysecurity-module/src/approval_callbacks_tests.rs @@ -900,3 +900,30 @@ async fn clients_admit_a_runtime_subject_with_its_own_stream_and_release_it() broker.abort(); Ok(()) } + +#[tokio::test] +async fn initialized_subjects_keep_their_base_stream_for_the_modules_lifetime() +-> std::result::Result<(), Box> { + let bus = MemoryBus::new(); + let broker = Broker::new().spawn(bus.clone()); + let host = tinybus::Connection::connect(bus.connect().await?).await?; + let module = tinybus::Connection::connect(bus.connect().await?).await?; + let peer = host.unique_name().ok_or("host peer")?; + let base = config(); + let redactor = + tinysecurity::Redactor::new(&tinysecurity_bus::RedactorConfig { secrets: vec![] })?; + let clients = ApprovalClients::new( + &module, + Some(peer.as_str()), + &base, + &native_audit_config(base.scopes[0].subject.clone(), false), + &redactor, + )?; + // The init subject came back as a runtime scope: only on its original stream. + let mut again = runtime_scope(3); + again.subject = base.scopes[0].subject.clone(); + assert!(clients.admit_scope(&again, &"b".repeat(32)).await.is_err()); + clients.admit_scope(&again, &"a".repeat(32)).await?; + broker.abort(); + Ok(()) +} diff --git a/crates/tinysecurity-policy/src/engine.rs b/crates/tinysecurity-policy/src/engine.rs index 3077ca6..f989f84 100644 --- a/crates/tinysecurity-policy/src/engine.rs +++ b/crates/tinysecurity-policy/src/engine.rs @@ -139,21 +139,23 @@ impl Policy { if let Some(reason) = scan_denial(&scan.verdict) { return Ok(self.denied(reason)); } - // The host-tool marker only says "a human must approve". Every concrete - // check runs first, so a later hard denial is never masked by the marker. - let mut marked = None; + // "A human must approve" is the weakest answer: every concrete check runs + // first, so a later hard denial is never masked by an earlier approval + // requirement or by the host-tool marker. + let mut approvable = None; for check in &request.call.checks { let decision = self.one(settings, hooks, &request.caller, check); - if decision.verdict == Verdict::Allow { - continue; - } - if matches!(check, Check::HostTool {}) { - marked.get_or_insert(decision); - continue; + match decision.verdict { + Verdict::Allow => {} + Verdict::Deny { + reason: DenialReason::ApprovalUnavailable, + } => { + approvable.get_or_insert(decision); + } + _ => return Ok(decision), } - return Ok(decision); } - Ok(marked.unwrap_or_else(|| self.allowed())) + Ok(approvable.unwrap_or_else(|| self.allowed())) } /// Evaluate an ordered batch against a single active generation. /// # Errors diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index cb75688..749baed 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -217,3 +217,34 @@ fn host_tool_marker_never_masks_a_later_hard_denial() -> Result<()> { ); Ok(()) } + +#[test] +fn an_earlier_approval_requirement_never_masks_a_later_hard_denial() -> Result<()> { + let policy = Policy::new(ModuleConfig::default())?; + let write = Check::Path { + path: "/tmp/scratch-output".into(), + write: true, + }; + let floor = Check::Path { + path: "/root/.ssh/id_rsa".into(), + write: false, + }; + let call = |checks| { + let mut request = host_call("path_check", checks); + request.caller.origin = Origin::Cron; + request.caller.tier = AccessTier::Write; + verdict(&policy, &request) + }; + // Control: a Cron write alone needs approval. + let needs_approval = Verdict::Deny { + reason: DenialReason::ApprovalUnavailable, + }; + assert_eq!(call(vec![write.clone()])?, needs_approval); + // Either order, the hard denial wins. + let first = call(vec![write.clone(), floor.clone()])?; + let second = call(vec![floor, write])?; + assert_eq!(first, second); + assert!(matches!(first, Verdict::Deny { .. })); + assert_ne!(first, needs_approval); + Ok(()) +} From 3d0194963b8a5d5357efd5dfc5ab831fea7a0f52 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 08:45:28 +0300 Subject: [PATCH 11/12] test(policy): use an absolute temp path so the masking test holds on Windows Co-authored-by: Medulla --- crates/tinysecurity-policy/src/engine_tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index 749baed..86754d7 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -221,8 +221,10 @@ fn host_tool_marker_never_masks_a_later_hard_denial() -> Result<()> { #[test] fn an_earlier_approval_requirement_never_masks_a_later_hard_denial() -> Result<()> { let policy = Policy::new(ModuleConfig::default())?; + // Absolute on every OS, outside every protected root. + let scratch = std::env::temp_dir().join("scratch-output"); let write = Check::Path { - path: "/tmp/scratch-output".into(), + path: scratch.to_string_lossy().into_owned(), write: true, }; let floor = Check::Path { From 28485b215104bd9b09c51d25a036cbf5862c45a5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 16 Oct 2026 09:19:37 +0300 Subject: [PATCH 12/12] fix(approval): initialized scopes are not retirable; stream pins expire after release Co-authored-by: Medulla --- MODULE.md | 10 ++- crates/tinysecurity-approval/src/engine.rs | 10 ++- .../src/engine_scopes_tests.rs | 27 ++++++ .../src/approval_callbacks.rs | 84 ++++++++++++++----- .../src/approval_callbacks_tests.rs | 26 ++++-- docs/specs/runtime-approval-scopes.md | 5 +- 6 files changed, 126 insertions(+), 36 deletions(-) diff --git a/MODULE.md b/MODULE.md index ad7ee24..4b3da76 100644 --- a/MODULE.md +++ b/MODULE.md @@ -384,9 +384,13 @@ ports must admit a subject (its store scope and audit stream) before it becomes usable: runtime scopes carry a host-persisted `audit_stream_id` and always forward audit to the host `AuditSink2`; they never open a native journal. The engine owns the generation, so repeating a registration after a policy advance is `unchanged` -rather than a new scope. A subject keeps its audit stream for the module's lifetime: -registering it again with another stream is refused, so a call still in flight for a -retired scope can never reach another scope's ledger. +rather than a new scope. A live subject keeps its audit stream, and a retired one +keeps it for an hour (far past any callback deadline): registering it again with +another stream in that window is refused, so a call still in flight for a retired +scope can never reach another scope's ledger. Pins older than that are reclaimed, so +only live and recently retired subjects count against a bound of about one million. +Initialized scopes can be replaced but not retired, so their (possibly native) +audit backend never changes. `ApprovalGrants2` rides on a registration: exact tool names, and exact tool names inside one named flow. A grant only converts a required approval into an diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 0d144ca..18cd86f 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -1192,14 +1192,20 @@ impl Engine { }, ) } - /// Remove a scope. Calls already holding it finish; every later call denies. + /// Remove a runtime scope. Calls already holding it finish; every later call + /// denies. Initialized scopes can be replaced but not retired. /// # Errors - /// Rejects unknown scopes and ports that cannot release the subject. + /// Rejects unknown or initialized scopes and ports that cannot release the subject. pub async fn retire_scope( &self, request: RetireApprovalScopeRequest2, ) -> Result { let _lifecycle = self.lifecycle.lock().await; + // Initialized scopes (the base audit stream, possibly a native journal) are + // replaced, never retired, so their subjects cannot come back on another backend. + if self.entry(&request.scope_id)?.audit_stream_id.is_none() { + return Err(Error::Denied); + } let removed = self .scopes .write() diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 4d390a2..4b1855a 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -903,3 +903,30 @@ async fn grant_revoked_while_the_reservation_is_written_burns_it() -> Result<()> ); Ok(()) } + +#[tokio::test] +async fn initialized_scopes_cannot_be_retired() -> Result<()> { + let (fixture, owner) = owner()?; + assert_eq!( + owner + .retire_scope(RetireApprovalScopeRequest2 { + scope_id: "a".repeat(32) + }) + .await + .err(), + Some(Error::Denied) + ); + assert!( + fixture + .released + .lock() + .map_err(|_| Error::Unavailable)? + .is_empty() + ); + // It keeps serving. + assert_eq!( + owner.begin(request()).await?.state, + ApprovalLifecycle2::Pending + ); + Ok(()) +} diff --git a/crates/tinysecurity-module/src/approval_callbacks.rs b/crates/tinysecurity-module/src/approval_callbacks.rs index 4a95812..bac85ec 100644 --- a/crates/tinysecurity-module/src/approval_callbacks.rs +++ b/crates/tinysecurity-module/src/approval_callbacks.rs @@ -8,8 +8,10 @@ use tinysecurity_bus::{ ApprovalRow2, ApprovalScope2, AuditConfig2, AuditEvent2, AuditKind2, AuditSinkRequest2, AuditSinkResponse2, HumanDecisionReceipt2, SubjectScope, VerifyHumanRequest2, callback_names2, }; -/// Most distinct runtime subjects whose stream the clients remember. +/// Most live or recently released runtime subjects whose stream the clients remember. const MAX_PINNED_STREAMS: usize = 1 << 20; +/// How long a released subject keeps its stream: well past any callback deadline. +const STREAM_PIN_TTL: std::time::Duration = std::time::Duration::from_secs(3600); /// Captured registered callback identity; no model-provided destination or scope. pub(crate) struct ApprovalStoreClient { connection: tinybus::Connection, @@ -120,10 +122,20 @@ pub(crate) struct ApprovalClients { audit: RwLock)>>, /// Runtime subjects forward audit to the host sink under this base policy. runtime: RuntimeAudit, - /// The one audit stream each runtime subject ever used. A subject keeps its - /// stream for the module's lifetime, so a call still in flight for a retired - /// scope cannot land in another scope's ledger after the subject registers again. - streams: RwLock>, + /// The audit stream of each runtime subject. A live subject always keeps its + /// stream, and a released one keeps it for `pin_ttl` (far beyond any callback + /// deadline), so a call still in flight for a retired scope cannot land in + /// another scope's ledger when the subject registers again. Pins of subjects + /// released longer ago are reclaimed, so only live and recently retired subjects + /// count against `max_pins`. + streams: RwLock>, + pin_ttl: std::time::Duration, + max_pins: usize, +} +type StreamKey = (String, String, String); +struct StreamPin { + stream: String, + released: Option, } struct RuntimeAudit { connection: tinybus::Connection, @@ -168,43 +180,70 @@ impl ApprovalClients { )?; clients.push((scope, Arc::new(client))); } - // Initialized subjects keep their base stream as well, so a retired one - // cannot come back on another stream. - let streams = config - .scopes - .iter() - .map(|scope| (Self::stream_key(scope), audit.stream_id.clone())) - .collect(); Ok(Self { store, audit: RwLock::new(clients), runtime, - streams: RwLock::new(streams), + streams: RwLock::new(std::collections::BTreeMap::new()), + pin_ttl: STREAM_PIN_TTL, + max_pins: MAX_PINNED_STREAMS, }) } - fn stream_key(scope: &ApprovalScope2) -> (String, String, String) { + fn stream_key(scope: &ApprovalScope2) -> StreamKey { ( scope.subject.user.clone(), scope.subject.workspace.clone(), scope.subject.agent.clone(), ) } - /// Remember a subject's stream on first use and refuse any other one afterwards. + /// Pin a subject's stream while it is live or recently released; refuse any + /// other stream for it until the pin has been released for `pin_ttl`. fn pin_stream(&self, scope: &ApprovalScope2, stream_id: &str) -> Result<()> { if stream_id.len() != 32 || !stream_id.bytes().all(|b| b.is_ascii_hexdigit()) { return Err(Error::Invalid); } let key = Self::stream_key(scope); + let now = std::time::Instant::now(); + let ttl = self.pin_ttl; + let expired = |pin: &StreamPin| { + pin.released + .is_some_and(|at| now.saturating_duration_since(at) >= ttl) + }; let mut streams = self.streams.write().map_err(|_| Error::Unavailable)?; - match streams.get(&key) { - Some(pinned) if pinned != stream_id => Err(Error::Conflict), - Some(_) => Ok(()), - None if streams.len() >= MAX_PINNED_STREAMS => Err(Error::Unavailable), - None => { - streams.insert(key, stream_id.into()); - Ok(()) + if let Some(pin) = streams.get_mut(&key) { + if pin.stream == stream_id { + pin.released = None; + return Ok(()); + } + if !expired(pin) { + return Err(Error::Conflict); + } + } else if streams.len() >= self.max_pins { + streams.retain(|_, pin| !expired(pin)); + if streams.len() >= self.max_pins { + return Err(Error::Unavailable); } } + streams.insert( + key, + StreamPin { + stream: stream_id.into(), + released: None, + }, + ); + Ok(()) + } + /// Start the retention clock of a released subject's pin. + fn unpin_stream(&self, scope: &ApprovalScope2) -> Result<()> { + if let Some(pin) = self + .streams + .write() + .map_err(|_| Error::Unavailable)? + .get_mut(&Self::stream_key(scope)) + { + pin.released = Some(std::time::Instant::now()); + } + Ok(()) } /// Build the audit client of a runtime subject: its own persisted stream, always /// forwarded to the host sink so no extra native journal is opened. @@ -291,6 +330,7 @@ impl Ports for ApprovalClients { .write() .map_err(|_| Error::Unavailable)? .retain(|(s, _)| s != &handle); + self.unpin_stream(scope)?; self.store.release(&handle) } async fn native_audit(&self, row: ApprovalRow2) -> Result { diff --git a/crates/tinysecurity-module/src/approval_callbacks_tests.rs b/crates/tinysecurity-module/src/approval_callbacks_tests.rs index 201d029..7404b6a 100644 --- a/crates/tinysecurity-module/src/approval_callbacks_tests.rs +++ b/crates/tinysecurity-module/src/approval_callbacks_tests.rs @@ -902,7 +902,7 @@ async fn clients_admit_a_runtime_subject_with_its_own_stream_and_release_it() } #[tokio::test] -async fn initialized_subjects_keep_their_base_stream_for_the_modules_lifetime() +async fn released_subjects_keep_their_stream_only_for_the_retention_window_and_pins_are_bounded() -> std::result::Result<(), Box> { let bus = MemoryBus::new(); let broker = Broker::new().spawn(bus.clone()); @@ -912,18 +912,30 @@ async fn initialized_subjects_keep_their_base_stream_for_the_modules_lifetime() let base = config(); let redactor = tinysecurity::Redactor::new(&tinysecurity_bus::RedactorConfig { secrets: vec![] })?; - let clients = ApprovalClients::new( + let mut clients = ApprovalClients::new( &module, Some(peer.as_str()), &base, &native_audit_config(base.scopes[0].subject.clone(), false), &redactor, )?; - // The init subject came back as a runtime scope: only on its original stream. - let mut again = runtime_scope(3); - again.subject = base.scopes[0].subject.clone(); - assert!(clients.admit_scope(&again, &"b".repeat(32)).await.is_err()); - clients.admit_scope(&again, &"a".repeat(32)).await?; + clients.max_pins = 2; + let (first, second, third) = (runtime_scope(11), runtime_scope(12), runtime_scope(13)); + clients.admit_scope(&first, &"d".repeat(32)).await?; + clients.admit_scope(&second, &"d".repeat(32)).await?; + // Live pins fill the table: nothing is reclaimable. + assert!(clients.admit_scope(&third, &"d".repeat(32)).await.is_err()); + clients.release_scope(&first).await?; + // Released but inside the window: still pinned, still counted. + assert!(clients.admit_scope(&first, &"e".repeat(32)).await.is_err()); + assert!(clients.admit_scope(&third, &"d".repeat(32)).await.is_err()); + // Past the window the pin is reclaimed and the subject may use another stream. + clients.pin_ttl = std::time::Duration::ZERO; + clients.admit_scope(&third, &"d".repeat(32)).await?; + // The table is full of live pins again, so a reclaimed subject must wait. + assert!(clients.admit_scope(&first, &"e".repeat(32)).await.is_err()); + clients.release_scope(&third).await?; + clients.admit_scope(&first, &"e".repeat(32)).await?; broker.abort(); Ok(()) } diff --git a/docs/specs/runtime-approval-scopes.md b/docs/specs/runtime-approval-scopes.md index 8730438..87c08fa 100644 --- a/docs/specs/runtime-approval-scopes.md +++ b/docs/specs/runtime-approval-scopes.md @@ -56,8 +56,9 @@ configuration re-registered on change), and does not treat a name as a wildcard. - The engine owns the generation; a repeated registration after an advance is `unchanged`. - Initialization rejects duplicate identities and duplicate subjects. -- A subject keeps one audit stream for the module's lifetime; a different stream for a - re-registered subject is refused. +- A live subject keeps its audit stream and a retired one keeps it for an hour; a + different stream for a re-registered subject is refused in that window. Initialized + scopes can be replaced but not retired. - 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.