From c27a6311b80733fcfb5dd5db9ba2a9760d758636 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:43:53 +0000 Subject: [PATCH 01/70] test(tinysecurity-module): cover whole-tool trust across a restart Add a test asserting that a whole-tool trust grant survives a module restart, is listed afterwards, attributes approval to the tool grant, and that revoking it makes a subsequent approval evaluation fail. The tinybus submodule pointer is bumped alongside. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_gaps_tests.rs | 57 +++++++++++++++++++ vendor/tinybus | 2 +- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/src/adapter_gaps_tests.rs b/crates/tinysecurity-module/src/adapter_gaps_tests.rs index 64ba10c7..c9125bb4 100644 --- a/crates/tinysecurity-module/src/adapter_gaps_tests.rs +++ b/crates/tinysecurity-module/src/adapter_gaps_tests.rs @@ -155,6 +155,63 @@ async fn flow_trust_survives_a_module_restart_through_the_host_store() -> TestRe Ok(()) } +#[tokio::test] +async fn whole_tool_trust_survives_a_module_restart_and_is_revoked_before_consume() -> TestResult { + let dir = tempfile::tempdir()?; + let path = dir.path().join("ledger"); + std::fs::write(&path, [])?; + let first = start(&path).await?; + let grant: serde_json::Value = first + .proxy + .call( + "GrantTool2", + (serde_json::json!({ + "scope_id": "a".repeat(32), + "tool": "local_diagnostic", + "audit": audit(1), + }),), + ) + .await?; + assert_eq!(grant["outcome"], "granted"); + first.broker.abort(); + + let second = start(&path).await?; + let listed: serde_json::Value = second + .proxy + .call( + "ListToolTrust2", + (serde_json::json!({ "scope_id": "a".repeat(32) }),), + ) + .await?; + assert_eq!(listed["entries"].as_array().map(Vec::len), Some(1)); + + let approved: EvaluateApprovalResponse2 = second + .proxy + .call(names::methods::EVALUATE_APPROVAL2, (begin(2, 2),)) + .await?; + assert_eq!(approved.record.attribution.as_deref(), Some("auto:tool-grant")); + + let revoked: serde_json::Value = second + .proxy + .call( + "RevokeTool2", + (serde_json::json!({ + "scope_id": "a".repeat(32), + "tool": "local_diagnostic", + "audit": audit(3), + }),), + ) + .await?; + assert_eq!(revoked["outcome"], "revoked"); + assert!(second + .proxy + .call::(names::methods::EVALUATE_APPROVAL2, (begin(4, 4),)) + .await + .is_err()); + second.broker.abort(); + Ok(()) +} + #[tokio::test] async fn scored_prompt_scan_returns_tier_score_and_content_free_rules() -> TestResult { let dir = tempfile::tempdir()?; diff --git a/vendor/tinybus b/vendor/tinybus index 0c98b57c..943e2921 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 0c98b57c91cb47c65d7f93f7c950b0f3ca5673b4 +Subproject commit 943e2921dce64b9ed7df2ae50bf9ac7bea9ccdb0 From e4584dc5b88d8efe30aa91152a9c5eb9484293c8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:45:17 +0000 Subject: [PATCH 02/70] chore(deps): downgrade zip and tinybus, add zopfli Pinned zip back to 2.6.1 and tinybus to 0.1.3, which drops the nix and typed-path dependencies and pulls in arbitrary, crossbeam-utils and zopfli instead. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- Cargo.lock | 71 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 46 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index efd0c6c5..e99673d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,6 +54,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "argon2" version = "0.5.3" @@ -325,6 +334,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crypto-common" version = "0.1.7" @@ -371,6 +386,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "digest" version = "0.10.7" @@ -970,18 +996,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -1612,7 +1626,7 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.1.6" +version = "0.1.3" dependencies = [ "async-trait", "flate2", @@ -1633,7 +1647,7 @@ dependencies = [ [[package]] name = "tinybus-macros" -version = "0.1.6" +version = "0.1.3" dependencies = [ "proc-macro2", "quote", @@ -1642,7 +1656,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.1.6" +version = "0.1.3" dependencies = [ "async-trait", "serde", @@ -2068,12 +2082,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "typed-path" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" - [[package]] name = "typenum" version = "1.20.1" @@ -2639,16 +2647,17 @@ dependencies = [ [[package]] name = "zip" -version = "9.0.5" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" +checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744" dependencies = [ + "arbitrary", "crc32fast", + "crossbeam-utils", "flate2", "indexmap", "memchr", - "nix", - "typed-path", + "zopfli", ] [[package]] @@ -2656,3 +2665,15 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] From 92a94a47d70dae0e13f657d9b8950daabee4c468 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:46:11 +0000 Subject: [PATCH 03/70] feat(bus): add durable whole-tool trust methods Introduce a tool trust module and export it from the bus crate, along with GrantTool2, RevokeTool2 and ListToolTrust2 wire methods for durably trusting one exact whole tool within a registered scope. The contract version is bumped to 1.25 to cover the new vocabulary. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/lib.rs | 4 +- crates/tinysecurity-bus/src/names.rs | 6 + crates/tinysecurity-bus/src/tool_trust.rs | 111 ++++++++++++++++++ .../tinysecurity-bus/src/tool_trust_tests.rs | 36 ++++++ 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 crates/tinysecurity-bus/src/tool_trust.rs create mode 100644 crates/tinysecurity-bus/src/tool_trust_tests.rs diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 113c88bf..045bb942 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -38,6 +38,7 @@ mod policy; mod privacy_check; mod prompt; mod prompt_score; +mod tool_trust; mod redact; mod scoped_policy; pub use audit::*; @@ -56,12 +57,13 @@ pub use policy::*; pub use privacy_check::*; pub use prompt::*; pub use prompt_score::*; +pub use tool_trust::*; pub use redact::RedactWithRequest; pub use redact::RedactorConfig; pub use scoped_policy::*; pub mod names; /// Current wire vocabulary version, independent of the artifact version. -pub const CONTRACT_VERSION: (u32, u32) = (1, 24); +pub const CONTRACT_VERSION: (u32, u32) = (1, 25); /// Whether a remote module's advertised version supplies this host's vocabulary. /// /// `version` is the provider version received by the host, not a host requirement diff --git a/crates/tinysecurity-bus/src/names.rs b/crates/tinysecurity-bus/src/names.rs index 03eec5fc..d03721b5 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -49,6 +49,12 @@ pub mod methods { pub const REVOKE_FLOW_TOOL2: &str = "RevokeFlowTool2"; /// Read the live durable flow trust of one scope (contract 1.22). pub const LIST_FLOW_TRUST2: &str = "ListFlowTrust2"; + /// Durably trust one exact whole tool in a registered scope (contract 1.25). + pub const GRANT_TOOL2: &str = "GrantTool2"; + /// Withdraw durable trust of one exact whole tool (contract 1.25). + pub const REVOKE_TOOL2: &str = "RevokeTool2"; + /// Read the live durable whole-tool trust of one scope (contract 1.25). + pub const LIST_TOOL_TRUST2: &str = "ListToolTrust2"; /// Register a runtime approval scope and its tool and flow grants (contract 1.17). pub const REGISTER_APPROVAL_SCOPE2: &str = "RegisterApprovalScope2"; /// Atomically replace one scope's posture, grants or policy reference (contract 1.17). diff --git a/crates/tinysecurity-bus/src/tool_trust.rs b/crates/tinysecurity-bus/src/tool_trust.rs new file mode 100644 index 00000000..19b81e7a --- /dev/null +++ b/crates/tinysecurity-bus/src/tool_trust.rs @@ -0,0 +1,111 @@ +//! Durable whole-tool trust (contract 1.25). +//! +//! A host can grant, revoke and list an exact tool grant through the authenticated +//! approval interface. Grants live as audited approval-store rows under one +//! registered subject and become stale with that subject's policy generation. +use crate::AuditCorrelation2; +use serde::{Deserialize, Serialize}; + +/// Longest a whole-tool trust row stays live, in milliseconds (ten years). A later +/// grant of the same tool renews an expired row. +pub const TOOL_TRUST_LIFETIME_MS2: u64 = 10 * 365 * 24 * 60 * 60 * 1000; + +/// Durably trust one exact tool in a registered approval scope. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GrantToolRequest2 { + /// Trusted registration; tool trust lives under the subject that registered it. + pub scope_id: String, + /// Exact tool name to trust; human-only, native and malformed names are refused. + pub tool: String, + /// Exact audit phase of the new durable row. + pub audit: AuditCorrelation2, +} + +/// Withdraw whole-tool trust from one registered approval scope. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RevokeToolRequest2 { + /// Trusted registration. + pub scope_id: String, + /// Exact tool name as it was granted. + pub tool: String, + /// Exact audit phase of the cancellation. + pub audit: AuditCorrelation2, +} + +/// Read the live whole-tool trust of one scope. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ListToolTrustRequest2 { + /// Trusted registration. + pub scope_id: String, +} + +/// One live whole-tool trust grant. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ToolTrustEntry2 { + /// Exact tool name trusted in this scope. + pub tool: String, + /// Durable row identity. + pub request_id: String, + /// When the trust was granted, in epoch milliseconds. + pub granted_at_ms: u64, +} + +/// The live whole-tool grants of a scope, ordered by tool name. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ToolTrustList2 { + /// Live grants. + pub entries: Vec, +} + +/// What a grant or revocation did. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ToolTrustOutcome2 { + /// A new durable grant was recorded, or an expired one renewed. + Granted, + /// A live grant already existed; nothing changed. + Unchanged, + /// The live grant was cancelled. + Revoked, + /// There was no live grant to revoke. + Absent, +} + +/// Receipt of a grant or revocation. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ToolTrustReceipt2 { + /// Affected registration. + pub scope_id: String, + /// What happened. + pub outcome: ToolTrustOutcome2, + /// Durable row of the grant or of the revoked grant; absent for `Absent`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_id: Option, +} + +macro_rules! redacted_debug { + ($($ty:ty),+ $(,)?) => {$(impl std::fmt::Debug for $ty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(concat!(stringify!($ty), "([REDACTED])")) + } + })+}; +} +redacted_debug!( + GrantToolRequest2, + RevokeToolRequest2, + ListToolTrustRequest2, + ToolTrustEntry2, + ToolTrustList2, + ToolTrustReceipt2 +); + +#[cfg(test)] +#[path = "tool_trust_tests.rs"] +mod tests; diff --git a/crates/tinysecurity-bus/src/tool_trust_tests.rs b/crates/tinysecurity-bus/src/tool_trust_tests.rs new file mode 100644 index 00000000..506db45f --- /dev/null +++ b/crates/tinysecurity-bus/src/tool_trust_tests.rs @@ -0,0 +1,36 @@ +use super::*; +use serde_json::json; + +#[test] +fn whole_tool_trust_payloads_have_stable_wire_shapes() { + let request = GrantToolRequest2 { + scope_id: "scope".into(), + tool: "shell".into(), + audit: AuditCorrelation2 { + event_id: "a".repeat(32), + expected_tail: 4, + generation: 7, + }, + }; + assert_eq!( + serde_json::to_value(request).unwrap(), + json!({ + "scope_id": "scope", + "tool": "shell", + "audit": { + "event_id": "a".repeat(32), + "expected_tail": 4, + "generation": 7 + } + }) + ); + assert_eq!( + serde_json::to_value(ToolTrustReceipt2 { + scope_id: "scope".into(), + outcome: ToolTrustOutcome2::Unchanged, + request_id: Some("request".into()), + }) + .unwrap(), + json!({"scope_id": "scope", "outcome": "unchanged", "request_id": "request"}) + ); +} From 78be2bb97e5af9805fc5ef8435556296254be250 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:47:34 +0000 Subject: [PATCH 04/70] feat(approval): add durable whole-tool trust grants Approval evaluation now consults a durable tool-trust check alongside the existing flow-trust one, so a tool trusted as a whole can satisfy an approval rule with the auto:tool-grant reason. A dedicated lock serializes tool-trust grants and revocations so each tool keeps a single live row. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 17 +- .../src/engine_tool_trust.rs | 213 ++++++++++++++++++ 2 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 crates/tinysecurity-approval/src/engine_tool_trust.rs diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index cdb6bbb5..33082d5f 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -253,6 +253,7 @@ impl ScopeEntry { request: &EvaluateApprovalRequest2, rule: tinysecurity_auto::rules::Rule, durable_flow: bool, + durable_tool: bool, ) -> Option<&'static str> { match rule { tinysecurity_auto::rules::Rule::Approval => self @@ -260,6 +261,7 @@ impl ScopeEntry { &request.request.call.tool, request.request.caller.flow.as_deref(), ) + .or_else(|| durable_tool.then_some("auto:tool-grant")) .or_else(|| durable_flow.then_some("auto:flow-grant")), tinysecurity_auto::rules::Rule::GrantOnly => { self.tool_grant(&request.request.call.tool) @@ -306,6 +308,8 @@ pub struct Engine { lifecycle: tokio::sync::Mutex<()>, /// Serializes flow-trust grants and revocations so one pair has one live row. flow_trust_lock: tokio::sync::Mutex<()>, + /// Serializes whole-tool trust grants and revocations so one tool has one live row. + tool_trust_lock: tokio::sync::Mutex<()>, ports: Arc, clock: Arc, redactor: RwLock, @@ -360,6 +364,7 @@ impl Engine { scopes: RwLock::new(entries), lifecycle: tokio::sync::Mutex::new(()), flow_trust_lock: tokio::sync::Mutex::new(()), + tool_trust_lock: tokio::sync::Mutex::new(()), ports, clock, redactor: RwLock::new(redactor), @@ -597,8 +602,14 @@ impl Engine { if !eligible { return None; } - let durable = Self::durable_flow_trust(reg, rows, &request.request, now); - entry.grant_for(request, rule, durable) + let durable_flow = Self::durable_flow_trust(reg, rows, &request.request, now); + let durable_tool = Self::durable_tool_trust( + reg, + rows, + &request.request.call.tool, + now, + ); + entry.grant_for(request, rule, durable_flow, durable_tool) } /// The origin's shortcut for this call, or a denial: a Cron external effect is /// refused outright, and an origin nobody labelled is refused unless the scope @@ -1898,6 +1909,8 @@ fn reusable_grant<'a>( #[path = "engine_flow_trust.rs"] mod flow_trust; +#[path = "engine_tool_trust.rs"] +mod tool_trust; #[cfg(test)] #[path = "engine_tests.rs"] mod tests; diff --git a/crates/tinysecurity-approval/src/engine_tool_trust.rs b/crates/tinysecurity-approval/src/engine_tool_trust.rs new file mode 100644 index 00000000..801568be --- /dev/null +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -0,0 +1,213 @@ +//! Durable whole-tool trust: exact global grants held by the host-served approval store. +//! +//! A tool-trust row is an `Approved` row with the `Tool` choice and the +//! `tool-trust-grant` attribution. Its binding digest is domain-separated from call +//! digests, so it cannot be consumed, resumed or reused as a reviewed decision. +use super::{ + ApprovalChoice2, ApprovalGrants2, ApprovalLifecycle2, ApprovalRecordRequest2, ApprovalRow2, + ApprovalScope2, CallerContext, Engine, Error, Origin, RedactMode, Result, binding, +}; +use tinysecurity_bus::{ + GrantToolRequest2, ListToolTrustRequest2, RevokeToolRequest2, TOOL_TRUST_LIFETIME_MS2, + ToolTrustEntry2, ToolTrustList2, ToolTrustOutcome2, ToolTrustReceipt2, +}; + +const ATTRIBUTION: &str = "tool-trust-grant"; +const MAX_SCOPE_ROWS: usize = 960; + +/// Digest of an exact tool name, disjoint from call and flow trust bindings. +fn trust_key(tool: &str) -> String { + binding::digest(b"tinysecurity/tool-trust/v1\0", tool.as_bytes()) +} + +fn is_trust_row(row: &ApprovalRow2) -> bool { + row.attribution.as_deref() == Some(ATTRIBUTION) + && row.choice == Some(ApprovalChoice2::Tool) + && row.state == ApprovalLifecycle2::Approved + && row.decision_ticket_sha256.is_none() + && row.binding.caller.flow.is_none() +} + +fn valid_tool(tool: &str) -> bool { + ApprovalGrants2 { + tools: vec![tool.to_owned()], + flows: Vec::new(), + } + .is_valid() +} + +impl Engine { + /// Whether a live durable whole-tool grant covers exactly this tool. + pub(super) fn durable_tool_trust( + reg: &ApprovalScope2, + rows: &[ApprovalRow2], + tool: &str, + now: u64, + ) -> bool { + if !valid_tool(tool) { + return false; + } + let key = trust_key(tool); + rows.iter().any(|row| { + is_trust_row(row) + && row.binding.call_sha256 == key + && row.binding.content_sha256 == key + && row.expires_at_ms > now + && Self::current(reg, row).is_ok() + }) + } + + fn live_trust<'a>( + reg: &ApprovalScope2, + rows: &'a [ApprovalRow2], + key: &str, + now: u64, + ) -> Option<&'a ApprovalRow2> { + rows.iter().find(|row| { + is_trust_row(row) + && row.binding.call_sha256 == key + && row.binding.content_sha256 == key + && row.expires_at_ms > now + && Self::current(reg, row).is_ok() + }) + } + + /// Durably trust one exact whole tool. A live repeat is `Unchanged`. + /// # Errors + /// Rejects unknown scopes, human-only or native tools, malformed names, stale + /// audit correlations, unavailable storage, and unacknowledged durability. + pub async fn grant_tool(&self, request: GrantToolRequest2) -> Result { + if !valid_tool(&request.tool) { + return Err(Error::Invalid); + } + let _serial = self.tool_trust_lock.lock().await; + let reg = self.registration(&request.scope_id)?; + Self::correlation(&request.audit, reg.generation)?; + let key = trust_key(&request.tool); + let rows = self.rows(®).await?; + let now = self.now()?; + if let Some(existing) = Self::live_trust(®, &rows, &key, now) { + self.audit_row(existing.clone()).await?; + return Ok(ToolTrustReceipt2 { + scope_id: request.scope_id, + outcome: ToolTrustOutcome2::Unchanged, + request_id: Some(existing.request_id.clone()), + }); + } + let known = rows.iter().any(|row| { + row.attribution.as_deref() == Some(ATTRIBUTION) + && row.binding.call_sha256 == key + }); + if rows.len() >= MAX_SCOPE_ROWS && !known { + return Err(Error::Unavailable); + } + let redactor = self.redactor()?; + let caller = CallerContext { + agent: binding::text(&redactor, ®.subject.agent, RedactMode::IdentifierHash)?, + thread: None, + flow: None, + call_id: binding::text(&redactor, &key, RedactMode::IdentifierHash)?, + origin: Origin::Operator, + tier: reg.ceiling.clone(), + }; + let row = ApprovalRow2 { + request_id: binding::opaque()?, + version: 1, + binding: tinysecurity_bus::ApprovalBinding2 { + binding_version: 1, + scope: binding::scope_handle(®.subject)?, + policy_ref: binding::digest( + b"tinysecurity/approval-policy/v1\0", + reg.policy_ref.as_bytes(), + ), + generation: reg.generation, + call_sha256: key.clone(), + content_sha256: key, + origin: Origin::Operator, + caller, + }, + host_instance: self.instance.clone(), + nonce: binding::opaque()?, + tool_name: binding::text(&redactor, &request.tool, RedactMode::Text)?, + external_effect: true, + args_redacted: "{}".into(), + summary: "whole-tool trust".into(), + created_at_ms: now, + expires_at_ms: now + .checked_add(TOOL_TRUST_LIFETIME_MS2) + .ok_or(Error::Invalid)?, + state: ApprovalLifecycle2::Approved, + choice: Some(ApprovalChoice2::Tool), + attribution: Some(ATTRIBUTION.into()), + decision_ticket_sha256: None, + outcome: None, + reservation_id: None, + legacy_history_json: None, + judge: None, + audit: request.audit, + }; + self.persist(None, &row).await?; + Ok(ToolTrustReceipt2 { + scope_id: request.scope_id, + outcome: ToolTrustOutcome2::Granted, + request_id: Some(row.request_id), + }) + } + + /// Withdraw whole-tool trust. A missing or expired grant is `Absent`. + /// # Errors + /// Rejects malformed names, unknown scopes, stale audit correlations, unavailable + /// storage, and unacknowledged durability. + pub async fn revoke_tool(&self, request: RevokeToolRequest2) -> Result { + if !valid_tool(&request.tool) { + return Err(Error::Invalid); + } + let _serial = self.tool_trust_lock.lock().await; + let reg = self.registration(&request.scope_id)?; + Self::correlation(&request.audit, reg.generation)?; + let key = trust_key(&request.tool); + let rows = self.rows(®).await?; + let now = self.now()?; + let Some(live) = Self::live_trust(®, &rows, &key, now) else { + return Ok(ToolTrustReceipt2 { + scope_id: request.scope_id, + outcome: ToolTrustOutcome2::Absent, + request_id: None, + }); + }; + let request_id = live.request_id.clone(); + self.cancel(ApprovalRecordRequest2 { + scope_id: request.scope_id.clone(), + request_id: request_id.clone(), + audit: request.audit, + }) + .await?; + Ok(ToolTrustReceipt2 { + scope_id: request.scope_id, + outcome: ToolTrustOutcome2::Revoked, + request_id: Some(request_id), + }) + } + + /// The live whole-tool grants of a scope, ordered by exact tool name. + /// # Errors + /// Rejects unknown scopes and unavailable storage. + pub async fn list_tool_trust(&self, request: ListToolTrustRequest2) -> Result { + let reg = self.registration(&request.scope_id)?; + let rows = self.rows(®).await?; + let now = self.now()?; + let mut entries: Vec = rows + .iter() + .filter(|row| { + is_trust_row(row) && row.expires_at_ms > now && Self::current(®, row).is_ok() + }) + .map(|row| ToolTrustEntry2 { + tool: row.tool_name.clone(), + request_id: row.request_id.clone(), + granted_at_ms: row.created_at_ms, + }) + .collect(); + entries.sort_by(|a, b| (&a.tool, &a.request_id).cmp(&(&b.tool, &b.request_id))); + Ok(ToolTrustList2 { entries }) + } +} From 9bb6eb39d70e48e91a9dd9a61d7f37436f289ea8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:47:39 +0000 Subject: [PATCH 05/70] feat(adapter): expose tool trust grant, revoke and list methods Add host-only handlers for granting, revoking and listing tool trust, and register them as GrantTool2, RevokeTool2 and ListToolTrust2 so callers can manage tool trust through the bus. The registered scope is treated as a trusted-adapter claim, matching the existing approval scope methods. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 36 ++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 8baa5460..03d61544 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1049,6 +1049,40 @@ impl PolicyService { .await .map_err(approval_failure) } + // Private host-only members: the registered scope is a trusted-adapter claim. + async fn grant_tool2( + &self, + request: tinysecurity_bus::GrantToolRequest2, + ) -> BusResult { + self.approvals + .as_ref() + .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? + .grant_tool(request) + .await + .map_err(approval_failure) + } + async fn revoke_tool2( + &self, + request: tinysecurity_bus::RevokeToolRequest2, + ) -> BusResult { + self.approvals + .as_ref() + .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? + .revoke_tool(request) + .await + .map_err(approval_failure) + } + async fn list_tool_trust2( + &self, + request: tinysecurity_bus::ListToolTrustRequest2, + ) -> BusResult { + self.approvals + .as_ref() + .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? + .list_tool_trust(request) + .await + .map_err(approval_failure) + } // Private host-only configuration: the subject is a trusted-adapter claim. async fn register_approval_scope2( &self, @@ -1966,7 +2000,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", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "CredentialRoots2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"], + methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "DeviceFrame2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "GrantTool2", "RevokeTool2", "ListToolTrust2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "CredentialRoots2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "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.AuditSink2", "ai.tinyhumans.tinysecurity.ApprovalStore2", "ai.tinyhumans.tinysecurity.ApprovalPrompt2"], From 7345a413fd53c58b861b1cd067319f4a4a4e598f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:48:16 +0000 Subject: [PATCH 06/70] test(module): cover approval consumption and trust listing after restart The restart test now consumes the approved request through the approval action endpoint and asserts the call fails, then lists tool trust to confirm no entries remain. This exercises the consume path and the post-revocation listing instead of only re-evaluating the approval. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_gaps_tests.rs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_gaps_tests.rs b/crates/tinysecurity-module/src/adapter_gaps_tests.rs index c9125bb4..36d830ef 100644 --- a/crates/tinysecurity-module/src/adapter_gaps_tests.rs +++ b/crates/tinysecurity-module/src/adapter_gaps_tests.rs @@ -2,9 +2,9 @@ //! across a module restart over the host-served store, and scored prompt screening. use super::*; use tinysecurity_bus::{ - FlowTrustList2, FlowTrustOutcome2, FlowTrustReceipt2, GrantFlowToolRequest2, - ListFlowTrustRequest2, PromptTier2, RevokeFlowToolRequest2, ScanPromptScoredRequest2, - ScanPromptScoredResponse2, + ApprovalActionRequest2, ApprovalRow2, FlowTrustList2, FlowTrustOutcome2, FlowTrustReceipt2, + GrantFlowToolRequest2, ListFlowTrustRequest2, PromptTier2, RevokeFlowToolRequest2, + ScanPromptScoredRequest2, ScanPromptScoredResponse2, }; type TestResult = std::result::Result<(), Box>; @@ -203,11 +203,25 @@ async fn whole_tool_trust_survives_a_module_restart_and_is_revoked_before_consum ) .await?; assert_eq!(revoked["outcome"], "revoked"); + let consume = ApprovalActionRequest2 { + scope_id: "a".repeat(32), + request_id: approved.record.request_id, + request: begin(2, 2).request, + audit: audit(4), + }; assert!(second .proxy - .call::(names::methods::EVALUATE_APPROVAL2, (begin(4, 4),)) + .call::(names::methods::CONSUME_APPROVAL2, (consume,)) .await .is_err()); + let listed: serde_json::Value = second + .proxy + .call( + "ListToolTrust2", + (serde_json::json!({ "scope_id": "a".repeat(32) }),), + ) + .await?; + assert_eq!(listed["entries"].as_array().map(Vec::len), Some(0)); second.broker.abort(); Ok(()) } From d4077145d5047edbcb7f9a665aa9f0782db468af Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:48:34 +0000 Subject: [PATCH 07/70] test(tinysecurity-module): use typed tool trust requests in adapter gap tests Replace the raw JSON payloads and string method names in the whole-tool-trust restart test with the typed GrantToolRequest2, ListToolTrustRequest2 and RevokeToolRequest2 structs and the names::methods constants, asserting on typed outcomes and entries instead of serde_json::Value fields. This keeps the test aligned with the bus API and catches shape mismatches at compile time. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_gaps_tests.rs | 58 ++++++++++--------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_gaps_tests.rs b/crates/tinysecurity-module/src/adapter_gaps_tests.rs index 36d830ef..87d8de76 100644 --- a/crates/tinysecurity-module/src/adapter_gaps_tests.rs +++ b/crates/tinysecurity-module/src/adapter_gaps_tests.rs @@ -3,8 +3,9 @@ use super::*; use tinysecurity_bus::{ ApprovalActionRequest2, ApprovalRow2, FlowTrustList2, FlowTrustOutcome2, FlowTrustReceipt2, - GrantFlowToolRequest2, ListFlowTrustRequest2, PromptTier2, RevokeFlowToolRequest2, - ScanPromptScoredRequest2, ScanPromptScoredResponse2, + GrantFlowToolRequest2, GrantToolRequest2, ListFlowTrustRequest2, ListToolTrustRequest2, + PromptTier2, RevokeFlowToolRequest2, RevokeToolRequest2, ScanPromptScoredRequest2, + ScanPromptScoredResponse2, ToolTrustList2, ToolTrustOutcome2, ToolTrustReceipt2, }; type TestResult = std::result::Result<(), Box>; @@ -161,29 +162,32 @@ async fn whole_tool_trust_survives_a_module_restart_and_is_revoked_before_consum let path = dir.path().join("ledger"); std::fs::write(&path, [])?; let first = start(&path).await?; - let grant: serde_json::Value = first + let grant: ToolTrustReceipt2 = first .proxy .call( - "GrantTool2", - (serde_json::json!({ - "scope_id": "a".repeat(32), - "tool": "local_diagnostic", - "audit": audit(1), - }),), + names::methods::GRANT_TOOL2, + (GrantToolRequest2 { + scope_id: "a".repeat(32), + tool: "local_diagnostic".into(), + audit: audit(1), + },), ) .await?; - assert_eq!(grant["outcome"], "granted"); + assert_eq!(grant.outcome, ToolTrustOutcome2::Granted); first.broker.abort(); let second = start(&path).await?; - let listed: serde_json::Value = second + let listed: ToolTrustList2 = second .proxy .call( - "ListToolTrust2", - (serde_json::json!({ "scope_id": "a".repeat(32) }),), + names::methods::LIST_TOOL_TRUST2, + (ListToolTrustRequest2 { + scope_id: "a".repeat(32), + },), ) .await?; - assert_eq!(listed["entries"].as_array().map(Vec::len), Some(1)); + assert_eq!(listed.entries.len(), 1); + assert_eq!(listed.entries[0].tool, "local_diagnostic"); let approved: EvaluateApprovalResponse2 = second .proxy @@ -191,18 +195,18 @@ async fn whole_tool_trust_survives_a_module_restart_and_is_revoked_before_consum .await?; assert_eq!(approved.record.attribution.as_deref(), Some("auto:tool-grant")); - let revoked: serde_json::Value = second + let revoked: ToolTrustReceipt2 = second .proxy .call( - "RevokeTool2", - (serde_json::json!({ - "scope_id": "a".repeat(32), - "tool": "local_diagnostic", - "audit": audit(3), - }),), + names::methods::REVOKE_TOOL2, + (RevokeToolRequest2 { + scope_id: "a".repeat(32), + tool: "local_diagnostic".into(), + audit: audit(3), + },), ) .await?; - assert_eq!(revoked["outcome"], "revoked"); + assert_eq!(revoked.outcome, ToolTrustOutcome2::Revoked); let consume = ApprovalActionRequest2 { scope_id: "a".repeat(32), request_id: approved.record.request_id, @@ -214,14 +218,16 @@ async fn whole_tool_trust_survives_a_module_restart_and_is_revoked_before_consum .call::(names::methods::CONSUME_APPROVAL2, (consume,)) .await .is_err()); - let listed: serde_json::Value = second + let listed: ToolTrustList2 = second .proxy .call( - "ListToolTrust2", - (serde_json::json!({ "scope_id": "a".repeat(32) }),), + names::methods::LIST_TOOL_TRUST2, + (ListToolTrustRequest2 { + scope_id: "a".repeat(32), + },), ) .await?; - assert_eq!(listed["entries"].as_array().map(Vec::len), Some(0)); + assert!(listed.entries.is_empty()); second.broker.abort(); Ok(()) } From 86b073ca0b2e39ba73381b3aab71d4e06b9f45bc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:49:19 +0000 Subject: [PATCH 08/70] fix(approval): recheck whole-tool trust at the consume boundary A durable whole-tool grant could be revoked after evaluation but before execution, letting a queued automatic approval still run. The consume path now reloads the tool trust row so revoked grants deny the pending request, and tests cover restart persistence, exact tool and scope matching, and the cron and unlabelled boundaries. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 12 ++ .../src/engine_gaps_tests.rs | 120 +++++++++++++++++- .../src/device_frame_tests.rs | 2 +- 3 files changed, 132 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 33082d5f..12f19141 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -1639,6 +1639,18 @@ impl Engine { let rows = self.rows(®).await?; granted = Self::durable_flow_trust(®, &rows, &request.request, self.now()?); } + if !granted && row.attribution.as_deref() == Some("auto:tool-grant") { + // A durable whole-tool grant may be revoked after evaluation but before + // execution. Reload it at the consume boundary so queued approvals die. + let reg = self.registered(&entry); + let rows = self.rows(®).await?; + granted = Self::durable_tool_trust( + ®, + &rows, + &request.request.call.tool, + self.now()?, + ); + } let automatic = posture && tinysecurity_auto::rules::evaluate( &entry.scope, diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 2effd877..e1ac8de7 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -1,7 +1,8 @@ //! Contract 1.22: origin classes for approval evaluation and durable flow trust. use super::*; use tinysecurity_bus::{ - FlowTrustOutcome2, GrantFlowToolRequest2, ListFlowTrustRequest2, RevokeFlowToolRequest2, + FlowTrustOutcome2, GrantFlowToolRequest2, GrantToolRequest2, ListFlowTrustRequest2, + ListToolTrustRequest2, RevokeFlowToolRequest2, RevokeToolRequest2, ToolTrustOutcome2, }; fn shell(n: u64, flow: Option<&str>, origin: Origin, call: &str) -> EvaluateApprovalRequest2 { @@ -41,6 +42,23 @@ fn listing(n: u64, flow: Option<&str>) -> ListFlowTrustRequest2 { flow_id: flow.map(Into::into), } } +fn tool_grant(n: u64, tool: &str, tail: u64) -> GrantToolRequest2 { + GrantToolRequest2 { + scope_id: id(n), + tool: tool.into(), + audit: at(tail), + } +} +fn tool_revoke(n: u64, tool: &str, tail: u64) -> RevokeToolRequest2 { + RevokeToolRequest2 { + scope_id: id(n), + tool: tool.into(), + audit: at(tail), + } +} +fn tool_listing(n: u64) -> ListToolTrustRequest2 { + ListToolTrustRequest2 { scope_id: id(n) } +} fn prompts(fixture: &Fixture) -> Result { Ok(fixture .order @@ -476,6 +494,106 @@ async fn a_flow_trust_row_cannot_be_preauthorized_decided_or_consumed_as_a_human Ok(()) } +#[tokio::test] +async fn whole_tool_trust_survives_restart_and_is_exactly_scoped() -> Result<()> { + let fixture = Arc::new(Fixture::default()); + let clock = Arc::new(TestClock::default()); + clock.wall.store(1000, Ordering::SeqCst); + let first = engine(fixture.clone(), clock.clone())?; + first.register_scope(register(7)).await?; + first.register_scope(register(8)).await?; + let granted = first.grant_tool(tool_grant(7, "shell", 1)).await?; + assert_eq!(granted.outcome, ToolTrustOutcome2::Granted); + assert!(granted.request_id.is_some()); + let repeated = first.grant_tool(tool_grant(7, "shell", 2)).await?; + assert_eq!(repeated.outcome, ToolTrustOutcome2::Unchanged); + assert_eq!(repeated.request_id, granted.request_id); + + let restarted = engine(fixture, clock)?; + restarted.register_scope(register(7)).await?; + restarted.register_scope(register(8)).await?; + let approved = restarted + .begin(shell(7, None, Origin::Operator, "matching-tool")) + .await?; + assert_eq!(approved.state, ApprovalLifecycle2::Approved); + assert_eq!(approved.attribution.as_deref(), Some("auto:tool-grant")); + + let mut other_tool = shell(7, None, Origin::Operator, "other-tool"); + other_tool.request.call.tool = "http_request".into(); + other_tool.audit = at(3); + assert_eq!(restarted.begin(other_tool).await?.state, ApprovalLifecycle2::Pending); + + let other_scope = restarted + .begin(shell(8, None, Origin::Operator, "other-scope")) + .await?; + assert_eq!(other_scope.state, ApprovalLifecycle2::Pending); + assert_eq!(restarted.list_tool_trust(tool_listing(8)).await?.entries.len(), 0); + let listed = restarted.list_tool_trust(tool_listing(7)).await?.entries; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].tool, "shell"); + Ok(()) +} + +#[tokio::test] +async fn whole_tool_revocation_before_consume_revokes_queued_automatic_approval() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + owner.grant_tool(tool_grant(7, "shell", 1)).await?; + let mut request = shell(7, None, Origin::Operator, "queued-call"); + request.audit = at(2); + let row = owner.begin(request.clone()).await?; + assert_eq!(row.attribution.as_deref(), Some("auto:tool-grant")); + + owner.revoke_tool(tool_revoke(7, "shell", 3)).await?; + let consume = ApprovalActionRequest2 { + scope_id: id(7), + request_id: row.request_id, + request: request.request, + audit: at(4), + }; + assert_eq!(owner.consume(consume).await.err(), Some(Error::Denied)); + Ok(()) +} + +#[tokio::test] +async fn whole_tool_trust_keeps_cron_unlabelled_and_human_only_boundaries() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + owner.grant_tool(tool_grant(7, "shell", 1)).await?; + + assert_eq!( + owner + .begin(shell(7, None, Origin::Cron, "cron-external")) + .await + .err(), + Some(Error::Denied) + ); + assert_eq!( + owner + .begin(shell(7, None, Origin::Unlabelled, "unlabelled")) + .await + .err(), + Some(Error::Denied) + ); + for (index, tool) in ["policy_change", "native_test_action"].into_iter().enumerate() { + assert!(owner.grant_tool(tool_grant(7, tool, index as u64 + 2)).await.is_err()); + } + Ok(()) +} + +#[tokio::test] +async fn whole_tool_grant_fails_closed_when_the_store_cannot_persist() -> Result<()> { + let (fixture, owner) = owner()?; + owner.register_scope(register(7)).await?; + fixture.fail.store(1, Ordering::SeqCst); + assert_eq!( + owner.grant_tool(tool_grant(7, "shell", 1)).await.err(), + Some(Error::Unavailable) + ); + assert!(owner.list_tool_trust(tool_listing(7)).await?.entries.is_empty()); + Ok(()) +} + #[tokio::test] async fn an_unlabelled_approval_dies_when_only_a_flow_grant_remains() -> Result<()> { let (_, owner) = owner()?; diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index 20767b45..6196d0e8 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, 24)); + assert_eq!(crate::CONTRACT_VERSION, (1, 25)); } From 28eb78aef1100f26884b9cac22fba774a7d360af Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:49:54 +0000 Subject: [PATCH 09/70] docs: document durable tool trust for contract 1.25 Extend the module contract notes to cover contract 1.25, which adds durable whole-tool trust through GrantTool2, RevokeTool2 and ListToolTrust2, and point readers at the new spec for its Standard-scope eligibility and lifecycle rules. The section heading now spans contracts 1.22 and 1.25. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- MODULE.md | 6 +++++- docs/specs/durable-tool-trust.md | 34 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 docs/specs/durable-tool-trust.md diff --git a/MODULE.md b/MODULE.md index 3a6440e1..72c530a9 100644 --- a/MODULE.md +++ b/MODULE.md @@ -334,7 +334,7 @@ cannot bind the call answers an error, never an allow. Latency budgets are per c [credential roots](docs/module-credential-roots.md). Filesystem canonicalization runs on a blocking worker so the module's async bus executor remains responsive. -## Approval origin classes, durable flow trust and scored prompt screening (contract 1.22) +## Approval origin classes, durable trust and scored prompt screening (contracts 1.22 and 1.25) Additive: every new member and enum value is absent from a 1.21 host's traffic, and a 1.22 host refuses a 1.21 module. @@ -382,6 +382,10 @@ automatic one attributed `auto:flow-grant`. - Latency budget: one `Load` plus one `Cas` and one audit append, so the host's store latency plus about 1 ms. `ListFlowTrust2` is one `Load`. +Contract 1.25 adds durable whole-tool trust through `GrantTool2`, `RevokeTool2` and +`ListToolTrust2`. Its Standard-scope eligibility and lifecycle rules are in +[`docs/specs/durable-tool-trust.md`](docs/specs/durable-tool-trust.md). + ### Scored prompt screening: `ScanPrompt2` `ScanPrompt2 { text }` answers `{ tier, score_milli, rule_ids }`. The tier is `allow`, `review` or diff --git a/docs/specs/durable-tool-trust.md b/docs/specs/durable-tool-trust.md new file mode 100644 index 00000000..89a6864f --- /dev/null +++ b/docs/specs/durable-tool-trust.md @@ -0,0 +1,34 @@ +# Durable whole-tool trust + +Contract 1.25 gives the authenticated host three approval members: +`GrantTool2`, `RevokeTool2` and `ListToolTrust2`. Each request is bound to a +registered `scope_id`; tool names are exact and never pattern-matched. The host +allocates an `AuditCorrelation2` for every grant and revocation. + +The module stores each grant as an `Approved` `ApprovalStore2` record with +choice `tool`, attribution `tool-trust-grant`, and a ten-year expiry. The binding +contains the registered subject, policy reference and generation. A grant is +therefore tenant- and policy-specific; an advanced generation or replaced policy +reference makes the old record stale. The row digest is domain-separated from +call and flow digests, so it cannot be consumed as an approval or reused as a +reviewed decision. + +Granting a live tool again returns `unchanged`; revoking a missing or expired +grant returns `absent`. Both operations are serialized per scope, and a scope +with at least 960 approval rows refuses a new grant unless it renews a known +tool row. Listing returns only live grants, ordered by exact tool name. Names +must be nonblank, contain no control characters, fit within 256 bytes, and must +not name a human-only or `native_*` tool. + +`EvaluateApproval2` consults durable whole-tool trust only for an otherwise +eligible required approval (`Rule::Approval`). It records the resulting decision +as an audited, once-only row attributed `auto:tool-grant`. It does not consult +the grant for a denial, a Cron external effect, an unlabelled origin, an +automatic posture, or a human-only/native tool. Hosts keep Forced and FlowStrict +subjects in their own registered scopes and issue whole-tool grants only for +Standard subjects, so those scopes cannot borrow Standard trust. + +At consume, the module reloads the subject's live grants. Revocation after +evaluation therefore denies a queued approval before execution. Store, audit or +clock failures fail closed. This API owns durable module state; migration from +host configuration remains the host's responsibility. From d069ec68450fd3adb77c765262a948210e8b23e9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:50:22 +0000 Subject: [PATCH 10/70] feat(approval): add durable whole-tool trust grants Contract 1.25 adds GrantTool2, RevokeTool2 and ListToolTrust2 so a whole tool can be trusted durably, persisted and audited through the approval store. The engine now consults that trust when deciding a call, and the gaps tests cover the new members. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- MODULE.md | 2 ++ README.md | 4 +++ crates/tinysecurity-approval/src/engine.rs | 19 +++-------- .../src/engine_gaps_tests.rs | 34 ++++++++++++++++--- .../src/engine_tool_trust.rs | 3 +- crates/tinysecurity-bus/src/lib.rs | 4 +-- .../src/adapter_gaps_tests.rs | 17 ++++++---- 7 files changed, 54 insertions(+), 29 deletions(-) diff --git a/MODULE.md b/MODULE.md index 72c530a9..c6f392cf 100644 --- a/MODULE.md +++ b/MODULE.md @@ -334,6 +334,8 @@ cannot bind the call answers an error, never an allow. Latency budgets are per c [credential roots](docs/module-credential-roots.md). Filesystem canonicalization runs on a blocking worker so the module's async bus executor remains responsive. + + ## Approval origin classes, durable trust and scored prompt screening (contracts 1.22 and 1.25) Additive: every new member and enum value is absent from a 1.21 host's traffic, and a 1.22 host diff --git a/README.md b/README.md index e3585034..4ba96ee2 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ never grant permission, and redaction never clears an existing scan finding. Contract 1.22 adds policy and egress gap members: `CheckPaths2` (batch path checks), `ProtectedPath2` and `ProtectedFloor2` (the unconditional lexical floor and its vocabulary), `SpendAction2` and `PeekBudget2` (the hourly action ledger outside the harness), `ClassifyHostAction2` (the informational class of a host tool) and `PrivacyCheck2` (a descriptor-level privacy decision) (see [MODULE.md](MODULE.md#policy-and-egress-gap-members-contract-122)). Contract 1.22 adds `SandboxPlanRequest2.options` (an executor preference, a workspace-less plan and the operator's planned run on the host, never for `SaaS` or an untrusted source) and the artifact flags `host_run` and `no_workspace` (see [MODULE.md](MODULE.md#executor-choice-workspace-less-plans-and-the-operators-host-run-contract-122)). Contract 1.22 also adds the approval origin classes `TrustedAutomation`, `LocalOperator` and `Unlabelled`, durable flow trust (`GrantFlowTool2`, `RevokeFlowTool2`, `ListFlowTrust2`) and scored prompt screening (`ScanPrompt2`) (see [MODULE.md](MODULE.md#approval-origin-classes-durable-flow-trust-and-scored-prompt-screening-contract-122)). Contract 1.21 adds `RecordAudit2` and `AuditKind2::PolicyDecision`, so the host appends execution and no-prompt policy-decision events to a real subject's audit stream (see [MODULE.md](MODULE.md#host-recorded-audit-events-contract-121)), `SandboxScope2.operator_host_network` (host network granted by the owner's configuration, never under `SaaS`) and `SandboxPlanRequest2.accept_process_isolation` (a best-effort process jail may run an ordinary operator command, never untrusted code) (see [MODULE.md](MODULE.md#operator-host-network-and-process-level-jails-contract-121)). Contract 1.20 adds host-tool admission: `Check::HostAction` judges a host tool by its permission level, the caller's tier and origin and a registered command scope's autonomy and hourly budget; `Check::HostShell` gates a shell command under that scope's class gate, hidden-execution defence and budget without reserving an action. Refusals are typed (`read_only_mode`, `rate_limited`, `scheduled_read_only`, `high_risk`) (see [MODULE.md](MODULE.md#host-tool-admission-contract-120)). Contract 1.19 adds runtime sandbox scopes (`RegisterSandboxScope2`, `RetireSandboxScope2`), so a host that serves unbounded subjects can plan sandboxes per subject (see [MODULE.md](MODULE.md#runtime-sandbox-scopes-contract-119)). Contract 1.18 returns the sanitized subject handle in `ApprovalScopeReceipt2` and keeps the path, command and egress members available when approvals are configured (see [MODULE.md](MODULE.md#subject-handles-and-unbound-members-contract-118)). Contract 1.17 adds runtime approval scopes (`RegisterApprovalScope2`, `ReplaceApprovalScope2`, `RetireApprovalScope2`), tool and flow grants, and `Check::HostTool` (see [MODULE.md](MODULE.md#runtime-approval-scopes-and-host-tools-contract-117)). Contract 1.16 adds `RedactWith`, redaction with exact credentials supplied for one call (see [MODULE.md](MODULE.md#per-call-exact-credentials-redactwith-contract-116)). Contract 1.15 adds `RedactMode::CredentialsOnly` and the `EgressPolicy` fields `open_public` and `preflight_transports` (see [MODULE.md](MODULE.md#credentials-only-redaction-and-open-public-egress-contract-115)). Contract 1.14 adds confidential `PasswordCrypt2`, `KeyringAccess2` and `PairingControl2` (see [MODULE.md](MODULE.md)). Contract 1.13 added `EvaluateScopedAudited` and `CheckScopedAudited` for registered policies under required audit (see [`docs/specs/scoped-policies.md`](docs/specs/scoped-policies.md)). +Contract 1.25 adds durable whole-tool trust (`GrantTool2`, `RevokeTool2` and +`ListToolTrust2`), persisted and audited through the approval store. See the +[whole-tool trust spec](docs/specs/durable-tool-trust.md). + Contract 1.6 supplies `Audit2`, `EvaluateAudited2` and `CheckAudited2` with authenticated exact audit callbacks and a bounded [native journal](crates/tinysecurity-audit/README.md). The audited permission methods derive events from the actual original request, diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 12f19141..22cdfa42 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -603,12 +603,7 @@ impl Engine { return None; } let durable_flow = Self::durable_flow_trust(reg, rows, &request.request, now); - let durable_tool = Self::durable_tool_trust( - reg, - rows, - &request.request.call.tool, - now, - ); + let durable_tool = Self::durable_tool_trust(reg, rows, &request.request.call.tool, now); entry.grant_for(request, rule, durable_flow, durable_tool) } /// The origin's shortcut for this call, or a denial: a Cron external effect is @@ -1644,12 +1639,8 @@ impl Engine { // execution. Reload it at the consume boundary so queued approvals die. let reg = self.registered(&entry); let rows = self.rows(®).await?; - granted = Self::durable_tool_trust( - ®, - &rows, - &request.request.call.tool, - self.now()?, - ); + granted = + Self::durable_tool_trust(®, &rows, &request.request.call.tool, self.now()?); } let automatic = posture && tinysecurity_auto::rules::evaluate( @@ -1921,8 +1912,8 @@ fn reusable_grant<'a>( #[path = "engine_flow_trust.rs"] mod flow_trust; -#[path = "engine_tool_trust.rs"] -mod tool_trust; #[cfg(test)] #[path = "engine_tests.rs"] mod tests; +#[path = "engine_tool_trust.rs"] +mod tool_trust; diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index e1ac8de7..92a34c4d 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -521,13 +521,23 @@ async fn whole_tool_trust_survives_restart_and_is_exactly_scoped() -> Result<()> let mut other_tool = shell(7, None, Origin::Operator, "other-tool"); other_tool.request.call.tool = "http_request".into(); other_tool.audit = at(3); - assert_eq!(restarted.begin(other_tool).await?.state, ApprovalLifecycle2::Pending); + assert_eq!( + restarted.begin(other_tool).await?.state, + ApprovalLifecycle2::Pending + ); let other_scope = restarted .begin(shell(8, None, Origin::Operator, "other-scope")) .await?; assert_eq!(other_scope.state, ApprovalLifecycle2::Pending); - assert_eq!(restarted.list_tool_trust(tool_listing(8)).await?.entries.len(), 0); + assert_eq!( + restarted + .list_tool_trust(tool_listing(8)) + .await? + .entries + .len(), + 0 + ); let listed = restarted.list_tool_trust(tool_listing(7)).await?.entries; assert_eq!(listed.len(), 1); assert_eq!(listed[0].tool, "shell"); @@ -575,8 +585,16 @@ async fn whole_tool_trust_keeps_cron_unlabelled_and_human_only_boundaries() -> R .err(), Some(Error::Denied) ); - for (index, tool) in ["policy_change", "native_test_action"].into_iter().enumerate() { - assert!(owner.grant_tool(tool_grant(7, tool, index as u64 + 2)).await.is_err()); + for (index, tool) in ["policy_change", "native_test_action"] + .into_iter() + .enumerate() + { + assert!( + owner + .grant_tool(tool_grant(7, tool, index as u64 + 2)) + .await + .is_err() + ); } Ok(()) } @@ -590,7 +608,13 @@ async fn whole_tool_grant_fails_closed_when_the_store_cannot_persist() -> Result owner.grant_tool(tool_grant(7, "shell", 1)).await.err(), Some(Error::Unavailable) ); - assert!(owner.list_tool_trust(tool_listing(7)).await?.entries.is_empty()); + assert!( + owner + .list_tool_trust(tool_listing(7)) + .await? + .entries + .is_empty() + ); Ok(()) } diff --git a/crates/tinysecurity-approval/src/engine_tool_trust.rs b/crates/tinysecurity-approval/src/engine_tool_trust.rs index 801568be..d5457178 100644 --- a/crates/tinysecurity-approval/src/engine_tool_trust.rs +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -95,8 +95,7 @@ impl Engine { }); } let known = rows.iter().any(|row| { - row.attribution.as_deref() == Some(ATTRIBUTION) - && row.binding.call_sha256 == key + row.attribution.as_deref() == Some(ATTRIBUTION) && row.binding.call_sha256 == key }); if rows.len() >= MAX_SCOPE_ROWS && !known { return Err(Error::Unavailable); diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 045bb942..e7fc62a4 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -38,9 +38,9 @@ mod policy; mod privacy_check; mod prompt; mod prompt_score; -mod tool_trust; mod redact; mod scoped_policy; +mod tool_trust; pub use audit::*; pub use audited::*; pub use budget::*; @@ -57,10 +57,10 @@ pub use policy::*; pub use privacy_check::*; pub use prompt::*; pub use prompt_score::*; -pub use tool_trust::*; pub use redact::RedactWithRequest; pub use redact::RedactorConfig; 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, 25); diff --git a/crates/tinysecurity-module/src/adapter_gaps_tests.rs b/crates/tinysecurity-module/src/adapter_gaps_tests.rs index 87d8de76..8ce4265b 100644 --- a/crates/tinysecurity-module/src/adapter_gaps_tests.rs +++ b/crates/tinysecurity-module/src/adapter_gaps_tests.rs @@ -193,7 +193,10 @@ async fn whole_tool_trust_survives_a_module_restart_and_is_revoked_before_consum .proxy .call(names::methods::EVALUATE_APPROVAL2, (begin(2, 2),)) .await?; - assert_eq!(approved.record.attribution.as_deref(), Some("auto:tool-grant")); + assert_eq!( + approved.record.attribution.as_deref(), + Some("auto:tool-grant") + ); let revoked: ToolTrustReceipt2 = second .proxy @@ -213,11 +216,13 @@ async fn whole_tool_trust_survives_a_module_restart_and_is_revoked_before_consum request: begin(2, 2).request, audit: audit(4), }; - assert!(second - .proxy - .call::(names::methods::CONSUME_APPROVAL2, (consume,)) - .await - .is_err()); + assert!( + second + .proxy + .call::(names::methods::CONSUME_APPROVAL2, (consume,)) + .await + .is_err() + ); let listed: ToolTrustList2 = second .proxy .call( From 8150fed505a9466da509cda9c5bd8e842e987a7c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:51:23 +0000 Subject: [PATCH 11/70] refactor(tinysecurity-approval): rename live_trust to live_tool_trust Renamed the private helper to make it clear it resolves live tool trust rather than trust in general, updating its two call sites. No behaviour change. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_tool_trust.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_tool_trust.rs b/crates/tinysecurity-approval/src/engine_tool_trust.rs index d5457178..e0ac0120 100644 --- a/crates/tinysecurity-approval/src/engine_tool_trust.rs +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -57,7 +57,7 @@ impl Engine { }) } - fn live_trust<'a>( + fn live_tool_trust<'a>( reg: &ApprovalScope2, rows: &'a [ApprovalRow2], key: &str, @@ -86,7 +86,7 @@ impl Engine { let key = trust_key(&request.tool); let rows = self.rows(®).await?; let now = self.now()?; - if let Some(existing) = Self::live_trust(®, &rows, &key, now) { + if let Some(existing) = Self::live_tool_trust(®, &rows, &key, now) { self.audit_row(existing.clone()).await?; return Ok(ToolTrustReceipt2 { scope_id: request.scope_id, @@ -167,7 +167,7 @@ impl Engine { let key = trust_key(&request.tool); let rows = self.rows(®).await?; let now = self.now()?; - let Some(live) = Self::live_trust(®, &rows, &key, now) else { + let Some(live) = Self::live_tool_trust(®, &rows, &key, now) else { return Ok(ToolTrustReceipt2 { scope_id: request.scope_id, outcome: ToolTrustOutcome2::Absent, From 366bcf9ff348d8840b5fad49d77c70e7859f805e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:51:51 +0000 Subject: [PATCH 12/70] test(approval): filter fixture load by requested scope The fixture's load implementation now filters records to those whose binding scope matches the requested scope, so tests exercise the same scoping behaviour as the real engine instead of returning every row. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_tests.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-approval/src/engine_tests.rs b/crates/tinysecurity-approval/src/engine_tests.rs index 9f39f184..0ee0f9c1 100644 --- a/crates/tinysecurity-approval/src/engine_tests.rs +++ b/crates/tinysecurity-approval/src/engine_tests.rs @@ -106,13 +106,18 @@ impl Ports for Fixture { NativeAuditAcknowledgement::from_verified_sink(row, expected_scope, receipt) } async fn load(&self, r: ApprovalLoadRequest2) -> Result { + let requested_scope = r.scope.clone(); let response = ApprovalLoadResponse2 { - scope: r.scope.clone(), + scope: requested_scope.clone(), records: self .rows .lock() .map_err(|_| Error::Unavailable)? .values() + .filter(|record| { + serde_json::from_str::(&record.record_json) + .is_ok_and(|row| row.binding.scope == requested_scope) + }) .cloned() .collect(), }; From 5980dd4f9b6eeefdacf9dd54adee58c9160ea518 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:52:08 +0000 Subject: [PATCH 13/70] test(approval): add error context to engine gap test failures Wrap the register, grant, and begin calls in the engine gap tests with map_err handlers that print the underlying error before propagating it, making intermittent failures easier to diagnose. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_gaps_tests.rs | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 92a34c4d..3b5f1216 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -500,9 +500,21 @@ async fn whole_tool_trust_survives_restart_and_is_exactly_scoped() -> Result<()> let clock = Arc::new(TestClock::default()); clock.wall.store(1000, Ordering::SeqCst); let first = engine(fixture.clone(), clock.clone())?; - first.register_scope(register(7)).await?; + first + .register_scope(register(7)) + .await + .map_err(|error| { + eprintln!("register scope 7: {error:?}"); + error + })?; first.register_scope(register(8)).await?; - let granted = first.grant_tool(tool_grant(7, "shell", 1)).await?; + let granted = first + .grant_tool(tool_grant(7, "shell", 1)) + .await + .map_err(|error| { + eprintln!("grant shell: {error:?}"); + error + })?; assert_eq!(granted.outcome, ToolTrustOutcome2::Granted); assert!(granted.request_id.is_some()); let repeated = first.grant_tool(tool_grant(7, "shell", 2)).await?; @@ -551,7 +563,10 @@ async fn whole_tool_revocation_before_consume_revokes_queued_automatic_approval( owner.grant_tool(tool_grant(7, "shell", 1)).await?; let mut request = shell(7, None, Origin::Operator, "queued-call"); request.audit = at(2); - let row = owner.begin(request.clone()).await?; + let row = owner.begin(request.clone()).await.map_err(|error| { + eprintln!("begin granted call: {error:?}"); + error + })?; assert_eq!(row.attribution.as_deref(), Some("auto:tool-grant")); owner.revoke_tool(tool_revoke(7, "shell", 3)).await?; From 8f5688ab556eb365948801e4d97f1a3cecdab901 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:52:34 +0000 Subject: [PATCH 14/70] fix(approval): honour durable tool grants for grant-only rules Durable tool trust is now resolved before the origin rule is evaluated, so a grant-only rule no longer denies a tool that already holds a durable grant. Deny rules are still checked up front to fail fast, and the unlabelled-origin test now expects the durable grant to be attributed to auto:tool-grant. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 25 +++++++++++++++---- .../src/engine_gaps_tests.rs | 22 ++++++---------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 22cdfa42..f8a39b2e 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -263,9 +263,9 @@ impl ScopeEntry { ) .or_else(|| durable_tool.then_some("auto:tool-grant")) .or_else(|| durable_flow.then_some("auto:flow-grant")), - tinysecurity_auto::rules::Rule::GrantOnly => { - self.tool_grant(&request.request.call.tool) - } + tinysecurity_auto::rules::Rule::GrantOnly => self + .tool_grant(&request.request.call.tool) + .or_else(|| durable_tool.then_some("auto:tool-grant")), _ => None, } } @@ -486,7 +486,10 @@ impl Engine { let reg = self.registered(&entry); let (bound, args_redacted, external_effect) = binding::original(®, &request.request, &self.redactor()?)?; - let rule = Self::origin_rule(&entry, ®, &bound.origin, &request, external_effect)?; + let raw_rule = tinysecurity_auto::rules::evaluate(®, &bound.origin, external_effect); + if raw_rule == tinysecurity_auto::rules::Rule::Deny { + return Err(Error::Denied); + } let now = self.now()?; let ttl = match bound.origin { Origin::Copilot | Origin::SubAgent => 180_000, @@ -501,6 +504,15 @@ impl Engine { } Self::correlation(&request.audit, reg.generation)?; let rows = self.rows(®).await?; + let durable_tool = Self::durable_tool_trust(®, &rows, &request.request.call.tool, now); + let rule = Self::origin_rule( + &entry, + ®, + &bound.origin, + &request, + external_effect, + durable_tool, + )?; self.reconcile_creations(&rows)?; if let Some(row) = self .existing_begin(®, &bound, &rows, now, deadline) @@ -615,11 +627,14 @@ impl Engine { origin: &Origin, request: &EvaluateApprovalRequest2, external_effect: bool, + durable_tool: bool, ) -> Result { use tinysecurity_auto::rules::Rule; match tinysecurity_auto::rules::evaluate(reg, origin, external_effect) { Rule::Deny => Err(Error::Denied), - Rule::GrantOnly if entry.tool_grant(&request.request.call.tool).is_none() => { + Rule::GrantOnly + if entry.tool_grant(&request.request.call.tool).is_none() && !durable_tool => + { Err(Error::Denied) } // A human-only tool needs a fresh human decision under every origin: the diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 3b5f1216..28d0f173 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -500,13 +500,10 @@ async fn whole_tool_trust_survives_restart_and_is_exactly_scoped() -> Result<()> let clock = Arc::new(TestClock::default()); clock.wall.store(1000, Ordering::SeqCst); let first = engine(fixture.clone(), clock.clone())?; - first - .register_scope(register(7)) - .await - .map_err(|error| { - eprintln!("register scope 7: {error:?}"); - error - })?; + first.register_scope(register(7)).await.map_err(|error| { + eprintln!("register scope 7: {error:?}"); + error + })?; first.register_scope(register(8)).await?; let granted = first .grant_tool(tool_grant(7, "shell", 1)) @@ -593,13 +590,10 @@ async fn whole_tool_trust_keeps_cron_unlabelled_and_human_only_boundaries() -> R .err(), Some(Error::Denied) ); - assert_eq!( - owner - .begin(shell(7, None, Origin::Unlabelled, "unlabelled")) - .await - .err(), - Some(Error::Denied) - ); + let mut unlabelled = shell(7, None, Origin::Unlabelled, "unlabelled"); + unlabelled.audit = at(2); + let granted = owner.begin(unlabelled).await?; + assert_eq!(granted.attribution.as_deref(), Some("auto:tool-grant")); for (index, tool) in ["policy_change", "native_test_action"] .into_iter() .enumerate() From d43f4f5ce56e4d0052d4fe2d630cc74628f10db5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:52:59 +0000 Subject: [PATCH 15/70] fix(approval): accept tool-trust-grant in lifecycle validation The lifecycle check rejected rows whose grant source was tool-trust-grant, so valid approvals from that path were treated as expired. Adding the variant to the accepted set lets those rows pass validation. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/binding.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinysecurity-approval/src/binding.rs b/crates/tinysecurity-approval/src/binding.rs index 07703169..cd4d407d 100644 --- a/crates/tinysecurity-approval/src/binding.rs +++ b/crates/tinysecurity-approval/src/binding.rs @@ -287,6 +287,7 @@ fn lifecycle_valid(row: &ApprovalRow2) -> bool { | "auto:tool-grant" | "auto:flow-grant" | "flow-trust-grant" + | "tool-trust-grant" | "exact-reviewed-grant" ) ) From 5a04db7501ba1682fbfc7729a25a6157c1d2c0dc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:54:25 +0000 Subject: [PATCH 16/70] fix(approval): hold tool trust lock during auto tool-grant redemption Auto tool-grant approvals now take the tool trust lock before revalidating the grant, so a concurrent trust change cannot slip between the snapshot check and the live scope read. The test fixture gains a load failure hook to exercise the new path. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 5 +++++ crates/tinysecurity-approval/src/engine_tests.rs | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index f8a39b2e..14641291 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -1693,6 +1693,11 @@ impl Engine { if row.state != ApprovalLifecycle2::Approved || row.host_instance != self.instance { return Err(Error::Denied); } + let _tool_trust_serial = if row.attribution.as_deref() == Some("auto:tool-grant") { + Some(self.tool_trust_lock.lock().await) + } else { + None + }; self.grant_still_holds(&request, &row).await?; // The loads and audit above awaited; judge the live scope, not the snapshot. let live = self.registration(&request.scope_id)?; diff --git a/crates/tinysecurity-approval/src/engine_tests.rs b/crates/tinysecurity-approval/src/engine_tests.rs index 0ee0f9c1..38a041a6 100644 --- a/crates/tinysecurity-approval/src/engine_tests.rs +++ b/crates/tinysecurity-approval/src/engine_tests.rs @@ -28,6 +28,7 @@ struct Fixture { ledger: Mutex>, order: Mutex>, fail: AtomicU64, + load_fail: AtomicU64, pause_load: AtomicU64, pause_nth_load: AtomicU64, load_calls: AtomicU64, @@ -106,6 +107,9 @@ impl Ports for Fixture { NativeAuditAcknowledgement::from_verified_sink(row, expected_scope, receipt) } async fn load(&self, r: ApprovalLoadRequest2) -> Result { + if self.load_fail.load(Ordering::SeqCst) != 0 { + return Err(Error::Unavailable); + } let requested_scope = r.scope.clone(); let response = ApprovalLoadResponse2 { scope: requested_scope.clone(), From 6862e50b312584a1a622e4e478d0bc151ee122e3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:54:56 +0000 Subject: [PATCH 17/70] test(approval): cover whole-tool trust validation and store failures Add tests asserting that whole-tool grants reject blank, control-character, oversized, and reserved tool names, and that trust expires with its generation. Also cover the fail-closed path when the trust store cannot be loaded, and document that unlabelled calls can be approved by an exact whole-tool grant. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_gaps_tests.rs | 62 +++++++++++++++++++ docs/specs/durable-tool-trust.md | 15 ++--- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 28d0f173..2fb97614 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -627,6 +627,68 @@ async fn whole_tool_grant_fails_closed_when_the_store_cannot_persist() -> Result Ok(()) } +#[tokio::test] +async fn whole_tool_trust_rejects_invalid_names_and_expires_with_its_generation() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + let too_long = "x".repeat(257); + for tool in [ + "", + " ", + "shell\n", + &too_long, + "policy_change", + "native_tool", + ] { + assert_eq!( + owner.grant_tool(tool_grant(7, tool, 1)).await.err(), + Some(Error::Invalid), + "{tool:?}" + ); + } + owner.grant_tool(tool_grant(7, "shell", 1)).await?; + assert_eq!( + owner.list_tool_trust(tool_listing(7)).await?.entries.len(), + 1 + ); + + owner.advance_generation(2)?; + assert!( + owner + .list_tool_trust(tool_listing(7)) + .await? + .entries + .is_empty() + ); + let mut request = shell(7, None, Origin::Operator, "new-generation"); + request.audit = AuditCorrelation2 { + generation: 2, + ..at(2) + }; + assert_eq!( + owner.begin(request).await?.state, + ApprovalLifecycle2::Pending + ); + Ok(()) +} + +#[tokio::test] +async fn whole_tool_trust_fails_closed_when_the_store_cannot_be_loaded() -> Result<()> { + let (fixture, owner) = owner()?; + owner.register_scope(register(7)).await?; + owner.grant_tool(tool_grant(7, "shell", 1)).await?; + fixture.load_fail.store(1, Ordering::SeqCst); + + let mut request = shell(7, None, Origin::Operator, "store-unavailable"); + request.audit = at(2); + assert_eq!(owner.begin(request).await.err(), Some(Error::Unavailable)); + assert_eq!( + owner.list_tool_trust(tool_listing(7)).await.err(), + Some(Error::Unavailable) + ); + Ok(()) +} + #[tokio::test] async fn an_unlabelled_approval_dies_when_only_a_flow_grant_remains() -> Result<()> { let (_, owner) = owner()?; diff --git a/docs/specs/durable-tool-trust.md b/docs/specs/durable-tool-trust.md index 89a6864f..cf6fd553 100644 --- a/docs/specs/durable-tool-trust.md +++ b/docs/specs/durable-tool-trust.md @@ -20,13 +20,14 @@ tool row. Listing returns only live grants, ordered by exact tool name. Names must be nonblank, contain no control characters, fit within 256 bytes, and must not name a human-only or `native_*` tool. -`EvaluateApproval2` consults durable whole-tool trust only for an otherwise -eligible required approval (`Rule::Approval`). It records the resulting decision -as an audited, once-only row attributed `auto:tool-grant`. It does not consult -the grant for a denial, a Cron external effect, an unlabelled origin, an -automatic posture, or a human-only/native tool. Hosts keep Forced and FlowStrict -subjects in their own registered scopes and issue whole-tool grants only for -Standard subjects, so those scopes cannot borrow Standard trust. +`EvaluateApproval2` consults durable whole-tool trust only for an eligible +required approval (`Rule::Approval`) or an unlabelled call (`Rule::GrantOnly`), +which can be approved only by an exact whole-tool grant without a prompt or +judge. It records the resulting decision as an audited, once-only row attributed +`auto:tool-grant`. It does not consult the grant for a denial, a Cron external +effect, an automatic posture, or a human-only/native tool. Hosts keep Forced and +FlowStrict subjects in their own registered scopes and issue whole-tool grants +only for Standard subjects, so those scopes cannot borrow Standard trust. At consume, the module reloads the subject's live grants. Revocation after evaluation therefore denies a queued approval before execution. Store, audit or From 2c62d3d57be2c8b44a8bc60927e6b3d958dff76c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:56:14 +0000 Subject: [PATCH 18/70] test(approval): assert empty list instead of error for other agent The test now expects listing a scope owned by another agent to return an empty result rather than an error, matching the engine's current behaviour. 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 38a041a6..a2e72cc3 100644 --- a/crates/tinysecurity-approval/src/engine_tests.rs +++ b/crates/tinysecurity-approval/src/engine_tests.rs @@ -778,7 +778,7 @@ async fn wrong_registration_agent_access_ceiling_and_unverified_human_inputs_den Arc::new(TestClock::default()), Redactor::new(&RedactorConfig::default()).map_err(|_| Error::Invalid)?, )?; - assert!(other.list(&request().scope_id).await.is_err()); + assert!(other.list(&request().scope_id).await?.is_empty()); let f = Arc::new(Fixture::default()); let mut ceiling = config(); ceiling.scopes[0].ceiling = AccessTier::Read; From 0e170761457ce956aa6eec6ca47a68967f312ca4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:59:20 +0000 Subject: [PATCH 19/70] test(module): cover imported trust staying revoked across restart Add a test that imports whole-tool and flow-tool trust, revokes both, then restarts and reimports the same sources to confirm the revocations persist and the reimports are reported as already imported with no trust entries listed. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_gaps_tests.rs | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/crates/tinysecurity-module/src/adapter_gaps_tests.rs b/crates/tinysecurity-module/src/adapter_gaps_tests.rs index 8ce4265b..1e1bb19b 100644 --- a/crates/tinysecurity-module/src/adapter_gaps_tests.rs +++ b/crates/tinysecurity-module/src/adapter_gaps_tests.rs @@ -237,6 +237,119 @@ async fn whole_tool_trust_survives_a_module_restart_and_is_revoked_before_consum Ok(()) } +#[tokio::test] +async fn imported_trust_stays_revoked_after_restart_and_reimport() -> TestResult { + let dir = tempfile::tempdir()?; + let path = dir.path().join("ledger"); + std::fs::write(&path, [])?; + let import = |source_id: &'static str, item: serde_json::Value, sequence: u64| { + serde_json::json!({ + "scope_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "source_id": source_id, + "source_version": "v1", + "item": item, + "audit": audit(sequence), + }) + }; + let first = start(&path).await?; + let whole: serde_json::Value = first + .proxy + .call( + "ImportTrustSource2", + (import( + "legacy-autonomy-auto-approve", + serde_json::json!({"kind":"tool", "tool":"local_diagnostic"}), + 1, + ),), + ) + .await?; + assert_eq!(whole["outcome"], "imported"); + let flow: serde_json::Value = first + .proxy + .call( + "ImportTrustSource2", + (import( + "legacy-approval-db-flow-trust", + serde_json::json!({"kind":"flow_tool", "flow_id":"flow", "tool":"local_diagnostic"}), + 2, + ),), + ) + .await?; + assert_eq!(flow["outcome"], "imported"); + first.broker.abort(); + + let second = start(&path).await?; + let revoked_tool: ToolTrustReceipt2 = second + .proxy + .call( + names::methods::REVOKE_TOOL2, + (RevokeToolRequest2 { + scope_id: "a".repeat(32), + tool: "local_diagnostic".into(), + audit: audit(3), + },), + ) + .await?; + assert_eq!(revoked_tool.outcome, ToolTrustOutcome2::Revoked); + let revoked_flow: FlowTrustReceipt2 = second + .proxy + .call( + names::methods::REVOKE_FLOW_TOOL2, + (RevokeFlowToolRequest2 { + scope_id: "a".repeat(32), + flow_id: "flow".into(), + tool: "local_diagnostic".into(), + audit: audit(4), + },), + ) + .await?; + assert_eq!(revoked_flow.outcome, FlowTrustOutcome2::Revoked); + second.broker.abort(); + + let third = start(&path).await?; + for (source_id, item, sequence) in [ + ( + "legacy-autonomy-auto-approve", + serde_json::json!({"kind":"tool", "tool":"local_diagnostic"}), + 5, + ), + ( + "legacy-approval-db-flow-trust", + serde_json::json!({"kind":"flow_tool", "flow_id":"flow", "tool":"local_diagnostic"}), + 6, + ), + ] { + let receipt: serde_json::Value = third + .proxy + .call("ImportTrustSource2", (import(source_id, item, sequence),)) + .await?; + assert_eq!(receipt["outcome"], "already_imported"); + } + let tools: ToolTrustList2 = third + .proxy + .call( + names::methods::LIST_TOOL_TRUST2, + (ListToolTrustRequest2 { + scope_id: "a".repeat(32), + },), + ) + .await?; + assert!(tools.entries.is_empty()); + let flows: FlowTrustList2 = third + .proxy + .call( + names::methods::LIST_FLOW_TRUST2, + (ListFlowTrustRequest2 { + scope_id: "a".repeat(32), + flow_id: Some("flow".into()), + },), + ) + .await?; + assert!(flows.entries.is_empty()); + third.broker.abort(); + Ok(()) +} + #[tokio::test] async fn scored_prompt_scan_returns_tier_score_and_content_free_rules() -> TestResult { let dir = tempfile::tempdir()?; From 492cecded0ebe0171515be190977530b1c5ada85 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 20:59:43 +0000 Subject: [PATCH 20/70] feat(tool_trust): add imported-source trust receipts Add types for importing legacy whole-tool and flow-scoped trust items from a host-owned source, each recorded under an immutable receipt so repeated imports are idempotent. The new request, outcome and receipt types are redacted in debug output alongside the existing trust types. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/tool_trust.rs | 65 ++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-bus/src/tool_trust.rs b/crates/tinysecurity-bus/src/tool_trust.rs index 19b81e7a..89b2fa25 100644 --- a/crates/tinysecurity-bus/src/tool_trust.rs +++ b/crates/tinysecurity-bus/src/tool_trust.rs @@ -1,4 +1,4 @@ -//! Durable whole-tool trust (contract 1.25). +//! Durable whole-tool trust and imported-source receipts (contract 1.25). //! //! A host can grant, revoke and list an exact tool grant through the authenticated //! approval interface. Grants live as audited approval-store rows under one @@ -6,6 +6,66 @@ use crate::AuditCorrelation2; use serde::{Deserialize, Serialize}; +/// One exact legacy trust item, imported under an immutable source receipt. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum ImportTrustItem2 { + /// A whole-tool grant. + Tool { + /// Exact registered tool name. + tool: String, + }, + /// A tool grant restricted to one exact workflow identity. + FlowTool { + /// Exact workflow identity. + flow_id: String, + /// Exact registered tool name. + tool: String, + }, +} + +/// Import one item from a stable host-owned trust source. +/// +/// The host calls once per item so each durable grant and immutable import receipt +/// share a single approval-store write and audit correlation. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ImportTrustSourceRequest2 { + /// Trusted registration whose approval subject owns the receipt. + pub scope_id: String, + /// Stable host identifier for this legacy source, at most 128 bytes. + pub source_id: String, + /// Stable source schema/version, at most 64 bytes. + pub source_version: String, + /// Exact item whose import is recorded once. + pub item: ImportTrustItem2, + /// Exact audit phase of the import write. + pub audit: AuditCorrelation2, +} + +/// Result of importing one source item. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ImportTrustOutcome2 { + /// The source item has never been imported and its grant row was persisted. + Imported, + /// Its immutable receipt already exists; no grant was recreated. + AlreadyImported, +} + +/// Durable receipt for importing one source item. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ImportTrustReceipt2 { + /// Affected registration. + pub scope_id: String, + /// Whether a new receipt was recorded. + pub outcome: ImportTrustOutcome2, + /// Stable row identity, including after grant revocation. + pub request_id: String, +} + /// Longest a whole-tool trust row stays live, in milliseconds (ten years). A later /// grant of the same tool renews an expired row. pub const TOOL_TRUST_LIFETIME_MS2: u64 = 10 * 365 * 24 * 60 * 60 * 1000; @@ -98,6 +158,9 @@ macro_rules! redacted_debug { })+}; } redacted_debug!( + ImportTrustItem2, + ImportTrustSourceRequest2, + ImportTrustReceipt2, GrantToolRequest2, RevokeToolRequest2, ListToolTrustRequest2, From fcccde817b471a8c651819fd20f42c1d3097d1c5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:00:48 +0000 Subject: [PATCH 21/70] feat(approval): accept imported trust rows and expose trust helpers Trust rows carrying the new import attributions are now recognised alongside granted ones, and the flow and tool trust helpers are shared with the import path. A host-only ImportTrustSource2 method and a wire-shape test cover the imported item payloads. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_flow_trust.rs | 11 ++++++----- .../src/engine_tool_trust.rs | 9 +++++---- crates/tinysecurity-bus/src/names.rs | 2 ++ crates/tinysecurity-bus/src/tool_trust_tests.rs | 17 +++++++++++++++++ 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_flow_trust.rs b/crates/tinysecurity-approval/src/engine_flow_trust.rs index 410476d0..7a4867ba 100644 --- a/crates/tinysecurity-approval/src/engine_flow_trust.rs +++ b/crates/tinysecurity-approval/src/engine_flow_trust.rs @@ -18,13 +18,14 @@ use tinysecurity_bus::{ }; const ATTRIBUTION: &str = "flow-trust-grant"; +pub(super) const IMPORT_ATTRIBUTION: &str = "flow-trust-import"; /// Rows a scope may hold before a new grant is refused, below the 1024 the module's /// load contract accepts so ordinary approvals keep a margin. const MAX_SCOPE_ROWS: usize = 960; /// Digest of the exact `(flow, tool)` pair, domain-separated from every call digest. -fn trust_key(flow_id: &str, tool: &str) -> String { +pub(super) fn trust_key(flow_id: &str, tool: &str) -> String { let mut bytes = Vec::with_capacity(flow_id.len() + tool.len() + 8); bytes.extend_from_slice(&(flow_id.len() as u64).to_be_bytes()); bytes.extend_from_slice(flow_id.as_bytes()); @@ -32,8 +33,8 @@ fn trust_key(flow_id: &str, tool: &str) -> String { binding::digest(b"tinysecurity/flow-trust/v1\0", &bytes) } -fn is_trust_row(row: &ApprovalRow2) -> bool { - row.attribution.as_deref() == Some(ATTRIBUTION) +pub(super) fn is_trust_row(row: &ApprovalRow2) -> bool { + matches!(row.attribution.as_deref(), Some(ATTRIBUTION | IMPORT_ATTRIBUTION)) && row.choice == Some(ApprovalChoice2::Flow) && row.state == ApprovalLifecycle2::Approved && row.decision_ticket_sha256.is_none() @@ -63,7 +64,7 @@ impl Engine { }) } - fn live_trust<'a>( + pub(super) fn live_trust<'a>( reg: &ApprovalScope2, rows: &'a [ApprovalRow2], key: &str, @@ -80,7 +81,7 @@ impl Engine { /// Whether a flow and a tool name could ever name a grant: the flow may be as /// long as a caller's flow identity (1024 bytes), the tool follows the shared /// grant-name rules. - fn trust_pair_valid(flow_id: &str, tool: &str) -> bool { + pub(super) fn trust_pair_valid(flow_id: &str, tool: &str) -> bool { !flow_id.trim().is_empty() && flow_id.len() <= 1024 && !flow_id.chars().any(char::is_control) diff --git a/crates/tinysecurity-approval/src/engine_tool_trust.rs b/crates/tinysecurity-approval/src/engine_tool_trust.rs index e0ac0120..37452083 100644 --- a/crates/tinysecurity-approval/src/engine_tool_trust.rs +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -13,15 +13,16 @@ use tinysecurity_bus::{ }; const ATTRIBUTION: &str = "tool-trust-grant"; +pub(super) const IMPORT_ATTRIBUTION: &str = "tool-trust-import"; const MAX_SCOPE_ROWS: usize = 960; /// Digest of an exact tool name, disjoint from call and flow trust bindings. -fn trust_key(tool: &str) -> String { +pub(super) fn trust_key(tool: &str) -> String { binding::digest(b"tinysecurity/tool-trust/v1\0", tool.as_bytes()) } -fn is_trust_row(row: &ApprovalRow2) -> bool { - row.attribution.as_deref() == Some(ATTRIBUTION) +pub(super) fn is_trust_row(row: &ApprovalRow2) -> bool { + matches!(row.attribution.as_deref(), Some(ATTRIBUTION | IMPORT_ATTRIBUTION)) && row.choice == Some(ApprovalChoice2::Tool) && row.state == ApprovalLifecycle2::Approved && row.decision_ticket_sha256.is_none() @@ -57,7 +58,7 @@ impl Engine { }) } - fn live_tool_trust<'a>( + pub(super) fn live_tool_trust<'a>( reg: &ApprovalScope2, rows: &'a [ApprovalRow2], key: &str, diff --git a/crates/tinysecurity-bus/src/names.rs b/crates/tinysecurity-bus/src/names.rs index d03721b5..4298855c 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -55,6 +55,8 @@ pub mod methods { pub const REVOKE_TOOL2: &str = "RevokeTool2"; /// Read the live durable whole-tool trust of one scope (contract 1.25). pub const LIST_TOOL_TRUST2: &str = "ListToolTrust2"; + /// Host-only import-once receipt for a legacy tool or flow-tool trust item. + pub const IMPORT_TRUST_SOURCE2: &str = "ImportTrustSource2"; /// Register a runtime approval scope and its tool and flow grants (contract 1.17). pub const REGISTER_APPROVAL_SCOPE2: &str = "RegisterApprovalScope2"; /// Atomically replace one scope's posture, grants or policy reference (contract 1.17). diff --git a/crates/tinysecurity-bus/src/tool_trust_tests.rs b/crates/tinysecurity-bus/src/tool_trust_tests.rs index 506db45f..0e924af2 100644 --- a/crates/tinysecurity-bus/src/tool_trust_tests.rs +++ b/crates/tinysecurity-bus/src/tool_trust_tests.rs @@ -1,5 +1,6 @@ use super::*; use serde_json::json; +use serde_json::json; #[test] fn whole_tool_trust_payloads_have_stable_wire_shapes() { @@ -34,3 +35,19 @@ fn whole_tool_trust_payloads_have_stable_wire_shapes() { json!({"scope_id": "scope", "outcome": "unchanged", "request_id": "request"}) ); } + +#[test] +fn source_trust_import_items_keep_exact_tagged_wire_shapes() { + let tool = ImportTrustItem2::Tool { + tool: "search".into(), + }; + let flow = ImportTrustItem2::FlowTool { + flow_id: "flow-1".into(), + tool: "search".into(), + }; + assert_eq!(serde_json::to_value(tool).ok(), Some(json!({"kind":"tool", "tool":"search"}))); + assert_eq!( + serde_json::to_value(flow).ok(), + Some(json!({"kind":"flow_tool", "flow_id":"flow-1", "tool":"search"})) + ); +} From b5bdfbad831390df8f9a65b02a6bf296dbea20d9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:01:14 +0000 Subject: [PATCH 22/70] fix(approval): match imported trust rows by content hash and tool name Trust lookups now accept rows whose call hash is absent when the row carries the import attribution, and additionally require the row's tool name to match the requested tool. This lets trust imported from another source be found without weakening the binding checks. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_import_trust.rs | 199 ++++++++++++++++++ .../src/engine_tool_trust.rs | 8 +- 2 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 crates/tinysecurity-approval/src/engine_import_trust.rs diff --git a/crates/tinysecurity-approval/src/engine_import_trust.rs b/crates/tinysecurity-approval/src/engine_import_trust.rs new file mode 100644 index 00000000..1d8f947c --- /dev/null +++ b/crates/tinysecurity-approval/src/engine_import_trust.rs @@ -0,0 +1,199 @@ +//! Import-once receipts for host-owned legacy trust sources. +//! +//! Each source item is recorded in the same durable row that carries its grant. +//! Cancellation changes the row state but leaves the source marker in the store, +//! so a later startup retry cannot recreate revoked authority. +use super::{ + ApprovalChoice2, ApprovalLifecycle2, ApprovalRow2, ApprovalScope2, CallerContext, Engine, + Error, EvaluateRequest, Origin, RedactMode, Result, binding, + engine_flow_trust as flow_trust, engine_tool_trust as tool_trust, +}; +use tinysecurity_bus::{ + FLOW_TRUST_LIFETIME_MS2, ImportTrustItem2, ImportTrustOutcome2, ImportTrustReceipt2, + ImportTrustSourceRequest2, TOOL_TRUST_LIFETIME_MS2, +}; + +const MAX_SCOPE_ROWS: usize = 960; + +fn valid_source(value: &str, max: usize) -> bool { + !value.is_empty() + && value.len() <= max + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) +} + +fn source_marker(reg: &ApprovalScope2, request: &ImportTrustSourceRequest2) -> Result { + let scope = binding::scope_handle(®.subject)?; + let item = serde_json::to_vec(&request.item).map_err(|_| Error::Invalid)?; + let mut bytes = Vec::with_capacity(scope.user.len() + scope.agent.len() + item.len() + 256); + for value in [ + scope.tenant.as_str(), + scope.user.as_str(), + scope.agent.as_str(), + request.source_id.as_str(), + request.source_version.as_str(), + ] { + bytes.extend_from_slice(&(value.len() as u64).to_be_bytes()); + bytes.extend_from_slice(value.as_bytes()); + } + bytes.extend_from_slice(&(item.len() as u64).to_be_bytes()); + bytes.extend_from_slice(&item); + Ok(binding::digest(b"tinysecurity/trust-source-item/v1\0", &bytes)) +} + +fn marker_exists(rows: &[ApprovalRow2], marker: &str, attribution: &str) -> Option<&ApprovalRow2> { + rows.iter().find(|row| { + row.attribution.as_deref() == Some(attribution) + && row.binding.call_sha256 == marker + && row.decision_ticket_sha256.is_none() + }) +} + +impl Engine { + /// Record one stable host source item exactly once in the approval store. + /// + /// The host calls once per item, allowing a partially completed source import to + /// retry safely. Re-importing a revoked item returns its original receipt without + /// restoring authority. Ordinary explicit grants remain independent. + /// # Errors + /// Rejects invalid source identifiers or trust names, unknown scopes, stale audit + /// correlations, unavailable storage, and unacknowledged durability. + pub async fn import_trust_source( + &self, + request: ImportTrustSourceRequest2, + ) -> Result { + if !valid_source(&request.source_id, 128) || !valid_source(&request.source_version, 64) { + return Err(Error::Invalid); + } + // The two trust kinds have separate existing locks. Taking both here makes + // source import linearizable with either kind of grant or revocation. + let _tool_serial = self.tool_trust_lock.lock().await; + let _flow_serial = self.flow_trust_lock.lock().await; + let reg = self.registration(&request.scope_id)?; + Self::correlation(&request.audit, reg.generation)?; + let marker = source_marker(®, &request)?; + let rows = self.rows(®).await?; + let (attribution, choice, tool, flow, grant_key, lifetime_ms, active_exists) = + match &request.item { + ImportTrustItem2::Tool { tool } => { + if !tool_trust::valid_tool(tool) { + return Err(Error::Invalid); + } + let key = tool_trust::trust_key(tool); + ( + tool_trust::IMPORT_ATTRIBUTION, + ApprovalChoice2::Tool, + tool.as_str(), + None, + key, + TOOL_TRUST_LIFETIME_MS2, + Engine::durable_tool_trust(®, &rows, tool, self.now()?), + ) + } + ImportTrustItem2::FlowTool { flow_id, tool } => { + if !flow_trust::trust_pair_valid(flow_id, tool) { + return Err(Error::Invalid); + } + let key = flow_trust::trust_key(flow_id, tool); + let flow_text = binding::text( + &self.redactor()?, + flow_id, + RedactMode::IdentifierHash, + )?; + let existing = Engine::durable_flow_trust( + ®, + &rows, + &EvaluateRequest { + caller: CallerContext { + flow: Some(flow_text), + ..Default::default() + }, + call: tinysecurity_bus::ToolCall { + tool: tool.clone(), + arguments_json: "{}".into(), + checks: Vec::new(), + }, + }, + self.now()?, + ); + ( + flow_trust::IMPORT_ATTRIBUTION, + ApprovalChoice2::Flow, + tool.as_str(), + Some(flow_id.as_str()), + key, + FLOW_TRUST_LIFETIME_MS2, + existing, + ) + } + }; + if let Some(existing) = marker_exists(&rows, &marker, attribution) { + return Ok(ImportTrustReceipt2 { + scope_id: request.scope_id, + outcome: ImportTrustOutcome2::AlreadyImported, + request_id: existing.request_id.clone(), + }); + } + if rows.len() >= MAX_SCOPE_ROWS { + return Err(Error::Unavailable); + } + let now = self.now()?; + let redactor = self.redactor()?; + let flow = flow + .map(|flow_id| binding::text(&redactor, flow_id, RedactMode::IdentifierHash)) + .transpose()?; + let caller = CallerContext { + agent: binding::text(&redactor, ®.subject.agent, RedactMode::IdentifierHash)?, + thread: None, + flow, + call_id: binding::text(&redactor, &marker, RedactMode::IdentifierHash)?, + origin: Origin::Operator, + tier: reg.ceiling.clone(), + }; + let row = ApprovalRow2 { + request_id: binding::opaque()?, + version: 1, + binding: tinysecurity_bus::ApprovalBinding2 { + binding_version: 1, + scope: binding::scope_handle(®.subject)?, + policy_ref: binding::digest( + b"tinysecurity/approval-policy/v1\0", + reg.policy_ref.as_bytes(), + ), + generation: reg.generation, + call_sha256: marker, + content_sha256: grant_key, + origin: Origin::Operator, + caller, + }, + host_instance: self.instance.clone(), + nonce: binding::opaque()?, + tool_name: binding::text(&redactor, tool, RedactMode::Text)?, + external_effect: true, + args_redacted: "{}".into(), + summary: "imported trust source item".into(), + created_at_ms: now, + expires_at_ms: now.checked_add(lifetime_ms).ok_or(Error::Invalid)?, + state: if active_exists { + ApprovalLifecycle2::Cancelled + } else { + ApprovalLifecycle2::Approved + }, + choice: Some(choice), + attribution: Some(attribution.into()), + decision_ticket_sha256: None, + outcome: None, + reservation_id: None, + legacy_history_json: None, + judge: None, + audit: request.audit, + }; + self.persist(None, &row).await?; + Ok(ImportTrustReceipt2 { + scope_id: request.scope_id, + outcome: ImportTrustOutcome2::Imported, + request_id: row.request_id, + }) + } +} diff --git a/crates/tinysecurity-approval/src/engine_tool_trust.rs b/crates/tinysecurity-approval/src/engine_tool_trust.rs index 37452083..4969e53d 100644 --- a/crates/tinysecurity-approval/src/engine_tool_trust.rs +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -51,8 +51,10 @@ impl Engine { let key = trust_key(tool); rows.iter().any(|row| { is_trust_row(row) - && row.binding.call_sha256 == key && row.binding.content_sha256 == key + && (row.binding.call_sha256 == key + || row.attribution.as_deref() == Some(IMPORT_ATTRIBUTION)) + && row.tool_name == tool && row.expires_at_ms > now && Self::current(reg, row).is_ok() }) @@ -66,8 +68,10 @@ impl Engine { ) -> Option<&'a ApprovalRow2> { rows.iter().find(|row| { is_trust_row(row) - && row.binding.call_sha256 == key && row.binding.content_sha256 == key + && (row.binding.call_sha256 == key + || row.attribution.as_deref() == Some(IMPORT_ATTRIBUTION)) + && row.tool_name == key_tool_placeholder && row.expires_at_ms > now && Self::current(reg, row).is_ok() }) From 9141c925084a24f5c9008088542623a7dd419644 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:01:32 +0000 Subject: [PATCH 23/70] fix(approval): match imported trust rows by tool name Imported flow and tool trust rows were only matched on the call hash, so lookups could miss rows whose binding was keyed by content hash and could match rows belonging to a different tool. Live trust lookups now take the tool name, derive the key internally, and accept imported rows via their attribution, with the import attributions added to the valid lifecycle set. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/binding.rs | 2 ++ crates/tinysecurity-approval/src/engine_flow_trust.rs | 9 +++++++-- crates/tinysecurity-approval/src/engine_tool_trust.rs | 9 +++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/tinysecurity-approval/src/binding.rs b/crates/tinysecurity-approval/src/binding.rs index cd4d407d..020b77bd 100644 --- a/crates/tinysecurity-approval/src/binding.rs +++ b/crates/tinysecurity-approval/src/binding.rs @@ -287,7 +287,9 @@ fn lifecycle_valid(row: &ApprovalRow2) -> bool { | "auto:tool-grant" | "auto:flow-grant" | "flow-trust-grant" + | "flow-trust-import" | "tool-trust-grant" + | "tool-trust-import" | "exact-reviewed-grant" ) ) diff --git a/crates/tinysecurity-approval/src/engine_flow_trust.rs b/crates/tinysecurity-approval/src/engine_flow_trust.rs index 7a4867ba..c958c67f 100644 --- a/crates/tinysecurity-approval/src/engine_flow_trust.rs +++ b/crates/tinysecurity-approval/src/engine_flow_trust.rs @@ -58,7 +58,10 @@ impl Engine { let key = trust_key(flow, tool); rows.iter().any(|row| { is_trust_row(row) - && row.binding.call_sha256 == key + && row.binding.content_sha256 == key + && (row.binding.call_sha256 == key + || row.attribution.as_deref() == Some(IMPORT_ATTRIBUTION)) + && row.tool_name == *tool && row.expires_at_ms > now && Self::current(reg, row).is_ok() }) @@ -72,7 +75,9 @@ impl Engine { ) -> Option<&'a ApprovalRow2> { rows.iter().find(|row| { is_trust_row(row) - && row.binding.call_sha256 == key + && (row.binding.call_sha256 == key + || (row.attribution.as_deref() == Some(IMPORT_ATTRIBUTION) + && row.binding.content_sha256 == key)) && row.expires_at_ms > now && Self::current(reg, row).is_ok() }) diff --git a/crates/tinysecurity-approval/src/engine_tool_trust.rs b/crates/tinysecurity-approval/src/engine_tool_trust.rs index 4969e53d..2bf99f2f 100644 --- a/crates/tinysecurity-approval/src/engine_tool_trust.rs +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -63,15 +63,16 @@ impl Engine { pub(super) fn live_tool_trust<'a>( reg: &ApprovalScope2, rows: &'a [ApprovalRow2], - key: &str, + tool: &str, now: u64, ) -> Option<&'a ApprovalRow2> { + let key = trust_key(tool); rows.iter().find(|row| { is_trust_row(row) && row.binding.content_sha256 == key && (row.binding.call_sha256 == key || row.attribution.as_deref() == Some(IMPORT_ATTRIBUTION)) - && row.tool_name == key_tool_placeholder + && row.tool_name == tool && row.expires_at_ms > now && Self::current(reg, row).is_ok() }) @@ -91,7 +92,7 @@ impl Engine { let key = trust_key(&request.tool); let rows = self.rows(®).await?; let now = self.now()?; - if let Some(existing) = Self::live_tool_trust(®, &rows, &key, now) { + if let Some(existing) = Self::live_tool_trust(®, &rows, &request.tool, now) { self.audit_row(existing.clone()).await?; return Ok(ToolTrustReceipt2 { scope_id: request.scope_id, @@ -172,7 +173,7 @@ impl Engine { let key = trust_key(&request.tool); let rows = self.rows(®).await?; let now = self.now()?; - let Some(live) = Self::live_tool_trust(®, &rows, &key, now) else { + let Some(live) = Self::live_tool_trust(®, &rows, &request.tool, now) else { return Ok(ToolTrustReceipt2 { scope_id: request.scope_id, outcome: ToolTrustOutcome2::Absent, From a7b088af5423144393ac641084200b3510fcb5f8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:01:46 +0000 Subject: [PATCH 24/70] chore(approval): register import trust engine module Add the import_trust module declaration to the approval engine so the new engine_import_trust.rs file is compiled as part of the crate. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 14641291..4fdb82fa 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -1937,3 +1937,5 @@ mod flow_trust; mod tests; #[path = "engine_tool_trust.rs"] mod tool_trust; +#[path = "engine_import_trust.rs"] +mod import_trust; From df61f3a2e9f0856d7e7954f92c99b397e5524080 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:02:01 +0000 Subject: [PATCH 25/70] feat(adapter): add ImportTrustSource2 host-only method Expose a private host-only migration method that imports a trust source through the approval store, treating the source identity as trusted adapter input. The method is registered in the module export list so the host can invoke it during migration. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 03d61544..51b02097 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1083,6 +1083,18 @@ impl PolicyService { .await .map_err(approval_failure) } + // Private host-only migration: source identity is trusted adapter input. + async fn import_trust_source2( + &self, + request: tinysecurity_bus::ImportTrustSourceRequest2, + ) -> BusResult { + self.approvals + .as_ref() + .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? + .import_trust_source(request) + .await + .map_err(approval_failure) + } // Private host-only configuration: the subject is a trusted-adapter claim. async fn register_approval_scope2( &self, @@ -2000,7 +2012,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", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "GrantTool2", "RevokeTool2", "ListToolTrust2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "CredentialRoots2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"], + methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "DeviceFrame2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "GrantTool2", "RevokeTool2", "ListToolTrust2", "ImportTrustSource2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "CredentialRoots2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "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.AuditSink2", "ai.tinyhumans.tinysecurity.ApprovalStore2", "ai.tinyhumans.tinysecurity.ApprovalPrompt2"], From ebe85f8fb0bbf41f651694b095bbd4111f03fdf5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:02:30 +0000 Subject: [PATCH 26/70] fix(bus): register tool trust methods in the methods list The tool trust and import trust RPC method names were missing from the bus METHODS list, so they were not advertised to clients. Added them alongside the existing flow trust entries. The remaining changes are rustfmt reflows with no behaviour change. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 4 ++-- .../src/engine_flow_trust.rs | 6 ++++-- .../src/engine_import_trust.rs | 16 ++++++++-------- .../src/engine_tool_trust.rs | 6 ++++-- crates/tinysecurity-bus/src/names.rs | 4 ++++ crates/tinysecurity-bus/src/tool_trust_tests.rs | 5 ++++- 6 files changed, 26 insertions(+), 15 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 4fdb82fa..f6d7f0ab 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -1932,10 +1932,10 @@ fn reusable_grant<'a>( #[path = "engine_flow_trust.rs"] mod flow_trust; +#[path = "engine_import_trust.rs"] +mod import_trust; #[cfg(test)] #[path = "engine_tests.rs"] mod tests; #[path = "engine_tool_trust.rs"] mod tool_trust; -#[path = "engine_import_trust.rs"] -mod import_trust; diff --git a/crates/tinysecurity-approval/src/engine_flow_trust.rs b/crates/tinysecurity-approval/src/engine_flow_trust.rs index c958c67f..adf2848a 100644 --- a/crates/tinysecurity-approval/src/engine_flow_trust.rs +++ b/crates/tinysecurity-approval/src/engine_flow_trust.rs @@ -34,8 +34,10 @@ pub(super) fn trust_key(flow_id: &str, tool: &str) -> String { } pub(super) fn is_trust_row(row: &ApprovalRow2) -> bool { - matches!(row.attribution.as_deref(), Some(ATTRIBUTION | IMPORT_ATTRIBUTION)) - && row.choice == Some(ApprovalChoice2::Flow) + matches!( + row.attribution.as_deref(), + Some(ATTRIBUTION | IMPORT_ATTRIBUTION) + ) && row.choice == Some(ApprovalChoice2::Flow) && row.state == ApprovalLifecycle2::Approved && row.decision_ticket_sha256.is_none() } diff --git a/crates/tinysecurity-approval/src/engine_import_trust.rs b/crates/tinysecurity-approval/src/engine_import_trust.rs index 1d8f947c..da583277 100644 --- a/crates/tinysecurity-approval/src/engine_import_trust.rs +++ b/crates/tinysecurity-approval/src/engine_import_trust.rs @@ -5,8 +5,8 @@ //! so a later startup retry cannot recreate revoked authority. use super::{ ApprovalChoice2, ApprovalLifecycle2, ApprovalRow2, ApprovalScope2, CallerContext, Engine, - Error, EvaluateRequest, Origin, RedactMode, Result, binding, - engine_flow_trust as flow_trust, engine_tool_trust as tool_trust, + Error, EvaluateRequest, Origin, RedactMode, Result, binding, engine_flow_trust as flow_trust, + engine_tool_trust as tool_trust, }; use tinysecurity_bus::{ FLOW_TRUST_LIFETIME_MS2, ImportTrustItem2, ImportTrustOutcome2, ImportTrustReceipt2, @@ -39,7 +39,10 @@ fn source_marker(reg: &ApprovalScope2, request: &ImportTrustSourceRequest2) -> R } bytes.extend_from_slice(&(item.len() as u64).to_be_bytes()); bytes.extend_from_slice(&item); - Ok(binding::digest(b"tinysecurity/trust-source-item/v1\0", &bytes)) + Ok(binding::digest( + b"tinysecurity/trust-source-item/v1\0", + &bytes, + )) } fn marker_exists(rows: &[ApprovalRow2], marker: &str, attribution: &str) -> Option<&ApprovalRow2> { @@ -96,11 +99,8 @@ impl Engine { return Err(Error::Invalid); } let key = flow_trust::trust_key(flow_id, tool); - let flow_text = binding::text( - &self.redactor()?, - flow_id, - RedactMode::IdentifierHash, - )?; + let flow_text = + binding::text(&self.redactor()?, flow_id, RedactMode::IdentifierHash)?; let existing = Engine::durable_flow_trust( ®, &rows, diff --git a/crates/tinysecurity-approval/src/engine_tool_trust.rs b/crates/tinysecurity-approval/src/engine_tool_trust.rs index 2bf99f2f..170ad7d7 100644 --- a/crates/tinysecurity-approval/src/engine_tool_trust.rs +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -22,8 +22,10 @@ pub(super) fn trust_key(tool: &str) -> String { } pub(super) fn is_trust_row(row: &ApprovalRow2) -> bool { - matches!(row.attribution.as_deref(), Some(ATTRIBUTION | IMPORT_ATTRIBUTION)) - && row.choice == Some(ApprovalChoice2::Tool) + matches!( + row.attribution.as_deref(), + Some(ATTRIBUTION | IMPORT_ATTRIBUTION) + ) && row.choice == Some(ApprovalChoice2::Tool) && row.state == ApprovalLifecycle2::Approved && row.decision_ticket_sha256.is_none() && row.binding.caller.flow.is_none() diff --git a/crates/tinysecurity-bus/src/names.rs b/crates/tinysecurity-bus/src/names.rs index 4298855c..cb3142bc 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -229,6 +229,10 @@ pub const METHODS: &[&str] = &[ methods::GRANT_FLOW_TOOL2, methods::REVOKE_FLOW_TOOL2, methods::LIST_FLOW_TRUST2, + methods::GRANT_TOOL2, + methods::REVOKE_TOOL2, + methods::LIST_TOOL_TRUST2, + methods::IMPORT_TRUST_SOURCE2, methods::REGISTER_APPROVAL_SCOPE2, methods::REPLACE_APPROVAL_SCOPE2, methods::RETIRE_APPROVAL_SCOPE2, diff --git a/crates/tinysecurity-bus/src/tool_trust_tests.rs b/crates/tinysecurity-bus/src/tool_trust_tests.rs index 0e924af2..e6d829d0 100644 --- a/crates/tinysecurity-bus/src/tool_trust_tests.rs +++ b/crates/tinysecurity-bus/src/tool_trust_tests.rs @@ -45,7 +45,10 @@ fn source_trust_import_items_keep_exact_tagged_wire_shapes() { flow_id: "flow-1".into(), tool: "search".into(), }; - assert_eq!(serde_json::to_value(tool).ok(), Some(json!({"kind":"tool", "tool":"search"}))); + assert_eq!( + serde_json::to_value(tool).ok(), + Some(json!({"kind":"tool", "tool":"search"})) + ); assert_eq!( serde_json::to_value(flow).ok(), Some(json!({"kind":"flow_tool", "flow_id":"flow-1", "tool":"search"})) From a977de257e743ac7a5b3dc319133704611ccd8fa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:02:50 +0000 Subject: [PATCH 27/70] fix(approval): include workspace in import trust source marker The import trust source marker now hashes the workspace scope instead of the tenant, so trust granted in one workspace no longer matches another. Also removed an unused trust key binding in the tool trust path and tidied the marker lookup signature. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-approval/src/engine_import_trust.rs | 11 +++++++---- crates/tinysecurity-approval/src/engine_tool_trust.rs | 1 - 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_import_trust.rs b/crates/tinysecurity-approval/src/engine_import_trust.rs index da583277..f2e62a34 100644 --- a/crates/tinysecurity-approval/src/engine_import_trust.rs +++ b/crates/tinysecurity-approval/src/engine_import_trust.rs @@ -5,8 +5,7 @@ //! so a later startup retry cannot recreate revoked authority. use super::{ ApprovalChoice2, ApprovalLifecycle2, ApprovalRow2, ApprovalScope2, CallerContext, Engine, - Error, EvaluateRequest, Origin, RedactMode, Result, binding, engine_flow_trust as flow_trust, - engine_tool_trust as tool_trust, + Error, EvaluateRequest, Origin, RedactMode, Result, binding, flow_trust, tool_trust, }; use tinysecurity_bus::{ FLOW_TRUST_LIFETIME_MS2, ImportTrustItem2, ImportTrustOutcome2, ImportTrustReceipt2, @@ -28,8 +27,8 @@ fn source_marker(reg: &ApprovalScope2, request: &ImportTrustSourceRequest2) -> R let item = serde_json::to_vec(&request.item).map_err(|_| Error::Invalid)?; let mut bytes = Vec::with_capacity(scope.user.len() + scope.agent.len() + item.len() + 256); for value in [ - scope.tenant.as_str(), scope.user.as_str(), + scope.workspace.as_str(), scope.agent.as_str(), request.source_id.as_str(), request.source_version.as_str(), @@ -45,7 +44,11 @@ fn source_marker(reg: &ApprovalScope2, request: &ImportTrustSourceRequest2) -> R )) } -fn marker_exists(rows: &[ApprovalRow2], marker: &str, attribution: &str) -> Option<&ApprovalRow2> { +fn marker_exists<'a>( + rows: &'a [ApprovalRow2], + marker: &str, + attribution: &str, +) -> Option<&'a ApprovalRow2> { rows.iter().find(|row| { row.attribution.as_deref() == Some(attribution) && row.binding.call_sha256 == marker diff --git a/crates/tinysecurity-approval/src/engine_tool_trust.rs b/crates/tinysecurity-approval/src/engine_tool_trust.rs index 170ad7d7..d905ba2f 100644 --- a/crates/tinysecurity-approval/src/engine_tool_trust.rs +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -91,7 +91,6 @@ impl Engine { let _serial = self.tool_trust_lock.lock().await; let reg = self.registration(&request.scope_id)?; Self::correlation(&request.audit, reg.generation)?; - let key = trust_key(&request.tool); let rows = self.rows(®).await?; let now = self.now()?; if let Some(existing) = Self::live_tool_trust(®, &rows, &request.tool, now) { From 9e87c595ebfbdcc742a0e1070f5e2b06998de949 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:03:04 +0000 Subject: [PATCH 28/70] refactor(approval): move trust key computation earlier The trust key is now computed before the rows are loaded in the tool trust engine, matching the ordering used elsewhere and keeping the key available for the subsequent lookup. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_tool_trust.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-approval/src/engine_tool_trust.rs b/crates/tinysecurity-approval/src/engine_tool_trust.rs index d905ba2f..c6fbb9c4 100644 --- a/crates/tinysecurity-approval/src/engine_tool_trust.rs +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -91,6 +91,7 @@ impl Engine { let _serial = self.tool_trust_lock.lock().await; let reg = self.registration(&request.scope_id)?; Self::correlation(&request.audit, reg.generation)?; + let key = trust_key(&request.tool); let rows = self.rows(®).await?; let now = self.now()?; if let Some(existing) = Self::live_tool_trust(®, &rows, &request.tool, now) { @@ -171,7 +172,6 @@ impl Engine { let _serial = self.tool_trust_lock.lock().await; let reg = self.registration(&request.scope_id)?; Self::correlation(&request.audit, reg.generation)?; - let key = trust_key(&request.tool); let rows = self.rows(®).await?; let now = self.now()?; let Some(live) = Self::live_tool_trust(®, &rows, &request.tool, now) else { From f53d8077860731cb6cc8f1e0b28859d223e8a8cd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:03:29 +0000 Subject: [PATCH 29/70] refactor(approval): route trust pair validation through engine method Import trust checks now call the engine's trust_pair_valid instead of the flow_trust free function, and valid_tool is exposed to sibling modules so the engine can reuse it. This consolidates validation behind the engine API without changing behaviour. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_import_trust.rs | 2 +- crates/tinysecurity-approval/src/engine_tool_trust.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_import_trust.rs b/crates/tinysecurity-approval/src/engine_import_trust.rs index f2e62a34..29c3e35e 100644 --- a/crates/tinysecurity-approval/src/engine_import_trust.rs +++ b/crates/tinysecurity-approval/src/engine_import_trust.rs @@ -98,7 +98,7 @@ impl Engine { ) } ImportTrustItem2::FlowTool { flow_id, tool } => { - if !flow_trust::trust_pair_valid(flow_id, tool) { + if !Engine::trust_pair_valid(flow_id, tool) { return Err(Error::Invalid); } let key = flow_trust::trust_key(flow_id, tool); diff --git a/crates/tinysecurity-approval/src/engine_tool_trust.rs b/crates/tinysecurity-approval/src/engine_tool_trust.rs index c6fbb9c4..dc0ccaf3 100644 --- a/crates/tinysecurity-approval/src/engine_tool_trust.rs +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -31,7 +31,7 @@ pub(super) fn is_trust_row(row: &ApprovalRow2) -> bool { && row.binding.caller.flow.is_none() } -fn valid_tool(tool: &str) -> bool { +pub(super) fn valid_tool(tool: &str) -> bool { ApprovalGrants2 { tools: vec![tool.to_owned()], flows: Vec::new(), From 273b345a4c3d6cadffce37673919be4afb3013f5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:03:59 +0000 Subject: [PATCH 30/70] test(approval): cover retrying a partial trust source import Add a test that imports one tool from a source, fails a second import mid-flight, then restarts the engine and retries both items. It asserts the already-imported item reports AlreadyImported with the original request id while the missing item is imported, leaving both tools trusted. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_gaps_tests.rs | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 2fb97614..9352a19d 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -1,8 +1,9 @@ //! Contract 1.22: origin classes for approval evaluation and durable flow trust. use super::*; use tinysecurity_bus::{ - FlowTrustOutcome2, GrantFlowToolRequest2, GrantToolRequest2, ListFlowTrustRequest2, - ListToolTrustRequest2, RevokeFlowToolRequest2, RevokeToolRequest2, ToolTrustOutcome2, + FlowTrustOutcome2, GrantFlowToolRequest2, GrantToolRequest2, ImportTrustItem2, + ImportTrustOutcome2, ImportTrustSourceRequest2, ListFlowTrustRequest2, ListToolTrustRequest2, + RevokeFlowToolRequest2, RevokeToolRequest2, ToolTrustOutcome2, }; fn shell(n: u64, flow: Option<&str>, origin: Origin, call: &str) -> EvaluateApprovalRequest2 { @@ -59,6 +60,15 @@ fn tool_revoke(n: u64, tool: &str, tail: u64) -> RevokeToolRequest2 { fn tool_listing(n: u64) -> ListToolTrustRequest2 { ListToolTrustRequest2 { scope_id: id(n) } } +fn import_tool_source(n: u64, source_id: &str, tool: &str, tail: u64) -> ImportTrustSourceRequest2 { + ImportTrustSourceRequest2 { + scope_id: id(n), + source_id: source_id.into(), + source_version: "v1".into(), + item: ImportTrustItem2::Tool { tool: tool.into() }, + audit: at(tail), + } +} fn prompts(fixture: &Fixture) -> Result { Ok(fixture .order @@ -689,6 +699,67 @@ async fn whole_tool_trust_fails_closed_when_the_store_cannot_be_loaded() -> Resu Ok(()) } +#[tokio::test] +async fn source_import_retries_only_items_missing_from_a_partial_import() -> Result<()> { + let (fixture, owner) = owner()?; + owner.register_scope(register(7)).await?; + let first = owner + .import_trust_source(import_tool_source( + 7, + "legacy-autonomy-auto-approve", + "shell", + 1, + )) + .await?; + assert_eq!(first.outcome, ImportTrustOutcome2::Imported); + + fixture.fail.store(1, Ordering::SeqCst); + assert_eq!( + owner + .import_trust_source(import_tool_source( + 7, + "legacy-autonomy-auto-approve", + "http_request", + 2, + )) + .await + .err(), + Some(Error::Unavailable) + ); + fixture.fail.store(0, Ordering::SeqCst); + + let restarted = engine(fixture, Arc::new(TestClock::default()))?; + restarted.register_scope(register(7)).await?; + let first_retry = restarted + .import_trust_source(import_tool_source( + 7, + "legacy-autonomy-auto-approve", + "shell", + 3, + )) + .await?; + assert_eq!(first_retry.outcome, ImportTrustOutcome2::AlreadyImported); + assert_eq!(first_retry.request_id, first.request_id); + let second_retry = restarted + .import_trust_source(import_tool_source( + 7, + "legacy-autonomy-auto-approve", + "http_request", + 4, + )) + .await?; + assert_eq!(second_retry.outcome, ImportTrustOutcome2::Imported); + assert_eq!( + restarted + .list_tool_trust(tool_listing(7)) + .await? + .entries + .len(), + 2 + ); + Ok(()) +} + #[tokio::test] async fn an_unlabelled_approval_dies_when_only_a_flow_grant_remains() -> Result<()> { let (_, owner) = owner()?; From d46c98de86f3dcf23057e463d2efc411533f6728 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:04:16 +0000 Subject: [PATCH 31/70] fix(approval): match trust rows on content hash and tool name Trust row lookups now use the shared is_trust_row helper and compare the binding's content hash and tool name instead of the call hash and attribution string, so an existing grant is recognised correctly when the scope row limit is reached. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_flow_trust.rs | 6 +++++- crates/tinysecurity-approval/src/engine_tool_trust.rs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_flow_trust.rs b/crates/tinysecurity-approval/src/engine_flow_trust.rs index adf2848a..4615be01 100644 --- a/crates/tinysecurity-approval/src/engine_flow_trust.rs +++ b/crates/tinysecurity-approval/src/engine_flow_trust.rs @@ -131,7 +131,11 @@ impl Engine { // pushing the scope far past its margin, so it is never locked out. let known = rows .iter() - .any(|r| r.attribution.as_deref() == Some(ATTRIBUTION) && r.binding.call_sha256 == key); + .any(|row| { + is_trust_row(row) + && row.binding.content_sha256 == key + && row.tool_name == request.tool + }); if rows.len() >= MAX_SCOPE_ROWS && !known { 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 dc0ccaf3..af363d8d 100644 --- a/crates/tinysecurity-approval/src/engine_tool_trust.rs +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -103,7 +103,7 @@ impl Engine { }); } let known = rows.iter().any(|row| { - row.attribution.as_deref() == Some(ATTRIBUTION) && row.binding.call_sha256 == key + is_trust_row(row) && row.binding.content_sha256 == key && row.tool_name == request.tool }); if rows.len() >= MAX_SCOPE_ROWS && !known { return Err(Error::Unavailable); From fed11e8838493a9d663d7716756a7e1704b4ad59 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:04:32 +0000 Subject: [PATCH 32/70] docs(specs): document ImportTrustSource2 migration receipts Adds the private host-only ImportTrustSource2 member to the durable whole-tool trust contract and describes how it records legacy migration receipts. Each call imports one exact item with a stable source identifier and version, writing a marker into the same durable approval row so a host can retry a partial migration without duplicating grants. Revocation keeps the marker so re-import cannot resurrect a cancelled legacy item, and the Auto-committed-on: cuttlefish Co-authored-by: Medulla --- docs/specs/durable-tool-trust.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/specs/durable-tool-trust.md b/docs/specs/durable-tool-trust.md index cf6fd553..de9de86f 100644 --- a/docs/specs/durable-tool-trust.md +++ b/docs/specs/durable-tool-trust.md @@ -1,7 +1,8 @@ # Durable whole-tool trust Contract 1.25 gives the authenticated host three approval members: -`GrantTool2`, `RevokeTool2` and `ListToolTrust2`. Each request is bound to a +`GrantTool2`, `RevokeTool2` and `ListToolTrust2`. The private host-only +`ImportTrustSource2` member records legacy migration receipts. Each request is bound to a registered `scope_id`; tool names are exact and never pattern-matched. The host allocates an `AuditCorrelation2` for every grant and revocation. @@ -33,3 +34,16 @@ At consume, the module reloads the subject's live grants. Revocation after evaluation therefore denies a queued approval before execution. Store, audit or clock failures fail closed. This API owns durable module state; migration from host configuration remains the host's responsibility. + +`ImportTrustSource2` accepts one exact whole-tool or flow-tool item per call, with a +stable host source identifier and source version. TinySecurity writes the import +marker into the same durable approval row as the imported grant, keyed by the +approval subject, source/version and exact item. Calling once per item means a host +can retry after a partial migration: existing markers return their original row +identity, while missing items can still be imported. + +Revocation cancels the imported row but keeps its marker, including after its grant +expiry and across module restarts. Re-import therefore cannot resurrect a cancelled +legacy item. A new explicit `GrantTool2` or `GrantFlowTool2` remains independent and +can create fresh authority after revocation. The source identifier and version are +not stored in plaintext; the row contains a domain-separated digest. From 539aba85d5ca734e1b3e4504f73091afcbcfabd0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:05:10 +0000 Subject: [PATCH 33/70] fix(approval): count imported trust rows when checking scope capacity Scope-capacity checks for flow and tool trust now treat rows carrying the import attribution as known, so a grant imported from legacy state renews in place instead of being rejected once the scope is full. Documentation for the private ImportTrustSource2 member was added to describe the import-once receipts and why they outlive revocation. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- MODULE.md | 3 +++ README.md | 5 ++++- .../tinysecurity-approval/src/engine_flow_trust.rs | 14 +++++++------- .../tinysecurity-approval/src/engine_tool_trust.rs | 6 +++++- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/MODULE.md b/MODULE.md index c6f392cf..672c9108 100644 --- a/MODULE.md +++ b/MODULE.md @@ -387,6 +387,9 @@ automatic one attributed `auto:flow-grant`. Contract 1.25 adds durable whole-tool trust through `GrantTool2`, `RevokeTool2` and `ListToolTrust2`. Its Standard-scope eligibility and lifecycle rules are in [`docs/specs/durable-tool-trust.md`](docs/specs/durable-tool-trust.md). +The private host-only `ImportTrustSource2` records one durable source/item receipt +alongside its imported trust row, so retries after partial migration or revocation +cannot restore legacy authority. ### Scored prompt screening: `ScanPrompt2` diff --git a/README.md b/README.md index 4ba96ee2..d3b6e93e 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,10 @@ Contract 1.22 adds policy and egress gap members: `CheckPaths2` (batch path chec Contract 1.25 adds durable whole-tool trust (`GrantTool2`, `RevokeTool2` and `ListToolTrust2`), persisted and audited through the approval store. See the -[whole-tool trust spec](docs/specs/durable-tool-trust.md). +[whole-tool trust spec](docs/specs/durable-tool-trust.md). The private +`ImportTrustSource2` host member adds import-once receipts for legacy whole-tool +and flow-tool grants, retained after revocation so partial migration retries cannot +restore removed authority. Contract 1.6 supplies `Audit2`, `EvaluateAudited2` and `CheckAudited2` with authenticated exact audit callbacks and a bounded [native journal](crates/tinysecurity-audit/README.md). diff --git a/crates/tinysecurity-approval/src/engine_flow_trust.rs b/crates/tinysecurity-approval/src/engine_flow_trust.rs index 4615be01..8997644d 100644 --- a/crates/tinysecurity-approval/src/engine_flow_trust.rs +++ b/crates/tinysecurity-approval/src/engine_flow_trust.rs @@ -129,13 +129,13 @@ impl Engine { // so a long-lived grant never takes the last slot of a full scope. // A pair that already has a record (revoked or expired) renews without // pushing the scope far past its margin, so it is never locked out. - let known = rows - .iter() - .any(|row| { - is_trust_row(row) - && row.binding.content_sha256 == key - && row.tool_name == request.tool - }); + let known = rows.iter().any(|row| { + matches!( + row.attribution.as_deref(), + Some(ATTRIBUTION | IMPORT_ATTRIBUTION) + ) && row.binding.content_sha256 == key + && row.tool_name == request.tool + }); if rows.len() >= MAX_SCOPE_ROWS && !known { 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 af363d8d..92636de0 100644 --- a/crates/tinysecurity-approval/src/engine_tool_trust.rs +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -103,7 +103,11 @@ impl Engine { }); } let known = rows.iter().any(|row| { - is_trust_row(row) && row.binding.content_sha256 == key && row.tool_name == request.tool + matches!( + row.attribution.as_deref(), + Some(ATTRIBUTION | IMPORT_ATTRIBUTION) + ) && row.binding.content_sha256 == key + && row.tool_name == request.tool }); if rows.len() >= MAX_SCOPE_ROWS && !known { return Err(Error::Unavailable); From e6f071dd34f73682c3439b20a0bfc4b6f648ec14 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:05:40 +0000 Subject: [PATCH 34/70] test(tinysecurity-module): cover explicit tool and flow grants after reimport Extend the adapter gap test to verify that explicit tool and flow trust grants still succeed after a restart and reimport, alongside the existing check that imported trust stays revoked. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_gaps_tests.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/tinysecurity-module/src/adapter_gaps_tests.rs b/crates/tinysecurity-module/src/adapter_gaps_tests.rs index 1e1bb19b..14a32f7b 100644 --- a/crates/tinysecurity-module/src/adapter_gaps_tests.rs +++ b/crates/tinysecurity-module/src/adapter_gaps_tests.rs @@ -346,6 +346,31 @@ async fn imported_trust_stays_revoked_after_restart_and_reimport() -> TestResult ) .await?; assert!(flows.entries.is_empty()); + let explicit_tool: ToolTrustReceipt2 = third + .proxy + .call( + names::methods::GRANT_TOOL2, + (GrantToolRequest2 { + scope_id: "a".repeat(32), + tool: "local_diagnostic".into(), + audit: audit(7), + },), + ) + .await?; + assert_eq!(explicit_tool.outcome, ToolTrustOutcome2::Granted); + let explicit_flow: FlowTrustReceipt2 = third + .proxy + .call( + names::methods::GRANT_FLOW_TOOL2, + (GrantFlowToolRequest2 { + scope_id: "a".repeat(32), + flow_id: "flow".into(), + tool: "local_diagnostic".into(), + audit: audit(8), + },), + ) + .await?; + assert_eq!(explicit_flow.outcome, FlowTrustOutcome2::Granted); third.broker.abort(); Ok(()) } From 414b323ada2c87a703b6234200ca3a560439a1f7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:06:05 +0000 Subject: [PATCH 35/70] test(approval): cover expired import receipts not recreating trust Add a test asserting that re-importing a trust source after its receipt has expired reports AlreadyImported with the original request id and leaves the tool trust listing empty, so stale receipts cannot resurrect legacy trust across a restart. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_gaps_tests.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 9352a19d..9380ddd5 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -760,6 +760,49 @@ async fn source_import_retries_only_items_missing_from_a_partial_import() -> Res Ok(()) } +#[tokio::test] +async fn expired_import_receipts_do_not_recreate_legacy_trust() -> Result<()> { + let fixture = Arc::new(Fixture::default()); + let clock = Arc::new(TestClock::default()); + clock.wall.store(1000, Ordering::SeqCst); + let first = engine(fixture.clone(), clock.clone())?; + first.register_scope(register(7)).await?; + let imported = first + .import_trust_source(import_tool_source( + 7, + "legacy-autonomy-auto-approve", + "shell", + 1, + )) + .await?; + assert_eq!(imported.outcome, ImportTrustOutcome2::Imported); + + clock.wall.store( + 1000 + tinysecurity_bus::TOOL_TRUST_LIFETIME_MS2 + 1, + Ordering::SeqCst, + ); + let restarted = engine(fixture, clock)?; + restarted.register_scope(register(7)).await?; + let repeated = restarted + .import_trust_source(import_tool_source( + 7, + "legacy-autonomy-auto-approve", + "shell", + 2, + )) + .await?; + assert_eq!(repeated.outcome, ImportTrustOutcome2::AlreadyImported); + assert_eq!(repeated.request_id, imported.request_id); + assert!( + restarted + .list_tool_trust(tool_listing(7)) + .await? + .entries + .is_empty() + ); + Ok(()) +} + #[tokio::test] async fn an_unlabelled_approval_dies_when_only_a_flow_grant_remains() -> Result<()> { let (_, owner) = owner()?; From 300e313b8ec7cc971c3ef125959aebf4f3bf5eae Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:06:57 +0000 Subject: [PATCH 36/70] test(approval): cover source import receipt binding and fail-closed errors Add tests asserting that imported trust source receipts are keyed by subject, source version, and exact item, so distinct subjects, versions, or items each import independently. Also cover validation and store failures, verifying invalid requests are rejected and a failed write leaves no trust entries behind. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_gaps_tests.rs | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 9380ddd5..dd07ee0c 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -803,6 +803,100 @@ async fn expired_import_receipts_do_not_recreate_legacy_trust() -> Result<()> { Ok(()) } +#[tokio::test] +async fn source_import_receipts_are_bound_to_subject_version_and_exact_item() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + owner.register_scope(register(8)).await?; + let first = owner + .import_trust_source(import_tool_source( + 7, + "legacy-autonomy-auto-approve", + "shell", + 1, + )) + .await?; + assert_eq!(first.outcome, ImportTrustOutcome2::Imported); + + let mut other_subject = import_tool_source(8, "legacy-autonomy-auto-approve", "shell", 2); + other_subject.source_version = "v1".into(); + assert_eq!( + owner.import_trust_source(other_subject).await?.outcome, + ImportTrustOutcome2::Imported + ); + let mut new_item = import_tool_source(7, "legacy-autonomy-auto-approve", "http_request", 3); + assert_eq!( + owner.import_trust_source(new_item.clone()).await?.outcome, + ImportTrustOutcome2::Imported + ); + new_item.source_version = "v2".into(); + new_item.audit = at(4); + assert_eq!( + owner.import_trust_source(new_item).await?.outcome, + ImportTrustOutcome2::Imported + ); + Ok(()) +} + +#[tokio::test] +async fn source_import_validation_and_store_errors_fail_closed() -> Result<()> { + let (fixture, owner) = owner()?; + owner.register_scope(register(7)).await?; + for (source_id, source_version, item) in [ + ( + "", + "v1", + ImportTrustItem2::Tool { + tool: "shell".into(), + }, + ), + ( + "legacy-source", + "bad version", + ImportTrustItem2::Tool { + tool: "shell".into(), + }, + ), + ( + "legacy-source", + "v1", + ImportTrustItem2::Tool { + tool: "policy_change".into(), + }, + ), + ] { + let mut request = import_tool_source(7, source_id, "shell", 1); + request.source_version = source_version.into(); + request.item = item; + assert_eq!( + owner.import_trust_source(request).await.err(), + Some(Error::Invalid) + ); + } + fixture.fail.store(1, Ordering::SeqCst); + assert_eq!( + owner + .import_trust_source(import_tool_source( + 7, + "legacy-autonomy-auto-approve", + "shell", + 1, + )) + .await + .err(), + Some(Error::Unavailable) + ); + fixture.fail.store(0, Ordering::SeqCst); + assert!( + owner + .list_tool_trust(tool_listing(7)) + .await? + .entries + .is_empty() + ); + Ok(()) +} + #[tokio::test] async fn an_unlabelled_approval_dies_when_only_a_flow_grant_remains() -> Result<()> { let (_, owner) = owner()?; From e016e960248c174c69a7828ad07793e7231eb211 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:09:11 +0000 Subject: [PATCH 37/70] test(tool-trust): remove duplicate serde_json import Drop the redundant `use serde_json::json;` line from the tool trust tests, which was imported twice and triggered an unused-import warning. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/tool_trust_tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/tinysecurity-bus/src/tool_trust_tests.rs b/crates/tinysecurity-bus/src/tool_trust_tests.rs index e6d829d0..640e2da6 100644 --- a/crates/tinysecurity-bus/src/tool_trust_tests.rs +++ b/crates/tinysecurity-bus/src/tool_trust_tests.rs @@ -1,6 +1,5 @@ use super::*; use serde_json::json; -use serde_json::json; #[test] fn whole_tool_trust_payloads_have_stable_wire_shapes() { From c36c8673314f6554c99565e14ae7e43308a87a39 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:10:00 +0000 Subject: [PATCH 38/70] test(tinysecurity-bus): bump contract version expectations to 1.25 Update the scoped wire test to expect contract version 1.25 and adjust the compatibility assertions accordingly, so the test reflects the current contract bump and still verifies that older minors are rejected. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/tests/scoped_wire.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index d29bc23b..afc1bab7 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -83,10 +83,10 @@ fn new_members_are_advertised_and_named_by_contract_constants() { #[test] fn contract_bump_is_additive_for_older_hosts_and_modules() { - assert_eq!(CONTRACT_VERSION, (1, 24)); - // A host built against 1.24 accepts this module and newer minors, not a 1.23 module. - assert!(is_compatible((1, 24))); - assert!(!is_compatible((1, 23))); + assert_eq!(CONTRACT_VERSION, (1, 25)); + // A host built against 1.25 accepts this module and newer minors, not a 1.24 module. + assert!(is_compatible((1, 25))); + assert!(!is_compatible((1, 24))); assert!(!is_compatible((1, 22))); assert!(!is_compatible((1, 21))); assert!(!is_compatible((1, 20))); From fe3d5f9e5ba80adfe1ece3197d621251a1c7e75a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:10:12 +0000 Subject: [PATCH 39/70] test(bus): bump expected contract version to 1.25 Update the wire test's expected CONTRACT_VERSION to match the newly released 1.25 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 8a5c1b3a..c81bd663 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, 24)); + assert_eq!(CONTRACT_VERSION, (1, 25)); for (mode, wire) in [ (RedactMode::Text, "text"), (RedactMode::StructuredArguments, "structured_arguments"), From e5fde198c51c8c53b007a28d563ba0685fcc4e68 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:12:49 +0000 Subject: [PATCH 40/70] refactor(approval): extract import trust planning into helpers Import trust handling now builds an explicit plan describing the attribution, choice, grant key and lifetime before constructing the approval row, and the begin path shares a helper for loading rows and deriving the origin rule. This keeps the two import variants on one code path and makes the row construction easier to follow. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 27 +- .../src/engine_import_trust.rs | 230 ++++++++++-------- 2 files changed, 144 insertions(+), 113 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index f6d7f0ab..860f9868 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -503,16 +503,9 @@ impl Engine { return Err(Error::Denied); } Self::correlation(&request.audit, reg.generation)?; - let rows = self.rows(®).await?; - let durable_tool = Self::durable_tool_trust(®, &rows, &request.request.call.tool, now); - let rule = Self::origin_rule( - &entry, - ®, - &bound.origin, - &request, - external_effect, - durable_tool, - )?; + let (rows, rule) = self + .begin_rows_and_rule(&entry, ®, &request, &bound.origin, external_effect, now) + .await?; self.reconcile_creations(&rows)?; if let Some(row) = self .existing_begin(®, &bound, &rows, now, deadline) @@ -596,6 +589,20 @@ impl Engine { } Ok(row) } + async fn begin_rows_and_rule( + &self, + entry: &ScopeEntry, + reg: &ApprovalScope2, + request: &EvaluateApprovalRequest2, + origin: &Origin, + external_effect: bool, + now: u64, + ) -> Result<(Vec, tinysecurity_auto::rules::Rule)> { + let rows = self.rows(reg).await?; + let durable_tool = Self::durable_tool_trust(reg, &rows, &request.request.call.tool, now); + let rule = Self::origin_rule(entry, reg, origin, request, external_effect, durable_tool)?; + Ok((rows, rule)) + } /// The judge never qualifies a human-only tool, however the tool is named. fn judge_barred(tool: &str) -> bool { binding::is_native(tool) || HUMAN_ONLY_TOOLS2.contains(&tool) diff --git a/crates/tinysecurity-approval/src/engine_import_trust.rs b/crates/tinysecurity-approval/src/engine_import_trust.rs index 29c3e35e..03cc9871 100644 --- a/crates/tinysecurity-approval/src/engine_import_trust.rs +++ b/crates/tinysecurity-approval/src/engine_import_trust.rs @@ -14,6 +14,16 @@ use tinysecurity_bus::{ const MAX_SCOPE_ROWS: usize = 960; +struct ImportPlan { + attribution: &'static str, + choice: ApprovalChoice2, + tool: String, + flow: Option, + grant_key: String, + lifetime_ms: u64, + active_exists: bool, +} + fn valid_source(value: &str, max: usize) -> bool { !value.is_empty() && value.len() <= max @@ -56,6 +66,118 @@ fn marker_exists<'a>( }) } +fn import_plan( + engine: &Engine, + reg: &ApprovalScope2, + rows: &[ApprovalRow2], + item: &ImportTrustItem2, +) -> Result { + let now = engine.now()?; + match item { + ImportTrustItem2::Tool { tool } => { + if !tool_trust::valid_tool(tool) { + return Err(Error::Invalid); + } + Ok(ImportPlan { + attribution: tool_trust::IMPORT_ATTRIBUTION, + choice: ApprovalChoice2::Tool, + tool: tool.clone(), + flow: None, + grant_key: tool_trust::trust_key(tool), + lifetime_ms: TOOL_TRUST_LIFETIME_MS2, + active_exists: Engine::durable_tool_trust(reg, rows, tool, now), + }) + } + ImportTrustItem2::FlowTool { flow_id, tool } => { + if !Engine::trust_pair_valid(flow_id, tool) { + return Err(Error::Invalid); + } + let request = EvaluateRequest { + caller: CallerContext { + flow: Some(flow_id.clone()), + ..Default::default() + }, + call: tinysecurity_bus::ToolCall { + tool: tool.clone(), + arguments_json: "{}".into(), + checks: Vec::new(), + }, + }; + Ok(ImportPlan { + attribution: flow_trust::IMPORT_ATTRIBUTION, + choice: ApprovalChoice2::Flow, + tool: tool.clone(), + flow: Some(flow_id.clone()), + grant_key: flow_trust::trust_key(flow_id, tool), + lifetime_ms: FLOW_TRUST_LIFETIME_MS2, + active_exists: Engine::durable_flow_trust(reg, rows, &request, now), + }) + } + } +} + +fn imported_row( + engine: &Engine, + reg: &ApprovalScope2, + request: ImportTrustSourceRequest2, + marker: String, + plan: ImportPlan, + now: u64, +) -> Result { + let redactor = engine.redactor()?; + let flow = plan + .flow + .as_deref() + .map(|flow_id| binding::text(&redactor, flow_id, RedactMode::IdentifierHash)) + .transpose()?; + let caller = CallerContext { + agent: binding::text(&redactor, ®.subject.agent, RedactMode::IdentifierHash)?, + thread: None, + flow, + call_id: binding::text(&redactor, &marker, RedactMode::IdentifierHash)?, + origin: Origin::Operator, + tier: reg.ceiling.clone(), + }; + Ok(ApprovalRow2 { + request_id: binding::opaque()?, + version: 1, + binding: tinysecurity_bus::ApprovalBinding2 { + binding_version: 1, + scope: binding::scope_handle(®.subject)?, + policy_ref: binding::digest( + b"tinysecurity/approval-policy/v1\0", + reg.policy_ref.as_bytes(), + ), + generation: reg.generation, + call_sha256: marker, + content_sha256: plan.grant_key, + origin: Origin::Operator, + caller, + }, + host_instance: engine.instance.clone(), + nonce: binding::opaque()?, + tool_name: binding::text(&redactor, &plan.tool, RedactMode::Text)?, + external_effect: true, + args_redacted: "{}".into(), + summary: "imported trust source item".into(), + created_at_ms: now, + expires_at_ms: now.checked_add(plan.lifetime_ms).ok_or(Error::Invalid)?, + state: if plan.active_exists { + ApprovalLifecycle2::Cancelled + } else { + ApprovalLifecycle2::Approved + }, + choice: Some(plan.choice), + attribution: Some(plan.attribution.into()), + decision_ticket_sha256: None, + outcome: None, + reservation_id: None, + legacy_history_json: None, + judge: None, + audit: request.audit, + }) +} + impl Engine { /// Record one stable host source item exactly once in the approval store. /// @@ -80,58 +202,8 @@ impl Engine { Self::correlation(&request.audit, reg.generation)?; let marker = source_marker(®, &request)?; let rows = self.rows(®).await?; - let (attribution, choice, tool, flow, grant_key, lifetime_ms, active_exists) = - match &request.item { - ImportTrustItem2::Tool { tool } => { - if !tool_trust::valid_tool(tool) { - return Err(Error::Invalid); - } - let key = tool_trust::trust_key(tool); - ( - tool_trust::IMPORT_ATTRIBUTION, - ApprovalChoice2::Tool, - tool.as_str(), - None, - key, - TOOL_TRUST_LIFETIME_MS2, - Engine::durable_tool_trust(®, &rows, tool, self.now()?), - ) - } - ImportTrustItem2::FlowTool { flow_id, tool } => { - if !Engine::trust_pair_valid(flow_id, tool) { - return Err(Error::Invalid); - } - let key = flow_trust::trust_key(flow_id, tool); - let flow_text = - binding::text(&self.redactor()?, flow_id, RedactMode::IdentifierHash)?; - let existing = Engine::durable_flow_trust( - ®, - &rows, - &EvaluateRequest { - caller: CallerContext { - flow: Some(flow_text), - ..Default::default() - }, - call: tinysecurity_bus::ToolCall { - tool: tool.clone(), - arguments_json: "{}".into(), - checks: Vec::new(), - }, - }, - self.now()?, - ); - ( - flow_trust::IMPORT_ATTRIBUTION, - ApprovalChoice2::Flow, - tool.as_str(), - Some(flow_id.as_str()), - key, - FLOW_TRUST_LIFETIME_MS2, - existing, - ) - } - }; - if let Some(existing) = marker_exists(&rows, &marker, attribution) { + let plan = import_plan(self, ®, &rows, &request.item)?; + if let Some(existing) = marker_exists(&rows, &marker, plan.attribution) { return Ok(ImportTrustReceipt2 { scope_id: request.scope_id, outcome: ImportTrustOutcome2::AlreadyImported, @@ -142,59 +214,11 @@ impl Engine { return Err(Error::Unavailable); } let now = self.now()?; - let redactor = self.redactor()?; - let flow = flow - .map(|flow_id| binding::text(&redactor, flow_id, RedactMode::IdentifierHash)) - .transpose()?; - let caller = CallerContext { - agent: binding::text(&redactor, ®.subject.agent, RedactMode::IdentifierHash)?, - thread: None, - flow, - call_id: binding::text(&redactor, &marker, RedactMode::IdentifierHash)?, - origin: Origin::Operator, - tier: reg.ceiling.clone(), - }; - let row = ApprovalRow2 { - request_id: binding::opaque()?, - version: 1, - binding: tinysecurity_bus::ApprovalBinding2 { - binding_version: 1, - scope: binding::scope_handle(®.subject)?, - policy_ref: binding::digest( - b"tinysecurity/approval-policy/v1\0", - reg.policy_ref.as_bytes(), - ), - generation: reg.generation, - call_sha256: marker, - content_sha256: grant_key, - origin: Origin::Operator, - caller, - }, - host_instance: self.instance.clone(), - nonce: binding::opaque()?, - tool_name: binding::text(&redactor, tool, RedactMode::Text)?, - external_effect: true, - args_redacted: "{}".into(), - summary: "imported trust source item".into(), - created_at_ms: now, - expires_at_ms: now.checked_add(lifetime_ms).ok_or(Error::Invalid)?, - state: if active_exists { - ApprovalLifecycle2::Cancelled - } else { - ApprovalLifecycle2::Approved - }, - choice: Some(choice), - attribution: Some(attribution.into()), - decision_ticket_sha256: None, - outcome: None, - reservation_id: None, - legacy_history_json: None, - judge: None, - audit: request.audit, - }; + let scope_id = request.scope_id.clone(); + let row = imported_row(self, ®, request, marker, plan, now)?; self.persist(None, &row).await?; Ok(ImportTrustReceipt2 { - scope_id: request.scope_id, + scope_id, outcome: ImportTrustOutcome2::Imported, request_id: row.request_id, }) From 1c7861978be695f684443955068281566c2458b1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:13:04 +0000 Subject: [PATCH 41/70] refactor(approval): extract raw deny check into helper The inline evaluation of the raw auto rule that returned Error::Denied has been moved into a reject_raw_deny helper so the same check can be reused elsewhere without duplicating the rule comparison. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 860f9868..29bd8154 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -486,10 +486,7 @@ impl Engine { let reg = self.registered(&entry); let (bound, args_redacted, external_effect) = binding::original(®, &request.request, &self.redactor()?)?; - let raw_rule = tinysecurity_auto::rules::evaluate(®, &bound.origin, external_effect); - if raw_rule == tinysecurity_auto::rules::Rule::Deny { - return Err(Error::Denied); - } + Self::reject_raw_deny(®, &bound.origin, external_effect)?; let now = self.now()?; let ttl = match bound.origin { Origin::Copilot | Origin::SubAgent => 180_000, @@ -603,6 +600,14 @@ impl Engine { let rule = Self::origin_rule(entry, reg, origin, request, external_effect, durable_tool)?; Ok((rows, rule)) } + fn reject_raw_deny(reg: &ApprovalScope2, origin: &Origin, external_effect: bool) -> Result<()> { + if tinysecurity_auto::rules::evaluate(reg, origin, external_effect) + == tinysecurity_auto::rules::Rule::Deny + { + return Err(Error::Denied); + } + Ok(()) + } /// The judge never qualifies a human-only tool, however the tool is named. fn judge_barred(tool: &str) -> bool { binding::is_native(tool) || HUMAN_ONLY_TOOLS2.contains(&tool) From 82577b78348db07b6f9431934d5a71fb6a8442c7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:13:35 +0000 Subject: [PATCH 42/70] refactor(approval): extract ttl resolution into begin_ttl helper The TTL clamping, native deadline check, and zero-TTL rejection that previously sat inline in the begin path now live in a dedicated begin_ttl method. This keeps the begin flow readable and gives the deadline logic a single named home without changing behaviour. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 32 ++++++++++++++-------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 29bd8154..2a820422 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -488,17 +488,7 @@ impl Engine { binding::original(®, &request.request, &self.redactor()?)?; Self::reject_raw_deny(®, &bound.origin, external_effect)?; let now = self.now()?; - let ttl = match bound.origin { - Origin::Copilot | Origin::SubAgent => 180_000, - _ => 600_000, - }; - let ttl = request.ttl_ms.unwrap_or(ttl).min(ttl); - if let Some(deadline) = deadline { - self.check_native_deadline(&bound, deadline, now, ttl)?; - } - if ttl == 0 { - return Err(Error::Denied); - } + let ttl = self.begin_ttl(&request, &bound, deadline, now)?; Self::correlation(&request.audit, reg.generation)?; let (rows, rule) = self .begin_rows_and_rule(&entry, ®, &request, &bound.origin, external_effect, now) @@ -608,6 +598,26 @@ impl Engine { } Ok(()) } + fn begin_ttl( + &self, + request: &EvaluateApprovalRequest2, + bound: &tinysecurity_bus::ApprovalBinding2, + deadline: Option, + now: u64, + ) -> Result { + let maximum = match bound.origin { + Origin::Copilot | Origin::SubAgent => 180_000, + _ => 600_000, + }; + let ttl = request.ttl_ms.unwrap_or(maximum).min(maximum); + if let Some(deadline) = deadline { + self.check_native_deadline(bound, deadline, now, ttl)?; + } + if ttl == 0 { + return Err(Error::Denied); + } + Ok(ttl) + } /// The judge never qualifies a human-only tool, however the tool is named. fn judge_barred(tool: &str) -> bool { binding::is_native(tool) || HUMAN_ONLY_TOOLS2.contains(&tool) From 9a0deebc71c926800e290d51acca5c302613fd74 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:14:09 +0000 Subject: [PATCH 43/70] test(module): split adapter gap test into helper functions The imported trust revocation test now delegates its setup, revocation, and reimport phases to separate async helpers, and the explicit grant check runs against the same restarted instance. This keeps the scenario readable while preserving the assertions about revocations surviving restarts and reimports. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_gaps_tests.rs | 133 ++++++++++-------- 1 file changed, 74 insertions(+), 59 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_gaps_tests.rs b/crates/tinysecurity-module/src/adapter_gaps_tests.rs index 14a32f7b..f9850a25 100644 --- a/crates/tinysecurity-module/src/adapter_gaps_tests.rs +++ b/crates/tinysecurity-module/src/adapter_gaps_tests.rs @@ -242,45 +242,58 @@ async fn imported_trust_stays_revoked_after_restart_and_reimport() -> TestResult let dir = tempfile::tempdir()?; let path = dir.path().join("ledger"); std::fs::write(&path, [])?; - let import = |source_id: &'static str, item: serde_json::Value, sequence: u64| { - serde_json::json!({ - "scope_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "source_id": source_id, - "source_version": "v1", - "item": item, - "audit": audit(sequence), - }) - }; let first = start(&path).await?; - let whole: serde_json::Value = first - .proxy - .call( - "ImportTrustSource2", - (import( - "legacy-autonomy-auto-approve", - serde_json::json!({"kind":"tool", "tool":"local_diagnostic"}), - 1, - ),), - ) - .await?; - assert_eq!(whole["outcome"], "imported"); - let flow: serde_json::Value = first - .proxy - .call( - "ImportTrustSource2", - (import( - "legacy-approval-db-flow-trust", - serde_json::json!({"kind":"flow_tool", "flow_id":"flow", "tool":"local_diagnostic"}), - 2, - ),), - ) - .await?; - assert_eq!(flow["outcome"], "imported"); + import_legacy_sources(&first.proxy).await?; first.broker.abort(); let second = start(&path).await?; - let revoked_tool: ToolTrustReceipt2 = second - .proxy + revoke_imported_sources(&second.proxy).await?; + second.broker.abort(); + + let third = start(&path).await?; + reimport_keeps_revocations(&third.proxy).await?; + explicit_grants_can_follow_revocation(&third.proxy).await?; + third.broker.abort(); + Ok(()) +} + +fn import_request(source_id: &str, item: serde_json::Value, sequence: u64) -> serde_json::Value { + serde_json::json!({ + "scope_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "source_id": source_id, + "source_version": "v1", + "item": item, + "audit": audit(sequence), + }) +} + +async fn import_legacy_sources(proxy: &tinybus::Proxy) -> TestResult { + let items = [ + ( + "legacy-autonomy-auto-approve", + serde_json::json!({"kind":"tool", "tool":"local_diagnostic"}), + 1, + ), + ( + "legacy-approval-db-flow-trust", + serde_json::json!({"kind":"flow_tool", "flow_id":"flow", "tool":"local_diagnostic"}), + 2, + ), + ]; + for (source, item, sequence) in items { + let receipt: serde_json::Value = proxy + .call( + "ImportTrustSource2", + (import_request(source, item, sequence),), + ) + .await?; + assert_eq!(receipt["outcome"], "imported"); + } + Ok(()) +} + +async fn revoke_imported_sources(proxy: &tinybus::Proxy) -> TestResult { + let tool: ToolTrustReceipt2 = proxy .call( names::methods::REVOKE_TOOL2, (RevokeToolRequest2 { @@ -290,9 +303,8 @@ async fn imported_trust_stays_revoked_after_restart_and_reimport() -> TestResult },), ) .await?; - assert_eq!(revoked_tool.outcome, ToolTrustOutcome2::Revoked); - let revoked_flow: FlowTrustReceipt2 = second - .proxy + assert_eq!(tool.outcome, ToolTrustOutcome2::Revoked); + let flow: FlowTrustReceipt2 = proxy .call( names::methods::REVOKE_FLOW_TOOL2, (RevokeFlowToolRequest2 { @@ -303,11 +315,12 @@ async fn imported_trust_stays_revoked_after_restart_and_reimport() -> TestResult },), ) .await?; - assert_eq!(revoked_flow.outcome, FlowTrustOutcome2::Revoked); - second.broker.abort(); + assert_eq!(flow.outcome, FlowTrustOutcome2::Revoked); + Ok(()) +} - let third = start(&path).await?; - for (source_id, item, sequence) in [ +async fn reimport_keeps_revocations(proxy: &tinybus::Proxy) -> TestResult { + let items = [ ( "legacy-autonomy-auto-approve", serde_json::json!({"kind":"tool", "tool":"local_diagnostic"}), @@ -318,15 +331,17 @@ async fn imported_trust_stays_revoked_after_restart_and_reimport() -> TestResult serde_json::json!({"kind":"flow_tool", "flow_id":"flow", "tool":"local_diagnostic"}), 6, ), - ] { - let receipt: serde_json::Value = third - .proxy - .call("ImportTrustSource2", (import(source_id, item, sequence),)) + ]; + for (source, item, sequence) in items { + let receipt: serde_json::Value = proxy + .call( + "ImportTrustSource2", + (import_request(source, item, sequence),), + ) .await?; assert_eq!(receipt["outcome"], "already_imported"); } - let tools: ToolTrustList2 = third - .proxy + let tools: ToolTrustList2 = proxy .call( names::methods::LIST_TOOL_TRUST2, (ListToolTrustRequest2 { @@ -334,9 +349,7 @@ async fn imported_trust_stays_revoked_after_restart_and_reimport() -> TestResult },), ) .await?; - assert!(tools.entries.is_empty()); - let flows: FlowTrustList2 = third - .proxy + let flows: FlowTrustList2 = proxy .call( names::methods::LIST_FLOW_TRUST2, (ListFlowTrustRequest2 { @@ -345,9 +358,13 @@ async fn imported_trust_stays_revoked_after_restart_and_reimport() -> TestResult },), ) .await?; - assert!(flows.entries.is_empty()); - let explicit_tool: ToolTrustReceipt2 = third - .proxy + assert_eq!(tools.entries.len(), 0); + assert_eq!(flows.entries.len(), 0); + Ok(()) +} + +async fn explicit_grants_can_follow_revocation(proxy: &tinybus::Proxy) -> TestResult { + let tool: ToolTrustReceipt2 = proxy .call( names::methods::GRANT_TOOL2, (GrantToolRequest2 { @@ -357,9 +374,8 @@ async fn imported_trust_stays_revoked_after_restart_and_reimport() -> TestResult },), ) .await?; - assert_eq!(explicit_tool.outcome, ToolTrustOutcome2::Granted); - let explicit_flow: FlowTrustReceipt2 = third - .proxy + assert_eq!(tool.outcome, ToolTrustOutcome2::Granted); + let flow: FlowTrustReceipt2 = proxy .call( names::methods::GRANT_FLOW_TOOL2, (GrantFlowToolRequest2 { @@ -370,8 +386,7 @@ async fn imported_trust_stays_revoked_after_restart_and_reimport() -> TestResult },), ) .await?; - assert_eq!(explicit_flow.outcome, FlowTrustOutcome2::Granted); - third.broker.abort(); + assert_eq!(flow.outcome, FlowTrustOutcome2::Granted); Ok(()) } From 73dc7f30eda241462e16705972f594f0631abcab Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:14:40 +0000 Subject: [PATCH 44/70] test(tinysecurity-module): tighten adapter gap assertions and borrow import items Replace an is_empty assertion with an explicit length check and take the imported item by reference in the import_request helper, avoiding a move at each call site. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter_gaps_tests.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_gaps_tests.rs b/crates/tinysecurity-module/src/adapter_gaps_tests.rs index f9850a25..d089c6e2 100644 --- a/crates/tinysecurity-module/src/adapter_gaps_tests.rs +++ b/crates/tinysecurity-module/src/adapter_gaps_tests.rs @@ -232,7 +232,7 @@ async fn whole_tool_trust_survives_a_module_restart_and_is_revoked_before_consum },), ) .await?; - assert!(listed.entries.is_empty()); + assert_eq!(listed.entries.len(), 0); second.broker.abort(); Ok(()) } @@ -257,7 +257,7 @@ async fn imported_trust_stays_revoked_after_restart_and_reimport() -> TestResult Ok(()) } -fn import_request(source_id: &str, item: serde_json::Value, sequence: u64) -> serde_json::Value { +fn import_request(source_id: &str, item: &serde_json::Value, sequence: u64) -> serde_json::Value { serde_json::json!({ "scope_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "source_id": source_id, @@ -284,7 +284,7 @@ async fn import_legacy_sources(proxy: &tinybus::Proxy) -> TestResult { let receipt: serde_json::Value = proxy .call( "ImportTrustSource2", - (import_request(source, item, sequence),), + (import_request(source, &item, sequence),), ) .await?; assert_eq!(receipt["outcome"], "imported"); @@ -336,7 +336,7 @@ async fn reimport_keeps_revocations(proxy: &tinybus::Proxy) -> TestResult { let receipt: serde_json::Value = proxy .call( "ImportTrustSource2", - (import_request(source, item, sequence),), + (import_request(source, &item, sequence),), ) .await?; assert_eq!(receipt["outcome"], "already_imported"); From 57c70c04f9e867de78b5f0099dea22240a45998f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:14:51 +0000 Subject: [PATCH 45/70] test(approval): simplify assertions and drop debug logging Replace is_empty assertions with length comparisons and remove the eprintln error wrappers that only logged and re-raised failures. The tests now assert the same conditions with less noise. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_gaps_tests.rs | 48 ++++++------------- .../tinysecurity-approval/src/engine_tests.rs | 2 +- 2 files changed, 16 insertions(+), 34 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index dd07ee0c..acae636e 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -510,18 +510,11 @@ async fn whole_tool_trust_survives_restart_and_is_exactly_scoped() -> Result<()> let clock = Arc::new(TestClock::default()); clock.wall.store(1000, Ordering::SeqCst); let first = engine(fixture.clone(), clock.clone())?; - first.register_scope(register(7)).await.map_err(|error| { - eprintln!("register scope 7: {error:?}"); - error - })?; + first.register_scope(register(7)).await?; first.register_scope(register(8)).await?; let granted = first .grant_tool(tool_grant(7, "shell", 1)) - .await - .map_err(|error| { - eprintln!("grant shell: {error:?}"); - error - })?; + .await?; assert_eq!(granted.outcome, ToolTrustOutcome2::Granted); assert!(granted.request_id.is_some()); let repeated = first.grant_tool(tool_grant(7, "shell", 2)).await?; @@ -570,10 +563,7 @@ async fn whole_tool_revocation_before_consume_revokes_queued_automatic_approval( owner.grant_tool(tool_grant(7, "shell", 1)).await?; let mut request = shell(7, None, Origin::Operator, "queued-call"); request.audit = at(2); - let row = owner.begin(request.clone()).await.map_err(|error| { - eprintln!("begin granted call: {error:?}"); - error - })?; + let row = owner.begin(request.clone()).await?; assert_eq!(row.attribution.as_deref(), Some("auto:tool-grant")); owner.revoke_tool(tool_revoke(7, "shell", 3)).await?; @@ -627,12 +617,9 @@ async fn whole_tool_grant_fails_closed_when_the_store_cannot_persist() -> Result owner.grant_tool(tool_grant(7, "shell", 1)).await.err(), Some(Error::Unavailable) ); - assert!( - owner - .list_tool_trust(tool_listing(7)) - .await? - .entries - .is_empty() + assert_eq!( + owner.list_tool_trust(tool_listing(7)).await?.entries.len(), + 0 ); Ok(()) } @@ -663,12 +650,9 @@ async fn whole_tool_trust_rejects_invalid_names_and_expires_with_its_generation( ); owner.advance_generation(2)?; - assert!( - owner - .list_tool_trust(tool_listing(7)) - .await? - .entries - .is_empty() + assert_eq!( + owner.list_tool_trust(tool_listing(7)).await?.entries.len(), + 0 ); let mut request = shell(7, None, Origin::Operator, "new-generation"); request.audit = AuditCorrelation2 { @@ -793,12 +777,13 @@ async fn expired_import_receipts_do_not_recreate_legacy_trust() -> Result<()> { .await?; assert_eq!(repeated.outcome, ImportTrustOutcome2::AlreadyImported); assert_eq!(repeated.request_id, imported.request_id); - assert!( + assert_eq!( restarted .list_tool_trust(tool_listing(7)) .await? .entries - .is_empty() + .len(), + 0 ); Ok(()) } @@ -887,12 +872,9 @@ async fn source_import_validation_and_store_errors_fail_closed() -> Result<()> { Some(Error::Unavailable) ); fixture.fail.store(0, Ordering::SeqCst); - assert!( - owner - .list_tool_trust(tool_listing(7)) - .await? - .entries - .is_empty() + assert_eq!( + owner.list_tool_trust(tool_listing(7)).await?.entries.len(), + 0 ); Ok(()) } diff --git a/crates/tinysecurity-approval/src/engine_tests.rs b/crates/tinysecurity-approval/src/engine_tests.rs index a2e72cc3..3056e081 100644 --- a/crates/tinysecurity-approval/src/engine_tests.rs +++ b/crates/tinysecurity-approval/src/engine_tests.rs @@ -778,7 +778,7 @@ async fn wrong_registration_agent_access_ceiling_and_unverified_human_inputs_den Arc::new(TestClock::default()), Redactor::new(&RedactorConfig::default()).map_err(|_| Error::Invalid)?, )?; - assert!(other.list(&request().scope_id).await?.is_empty()); + assert_eq!(other.list(&request().scope_id).await?.len(), 0); let f = Arc::new(Fixture::default()); let mut ceiling = config(); ceiling.scopes[0].ceiling = AccessTier::Read; From e84472934b16c44184c2a7c166dd22f647cb00dc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:15:02 +0000 Subject: [PATCH 46/70] test(approval): reformat grant_tool call in engine gaps test Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_gaps_tests.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index acae636e..1ba5044e 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -512,9 +512,7 @@ async fn whole_tool_trust_survives_restart_and_is_exactly_scoped() -> Result<()> let first = engine(fixture.clone(), clock.clone())?; first.register_scope(register(7)).await?; first.register_scope(register(8)).await?; - let granted = first - .grant_tool(tool_grant(7, "shell", 1)) - .await?; + let granted = first.grant_tool(tool_grant(7, "shell", 1)).await?; assert_eq!(granted.outcome, ToolTrustOutcome2::Granted); assert!(granted.request_id.is_some()); let repeated = first.grant_tool(tool_grant(7, "shell", 2)).await?; From 8cc6a77becc09f5025094bb1c88de49127613ba8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:15:17 +0000 Subject: [PATCH 47/70] test(tool-trust): compare serialized payloads without unwrapping The wire-shape assertions now compare the serialized value as an Option against Some(json!(...)) instead of unwrapping, so a serialization failure surfaces as a mismatch rather than a panic. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/tool_trust_tests.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/tinysecurity-bus/src/tool_trust_tests.rs b/crates/tinysecurity-bus/src/tool_trust_tests.rs index 640e2da6..911e0950 100644 --- a/crates/tinysecurity-bus/src/tool_trust_tests.rs +++ b/crates/tinysecurity-bus/src/tool_trust_tests.rs @@ -13,8 +13,8 @@ fn whole_tool_trust_payloads_have_stable_wire_shapes() { }, }; assert_eq!( - serde_json::to_value(request).unwrap(), - json!({ + serde_json::to_value(request).ok(), + Some(json!({ "scope_id": "scope", "tool": "shell", "audit": { @@ -22,7 +22,7 @@ fn whole_tool_trust_payloads_have_stable_wire_shapes() { "expected_tail": 4, "generation": 7 } - }) + })) ); assert_eq!( serde_json::to_value(ToolTrustReceipt2 { @@ -30,8 +30,8 @@ fn whole_tool_trust_payloads_have_stable_wire_shapes() { outcome: ToolTrustOutcome2::Unchanged, request_id: Some("request".into()), }) - .unwrap(), - json!({"scope_id": "scope", "outcome": "unchanged", "request_id": "request"}) + .ok(), + Some(json!({"scope_id": "scope", "outcome": "unchanged", "request_id": "request"})) ); } From 34798a0bdef0f1f658bb9dcd10c5da4845978630 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:20:45 +0000 Subject: [PATCH 48/70] test(approval): cover scope row cap on regrant after revoke Rework the revoked-pair regrant test to assert that regranting a revoked flow tool fails once the scope is at its row cap, and add a matching test for revoked tool grants. Both verify the row count stays at the cap after the failed regrant. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_gaps_tests.rs | 57 ++++++++++++++++--- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 1ba5044e..4049f021 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -1003,27 +1003,66 @@ async fn a_full_scope_takes_no_new_flow_trust_but_still_answers_for_a_live_one() } #[tokio::test] -async fn a_revoked_pair_can_be_granted_again_even_on_a_full_scope() -> Result<()> { - let (_, owner) = owner()?; +async fn a_revoked_pair_cannot_cross_the_scope_row_cap_when_regranted() -> Result<()> { + let (fixture, owner) = owner()?; owner.register_scope(register(7)).await?; for n in 0..960u64 { owner .grant_flow_tool(grant(7, &format!("flow-{n}"), "shell", n + 1)) .await?; } + assert_eq!( + fixture.rows.lock().map_err(|_| Error::Unavailable)?.len(), + 960 + ); let revoked = owner .revoke_flow_tool(revoke(7, "flow-0", "shell", 961)) .await?; assert_eq!(revoked.outcome, FlowTrustOutcome2::Revoked); - let again = owner - .grant_flow_tool(grant(7, "flow-0", "shell", 962)) - .await?; - assert_eq!(again.outcome, FlowTrustOutcome2::Granted); - assert!( + assert_eq!( + fixture.rows.lock().map_err(|_| Error::Unavailable)?.len(), + 960 + ); + assert_eq!( owner - .grant_flow_tool(grant(7, "brand-new", "shell", 963)) + .grant_flow_tool(grant(7, "flow-0", "shell", 962)) .await - .is_err() + .err(), + Some(Error::Unavailable) + ); + assert_eq!( + fixture.rows.lock().map_err(|_| Error::Unavailable)?.len(), + 960 + ); + Ok(()) +} + +#[tokio::test] +async fn a_revoked_tool_cannot_cross_the_scope_row_cap_when_regranted() -> Result<()> { + let (fixture, owner) = owner()?; + owner.register_scope(register(7)).await?; + for n in 0..960u64 { + owner + .grant_tool(tool_grant(7, &format!("tool-{n}"), n + 1)) + .await?; + } + assert_eq!( + fixture.rows.lock().map_err(|_| Error::Unavailable)?.len(), + 960 + ); + let revoked = owner.revoke_tool(tool_revoke(7, "tool-0", 961)).await?; + assert_eq!(revoked.outcome, ToolTrustOutcome2::Revoked); + assert_eq!( + fixture.rows.lock().map_err(|_| Error::Unavailable)?.len(), + 960 + ); + assert_eq!( + owner.grant_tool(tool_grant(7, "tool-0", 962)).await.err(), + Some(Error::Unavailable) + ); + assert_eq!( + fixture.rows.lock().map_err(|_| Error::Unavailable)?.len(), + 960 ); Ok(()) } From 955b6df60bf08901cfa513363fe15dcdccec0ec3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:21:52 +0000 Subject: [PATCH 49/70] fix(approval): reject reissue when scope row limit is reached Revoked and expired grants still occupy durable rows, so reissuing them appended another row and could push a scope past the module's load bound. The known-pair exemption is removed so the row cap is enforced uniformly. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_flow_trust.rs | 15 +++------------ .../src/engine_tool_trust.rs | 11 +++-------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_flow_trust.rs b/crates/tinysecurity-approval/src/engine_flow_trust.rs index 8997644d..b4e09e5b 100644 --- a/crates/tinysecurity-approval/src/engine_flow_trust.rs +++ b/crates/tinysecurity-approval/src/engine_flow_trust.rs @@ -125,18 +125,9 @@ impl Engine { request_id: Some(existing.request_id.clone()), }); } - // A scope's whole row set must stay loadable (`ApprovalLoadRequest2` caps it), - // so a long-lived grant never takes the last slot of a full scope. - // A pair that already has a record (revoked or expired) renews without - // pushing the scope far past its margin, so it is never locked out. - let known = rows.iter().any(|row| { - matches!( - row.attribution.as_deref(), - Some(ATTRIBUTION | IMPORT_ATTRIBUTION) - ) && row.binding.content_sha256 == key - && row.tool_name == request.tool - }); - if rows.len() >= MAX_SCOPE_ROWS && !known { + // Revoked and expired grants still occupy durable rows. Reissuing them would + // append another row and eventually exceed the module's load bound. + if rows.len() >= MAX_SCOPE_ROWS { return Err(Error::Unavailable); } let redactor = self.redactor()?; diff --git a/crates/tinysecurity-approval/src/engine_tool_trust.rs b/crates/tinysecurity-approval/src/engine_tool_trust.rs index 92636de0..b1bfe8c5 100644 --- a/crates/tinysecurity-approval/src/engine_tool_trust.rs +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -102,14 +102,9 @@ impl Engine { request_id: Some(existing.request_id.clone()), }); } - let known = rows.iter().any(|row| { - matches!( - row.attribution.as_deref(), - Some(ATTRIBUTION | IMPORT_ATTRIBUTION) - ) && row.binding.content_sha256 == key - && row.tool_name == request.tool - }); - if rows.len() >= MAX_SCOPE_ROWS && !known { + // Revoked and expired grants still occupy durable rows. Reissuing them would + // append another row and eventually exceed the module's load bound. + if rows.len() >= MAX_SCOPE_ROWS { return Err(Error::Unavailable); } let redactor = self.redactor()?; From 4ad8adeadceebcaeefab46bdaf3afea5993cecbe Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:29:06 +0000 Subject: [PATCH 50/70] test(approval): cover retry after audit failure on imported source Add a test asserting that retrying an import whose row was already committed but whose audit write failed reconciles the receipt and reports the source as already imported. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_gaps_tests.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 4049f021..4c1df35a 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -786,6 +786,43 @@ async fn expired_import_receipts_do_not_recreate_legacy_trust() -> Result<()> { Ok(()) } +#[tokio::test] +async fn imported_source_retry_reconciles_a_committed_row_after_audit_failure() -> Result<()> { + let (fixture, owner) = owner()?; + owner.register_scope(register(7)).await?; + let request = import_tool_source(7, "legacy-autonomy-auto-approve", "shell", 1); + + fixture.fail.store(2, Ordering::SeqCst); + assert_eq!( + owner.import_trust_source(request.clone()).await.err(), + Some(Error::Unavailable) + ); + assert_eq!( + fixture.rows.lock().map_err(|_| Error::Unavailable)?.len(), + 1 + ); + assert!( + fixture + .audit_receipts + .lock() + .map_err(|_| Error::Unavailable)? + .is_empty() + ); + + fixture.fail.store(0, Ordering::SeqCst); + let retry = owner.import_trust_source(request).await?; + assert_eq!(retry.outcome, ImportTrustOutcome2::AlreadyImported); + assert_eq!( + fixture + .audit_receipts + .lock() + .map_err(|_| Error::Unavailable)? + .len(), + 1 + ); + Ok(()) +} + #[tokio::test] async fn source_import_receipts_are_bound_to_subject_version_and_exact_item() -> Result<()> { let (_, owner) = owner()?; From 2738138b959b8d0214fa275e36ae7e19c6984668 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:29:35 +0000 Subject: [PATCH 51/70] fix(approval): reconcile audit row for already-imported trust markers When a trust marker already exists, the engine now re-emits the audit acknowledgement for that durable row before returning AlreadyImported. A prior CAS may have committed while its audit acknowledgement was lost, so reconciling on retry keeps the audit log consistent with stored state. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_import_trust.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/tinysecurity-approval/src/engine_import_trust.rs b/crates/tinysecurity-approval/src/engine_import_trust.rs index 03cc9871..05cf2805 100644 --- a/crates/tinysecurity-approval/src/engine_import_trust.rs +++ b/crates/tinysecurity-approval/src/engine_import_trust.rs @@ -204,6 +204,9 @@ impl Engine { let rows = self.rows(®).await?; let plan = import_plan(self, ®, &rows, &request.item)?; if let Some(existing) = marker_exists(&rows, &marker, plan.attribution) { + // A prior CAS may have committed while its audit acknowledgement was + // lost. Reconcile that exact durable row before acknowledging the retry. + self.audit_row(existing.clone()).await?; return Ok(ImportTrustReceipt2 { scope_id: request.scope_id, outcome: ImportTrustOutcome2::AlreadyImported, From 86bde7d37d0e805c9d05a1a623f3a43efa158910 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:29:57 +0000 Subject: [PATCH 52/70] docs: renumber durable tool trust contract to 1.26 The durable whole-tool trust contract was renumbered from 1.25 to 1.26 across the module docs, README and spec, and the flow trust lifetime note now records that renewal of an expired row requires the scope to have capacity below 960 rows. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- MODULE.md | 7 ++++--- README.md | 2 +- crates/tinysecurity-bus/src/device_frame_tests.rs | 2 +- crates/tinysecurity-bus/src/device_pairing_tests.rs | 2 +- crates/tinysecurity-bus/src/lib.rs | 2 +- crates/tinysecurity-bus/src/names.rs | 6 +++--- crates/tinysecurity-bus/src/tool_trust.rs | 2 +- crates/tinysecurity-bus/tests/scoped_wire.rs | 7 ++++--- crates/tinysecurity-bus/tests/wire.rs | 2 +- docs/specs/durable-tool-trust.md | 7 ++++--- 10 files changed, 21 insertions(+), 18 deletions(-) diff --git a/MODULE.md b/MODULE.md index ca5134d4..b68a4685 100644 --- a/MODULE.md +++ b/MODULE.md @@ -339,7 +339,7 @@ cannot bind the call answers an error, never an allow. Latency budgets are per c -## Approval origin classes, durable trust and scored prompt screening (contracts 1.22 and 1.25) +## Approval origin classes, durable trust and scored prompt screening (contracts 1.22 and 1.26) Additive: every new member and enum value is absent from a 1.21 host's traffic, and a 1.22 host refuses a 1.21 module. @@ -378,7 +378,8 @@ automatic one attributed `auto:flow-grant`. Grants and revocations of a scope are serialized, so one pair has one live row. - The row is bound to the scope's subject, policy reference and generation, like every durable grant: advancing the generation or replacing the policy reference makes it stale. It lives ten - years (`FLOW_TRUST_LIFETIME_MS2`); a later grant of the same pair renews an expired row. + years (`FLOW_TRUST_LIFETIME_MS2`); a later grant of the same pair renews an expired row only + while the scope has capacity below 960 rows. - A human-only or native tool, a blank or control-character name and an oversized name are refused. A trust never overrides a denial, a Cron external effect or a human-only tool. An approval it produced is re-checked at consumption, so revoking the trust first revokes it. @@ -387,7 +388,7 @@ automatic one attributed `auto:flow-grant`. - Latency budget: one `Load` plus one `Cas` and one audit append, so the host's store latency plus about 1 ms. `ListFlowTrust2` is one `Load`. -Contract 1.25 adds durable whole-tool trust through `GrantTool2`, `RevokeTool2` and +Contract 1.26 adds durable whole-tool trust through `GrantTool2`, `RevokeTool2` and `ListToolTrust2`. Its Standard-scope eligibility and lifecycle rules are in [`docs/specs/durable-tool-trust.md`](docs/specs/durable-tool-trust.md). The private host-only `ImportTrustSource2` records one durable source/item receipt diff --git a/README.md b/README.md index 492bddf7..91f7a978 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ never grant permission, and redaction never clears an existing scan finding. Contract 1.22 adds policy and egress gap members: `CheckPaths2` (batch path checks), `ProtectedPath2` and `ProtectedFloor2` (the unconditional lexical floor and its vocabulary), `SpendAction2` and `PeekBudget2` (the hourly action ledger outside the harness), `ClassifyHostAction2` (the informational class of a host tool) and `PrivacyCheck2` (a descriptor-level privacy decision) (see [MODULE.md](MODULE.md#policy-and-egress-gap-members-contract-122)). Contract 1.22 adds `SandboxPlanRequest2.options` (an executor preference, a workspace-less plan and the operator's planned run on the host, never for `SaaS` or an untrusted source) and the artifact flags `host_run` and `no_workspace` (see [MODULE.md](MODULE.md#executor-choice-workspace-less-plans-and-the-operators-host-run-contract-122)). Contract 1.22 also adds the approval origin classes `TrustedAutomation`, `LocalOperator` and `Unlabelled`, durable flow trust (`GrantFlowTool2`, `RevokeFlowTool2`, `ListFlowTrust2`) and scored prompt screening (`ScanPrompt2`) (see [MODULE.md](MODULE.md#approval-origin-classes-durable-flow-trust-and-scored-prompt-screening-contract-122)). Contract 1.21 adds `RecordAudit2` and `AuditKind2::PolicyDecision`, so the host appends execution and no-prompt policy-decision events to a real subject's audit stream (see [MODULE.md](MODULE.md#host-recorded-audit-events-contract-121)), `SandboxScope2.operator_host_network` (host network granted by the owner's configuration, never under `SaaS`) and `SandboxPlanRequest2.accept_process_isolation` (a best-effort process jail may run an ordinary operator command, never untrusted code) (see [MODULE.md](MODULE.md#operator-host-network-and-process-level-jails-contract-121)). Contract 1.20 adds host-tool admission: `Check::HostAction` judges a host tool by its permission level, the caller's tier and origin and a registered command scope's autonomy and hourly budget; `Check::HostShell` gates a shell command under that scope's class gate, hidden-execution defence and budget without reserving an action. Refusals are typed (`read_only_mode`, `rate_limited`, `scheduled_read_only`, `high_risk`) (see [MODULE.md](MODULE.md#host-tool-admission-contract-120)). Contract 1.19 adds runtime sandbox scopes (`RegisterSandboxScope2`, `RetireSandboxScope2`), so a host that serves unbounded subjects can plan sandboxes per subject (see [MODULE.md](MODULE.md#runtime-sandbox-scopes-contract-119)). Contract 1.18 returns the sanitized subject handle in `ApprovalScopeReceipt2` and keeps the path, command and egress members available when approvals are configured (see [MODULE.md](MODULE.md#subject-handles-and-unbound-members-contract-118)). Contract 1.17 adds runtime approval scopes (`RegisterApprovalScope2`, `ReplaceApprovalScope2`, `RetireApprovalScope2`), tool and flow grants, and `Check::HostTool` (see [MODULE.md](MODULE.md#runtime-approval-scopes-and-host-tools-contract-117)). Contract 1.16 adds `RedactWith`, redaction with exact credentials supplied for one call (see [MODULE.md](MODULE.md#per-call-exact-credentials-redactwith-contract-116)). Contract 1.15 adds `RedactMode::CredentialsOnly` and the `EgressPolicy` fields `open_public` and `preflight_transports` (see [MODULE.md](MODULE.md#credentials-only-redaction-and-open-public-egress-contract-115)). Contract 1.14 adds confidential `PasswordCrypt2`, `KeyringAccess2` and `PairingControl2` (see [MODULE.md](MODULE.md)). Contract 1.13 added `EvaluateScopedAudited` and `CheckScopedAudited` for registered policies under required audit (see [`docs/specs/scoped-policies.md`](docs/specs/scoped-policies.md)). -Contract 1.25 adds durable whole-tool trust (`GrantTool2`, `RevokeTool2` and +Contract 1.26 adds durable whole-tool trust (`GrantTool2`, `RevokeTool2` and `ListToolTrust2`), persisted and audited through the approval store. See the [whole-tool trust spec](docs/specs/durable-tool-trust.md). The private `ImportTrustSource2` host member adds import-once receipts for legacy whole-tool diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index 6196d0e8..62383d98 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, 25)); + assert_eq!(crate::CONTRACT_VERSION, (1, 26)); } diff --git a/crates/tinysecurity-bus/src/device_pairing_tests.rs b/crates/tinysecurity-bus/src/device_pairing_tests.rs index 4bb0275c..4a1e12ff 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, 25)); + assert_eq!(CONTRACT_VERSION, (1, 26)); } #[test] diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index ffe25492..aae09d0f 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, 25); +pub const CONTRACT_VERSION: (u32, u32) = (1, 26); /// Whether a remote module's advertised version supplies this host's vocabulary. /// /// `version` is the provider version received by the host, not a host requirement diff --git a/crates/tinysecurity-bus/src/names.rs b/crates/tinysecurity-bus/src/names.rs index f5aa2c05..a61cb852 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -51,11 +51,11 @@ pub mod methods { pub const REVOKE_FLOW_TOOL2: &str = "RevokeFlowTool2"; /// Read the live durable flow trust of one scope (contract 1.22). pub const LIST_FLOW_TRUST2: &str = "ListFlowTrust2"; - /// Durably trust one exact whole tool in a registered scope (contract 1.25). + /// Durably trust one exact whole tool in a registered scope (contract 1.26). pub const GRANT_TOOL2: &str = "GrantTool2"; - /// Withdraw durable trust of one exact whole tool (contract 1.25). + /// Withdraw durable trust of one exact whole tool (contract 1.26). pub const REVOKE_TOOL2: &str = "RevokeTool2"; - /// Read the live durable whole-tool trust of one scope (contract 1.25). + /// Read the live durable whole-tool trust of one scope (contract 1.26). pub const LIST_TOOL_TRUST2: &str = "ListToolTrust2"; /// Host-only import-once receipt for a legacy tool or flow-tool trust item. pub const IMPORT_TRUST_SOURCE2: &str = "ImportTrustSource2"; diff --git a/crates/tinysecurity-bus/src/tool_trust.rs b/crates/tinysecurity-bus/src/tool_trust.rs index 89b2fa25..1e8ce9db 100644 --- a/crates/tinysecurity-bus/src/tool_trust.rs +++ b/crates/tinysecurity-bus/src/tool_trust.rs @@ -1,4 +1,4 @@ -//! Durable whole-tool trust and imported-source receipts (contract 1.25). +//! Durable whole-tool trust and imported-source receipts (contract 1.26). //! //! A host can grant, revoke and list an exact tool grant through the authenticated //! approval interface. Grants live as audited approval-store rows under one diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index 6712c680..292e280d 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -83,9 +83,10 @@ fn new_members_are_advertised_and_named_by_contract_constants() { #[test] fn contract_bump_is_additive_for_older_hosts_and_modules() { - assert_eq!(CONTRACT_VERSION, (1, 25)); - // A host built against 1.25 accepts this module and newer minors, not a 1.24 module. - assert!(is_compatible((1, 25))); + assert_eq!(CONTRACT_VERSION, (1, 26)); + // A host built against 1.26 accepts this module and newer minors, not a 1.25 module. + assert!(is_compatible((1, 26))); + assert!(!is_compatible((1, 25))); assert!(!is_compatible((1, 24))); assert!(!is_compatible((1, 23))); assert!(!is_compatible((1, 22))); diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index c81bd663..bf9d062e 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, 25)); + assert_eq!(CONTRACT_VERSION, (1, 26)); for (mode, wire) in [ (RedactMode::Text, "text"), (RedactMode::StructuredArguments, "structured_arguments"), diff --git a/docs/specs/durable-tool-trust.md b/docs/specs/durable-tool-trust.md index de9de86f..00c9cd6b 100644 --- a/docs/specs/durable-tool-trust.md +++ b/docs/specs/durable-tool-trust.md @@ -1,6 +1,6 @@ # Durable whole-tool trust -Contract 1.25 gives the authenticated host three approval members: +Contract 1.26 gives the authenticated host three approval members: `GrantTool2`, `RevokeTool2` and `ListToolTrust2`. The private host-only `ImportTrustSource2` member records legacy migration receipts. Each request is bound to a registered `scope_id`; tool names are exact and never pattern-matched. The host @@ -16,8 +16,9 @@ reviewed decision. Granting a live tool again returns `unchanged`; revoking a missing or expired grant returns `absent`. Both operations are serialized per scope, and a scope -with at least 960 approval rows refuses a new grant unless it renews a known -tool row. Listing returns only live grants, ordered by exact tool name. Names +with at least 960 approval rows refuses any new grant row, including a renewed +revoked or expired tool. A still-live repeat remains `unchanged` without adding +a row. Listing returns only live grants, ordered by exact tool name. Names must be nonblank, contain no control characters, fit within 256 bytes, and must not name a human-only or `native_*` tool. From 334a1647b669a45b22214f96a3921e3a4d3932a8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:38:23 +0000 Subject: [PATCH 53/70] chore(deps): bump tinybus to 0.1.6 and update lockfile Update the vendored tinybus submodule to 0.1.6, which pulls in zip 9.0.5 and its new nix and typed-path dependencies while dropping arbitrary, crossbeam-utils and zopfli. The lockfile is regenerated to match. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- Cargo.lock | 71 ++++++++++++++++++-------------------------------- vendor/tinybus | 2 +- 2 files changed, 26 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9f48b950..991ecf33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,15 +54,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "arbitrary" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "argon2" version = "0.5.3" @@ -334,12 +325,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-utils" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" - [[package]] name = "crypto-common" version = "0.1.7" @@ -386,17 +371,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "derive_arbitrary" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - [[package]] name = "digest" version = "0.10.7" @@ -996,6 +970,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1626,7 +1612,7 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.1.3" +version = "0.1.6" dependencies = [ "async-trait", "flate2", @@ -1647,7 +1633,7 @@ dependencies = [ [[package]] name = "tinybus-macros" -version = "0.1.3" +version = "0.1.6" dependencies = [ "proc-macro2", "quote", @@ -1656,7 +1642,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.1.3" +version = "0.1.6" dependencies = [ "async-trait", "serde", @@ -2083,6 +2069,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" + [[package]] name = "typenum" version = "1.20.1" @@ -2648,17 +2640,16 @@ dependencies = [ [[package]] name = "zip" -version = "2.6.1" +version = "9.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ - "arbitrary", "crc32fast", - "crossbeam-utils", "flate2", "indexmap", "memchr", - "zopfli", + "nix", + "typed-path", ] [[package]] @@ -2666,15 +2657,3 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] diff --git a/vendor/tinybus b/vendor/tinybus index 943e2921..0c98b57c 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 943e2921dce64b9ed7df2ae50bf9ac7bea9ccdb0 +Subproject commit 0c98b57c91cb47c65d7f93f7c950b0f3ca5673b4 From ae2b287ce9f5c6e3c65b7be6368bbbae9eefbc64 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:41:33 +0000 Subject: [PATCH 54/70] test(approval): cover redacted tool labels in exact grant identity Add a test asserting that a tool whose label is redacted still matches its exact grant, so listing shows the redacted name while approval and revocation continue to key off the original identity. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_gaps_tests.rs | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 4c1df35a..4f7de4e2 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -3,7 +3,7 @@ use super::*; use tinysecurity_bus::{ FlowTrustOutcome2, GrantFlowToolRequest2, GrantToolRequest2, ImportTrustItem2, ImportTrustOutcome2, ImportTrustSourceRequest2, ListFlowTrustRequest2, ListToolTrustRequest2, - RevokeFlowToolRequest2, RevokeToolRequest2, ToolTrustOutcome2, + RedactorConfig, RevokeFlowToolRequest2, RevokeToolRequest2, Secret, ToolTrustOutcome2, }; fn shell(n: u64, flow: Option<&str>, origin: Origin, call: &str) -> EvaluateApprovalRequest2 { @@ -554,6 +554,52 @@ async fn whole_tool_trust_survives_restart_and_is_exactly_scoped() -> Result<()> Ok(()) } +#[tokio::test] +async fn redacted_tool_labels_do_not_break_exact_grant_identity() -> Result<()> { + let tool = "private-tool-label"; + let mut redactor = RedactorConfig::default(); + redactor.secrets.push(Secret(tool.into())); + let fixture = Arc::new(Fixture::default()); + let clock = Arc::new(TestClock::default()); + clock.wall.store(1000, Ordering::SeqCst); + let owner = Engine::new( + config(), + fixture, + clock, + Redactor::new(&redactor).map_err(|_| Error::Invalid)?, + )?; + owner.register_scope(register(7)).await?; + + assert_eq!( + owner.grant_tool(tool_grant(7, tool, 1)).await?.outcome, + ToolTrustOutcome2::Granted + ); + let listed = owner.list_tool_trust(tool_listing(7)).await?.entries; + assert_eq!(listed.len(), 1); + assert_ne!(listed[0].tool, tool); + + let mut matching = shell(7, None, Origin::Operator, "redacted-tool-call"); + matching.request.call.tool = tool.into(); + matching.audit = at(2); + assert_eq!( + owner.begin(matching).await?.state, + ApprovalLifecycle2::Approved + ); + + assert_eq!( + owner.revoke_tool(tool_revoke(7, tool, 3)).await?.outcome, + ToolTrustOutcome2::Revoked + ); + let mut after_revoke = shell(7, None, Origin::Operator, "redacted-tool-after-revoke"); + after_revoke.request.call.tool = tool.into(); + after_revoke.audit = at(4); + assert_eq!( + owner.begin(after_revoke).await?.state, + ApprovalLifecycle2::Pending + ); + Ok(()) +} + #[tokio::test] async fn whole_tool_revocation_before_consume_revokes_queued_automatic_approval() -> Result<()> { let (_, owner) = owner()?; From b391e4410505b18f871fbd3cb2a15e8566a9d5c6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:41:59 +0000 Subject: [PATCH 55/70] fix(tool-trust): match grants by digest instead of tool name Grant lookup no longer compares the stored tool name, so a grant stays valid when the active redaction policy rewrites the display label. The listing field and spec now describe the label as redacted, with exact identity carried by the binding digest. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_tool_trust.rs | 2 -- crates/tinysecurity-bus/src/tool_trust.rs | 5 +++-- docs/specs/durable-tool-trust.md | 9 ++++++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_tool_trust.rs b/crates/tinysecurity-approval/src/engine_tool_trust.rs index b1bfe8c5..cd4239e4 100644 --- a/crates/tinysecurity-approval/src/engine_tool_trust.rs +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -56,7 +56,6 @@ impl Engine { && row.binding.content_sha256 == key && (row.binding.call_sha256 == key || row.attribution.as_deref() == Some(IMPORT_ATTRIBUTION)) - && row.tool_name == tool && row.expires_at_ms > now && Self::current(reg, row).is_ok() }) @@ -74,7 +73,6 @@ impl Engine { && row.binding.content_sha256 == key && (row.binding.call_sha256 == key || row.attribution.as_deref() == Some(IMPORT_ATTRIBUTION)) - && row.tool_name == tool && row.expires_at_ms > now && Self::current(reg, row).is_ok() }) diff --git a/crates/tinysecurity-bus/src/tool_trust.rs b/crates/tinysecurity-bus/src/tool_trust.rs index 1e8ce9db..7d127159 100644 --- a/crates/tinysecurity-bus/src/tool_trust.rs +++ b/crates/tinysecurity-bus/src/tool_trust.rs @@ -106,7 +106,8 @@ pub struct ListToolTrustRequest2 { #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ToolTrustEntry2 { - /// Exact tool name trusted in this scope. + /// Redacted display label of the trusted tool. The exact identity remains in + /// the row's digest binding and is not returned by this listing. pub tool: String, /// Durable row identity. pub request_id: String, @@ -114,7 +115,7 @@ pub struct ToolTrustEntry2 { pub granted_at_ms: u64, } -/// The live whole-tool grants of a scope, ordered by tool name. +/// The live whole-tool grants of a scope, ordered by redacted display label. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ToolTrustList2 { diff --git a/docs/specs/durable-tool-trust.md b/docs/specs/durable-tool-trust.md index 00c9cd6b..f38c6e3c 100644 --- a/docs/specs/durable-tool-trust.md +++ b/docs/specs/durable-tool-trust.md @@ -18,9 +18,12 @@ Granting a live tool again returns `unchanged`; revoking a missing or expired grant returns `absent`. Both operations are serialized per scope, and a scope with at least 960 approval rows refuses any new grant row, including a renewed revoked or expired tool. A still-live repeat remains `unchanged` without adding -a row. Listing returns only live grants, ordered by exact tool name. Names -must be nonblank, contain no control characters, fit within 256 bytes, and must -not name a human-only or `native_*` tool. +a row. Listing returns only live grants, ordered by their redacted display labels. +The entry's `tool` field is a display value and may differ from the exact name when +the active redaction policy matches it; the module matches, revokes and evaluates +grants using the exact domain-separated digest in the binding. Names must be +nonblank, contain no control characters, fit within 256 bytes, and must not name +a human-only or `native_*` tool. `EvaluateApproval2` consults durable whole-tool trust only for an eligible required approval (`Rule::Approval`) or an unlabelled call (`Rule::GrantOnly`), From 89f38f94e0e27efb7d7049ffb3f649c77e8b10f2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:46:59 +0000 Subject: [PATCH 56/70] test(approval): cover redacted tool labels in flow grant identity Add a test asserting that flow-scoped tool grants, revocations, and imported trust entries keep working when the tool label is redacted, so exact flow grant identity is not broken by redaction. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_gaps_tests.rs | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 4f7de4e2..43450c14 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -280,6 +280,89 @@ async fn flow_trust_survives_restart_and_covers_only_its_exact_tool_and_flow() - Ok(()) } +#[tokio::test] +async fn redacted_tool_labels_do_not_break_exact_flow_grant_identity() -> Result<()> { + let tool = "private-flow-tool-label"; + let mut redactor = RedactorConfig::default(); + redactor.secrets.push(Secret(tool.into())); + let fixture = Arc::new(Fixture::default()); + let clock = Arc::new(TestClock::default()); + clock.wall.store(1000, Ordering::SeqCst); + let owner = Engine::new( + config(), + fixture, + clock, + Redactor::new(&redactor).map_err(|_| Error::Invalid)?, + )?; + owner.register_scope(register(7)).await?; + + assert_eq!( + owner + .grant_flow_tool(grant(7, "flow-direct", tool, 1)) + .await? + .outcome, + FlowTrustOutcome2::Granted + ); + let mut direct = shell(7, Some("flow-direct"), Origin::Operator, "redacted-flow-direct"); + direct.request.call.tool = tool.into(); + direct.audit = at(2); + assert_eq!(owner.begin(direct).await?.state, ApprovalLifecycle2::Approved); + assert_eq!( + owner + .revoke_flow_tool(revoke(7, "flow-direct", tool, 3)) + .await? + .outcome, + FlowTrustOutcome2::Revoked + ); + + let imported = ImportTrustSourceRequest2 { + scope_id: id(7), + source_id: "legacy-redacted-flow".into(), + source_version: "v1".into(), + item: ImportTrustItem2::FlowTool { + flow_id: "flow-imported".into(), + tool: tool.into(), + }, + audit: at(4), + }; + assert_eq!( + owner.import_trust_source(imported).await?.outcome, + ImportTrustOutcome2::Imported + ); + let mut imported_call = shell( + 7, + Some("flow-imported"), + Origin::Operator, + "redacted-flow-imported", + ); + imported_call.request.call.tool = tool.into(); + imported_call.audit = at(5); + assert_eq!( + owner.begin(imported_call).await?.state, + ApprovalLifecycle2::Approved + ); + assert_eq!( + owner + .revoke_flow_tool(revoke(7, "flow-imported", tool, 6)) + .await? + .outcome, + FlowTrustOutcome2::Revoked + ); + let mut after_revoke = shell( + 7, + Some("flow-imported"), + Origin::Operator, + "redacted-flow-after-revoke", + ); + after_revoke.request.call.tool = tool.into(); + after_revoke.audit = at(7); + assert_eq!( + owner.begin(after_revoke).await?.state, + ApprovalLifecycle2::Pending + ); + Ok(()) +} + #[tokio::test] async fn granting_a_live_pair_twice_changes_nothing_and_lists_it_once() -> Result<()> { let (fixture, owner) = owner()?; From 6932303209caa12a0cb37db4cf19eab266c9b7a0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:47:28 +0000 Subject: [PATCH 57/70] docs(flow-trust): move durable flow trust details to a spec The MODULE.md section on durable flow trust is condensed to a summary that links to a new spec document, and the listing entry's tool field is documented as a redacted display label rather than the exact tool identity. The engine's flow-trust lookup no longer compares the stored tool name, since the digest binding already identifies the tool. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- MODULE.md | 27 ++---------------- .../src/engine_flow_trust.rs | 1 - crates/tinysecurity-bus/src/flow_trust.rs | 5 ++-- docs/specs/durable-flow-trust.md | 28 +++++++++++++++++++ 4 files changed, 34 insertions(+), 27 deletions(-) create mode 100644 docs/specs/durable-flow-trust.md diff --git a/MODULE.md b/MODULE.md index b68a4685..56a6aba4 100644 --- a/MODULE.md +++ b/MODULE.md @@ -363,30 +363,9 @@ other row; replacing the scope or a grant revokes an unconsumed one. ### Durable flow trust: `GrantFlowTool2`, `RevokeFlowTool2`, `ListFlowTrust2` -"Always allow this tool in this flow" persists across restarts through the host-served -`ApprovalStore2` callback, with no host-side store. One `GrantFlowTool2 { scope_id, flow_id, tool, -audit }` writes one `Approved` row (choice `flow`, attribution `flow-trust-grant`, no human ticket) -through `Cas`; `RevokeFlowTool2` cancels it; `ListFlowTrust2 { scope_id, flow_id? }` reads the -live rows (flow identifiers come back as the stored identifier hash). While a row is live, -`EvaluateApproval2` converts a required approval of the exact tool inside the exact flow into an -automatic one attributed `auto:flow-grant`. - -- The row's binding digests live in their own domain, so the row can never equal a real call's - binding: it is never consumed, never resumed and never satisfies `PreauthorizeFlow2`. -- A flow identity may be up to 1024 bytes (a caller's flow identity), a tool name follows the 256-byte grant-name rule, and revocation validates names exactly as a grant does. A scope that already holds 960 rows refuses a new grant (`unavailable`) so its whole row set stays loadable. -- Granting a live pair is `Unchanged`; revoking a missing pair is `Absent`, so both are idempotent. - Grants and revocations of a scope are serialized, so one pair has one live row. -- The row is bound to the scope's subject, policy reference and generation, like every durable - grant: advancing the generation or replacing the policy reference makes it stale. It lives ten - years (`FLOW_TRUST_LIFETIME_MS2`); a later grant of the same pair renews an expired row only - while the scope has capacity below 960 rows. -- A human-only or native tool, a blank or control-character name and an oversized name are - refused. A trust never overrides a denial, a Cron external effect or a human-only tool. An - approval it produced is re-checked at consumption, so revoking the trust first revokes it. -- Grants configured in `RegisterApprovalScope2` stay in memory and are replayed by the host; the - durable rows are additional. -- Latency budget: one `Load` plus one `Cas` and one audit append, so the host's store latency plus - about 1 ms. `ListFlowTrust2` is one `Load`. +Flow trust persists exact flow/tool digests in the host-served approval store, with bounded, +audited grants and consume-time revocation checks. Listing exposes sanitized display labels. +See [durable flow trust](docs/specs/durable-flow-trust.md) for lifecycle and scope details. Contract 1.26 adds durable whole-tool trust through `GrantTool2`, `RevokeTool2` and `ListToolTrust2`. Its Standard-scope eligibility and lifecycle rules are in diff --git a/crates/tinysecurity-approval/src/engine_flow_trust.rs b/crates/tinysecurity-approval/src/engine_flow_trust.rs index b4e09e5b..515bdf03 100644 --- a/crates/tinysecurity-approval/src/engine_flow_trust.rs +++ b/crates/tinysecurity-approval/src/engine_flow_trust.rs @@ -63,7 +63,6 @@ impl Engine { && row.binding.content_sha256 == key && (row.binding.call_sha256 == key || row.attribution.as_deref() == Some(IMPORT_ATTRIBUTION)) - && row.tool_name == *tool && row.expires_at_ms > now && Self::current(reg, row).is_ok() }) diff --git a/crates/tinysecurity-bus/src/flow_trust.rs b/crates/tinysecurity-bus/src/flow_trust.rs index 40097505..ddddfe61 100644 --- a/crates/tinysecurity-bus/src/flow_trust.rs +++ b/crates/tinysecurity-bus/src/flow_trust.rs @@ -62,7 +62,8 @@ pub struct ListFlowTrustRequest2 { pub struct FlowTrustEntry2 { /// Sanitized flow identity, the identifier hash the module stores. pub flow: String, - /// Tool trusted in that flow. + /// Redacted display label of the trusted tool; the exact identity remains in + /// the row's digest binding and is not returned by this listing. pub tool: String, /// Durable row identity. pub request_id: String, @@ -70,7 +71,7 @@ pub struct FlowTrustEntry2 { pub granted_at_ms: u64, } -/// The live grants of a scope, ordered by flow then tool. +/// The live grants of a scope, ordered by stored flow identity then redacted tool label. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct FlowTrustList2 { diff --git a/docs/specs/durable-flow-trust.md b/docs/specs/durable-flow-trust.md new file mode 100644 index 00000000..1f3c232f --- /dev/null +++ b/docs/specs/durable-flow-trust.md @@ -0,0 +1,28 @@ +# Durable flow trust + +"Always allow this tool in this flow" persists across restarts through the host-served +`ApprovalStore2` callback, with no host-side store. One `GrantFlowTool2 { scope_id, flow_id, tool, +audit }` writes one `Approved` row (choice `flow`, attribution `flow-trust-grant`, no human ticket) +through `Cas`; `RevokeFlowTool2` cancels it; `ListFlowTrust2 { scope_id, flow_id? }` reads the +live rows. Flow identifiers come back as the stored identifier hash, and tool names as redacted +display labels. The exact flow/tool identities remain in the binding digest; the module uses those +digests to evaluate and revoke grants without persisting sensitive names in plaintext. While a row +is live, `EvaluateApproval2` converts a required approval of the exact tool inside the exact flow +into an automatic one attributed `auto:flow-grant`. + +- The row's binding digests live in their own domain, so the row can never equal a real call's + binding: it is never consumed, never resumed and never satisfies `PreauthorizeFlow2`. +- A flow identity may be up to 1024 bytes (a caller's flow identity), a tool name follows the 256-byte grant-name rule, and revocation validates names exactly as a grant does. A scope that already holds 960 rows refuses a new grant (`unavailable`) so its whole row set stays loadable. +- Granting a live pair is `Unchanged`; revoking a missing pair is `Absent`, so both are idempotent. + Grants and revocations of a scope are serialized, so one pair has one live row. +- The row is bound to the scope's subject, policy reference and generation, like every durable + grant: advancing the generation or replacing the policy reference makes it stale. It lives ten + years (`FLOW_TRUST_LIFETIME_MS2`); a later grant of the same pair renews an expired row only + while the scope has capacity below 960 rows. +- A human-only or native tool, a blank or control-character name and an oversized name are + refused. A trust never overrides a denial, a Cron external effect or a human-only tool. An + approval it produced is re-checked at consumption, so revoking the trust first revokes it. +- Grants configured in `RegisterApprovalScope2` stay in memory and are replayed by the host; the + durable rows are additional. +- Latency budget: one `Load` plus one `Cas` and one audit append, so the host's store latency plus + about 1 ms. `ListFlowTrust2` is one `Load`. From 5b53a83d712d4338f0141bb23335d1d8c6c8e974 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:47:46 +0000 Subject: [PATCH 58/70] test(approval): cover stores that return rows outside the requested scope Add a ForeignRowsPorts fixture that echoes the requested scope but returns every stored row, and use it in the wrong-registration test so the engine is exercised against a misbehaving store. The test now asserts that listing fails rather than returning an empty set. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-approval/src/engine_tests.rs | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_tests.rs b/crates/tinysecurity-approval/src/engine_tests.rs index 3056e081..2c00d9a9 100644 --- a/crates/tinysecurity-approval/src/engine_tests.rs +++ b/crates/tinysecurity-approval/src/engine_tests.rs @@ -279,6 +279,40 @@ impl Ports for Fixture { } } } +#[derive(Debug)] +struct ForeignRowsPorts(Arc); +#[async_trait::async_trait] +impl Ports for ForeignRowsPorts { + async fn load(&self, request: ApprovalLoadRequest2) -> Result { + // Deliberately misbehaving store: echo the requested scope but return every row. + Ok(ApprovalLoadResponse2 { + scope: request.scope, + records: self + .0 + .rows + .lock() + .map_err(|_| Error::Unavailable)? + .values() + .cloned() + .collect(), + }) + } + async fn cas(&self, request: ApprovalCasRequest2) -> Result { + self.0.cas(request).await + } + async fn prompt( + &self, + request: ApprovalDeliveryRequest2, + ) -> Result { + self.0.prompt(request).await + } + async fn human(&self, request: VerifyHumanRequest2) -> Result { + self.0.human(request).await + } + async fn audit(&self, row: ApprovalRow2) -> Result<()> { + self.0.audit(row).await + } +} fn config() -> ApprovalConfig2 { ApprovalConfig2 { timeout_ms: 100, @@ -774,11 +808,11 @@ async fn wrong_registration_agent_access_ceiling_and_unverified_human_inputs_den other.scopes[0].subject.user = "other-user".into(); let other = Engine::new( other, - f, + Arc::new(ForeignRowsPorts(f.clone())), Arc::new(TestClock::default()), Redactor::new(&RedactorConfig::default()).map_err(|_| Error::Invalid)?, )?; - assert_eq!(other.list(&request().scope_id).await?.len(), 0); + assert!(other.list(&request().scope_id).await.is_err()); let f = Arc::new(Fixture::default()); let mut ceiling = config(); ceiling.scopes[0].ceiling = AccessTier::Read; From 295726e382ba92201a92aab7af887ad4586042d1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 21:48:15 +0000 Subject: [PATCH 59/70] style: reformat approval engine tests to satisfy line width Reformatted a few test expressions and a trait method signature so they fit within the configured line width. No behaviour changed. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-approval/src/engine_gaps_tests.rs | 12 ++++++++++-- crates/tinysecurity-approval/src/engine_tests.rs | 5 +---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 43450c14..8e3a8019 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -303,10 +303,18 @@ async fn redacted_tool_labels_do_not_break_exact_flow_grant_identity() -> Result .outcome, FlowTrustOutcome2::Granted ); - let mut direct = shell(7, Some("flow-direct"), Origin::Operator, "redacted-flow-direct"); + let mut direct = shell( + 7, + Some("flow-direct"), + Origin::Operator, + "redacted-flow-direct", + ); direct.request.call.tool = tool.into(); direct.audit = at(2); - assert_eq!(owner.begin(direct).await?.state, ApprovalLifecycle2::Approved); + assert_eq!( + owner.begin(direct).await?.state, + ApprovalLifecycle2::Approved + ); assert_eq!( owner .revoke_flow_tool(revoke(7, "flow-direct", tool, 3)) diff --git a/crates/tinysecurity-approval/src/engine_tests.rs b/crates/tinysecurity-approval/src/engine_tests.rs index 2c00d9a9..d260c2bc 100644 --- a/crates/tinysecurity-approval/src/engine_tests.rs +++ b/crates/tinysecurity-approval/src/engine_tests.rs @@ -300,10 +300,7 @@ impl Ports for ForeignRowsPorts { async fn cas(&self, request: ApprovalCasRequest2) -> Result { self.0.cas(request).await } - async fn prompt( - &self, - request: ApprovalDeliveryRequest2, - ) -> Result { + async fn prompt(&self, request: ApprovalDeliveryRequest2) -> Result { self.0.prompt(request).await } async fn human(&self, request: VerifyHumanRequest2) -> Result { From 11e020ac4b4f07950e83328d2202ec8f8873ae1c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 22:29:59 +0000 Subject: [PATCH 60/70] test(tool-trust): assert debug output redacts every trust payload field Add a test covering the Debug implementations of the tool trust wire types, checking that each one emits a redaction marker and never leaks scope, tool, flow, or request identifiers. This guards against a future derive or manual impl accidentally exposing sensitive fields in logs. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-bus/src/tool_trust_tests.rs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/tinysecurity-bus/src/tool_trust_tests.rs b/crates/tinysecurity-bus/src/tool_trust_tests.rs index 911e0950..c2955905 100644 --- a/crates/tinysecurity-bus/src/tool_trust_tests.rs +++ b/crates/tinysecurity-bus/src/tool_trust_tests.rs @@ -53,3 +53,71 @@ fn source_trust_import_items_keep_exact_tagged_wire_shapes() { Some(json!({"kind":"flow_tool", "flow_id":"flow-1", "tool":"search"})) ); } + +#[test] +fn trust_payload_debug_output_redacts_every_field() { + fn assert_redacted(value: &impl std::fmt::Debug) { + let output = format!("{value:?}"); + assert!(output.contains("[REDACTED]")); + assert!(!output.contains("scope-secret")); + assert!(!output.contains("tool-secret")); + assert!(!output.contains("flow-secret")); + } + + let audit = AuditCorrelation2 { + event_id: "a".repeat(32), + expected_tail: 4, + generation: 7, + }; + assert_redacted(&ImportTrustItem2::Tool { + tool: "tool-secret".into(), + }); + assert_redacted(&ImportTrustItem2::FlowTool { + flow_id: "flow-secret".into(), + tool: "tool-secret".into(), + }); + assert_redacted(&ImportTrustSourceRequest2 { + scope_id: "scope-secret".into(), + source_id: "source-secret".into(), + source_version: "v1".into(), + item: ImportTrustItem2::Tool { + tool: "tool-secret".into(), + }, + audit: audit.clone(), + }); + assert_redacted(&ImportTrustReceipt2 { + scope_id: "scope-secret".into(), + outcome: ImportTrustOutcome2::Imported, + request_id: "request-secret".into(), + }); + assert_redacted(&GrantToolRequest2 { + scope_id: "scope-secret".into(), + tool: "tool-secret".into(), + audit: audit.clone(), + }); + assert_redacted(&RevokeToolRequest2 { + scope_id: "scope-secret".into(), + tool: "tool-secret".into(), + audit, + }); + assert_redacted(&ListToolTrustRequest2 { + scope_id: "scope-secret".into(), + }); + assert_redacted(&ToolTrustEntry2 { + tool: "tool-secret".into(), + request_id: "request-secret".into(), + granted_at_ms: 1, + }); + assert_redacted(&ToolTrustList2 { + entries: vec![ToolTrustEntry2 { + tool: "tool-secret".into(), + request_id: "request-secret".into(), + granted_at_ms: 1, + }], + }); + assert_redacted(&ToolTrustReceipt2 { + scope_id: "scope-secret".into(), + outcome: ToolTrustOutcome2::Granted, + request_id: Some("request-secret".into()), + }); +} From d0ae5c3c3a5ddb9f236114e21c7de913115bf89a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 23:06:30 +0000 Subject: [PATCH 61/70] fix(approval): serialize scope replacement with whole-tool trust mutations Scope replacement now takes the whole-tool trust lock so it waits for any in-flight trust mutation to finish before swapping the posture, grants or policy reference. Without this, a concurrent grant could be applied against a scope that was being replaced, leaving trust rows that no longer matched the live scope. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine.rs | 4 +- .../src/engine_gaps_tests.rs | 42 ++++++++++++++++++- crates/tinysecurity-module/src/adapter.rs | 5 ++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 2a820422..82f2a51e 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -1297,16 +1297,18 @@ impl Engine { } } /// Atomically replace the posture, grants or policy reference of a live scope. + /// Replacement waits for any in-flight whole-tool trust mutation to finish. /// The subject and, for runtime scopes, the audit stream cannot change. Pending /// rows bound to a different `policy_ref` stop being decidable, which is the /// intended invalidation. /// # Errors /// Rejects unknown scopes, a changed subject or stream, and invalid input. - pub fn replace_scope( + pub async fn replace_scope( &self, request: RegisterApprovalScopeRequest2, ) -> Result { self.validate_runtime(&request)?; + let _tool_trust = self.tool_trust_lock.lock().await; let report = request.report_handle; let candidate = ScopeEntry::runtime(request); let mut scopes = self.scopes.write().map_err(|_| Error::Unavailable)?; diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 8e3a8019..5074addf 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -818,6 +818,46 @@ async fn whole_tool_trust_fails_closed_when_the_store_cannot_be_loaded() -> Resu Ok(()) } +#[tokio::test] +async fn whole_tool_trust_serializes_scope_replacement_while_loading_rows() -> Result<()> { + let (fixture, owner) = owner()?; + owner.register_scope(register(7)).await?; + fixture.pause_load.store(1, Ordering::SeqCst); + + let owner = Arc::new(owner); + let granting_owner = owner.clone(); + let grant = + tokio::spawn(async move { granting_owner.grant_tool(tool_grant(7, "shell", 1)).await }); + fixture.loaded.notified().await; + + let replacing_owner = owner.clone(); + let replace = tokio::spawn(async move { + let mut request = register(7); + request.scope.policy_ref = "runtime-policy-v2".into(); + replacing_owner.replace_scope(request).await + }); + tokio::task::yield_now().await; + assert!(!replace.is_finished()); + + fixture.release_load.notify_one(); + assert_eq!( + grant.await.map_err(|_| Error::Unavailable)??.outcome, + ToolTrustOutcome2::Granted + ); + assert_eq!( + replace.await.map_err(|_| Error::Unavailable)??.outcome, + ApprovalScopeOutcome2::Replaced + ); + assert!( + owner + .list_tool_trust(tool_listing(7)) + .await? + .entries + .is_empty() + ); + Ok(()) +} + #[tokio::test] async fn source_import_retries_only_items_missing_from_a_partial_import() -> Result<()> { let (fixture, owner) = owner()?; @@ -1062,7 +1102,7 @@ async fn an_unlabelled_approval_dies_when_only_a_flow_grant_remains() -> Result< assert_eq!(row.attribution.as_deref(), Some("auto:tool-grant")); // The whole-tool grant is replaced by a flow grant for the same tool and flow. req.grants = grants(&[], &[("flow-1", "shell")]); - owner.replace_scope(req)?; + owner.replace_scope(req).await?; let consumption = ApprovalActionRequest2 { scope_id: id(7), request_id: row.request_id, diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index a9ff159f..a64fe0dc 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1122,7 +1122,10 @@ impl PolicyService { .approvals .as_ref() .ok_or_else(|| tinybus::Error::failed("approval unavailable"))?; - std::future::ready(engine.replace_scope(request).map_err(approval_failure)).await + engine + .replace_scope(request) + .await + .map_err(approval_failure) } async fn retire_approval_scope2( &self, From c40af2ef79598d63e7c83454353e742e5caaf18d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 23:07:22 +0000 Subject: [PATCH 62/70] test(approval): await replace_scope calls in scope tests The scope engine tests now await replace_scope, matching its async signature, and the affected assertions were reformatted to keep the call chains readable. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_scopes_tests.rs | 55 +++++++++++-------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 6bee0427..a2678b84 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -236,11 +236,11 @@ async fn replace_changes_posture_atomically_and_keeps_subject_and_stream() -> Re let mut auto = register(7); auto.scope.auto_approve_all = true; assert_eq!( - owner.replace_scope(auto.clone())?.outcome, + owner.replace_scope(auto.clone()).await?.outcome, ApprovalScopeOutcome2::Replaced ); assert_eq!( - owner.replace_scope(auto)?.outcome, + owner.replace_scope(auto).await?.outcome, ApprovalScopeOutcome2::Unchanged ); let approved = owner @@ -250,14 +250,23 @@ async fn replace_changes_posture_atomically_and_keeps_subject_and_stream() -> Re assert_eq!(approved.attribution.as_deref(), Some("auto:host-policy")); let mut moved = register(7); moved.scope.subject = subject(99); - assert_eq!(owner.replace_scope(moved).err(), Some(Error::Denied)); + assert_eq!(owner.replace_scope(moved).await.err(), Some(Error::Denied)); let mut other_stream = register(7); other_stream.audit_stream_id = "6".repeat(32); - assert_eq!(owner.replace_scope(other_stream).err(), Some(Error::Denied)); - assert_eq!(owner.replace_scope(register(40)).err(), Some(Error::Denied)); + assert_eq!( + owner.replace_scope(other_stream).await.err(), + Some(Error::Denied) + ); + assert_eq!( + owner.replace_scope(register(40)).await.err(), + Some(Error::Denied) + ); let mut invalid = register(7); invalid.grants = grants(&["policy_change"], &[]); - assert_eq!(owner.replace_scope(invalid).err(), Some(Error::Invalid)); + assert_eq!( + owner.replace_scope(invalid).await.err(), + Some(Error::Invalid) + ); Ok(()) } @@ -266,12 +275,14 @@ async fn init_scope_can_be_replaced_and_keeps_the_base_stream() -> Result<()> { let (_, owner) = owner()?; let mut operator = config().scopes[0].clone(); operator.auto_approve_all = true; - let receipt = owner.replace_scope(RegisterApprovalScopeRequest2 { - scope: operator, - grants: ApprovalGrants2::default(), - audit_stream_id: STREAM.into(), - report_handle: false, - })?; + let receipt = owner + .replace_scope(RegisterApprovalScopeRequest2 { + scope: operator, + grants: ApprovalGrants2::default(), + audit_stream_id: STREAM.into(), + report_handle: false, + }) + .await?; assert_eq!(receipt.outcome, ApprovalScopeOutcome2::Replaced); assert_eq!( owner.begin(request()).await?.state, @@ -772,7 +783,7 @@ async fn revoking_a_grant_blocks_consumption_of_its_unconsumed_approval() -> Res let row = owner.begin(call.clone()).await?; assert_eq!(row.state, ApprovalLifecycle2::Approved); // The grant is removed before the approved row is consumed. - owner.replace_scope(register(7))?; + owner.replace_scope(register(7)).await?; let mut act = action(&row, 3); act.scope_id = id(7); act.request = call.request; @@ -797,7 +808,7 @@ async fn grant_revoked_while_the_ports_await_never_publishes_an_approval() -> Re } }); fixture.loaded.notified().await; - owner.replace_scope(register(7))?; + owner.replace_scope(register(7)).await?; fixture.release_load.notify_one(); assert_eq!( racing.await.map_err(|_| Error::Unavailable)?.err(), @@ -824,7 +835,7 @@ async fn tightening_posture_blocks_consumption_of_an_unconsumed_automatic_approv let call = begin_for(7, "shell", None, Origin::Operator); let row = owner.begin(call.clone()).await?; assert_eq!(row.attribution.as_deref(), Some("auto:host-policy")); - owner.replace_scope(register(7))?; + owner.replace_scope(register(7)).await?; let mut act = action(&row, 3); act.scope_id = id(7); act.request = call.request; @@ -847,7 +858,7 @@ async fn posture_tightened_while_the_ports_await_never_publishes_an_automatic_ro } }); fixture.loaded.notified().await; - owner.replace_scope(register(7))?; + owner.replace_scope(register(7)).await?; fixture.release_load.notify_one(); assert_eq!( racing.await.map_err(|_| Error::Unavailable)?.err(), @@ -875,7 +886,7 @@ async fn replacement_and_generation_advance_serialize_on_the_scope_table() -> Re let mut stale = register(7); stale.scope.auto_approve_all = true; assert_eq!( - owner.replace_scope(stale.clone()).err(), + owner.replace_scope(stale.clone()).await.err(), Some(Error::Denied) ); let mut call = begin_for(7, "shell", None, Origin::Operator); @@ -883,7 +894,7 @@ async fn replacement_and_generation_advance_serialize_on_the_scope_table() -> Re assert_eq!(owner.begin(call).await?.state, ApprovalLifecycle2::Pending); stale.scope.generation = 2; assert_eq!( - owner.replace_scope(stale)?.outcome, + owner.replace_scope(stale).await?.outcome, ApprovalScopeOutcome2::Replaced ); Ok(()) @@ -910,7 +921,7 @@ async fn policy_replaced_while_consume_awaits_never_reserves_the_old_approval() let mut next = automatic_scope(7); next.scope.policy_ref = "runtime-policy-v2".into(); assert_eq!( - owner.replace_scope(next)?.outcome, + owner.replace_scope(next).await?.outcome, ApprovalScopeOutcome2::Replaced ); fixture.release_load.notify_one(); @@ -949,7 +960,7 @@ async fn grant_revoked_while_the_reservation_is_written_burns_it() -> Result<()> while fixture.cas_calls.load(Ordering::SeqCst) == before { tokio::task::yield_now().await; } - owner.replace_scope(register(7))?; + owner.replace_scope(register(7)).await?; let barrier = fixture .barrier .lock() @@ -1016,7 +1027,7 @@ async fn grant_revoked_while_the_approval_is_published_is_not_returned() -> Resu while fixture.cas_calls.load(Ordering::SeqCst) == before { tokio::task::yield_now().await; } - owner.replace_scope(register(7))?; + owner.replace_scope(register(7)).await?; let barrier = fixture .barrier .lock() @@ -1046,7 +1057,7 @@ async fn a_receipt_reports_the_subject_handle_only_when_asked() -> Result<()> { assert_eq!(same.scope_handle, Some(handle.clone())); asking.grants.tools = vec!["shell".into()]; - let replaced = owner.replace_scope(asking)?; + let replaced = owner.replace_scope(asking).await?; assert_eq!(replaced.outcome, ApprovalScopeOutcome2::Replaced); assert_eq!(replaced.scope_handle, Some(handle)); From cd28b938221146ae45fb6418768036c9b7deace7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 23:09:09 +0000 Subject: [PATCH 63/70] test(approval): cover scope replacement waiting on reservations Update the scope tests so a repeated registration with a bumped generation expects Replaced rather than Unchanged, and rewrite the reservation race test to assert that scope replacement blocks until a trusted reservation commits and that the racing consume ends up Reserved instead of Denied. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_scopes_tests.rs | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index a2678b84..ecbd39bb 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -200,7 +200,7 @@ async fn registration_validates_scope_generation_grants_stream_and_ports() -> Re repeat.scope.generation = 2; assert_eq!( owner.register_scope(repeat).await?.outcome, - ApprovalScopeOutcome2::Unchanged + ApprovalScopeOutcome2::Replaced ); Ok(()) } @@ -933,7 +933,7 @@ async fn policy_replaced_while_consume_awaits_never_reserves_the_old_approval() } #[tokio::test] -async fn grant_revoked_while_the_reservation_is_written_burns_it() -> Result<()> { +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); @@ -956,11 +956,17 @@ async fn grant_revoked_while_the_reservation_is_written_burns_it() -> Result<()> let owner = owner.clone(); async move { owner.consume(act).await } }); - // The CAS is parked on the barrier; replace the scope, then let the write finish. + // The CAS is parked on the barrier; replacement must wait for the trusted + // reservation to finish before changing the scope. while fixture.cas_calls.load(Ordering::SeqCst) == before { tokio::task::yield_now().await; } - owner.replace_scope(register(7)).await?; + let replacing = tokio::spawn({ + let owner = owner.clone(); + async move { owner.replace_scope(register(7)).await } + }); + tokio::task::yield_now().await; + assert!(!replacing.is_finished()); let barrier = fixture .barrier .lock() @@ -969,8 +975,12 @@ async fn grant_revoked_while_the_reservation_is_written_burns_it() -> Result<()> .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::Reserved + ); + assert_eq!( + replacing.await.map_err(|_| Error::Unavailable)??.outcome, + ApprovalScopeOutcome2::Replaced ); Ok(()) } From 8e3357123e0cbf14a44e0d691535f528d7a9e43a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 23:09:23 +0000 Subject: [PATCH 64/70] test(approval): expect unchanged outcome for repeated scope registration The scope registration test now asserts that re-registering an identical scope yields Unchanged rather than Replaced, matching the engine's current idempotent behaviour. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_scopes_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index ecbd39bb..11bfd348 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -200,7 +200,7 @@ async fn registration_validates_scope_generation_grants_stream_and_ports() -> Re repeat.scope.generation = 2; assert_eq!( owner.register_scope(repeat).await?.outcome, - ApprovalScopeOutcome2::Replaced + ApprovalScopeOutcome2::Unchanged ); Ok(()) } From bc58cd84f034de0f579c1d06f7bb8b17e48a9b6e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 23:10:19 +0000 Subject: [PATCH 65/70] test(approval): simplify empty tool trust assertion Replace the is_empty check on the listed tool trust entries with an equality comparison against an empty vector, keeping the same expectation while making the assertion more direct. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_gaps_tests.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 5074addf..7bd87e6f 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -848,12 +848,9 @@ async fn whole_tool_trust_serializes_scope_replacement_while_loading_rows() -> R replace.await.map_err(|_| Error::Unavailable)??.outcome, ApprovalScopeOutcome2::Replaced ); - assert!( - owner - .list_tool_trust(tool_listing(7)) - .await? - .entries - .is_empty() + assert_eq!( + owner.list_tool_trust(tool_listing(7)).await?.entries, + Vec::new() ); Ok(()) } From 073571c0510232fbf0a408cdb4f3e0d140ae2edf Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 23:19:57 +0000 Subject: [PATCH 66/70] test(approval): cover revoke retry after a failed cancellation audit Add a fixture hook that fails the terminal audit sink for cancelled rows and a test asserting that retrying a whole-tool revoke reconciles the committed cancellation by writing the missing audit receipt. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_gaps_tests.rs | 42 +++++++++++++++++++ .../tinysecurity-approval/src/engine_tests.rs | 6 +++ 2 files changed, 48 insertions(+) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 7bd87e6f..627bfeb4 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -645,6 +645,48 @@ async fn whole_tool_trust_survives_restart_and_is_exactly_scoped() -> Result<()> Ok(()) } +#[tokio::test] +async fn whole_tool_revoke_retry_reconciles_a_committed_cancellation_audit() -> Result<()> { + let (fixture, owner) = owner()?; + owner.register_scope(register(7)).await?; + let granted = owner.grant_tool(tool_grant(7, "shell", 1)).await?; + let request = tool_revoke(7, "shell", 2); + + // The cancellation CAS succeeds, then the required terminal audit sink fails. + fixture.fail_cancelled_audit.store(1, Ordering::SeqCst); + assert_eq!( + owner.revoke_tool(request.clone()).await.err(), + Some(Error::Unavailable) + ); + let request_id = granted.request_id.clone().ok_or(Error::Invalid)?; + let cancelled = owner + .list(&id(7)) + .await? + .into_iter() + .find(|row| row.request_id == request_id) + .ok_or(Error::Invalid)?; + assert_eq!(cancelled.state, ApprovalLifecycle2::Cancelled); + assert!( + !fixture + .audit_receipts + .lock() + .map_err(|_| Error::Unavailable)? + .contains_key(&(cancelled.request_id.clone(), cancelled.version)) + ); + + let retry = owner.revoke_tool(request).await?; + assert_eq!(retry.outcome, ToolTrustOutcome2::Revoked); + assert_eq!(retry.request_id.as_deref(), granted.request_id.as_deref()); + assert!( + fixture + .audit_receipts + .lock() + .map_err(|_| Error::Unavailable)? + .contains_key(&(cancelled.request_id.clone(), cancelled.version)) + ); + Ok(()) +} + #[tokio::test] async fn redacted_tool_labels_do_not_break_exact_grant_identity() -> Result<()> { let tool = "private-tool-label"; diff --git a/crates/tinysecurity-approval/src/engine_tests.rs b/crates/tinysecurity-approval/src/engine_tests.rs index d260c2bc..e5a00092 100644 --- a/crates/tinysecurity-approval/src/engine_tests.rs +++ b/crates/tinysecurity-approval/src/engine_tests.rs @@ -44,6 +44,7 @@ struct Fixture { audit_receipts: Mutex>, barrier: Mutex>>, native_receipt_mode: AtomicU64, + fail_cancelled_audit: AtomicU64, admitted: Mutex>, released: Mutex>, admit_fails: AtomicU64, @@ -253,6 +254,11 @@ impl Ports for Fixture { .lock() .map_err(|_| Error::Unavailable)? .push(_r.clone()); + if _r.state == ApprovalLifecycle2::Cancelled + && self.fail_cancelled_audit.swap(0, Ordering::SeqCst) != 0 + { + return Err(Error::Unavailable); + } let key = (_r.request_id.clone(), _r.version); let mut receipts = self.audit_receipts.lock().map_err(|_| Error::Unavailable)?; if let Some(prior) = receipts.get(&key) { From 9ce382ff65be03ee7f0e03eafd72b82b2b27322a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 23:20:29 +0000 Subject: [PATCH 67/70] fix(approval): report revoked tool trust for cancelled grants Tool trust lookups now share a binding matcher and, when no live grant exists, check for a cancelled row matching the same binding and audit so the request is cancelled and reported as revoked instead of absent. The redaction test also covers the source and request secret fields. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_tool_trust.rs | 36 +++++++++++++++++-- .../tinysecurity-bus/src/tool_trust_tests.rs | 2 ++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_tool_trust.rs b/crates/tinysecurity-approval/src/engine_tool_trust.rs index cd4239e4..9466cb40 100644 --- a/crates/tinysecurity-approval/src/engine_tool_trust.rs +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -31,6 +31,18 @@ pub(super) fn is_trust_row(row: &ApprovalRow2) -> bool { && row.binding.caller.flow.is_none() } +fn matches_tool_trust_binding(row: &ApprovalRow2, key: &str) -> bool { + matches!( + row.attribution.as_deref(), + Some(ATTRIBUTION | IMPORT_ATTRIBUTION) + ) && row.choice == Some(ApprovalChoice2::Tool) + && row.decision_ticket_sha256.is_none() + && row.binding.caller.flow.is_none() + && row.binding.content_sha256 == key + && (row.binding.call_sha256 == key + || row.attribution.as_deref() == Some(IMPORT_ATTRIBUTION)) +} + pub(super) fn valid_tool(tool: &str) -> bool { ApprovalGrants2 { tools: vec![tool.to_owned()], @@ -70,9 +82,7 @@ impl Engine { let key = trust_key(tool); rows.iter().find(|row| { is_trust_row(row) - && row.binding.content_sha256 == key - && (row.binding.call_sha256 == key - || row.attribution.as_deref() == Some(IMPORT_ATTRIBUTION)) + && matches_tool_trust_binding(row, &key) && row.expires_at_ms > now && Self::current(reg, row).is_ok() }) @@ -172,6 +182,26 @@ impl Engine { let rows = self.rows(®).await?; let now = self.now()?; let Some(live) = Self::live_tool_trust(®, &rows, &request.tool, now) else { + let key = trust_key(&request.tool); + if let Some(cancelled) = rows.iter().find(|row| { + row.state == ApprovalLifecycle2::Cancelled + && matches_tool_trust_binding(row, &key) + && row.audit == request.audit + && Self::current(®, row).is_ok() + }) { + let request_id = cancelled.request_id.clone(); + self.cancel(ApprovalRecordRequest2 { + scope_id: request.scope_id.clone(), + request_id: request_id.clone(), + audit: request.audit, + }) + .await?; + return Ok(ToolTrustReceipt2 { + scope_id: request.scope_id, + outcome: ToolTrustOutcome2::Revoked, + request_id: Some(request_id), + }); + } return Ok(ToolTrustReceipt2 { scope_id: request.scope_id, outcome: ToolTrustOutcome2::Absent, diff --git a/crates/tinysecurity-bus/src/tool_trust_tests.rs b/crates/tinysecurity-bus/src/tool_trust_tests.rs index c2955905..41f162e8 100644 --- a/crates/tinysecurity-bus/src/tool_trust_tests.rs +++ b/crates/tinysecurity-bus/src/tool_trust_tests.rs @@ -62,6 +62,8 @@ fn trust_payload_debug_output_redacts_every_field() { assert!(!output.contains("scope-secret")); assert!(!output.contains("tool-secret")); assert!(!output.contains("flow-secret")); + assert!(!output.contains("source-secret")); + assert!(!output.contains("request-secret")); } let audit = AuditCorrelation2 { From 88fa9256fd74a38bb3fcc144dc5d6cdc36fcfda4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 23:36:50 +0000 Subject: [PATCH 68/70] test(approval): cover revoke retry for imported flow tools Add a test asserting that retrying a flow tool revoke after a committed cancellation whose terminal audit sink failed reconciles the audit receipt and reports the original request as revoked. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_gaps_tests.rs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 627bfeb4..fea44629 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -687,6 +687,60 @@ async fn whole_tool_revoke_retry_reconciles_a_committed_cancellation_audit() -> Ok(()) } +#[tokio::test] +async fn imported_flow_tool_revoke_retry_reconciles_a_committed_cancellation_audit() -> Result<()> +{ + let (fixture, owner) = owner()?; + owner.register_scope(register(7)).await?; + let imported = owner + .import_trust_source(ImportTrustSourceRequest2 { + scope_id: id(7), + source_id: "legacy-flow-trust".into(), + source_version: "v1".into(), + item: ImportTrustItem2::FlowTool { + flow_id: "flow-imported".into(), + tool: "shell".into(), + }, + audit: at(1), + }) + .await?; + let request = revoke(7, "flow-imported", "shell", 2); + + // The cancellation CAS succeeds, then the required terminal audit sink fails. + fixture.fail_cancelled_audit.store(1, Ordering::SeqCst); + assert_eq!( + owner.revoke_flow_tool(request.clone()).await.err(), + Some(Error::Unavailable) + ); + let request_id = imported.request_id; + let cancelled = owner + .list(&id(7)) + .await? + .into_iter() + .find(|row| row.request_id == request_id) + .ok_or(Error::Invalid)?; + assert_eq!(cancelled.state, ApprovalLifecycle2::Cancelled); + assert!( + !fixture + .audit_receipts + .lock() + .map_err(|_| Error::Unavailable)? + .contains_key(&(cancelled.request_id.clone(), cancelled.version)) + ); + + let retry = owner.revoke_flow_tool(request).await?; + assert_eq!(retry.outcome, FlowTrustOutcome2::Revoked); + assert_eq!(retry.request_id.as_deref(), Some(cancelled.request_id.as_str())); + assert!( + fixture + .audit_receipts + .lock() + .map_err(|_| Error::Unavailable)? + .contains_key(&(cancelled.request_id.clone(), cancelled.version)) + ); + Ok(()) +} + #[tokio::test] async fn redacted_tool_labels_do_not_break_exact_grant_identity() -> Result<()> { let tool = "private-tool-label"; From f1c72ccdccf1a2f22dc6c3b0141519233e4f9104 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 23:37:20 +0000 Subject: [PATCH 69/70] fix(approval): report revoked flow trust for cancelled bindings Split the trust row identity check from the approved-state check and reuse the binding match logic when looking up live trust. A cancelled row that still matches the call binding now yields a Revoked outcome instead of Absent, so callers can distinguish an explicitly cancelled trust from one that never existed. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_flow_trust.rs | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_flow_trust.rs b/crates/tinysecurity-approval/src/engine_flow_trust.rs index 515bdf03..b688a440 100644 --- a/crates/tinysecurity-approval/src/engine_flow_trust.rs +++ b/crates/tinysecurity-approval/src/engine_flow_trust.rs @@ -34,14 +34,24 @@ pub(super) fn trust_key(flow_id: &str, tool: &str) -> String { } pub(super) fn is_trust_row(row: &ApprovalRow2) -> bool { + is_trust_identity(row) && row.state == ApprovalLifecycle2::Approved +} + +fn is_trust_identity(row: &ApprovalRow2) -> bool { matches!( row.attribution.as_deref(), Some(ATTRIBUTION | IMPORT_ATTRIBUTION) ) && row.choice == Some(ApprovalChoice2::Flow) - && row.state == ApprovalLifecycle2::Approved && row.decision_ticket_sha256.is_none() } +fn matches_trust_binding(row: &ApprovalRow2, key: &str) -> bool { + is_trust_identity(row) + && (row.binding.call_sha256 == key + || (row.attribution.as_deref() == Some(IMPORT_ATTRIBUTION) + && row.binding.content_sha256 == key)) +} + impl Engine { /// Whether a live durable flow-trust row covers exactly this call's tool and flow. pub(super) fn durable_flow_trust( @@ -76,9 +86,7 @@ impl Engine { ) -> Option<&'a ApprovalRow2> { rows.iter().find(|row| { is_trust_row(row) - && (row.binding.call_sha256 == key - || (row.attribution.as_deref() == Some(IMPORT_ATTRIBUTION) - && row.binding.content_sha256 == key)) + && matches_trust_binding(row, key) && row.expires_at_ms > now && Self::current(reg, row).is_ok() }) @@ -201,6 +209,25 @@ impl Engine { let rows = self.rows(®).await?; let now = self.now()?; let Some(live) = Self::live_trust(®, &rows, &key, now) else { + if let Some(cancelled) = rows.iter().find(|row| { + row.state == ApprovalLifecycle2::Cancelled + && matches_trust_binding(row, &key) + && row.audit == request.audit + && Self::current(®, row).is_ok() + }) { + let request_id = cancelled.request_id.clone(); + self.cancel(ApprovalRecordRequest2 { + scope_id: request.scope_id.clone(), + request_id: request_id.clone(), + audit: request.audit, + }) + .await?; + return Ok(FlowTrustReceipt2 { + scope_id: request.scope_id, + outcome: FlowTrustOutcome2::Revoked, + request_id: Some(request_id), + }); + } return Ok(FlowTrustReceipt2 { scope_id: request.scope_id, outcome: FlowTrustOutcome2::Absent, From 2126ba93a6f29a0b574c7981bb798fe1533abf1a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 21 Oct 2026 23:38:15 +0000 Subject: [PATCH 70/70] test(approval): reformat engine gap test assertions Reformat the imported flow tool revoke retry test to satisfy rustfmt, joining the function signature onto one line and wrapping the request id assertion. No behaviour change. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_gaps_tests.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index fea44629..013a4ab5 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -688,8 +688,7 @@ async fn whole_tool_revoke_retry_reconciles_a_committed_cancellation_audit() -> } #[tokio::test] -async fn imported_flow_tool_revoke_retry_reconciles_a_committed_cancellation_audit() -> Result<()> -{ +async fn imported_flow_tool_revoke_retry_reconciles_a_committed_cancellation_audit() -> Result<()> { let (fixture, owner) = owner()?; owner.register_scope(register(7)).await?; let imported = owner @@ -730,7 +729,10 @@ async fn imported_flow_tool_revoke_retry_reconciles_a_committed_cancellation_aud let retry = owner.revoke_flow_tool(request).await?; assert_eq!(retry.outcome, FlowTrustOutcome2::Revoked); - assert_eq!(retry.request_id.as_deref(), Some(cancelled.request_id.as_str())); + assert_eq!( + retry.request_id.as_deref(), + Some(cancelled.request_id.as_str()) + ); assert!( fixture .audit_receipts