From 98f821e8896164e12c29b1b0dd4b72355df95e1f Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Thu, 30 Apr 2026 22:42:10 +0300 Subject: [PATCH 1/5] feat(reborn): recarve capability obligations handoff --- Cargo.lock | 2 + crates/ironclaw_capabilities/src/error.rs | 7 + crates/ironclaw_capabilities/src/helpers.rs | 16 +- crates/ironclaw_capabilities/src/host.rs | 519 +++++++++-- crates/ironclaw_capabilities/src/lib.rs | 6 + .../ironclaw_capabilities/src/obligations.rs | 142 +++ .../capability_obligation_handler_contract.rs | 606 ++++++++++++ crates/ironclaw_host_runtime/Cargo.toml | 4 +- crates/ironclaw_host_runtime/src/lib.rs | 5 + .../ironclaw_host_runtime/src/obligations.rs | 867 ++++++++++++++++++ .../ironclaw_host_runtime/src/production.rs | 61 +- .../builtin_obligation_handler_contract.rs | 636 +++++++++++++ docs/reborn/contracts/capabilities.md | 39 + docs/reborn/contracts/host-runtime.md | 31 + docs/reborn/contracts/network.md | 4 +- docs/reborn/contracts/secrets.md | 4 +- 16 files changed, 2851 insertions(+), 98 deletions(-) create mode 100644 crates/ironclaw_capabilities/src/obligations.rs create mode 100644 crates/ironclaw_capabilities/tests/capability_obligation_handler_contract.rs create mode 100644 crates/ironclaw_host_runtime/src/obligations.rs create mode 100644 crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs create mode 100644 docs/reborn/contracts/capabilities.md create mode 100644 docs/reborn/contracts/host-runtime.md diff --git a/Cargo.lock b/Cargo.lock index 9c5119c0b0f..06cf4a4e78d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4166,10 +4166,12 @@ dependencies = [ "chrono", "ironclaw_authorization", "ironclaw_capabilities", + "ironclaw_events", "ironclaw_extensions", "ironclaw_host_api", "ironclaw_network", "ironclaw_processes", + "ironclaw_resources", "ironclaw_run_state", "ironclaw_safety", "ironclaw_secrets", diff --git a/crates/ironclaw_capabilities/src/error.rs b/crates/ironclaw_capabilities/src/error.rs index c89d315bda7..c310a272fef 100644 --- a/crates/ironclaw_capabilities/src/error.rs +++ b/crates/ironclaw_capabilities/src/error.rs @@ -1,6 +1,8 @@ use ironclaw_authorization::CapabilityLeaseError; use ironclaw_host_api::{CapabilityId, DenyReason, DispatchError, HostApiError, Obligation}; use ironclaw_processes::ProcessError; + +use crate::CapabilityObligationFailureKind; use ironclaw_run_state::{ApprovalStatus, RunStateError, RunStatus}; use thiserror::Error; @@ -26,6 +28,11 @@ pub enum CapabilityInvocationError { capability: CapabilityId, obligations: Vec, }, + #[error("capability {capability} obligation handling failed: {kind}")] + ObligationFailed { + capability: CapabilityId, + kind: CapabilityObligationFailureKind, + }, #[error("capability {capability} invocation requires approval")] AuthorizationRequiresApproval { capability: CapabilityId }, #[error("capability {capability} invocation fingerprint failed: {source}")] diff --git a/crates/ironclaw_capabilities/src/helpers.rs b/crates/ironclaw_capabilities/src/helpers.rs index 0c738e1fd57..7e053ac9f6b 100644 --- a/crates/ironclaw_capabilities/src/helpers.rs +++ b/crates/ironclaw_capabilities/src/helpers.rs @@ -3,27 +3,13 @@ use ironclaw_authorization::{ }; use ironclaw_host_api::{ Action, ApprovalRequest, CapabilityId, ExecutionContext, InvocationFingerprint, InvocationId, - Obligation, Principal, ResourceEstimate, ResourceScope, + Principal, ResourceEstimate, ResourceScope, }; use ironclaw_run_state::{ApprovalStatus, RunStateError, RunStateStore}; use tracing::warn; use crate::{CapabilityInvocationError, ResumeContextMismatchKind}; -pub(crate) fn ensure_no_obligations( - capability: &CapabilityId, - obligations: Vec, -) -> Result<(), CapabilityInvocationError> { - if obligations.is_empty() { - Ok(()) - } else { - Err(CapabilityInvocationError::UnsupportedObligations { - capability: capability.clone(), - obligations, - }) - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CapabilityActionKind { Dispatch, diff --git a/crates/ironclaw_capabilities/src/host.rs b/crates/ironclaw_capabilities/src/host.rs index 7ddd66e8fea..0bd2772940e 100644 --- a/crates/ironclaw_capabilities/src/host.rs +++ b/crates/ironclaw_capabilities/src/host.rs @@ -1,7 +1,8 @@ use ironclaw_authorization::{CapabilityLeaseStore, TrustAwareCapabilityDispatchAuthorizer}; use ironclaw_extensions::ExtensionRegistry; use ironclaw_host_api::{ - CapabilityDispatchRequest, CapabilityDispatcher, Decision, DenyReason, ProcessId, + CapabilityDispatchRequest, CapabilityDispatchResult, CapabilityDispatcher, Decision, + DenyReason, ExecutionContext, Obligation, ProcessId, ResourceEstimate, }; use ironclaw_processes::{ProcessManager, ProcessStart}; use ironclaw_run_state::{ @@ -11,14 +12,17 @@ use tracing::warn; use crate::helpers::{ CapabilityActionKind, approval_not_approved_error_kind, capability_lease_error_kind, - claim_error_may_be_concurrent_resume, complete_run_after_side_effect, ensure_no_obligations, - fail_run_if_configured, invocation_fingerprint_for_kind, matching_approval_lease, - resume_context_mismatch_kind, run_state_error_kind, - validate_approval_request_matches_invocation, + claim_error_may_be_concurrent_resume, complete_run_after_side_effect, fail_run_if_configured, + invocation_fingerprint_for_kind, matching_approval_lease, resume_context_mismatch_kind, + run_state_error_kind, validate_approval_request_matches_invocation, }; +use crate::obligations::post_dispatch_obligations; use crate::{ CapabilityInvocationError, CapabilityInvocationRequest, CapabilityInvocationResult, - CapabilityResumeRequest, CapabilitySpawnRequest, CapabilitySpawnResult, + CapabilityObligationAbortRequest, CapabilityObligationCompletionRequest, + CapabilityObligationError, CapabilityObligationHandler, CapabilityObligationOutcome, + CapabilityObligationPhase, CapabilityObligationRequest, CapabilityResumeRequest, + CapabilitySpawnRequest, CapabilitySpawnResult, }; pub struct CapabilityHost<'a, D> @@ -32,6 +36,7 @@ where approval_requests: Option<&'a dyn ApprovalRequestStore>, capability_leases: Option<&'a dyn CapabilityLeaseStore>, process_manager: Option<&'a dyn ProcessManager>, + obligation_handler: Option<&'a dyn CapabilityObligationHandler>, } impl<'a, D> CapabilityHost<'a, D> @@ -51,6 +56,7 @@ where approval_requests: None, capability_leases: None, process_manager: None, + obligation_handler: None, } } @@ -102,6 +108,14 @@ where self } + /// Attaches the obligation handler that satisfies allow-decision + /// obligations before/after side effects. Without a handler, non-empty + /// obligations fail closed. + pub fn with_obligation_handler(mut self, handler: &'a dyn CapabilityObligationHandler) -> Self { + self.obligation_handler = Some(handler); + self + } + pub async fn invoke_json( &self, request: CapabilityInvocationRequest, @@ -146,6 +160,8 @@ where }); }; + let obligations; + let obligation_outcome; match self .authorizer .authorize_dispatch_with_trust( @@ -156,18 +172,34 @@ where ) .await { - Decision::Allow { obligations } => { - if let Err(error) = - ensure_no_obligations(&request.capability_id, obligations.into_vec()) - { - fail_run_if_configured( - self.run_state, - &scope, - invocation_id, - "UnsupportedObligations", + Decision::Allow { + obligations: allowed_obligations, + } => { + let allowed_obligations = allowed_obligations.into_vec(); + match self + .prepare_obligations( + CapabilityObligationPhase::Invoke, + &request.context, + &request.capability_id, + &request.estimate, + allowed_obligations.clone(), ) - .await; - return Err(error); + .await + { + Ok(outcome) => { + obligations = allowed_obligations; + obligation_outcome = outcome; + } + Err(error) => { + fail_run_if_configured( + self.run_state, + &scope, + invocation_id, + obligation_invocation_error_kind(&error), + ) + .await; + return Err(error); + } } } Decision::Deny { reason } => { @@ -295,22 +327,65 @@ where let dispatch = match self .dispatcher .dispatch_json(CapabilityDispatchRequest { - capability_id: request.capability_id, + capability_id: request.capability_id.clone(), scope: scope.clone(), - estimate: request.estimate, - mounts: None, - resource_reservation: None, + estimate: request.estimate.clone(), + mounts: obligation_outcome.mounts.clone(), + resource_reservation: obligation_outcome.resource_reservation.clone(), input: request.input, }) .await { Ok(dispatch) => dispatch, Err(error) => { + self.abort_obligations( + CapabilityObligationPhase::Invoke, + &request.context, + &request.capability_id, + &request.estimate, + obligations.as_slice(), + &obligation_outcome, + ) + .await; fail_run_if_configured(self.run_state, &scope, invocation_id, "Dispatch").await; return Err(CapabilityInvocationError::from(error)); } }; + let dispatch = match self + .complete_dispatch_obligations( + CapabilityObligationPhase::Invoke, + &request.context, + &request.capability_id, + &request.estimate, + obligations.as_slice(), + &dispatch, + ) + .await + { + Ok(dispatch) => dispatch, + Err(error) => { + let cleanup_outcome = CapabilityObligationOutcome::default(); + self.abort_obligations( + CapabilityObligationPhase::Invoke, + &request.context, + &request.capability_id, + &request.estimate, + obligations.as_slice(), + &cleanup_outcome, + ) + .await; + fail_run_if_configured( + self.run_state, + &scope, + invocation_id, + obligation_invocation_error_kind(&error), + ) + .await; + return Err(error); + } + }; + if let Some(run_state) = self.run_state { complete_run_after_side_effect( run_state, @@ -477,7 +552,7 @@ where let mut authorized_context = request.context.clone(); authorized_context.grants.grants.push(lease.grant.clone()); - match self + let obligations = match self .authorizer .authorize_dispatch_with_trust( &authorized_context, @@ -487,18 +562,9 @@ where ) .await { - Decision::Allow { obligations } => { - if let Err(error) = ensure_no_obligations(&capability_id, obligations.into_vec()) { - fail_run_if_configured( - Some(run_state), - &scope, - invocation_id, - "UnsupportedObligations", - ) - .await; - return Err(error); - } - } + Decision::Allow { + obligations: allowed_obligations, + } => allowed_obligations.into_vec(), Decision::Deny { reason } => { fail_run_if_configured( Some(run_state), @@ -524,7 +590,7 @@ where capability: request.capability_id, }); } - } + }; let claimed_lease = match capability_leases .claim(&scope, lease.grant.id, &invocation_fingerprint) @@ -553,20 +619,65 @@ where } }; + let obligation_outcome = match self + .prepare_obligations( + CapabilityObligationPhase::Resume, + &authorized_context, + &request.capability_id, + &request.estimate, + obligations.clone(), + ) + .await + { + Ok(outcome) => outcome, + Err(error) => { + fail_run_if_configured( + Some(run_state), + &scope, + invocation_id, + obligation_invocation_error_kind(&error), + ) + .await; + if let Err(revoke_error) = capability_leases + .revoke(&scope, claimed_lease.grant.id) + .await + { + warn!( + lease_id = %claimed_lease.grant.id, + invocation_id = %invocation_id, + capability_id = %capability_id, + obligation_error = %error, + revoke_error_kind = capability_lease_error_kind(&revoke_error), + "capability lease revoke failed after obligation failure; lease may remain claimed", + ); + } + return Err(error); + } + }; + let dispatch = match self .dispatcher .dispatch_json(CapabilityDispatchRequest { - capability_id: request.capability_id, + capability_id: request.capability_id.clone(), scope: scope.clone(), - estimate: request.estimate, - mounts: None, - resource_reservation: None, + estimate: request.estimate.clone(), + mounts: obligation_outcome.mounts.clone(), + resource_reservation: obligation_outcome.resource_reservation.clone(), input: request.input, }) .await { Ok(dispatch) => dispatch, Err(error) => { + self.abort_obligations( + CapabilityObligationPhase::Resume, + &authorized_context, + &request.capability_id, + &request.estimate, + obligations.as_slice(), + &obligation_outcome, + ) + .await; fail_run_if_configured(Some(run_state), &scope, invocation_id, "Dispatch").await; let invocation_error = CapabilityInvocationError::from(error); if let Err(revoke_error) = capability_leases @@ -586,6 +697,53 @@ where } }; + let dispatch = match self + .complete_dispatch_obligations( + CapabilityObligationPhase::Resume, + &authorized_context, + &request.capability_id, + &request.estimate, + obligations.as_slice(), + &dispatch, + ) + .await + { + Ok(dispatch) => dispatch, + Err(error) => { + let cleanup_outcome = CapabilityObligationOutcome::default(); + self.abort_obligations( + CapabilityObligationPhase::Resume, + &authorized_context, + &request.capability_id, + &request.estimate, + obligations.as_slice(), + &cleanup_outcome, + ) + .await; + fail_run_if_configured( + Some(run_state), + &scope, + invocation_id, + obligation_invocation_error_kind(&error), + ) + .await; + if let Err(revoke_error) = capability_leases + .revoke(&scope, claimed_lease.grant.id) + .await + { + warn!( + lease_id = %claimed_lease.grant.id, + invocation_id = %invocation_id, + capability_id = %capability_id, + obligation_error = %error, + revoke_error_kind = capability_lease_error_kind(&revoke_error), + "capability lease revoke failed after completion obligation failure; lease may remain claimed", + ); + } + return Err(error); + } + }; + if let Err(error) = capability_leases .consume(&scope, claimed_lease.grant.id) .await @@ -767,7 +925,7 @@ where let mut authorized_context = request.context.clone(); authorized_context.grants.grants.push(lease.grant.clone()); - match self + let obligations = match self .authorizer .authorize_spawn_with_trust( &authorized_context, @@ -777,18 +935,9 @@ where ) .await { - Decision::Allow { obligations } => { - if let Err(error) = ensure_no_obligations(&capability_id, obligations.into_vec()) { - fail_run_if_configured( - Some(run_state), - &scope, - invocation_id, - "UnsupportedObligations", - ) - .await; - return Err(error); - } - } + Decision::Allow { + obligations: allowed_obligations, + } => allowed_obligations.into_vec(), Decision::Deny { reason } => { fail_run_if_configured( Some(run_state), @@ -814,7 +963,7 @@ where capability: request.capability_id, }); } - } + }; let claimed_lease = match capability_leases .claim(&scope, lease.grant.id, &invocation_fingerprint) @@ -843,6 +992,50 @@ where } }; + let obligation_outcome = match self + .prepare_obligations( + CapabilityObligationPhase::Spawn, + &authorized_context, + &request.capability_id, + &request.estimate, + obligations.clone(), + ) + .await + { + Ok(outcome) => outcome, + Err(error) => { + fail_run_if_configured( + Some(run_state), + &scope, + invocation_id, + obligation_invocation_error_kind(&error), + ) + .await; + if let Err(revoke_error) = capability_leases + .revoke(&scope, claimed_lease.grant.id) + .await + { + warn!( + lease_id = %claimed_lease.grant.id, + invocation_id = %invocation_id, + capability_id = %capability_id, + obligation_error = %error, + revoke_error_kind = capability_lease_error_kind(&revoke_error), + "capability lease revoke failed after spawn obligation failure; lease may remain claimed", + ); + } + return Err(error); + } + }; + let effective_mounts = obligation_outcome + .mounts + .clone() + .unwrap_or_else(|| authorized_context.mounts.clone()); + let resource_reservation_id = obligation_outcome + .resource_reservation + .as_ref() + .map(|reservation| reservation.id); + let process = match process_manager .spawn(ProcessStart { process_id: ProcessId::new(), @@ -850,18 +1043,27 @@ where invocation_id, scope: scope.clone(), extension_id: descriptor.provider.clone(), - capability_id: request.capability_id, + capability_id: request.capability_id.clone(), runtime: descriptor.runtime, - grants: authorized_context.grants, - mounts: authorized_context.mounts, - estimated_resources: request.estimate, - resource_reservation_id: None, + grants: authorized_context.grants.clone(), + mounts: effective_mounts, + estimated_resources: request.estimate.clone(), + resource_reservation_id, input: request.input, }) .await { Ok(process) => process, Err(error) => { + self.abort_obligations( + CapabilityObligationPhase::Spawn, + &authorized_context, + &request.capability_id, + &request.estimate, + obligations.as_slice(), + &obligation_outcome, + ) + .await; fail_run_if_configured(Some(run_state), &scope, invocation_id, "ProcessSpawn") .await; let invocation_error = CapabilityInvocationError::from(error); @@ -949,6 +1151,8 @@ where }); }; + let obligations; + let obligation_outcome; match self .authorizer .authorize_spawn_with_trust( @@ -959,18 +1163,34 @@ where ) .await { - Decision::Allow { obligations } => { - if let Err(error) = - ensure_no_obligations(&request.capability_id, obligations.into_vec()) - { - fail_run_if_configured( - self.run_state, - &scope, - invocation_id, - "UnsupportedObligations", + Decision::Allow { + obligations: allowed_obligations, + } => { + let allowed_obligations = allowed_obligations.into_vec(); + match self + .prepare_obligations( + CapabilityObligationPhase::Spawn, + &request.context, + &request.capability_id, + &request.estimate, + allowed_obligations.clone(), ) - .await; - return Err(error); + .await + { + Ok(outcome) => { + obligations = allowed_obligations; + obligation_outcome = outcome; + } + Err(error) => { + fail_run_if_configured( + self.run_state, + &scope, + invocation_id, + obligation_invocation_error_kind(&error), + ) + .await; + return Err(error); + } } } Decision::Deny { reason } => { @@ -1095,6 +1315,15 @@ where } } + let effective_mounts = obligation_outcome + .mounts + .clone() + .unwrap_or_else(|| request.context.mounts.clone()); + let resource_reservation_id = obligation_outcome + .resource_reservation + .as_ref() + .map(|reservation| reservation.id); + let process = match process_manager .spawn(ProcessStart { process_id: ProcessId::new(), @@ -1102,18 +1331,27 @@ where invocation_id, scope: scope.clone(), extension_id: descriptor.provider.clone(), - capability_id: request.capability_id, + capability_id: request.capability_id.clone(), runtime: descriptor.runtime, - grants: request.context.grants, - mounts: request.context.mounts, - estimated_resources: request.estimate, - resource_reservation_id: None, + grants: request.context.grants.clone(), + mounts: effective_mounts, + estimated_resources: request.estimate.clone(), + resource_reservation_id, input: request.input, }) .await { Ok(process) => process, Err(error) => { + self.abort_obligations( + CapabilityObligationPhase::Spawn, + &request.context, + &request.capability_id, + &request.estimate, + obligations.as_slice(), + &obligation_outcome, + ) + .await; fail_run_if_configured(self.run_state, &scope, invocation_id, "ProcessSpawn").await; return Err(CapabilityInvocationError::from(error)); } @@ -1132,4 +1370,137 @@ where Ok(CapabilitySpawnResult { process }) } + + async fn prepare_obligations( + &self, + phase: CapabilityObligationPhase, + context: &ExecutionContext, + capability_id: &ironclaw_host_api::CapabilityId, + estimate: &ResourceEstimate, + obligations: Vec, + ) -> Result { + if obligations.is_empty() { + return Ok(CapabilityObligationOutcome::default()); + } + if matches!(phase, CapabilityObligationPhase::Spawn) { + let unsupported = post_dispatch_obligations(&obligations); + if !unsupported.is_empty() { + return Err(CapabilityInvocationError::UnsupportedObligations { + capability: capability_id.clone(), + obligations: unsupported, + }); + } + } + let Some(handler) = self.obligation_handler else { + return Err(CapabilityInvocationError::UnsupportedObligations { + capability: capability_id.clone(), + obligations, + }); + }; + handler + .prepare(CapabilityObligationRequest { + phase, + context, + capability_id, + estimate, + obligations: obligations.as_slice(), + }) + .await + .map_err(|error| obligation_error_to_invocation(capability_id, error)) + } + + async fn complete_dispatch_obligations( + &self, + phase: CapabilityObligationPhase, + context: &ExecutionContext, + capability_id: &ironclaw_host_api::CapabilityId, + estimate: &ResourceEstimate, + obligations: &[Obligation], + dispatch: &CapabilityDispatchResult, + ) -> Result { + if obligations.is_empty() { + return Ok(dispatch.clone()); + } + let Some(handler) = self.obligation_handler else { + let unsupported = post_dispatch_obligations(obligations); + if unsupported.is_empty() { + return Ok(dispatch.clone()); + } + return Err(CapabilityInvocationError::UnsupportedObligations { + capability: capability_id.clone(), + obligations: unsupported, + }); + }; + handler + .complete_dispatch(CapabilityObligationCompletionRequest { + phase, + context, + capability_id, + estimate, + obligations, + dispatch, + }) + .await + .map_err(|error| obligation_error_to_invocation(capability_id, error)) + } + + async fn abort_obligations( + &self, + phase: CapabilityObligationPhase, + context: &ExecutionContext, + capability_id: &ironclaw_host_api::CapabilityId, + estimate: &ResourceEstimate, + obligations: &[Obligation], + outcome: &CapabilityObligationOutcome, + ) { + if obligations.is_empty() { + return; + } + let Some(handler) = self.obligation_handler else { + return; + }; + if let Err(error) = handler + .abort(CapabilityObligationAbortRequest { + phase, + context, + capability_id, + estimate, + obligations, + outcome, + }) + .await + { + warn!( + capability_id = %capability_id, + error = %error, + "obligation abort failed after downstream side-effect failure", + ); + } + } +} + +fn obligation_error_to_invocation( + capability_id: &ironclaw_host_api::CapabilityId, + error: CapabilityObligationError, +) -> CapabilityInvocationError { + match error { + CapabilityObligationError::Unsupported { obligations } => { + CapabilityInvocationError::UnsupportedObligations { + capability: capability_id.clone(), + obligations, + } + } + CapabilityObligationError::Failed { kind } => CapabilityInvocationError::ObligationFailed { + capability: capability_id.clone(), + kind, + }, + } +} + +fn obligation_invocation_error_kind(error: &CapabilityInvocationError) -> &'static str { + match error { + CapabilityInvocationError::UnsupportedObligations { .. } => "UnsupportedObligations", + CapabilityInvocationError::ObligationFailed { .. } => "ObligationFailed", + _ => "Obligation", + } } diff --git a/crates/ironclaw_capabilities/src/lib.rs b/crates/ironclaw_capabilities/src/lib.rs index 05319e399dc..eead0815cc7 100644 --- a/crates/ironclaw_capabilities/src/lib.rs +++ b/crates/ironclaw_capabilities/src/lib.rs @@ -7,10 +7,16 @@ mod error; mod helpers; mod host; +mod obligations; mod requests; pub use error::{CapabilityInvocationError, ResumeContextMismatchKind}; pub use host::CapabilityHost; +pub use obligations::{ + CapabilityObligationAbortRequest, CapabilityObligationCompletionRequest, + CapabilityObligationError, CapabilityObligationFailureKind, CapabilityObligationHandler, + CapabilityObligationOutcome, CapabilityObligationPhase, CapabilityObligationRequest, +}; pub use requests::{ CapabilityInvocationRequest, CapabilityInvocationResult, CapabilityResumeRequest, CapabilitySpawnRequest, CapabilitySpawnResult, diff --git a/crates/ironclaw_capabilities/src/obligations.rs b/crates/ironclaw_capabilities/src/obligations.rs new file mode 100644 index 00000000000..acdcfd76394 --- /dev/null +++ b/crates/ironclaw_capabilities/src/obligations.rs @@ -0,0 +1,142 @@ +use async_trait::async_trait; +use ironclaw_host_api::{ + CapabilityDispatchResult, CapabilityId, ExecutionContext, MountView, Obligation, + ResourceEstimate, ResourceReservation, +}; +use thiserror::Error; + +/// Capability-host phase where authorization obligations are satisfied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapabilityObligationPhase { + Invoke, + Resume, + Spawn, +} + +/// Request passed to a configured obligation handler before side effects continue. +pub struct CapabilityObligationRequest<'a> { + pub phase: CapabilityObligationPhase, + pub context: &'a ExecutionContext, + pub capability_id: &'a CapabilityId, + pub estimate: &'a ResourceEstimate, + pub obligations: &'a [Obligation], +} + +/// Effects produced by pre-dispatch obligation handling. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CapabilityObligationOutcome { + pub mounts: Option, + pub resource_reservation: Option, +} + +/// Request passed to a configured obligation handler after successful dispatch. +pub struct CapabilityObligationCompletionRequest<'a> { + pub phase: CapabilityObligationPhase, + pub context: &'a ExecutionContext, + pub capability_id: &'a CapabilityId, + pub estimate: &'a ResourceEstimate, + pub obligations: &'a [Obligation], + pub dispatch: &'a CapabilityDispatchResult, +} + +/// Request passed to a configured obligation handler to clean up prepared effects. +pub struct CapabilityObligationAbortRequest<'a> { + pub phase: CapabilityObligationPhase, + pub context: &'a ExecutionContext, + pub capability_id: &'a CapabilityId, + pub estimate: &'a ResourceEstimate, + pub obligations: &'a [Obligation], + pub outcome: &'a CapabilityObligationOutcome, +} + +/// Stable, sanitized obligation-handler failure categories. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapabilityObligationFailureKind { + Audit, + Mount, + Network, + Output, + Resource, + Secret, +} + +impl std::fmt::Display for CapabilityObligationFailureKind { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Audit => "Audit", + Self::Mount => "Mount", + Self::Network => "Network", + Self::Output => "Output", + Self::Resource => "Resource", + Self::Secret => "Secret", + }) + } +} + +/// Obligation handler failures. Variants intentionally avoid raw input/output. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum CapabilityObligationError { + #[error("unsupported authorization obligations: {count} item(s)", count = obligations.len())] + Unsupported { obligations: Vec }, + #[error("authorization obligation failed: {kind}")] + Failed { + kind: CapabilityObligationFailureKind, + }, +} + +/// Host-provided obligation satisfaction seam. +#[async_trait] +pub trait CapabilityObligationHandler: Send + Sync { + /// Satisfies all obligations before downstream side effects. + async fn satisfy( + &self, + request: CapabilityObligationRequest<'_>, + ) -> Result<(), CapabilityObligationError>; + + /// Satisfies obligations and returns narrowed dispatch effects. + async fn prepare( + &self, + request: CapabilityObligationRequest<'_>, + ) -> Result { + self.satisfy(request).await?; + Ok(CapabilityObligationOutcome::default()) + } + + /// Cleans up effects created by [`Self::prepare`] after downstream failure. + async fn abort( + &self, + _request: CapabilityObligationAbortRequest<'_>, + ) -> Result<(), CapabilityObligationError> { + Ok(()) + } + + /// Completes dispatch-result obligations before output returns to callers. + async fn complete_dispatch( + &self, + request: CapabilityObligationCompletionRequest<'_>, + ) -> Result { + let unsupported = post_dispatch_obligations(request.obligations); + if unsupported.is_empty() { + Ok(request.dispatch.clone()) + } else { + Err(CapabilityObligationError::Unsupported { + obligations: unsupported, + }) + } + } +} + +pub(crate) fn post_dispatch_obligations(obligations: &[Obligation]) -> Vec { + obligations + .iter() + .filter(|obligation| { + matches!( + obligation, + Obligation::AuditAfter + | Obligation::RedactOutput + | Obligation::EnforceOutputLimit { .. } + ) + }) + .cloned() + .collect() +} diff --git a/crates/ironclaw_capabilities/tests/capability_obligation_handler_contract.rs b/crates/ironclaw_capabilities/tests/capability_obligation_handler_contract.rs new file mode 100644 index 00000000000..4fa985cb857 --- /dev/null +++ b/crates/ironclaw_capabilities/tests/capability_obligation_handler_contract.rs @@ -0,0 +1,606 @@ +mod support; + +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; + +use async_trait::async_trait; +use ironclaw_authorization::TrustAwareCapabilityDispatchAuthorizer; +use ironclaw_capabilities::*; +use ironclaw_host_api::*; +use ironclaw_processes::*; +use ironclaw_trust::TrustDecision; +use serde_json::json; + +use support::*; + +#[tokio::test] +async fn capability_host_uses_obligation_handler_before_dispatch() { + let registry = registry_with_echo_capability(); + let dispatcher = RecordingDispatcher::default(); + let authorizer = ObligatingAuthorizer::new(vec![Obligation::AuditBefore]); + let handler = RecordingObligationHandler::default(); + let host = + CapabilityHost::new(®istry, &dispatcher, &authorizer).with_obligation_handler(&handler); + + let result = host + .invoke_json(CapabilityInvocationRequest { + context: execution_context(CapabilitySet::default()), + capability_id: capability_id(), + estimate: ResourceEstimate::default(), + input: json!({"message": "handled"}), + trust_decision: trust_decision(), + }) + .await + .unwrap(); + + assert_eq!(result.dispatch.output, json!({"ok": true})); + assert!(dispatcher.has_request()); + assert_eq!( + handler.records(), + vec![ObligationRecord { + phase: CapabilityObligationPhase::Invoke, + obligations: vec![Obligation::AuditBefore], + }] + ); +} + +#[tokio::test] +async fn capability_host_still_fails_closed_when_handler_rejects_obligations() { + let registry = registry_with_echo_capability(); + let dispatcher = RecordingDispatcher::default(); + let authorizer = ObligatingAuthorizer::new(vec![Obligation::RedactOutput]); + let handler = RecordingObligationHandler::default(); + let host = + CapabilityHost::new(®istry, &dispatcher, &authorizer).with_obligation_handler(&handler); + + let err = host + .invoke_json(CapabilityInvocationRequest { + context: execution_context(CapabilitySet::default()), + capability_id: capability_id(), + estimate: ResourceEstimate::default(), + input: json!({"message": "must not dispatch"}), + trust_decision: trust_decision(), + }) + .await + .unwrap_err(); + + assert!(matches!( + err, + CapabilityInvocationError::UnsupportedObligations { .. } + )); + assert!(!dispatcher.has_request()); + assert_eq!(handler.records().len(), 1); +} + +#[tokio::test] +async fn capability_host_passes_prepared_effects_to_dispatch() { + let registry = registry_with_echo_capability(); + let reservation_id = ResourceReservationId::new(); + let narrowed_mounts = mount_view( + "/workspace", + "/projects/demo", + MountPermissions::read_only(), + ); + let dispatcher = RecordingDispatcher::default(); + let mut context = execution_context(CapabilitySet::default()); + context.mounts = mount_view( + "/workspace", + "/projects/demo", + MountPermissions::read_write(), + ); + let estimate = ResourceEstimate { + concurrency_slots: Some(1), + ..ResourceEstimate::default() + }; + let scope = context.resource_scope.clone(); + let authorizer = ObligatingAuthorizer::new(vec![ + Obligation::UseScopedMounts { + mounts: narrowed_mounts.clone(), + }, + Obligation::ReserveResources { reservation_id }, + ]); + let handler = EffectObligationHandler { + mounts: Some(narrowed_mounts.clone()), + reservation: Some(ResourceReservation { + id: reservation_id, + scope: scope.clone(), + estimate: estimate.clone(), + }), + aborted: Arc::new(AtomicBool::new(false)), + }; + let host = + CapabilityHost::new(®istry, &dispatcher, &authorizer).with_obligation_handler(&handler); + + host.invoke_json(CapabilityInvocationRequest { + context, + capability_id: capability_id(), + estimate: estimate.clone(), + input: json!({"message": "prepared effects"}), + trust_decision: trust_decision(), + }) + .await + .unwrap(); + + let request = dispatcher.take_request(); + assert_eq!(request.scope, scope); + assert_eq!(request.estimate, estimate); + assert_eq!(request.mounts, Some(narrowed_mounts)); + assert_eq!( + request + .resource_reservation + .as_ref() + .map(|reservation| reservation.id), + Some(reservation_id) + ); +} + +#[tokio::test] +async fn capability_host_completes_post_dispatch_obligations_before_returning() { + let registry = registry_with_echo_capability(); + let dispatcher = OutputDispatcher::new(json!({"token": "secret-token"})); + let authorizer = ObligatingAuthorizer::new(vec![Obligation::RedactOutput]); + let handler = RedactingObligationHandler; + let host = + CapabilityHost::new(®istry, &dispatcher, &authorizer).with_obligation_handler(&handler); + + let result = host + .invoke_json(CapabilityInvocationRequest { + context: execution_context(CapabilitySet::default()), + capability_id: capability_id(), + estimate: ResourceEstimate::default(), + input: json!({"message": "post dispatch"}), + trust_decision: trust_decision(), + }) + .await + .unwrap(); + + assert_eq!(result.dispatch.output, json!({"token": "[REDACTED]"})); +} + +#[tokio::test] +async fn capability_host_aborts_staged_obligations_when_completion_fails() { + let registry = registry_with_echo_capability(); + let dispatcher = OutputDispatcher::new(json!({"oversized": true})); + let aborted_outcome = Arc::new(Mutex::new(None)); + let reservation_id = ResourceReservationId::new(); + let context = execution_context(CapabilitySet::default()); + let estimate = ResourceEstimate::default(); + let handler = FailingCompletionObligationHandler { + reservation: ResourceReservation { + id: reservation_id, + scope: context.resource_scope.clone(), + estimate: estimate.clone(), + }, + aborted_outcome: Arc::clone(&aborted_outcome), + }; + let authorizer = ObligatingAuthorizer::new(vec![ + Obligation::ReserveResources { reservation_id }, + Obligation::RedactOutput, + ]); + let host = + CapabilityHost::new(®istry, &dispatcher, &authorizer).with_obligation_handler(&handler); + + let err = host + .invoke_json(CapabilityInvocationRequest { + context, + capability_id: capability_id(), + estimate, + input: json!({"message": "completion fails"}), + trust_decision: trust_decision(), + }) + .await + .unwrap_err(); + + assert!(matches!( + err, + CapabilityInvocationError::ObligationFailed { .. } + )); + let aborted = aborted_outcome.lock().unwrap().clone().unwrap(); + assert!(aborted.resource_reservation.is_none()); +} + +#[tokio::test] +async fn capability_host_passes_prepared_mounts_to_process_start() { + let registry = registry_with_echo_capability(); + let dispatcher = RecordingDispatcher::default(); + let narrowed_mounts = mount_view( + "/workspace", + "/projects/demo", + MountPermissions::read_only(), + ); + let authorizer = ObligatingAuthorizer::new(vec![Obligation::UseScopedMounts { + mounts: narrowed_mounts.clone(), + }]); + let handler = EffectObligationHandler { + mounts: Some(narrowed_mounts.clone()), + reservation: None, + aborted: Arc::new(AtomicBool::new(false)), + }; + let process_manager = MountRecordingProcessManager::default(); + let host = CapabilityHost::new(®istry, &dispatcher, &authorizer) + .with_obligation_handler(&handler) + .with_process_manager(&process_manager); + let mut context = execution_context(CapabilitySet::default()); + context.mounts = mount_view( + "/workspace", + "/projects/demo", + MountPermissions::read_write(), + ); + + host.spawn_json(CapabilitySpawnRequest { + context, + capability_id: capability_id(), + estimate: ResourceEstimate::default(), + input: json!({"message": "prepared mount"}), + trust_decision: trust_decision(), + }) + .await + .unwrap(); + + assert_eq!(process_manager.mounts(), Some(narrowed_mounts)); +} + +#[tokio::test] +async fn capability_host_aborts_prepared_obligations_when_process_start_fails() { + let registry = registry_with_echo_capability(); + let dispatcher = RecordingDispatcher::default(); + let context = execution_context(CapabilitySet::default()); + let estimate = ResourceEstimate::default(); + let aborted = Arc::new(AtomicBool::new(false)); + let reservation_id = ResourceReservationId::new(); + let handler = EffectObligationHandler { + mounts: None, + reservation: Some(ResourceReservation { + id: reservation_id, + scope: context.resource_scope.clone(), + estimate: estimate.clone(), + }), + aborted: Arc::clone(&aborted), + }; + let authorizer = + ObligatingAuthorizer::new(vec![Obligation::ReserveResources { reservation_id }]); + let process_manager = FailingProcessManager; + let host = CapabilityHost::new(®istry, &dispatcher, &authorizer) + .with_obligation_handler(&handler) + .with_process_manager(&process_manager); + + let err = host + .spawn_json(CapabilitySpawnRequest { + context, + capability_id: capability_id(), + estimate, + input: json!({"message": "spawn fails"}), + trust_decision: trust_decision(), + }) + .await + .unwrap_err(); + + assert!(matches!(err, CapabilityInvocationError::Process { .. })); + assert!(aborted.load(Ordering::SeqCst)); + assert!(!dispatcher.has_request()); +} + +#[tokio::test] +async fn capability_host_rejects_post_output_obligations_for_spawn_before_handler_or_process() { + let registry = registry_with_echo_capability(); + let dispatcher = RecordingDispatcher::default(); + let authorizer = ObligatingAuthorizer::new(vec![Obligation::RedactOutput]); + let observed = Arc::new(AtomicBool::new(false)); + let handler = FlaggingObligationHandler { + observed: Arc::clone(&observed), + }; + let process_manager = PanicProcessManager; + let host = CapabilityHost::new(®istry, &dispatcher, &authorizer) + .with_obligation_handler(&handler) + .with_process_manager(&process_manager); + + let err = host + .spawn_json(CapabilitySpawnRequest { + context: execution_context(CapabilitySet::default()), + capability_id: capability_id(), + estimate: ResourceEstimate::default(), + input: json!({"message": "must not spawn"}), + trust_decision: trust_decision(), + }) + .await + .unwrap_err(); + + assert!(matches!( + err, + CapabilityInvocationError::UnsupportedObligations { .. } + )); + assert!(!observed.load(Ordering::SeqCst)); +} + +struct ObligatingAuthorizer { + obligations: Vec, +} + +impl ObligatingAuthorizer { + fn new(obligations: Vec) -> Self { + Self { obligations } + } +} + +#[async_trait] +impl TrustAwareCapabilityDispatchAuthorizer for ObligatingAuthorizer { + async fn authorize_dispatch_with_trust( + &self, + _context: &ExecutionContext, + _descriptor: &CapabilityDescriptor, + _estimate: &ResourceEstimate, + _trust_decision: &TrustDecision, + ) -> Decision { + Decision::Allow { + obligations: Obligations::new(self.obligations.clone()).unwrap(), + } + } + + async fn authorize_spawn_with_trust( + &self, + _context: &ExecutionContext, + _descriptor: &CapabilityDescriptor, + _estimate: &ResourceEstimate, + _trust_decision: &TrustDecision, + ) -> Decision { + Decision::Allow { + obligations: Obligations::new(self.obligations.clone()).unwrap(), + } + } +} + +#[derive(Default)] +struct RecordingObligationHandler { + records: Mutex>, +} + +impl RecordingObligationHandler { + fn records(&self) -> Vec { + self.records.lock().unwrap().clone() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ObligationRecord { + phase: CapabilityObligationPhase, + obligations: Vec, +} + +#[async_trait] +impl CapabilityObligationHandler for RecordingObligationHandler { + async fn satisfy( + &self, + request: CapabilityObligationRequest<'_>, + ) -> Result<(), CapabilityObligationError> { + self.records.lock().unwrap().push(ObligationRecord { + phase: request.phase, + obligations: request.obligations.to_vec(), + }); + if request + .obligations + .iter() + .all(|obligation| matches!(obligation, Obligation::AuditBefore)) + { + Ok(()) + } else { + Err(CapabilityObligationError::Unsupported { + obligations: request.obligations.to_vec(), + }) + } + } +} + +struct EffectObligationHandler { + mounts: Option, + reservation: Option, + aborted: Arc, +} + +#[async_trait] +impl CapabilityObligationHandler for EffectObligationHandler { + async fn satisfy( + &self, + _request: CapabilityObligationRequest<'_>, + ) -> Result<(), CapabilityObligationError> { + Ok(()) + } + + async fn prepare( + &self, + _request: CapabilityObligationRequest<'_>, + ) -> Result { + Ok(CapabilityObligationOutcome { + mounts: self.mounts.clone(), + resource_reservation: self.reservation.clone(), + }) + } + + async fn abort( + &self, + _request: CapabilityObligationAbortRequest<'_>, + ) -> Result<(), CapabilityObligationError> { + self.aborted.store(true, Ordering::SeqCst); + Ok(()) + } +} + +struct FailingCompletionObligationHandler { + reservation: ResourceReservation, + aborted_outcome: Arc>>, +} + +#[async_trait] +impl CapabilityObligationHandler for FailingCompletionObligationHandler { + async fn satisfy( + &self, + _request: CapabilityObligationRequest<'_>, + ) -> Result<(), CapabilityObligationError> { + Ok(()) + } + + async fn prepare( + &self, + _request: CapabilityObligationRequest<'_>, + ) -> Result { + Ok(CapabilityObligationOutcome { + mounts: None, + resource_reservation: Some(self.reservation.clone()), + }) + } + + async fn complete_dispatch( + &self, + _request: CapabilityObligationCompletionRequest<'_>, + ) -> Result { + Err(CapabilityObligationError::Failed { + kind: CapabilityObligationFailureKind::Output, + }) + } + + async fn abort( + &self, + request: CapabilityObligationAbortRequest<'_>, + ) -> Result<(), CapabilityObligationError> { + *self.aborted_outcome.lock().unwrap() = Some(request.outcome.clone()); + Ok(()) + } +} + +struct RedactingObligationHandler; + +#[async_trait] +impl CapabilityObligationHandler for RedactingObligationHandler { + async fn satisfy( + &self, + request: CapabilityObligationRequest<'_>, + ) -> Result<(), CapabilityObligationError> { + assert_eq!(request.obligations, &[Obligation::RedactOutput]); + Ok(()) + } + + async fn complete_dispatch( + &self, + request: CapabilityObligationCompletionRequest<'_>, + ) -> Result { + assert_eq!(request.obligations, &[Obligation::RedactOutput]); + let mut dispatch = request.dispatch.clone(); + dispatch.output = json!({"token": "[REDACTED]"}); + Ok(dispatch) + } +} + +struct FlaggingObligationHandler { + observed: Arc, +} + +#[async_trait] +impl CapabilityObligationHandler for FlaggingObligationHandler { + async fn satisfy( + &self, + _request: CapabilityObligationRequest<'_>, + ) -> Result<(), CapabilityObligationError> { + self.observed.store(true, Ordering::SeqCst); + Ok(()) + } +} + +#[derive(Default)] +struct MountRecordingProcessManager { + mounts: Mutex>, +} + +impl MountRecordingProcessManager { + fn mounts(&self) -> Option { + self.mounts.lock().unwrap().clone() + } +} + +#[async_trait] +impl ProcessManager for MountRecordingProcessManager { + async fn spawn(&self, start: ProcessStart) -> Result { + *self.mounts.lock().unwrap() = Some(start.mounts.clone()); + Ok(process_record_from_start(start, ProcessStatus::Running)) + } +} + +struct FailingProcessManager; + +#[async_trait] +impl ProcessManager for FailingProcessManager { + async fn spawn(&self, start: ProcessStart) -> Result { + Err(ProcessError::ProcessAlreadyExists { + process_id: start.process_id, + }) + } +} + +struct PanicProcessManager; + +#[async_trait] +impl ProcessManager for PanicProcessManager { + async fn spawn(&self, _start: ProcessStart) -> Result { + panic!("process manager must not be called for unsupported post-output spawn obligations") + } +} + +struct OutputDispatcher { + output: serde_json::Value, +} + +impl OutputDispatcher { + fn new(output: serde_json::Value) -> Self { + Self { output } + } +} + +#[async_trait] +impl CapabilityDispatcher for OutputDispatcher { + async fn dispatch_json( + &self, + request: CapabilityDispatchRequest, + ) -> Result { + Ok(CapabilityDispatchResult { + capability_id: request.capability_id, + provider: extension_id(), + runtime: RuntimeKind::Wasm, + output: self.output.clone(), + usage: ResourceUsage::default(), + receipt: ResourceReceipt { + id: ResourceReservationId::new(), + scope: request.scope, + status: ReservationStatus::Reconciled, + estimate: request.estimate, + actual: Some(ResourceUsage::default()), + }, + }) + } +} + +fn process_record_from_start(start: ProcessStart, status: ProcessStatus) -> ProcessRecord { + ProcessRecord { + process_id: start.process_id, + parent_process_id: start.parent_process_id, + invocation_id: start.invocation_id, + scope: start.scope, + extension_id: start.extension_id, + capability_id: start.capability_id, + runtime: start.runtime, + status, + grants: start.grants, + mounts: start.mounts, + estimated_resources: start.estimated_resources, + resource_reservation_id: start.resource_reservation_id, + error_kind: None, + } +} + +fn mount_view(alias: &str, target: &str, permissions: MountPermissions) -> MountView { + MountView::new(vec![MountGrant::new( + MountAlias::new(alias).unwrap(), + VirtualPath::new(target).unwrap(), + permissions, + )]) + .unwrap() +} diff --git a/crates/ironclaw_host_runtime/Cargo.toml b/crates/ironclaw_host_runtime/Cargo.toml index c5d821ad04f..8c944e906e7 100644 --- a/crates/ironclaw_host_runtime/Cargo.toml +++ b/crates/ironclaw_host_runtime/Cargo.toml @@ -6,12 +6,15 @@ publish = false [dependencies] async-trait = "0.1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } ironclaw_authorization = { path = "../ironclaw_authorization" } ironclaw_capabilities = { path = "../ironclaw_capabilities" } +ironclaw_events = { path = "../ironclaw_events" } ironclaw_extensions = { path = "../ironclaw_extensions" } ironclaw_host_api = { path = "../ironclaw_host_api" } ironclaw_network = { path = "../ironclaw_network" } ironclaw_processes = { path = "../ironclaw_processes" } +ironclaw_resources = { path = "../ironclaw_resources" } ironclaw_run_state = { path = "../ironclaw_run_state" } ironclaw_safety = { path = "../ironclaw_safety" } ironclaw_secrets = { path = "../ironclaw_secrets" } @@ -24,5 +27,4 @@ tracing = "0.1" url = "2" [dev-dependencies] -chrono = { version = "0.4", default-features = false, features = ["clock"] } tokio = { version = "1", features = ["macros", "rt", "time"] } diff --git a/crates/ironclaw_host_runtime/src/lib.rs b/crates/ironclaw_host_runtime/src/lib.rs index 100052e1dae..9ae92cc051f 100644 --- a/crates/ironclaw_host_runtime/src/lib.rs +++ b/crates/ironclaw_host_runtime/src/lib.rs @@ -43,8 +43,13 @@ use serde_json::Value; use std::fmt; use thiserror::Error; +mod obligations; mod production; +pub use obligations::{ + BuiltinObligationHandler, NetworkObligationPolicyStore, RuntimeSecretInjectionStore, + RuntimeSecretInjectionStoreError, +}; pub use production::DefaultHostRuntime; /// Stable, validated idempotency key supplied by upper turn/loop services. diff --git a/crates/ironclaw_host_runtime/src/obligations.rs b/crates/ironclaw_host_runtime/src/obligations.rs new file mode 100644 index 00000000000..b2353f81450 --- /dev/null +++ b/crates/ironclaw_host_runtime/src/obligations.rs @@ -0,0 +1,867 @@ +use std::{ + collections::HashMap, + fmt, + sync::{Arc, Mutex}, +}; + +use async_trait::async_trait; +use chrono::Utc; +use ironclaw_capabilities::{ + CapabilityObligationAbortRequest, CapabilityObligationCompletionRequest, + CapabilityObligationError, CapabilityObligationFailureKind, CapabilityObligationHandler, + CapabilityObligationOutcome, CapabilityObligationPhase, CapabilityObligationRequest, +}; +use ironclaw_events::AuditSink; +use ironclaw_host_api::{ + ActionResultSummary, ActionSummary, AuditEnvelope, AuditEventId, AuditStage, + CapabilityDispatchResult, CapabilityId, DecisionSummary, EffectKind, MountView, NetworkPolicy, + Obligation, ResourceReservation, ResourceScope, SecretHandle, +}; +use ironclaw_resources::ResourceGovernor; +use ironclaw_safety::LeakDetector; +use ironclaw_secrets::{SecretMaterial, SecretStore}; + +/// One-shot runtime secret material staged after `InjectSecretOnce` lease consumption. +/// +/// The store is keyed by scoped invocation, capability, and handle. Runtime adapters +/// must use `take(...)` so staged material is removed before it can be reused. +#[derive(Clone, Default)] +pub struct RuntimeSecretInjectionStore { + secrets: Arc>>, +} + +impl RuntimeSecretInjectionStore { + pub fn new() -> Self { + Self::default() + } + + pub fn insert( + &self, + scope: &ResourceScope, + capability_id: &CapabilityId, + handle: &SecretHandle, + material: SecretMaterial, + ) -> Result<(), RuntimeSecretInjectionStoreError> { + self.lock()?.insert( + RuntimeSecretInjectionKey::new(scope, capability_id, handle), + material, + ); + Ok(()) + } + + pub fn take( + &self, + scope: &ResourceScope, + capability_id: &CapabilityId, + handle: &SecretHandle, + ) -> Result, RuntimeSecretInjectionStoreError> { + Ok(self.lock()?.remove(&RuntimeSecretInjectionKey::new( + scope, + capability_id, + handle, + ))) + } + + fn lock( + &self, + ) -> Result< + std::sync::MutexGuard<'_, HashMap>, + RuntimeSecretInjectionStoreError, + > { + self.secrets + .lock() + .map_err(|_| RuntimeSecretInjectionStoreError::Unavailable) + } +} + +impl fmt::Debug for RuntimeSecretInjectionStore { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RuntimeSecretInjectionStore") + .field("secrets", &"[REDACTED]") + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RuntimeSecretInjectionStoreError { + Unavailable, +} + +impl fmt::Display for RuntimeSecretInjectionStoreError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unavailable => formatter.write_str("runtime secret injection store unavailable"), + } + } +} + +impl std::error::Error for RuntimeSecretInjectionStoreError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct RuntimeSecretInjectionKey { + tenant_id: String, + user_id: String, + agent_id: Option, + project_id: Option, + mission_id: Option, + thread_id: Option, + invocation_id: String, + capability_id: String, + handle: String, +} + +impl RuntimeSecretInjectionKey { + fn new(scope: &ResourceScope, capability_id: &CapabilityId, handle: &SecretHandle) -> Self { + Self { + tenant_id: scope.tenant_id.as_str().to_string(), + user_id: scope.user_id.as_str().to_string(), + agent_id: scope.agent_id.as_ref().map(|id| id.as_str().to_string()), + project_id: scope.project_id.as_ref().map(|id| id.as_str().to_string()), + mission_id: scope.mission_id.as_ref().map(|id| id.as_str().to_string()), + thread_id: scope.thread_id.as_ref().map(|id| id.as_str().to_string()), + invocation_id: scope.invocation_id.to_string(), + capability_id: capability_id.as_str().to_string(), + handle: handle.as_str().to_string(), + } + } +} + +/// In-memory policy handoff from obligation handling to runtime adapters. +/// +/// Policies are keyed by tenant/user/project/mission/thread/invocation scope and +/// capability id, and are consumed by runtime adapters immediately before the +/// actual runtime dispatch. +#[derive(Debug, Clone, Default)] +pub struct NetworkObligationPolicyStore { + policies: Arc>>, +} + +impl NetworkObligationPolicyStore { + pub fn new() -> Self { + Self::default() + } + + pub fn insert( + &self, + scope: &ResourceScope, + capability_id: &CapabilityId, + policy: NetworkPolicy, + ) { + self.policies + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(NetworkPolicyKey::new(scope, capability_id), policy); + } + + pub fn take( + &self, + scope: &ResourceScope, + capability_id: &CapabilityId, + ) -> Option { + self.policies + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(&NetworkPolicyKey::new(scope, capability_id)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct NetworkPolicyKey { + tenant_id: String, + user_id: String, + agent_id: Option, + project_id: Option, + mission_id: Option, + thread_id: Option, + invocation_id: String, + capability_id: String, +} + +impl NetworkPolicyKey { + fn new(scope: &ResourceScope, capability_id: &CapabilityId) -> Self { + Self { + tenant_id: scope.tenant_id.as_str().to_string(), + user_id: scope.user_id.as_str().to_string(), + agent_id: scope.agent_id.as_ref().map(|id| id.as_str().to_string()), + project_id: scope.project_id.as_ref().map(|id| id.as_str().to_string()), + mission_id: scope.mission_id.as_ref().map(|id| id.as_str().to_string()), + thread_id: scope.thread_id.as_ref().map(|id| id.as_str().to_string()), + invocation_id: scope.invocation_id.to_string(), + capability_id: capability_id.as_str().to_string(), + } + } +} + +/// Built-in obligation handler for the current host-runtime slice. +#[derive(Clone, Default)] +pub struct BuiltinObligationHandler { + audit_sink: Option>, + network_policies: Option>, + secret_store: Option>, + secret_injections: Option>, + resource_governor: Option>, +} + +impl BuiltinObligationHandler { + pub fn new() -> Self { + Self::default() + } + + pub fn with_audit_sink(mut self, sink: Arc) -> Self + where + T: AuditSink + 'static, + { + let sink: Arc = sink; + self.audit_sink = Some(sink); + self + } + + pub fn with_audit_sink_dyn(mut self, sink: Arc) -> Self { + self.audit_sink = Some(sink); + self + } + + pub fn with_network_policy_store(mut self, store: Arc) -> Self { + self.network_policies = Some(store); + self + } + + pub fn with_secret_store(mut self, store: Arc) -> Self + where + T: SecretStore + 'static, + { + let store: Arc = store; + self.secret_store = Some(store); + self + } + + pub fn with_secret_store_dyn(mut self, store: Arc) -> Self { + self.secret_store = Some(store); + self + } + + pub fn with_secret_injection_store(mut self, store: Arc) -> Self { + self.secret_injections = Some(store); + self + } + + pub fn with_resource_governor(mut self, governor: Arc) -> Self + where + T: ResourceGovernor + 'static, + { + let governor: Arc = governor; + self.resource_governor = Some(governor); + self + } + + pub fn with_resource_governor_dyn(mut self, governor: Arc) -> Self { + self.resource_governor = Some(governor); + self + } + + async fn emit_audit_before( + &self, + request: &CapabilityObligationRequest<'_>, + ) -> Result<(), CapabilityObligationError> { + let Some(audit_sink) = &self.audit_sink else { + return Err(CapabilityObligationError::Failed { + kind: CapabilityObligationFailureKind::Audit, + }); + }; + + audit_sink + .emit_audit(audit_before_record(request)) + .await + .map_err(|_| CapabilityObligationError::Failed { + kind: CapabilityObligationFailureKind::Audit, + }) + } + + async fn preflight_secret_injection( + &self, + request: &CapabilityObligationRequest<'_>, + handles: &[SecretHandle], + ) -> Result<(), CapabilityObligationError> { + if handles.is_empty() { + return Ok(()); + } + let Some(secret_store) = &self.secret_store else { + return Err(secret_obligation_failed()); + }; + if self.secret_injections.is_none() { + return Err(secret_obligation_failed()); + } + for handle in handles { + let exists = secret_store + .metadata(&request.context.resource_scope, handle) + .await + .map_err(|_| secret_obligation_failed())? + .is_some(); + if !exists { + return Err(secret_obligation_failed()); + } + } + Ok(()) + } + + async fn inject_secrets( + &self, + request: &CapabilityObligationRequest<'_>, + handles: &[SecretHandle], + ) -> Result<(), CapabilityObligationError> { + if handles.is_empty() { + return Ok(()); + } + let Some(secret_store) = &self.secret_store else { + return Err(secret_obligation_failed()); + }; + let Some(secret_injections) = &self.secret_injections else { + return Err(secret_obligation_failed()); + }; + + let mut material = Vec::with_capacity(handles.len()); + for handle in handles { + let lease = secret_store + .lease_once(&request.context.resource_scope, handle) + .await + .map_err(|_| secret_obligation_failed())?; + let secret = secret_store + .consume(&request.context.resource_scope, lease.id) + .await + .map_err(|_| secret_obligation_failed())?; + material.push((handle.clone(), secret)); + } + + for (handle, secret) in material { + secret_injections + .insert( + &request.context.resource_scope, + request.capability_id, + &handle, + secret, + ) + .map_err(|_| secret_obligation_failed())?; + } + Ok(()) + } + + fn reserve_resource_obligation( + &self, + request: &CapabilityObligationRequest<'_>, + ) -> Result, CapabilityObligationError> { + let mut reservation_id = None; + for obligation in request.obligations { + if let Obligation::ReserveResources { reservation_id: id } = obligation { + if reservation_id.is_some() { + return Err(resource_obligation_failed()); + } + reservation_id = Some(*id); + } + } + let Some(reservation_id) = reservation_id else { + return Ok(None); + }; + let Some(governor) = &self.resource_governor else { + return Err(resource_obligation_failed()); + }; + governor + .reserve_with_id( + request.context.resource_scope.clone(), + request.estimate.clone(), + reservation_id, + ) + .map(Some) + .map_err(|_| resource_obligation_failed()) + } + + async fn finish_prepare( + &self, + request: &CapabilityObligationRequest<'_>, + secret_handles: &[SecretHandle], + network_policy: Option, + ) -> Result<(), CapabilityObligationError> { + if request + .obligations + .iter() + .any(|obligation| matches!(obligation, Obligation::AuditBefore)) + { + self.emit_audit_before(request).await?; + } + + self.inject_secrets(request, secret_handles).await?; + + if let Some(policy) = network_policy { + let Some(store) = &self.network_policies else { + return Err(network_obligation_failed()); + }; + store.insert( + &request.context.resource_scope, + request.capability_id, + policy, + ); + } + + Ok(()) + } + + async fn emit_audit_after( + &self, + request: &CapabilityObligationCompletionRequest<'_>, + output_bytes: u64, + ) -> Result<(), CapabilityObligationError> { + let Some(audit_sink) = &self.audit_sink else { + return Err(CapabilityObligationError::Failed { + kind: CapabilityObligationFailureKind::Audit, + }); + }; + + audit_sink + .emit_audit(audit_after_record(request, output_bytes)) + .await + .map_err(|_| CapabilityObligationError::Failed { + kind: CapabilityObligationFailureKind::Audit, + }) + } +} + +#[async_trait] +impl CapabilityObligationHandler for BuiltinObligationHandler { + async fn satisfy( + &self, + request: CapabilityObligationRequest<'_>, + ) -> Result<(), CapabilityObligationError> { + // `satisfy` is the direct one-shot path for callers that need staged + // network/secret handoff but do not need to pass prepared mounts or a + // reservation downstream. Resource reservations are immediately + // released because no downstream dispatcher/process can reconcile them + // on this path. CapabilityHost uses `prepare`/`complete`/`abort` + // directly instead. + let outcome = self + .prepare(CapabilityObligationRequest { + phase: request.phase, + context: request.context, + capability_id: request.capability_id, + estimate: request.estimate, + obligations: request.obligations, + }) + .await?; + if outcome.resource_reservation.is_some() { + self.abort(CapabilityObligationAbortRequest { + phase: request.phase, + context: request.context, + capability_id: request.capability_id, + estimate: request.estimate, + obligations: request.obligations, + outcome: &outcome, + }) + .await?; + } + Ok(()) + } + + async fn prepare( + &self, + request: CapabilityObligationRequest<'_>, + ) -> Result { + let unsupported = unsupported_obligations(request.phase, request.obligations); + if !unsupported.is_empty() { + return Err(CapabilityObligationError::Unsupported { + obligations: unsupported, + }); + } + + let network_policy = network_policy_obligation(request.obligations)?; + if network_policy.is_some() && self.network_policies.is_none() { + return Err(network_obligation_failed()); + } + let scoped_mounts = scoped_mount_obligation(request.context, request.obligations)?; + let secret_handles = secret_injection_obligations(request.obligations); + self.preflight_secret_injection(&request, &secret_handles) + .await?; + let resource_reservation = self.reserve_resource_obligation(&request)?; + let outcome = CapabilityObligationOutcome { + mounts: scoped_mounts, + resource_reservation, + }; + + if let Err(error) = self + .finish_prepare(&request, &secret_handles, network_policy) + .await + { + self.abort(CapabilityObligationAbortRequest { + phase: request.phase, + context: request.context, + capability_id: request.capability_id, + estimate: request.estimate, + obligations: request.obligations, + outcome: &outcome, + }) + .await?; + return Err(error); + } + + Ok(outcome) + } + + async fn abort( + &self, + request: CapabilityObligationAbortRequest<'_>, + ) -> Result<(), CapabilityObligationError> { + if request + .obligations + .iter() + .any(|obligation| matches!(obligation, Obligation::ApplyNetworkPolicy { .. })) + && let Some(store) = &self.network_policies + { + let _ = store.take(&request.context.resource_scope, request.capability_id); + } + + if let Some(store) = &self.secret_injections { + for handle in secret_injection_obligations(request.obligations) { + let _ = store + .take( + &request.context.resource_scope, + request.capability_id, + &handle, + ) + .map_err(|_| secret_obligation_failed())?; + } + } + + if let Some(reservation) = &request.outcome.resource_reservation { + let Some(governor) = &self.resource_governor else { + return Err(resource_obligation_failed()); + }; + governor + .release(reservation.id) + .map_err(|_| resource_obligation_failed())?; + } + Ok(()) + } + + async fn complete_dispatch( + &self, + request: CapabilityObligationCompletionRequest<'_>, + ) -> Result { + let unsupported = unsupported_completion_obligations(request.phase, request.obligations); + if !unsupported.is_empty() { + return Err(CapabilityObligationError::Unsupported { + obligations: unsupported, + }); + } + + let mut dispatch = request.dispatch.clone(); + if request + .obligations + .iter() + .any(|obligation| matches!(obligation, Obligation::RedactOutput)) + { + dispatch.output = redact_output(dispatch.output)?; + } + + let output_bytes = dispatch_output_bytes(&dispatch.output)?; + for obligation in request.obligations { + if let Obligation::EnforceOutputLimit { bytes } = obligation + && output_bytes > *bytes + { + return Err(output_obligation_failed()); + } + } + + if request + .obligations + .iter() + .any(|obligation| matches!(obligation, Obligation::AuditAfter)) + { + self.emit_audit_after(&request, output_bytes).await?; + } + + Ok(dispatch) + } +} + +fn unsupported_obligations( + phase: CapabilityObligationPhase, + obligations: &[Obligation], +) -> Vec { + obligations + .iter() + .filter(|obligation| !obligation_supported_before_dispatch(phase, obligation)) + .cloned() + .collect() +} + +fn obligation_supported_before_dispatch( + phase: CapabilityObligationPhase, + obligation: &Obligation, +) -> bool { + match obligation { + Obligation::AuditBefore + | Obligation::ApplyNetworkPolicy { .. } + | Obligation::EnforceResourceCeiling { .. } + | Obligation::InjectSecretOnce { .. } + | Obligation::ReserveResources { .. } + | Obligation::UseScopedMounts { .. } => true, + Obligation::AuditAfter + | Obligation::RedactOutput + | Obligation::EnforceOutputLimit { .. } => { + !matches!(phase, CapabilityObligationPhase::Spawn) + } + } +} + +fn unsupported_completion_obligations( + phase: CapabilityObligationPhase, + obligations: &[Obligation], +) -> Vec { + obligations + .iter() + .filter(|obligation| !obligation_supported_after_dispatch(phase, obligation)) + .cloned() + .collect() +} + +fn obligation_supported_after_dispatch( + phase: CapabilityObligationPhase, + obligation: &Obligation, +) -> bool { + match obligation { + Obligation::AuditBefore + | Obligation::ApplyNetworkPolicy { .. } + | Obligation::EnforceResourceCeiling { .. } + | Obligation::InjectSecretOnce { .. } + | Obligation::ReserveResources { .. } + | Obligation::UseScopedMounts { .. } => true, + Obligation::AuditAfter + | Obligation::RedactOutput + | Obligation::EnforceOutputLimit { .. } => { + !matches!(phase, CapabilityObligationPhase::Spawn) + } + } +} + +fn secret_injection_obligations(obligations: &[Obligation]) -> Vec { + obligations + .iter() + .filter_map(|obligation| match obligation { + Obligation::InjectSecretOnce { handle } => Some(handle.clone()), + _ => None, + }) + .collect() +} + +fn network_policy_obligation( + obligations: &[Obligation], +) -> Result, CapabilityObligationError> { + let mut policy = None; + for obligation in obligations { + if let Obligation::ApplyNetworkPolicy { policy: next } = obligation { + if policy.is_some() { + return Err(network_obligation_failed()); + } + validate_network_policy_metadata(next)?; + policy = Some(next.clone()); + } + } + Ok(policy) +} + +fn scoped_mount_obligation( + context: &ironclaw_host_api::ExecutionContext, + obligations: &[Obligation], +) -> Result, CapabilityObligationError> { + let mut mounts = None; + for obligation in obligations { + if let Obligation::UseScopedMounts { mounts: next } = obligation { + if mounts.is_some() { + return Err(mount_obligation_failed()); + } + next.validate().map_err(|_| mount_obligation_failed())?; + if !next.is_subset_of(&context.mounts) { + return Err(mount_obligation_failed()); + } + mounts = Some(next.clone()); + } + } + Ok(mounts) +} + +fn validate_network_policy_metadata( + policy: &NetworkPolicy, +) -> Result<(), CapabilityObligationError> { + if policy.allowed_targets.is_empty() { + return Err(network_obligation_failed()); + } + Ok(()) +} + +fn network_obligation_failed() -> CapabilityObligationError { + CapabilityObligationError::Failed { + kind: CapabilityObligationFailureKind::Network, + } +} + +fn secret_obligation_failed() -> CapabilityObligationError { + CapabilityObligationError::Failed { + kind: CapabilityObligationFailureKind::Secret, + } +} + +fn resource_obligation_failed() -> CapabilityObligationError { + CapabilityObligationError::Failed { + kind: CapabilityObligationFailureKind::Resource, + } +} + +fn mount_obligation_failed() -> CapabilityObligationError { + CapabilityObligationError::Failed { + kind: CapabilityObligationFailureKind::Mount, + } +} + +fn output_obligation_failed() -> CapabilityObligationError { + CapabilityObligationError::Failed { + kind: CapabilityObligationFailureKind::Output, + } +} + +fn dispatch_output_bytes(output: &serde_json::Value) -> Result { + serde_json::to_vec(output) + .map(|bytes| bytes.len() as u64) + .map_err(|_| output_obligation_failed()) +} + +fn redact_output( + output: serde_json::Value, +) -> Result { + match output { + serde_json::Value::String(value) => LeakDetector::new() + .scan_and_clean(&value) + .map(serde_json::Value::String) + .map_err(|_| output_obligation_failed()), + serde_json::Value::Array(values) => values + .into_iter() + .map(redact_output) + .collect::, _>>() + .map(serde_json::Value::Array), + serde_json::Value::Object(entries) => entries + .into_iter() + .map(|(key, value)| redact_output(value).map(|value| (key, value))) + .collect::, _>>() + .map(serde_json::Value::Object), + value => Ok(value), + } +} + +fn audit_before_record(request: &CapabilityObligationRequest<'_>) -> AuditEnvelope { + AuditEnvelope { + event_id: AuditEventId::new(), + correlation_id: request.context.correlation_id, + stage: AuditStage::Before, + timestamp: Utc::now(), + tenant_id: request.context.tenant_id.clone(), + user_id: request.context.user_id.clone(), + agent_id: request.context.agent_id.clone(), + project_id: request.context.project_id.clone(), + mission_id: request.context.mission_id.clone(), + thread_id: request.context.thread_id.clone(), + invocation_id: request.context.invocation_id, + process_id: request.context.process_id, + approval_request_id: None, + extension_id: Some(request.context.extension_id.clone()), + action: ActionSummary { + kind: capability_action_kind(request.phase).to_string(), + target: Some(request.capability_id.as_str().to_string()), + effects: capability_action_effects(request.phase), + }, + decision: DecisionSummary { + kind: "obligation_satisfied".to_string(), + reason: None, + actor: None, + }, + result: Some(ActionResultSummary { + success: true, + status: Some(obligation_status(request.obligations)), + output_bytes: None, + }), + } +} + +fn audit_after_record( + request: &CapabilityObligationCompletionRequest<'_>, + output_bytes: u64, +) -> AuditEnvelope { + AuditEnvelope { + event_id: AuditEventId::new(), + correlation_id: request.context.correlation_id, + stage: AuditStage::After, + timestamp: Utc::now(), + tenant_id: request.context.tenant_id.clone(), + user_id: request.context.user_id.clone(), + agent_id: request.context.agent_id.clone(), + project_id: request.context.project_id.clone(), + mission_id: request.context.mission_id.clone(), + thread_id: request.context.thread_id.clone(), + invocation_id: request.context.invocation_id, + process_id: request.context.process_id, + approval_request_id: None, + extension_id: Some(request.context.extension_id.clone()), + action: ActionSummary { + kind: capability_action_kind(request.phase).to_string(), + target: Some(request.capability_id.as_str().to_string()), + effects: capability_action_effects(request.phase), + }, + decision: DecisionSummary { + kind: "obligation_satisfied".to_string(), + reason: None, + actor: None, + }, + result: Some(ActionResultSummary { + success: true, + status: Some(obligation_status(request.obligations)), + output_bytes: Some(output_bytes), + }), + } +} + +fn capability_action_kind(phase: CapabilityObligationPhase) -> &'static str { + match phase { + CapabilityObligationPhase::Invoke => "capability_invoke", + CapabilityObligationPhase::Resume => "capability_resume", + CapabilityObligationPhase::Spawn => "capability_spawn", + } +} + +fn capability_action_effects(phase: CapabilityObligationPhase) -> Vec { + match phase { + CapabilityObligationPhase::Invoke | CapabilityObligationPhase::Resume => { + vec![EffectKind::DispatchCapability] + } + CapabilityObligationPhase::Spawn => { + vec![EffectKind::DispatchCapability, EffectKind::SpawnProcess] + } + } +} + +fn obligation_status(obligations: &[Obligation]) -> String { + obligations + .iter() + .filter_map(obligation_label) + .collect::>() + .join(",") +} + +fn obligation_label(obligation: &Obligation) -> Option<&'static str> { + match obligation { + Obligation::AuditBefore => Some("audit_before"), + Obligation::AuditAfter => Some("audit_after"), + Obligation::RedactOutput => Some("redact_output"), + Obligation::ApplyNetworkPolicy { .. } => Some("apply_network_policy"), + Obligation::InjectSecretOnce { .. } => Some("inject_secret_once"), + Obligation::EnforceOutputLimit { .. } => Some("enforce_output_limit"), + Obligation::ReserveResources { .. } => Some("reserve_resources"), + Obligation::UseScopedMounts { .. } => Some("use_scoped_mounts"), + Obligation::EnforceResourceCeiling { .. } => Some("enforce_resource_ceiling"), + } +} diff --git a/crates/ironclaw_host_runtime/src/production.rs b/crates/ironclaw_host_runtime/src/production.rs index e4b163c98f8..419ae3c12fc 100644 --- a/crates/ironclaw_host_runtime/src/production.rs +++ b/crates/ironclaw_host_runtime/src/production.rs @@ -16,7 +16,7 @@ use async_trait::async_trait; use ironclaw_authorization::{CapabilityLeaseStore, TrustAwareCapabilityDispatchAuthorizer}; use ironclaw_capabilities::{ CapabilityHost, CapabilityInvocationError, CapabilityInvocationRequest, - CapabilityInvocationResult, + CapabilityInvocationResult, CapabilityObligationHandler, }; use ironclaw_extensions::ExtensionRegistry; use ironclaw_host_api::{ @@ -29,9 +29,9 @@ use ironclaw_processes::{ use ironclaw_run_state::{ApprovalRequestStore, RunStateError, RunStateStore, RunStatus}; use crate::{ - CancelRuntimeWorkOutcome, CancelRuntimeWorkRequest, CapabilitySurfaceVersion, HostRuntime, - HostRuntimeError, HostRuntimeHealth, HostRuntimeStatus, RuntimeApprovalGate, - RuntimeBackendHealth, RuntimeBlockedReason, RuntimeCapabilityCompleted, + BuiltinObligationHandler, CancelRuntimeWorkOutcome, CancelRuntimeWorkRequest, + CapabilitySurfaceVersion, HostRuntime, HostRuntimeError, HostRuntimeHealth, HostRuntimeStatus, + RuntimeApprovalGate, RuntimeBackendHealth, RuntimeBlockedReason, RuntimeCapabilityCompleted, RuntimeCapabilityFailure, RuntimeCapabilityOutcome, RuntimeCapabilityRequest, RuntimeFailureKind, RuntimeStatusRequest, RuntimeWorkId, RuntimeWorkSummary, VisibleCapabilityRequest, VisibleCapabilitySurface, @@ -50,6 +50,7 @@ pub struct DefaultHostRuntime { process_result_store: Option>, process_cancellation_registry: Option>, runtime_health: Option>, + obligation_handler: Option>, surface_version: CapabilitySurfaceVersion, } @@ -82,6 +83,7 @@ impl DefaultHostRuntime { process_result_store: None, process_cancellation_registry: None, runtime_health: None, + obligation_handler: None, surface_version, } } @@ -146,6 +148,33 @@ impl DefaultHostRuntime { self.runtime_health = Some(health); self } + + /// Attaches a host-provided obligation handler. + pub fn with_obligation_handler(mut self, handler: Arc) -> Self + where + T: CapabilityObligationHandler + 'static, + { + let handler: Arc = handler; + self.obligation_handler = Some(handler); + self + } + + /// Attaches an already-erased host-provided obligation handler. + pub fn with_obligation_handler_dyn( + mut self, + handler: Arc, + ) -> Self { + self.obligation_handler = Some(handler); + self + } + + /// Installs the default built-in obligation handler with no optional backing + /// stores. Obligations requiring audit/network/secret/resource backing still + /// fail closed until the caller supplies a fully configured handler through + /// [`Self::with_obligation_handler`] or [`Self::with_obligation_handler_dyn`]. + pub fn with_builtin_obligation_handler(self) -> Self { + self.with_obligation_handler(Arc::new(BuiltinObligationHandler::new())) + } } #[async_trait] @@ -189,6 +218,9 @@ impl HostRuntime for DefaultHostRuntime { if let Some(process_manager) = &self.process_manager { host = host.with_process_manager(process_manager.as_ref()); } + if let Some(obligation_handler) = &self.obligation_handler { + host = host.with_obligation_handler(obligation_handler.as_ref()); + } let invocation = CapabilityInvocationRequest { context: request.context, @@ -571,6 +603,7 @@ fn sanitized_failure_message(error: &CapabilityInvocationError) -> Option RuntimeFai | CapabilityInvocationError::ResumeContextMismatch { .. } => { RuntimeFailureKind::Authorization } + CapabilityInvocationError::ObligationFailed { kind, .. } => match kind { + ironclaw_capabilities::CapabilityObligationFailureKind::Audit => { + RuntimeFailureKind::Backend + } + ironclaw_capabilities::CapabilityObligationFailureKind::Mount => { + RuntimeFailureKind::Authorization + } + ironclaw_capabilities::CapabilityObligationFailureKind::Network => { + RuntimeFailureKind::Network + } + ironclaw_capabilities::CapabilityObligationFailureKind::Output => { + RuntimeFailureKind::OutputTooLarge + } + ironclaw_capabilities::CapabilityObligationFailureKind::Resource => { + RuntimeFailureKind::Resource + } + ironclaw_capabilities::CapabilityObligationFailureKind::Secret => { + RuntimeFailureKind::Authorization + } + }, CapabilityInvocationError::InvocationFingerprint { .. } => RuntimeFailureKind::InvalidInput, CapabilityInvocationError::ApprovalStoreMissing { .. } | CapabilityInvocationError::ResumeStoreMissing { .. } diff --git a/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs b/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs new file mode 100644 index 00000000000..5738317981d --- /dev/null +++ b/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs @@ -0,0 +1,636 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use ironclaw_authorization::TrustAwareCapabilityDispatchAuthorizer; +use ironclaw_capabilities::{ + CapabilityObligationCompletionRequest, CapabilityObligationError, + CapabilityObligationFailureKind, CapabilityObligationHandler, CapabilityObligationPhase, + CapabilityObligationRequest, +}; +use ironclaw_events::InMemoryAuditSink; +use ironclaw_extensions::{ExtensionManifest, ExtensionPackage, ExtensionRegistry}; +use ironclaw_host_api::*; +use ironclaw_host_runtime::{ + BuiltinObligationHandler, CapabilitySurfaceVersion, DefaultHostRuntime, HostRuntime, + NetworkObligationPolicyStore, RuntimeCapabilityOutcome, RuntimeCapabilityRequest, + RuntimeSecretInjectionStore, +}; +use ironclaw_resources::{InMemoryResourceGovernor, ResourceAccount}; +use ironclaw_secrets::{InMemorySecretStore, SecretMaterial, SecretStore}; +use ironclaw_trust::{AuthorityCeiling, EffectiveTrustClass, TrustDecision, TrustProvenance}; +use secrecy::ExposeSecret; +use serde_json::json; + +#[tokio::test] +async fn builtin_obligation_handler_emits_metadata_only_audit_before() { + let audit = Arc::new(InMemoryAuditSink::new()); + let handler = BuiltinObligationHandler::new().with_audit_sink(audit.clone()); + let context = execution_context(CapabilitySet::default()); + let capability_id = capability_id(); + let estimate = ResourceEstimate::default(); + let obligations = vec![Obligation::AuditBefore]; + + handler + .satisfy(CapabilityObligationRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + }) + .await + .unwrap(); + + let records = audit.records(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].stage, AuditStage::Before); + assert_eq!(records[0].tenant_id, context.tenant_id); + assert_eq!(records[0].user_id, context.user_id); + assert_eq!(records[0].invocation_id, context.invocation_id); + assert_eq!(records[0].action.kind, "capability_invoke"); + assert_eq!(records[0].action.target.as_deref(), Some("echo.say")); + assert_eq!(records[0].decision.kind, "obligation_satisfied"); + assert_eq!( + records[0] + .result + .as_ref() + .and_then(|result| result.status.as_deref()), + Some("audit_before") + ); + let serialized = serde_json::to_string(&records[0]).unwrap(); + assert!(!serialized.contains("raw input")); + assert!(!serialized.contains("secret")); +} + +#[tokio::test] +async fn builtin_obligation_handler_enforces_output_limit_after_dispatch() { + let handler = BuiltinObligationHandler::new(); + let context = execution_context(CapabilitySet::default()); + let capability_id = capability_id(); + let estimate = ResourceEstimate::default(); + let obligations = vec![Obligation::EnforceOutputLimit { bytes: 8 }]; + let dispatch = sample_dispatch( + &context.resource_scope, + &capability_id, + json!({"message": "this output is too large"}), + ); + + handler + .satisfy(CapabilityObligationRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + }) + .await + .unwrap(); + let err = handler + .complete_dispatch(CapabilityObligationCompletionRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + dispatch: &dispatch, + }) + .await + .unwrap_err(); + + assert!(matches!( + err, + CapabilityObligationError::Failed { + kind: CapabilityObligationFailureKind::Output + } + )); +} + +#[tokio::test] +async fn builtin_obligation_handler_redacts_output_after_dispatch() { + let handler = BuiltinObligationHandler::new(); + let context = execution_context(CapabilitySet::default()); + let capability_id = capability_id(); + let estimate = ResourceEstimate::default(); + let obligations = vec![Obligation::RedactOutput]; + let leaked = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let dispatch = sample_dispatch( + &context.resource_scope, + &capability_id, + json!({"authorization": leaked}), + ); + + handler + .satisfy(CapabilityObligationRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + }) + .await + .unwrap(); + let completed = handler + .complete_dispatch(CapabilityObligationCompletionRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + dispatch: &dispatch, + }) + .await + .unwrap(); + + let serialized = serde_json::to_string(&completed.output).unwrap(); + assert!(serialized.contains("[REDACTED]")); + assert!(!serialized.contains(leaked)); +} + +#[tokio::test] +async fn builtin_obligation_handler_stores_network_policy_for_runtime_handoff() { + let policy_store = Arc::new(NetworkObligationPolicyStore::new()); + let handler = BuiltinObligationHandler::new().with_network_policy_store(policy_store.clone()); + let context = execution_context(CapabilitySet::default()); + let capability_id = capability_id(); + let estimate = ResourceEstimate::default(); + let obligations = vec![Obligation::ApplyNetworkPolicy { + policy: allowed_network_policy(), + }]; + + handler + .satisfy(CapabilityObligationRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + }) + .await + .unwrap(); + + assert!( + policy_store + .take(&context.resource_scope, &capability_id) + .is_some(), + "accepted network obligations must be handed to runtime adapters" + ); +} + +#[tokio::test] +async fn builtin_obligation_handler_removes_network_policy_on_abort() { + let policy_store = Arc::new(NetworkObligationPolicyStore::new()); + let handler = BuiltinObligationHandler::new().with_network_policy_store(policy_store.clone()); + let context = execution_context(CapabilitySet::default()); + let capability_id = capability_id(); + let estimate = ResourceEstimate::default(); + let obligations = vec![Obligation::ApplyNetworkPolicy { + policy: allowed_network_policy(), + }]; + + let outcome = handler + .prepare(CapabilityObligationRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + }) + .await + .unwrap(); + handler + .abort(ironclaw_capabilities::CapabilityObligationAbortRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + outcome: &outcome, + }) + .await + .unwrap(); + + assert!( + policy_store + .take(&context.resource_scope, &capability_id) + .is_none() + ); +} + +#[test] +fn network_obligation_policy_store_isolates_agent_scope() { + let store = NetworkObligationPolicyStore::new(); + let capability_id = capability_id(); + let mut agent_a = execution_context(CapabilitySet::default()).resource_scope; + agent_a.agent_id = Some(AgentId::new("agent-a").unwrap()); + let mut agent_b = agent_a.clone(); + agent_b.agent_id = Some(AgentId::new("agent-b").unwrap()); + + store.insert(&agent_a, &capability_id, allowed_network_policy()); + + assert!(store.take(&agent_b, &capability_id).is_none()); + assert!(store.take(&agent_a, &capability_id).is_some()); +} + +#[tokio::test] +async fn builtin_obligation_handler_leases_consumes_and_stages_secret_once() { + let secret_store = Arc::new(InMemorySecretStore::new()); + let injection_store = Arc::new(RuntimeSecretInjectionStore::new()); + let handler = BuiltinObligationHandler::new() + .with_secret_store(secret_store.clone()) + .with_secret_injection_store(injection_store.clone()); + let context = execution_context(CapabilitySet::default()); + let capability_id = capability_id(); + let estimate = ResourceEstimate::default(); + let handle = SecretHandle::new("api_token").unwrap(); + secret_store + .put( + context.resource_scope.clone(), + handle.clone(), + SecretMaterial::from("runtime-secret"), + ) + .await + .unwrap(); + let obligations = vec![Obligation::InjectSecretOnce { + handle: handle.clone(), + }]; + + handler + .satisfy(CapabilityObligationRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + }) + .await + .unwrap(); + + let material = injection_store + .take(&context.resource_scope, &capability_id, &handle) + .unwrap() + .expect("secret material should be staged exactly once"); + assert_eq!(material.expose_secret(), "runtime-secret"); + assert!( + injection_store + .take(&context.resource_scope, &capability_id, &handle) + .unwrap() + .is_none(), + "runtime secret injection store must be one-shot" + ); +} + +#[tokio::test] +async fn builtin_obligation_handler_removes_staged_secret_on_abort() { + let secret_store = Arc::new(InMemorySecretStore::new()); + let injection_store = Arc::new(RuntimeSecretInjectionStore::new()); + let handler = BuiltinObligationHandler::new() + .with_secret_store(secret_store.clone()) + .with_secret_injection_store(injection_store.clone()); + let context = execution_context(CapabilitySet::default()); + let capability_id = capability_id(); + let estimate = ResourceEstimate::default(); + let handle = SecretHandle::new("api_token").unwrap(); + secret_store + .put( + context.resource_scope.clone(), + handle.clone(), + SecretMaterial::from("runtime-secret"), + ) + .await + .unwrap(); + let obligations = vec![Obligation::InjectSecretOnce { + handle: handle.clone(), + }]; + + let outcome = handler + .prepare(CapabilityObligationRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + }) + .await + .unwrap(); + assert!( + injection_store + .take(&context.resource_scope, &capability_id, &handle) + .unwrap() + .is_some() + ); + injection_store + .insert( + &context.resource_scope, + &capability_id, + &handle, + SecretMaterial::from("runtime-secret"), + ) + .unwrap(); + + handler + .abort(ironclaw_capabilities::CapabilityObligationAbortRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + outcome: &outcome, + }) + .await + .unwrap(); + + assert!( + injection_store + .take(&context.resource_scope, &capability_id, &handle) + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn builtin_obligation_handler_returns_scoped_mount_outcome_when_subset() { + let handler = BuiltinObligationHandler::new(); + let mut context = execution_context(CapabilitySet::default()); + context.mounts = mount_view( + "/workspace", + "/projects/demo", + MountPermissions::read_write(), + ); + let scoped_mounts = mount_view( + "/workspace", + "/projects/demo", + MountPermissions::read_only(), + ); + let capability_id = capability_id(); + let estimate = ResourceEstimate::default(); + let obligations = vec![Obligation::UseScopedMounts { + mounts: scoped_mounts.clone(), + }]; + + let outcome = handler + .prepare(CapabilityObligationRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + }) + .await + .unwrap(); + + assert_eq!(outcome.mounts, Some(scoped_mounts)); +} + +#[tokio::test] +async fn builtin_obligation_handler_reserves_requested_resources_and_releases_on_abort() { + let governor = Arc::new(InMemoryResourceGovernor::new()); + let handler = BuiltinObligationHandler::new().with_resource_governor(governor.clone()); + let context = execution_context(CapabilitySet::default()); + let account = ResourceAccount::tenant(context.resource_scope.tenant_id.clone()); + let capability_id = capability_id(); + let estimate = ResourceEstimate { + concurrency_slots: Some(1), + ..ResourceEstimate::default() + }; + let reservation_id = ResourceReservationId::new(); + let obligations = vec![Obligation::ReserveResources { reservation_id }]; + + let outcome = handler + .prepare(CapabilityObligationRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + }) + .await + .unwrap(); + + assert_eq!( + outcome + .resource_reservation + .as_ref() + .map(|reservation| reservation.id), + Some(reservation_id) + ); + assert_eq!(governor.reserved_for(&account).concurrency_slots, 1); + + handler + .abort(ironclaw_capabilities::CapabilityObligationAbortRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + outcome: &outcome, + }) + .await + .unwrap(); + + assert_eq!(governor.reserved_for(&account).concurrency_slots, 0); +} + +#[tokio::test] +async fn default_host_runtime_installs_configured_obligation_handler() { + let registry = Arc::new(registry_with_echo_capability()); + let dispatcher = Arc::new(RecordingDispatcher); + let authorizer: Arc = + Arc::new(ObligatingAuthorizer::new(vec![Obligation::AuditBefore])); + let audit = Arc::new(InMemoryAuditSink::new()); + let handler = Arc::new(BuiltinObligationHandler::new().with_audit_sink(audit.clone())); + let runtime = DefaultHostRuntime::new( + registry, + dispatcher, + authorizer, + CapabilitySurfaceVersion::new("surface-v1").unwrap(), + ) + .with_obligation_handler(handler); + + let outcome = runtime + .invoke_capability(RuntimeCapabilityRequest::new( + execution_context(CapabilitySet::default()), + capability_id(), + ResourceEstimate::default(), + json!({"message": "obligated"}), + trust_decision(), + )) + .await + .unwrap(); + + assert!(matches!(outcome, RuntimeCapabilityOutcome::Completed(_))); + let records = audit.records(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].action.kind, "capability_invoke"); +} + +struct ObligatingAuthorizer { + obligations: Vec, +} + +impl ObligatingAuthorizer { + fn new(obligations: Vec) -> Self { + Self { obligations } + } +} + +#[async_trait] +impl TrustAwareCapabilityDispatchAuthorizer for ObligatingAuthorizer { + async fn authorize_dispatch_with_trust( + &self, + _context: &ExecutionContext, + _descriptor: &CapabilityDescriptor, + _estimate: &ResourceEstimate, + _trust_decision: &TrustDecision, + ) -> Decision { + Decision::Allow { + obligations: Obligations::new(self.obligations.clone()).unwrap(), + } + } +} + +#[derive(Default)] +struct RecordingDispatcher; + +#[async_trait] +impl CapabilityDispatcher for RecordingDispatcher { + async fn dispatch_json( + &self, + request: CapabilityDispatchRequest, + ) -> Result { + Ok(CapabilityDispatchResult { + capability_id: request.capability_id, + provider: ExtensionId::new("echo").unwrap(), + runtime: RuntimeKind::Wasm, + output: json!({"ok": true}), + usage: ResourceUsage::default(), + receipt: ResourceReceipt { + id: request + .resource_reservation + .as_ref() + .map(|reservation| reservation.id) + .unwrap_or_default(), + scope: request.scope, + status: ReservationStatus::Reconciled, + estimate: request.estimate, + actual: Some(ResourceUsage::default()), + }, + }) + } +} + +fn sample_dispatch( + scope: &ResourceScope, + capability_id: &CapabilityId, + output: serde_json::Value, +) -> CapabilityDispatchResult { + CapabilityDispatchResult { + capability_id: capability_id.clone(), + provider: ExtensionId::new("echo").unwrap(), + runtime: RuntimeKind::Wasm, + output, + usage: ResourceUsage::default(), + receipt: ResourceReceipt { + id: ResourceReservationId::new(), + scope: scope.clone(), + status: ReservationStatus::Reconciled, + estimate: ResourceEstimate::default(), + actual: Some(ResourceUsage::default()), + }, + } +} + +fn mount_view(alias: &str, target: &str, permissions: MountPermissions) -> MountView { + MountView::new(vec![MountGrant::new( + MountAlias::new(alias).unwrap(), + VirtualPath::new(target).unwrap(), + permissions, + )]) + .unwrap() +} + +fn allowed_network_policy() -> NetworkPolicy { + NetworkPolicy { + allowed_targets: vec![NetworkTargetPattern { + scheme: Some(NetworkScheme::Https), + host_pattern: "api.example.test".to_string(), + port: None, + }], + deny_private_ip_ranges: true, + max_egress_bytes: Some(1024), + } +} + +fn registry_with_echo_capability() -> ExtensionRegistry { + let manifest = ExtensionManifest::parse(ECHO_MANIFEST).unwrap(); + let root = VirtualPath::new(format!("/system/extensions/{}", manifest.id.as_str())).unwrap(); + let package = ExtensionPackage::from_manifest(manifest, root).unwrap(); + let mut registry = ExtensionRegistry::new(); + registry.insert(package).unwrap(); + registry +} + +fn execution_context(grants: CapabilitySet) -> ExecutionContext { + let invocation_id = InvocationId::new(); + let resource_scope = ResourceScope { + tenant_id: TenantId::new("tenant1").unwrap(), + user_id: UserId::new("user1").unwrap(), + agent_id: None, + project_id: Some(ProjectId::new("project1").unwrap()), + mission_id: None, + thread_id: None, + invocation_id, + }; + ExecutionContext { + invocation_id, + correlation_id: CorrelationId::new(), + process_id: None, + parent_process_id: None, + tenant_id: resource_scope.tenant_id.clone(), + user_id: resource_scope.user_id.clone(), + agent_id: resource_scope.agent_id.clone(), + project_id: resource_scope.project_id.clone(), + mission_id: resource_scope.mission_id.clone(), + thread_id: resource_scope.thread_id.clone(), + extension_id: ExtensionId::new("caller").unwrap(), + runtime: RuntimeKind::Wasm, + trust: TrustClass::Sandbox, + grants, + mounts: MountView::default(), + resource_scope, + } +} + +fn capability_id() -> CapabilityId { + CapabilityId::new("echo.say").unwrap() +} + +fn trust_decision() -> TrustDecision { + TrustDecision { + effective_trust: EffectiveTrustClass::sandbox(), + authority_ceiling: AuthorityCeiling { + allowed_effects: vec![EffectKind::DispatchCapability], + max_resource_ceiling: None, + }, + provenance: TrustProvenance::Default, + evaluated_at: chrono::Utc::now(), + } +} + +const ECHO_MANIFEST: &str = r#" +id = "echo" +name = "Echo" +version = "0.1.0" +description = "Echo test extension" +trust = "third_party" + +[runtime] +kind = "wasm" +module = "echo.wasm" + +[[capabilities]] +id = "echo.say" +description = "Echoes input" +effects = ["dispatch_capability"] +default_permission = "allow" +parameters_schema = {} +"#; diff --git a/docs/reborn/contracts/capabilities.md b/docs/reborn/contracts/capabilities.md new file mode 100644 index 00000000000..70e8999239e --- /dev/null +++ b/docs/reborn/contracts/capabilities.md @@ -0,0 +1,39 @@ +# Reborn Capability Host Contract + +`ironclaw_capabilities` is the caller-facing capability invocation service. It coordinates extension descriptor lookup, trust-aware authorization, approval resume, run-state transitions, optional obligation handling, dispatch, and process spawning without depending on concrete runtime crates. + +## Obligation handling + +Authorization may return `Decision::Allow { obligations }`. The capability host must fail closed unless either: + +- the obligation list is empty; or +- a configured `CapabilityObligationHandler` accepts and satisfies every obligation. + +The public handler seam is: + +- `CapabilityObligationHandler::prepare(...)` before downstream side effects. +- `CapabilityObligationHandler::abort(...)` after prepare succeeded but dispatch/spawn failed. +- `CapabilityObligationHandler::complete_dispatch(...)` after successful inline dispatch but before output is returned. + +Supported phases are: + +- `Invoke` for inline capability dispatch. +- `Resume` for approved inline dispatch resume. +- `Spawn` for background process start. + +Post-output obligations (`AuditAfter`, `RedactOutput`, `EnforceOutputLimit`) are invalid for `Spawn` and must fail before process start. + +Prepared effects are explicit handoffs, not ambient state: + +- `CapabilityObligationOutcome.mounts` narrows the effective mount view passed to dispatch/process start. +- `CapabilityObligationOutcome.resource_reservation` hands a prepared reservation to dispatch/process start. + +If downstream dispatch/spawn fails after `prepare`, the capability host calls `abort` so handlers can release reservations or discard staged side effects. + +## Failure taxonomy + +- Unsupported obligations surface as `CapabilityInvocationError::UnsupportedObligations`. +- Handler failures surface as `CapabilityInvocationError::ObligationFailed` with a stable `CapabilityObligationFailureKind`. +- Run state records use stable error kinds such as `UnsupportedObligations` and `ObligationFailed`. + +Capability host errors must not expose raw secrets, raw output, raw DB/provider errors, or raw host paths. diff --git a/docs/reborn/contracts/host-runtime.md b/docs/reborn/contracts/host-runtime.md new file mode 100644 index 00000000000..303659396b1 --- /dev/null +++ b/docs/reborn/contracts/host-runtime.md @@ -0,0 +1,31 @@ +# Reborn Host Runtime Contract + +`ironclaw_host_runtime` is the composition-facing host boundary above Reborn capability, process, network, secret, audit, and resource substrates. Upper turn/loop services depend on the `HostRuntime` trait and receive structured outcomes instead of concrete substrate handles. + +## Obligation composition + +`DefaultHostRuntime` may be configured with a `CapabilityObligationHandler` through `with_obligation_handler(...)`. It forwards the handler into `CapabilityHost` for capability invocations. + +`BuiltinObligationHandler` is the default host-owned implementation for current V1 obligations. It is deliberately fail-closed: obligations that require backing services fail unless the corresponding store/sink/governor is configured. + +Supported built-in behavior: + +- `AuditBefore`: emits metadata-only `AuditStage::Before` records. +- `AuditAfter`: emits metadata-only `AuditStage::After` records after dispatch output is available. +- `ApplyNetworkPolicy`: validates policy metadata and stages a scoped policy in `NetworkObligationPolicyStore` for runtime handoff. +- `InjectSecretOnce`: verifies the secret exists, leases and consumes it exactly once, then stages material in `RuntimeSecretInjectionStore` for one runtime take. +- `UseScopedMounts`: accepts only mount views that are subsets of the execution context mount view and returns the narrowed view to the capability host. +- `ReserveResources`: reserves the exact requested reservation id through a configured `ResourceGovernor` and returns the reservation for dispatch/process handoff. +- `RedactOutput`: sanitizes dispatch output before publication. +- `EnforceOutputLimit`: fails before publication if serialized output exceeds the limit. + +## Isolation rules + +- `NetworkObligationPolicyStore` keys policies by full `ResourceScope` plus capability id and consumes entries with `take(...)`. +- `RuntimeSecretInjectionStore` keys material by full `ResourceScope`, capability id, and secret handle and consumes entries with `take(...)`. +- Staged secrets must never be logged or exposed through debug output. +- Handler errors must use stable categories and avoid raw provider/backend details. + +## Runtime HTTP egress + +Runtime HTTP remains host-mediated through `RuntimeHttpEgress` and `HostHttpEgressService`. Runtime code must not perform ad-hoc DNS/private-IP checks or direct HTTP clients; `ironclaw_network` owns network policy enforcement and `ironclaw_secrets` owns secret lease/consume semantics. diff --git a/docs/reborn/contracts/network.md b/docs/reborn/contracts/network.md index 5d740675edc..84330d91596 100644 --- a/docs/reborn/contracts/network.md +++ b/docs/reborn/contracts/network.md @@ -64,8 +64,8 @@ Ownership remains: host_api -> NetworkPolicy, NetworkTarget, NetworkMethod shapes network -> scoped policy evaluation, DNS/private-IP checks, bounded HTTP transport authorization -> whether a caller has a grant with network authority -capabilities -> caller-facing workflow; currently fails closed on ApplyNetworkPolicy obligations -host_runtime -> shared runtime HTTP egress composition with secrets and runtime adapters +capabilities -> caller-facing workflow; fails closed on ApplyNetworkPolicy unless an obligation handler is configured +host_runtime -> built-in obligation handler validates/stages scoped network policy and shared runtime HTTP egress enforces policy runtimes -> translate native HTTP calls into host-mediated egress requests ``` diff --git a/docs/reborn/contracts/secrets.md b/docs/reborn/contracts/secrets.md index d986be55365..d1bb7685ed6 100644 --- a/docs/reborn/contracts/secrets.md +++ b/docs/reborn/contracts/secrets.md @@ -48,8 +48,8 @@ Ownership remains: host_api -> opaque SecretHandle and Action::UseSecret shapes secrets -> scoped storage, metadata, and one-shot leases authorization -> whether a caller may use a SecretHandle -capabilities -> caller-facing workflow; currently fails closed on InjectSecretOnce obligations -host_runtime -> shared runtime HTTP egress composition that leases, injects, and redacts secrets for host-mediated requests +capabilities -> caller-facing workflow; fails closed on InjectSecretOnce unless an obligation handler is configured +host_runtime -> built-in obligation handler leases/stages one-shot secret material and shared runtime HTTP egress injects/redacts secrets for host-mediated requests runtimes -> consume injected values only after host-side authorization and lease handling ``` From 015036beef97c4f1e7f4b2592546fe4b2292eb33 Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Thu, 30 Apr 2026 23:03:51 +0300 Subject: [PATCH 2/5] fix(reborn): harden built-in obligation handling --- .../ironclaw_host_runtime/src/obligations.rs | 33 +++-- .../builtin_obligation_handler_contract.rs | 130 +++++++++++++++++- docs/reborn/contracts/host-runtime.md | 4 +- 3 files changed, 154 insertions(+), 13 deletions(-) diff --git a/crates/ironclaw_host_runtime/src/obligations.rs b/crates/ironclaw_host_runtime/src/obligations.rs index b2353f81450..b18ceff513e 100644 --- a/crates/ironclaw_host_runtime/src/obligations.rs +++ b/crates/ironclaw_host_runtime/src/obligations.rs @@ -599,10 +599,10 @@ fn obligation_supported_before_dispatch( match obligation { Obligation::AuditBefore | Obligation::ApplyNetworkPolicy { .. } - | Obligation::EnforceResourceCeiling { .. } | Obligation::InjectSecretOnce { .. } | Obligation::ReserveResources { .. } | Obligation::UseScopedMounts { .. } => true, + Obligation::EnforceResourceCeiling { .. } => false, Obligation::AuditAfter | Obligation::RedactOutput | Obligation::EnforceOutputLimit { .. } => { @@ -629,10 +629,10 @@ fn obligation_supported_after_dispatch( match obligation { Obligation::AuditBefore | Obligation::ApplyNetworkPolicy { .. } - | Obligation::EnforceResourceCeiling { .. } | Obligation::InjectSecretOnce { .. } | Obligation::ReserveResources { .. } | Obligation::UseScopedMounts { .. } => true, + Obligation::EnforceResourceCeiling { .. } => false, Obligation::AuditAfter | Obligation::RedactOutput | Obligation::EnforceOutputLimit { .. } => { @@ -736,24 +736,35 @@ fn redact_output( output: serde_json::Value, ) -> Result { match output { - serde_json::Value::String(value) => LeakDetector::new() - .scan_and_clean(&value) - .map(serde_json::Value::String) - .map_err(|_| output_obligation_failed()), + serde_json::Value::String(value) => { + redact_output_string(value).map(serde_json::Value::String) + } serde_json::Value::Array(values) => values .into_iter() .map(redact_output) .collect::, _>>() .map(serde_json::Value::Array), - serde_json::Value::Object(entries) => entries - .into_iter() - .map(|(key, value)| redact_output(value).map(|value| (key, value))) - .collect::, _>>() - .map(serde_json::Value::Object), + serde_json::Value::Object(entries) => { + let mut redacted = serde_json::Map::with_capacity(entries.len()); + for (key, value) in entries { + let key = redact_output_string(key)?; + let value = redact_output(value)?; + if redacted.insert(key, value).is_some() { + return Err(output_obligation_failed()); + } + } + Ok(serde_json::Value::Object(redacted)) + } value => Ok(value), } } +fn redact_output_string(value: String) -> Result { + LeakDetector::new() + .scan_and_clean(&value) + .map_err(|_| output_obligation_failed()) +} + fn audit_before_record(request: &CapabilityObligationRequest<'_>) -> AuditEnvelope { AuditEnvelope { event_id: AuditEventId::new(), diff --git a/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs b/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs index 5738317981d..9890f1977f8 100644 --- a/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs +++ b/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs @@ -13,7 +13,7 @@ use ironclaw_host_api::*; use ironclaw_host_runtime::{ BuiltinObligationHandler, CapabilitySurfaceVersion, DefaultHostRuntime, HostRuntime, NetworkObligationPolicyStore, RuntimeCapabilityOutcome, RuntimeCapabilityRequest, - RuntimeSecretInjectionStore, + RuntimeFailureKind, RuntimeSecretInjectionStore, }; use ironclaw_resources::{InMemoryResourceGovernor, ResourceAccount}; use ironclaw_secrets::{InMemorySecretStore, SecretMaterial, SecretStore}; @@ -146,6 +146,74 @@ async fn builtin_obligation_handler_redacts_output_after_dispatch() { assert!(!serialized.contains(leaked)); } +#[tokio::test] +async fn builtin_obligation_handler_redacts_secret_like_object_keys() { + let handler = BuiltinObligationHandler::new(); + let context = execution_context(CapabilitySet::default()); + let capability_id = capability_id(); + let estimate = ResourceEstimate::default(); + let obligations = vec![Obligation::RedactOutput]; + let leaked = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let dispatch = sample_dispatch( + &context.resource_scope, + &capability_id, + json!({ leaked: "value" }), + ); + + let completed = handler + .complete_dispatch(CapabilityObligationCompletionRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + dispatch: &dispatch, + }) + .await + .unwrap(); + + let serialized = serde_json::to_string(&completed.output).unwrap(); + assert!(serialized.contains("[REDACTED]")); + assert!(!serialized.contains(leaked)); +} + +#[tokio::test] +async fn builtin_obligation_handler_fails_closed_when_redacted_object_keys_collide() { + let handler = BuiltinObligationHandler::new(); + let context = execution_context(CapabilitySet::default()); + let capability_id = capability_id(); + let estimate = ResourceEstimate::default(); + let obligations = vec![Obligation::RedactOutput]; + let leaked = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let dispatch = sample_dispatch( + &context.resource_scope, + &capability_id, + json!({ + leaked: "secret-key", + "[REDACTED].aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": "existing", + }), + ); + + let err = handler + .complete_dispatch(CapabilityObligationCompletionRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + dispatch: &dispatch, + }) + .await + .unwrap_err(); + + assert!(matches!( + err, + CapabilityObligationError::Failed { + kind: CapabilityObligationFailureKind::Output + } + )); +} + #[tokio::test] async fn builtin_obligation_handler_stores_network_policy_for_runtime_handoff() { let policy_store = Arc::new(NetworkObligationPolicyStore::new()); @@ -430,6 +498,43 @@ async fn builtin_obligation_handler_reserves_requested_resources_and_releases_on assert_eq!(governor.reserved_for(&account).concurrency_slots, 0); } +#[tokio::test] +async fn default_host_runtime_fails_closed_on_resource_ceiling_until_handoff_exists() { + let registry = Arc::new(registry_with_echo_capability()); + let dispatcher = Arc::new(PanicDispatcher); + let authorizer: Arc = + Arc::new(ObligatingAuthorizer::new(vec![ + Obligation::EnforceResourceCeiling { + ceiling: resource_ceiling(), + }, + ])); + let runtime = DefaultHostRuntime::new( + registry, + dispatcher, + authorizer, + CapabilitySurfaceVersion::new("surface-v1").unwrap(), + ) + .with_builtin_obligation_handler(); + + let outcome = runtime + .invoke_capability(RuntimeCapabilityRequest::new( + execution_context(CapabilitySet::default()), + capability_id(), + ResourceEstimate::default(), + json!({"message": "must not dispatch"}), + trust_decision(), + )) + .await + .unwrap(); + + match outcome { + RuntimeCapabilityOutcome::Failed(failure) => { + assert_eq!(failure.kind, RuntimeFailureKind::Authorization); + } + other => panic!("expected failed outcome, got {other:?}"), + } +} + #[tokio::test] async fn default_host_runtime_installs_configured_obligation_handler() { let registry = Arc::new(registry_with_echo_capability()); @@ -491,6 +596,18 @@ impl TrustAwareCapabilityDispatchAuthorizer for ObligatingAuthorizer { #[derive(Default)] struct RecordingDispatcher; +struct PanicDispatcher; + +#[async_trait] +impl CapabilityDispatcher for PanicDispatcher { + async fn dispatch_json( + &self, + _request: CapabilityDispatchRequest, + ) -> Result { + panic!("dispatcher must not be called for unsupported resource-ceiling obligations") + } +} + #[async_trait] impl CapabilityDispatcher for RecordingDispatcher { async fn dispatch_json( @@ -548,6 +665,17 @@ fn mount_view(alias: &str, target: &str, permissions: MountPermissions) -> Mount .unwrap() } +fn resource_ceiling() -> ResourceCeiling { + ResourceCeiling { + max_usd: None, + max_input_tokens: None, + max_output_tokens: None, + max_wall_clock_ms: Some(1), + max_output_bytes: None, + sandbox: None, + } +} + fn allowed_network_policy() -> NetworkPolicy { NetworkPolicy { allowed_targets: vec![NetworkTargetPattern { diff --git a/docs/reborn/contracts/host-runtime.md b/docs/reborn/contracts/host-runtime.md index 303659396b1..9a0ad469bb4 100644 --- a/docs/reborn/contracts/host-runtime.md +++ b/docs/reborn/contracts/host-runtime.md @@ -16,9 +16,11 @@ Supported built-in behavior: - `InjectSecretOnce`: verifies the secret exists, leases and consumes it exactly once, then stages material in `RuntimeSecretInjectionStore` for one runtime take. - `UseScopedMounts`: accepts only mount views that are subsets of the execution context mount view and returns the narrowed view to the capability host. - `ReserveResources`: reserves the exact requested reservation id through a configured `ResourceGovernor` and returns the reservation for dispatch/process handoff. -- `RedactOutput`: sanitizes dispatch output before publication. +- `RedactOutput`: sanitizes dispatch output string values and object keys before publication, failing closed if redacted keys collide. - `EnforceOutputLimit`: fails before publication if serialized output exceeds the limit. +`EnforceResourceCeiling` is intentionally fail-closed in this slice until an explicit runtime/sandbox ceiling handoff exists. `EnforceOutputLimit` covers the output-byte part of a resource ceiling today; other ceiling fields must not be silently accepted. + ## Isolation rules - `NetworkObligationPolicyStore` keys policies by full `ResourceScope` plus capability id and consumes entries with `take(...)`. From 0f4e95baa42ad03a771b31a67a05538b08ed7945 Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Thu, 30 Apr 2026 23:22:23 +0300 Subject: [PATCH 3/5] fix: address review findings (iteration 1) --- .../ironclaw_host_runtime/src/obligations.rs | 82 ++++++++--- .../builtin_obligation_handler_contract.rs | 134 +++++++++++++++--- docs/reborn/contracts/host-runtime.md | 1 + 3 files changed, 177 insertions(+), 40 deletions(-) diff --git a/crates/ironclaw_host_runtime/src/obligations.rs b/crates/ironclaw_host_runtime/src/obligations.rs index b18ceff513e..a3a57f4af24 100644 --- a/crates/ironclaw_host_runtime/src/obligations.rs +++ b/crates/ironclaw_host_runtime/src/obligations.rs @@ -436,7 +436,14 @@ impl CapabilityObligationHandler for BuiltinObligationHandler { // reservation downstream. Resource reservations are immediately // released because no downstream dispatcher/process can reconcile them // on this path. CapabilityHost uses `prepare`/`complete`/`abort` - // directly instead. + // directly instead. Post-dispatch obligations fail closed here because + // this path has no dispatch result to redact, limit, or audit. + let post_dispatch = post_dispatch_obligations(request.obligations); + if !post_dispatch.is_empty() { + return Err(CapabilityObligationError::Unsupported { + obligations: post_dispatch, + }); + } let outcome = self .prepare(CapabilityObligationRequest { phase: request.phase, @@ -508,26 +515,11 @@ impl CapabilityObligationHandler for BuiltinObligationHandler { &self, request: CapabilityObligationAbortRequest<'_>, ) -> Result<(), CapabilityObligationError> { - if request - .obligations - .iter() - .any(|obligation| matches!(obligation, Obligation::ApplyNetworkPolicy { .. })) - && let Some(store) = &self.network_policies - { - let _ = store.take(&request.context.resource_scope, request.capability_id); - } - - if let Some(store) = &self.secret_injections { - for handle in secret_injection_obligations(request.obligations) { - let _ = store - .take( - &request.context.resource_scope, - request.capability_id, - &handle, - ) - .map_err(|_| secret_obligation_failed())?; - } - } + self.discard_staged_handoffs( + &request.context.resource_scope, + request.capability_id, + request.obligations, + )?; if let Some(reservation) = &request.outcome.resource_reservation { let Some(governor) = &self.resource_governor else { @@ -569,6 +561,12 @@ impl CapabilityObligationHandler for BuiltinObligationHandler { } } + self.discard_staged_handoffs( + &request.context.resource_scope, + request.capability_id, + request.obligations, + )?; + if request .obligations .iter() @@ -581,6 +579,48 @@ impl CapabilityObligationHandler for BuiltinObligationHandler { } } +impl BuiltinObligationHandler { + fn discard_staged_handoffs( + &self, + scope: &ResourceScope, + capability_id: &CapabilityId, + obligations: &[Obligation], + ) -> Result<(), CapabilityObligationError> { + if obligations + .iter() + .any(|obligation| matches!(obligation, Obligation::ApplyNetworkPolicy { .. })) + && let Some(store) = &self.network_policies + { + let _ = store.take(scope, capability_id); + } + + if let Some(store) = &self.secret_injections { + for handle in secret_injection_obligations(obligations) { + let _ = store + .take(scope, capability_id, &handle) + .map_err(|_| secret_obligation_failed())?; + } + } + + Ok(()) + } +} + +fn post_dispatch_obligations(obligations: &[Obligation]) -> Vec { + obligations + .iter() + .filter(|obligation| { + matches!( + obligation, + Obligation::AuditAfter + | Obligation::RedactOutput + | Obligation::EnforceOutputLimit { .. } + ) + }) + .cloned() + .collect() +} + fn unsupported_obligations( phase: CapabilityObligationPhase, obligations: &[Obligation], diff --git a/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs b/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs index 9890f1977f8..e6c099f73e6 100644 --- a/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs +++ b/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs @@ -63,19 +63,14 @@ async fn builtin_obligation_handler_emits_metadata_only_audit_before() { } #[tokio::test] -async fn builtin_obligation_handler_enforces_output_limit_after_dispatch() { +async fn builtin_obligation_handler_satisfy_fails_closed_on_post_dispatch_obligations() { let handler = BuiltinObligationHandler::new(); let context = execution_context(CapabilitySet::default()); let capability_id = capability_id(); let estimate = ResourceEstimate::default(); - let obligations = vec![Obligation::EnforceOutputLimit { bytes: 8 }]; - let dispatch = sample_dispatch( - &context.resource_scope, - &capability_id, - json!({"message": "this output is too large"}), - ); + let obligations = vec![Obligation::RedactOutput]; - handler + let err = handler .satisfy(CapabilityObligationRequest { phase: CapabilityObligationPhase::Invoke, context: &context, @@ -84,7 +79,27 @@ async fn builtin_obligation_handler_enforces_output_limit_after_dispatch() { obligations: &obligations, }) .await - .unwrap(); + .unwrap_err(); + + assert!(matches!( + err, + CapabilityObligationError::Unsupported { obligations } if obligations == vec![Obligation::RedactOutput] + )); +} + +#[tokio::test] +async fn builtin_obligation_handler_enforces_output_limit_after_dispatch() { + let handler = BuiltinObligationHandler::new(); + let context = execution_context(CapabilitySet::default()); + let capability_id = capability_id(); + let estimate = ResourceEstimate::default(); + let obligations = vec![Obligation::EnforceOutputLimit { bytes: 8 }]; + let dispatch = sample_dispatch( + &context.resource_scope, + &capability_id, + json!({"message": "this output is too large"}), + ); + let err = handler .complete_dispatch(CapabilityObligationCompletionRequest { phase: CapabilityObligationPhase::Invoke, @@ -119,16 +134,6 @@ async fn builtin_obligation_handler_redacts_output_after_dispatch() { json!({"authorization": leaked}), ); - handler - .satisfy(CapabilityObligationRequest { - phase: CapabilityObligationPhase::Invoke, - context: &context, - capability_id: &capability_id, - estimate: &estimate, - obligations: &obligations, - }) - .await - .unwrap(); let completed = handler .complete_dispatch(CapabilityObligationCompletionRequest { phase: CapabilityObligationPhase::Invoke, @@ -415,6 +420,97 @@ async fn builtin_obligation_handler_removes_staged_secret_on_abort() { ); } +#[tokio::test] +async fn builtin_obligation_handler_cleans_unused_staged_handoffs_after_dispatch_completion() { + let policy_store = Arc::new(NetworkObligationPolicyStore::new()); + let secret_store = Arc::new(InMemorySecretStore::new()); + let injection_store = Arc::new(RuntimeSecretInjectionStore::new()); + let handler = BuiltinObligationHandler::new() + .with_network_policy_store(policy_store.clone()) + .with_secret_store(secret_store.clone()) + .with_secret_injection_store(injection_store.clone()); + let context = execution_context(CapabilitySet::default()); + let capability_id = capability_id(); + let estimate = ResourceEstimate::default(); + let handle = SecretHandle::new("api_token").unwrap(); + secret_store + .put( + context.resource_scope.clone(), + handle.clone(), + SecretMaterial::from("runtime-secret"), + ) + .await + .unwrap(); + let policy = allowed_network_policy(); + let obligations = vec![ + Obligation::ApplyNetworkPolicy { + policy: policy.clone(), + }, + Obligation::InjectSecretOnce { + handle: handle.clone(), + }, + ]; + + handler + .prepare(CapabilityObligationRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + }) + .await + .unwrap(); + assert!( + policy_store + .take(&context.resource_scope, &capability_id) + .is_some() + ); + assert!( + injection_store + .take(&context.resource_scope, &capability_id, &handle) + .unwrap() + .is_some() + ); + policy_store.insert(&context.resource_scope, &capability_id, policy); + injection_store + .insert( + &context.resource_scope, + &capability_id, + &handle, + SecretMaterial::from("runtime-secret"), + ) + .unwrap(); + + handler + .complete_dispatch(CapabilityObligationCompletionRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + dispatch: &sample_dispatch( + &context.resource_scope, + &capability_id, + json!({"ok": true}), + ), + }) + .await + .unwrap(); + + assert!( + policy_store + .take(&context.resource_scope, &capability_id) + .is_none() + ); + assert!( + injection_store + .take(&context.resource_scope, &capability_id, &handle) + .unwrap() + .is_none() + ); +} + #[tokio::test] async fn builtin_obligation_handler_returns_scoped_mount_outcome_when_subset() { let handler = BuiltinObligationHandler::new(); diff --git a/docs/reborn/contracts/host-runtime.md b/docs/reborn/contracts/host-runtime.md index 9a0ad469bb4..eb74c7b6e0a 100644 --- a/docs/reborn/contracts/host-runtime.md +++ b/docs/reborn/contracts/host-runtime.md @@ -25,6 +25,7 @@ Supported built-in behavior: - `NetworkObligationPolicyStore` keys policies by full `ResourceScope` plus capability id and consumes entries with `take(...)`. - `RuntimeSecretInjectionStore` keys material by full `ResourceScope`, capability id, and secret handle and consumes entries with `take(...)`. +- Inline dispatch completion discards any unconsumed staged network/secret handoffs so successful calls do not leave reusable ambient state behind. - Staged secrets must never be logged or exposed through debug output. - Handler errors must use stable categories and avoid raw provider/backend details. From af4a02cf46f8b6961dcdd2e12e832b893b744771 Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Thu, 30 Apr 2026 23:34:54 +0300 Subject: [PATCH 4/5] fix: address CI advisory (iteration 2) --- Cargo.lock | 164 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 4 +- 2 files changed, 84 insertions(+), 84 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 06cf4a4e78d..11fc825a7a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,7 +235,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.59.0", "x11rb", ] @@ -1783,27 +1783,27 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046d4b584c3bb9b5eb500c8f29549bec36be11000f1ba2a927cef3d1a9875691" +checksum = "adc822414b18d1f5b1b33ce1441534e311e62fef86ebb5b9d382af857d0272c9" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9b194a7870becb1490366fc0ae392ccd188065ff35f8391e77ac659db6fb977" +checksum = "8c646808b06f4532478d8d6057d74f15c3322f10d995d9486e7dcea405bf521a" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb6a4ab44c6b371e661846b97dab687387a60ac4e2f864e2d4257284aad9e889" +checksum = "7b5996f01a686b2349cdb379083ec5ad3e8cb8767fb2d495d3a4f2ee4163a18d" dependencies = [ "cranelift-entity", "wasmtime-internal-core", @@ -1811,9 +1811,9 @@ dependencies = [ [[package]] name = "cranelift-bitset" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8b7a44150c2f471a94023482bda1902710746e4bed9f9973d60c5a94319b06d" +checksum = "523fea83273f6a985520f57788809a4de2165794d9ab00fb1254fceb4f5aa00c" dependencies = [ "serde", "serde_derive", @@ -1822,9 +1822,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01b06598133b1dd76758b8b95f8d6747c124124aade50cea96a3d88b962da9fa" +checksum = "d73d1e372730b5f64ed1a2bd9f01fe4686c8ec14a28034e3084e530c8d951878" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -1850,9 +1850,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6190e2e7bcf0a678da2f715363d34ed530fedf7a2f0ab75edaefef72a70465ff" +checksum = "b0319c18165e93dc1ebf78946a8da0b1c341c95b4a39729a69574671639bdb5f" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -1863,24 +1863,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f583cf203d1aa8b79560e3b01f929bdacf9070b015eec4ea9c46e22a3f83e4a0" +checksum = "9195cd8aeecb55e401aa96b2eaa55921636e8246c127ed7908f7ef7e0d40f270" [[package]] name = "cranelift-control" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803159df35cc398ae54473c150b16d6c77e92ab2948be638488de126a3328fbc" +checksum = "8976c2154b74136322befc74222ab5c7249edd7e2604f8cbef2b94975541ffb9" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e417257082d88087f5bcce677525bdaa8322b88dd7f175ed1a1fd41d546c" +checksum = "6038b3147c7982f4951150d5f96c7c06c1e7214b99d4b4a98607aadf8ded89d1" dependencies = [ "cranelift-bitset", "serde", @@ -1890,9 +1890,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14db6b0e0e4994c581092df78d837be2072578f7cb2528f96a6cf895e56dee63" +checksum = "4cbd294abe236e23cc3d907b0936226b6a8342db7636daa9c7c72be1e323420e" dependencies = [ "cranelift-codegen", "log", @@ -1902,15 +1902,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec66ea5025c7317383699778282ac98741d68444f956e3b1d7b62f12b7216e67" +checksum = "b5a90b6ed3aba84189352a87badeb93b2126d3724225a42dc67fdce53d1b139c" [[package]] name = "cranelift-native" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373ade56438e6232619d85678477d0a88a31b3581936e0503e61e96b546b0800" +checksum = "c3ec0cc1a54e22925eacf4fc3dc815f907734d3b377899d19d52bec04863e853" dependencies = [ "cranelift-codegen", "libc", @@ -1919,9 +1919,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef53619d3cd5c78fd998c6d9420547af26b72e6456f94c2a8a2334cb76b42baa" +checksum = "948865622f87f30907bb46fbb081b235ae63c1896a99a83c26a003305c1fa82d" [[package]] name = "crc" @@ -2408,7 +2408,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2614,7 +2614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3570,7 +3570,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -4420,7 +4420,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5224,7 +5224,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6121,9 +6121,9 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "010dec3755eb61b2f1051ecb3611b718460b7a74c131e474de2af20a845938af" +checksum = "7ec12fe19a9588315a49fe5704502a9c02d6a198303314b0c7c86123b06d29e5" dependencies = [ "cranelift-bitset", "log", @@ -6133,9 +6133,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad360c32e85ca4b083ac0e2b6856e8f11c3d5060dafa7d5dc57b370857fa3018" +checksum = "36f7d5ef31ebf1b46cd7e722ffef934e670d7e462f49aa01cde07b9b76dca580" dependencies = [ "proc-macro2", "quote", @@ -6227,7 +6227,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls 0.23.37", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -6264,9 +6264,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -6923,7 +6923,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7943,7 +7943,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9259,9 +9259,9 @@ dependencies = [ [[package]] name = "wasmtime" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce205cd643d661b5ba5ba4717e13730262e8cdbc8f2eacbc7b906d45c1a74026" +checksum = "efb1ed5899dde98357cfdcf647a4614498798719793898245b4b34e663addabf" dependencies = [ "addr2line", "async-trait", @@ -9312,9 +9312,9 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b8b78abf3677d4a0a5db82e5015b4d085ff3a1b8b472cbb8c70d4b769f019ce" +checksum = "4172382dcc785c31d0e862c6780a18f5dd437914d22c4691351f965ef751c821" dependencies = [ "anyhow", "cpp_demangle", @@ -9343,9 +9343,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cache" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e4fd4103ba413c0da2e636f73490c6c8e446d708cbde7573703941bc3d6a448" +checksum = "4ed398988226d7aa0505ac6bb576e09532ad722d702ec4e66365d78ed695c95f" dependencies = [ "base64 0.22.1", "directories-next", @@ -9363,9 +9363,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d3d6914f34be2f9d78d8ee9f422e834dfc204e71ccce697205fae95fed87892" +checksum = "ae5ec9fff073ff13b81732d56a9515d761c245750bcda09093827f84130ebc25" dependencies = [ "anyhow", "proc-macro2", @@ -9378,15 +9378,15 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-util" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3751b0616b914fdd87fe1bf804694a078f321b000338e6476bc48a4d6e454f21" +checksum = "935d9ab293ba27d1ec9aa7bc1b3a43993dbe961af2a8f23f90a11e1331b4c13f" [[package]] name = "wasmtime-internal-core" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22632b187e1b0716f1b9ac57ad29013bed33175fcb19e10bb6896126f82fac67" +checksum = "9a3820b174f477d2a7083209d1ad5353fcdb11eaea434b2137b8681029460dd3" dependencies = [ "anyhow", "hashbrown 0.16.1", @@ -9396,9 +9396,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cranelift" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b3ca07b3e0bb3429674b173b5800577719d600774dd81bff58f775c0aaa64ee" +checksum = "d1679d205caf9766c6aa309d45bb3e7c634d7725e3164404df33824b9f7c4fb7" dependencies = [ "cfg-if", "cranelift-codegen", @@ -9423,9 +9423,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20c8b2c9704eb1f33ead025ec16038277ccb63d0a14c31e99d5b765d7c36da55" +checksum = "f1e505254058be5b0df458d670ee42d9eafe2349d04c1296e9dc01071dc20a85" dependencies = [ "cc", "cfg-if", @@ -9438,9 +9438,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d950310d07391d34369f62c48336ebb14eacbd4d6f772bb5f349c24e838e0664" +checksum = "1c2e05b345f1773e59c20e6ad7298fd6857cdea245023d88bb659c96d8f0ea72" dependencies = [ "cc", "object", @@ -9450,9 +9450,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3606662c156962d096be3127b8b8ae8ee2f8be3f896dad29259ff01ddb64abfd" +checksum = "b86701b234a4643e3f111869aa792b3a05a06e02d486ee9cb6c04dae16b52dab" dependencies = [ "cfg-if", "libc", @@ -9462,9 +9462,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-unwinder" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75eef0747e52dc545b075f64fd0e0cc237ae738e641266b1970e07e2d744bc32" +checksum = "f63558d801beb83dde9b336eb4ae049019aee26627926edb32cd119d7e4c83cd" dependencies = [ "cfg-if", "cranelift-codegen", @@ -9475,9 +9475,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b0a5dab02a8fb527f547855ecc0e05f9fdc3d5bd57b8b080349408f9a6cece" +checksum = "737c4d956fc3a848541a064afb683dd2771132a6b125be5baaf95c4379aa47df" dependencies = [ "proc-macro2", "quote", @@ -9486,9 +9486,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-winch" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8007342bd12ff400293a817973f7ecd6f1d9a8549a53369a9c1af357166f1f1e" +checksum = "f599b79545e3bba0b7913406055ebede5bb0dabee9ba2015ef25a9f4c9f47807" dependencies = [ "cranelift-codegen", "gimli", @@ -9503,9 +9503,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7900c3e3c1d6e475bc225d73b02d6d5484815f260022e6964dca9558e50dd01a" +checksum = "2192a77a00b9a67800c2b4e1c70fb6abca79d6b529e53a2ef9dcdcc36090330d" dependencies = [ "anyhow", "bitflags 2.11.0", @@ -9516,9 +9516,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3e3ddcfad69e9eb025bd19bff70dad45bafe1d6eacd134c0ffdfc4c161d045" +checksum = "00c7daf53ba2f64aa089f47d9a54bec654a45b7b1b55660efecfb09a2e6cfbcf" dependencies = [ "async-trait", "bitflags 2.11.0", @@ -9546,9 +9546,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi-io" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ca5dd3b9f04a851c422d05f333366722742da46bff9369ae0191f32cf83565a" +checksum = "c2c9c6cd3daf62a4fb75ac4742c976fee1939686ffe461a366ce6446c58a58a0" dependencies = [ "async-trait", "bytes", @@ -9671,9 +9671,9 @@ dependencies = [ [[package]] name = "wiggle" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1b1135efc8e5a008971897bea8d41ca56d8d501d4efb807842ae0a1c78f639" +checksum = "9c8cfd3db2f05619c6f36f257d84327c11546e28d61e3a1c1220aaad553bc4b0" dependencies = [ "bitflags 2.11.0", "thiserror 2.0.18", @@ -9685,9 +9685,9 @@ dependencies = [ [[package]] name = "wiggle-generate" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7bc2b0d50ec8773b44fbfe1da6cb5cc44a92deaf8483233dcf0831e6db33172" +checksum = "4bd7a197903e5b4ff5e13aef9c891960d71e92073600ecf4c86c7e795ac1c803" dependencies = [ "heck", "proc-macro2", @@ -9699,9 +9699,9 @@ dependencies = [ [[package]] name = "wiggle-macro" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6c7d44ea552e1fbfdcd7a2cd83f5c2d1e803d5b1a11e3462c06888b77f455f" +checksum = "6410b86fcec207070d9372b215d3470bad67215e6bbac46981a16999c4abbc28" dependencies = [ "proc-macro2", "quote", @@ -9731,7 +9731,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9742,9 +9742,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "winch-codegen" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb9f45f7172a2628c8317766e427babc0a400f9d10b1c0f0b0617c5ed5b79de6" +checksum = "52dbb0cf07b0dfe7b7a1ca8efb8f94ba98bd0fb144c411ea1665c78f0449e958" dependencies = [ "cranelift-assembler-x64", "cranelift-codegen", diff --git a/Cargo.toml b/Cargo.toml index 0b7e75e7508..2782e813d1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -151,8 +151,8 @@ open = "5" pgvector = { version = "0.4", features = ["postgres"], optional = true } # WASM sandbox for untrusted tool execution -wasmtime = { version = "43.0.1", features = ["component-model"] } -wasmtime-wasi = "43.0.1" # WASI support for component model +wasmtime = { version = "43.0.2", features = ["component-model"] } +wasmtime-wasi = "43.0.2" # WASI support for component model wasmparser = "0.245.1" # WASM binary parsing for validation # Cryptography for secrets management From 8b44a957ab97981e2b8566ddf352e60cc7eb5c7b Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Fri, 1 May 2026 10:57:39 +0300 Subject: [PATCH 5/5] fix: preserve direct obligation handoffs --- .../ironclaw_host_runtime/src/obligations.rs | 50 ++++++++------ .../builtin_obligation_handler_contract.rs | 66 +++++++++++++++++++ docs/reborn/contracts/host-runtime.md | 1 + 3 files changed, 96 insertions(+), 21 deletions(-) diff --git a/crates/ironclaw_host_runtime/src/obligations.rs b/crates/ironclaw_host_runtime/src/obligations.rs index a3a57f4af24..805db53117f 100644 --- a/crates/ironclaw_host_runtime/src/obligations.rs +++ b/crates/ironclaw_host_runtime/src/obligations.rs @@ -433,11 +433,12 @@ impl CapabilityObligationHandler for BuiltinObligationHandler { ) -> Result<(), CapabilityObligationError> { // `satisfy` is the direct one-shot path for callers that need staged // network/secret handoff but do not need to pass prepared mounts or a - // reservation downstream. Resource reservations are immediately - // released because no downstream dispatcher/process can reconcile them - // on this path. CapabilityHost uses `prepare`/`complete`/`abort` - // directly instead. Post-dispatch obligations fail closed here because - // this path has no dispatch result to redact, limit, or audit. + // reservation downstream. Resource reservations are released without + // discarding staged handoffs because successful callers still need the + // network/secret material handed to runtime adapters. CapabilityHost + // uses `prepare`/`complete`/`abort` directly instead. Post-dispatch + // obligations fail closed here because this path has no dispatch result + // to redact, limit, or audit. let post_dispatch = post_dispatch_obligations(request.obligations); if !post_dispatch.is_empty() { return Err(CapabilityObligationError::Unsupported { @@ -453,16 +454,15 @@ impl CapabilityObligationHandler for BuiltinObligationHandler { obligations: request.obligations, }) .await?; - if outcome.resource_reservation.is_some() { - self.abort(CapabilityObligationAbortRequest { - phase: request.phase, - context: request.context, - capability_id: request.capability_id, - estimate: request.estimate, - obligations: request.obligations, - outcome: &outcome, - }) - .await?; + if let Some(reservation) = &outcome.resource_reservation + && let Err(error) = self.release_resource_reservation(reservation) + { + let _ = self.discard_staged_handoffs( + &request.context.resource_scope, + request.capability_id, + request.obligations, + ); + return Err(error); } Ok(()) } @@ -522,12 +522,7 @@ impl CapabilityObligationHandler for BuiltinObligationHandler { )?; if let Some(reservation) = &request.outcome.resource_reservation { - let Some(governor) = &self.resource_governor else { - return Err(resource_obligation_failed()); - }; - governor - .release(reservation.id) - .map_err(|_| resource_obligation_failed())?; + self.release_resource_reservation(reservation)?; } Ok(()) } @@ -580,6 +575,19 @@ impl CapabilityObligationHandler for BuiltinObligationHandler { } impl BuiltinObligationHandler { + fn release_resource_reservation( + &self, + reservation: &ResourceReservation, + ) -> Result<(), CapabilityObligationError> { + let Some(governor) = &self.resource_governor else { + return Err(resource_obligation_failed()); + }; + governor + .release(reservation.id) + .map(|_| ()) + .map_err(|_| resource_obligation_failed()) + } + fn discard_staged_handoffs( &self, scope: &ResourceScope, diff --git a/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs b/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs index e6c099f73e6..3fad37d0f1a 100644 --- a/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs +++ b/crates/ironclaw_host_runtime/tests/builtin_obligation_handler_contract.rs @@ -420,6 +420,72 @@ async fn builtin_obligation_handler_removes_staged_secret_on_abort() { ); } +#[tokio::test] +async fn builtin_obligation_handler_satisfy_preserves_staged_handoffs_when_releasing_reservation() { + let policy_store = Arc::new(NetworkObligationPolicyStore::new()); + let secret_store = Arc::new(InMemorySecretStore::new()); + let injection_store = Arc::new(RuntimeSecretInjectionStore::new()); + let governor = Arc::new(InMemoryResourceGovernor::new()); + let handler = BuiltinObligationHandler::new() + .with_network_policy_store(policy_store.clone()) + .with_secret_store(secret_store.clone()) + .with_secret_injection_store(injection_store.clone()) + .with_resource_governor(governor.clone()); + let context = execution_context(CapabilitySet::default()); + let account = ResourceAccount::tenant(context.resource_scope.tenant_id.clone()); + let capability_id = capability_id(); + let estimate = ResourceEstimate { + concurrency_slots: Some(1), + ..ResourceEstimate::default() + }; + let handle = SecretHandle::new("api_token").unwrap(); + secret_store + .put( + context.resource_scope.clone(), + handle.clone(), + SecretMaterial::from("runtime-secret"), + ) + .await + .unwrap(); + let obligations = vec![ + Obligation::ApplyNetworkPolicy { + policy: allowed_network_policy(), + }, + Obligation::InjectSecretOnce { + handle: handle.clone(), + }, + Obligation::ReserveResources { + reservation_id: ResourceReservationId::new(), + }, + ]; + + handler + .satisfy(CapabilityObligationRequest { + phase: CapabilityObligationPhase::Invoke, + context: &context, + capability_id: &capability_id, + estimate: &estimate, + obligations: &obligations, + }) + .await + .unwrap(); + + assert_eq!(governor.reserved_for(&account).concurrency_slots, 0); + assert!( + policy_store + .take(&context.resource_scope, &capability_id) + .is_some(), + "direct satisfy should preserve staged network handoff after releasing reservation" + ); + assert!( + injection_store + .take(&context.resource_scope, &capability_id, &handle) + .unwrap() + .is_some(), + "direct satisfy should preserve staged secret handoff after releasing reservation" + ); +} + #[tokio::test] async fn builtin_obligation_handler_cleans_unused_staged_handoffs_after_dispatch_completion() { let policy_store = Arc::new(NetworkObligationPolicyStore::new()); diff --git a/docs/reborn/contracts/host-runtime.md b/docs/reborn/contracts/host-runtime.md index eb74c7b6e0a..e3dec6895da 100644 --- a/docs/reborn/contracts/host-runtime.md +++ b/docs/reborn/contracts/host-runtime.md @@ -25,6 +25,7 @@ Supported built-in behavior: - `NetworkObligationPolicyStore` keys policies by full `ResourceScope` plus capability id and consumes entries with `take(...)`. - `RuntimeSecretInjectionStore` keys material by full `ResourceScope`, capability id, and secret handle and consumes entries with `take(...)`. +- Direct `satisfy(...)` releases any prepared resource reservation without discarding successfully staged network/secret handoffs that the caller still needs to pass to runtime adapters. - Inline dispatch completion discards any unconsumed staged network/secret handoffs so successful calls do not leave reusable ambient state behind. - Staged secrets must never be logged or exposed through debug output. - Handler errors must use stable categories and avoid raw provider/backend details.