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
20 changes: 20 additions & 0 deletions MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,26 @@ 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.

### Subject handles and unbound members (contract 1.18)

Additive: a 1.17 host keeps working against a 1.18 module (its wire shapes do not
change), and a 1.18 host refuses a 1.17 module.

A registration (`RegisterApprovalScopeRequest2.report_handle`) can ask the receipt
(`ApprovalScopeReceipt2.scope_handle`) to carry the sanitized subject handle the module
sends in every host callback (`ApprovalStore2`, `AuditSink2`, `ApprovalPrompt2`) for
that registration. A host that serves many subjects keys its stores by it, so it reads
the handle from the receipt instead of reproducing the derivation. A request that does
not ask, and every retirement, gets the 1.17 receipt without the field.

Configuring approvals no longer refuses the filesystem, command and egress members
(`ValidatePath`, `ValidateParent`, `CheckPath`, `CheckCommand`, `ScopedUrlGuard`,
`ScopedEgressCheck`, `ConsumeEgressAuthorization`). They are separate permissions that
never ask a human, so only a required audit (a durable event per permission) refuses
them. The tool-permission members (`Evaluate`, `Check` and their scoped and audited
forms) still cannot return `Allow` once approvals are configured, because a tool call
must go through the durable lifecycle.

## 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.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.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.6 supplies `Audit2`, `EvaluateAudited2` and `CheckAudited2` with
authenticated exact audit callbacks and a bounded [native journal](crates/tinysecurity-audit/README.md).
Expand Down
32 changes: 23 additions & 9 deletions crates/tinysecurity-approval/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1083,12 +1083,16 @@ impl Engine {
}
fn receipt(
scopes: &ScopeTable,
scope_id: &str,
scope: &ApprovalScope2,
generation: u64,
outcome: ApprovalScopeOutcome2,
report_handle: bool,
) -> Result<ApprovalScopeReceipt2> {
Ok(ApprovalScopeReceipt2 {
scope_id: scope_id.into(),
scope_id: scope.scope_id.clone(),
scope_handle: report_handle
.then(|| binding::scope_handle(&scope.subject))
.transpose()?,
generation,
outcome,
live_scopes: u32::try_from(scopes.by_id.len()).map_err(|_| Error::Invalid)?,
Expand All @@ -1105,6 +1109,7 @@ impl Engine {
request: RegisterApprovalScopeRequest2,
) -> Result<ApprovalScopeReceipt2> {
self.validate_runtime(&request)?;
let report = request.report_handle;
let candidate = ScopeEntry::runtime(request);
// Registration and retirement are serialized, so every check below still
// holds when the entry is inserted and a failed admission is ours to undo.
Expand All @@ -1119,9 +1124,10 @@ impl Engine {
{
Self::receipt(
&scopes,
&candidate.scope.scope_id,
&candidate.scope,
candidate.scope.generation,
ApprovalScopeOutcome2::Unchanged,
report,
)
} else {
Err(Error::Conflict)
Expand All @@ -1146,10 +1152,15 @@ impl Engine {
let created = {
let mut scopes = self.scopes.write().map_err(|_| Error::Unavailable)?;
self.current_generation(&candidate.scope).map(|()| {
let (id, generation) =
(candidate.scope.scope_id.clone(), candidate.scope.generation);
let (scope, generation) = (candidate.scope.clone(), candidate.scope.generation);
scopes.insert(candidate);
Self::receipt(&scopes, &id, generation, ApprovalScopeOutcome2::Created)
Self::receipt(
&scopes,
&scope,
generation,
ApprovalScopeOutcome2::Created,
report,
)
})
};
match created {
Expand All @@ -1171,6 +1182,7 @@ impl Engine {
request: RegisterApprovalScopeRequest2,
) -> Result<ApprovalScopeReceipt2> {
self.validate_runtime(&request)?;
let report = request.report_handle;
let candidate = ScopeEntry::runtime(request);
let mut scopes = self.scopes.write().map_err(|_| Error::Unavailable)?;
// `advance_generation` takes this lock too, so the check is the linearization
Expand All @@ -1189,7 +1201,7 @@ impl Engine {
{
return Err(Error::Denied);
}
let (id, generation) = (candidate.scope.scope_id.clone(), candidate.scope.generation);
let (scope, generation) = (candidate.scope.clone(), candidate.scope.generation);
let same = existing.same_posture(&candidate);
if !same {
// Keep the original stream owner: init scopes stay on the base stream.
Expand All @@ -1199,13 +1211,14 @@ impl Engine {
}
Self::receipt(
&scopes,
&id,
&scope,
generation,
if same {
ApprovalScopeOutcome2::Unchanged
} else {
ApprovalScopeOutcome2::Replaced
},
report,
)
}
/// Remove a runtime scope. Calls already holding it finish; every later call
Expand Down Expand Up @@ -1240,9 +1253,10 @@ impl Engine {
let scopes = self.scopes.read().map_err(|_| Error::Unavailable)?;
Self::receipt(
&scopes,
&request.scope_id,
&removed.scope,
0,
ApprovalScopeOutcome2::Retired,
false,
)
}
fn correlation(audit: &AuditCorrelation2, generation: u64) -> Result<()> {
Expand Down
31 changes: 31 additions & 0 deletions crates/tinysecurity-approval/src/engine_scopes_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ fn register(n: u64) -> RegisterApprovalScopeRequest2 {
scope: scope(n),
grants: ApprovalGrants2::default(),
audit_stream_id: STREAM.into(),
report_handle: false,
}
}
fn begin_for(n: u64, tool: &str, flow: Option<&str>, origin: Origin) -> EvaluateApprovalRequest2 {
Expand Down Expand Up @@ -269,6 +270,7 @@ async fn init_scope_can_be_replaced_and_keeps_the_base_stream() -> Result<()> {
scope: operator,
grants: ApprovalGrants2::default(),
audit_stream_id: STREAM.into(),
report_handle: false,
})?;
assert_eq!(receipt.outcome, ApprovalScopeOutcome2::Replaced);
assert_eq!(
Expand Down Expand Up @@ -985,3 +987,32 @@ async fn grant_revoked_while_the_approval_is_published_is_not_returned() -> Resu
);
Ok(())
}

#[tokio::test]
async fn a_receipt_reports_the_subject_handle_only_when_asked() -> Result<()> {
let (_fixture, owner) = owner()?;
let handle = binding::scope_handle(&subject(21))?;
let quiet = owner.register_scope(register(21)).await?;
assert_eq!(quiet.outcome, ApprovalScopeOutcome2::Created);
assert!(quiet.scope_handle.is_none(), "an older host never asked");

let mut asking = register(21);
asking.report_handle = true;
let same = owner.register_scope(asking.clone()).await?;
assert_eq!(same.outcome, ApprovalScopeOutcome2::Unchanged);
assert_eq!(same.scope_handle, Some(handle.clone()));

asking.grants.tools = vec!["shell".into()];
let replaced = owner.replace_scope(asking)?;
assert_eq!(replaced.outcome, ApprovalScopeOutcome2::Replaced);
assert_eq!(replaced.scope_handle, Some(handle));

let retired = owner
.retire_scope(RetireApprovalScopeRequest2 {
scope_id: register(21).scope.scope_id,
})
.await?;
assert_eq!(retired.outcome, ApprovalScopeOutcome2::Retired);
assert!(retired.scope_handle.is_none(), "a retirement reports none");
Ok(())
}
13 changes: 13 additions & 0 deletions crates/tinysecurity-bus/src/approval_scopes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ pub struct RegisterApprovalScopeRequest2 {
/// Host-persisted random 32-hex audit stream of this subject. Runtime scopes
/// always forward to the host `AuditSink2`; they never open a native file.
pub audit_stream_id: String,
/// Ask for the subject handle in the receipt (contract 1.18). A host built
/// against an older contract never sets it, so its receipts keep their shape.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub report_handle: bool,
}

/// Retire one runtime scope. Initialized scopes can be replaced but never retired.
Expand Down Expand Up @@ -132,6 +136,15 @@ pub enum ApprovalScopeOutcome2 {
pub struct ApprovalScopeReceipt2 {
/// Affected registration.
pub scope_id: String,
/// The sanitized subject handle the module uses for this registration in every
/// host callback (`ApprovalStore2`, `AuditSink2`, `ApprovalPrompt2`). A host that
/// serves many subjects keys its stores by it, so it learns the handle from the
/// receipt instead of reproducing the module's derivation (contract 1.18).
/// Present only when the registration asked for it
/// ([`RegisterApprovalScopeRequest2::report_handle`]) and never on a retirement,
/// so a receipt for an older host keeps its 1.17 shape.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope_handle: Option<crate::SubjectScope>,
/// Generation the scope is bound to (0 once retired).
pub generation: u64,
/// What happened.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
33 changes: 33 additions & 0 deletions crates/tinysecurity-bus/src/approval_scopes_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,27 @@ fn register_request_wire_is_strict_defaults_grants_and_redacts_debug() -> TestRe
scope: scope(),
grants: ApprovalGrants2::default(),
audit_stream_id: "b".repeat(32),
report_handle: false,
};
let mut value = serde_json::to_value(&request)?;
assert!(
value.get("report_handle").is_none(),
"a request that does not ask keeps its 1.17 wire shape"
);
assert_eq!(
serde_json::from_value::<RegisterApprovalScopeRequest2>(value.clone())?,
request
);
let asking = RegisterApprovalScopeRequest2 {
report_handle: true,
..request.clone()
};
let asked = serde_json::to_value(&asking)?;
assert_eq!(asked["report_handle"], true);
assert_eq!(
serde_json::from_value::<RegisterApprovalScopeRequest2>(asked)?,
asking
);
value.as_object_mut().ok_or("object")?.remove("grants");
assert_eq!(
serde_json::from_value::<RegisterApprovalScopeRequest2>(value.clone())?,
Expand Down Expand Up @@ -147,17 +162,35 @@ fn retire_and_receipt_wire_round_trip() -> TestResult {
);
let receipt = ApprovalScopeReceipt2 {
scope_id: "a".repeat(32),
scope_handle: Some(SubjectScope {
user: "b".repeat(64),
workspace: "c".repeat(64),
agent: "d".repeat(64),
}),
generation: 3,
outcome: ApprovalScopeOutcome2::Replaced,
live_scopes: 2,
};
let value = serde_json::to_value(&receipt)?;
assert_eq!(value["outcome"], "replaced");
assert_eq!(value["scope_handle"]["agent"], "d".repeat(64));
assert_eq!(
serde_json::from_value::<ApprovalScopeReceipt2>(value)?,
receipt
);
assert_eq!(format!("{receipt:?}"), "ApprovalScopeReceipt2([REDACTED])");
// An older host's receipt keeps its shape: no handle is written unless asked for,
// and a receipt without one still decodes.
let quiet = ApprovalScopeReceipt2 {
scope_handle: None,
..receipt.clone()
};
let quiet_value = serde_json::to_value(&quiet)?;
assert!(quiet_value.get("scope_handle").is_none());
assert_eq!(
serde_json::from_value::<ApprovalScopeReceipt2>(quiet_value)?,
quiet
);
for outcome in ["created", "unchanged", "replaced", "retired"] {
assert!(serde_json::from_value::<ApprovalScopeOutcome2>(outcome.into()).is_ok());
}
Expand Down
2 changes: 1 addition & 1 deletion crates/tinysecurity-bus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ pub use redact::RedactorConfig;
pub use scoped_policy::*;
pub mod names;
/// Current wire vocabulary version, independent of the artifact version.
pub const CONTRACT_VERSION: (u32, u32) = (1, 17);
pub const CONTRACT_VERSION: (u32, u32) = (1, 18);
/// Whether a remote module's advertised version supplies this host's vocabulary.
///
/// `version` is the provider version received by the host, not a host requirement
Expand Down
7 changes: 4 additions & 3 deletions crates/tinysecurity-bus/tests/scoped_wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,11 @@ fn new_members_are_advertised_and_named_by_contract_constants() {

#[test]
fn contract_bump_is_additive_for_older_hosts_and_modules() {
assert_eq!(CONTRACT_VERSION, (1, 17));
// A host built against 1.17 accepts this module and newer minors, not a 1.16 module.
assert!(is_compatible((1, 17)));
assert_eq!(CONTRACT_VERSION, (1, 18));
// A host built against 1.18 accepts this module and newer minors, not a 1.17 module.
assert!(is_compatible((1, 18)));
assert!(is_compatible((1, 19)));
assert!(!is_compatible((1, 17)));
assert!(!is_compatible((1, 16)));
assert!(!is_compatible((1, 15)));
assert!(!is_compatible((1, 14)));
Expand Down
2 changes: 1 addition & 1 deletion crates/tinysecurity-bus/tests/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ fn redactor_init_is_additive_and_secrets_never_enter_public_info()
serde_json::to_value(&config)?["redactor"]["secrets"][0],
"literal-private"
);
assert_eq!(CONTRACT_VERSION, (1, 17));
assert_eq!(CONTRACT_VERSION, (1, 18));
for (mode, wire) in [
(RedactMode::Text, "text"),
(RedactMode::StructuredArguments, "structured_arguments"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ fn registration(grants: ApprovalGrants2) -> RegisterApprovalScopeRequest2 {
},
grants,
audit_stream_id: STREAM.into(),
report_handle: false,
}
}
fn audit(sequence: u64) -> AuditCorrelation2 {
Expand Down
14 changes: 12 additions & 2 deletions crates/tinysecurity-module/src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,18 @@ impl PolicyService {
fn requires_bound_audit(&self) -> bool {
self.approvals.is_some() || self.audit.as_ref().is_some_and(|client| client.required())
}
/// Whether the configured audit makes every permission carry its own durable event.
fn audit_required(&self) -> bool {
self.audit.as_ref().is_some_and(|client| client.required())
Comment thread
senamakel marked this conversation as resolved.
}
/// Refuse a permission member that has no audit correlation when audit is required.
///
/// Configured approvals alone do not refuse these members: the durable approval
/// lifecycle governs `EvaluateApproval2`, while filesystem, command and egress
/// questions are separate permissions that never request a human decision. A host
/// that configures approvals must keep validating paths and commands.
fn require_unbound_permission(&self) -> BusResult<()> {
if self.requires_bound_audit() {
if self.audit_required() {
return Err(tinybus::Error::failed(
"required audit needs bound permission",
));
Expand Down Expand Up @@ -824,7 +834,7 @@ impl PolicyService {
async fn policy_info(&self) -> BusResult<PolicyInfo> {
let mut info = { self.read()?.info() };
info.callbacks.audit = self.audit.is_some();
info.callbacks.audit_required = self.requires_bound_audit();
info.callbacks.audit_required = self.audit_required();
info.callbacks.approvals = self.approvals.is_some();
info.callbacks.sandbox = self.sandbox.is_some();
info.callbacks.secret = self.secret.is_some();
Expand Down
Loading
Loading