diff --git a/Cargo.lock b/Cargo.lock index fd90681a..991ecf33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2071,9 +2071,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typed-path" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" [[package]] name = "typenum" diff --git a/MODULE.md b/MODULE.md index ae60761c..56a6aba4 100644 --- a/MODULE.md +++ b/MODULE.md @@ -337,7 +337,9 @@ 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.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. @@ -361,29 +363,16 @@ 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. -- 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 +[`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 7b2d3681..91f7a978 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,13 @@ 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.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 +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). The audited permission methods derive events from the actual original request, diff --git a/crates/tinysecurity-approval/src/binding.rs b/crates/tinysecurity-approval/src/binding.rs index 07703169..020b77bd 100644 --- a/crates/tinysecurity-approval/src/binding.rs +++ b/crates/tinysecurity-approval/src/binding.rs @@ -287,6 +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.rs b/crates/tinysecurity-approval/src/engine.rs index cdb6bbb5..82f2a51e 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,10 +261,11 @@ 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) - } + tinysecurity_auto::rules::Rule::GrantOnly => self + .tool_grant(&request.request.call.tool) + .or_else(|| durable_tool.then_some("auto:tool-grant")), _ => None, } } @@ -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), @@ -481,21 +486,13 @@ 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)?; + 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 = self.rows(®).await?; + 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) @@ -579,6 +576,48 @@ 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)) + } + 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(()) + } + 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) @@ -597,8 +636,9 @@ 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 @@ -609,11 +649,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 @@ -1254,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)?; @@ -1628,6 +1673,14 @@ 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, @@ -1664,6 +1717,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)?; @@ -1898,6 +1956,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; diff --git a/crates/tinysecurity-approval/src/engine_flow_trust.rs b/crates/tinysecurity-approval/src/engine_flow_trust.rs index 410476d0..b688a440 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,13 +33,25 @@ 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) - && row.choice == Some(ApprovalChoice2::Flow) - && row.state == ApprovalLifecycle2::Approved +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.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( @@ -57,13 +70,15 @@ 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.expires_at_ms > now && Self::current(reg, row).is_ok() }) } - fn live_trust<'a>( + pub(super) fn live_trust<'a>( reg: &ApprovalScope2, rows: &'a [ApprovalRow2], key: &str, @@ -71,7 +86,7 @@ impl Engine { ) -> Option<&'a ApprovalRow2> { rows.iter().find(|row| { is_trust_row(row) - && row.binding.call_sha256 == key + && matches_trust_binding(row, key) && row.expires_at_ms > now && Self::current(reg, row).is_ok() }) @@ -80,7 +95,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) @@ -117,14 +132,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(|r| r.attribution.as_deref() == Some(ATTRIBUTION) && r.binding.call_sha256 == key); - 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()?; @@ -199,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, diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index 2effd877..013a4ab5 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -1,7 +1,9 @@ //! Contract 1.22: origin classes for approval evaluation and durable flow trust. use super::*; use tinysecurity_bus::{ - FlowTrustOutcome2, GrantFlowToolRequest2, ListFlowTrustRequest2, RevokeFlowToolRequest2, + FlowTrustOutcome2, GrantFlowToolRequest2, GrantToolRequest2, ImportTrustItem2, + ImportTrustOutcome2, ImportTrustSourceRequest2, ListFlowTrustRequest2, ListToolTrustRequest2, + RedactorConfig, RevokeFlowToolRequest2, RevokeToolRequest2, Secret, ToolTrustOutcome2, }; fn shell(n: u64, flow: Option<&str>, origin: Origin, call: &str) -> EvaluateApprovalRequest2 { @@ -41,6 +43,32 @@ 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 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 @@ -252,6 +280,97 @@ 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()?; @@ -476,6 +595,597 @@ 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_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 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"; + 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()?; + 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) + ); + 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() + { + 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_eq!( + owner.list_tool_trust(tool_listing(7)).await?.entries.len(), + 0 + ); + 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_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 { + 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 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_eq!( + owner.list_tool_trust(tool_listing(7)).await?.entries, + Vec::new() + ); + 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 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_eq!( + restarted + .list_tool_trust(tool_listing(7)) + .await? + .entries + .len(), + 0 + ); + 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()?; + 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_eq!( + owner.list_tool_trust(tool_listing(7)).await?.entries.len(), + 0 + ); + Ok(()) +} + #[tokio::test] async fn an_unlabelled_approval_dies_when_only_a_flow_grant_remains() -> Result<()> { let (_, owner) = owner()?; @@ -487,7 +1197,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, @@ -602,27 +1312,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(()) } 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..05cf2805 --- /dev/null +++ b/crates/tinysecurity-approval/src/engine_import_trust.rs @@ -0,0 +1,229 @@ +//! 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, flow_trust, tool_trust, +}; +use tinysecurity_bus::{ + FLOW_TRUST_LIFETIME_MS2, ImportTrustItem2, ImportTrustOutcome2, ImportTrustReceipt2, + ImportTrustSourceRequest2, TOOL_TRUST_LIFETIME_MS2, +}; + +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 + && 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.user.as_str(), + scope.workspace.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<'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 + && row.decision_ticket_sha256.is_none() + }) +} + +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. + /// + /// 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 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, + request_id: existing.request_id.clone(), + }); + } + if rows.len() >= MAX_SCOPE_ROWS { + return Err(Error::Unavailable); + } + let now = self.now()?; + 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, + outcome: ImportTrustOutcome2::Imported, + request_id: row.request_id, + }) + } +} diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 6bee0427..11bfd348 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(); @@ -922,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); @@ -945,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))?; + 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() @@ -958,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(()) } @@ -1016,7 +1037,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 +1067,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)); diff --git a/crates/tinysecurity-approval/src/engine_tests.rs b/crates/tinysecurity-approval/src/engine_tests.rs index 9f39f184..e5a00092 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, @@ -43,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, @@ -106,13 +108,21 @@ 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: 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(), }; @@ -244,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) { @@ -270,6 +285,37 @@ 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, @@ -765,7 +811,7 @@ 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)?, )?; 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..9466cb40 --- /dev/null +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -0,0 +1,246 @@ +//! 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"; +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. +pub(super) fn trust_key(tool: &str) -> String { + binding::digest(b"tinysecurity/tool-trust/v1\0", tool.as_bytes()) +} + +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() + && 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()], + 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.content_sha256 == key + && (row.binding.call_sha256 == key + || row.attribution.as_deref() == Some(IMPORT_ATTRIBUTION)) + && row.expires_at_ms > now + && Self::current(reg, row).is_ok() + }) + } + + pub(super) fn live_tool_trust<'a>( + reg: &ApprovalScope2, + rows: &'a [ApprovalRow2], + tool: &str, + now: u64, + ) -> Option<&'a ApprovalRow2> { + let key = trust_key(tool); + rows.iter().find(|row| { + is_trust_row(row) + && matches_tool_trust_binding(row, &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_tool_trust(®, &rows, &request.tool, 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()), + }); + } + // 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()?; + 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 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, + 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 }) + } +} 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/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/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 73e5e38a..aae09d0f 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -42,6 +42,7 @@ mod prompt; mod prompt_score; mod redact; mod scoped_policy; +mod tool_trust; pub use audit::*; pub use audited::*; pub use budget::*; @@ -61,9 +62,10 @@ pub use prompt_score::*; 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); +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 59d143b6..a61cb852 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -51,6 +51,14 @@ 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.26). + pub const GRANT_TOOL2: &str = "GrantTool2"; + /// 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.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"; /// 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). @@ -224,6 +232,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.rs b/crates/tinysecurity-bus/src/tool_trust.rs new file mode 100644 index 00000000..7d127159 --- /dev/null +++ b/crates/tinysecurity-bus/src/tool_trust.rs @@ -0,0 +1,175 @@ +//! 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 +//! registered subject and become stale with that subject's policy generation. +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; + +/// 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 { + /// 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, + /// When the trust was granted, in epoch milliseconds. + pub granted_at_ms: u64, +} + +/// 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 { + /// 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!( + ImportTrustItem2, + ImportTrustSourceRequest2, + ImportTrustReceipt2, + 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..41f162e8 --- /dev/null +++ b/crates/tinysecurity-bus/src/tool_trust_tests.rs @@ -0,0 +1,125 @@ +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).ok(), + Some(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()), + }) + .ok(), + Some(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"})) + ); +} + +#[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")); + assert!(!output.contains("source-secret")); + assert!(!output.contains("request-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()), + }); +} 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/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index c63fed1b..a64fe0dc 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1056,6 +1056,52 @@ 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 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, @@ -1076,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, @@ -2000,7 +2049,7 @@ tinybus_module::module_export! { config = ModuleConfig, worker_threads = 1, provides = ["ai.tinyhumans.tinysecurity.Policy"], - methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "DeviceFrame2", "DevicePairing2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "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", "DevicePairing2", "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.DevicePairingHost2", "ai.tinyhumans.tinysecurity.AuditSink2", "ai.tinyhumans.tinysecurity.ApprovalStore2", "ai.tinyhumans.tinysecurity.ApprovalPrompt2"], diff --git a/crates/tinysecurity-module/src/adapter_gaps_tests.rs b/crates/tinysecurity-module/src/adapter_gaps_tests.rs index 64ba10c7..d089c6e2 100644 --- a/crates/tinysecurity-module/src/adapter_gaps_tests.rs +++ b/crates/tinysecurity-module/src/adapter_gaps_tests.rs @@ -2,9 +2,10 @@ //! 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, GrantToolRequest2, ListFlowTrustRequest2, ListToolTrustRequest2, + PromptTier2, RevokeFlowToolRequest2, RevokeToolRequest2, ScanPromptScoredRequest2, + ScanPromptScoredResponse2, ToolTrustList2, ToolTrustOutcome2, ToolTrustReceipt2, }; type TestResult = std::result::Result<(), Box>; @@ -155,6 +156,240 @@ 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: ToolTrustReceipt2 = first + .proxy + .call( + names::methods::GRANT_TOOL2, + (GrantToolRequest2 { + scope_id: "a".repeat(32), + tool: "local_diagnostic".into(), + audit: audit(1), + },), + ) + .await?; + assert_eq!(grant.outcome, ToolTrustOutcome2::Granted); + first.broker.abort(); + + let second = start(&path).await?; + let listed: ToolTrustList2 = second + .proxy + .call( + names::methods::LIST_TOOL_TRUST2, + (ListToolTrustRequest2 { + scope_id: "a".repeat(32), + },), + ) + .await?; + assert_eq!(listed.entries.len(), 1); + assert_eq!(listed.entries[0].tool, "local_diagnostic"); + + 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: 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.outcome, ToolTrustOutcome2::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::CONSUME_APPROVAL2, (consume,)) + .await + .is_err() + ); + let listed: ToolTrustList2 = second + .proxy + .call( + names::methods::LIST_TOOL_TRUST2, + (ListToolTrustRequest2 { + scope_id: "a".repeat(32), + },), + ) + .await?; + assert_eq!(listed.entries.len(), 0); + second.broker.abort(); + 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 first = start(&path).await?; + import_legacy_sources(&first.proxy).await?; + first.broker.abort(); + + let second = start(&path).await?; + 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 { + scope_id: "a".repeat(32), + tool: "local_diagnostic".into(), + audit: audit(3), + },), + ) + .await?; + assert_eq!(tool.outcome, ToolTrustOutcome2::Revoked); + let flow: FlowTrustReceipt2 = 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!(flow.outcome, FlowTrustOutcome2::Revoked); + Ok(()) +} + +async fn reimport_keeps_revocations(proxy: &tinybus::Proxy) -> TestResult { + let items = [ + ( + "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, + ), + ]; + 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 = proxy + .call( + names::methods::LIST_TOOL_TRUST2, + (ListToolTrustRequest2 { + scope_id: "a".repeat(32), + },), + ) + .await?; + let flows: FlowTrustList2 = proxy + .call( + names::methods::LIST_FLOW_TRUST2, + (ListFlowTrustRequest2 { + scope_id: "a".repeat(32), + flow_id: Some("flow".into()), + },), + ) + .await?; + 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 { + scope_id: "a".repeat(32), + tool: "local_diagnostic".into(), + audit: audit(7), + },), + ) + .await?; + assert_eq!(tool.outcome, ToolTrustOutcome2::Granted); + let flow: FlowTrustReceipt2 = 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!(flow.outcome, FlowTrustOutcome2::Granted); + Ok(()) +} + #[tokio::test] async fn scored_prompt_scan_returns_tier_score_and_content_free_rules() -> TestResult { let dir = tempfile::tempdir()?; 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`. diff --git a/docs/specs/durable-tool-trust.md b/docs/specs/durable-tool-trust.md new file mode 100644 index 00000000..f38c6e3c --- /dev/null +++ b/docs/specs/durable-tool-trust.md @@ -0,0 +1,53 @@ +# Durable whole-tool trust + +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 +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 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 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`), +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 +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.