Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ jobs:
$verifier = (Resolve-Path 'target/debug/examples/verify_module.exe').Path
cargo run --locked --package tinysecurity-module --example approval_native_contract -- $verifiedModule
if ($LASTEXITCODE -ne 0) { throw 'native approval verifier failed in workspace' }
cargo run --locked --package tinysecurity-module --example approval_scope_native_contract -- $verifiedModule
if ($LASTEXITCODE -ne 0) { throw 'native approval scope verifier failed in workspace' }
cargo run --locked --package tinysecurity-module --example judge_native_contract -- $verifiedModule
if ($LASTEXITCODE -ne 0) { throw 'native judge verifier failed in workspace' }
cargo run --locked --package tinysecurity-module --example secret_cipher_native_contract -- $verifiedModule
Expand Down Expand Up @@ -224,6 +226,7 @@ jobs:
cargo run -p tinysecurity-module --example verify_module -- "$module"
cargo run -p tinysecurity-module --example audit_native_contract -- "$module"
cargo run -p tinysecurity-module --example approval_native_contract -- "$module"
cargo run -p tinysecurity-module --example approval_scope_native_contract -- "$module"
cargo run -p tinysecurity-module --example judge_native_contract -- "$module"
cargo run -p tinysecurity-module --example sandbox_native_contract -- "$module"
cargo run -p tinysecurity-module --example secret_cipher_native_contract -- "$module"
Expand All @@ -239,6 +242,7 @@ jobs:
(cd target && ./debug/examples/verify_module "$module")
(cd target && ./debug/examples/audit_native_contract "$module")
(cd target && ./debug/examples/approval_native_contract "$module")
(cd target && ./debug/examples/approval_scope_native_contract "$module")
(cd target && ./debug/examples/judge_native_contract "$module")
(cd target && ./debug/examples/sandbox_native_contract "$module")

Expand Down
48 changes: 48 additions & 0 deletions MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,54 @@ verdict is the executor's risk. `PinnedDirect` stays the only pinned transport,
a local capability never accepts a preflight transport. Both fields are omitted from
the wire when unset, so existing policies serialize unchanged.

## Runtime approval scopes and host tools (contract 1.17)
Comment thread
senamakel marked this conversation as resolved.

Additive: a 1.16 host keeps working against a 1.17 module, and a 1.17 host refuses
a 1.16 module (it requires the three scope members and `Check::HostTool`).

`ApprovalConfig2` fixes at most 128 operator scopes at init. A host that serves
many subjects (SaaS tenants, derived embedded agents) registers each one later:

| Member | Effect |
| --- | --- |
| `RegisterApprovalScope2` | Adds a scope. An identical repeat is `unchanged`; the same identity or the same subject with anything else is a conflict. A stale generation, bad grants or a malformed stream deny. |
| `ReplaceApprovalScope2` | Atomically changes posture, grants or `policy_ref` of a live scope (init scopes included). The subject and, for runtime scopes, the audit stream cannot change. Rows bound to another `policy_ref` stop being decidable. |
| `RetireApprovalScope2` | Removes a scope; every later call under it denies and the ports release the subject. |

All three are private host-only members served to the authenticated init peer,
like the other registrations. The table holds at most 65,536 live scopes. The
ports must admit a subject (its store scope and audit stream) before it becomes
usable: runtime scopes carry a host-persisted `audit_stream_id` and always forward
audit to the host `AuditSink2`; they never open a native journal. The engine owns
the generation, so repeating a registration after a policy advance is `unchanged`
rather than a new scope. A live subject keeps its audit stream, and a retired one
keeps it for an hour (far past any callback deadline): registering it again with
another stream in that window is refused, so a call still in flight for a retired
scope can never reach another scope's ledger. Pins older than that are reclaimed, so
only live and recently retired subjects count against a bound of about one million.
Initialized scopes can be replaced but not retired, so their (possibly native)
audit backend never changes.

`ApprovalGrants2` rides on a registration: exact tool names, and exact tool names
inside one named flow. A grant only converts a required approval into an
automatic one (`auto:tool-grant`, `auto:flow-grant`), persisted and audited like
every other row. It applies to SaaS origins (the scope is the tenant's own), but
never overrides a denial, a Cron external effect, reviewed reuse or a human-only
tool (`HUMAN_ONLY_TOOLS2`, `native_*`), which registration also refuses to name.
Names are never wildcards. Name-wide permanence is therefore host configuration
re-registered with `ReplaceApprovalScope2`, not a persisted human row: durable
`Tool`/`Flow` choices stay exact-content grants.

`Check::HostTool {}` lets the host request approval for a tool whose effect the
module cannot inspect (`gmail_send`, a Composio action). The module never
authorizes it by itself (alone it is `approval_unavailable`, which the approval
path treats as "needs a human"), and it never relaxes a `Path` or `Command`
check carried beside it. It is valid only for tool names outside the module's own
effect classes (`local_diagnostic`, `path_check`, `command_check`), the human-only
tools and `native_*`, and never beside `Unknown` or `PolicyChange`. Audit
correlations stay host-allocated (`AuditCorrelation2.event_id` and the tail it
tracks per stream); the module does not mint them.

## Per-call exact credentials: `RedactWith` (contract 1.16)

Additive: a 1.15 host keeps working against a 1.16 module, and a 1.16 host
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Blocked original content denies; suspicious content denies with
approval-unavailable in unbound policy methods. Scan verdicts
never grant permission, and redaction never clears an existing scan finding.

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.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.6 supplies `Audit2`, `EvaluateAudited2` and `CheckAudited2` with
authenticated exact audit callbacks and a bounded [native journal](crates/tinysecurity-audit/README.md).
Expand Down
2 changes: 2 additions & 0 deletions crates/tinysecurity-approval/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ sha2.workspace = true
getrandom.workspace = true
thiserror.workspace = true
async-trait.workspace = true
# Serializes scope registration and retirement across the ports' awaits.
tokio = { workspace = true, features = ["sync"] }
[dev-dependencies]
# Typed fake judge responses test durable approval integration without HTTP.
tinyinference-decisions.workspace = true
Expand Down
4 changes: 4 additions & 0 deletions crates/tinysecurity-approval/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
`ConsumeApproval2`, `RecordExecution2`, `ListApprovals2`, `CancelApproval2`,
`ExpireApproval2`, `PreauthorizeFlow2`, and `ImportApprovalHistory2`.
`ParseReply` recognizes intent and never supplies human authority.
Contract 1.17 adds `RegisterApprovalScope2`, `ReplaceApprovalScope2` and
`RetireApprovalScope2` for subjects registered after init, tool and flow grants
(`auto:tool-grant`, `auto:flow-grant`) and `Check::HostTool`; see
[MODULE.md](../../MODULE.md#runtime-approval-scopes-and-host-tools-contract-117).

## Initialization and callbacks

Expand Down
12 changes: 10 additions & 2 deletions crates/tinysecurity-approval/src/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,10 @@ pub(crate) fn original(
let external = request.call.checks.iter().any(|check| match check {
Check::LocalDiagnostic {} => false,
Check::Path { write, .. } => *write,
Check::Command { .. } | Check::PolicyChange { .. } | Check::Unknown { .. } => true,
Check::Command { .. }
| Check::PolicyChange { .. }
| Check::HostTool {}
| Check::Unknown { .. } => true,
});
let mut value = serde_json::json!({"scope":reg.subject,"policy_ref":reg.policy_ref,"generation":reg.generation,"caller":request.caller,"tool":request.call.tool,"checks":request.call.checks,"arguments":args,"external_effect":external});
depth(&value, 0)?;
Expand Down Expand Up @@ -273,7 +276,12 @@ fn lifecycle_valid(row: &ApprovalRow2) -> bool {
&& (row.decision_ticket_sha256.is_some()
|| matches!(
row.attribution.as_deref(),
Some("auto:host-policy" | "exact-reviewed-grant")
Some(
"auto:host-policy"
| "auto:tool-grant"
| "auto:flow-grant"
| "exact-reviewed-grant"
)
)
|| (row.attribution.as_deref() == Some("auto:jev")
&& row.choice == Some(ApprovalChoice2::Once)
Expand Down
Loading
Loading