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