Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
c27a631
test(tinysecurity-module): cover whole-tool trust across a restart
senamakel Oct 21, 2026
e4584dc
chore(deps): downgrade zip and tinybus, add zopfli
senamakel Oct 21, 2026
92a94a4
feat(bus): add durable whole-tool trust methods
senamakel Oct 21, 2026
78be2bb
feat(approval): add durable whole-tool trust grants
senamakel Oct 21, 2026
9bb6eb3
feat(adapter): expose tool trust grant, revoke and list methods
senamakel Oct 21, 2026
7345a41
test(module): cover approval consumption and trust listing after restart
senamakel Oct 21, 2026
d407714
test(tinysecurity-module): use typed tool trust requests in adapter g…
senamakel Oct 21, 2026
86b073c
fix(approval): recheck whole-tool trust at the consume boundary
senamakel Oct 21, 2026
28eb78a
docs: document durable tool trust for contract 1.25
senamakel Oct 21, 2026
d069ec6
feat(approval): add durable whole-tool trust grants
senamakel Oct 21, 2026
8150fed
refactor(tinysecurity-approval): rename live_trust to live_tool_trust
senamakel Oct 21, 2026
366bcf9
test(approval): filter fixture load by requested scope
senamakel Oct 21, 2026
5980dd4
test(approval): add error context to engine gap test failures
senamakel Oct 21, 2026
8f5688a
fix(approval): honour durable tool grants for grant-only rules
senamakel Oct 21, 2026
d43f4f5
fix(approval): accept tool-trust-grant in lifecycle validation
senamakel Oct 21, 2026
5a04db7
fix(approval): hold tool trust lock during auto tool-grant redemption
senamakel Oct 21, 2026
6862e50
test(approval): cover whole-tool trust validation and store failures
senamakel Oct 21, 2026
2c62d3d
test(approval): assert empty list instead of error for other agent
senamakel Oct 21, 2026
0e17076
test(module): cover imported trust staying revoked across restart
senamakel Oct 21, 2026
492cecd
feat(tool_trust): add imported-source trust receipts
senamakel Oct 21, 2026
fcccde8
feat(approval): accept imported trust rows and expose trust helpers
senamakel Oct 21, 2026
b5bdfba
fix(approval): match imported trust rows by content hash and tool name
senamakel Oct 21, 2026
9141c92
fix(approval): match imported trust rows by tool name
senamakel Oct 21, 2026
a7b088a
chore(approval): register import trust engine module
senamakel Oct 21, 2026
df61f3a
feat(adapter): add ImportTrustSource2 host-only method
senamakel Oct 21, 2026
ebe85f8
fix(bus): register tool trust methods in the methods list
senamakel Oct 21, 2026
a977de2
fix(approval): include workspace in import trust source marker
senamakel Oct 21, 2026
9e87c59
refactor(approval): move trust key computation earlier
senamakel Oct 21, 2026
f53d807
refactor(approval): route trust pair validation through engine method
senamakel Oct 21, 2026
273b345
test(approval): cover retrying a partial trust source import
senamakel Oct 21, 2026
d46c98d
fix(approval): match trust rows on content hash and tool name
senamakel Oct 21, 2026
fed11e8
docs(specs): document ImportTrustSource2 migration receipts
senamakel Oct 21, 2026
539aba8
fix(approval): count imported trust rows when checking scope capacity
senamakel Oct 21, 2026
e6f071d
test(tinysecurity-module): cover explicit tool and flow grants after …
senamakel Oct 21, 2026
414b323
test(approval): cover expired import receipts not recreating trust
senamakel Oct 21, 2026
300e313
test(approval): cover source import receipt binding and fail-closed e…
senamakel Oct 21, 2026
e016e96
test(tool-trust): remove duplicate serde_json import
senamakel Oct 21, 2026
c36c867
test(tinysecurity-bus): bump contract version expectations to 1.25
senamakel Oct 21, 2026
fe3d5f9
test(bus): bump expected contract version to 1.25
senamakel Oct 21, 2026
e5fde19
refactor(approval): extract import trust planning into helpers
senamakel Oct 21, 2026
1c78619
refactor(approval): extract raw deny check into helper
senamakel Oct 21, 2026
82577b7
refactor(approval): extract ttl resolution into begin_ttl helper
senamakel Oct 21, 2026
9a0deeb
test(module): split adapter gap test into helper functions
senamakel Oct 21, 2026
73dc7f3
test(tinysecurity-module): tighten adapter gap assertions and borrow …
senamakel Oct 21, 2026
57c70c0
test(approval): simplify assertions and drop debug logging
senamakel Oct 21, 2026
e844729
test(approval): reformat grant_tool call in engine gaps test
senamakel Oct 21, 2026
8cc6a77
test(tool-trust): compare serialized payloads without unwrapping
senamakel Oct 21, 2026
34798a0
test(approval): cover scope row cap on regrant after revoke
senamakel Oct 21, 2026
955b6df
fix(approval): reject reissue when scope row limit is reached
senamakel Oct 21, 2026
6635d9a
Merge pull request #39 from tinyhumansai/epic/7328-device-pairing
senamakel Oct 21, 2026
4ad8ade
test(approval): cover retry after audit failure on imported source
senamakel Oct 21, 2026
2738138
fix(approval): reconcile audit row for already-imported trust markers
senamakel Oct 21, 2026
86bde7d
docs: renumber durable tool trust contract to 1.26
senamakel Oct 21, 2026
334a164
chore(deps): bump tinybus to 0.1.6 and update lockfile
senamakel Oct 21, 2026
ae2b287
test(approval): cover redacted tool labels in exact grant identity
senamakel Oct 21, 2026
b391e44
fix(tool-trust): match grants by digest instead of tool name
senamakel Oct 21, 2026
89f38f9
test(approval): cover redacted tool labels in flow grant identity
senamakel Oct 21, 2026
6932303
docs(flow-trust): move durable flow trust details to a spec
senamakel Oct 21, 2026
5b53a83
test(approval): cover stores that return rows outside the requested s…
senamakel Oct 21, 2026
295726e
style: reformat approval engine tests to satisfy line width
senamakel Oct 21, 2026
11e020a
test(tool-trust): assert debug output redacts every trust payload field
senamakel Oct 21, 2026
d0ae5c3
fix(approval): serialize scope replacement with whole-tool trust muta…
senamakel Oct 21, 2026
c40af2e
test(approval): await replace_scope calls in scope tests
senamakel Oct 21, 2026
cd28b93
test(approval): cover scope replacement waiting on reservations
senamakel Oct 21, 2026
8e33571
test(approval): expect unchanged outcome for repeated scope registration
senamakel Oct 21, 2026
bc58cd8
test(approval): simplify empty tool trust assertion
senamakel Oct 21, 2026
073571c
test(approval): cover revoke retry after a failed cancellation audit
senamakel Oct 21, 2026
9ce382f
fix(approval): report revoked tool trust for cancelled grants
senamakel Oct 21, 2026
88fa925
test(approval): cover revoke retry for imported flow tools
senamakel Oct 21, 2026
f1c72cc
fix(approval): report revoked flow trust for cancelled bindings
senamakel Oct 21, 2026
2126ba9
test(approval): reformat engine gap test assertions
senamakel Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 13 additions & 24 deletions MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
<a id="approval-origin-classes-durable-flow-trust-and-scored-prompt-screening-contract-122"></a>

## 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.
Expand All @@ -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.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
### Scored prompt screening: `ScanPrompt2`

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions crates/tinysecurity-approval/src/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
)
Expand Down
102 changes: 82 additions & 20 deletions crates/tinysecurity-approval/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,17 +253,19 @@ 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
.grant(
&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,
}
}
Expand Down Expand Up @@ -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<dyn Ports>,
clock: Arc<dyn Clock>,
redactor: RwLock<Redactor>,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -481,21 +486,13 @@ impl Engine {
let reg = self.registered(&entry);
let (bound, args_redacted, external_effect) =
binding::original(&reg, &request.request, &self.redactor()?)?;
let rule = Self::origin_rule(&entry, &reg, &bound.origin, &request, external_effect)?;
Self::reject_raw_deny(&reg, &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(&reg).await?;
let (rows, rule) = self
.begin_rows_and_rule(&entry, &reg, &request, &bound.origin, external_effect, now)
.await?;
self.reconcile_creations(&rows)?;
if let Some(row) = self
.existing_begin(&reg, &bound, &rows, now, deadline)
Expand Down Expand Up @@ -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<ApprovalRow2>, 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<u64>,
now: u64,
) -> Result<u64> {
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)
Expand All @@ -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
Expand All @@ -609,11 +649,14 @@ impl Engine {
origin: &Origin,
request: &EvaluateApprovalRequest2,
external_effect: bool,
durable_tool: bool,
) -> Result<tinysecurity_auto::rules::Rule> {
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
Expand Down Expand Up @@ -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<ApprovalScopeReceipt2> {
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)?;
Expand Down Expand Up @@ -1628,6 +1673,14 @@ impl Engine {
let rows = self.rows(&reg).await?;
granted = Self::durable_flow_trust(&reg, &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(&reg).await?;
granted =
Self::durable_tool_trust(&reg, &rows, &request.request.call.tool, self.now()?);
}
let automatic = posture
&& tinysecurity_auto::rules::evaluate(
&entry.scope,
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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;
Loading
Loading