From e7dbca8bb2a303c928e16e688c750d2cd03d44ca Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Tue, 21 Jul 2026 03:45:26 +0000 Subject: [PATCH] =?UTF-8?q?refactor(reborn):=20move=20resume-path=20author?= =?UTF-8?q?ity=20into=20the=20kernel=20+=20delete=20host=5Fruntime=20pre-a?= =?UTF-8?q?uthorization=20(=C2=A75.3.2/=C2=A79,=20R-A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the capability kernel to be the single authority site for resume as it already is for invoke, then deletes host_runtime's now-truly-redundant pre-authorization. Concretely: (a) Kernel resume authority. authorize_resumed and the resume_spawn_json fold now run runtime-policy planning on resume via a new resume_preflight that resolves the descriptor and enforces plan_capability BEFORE the run-state lookup — reversing #6386's "planning is NOT re-run on resume" so a policy tightened between invoke and resume fails closed. Descriptor/trust existence is checked first, so an unknown resume capability short-circuits to UnknownCapability (-> MissingRuntime) instead of the run-state-not-found Backend path; failure fails ONLY the matching blocked run (scope isolation), keyed by scope+invocation+status+capability[+approval]. This restores host_runtime's old production precedence (its deleted open_pre_authorization resolved the registry before the kernel's mismatch check ran). The run-state fail records the planner-specific INTERNAL error_kind (planner_error_kind, e.g. "process_backend_none") while the model-visible message stays the sanitized DenyReason::PolicyDenied — the planner enum name never reaches the model. The kernel still stamps context.trust on the resume authorize_context clone before the authorizer. (b) host_runtime deletion. open_pre_authorization + its invoke/spawn/resume/ auth-resume/resume-spawn callers, the fail_matching_blocked_{,auth_}resume helpers, and the now-dead evaluate_invocation_trust / enforce_runtime_policy / TrustEvaluationError / RuntimePolicyEvaluationError / failure + trust-input helpers are removed; the local-manifest trust-input unit test moves to ironclaw_capabilities::trust. Kept: trust_policy/runtime_policy fields (wired to the kernel), plan_capability for surface.rs visibility, the context.validate() forged-scope guard, the credential/gate helpers, and the HostPolicyFacts impl. (c) Descriptor/start reorder. authorize() and authorize_spawn() resolve the descriptor BEFORE run_state.start, so an unknown capability creates no run record (restoring the no-record-for-unknown behavior the deleted pre-check gave). (d) Message sanitization tests. Invoke-path runtime-policy-denial tests (builtin_http + tool_surface extension/secret/mcp) now assert the sanitized DenyReason message ("denied", not the leaked NetworkMode::/SecretMode:: planner enum tokens) — the #6386 sanitization, now observable once the redundant host_runtime pre-check is gone. (e) Mismatch tests corrected. The three capability-id-mismatch resume tests now trigger the mismatch with a known-but-different, plannable capability (echo.other) so the run-state mismatch check fires (ResumeContextMismatch), isolating "mismatch" from "unknown-capability"; a new kernel test pins "unknown capability on resume -> UnknownCapability + fail matching run". Verified context.trust has no reader except the kernel-stamped authorize_context (ironclaw_authorization::authorize_from_grants_with_trust). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ironclaw_capabilities/src/host.rs | 232 ++++++- crates/ironclaw_capabilities/src/trust.rs | 64 ++ .../capability_host_auth_resume_contract.rs | 8 +- .../capability_host_run_state_contract.rs | 87 ++- .../tests/support/mod.rs | 19 + .../ironclaw_host_runtime/src/production.rs | 638 ++---------------- .../tests/first_party_builtin_tools.rs | 12 +- .../tests/tool_surface_contract.rs | 34 +- 8 files changed, 438 insertions(+), 656 deletions(-) diff --git a/crates/ironclaw_capabilities/src/host.rs b/crates/ironclaw_capabilities/src/host.rs index be3db623dcd..f07214e0da0 100644 --- a/crates/ironclaw_capabilities/src/host.rs +++ b/crates/ironclaw_capabilities/src/host.rs @@ -4,12 +4,13 @@ use ironclaw_authorization::{ }; use ironclaw_extensions::ExtensionRegistry; use ironclaw_host_api::{ - ActivityId, Actor, AuthorizeResult, Authorized, Blocked, CapabilityAuthorizer, - CapabilityDescriptor, CapabilityDispatchRequest, CapabilityDispatchResult, - CapabilityDispatcher, CapabilityGrantId, CapabilityId, Decision, DenyReason, DenyRef, - DispatchError, EffectiveRuntimePolicy, ExecutionContext, GateRef, GateWaypoint, Invocation, - InvocationFingerprint, InvocationId, InvocationOrigin, Obligation, PermissionMode, ProcessId, - ProductKind, ResourceEstimate, ResourceScope, RuntimeLane, Timestamp, + ActivityId, Actor, ApprovalRequestId, AuthorizeResult, Authorized, Blocked, + CapabilityAuthorizer, CapabilityDescriptor, CapabilityDispatchRequest, + CapabilityDispatchResult, CapabilityDispatcher, CapabilityGrantId, CapabilityId, Decision, + DenyReason, DenyRef, DispatchError, EffectiveRuntimePolicy, ExecutionContext, GateRef, + GateWaypoint, Invocation, InvocationFingerprint, InvocationId, InvocationOrigin, Obligation, + PermissionMode, ProcessId, ProductKind, ResourceEstimate, ResourceScope, RuntimeLane, + Timestamp, }; use ironclaw_processes::{ProcessManager, ProcessStart}; use ironclaw_run_state::{ @@ -89,6 +90,20 @@ struct PendingClaimAfterAuth<'r> { fingerprint: InvocationFingerprint, } +/// Which blocked run a resume-path preflight failure may fail (§5.3.2/§9, R-A). +/// Mirrors host_runtime's two deleted matchers: the approval-resume / +/// spawn-resume paths key on a `BlockedApproval` record and compare the +/// `approval_request_id`; the auth-resume path keys on a `BlockedAuth` record and +/// does NOT compare `approval_request_id` (its `block_auth` transition clears the +/// persisted id to `None`). +#[derive(Debug, Clone, Copy)] +enum BlockedResumeKind { + Approval { + approval_request_id: ApprovalRequestId, + }, + Auth, +} + /// Encodes the three mutually-exclusive approval-lease states that /// `dispatch_resumed_capability` must handle. enum ResumedLeaseState<'r> { @@ -581,6 +596,19 @@ where source, })?; + // Resolve the descriptor BEFORE starting a run record: an unknown + // capability must short-circuit without creating a run record (restoring + // the behavior host_runtime's deleted pre-check provided). Neither the + // fingerprint above nor `run_state.start` below needs the descriptor, so + // hoisting this lookup is safe; everything from `start` onward keeps its + // original order (the credential pre-flight still runs after `start`). + let Some(descriptor) = self.registry.get_capability(&request.capability_id) else { + debug!("capability invocation failed before authorization: unknown capability"); + return Err(CapabilityInvocationError::UnknownCapability { + capability: request.capability_id.clone(), + }); + }; + if let Some(run_state) = self.run_state { run_state .start(RunStart { @@ -596,15 +624,6 @@ where debug!("capability run state started"); } - let Some(descriptor) = self.registry.get_capability(&request.capability_id) else { - debug!("capability invocation failed before authorization: unknown capability"); - fail_run_if_configured(self.run_state, &scope, invocation_id, "UnknownCapability") - .await; - return Err(CapabilityInvocationError::UnknownCapability { - capability: request.capability_id.clone(), - }); - }; - // Kernel-computed trust + in-fold runtime-policy planning (§5.3.2/§9), // relocated from host_runtime's `open_pre_authorization`. The // `context.trust` stamp reproduces what `open_pre_authorization` did @@ -1056,6 +1075,21 @@ where }); } + // Resume-path pre-authorization (§5.3.2/§9, R-A): resolve the descriptor + // and enforce runtime-policy planning BEFORE the run-state lookup so an + // unknown capability short-circuits to `UnknownCapability` + // (→ `MissingRuntime`) instead of the run-state-not-found `Backend` path, + // and a policy tightened between invoke and resume fails closed. On + // refusal only the matching `BlockedApproval` run is failed. + self.resume_preflight( + &request.context, + &request.capability_id, + BlockedResumeKind::Approval { + approval_request_id: request.approval_request_id, + }, + ) + .await?; + let invocation_fingerprint = invocation_fingerprint_for_kind( CapabilityActionKind::Dispatch, &scope, @@ -1232,6 +1266,17 @@ where }); } + // Resume-path pre-authorization (§5.3.2/§9, R-A): descriptor + runtime-policy + // planning BEFORE the run-state lookup (see `resume_json`). On refusal only + // the matching `BlockedAuth` run is failed — `approval_request_id` is NOT + // compared, because `block_auth` clears it to `None` on the record. + self.resume_preflight( + &request.context, + &request.capability_id, + BlockedResumeKind::Auth, + ) + .await?; + let run_record = run_state .get(&scope, invocation_id) .await? @@ -1567,6 +1612,19 @@ where }); } + // Resume-path pre-authorization (§5.3.2/§9, R-A): descriptor + runtime-policy + // planning BEFORE the run-state lookup (see `resume_json`), so an unknown + // capability short-circuits to `MissingRuntime` and a tightened policy fails + // closed. On refusal only the matching `BlockedApproval` run is failed. + self.resume_preflight( + &request.context, + &request.capability_id, + BlockedResumeKind::Approval { + approval_request_id: request.approval_request_id, + }, + ) + .await?; + let invocation_fingerprint = invocation_fingerprint_for_kind( CapabilityActionKind::Spawn, &scope, @@ -1692,8 +1750,9 @@ where let mut authorized_context = request.context.clone(); authorized_context.grants.grants.push(lease.grant.clone()); - // Kernel-computed trust + in-fold runtime-policy planning (§5.3.2/§9) on - // the spawn-resume path, mirroring host_runtime's pre-authorization. + // Kernel-computed trust on the spawn-resume path (§5.3.2/§9). Runtime-policy + // planning already ran in `resume_preflight` above (fail-closed before the + // lease was claimed), so it is not repeated here. let trust_decision = match self.evaluate_trust(&capability_id) { Ok(d) => d, Err(error) => { @@ -1707,16 +1766,6 @@ where return Err(error); } }; - if let Err(error) = self.enforce_runtime_policy(descriptor) { - fail_run_if_configured( - Some(run_state), - &scope, - invocation_id, - "AuthorizationDenied", - ) - .await; - return Err(error); - } authorized_context.trust = trust_decision.effective_trust.class(); let obligations = match self @@ -2051,6 +2100,15 @@ where source, })?; + // Resolve the descriptor BEFORE starting a run record (see `authorize`): + // an unknown capability short-circuits without creating a run record, so + // no `fail_run_if_configured` is needed here. + let Some(descriptor) = self.registry.get_capability(&request.capability_id) else { + return Err(CapabilityInvocationError::UnknownCapability { + capability: request.capability_id.clone(), + }); + }; + if let Some(run_state) = self.run_state { run_state .start(RunStart { @@ -2065,14 +2123,6 @@ where .await?; } - let Some(descriptor) = self.registry.get_capability(&request.capability_id) else { - fail_run_if_configured(self.run_state, &scope, invocation_id, "UnknownCapability") - .await; - return Err(CapabilityInvocationError::UnknownCapability { - capability: request.capability_id.clone(), - }); - }; - // Kernel-computed trust + in-fold runtime-policy planning (§5.3.2/§9), // mirroring `authorize()` on the spawn path. let trust_decision = match self.evaluate_trust(&request.capability_id) { @@ -2339,6 +2389,97 @@ where } } + /// Resume-path pre-authorization, relocated from host_runtime's deleted + /// `open_pre_authorization` + `fail_matching_blocked_{,auth_}resume_on_preflight_error` + /// (§5.3.2/§9, R-A). Resolves the descriptor and enforces runtime-policy + /// planning on the resumed capability BEFORE the fold's run-state lookup, so an + /// unknown capability short-circuits to `UnknownCapability` (→ `MissingRuntime`) + /// instead of the run-state-not-found `Backend` path, and a runtime policy + /// tightened between invoke and resume fails closed (reversing #6386's + /// "planning is NOT re-run on resume"). On refusal it fails ONLY the matching + /// blocked run — via [`Self::fail_matching_blocked_resume_run`] — recording the + /// planner-specific INTERNAL `error_kind`, then returns the sanitized error (the + /// model-visible message stays sanitized through `DenyReason`; the planner + /// detail rides only the run-state audit record). Trust is still classified + /// downstream (in `authorize_resumed` / the spawn-resume fold), which stamps + /// `context.trust` before the authorizer. + async fn resume_preflight( + &self, + context: &ExecutionContext, + capability_id: &CapabilityId, + blocked: BlockedResumeKind, + ) -> Result<(), CapabilityInvocationError> { + let Some(descriptor) = self.registry.get_capability(capability_id) else { + self.fail_matching_blocked_resume_run( + context, + capability_id, + blocked, + "unknown_capability", + ) + .await; + return Err(CapabilityInvocationError::UnknownCapability { + capability: capability_id.clone(), + }); + }; + if let Err(planner_error) = plan_capability(descriptor, self.runtime_policy) { + let error_kind = planner_error_kind(&planner_error); + self.fail_matching_blocked_resume_run(context, capability_id, blocked, error_kind) + .await; + return Err(runtime_policy_error_to_invocation_error( + capability_id, + planner_error, + )); + } + Ok(()) + } + + /// Fail ONLY the blocked run that matches this resume request, relocated from + /// host_runtime's deleted `fail_matching_blocked_{,auth_}resume_on_preflight_error` + /// (§5.3.2/§9, R-A). Keyed by the request scope + invocation; a wrong-scope or + /// otherwise non-matching request leaves other blocked runs untouched (scope + /// isolation). The matching run is transitioned to `Failed` with `error_kind`. + async fn fail_matching_blocked_resume_run( + &self, + context: &ExecutionContext, + capability_id: &CapabilityId, + blocked: BlockedResumeKind, + error_kind: &'static str, + ) { + let Some(run_state) = self.run_state else { + return; + }; + let scope = &context.resource_scope; + let invocation_id = context.invocation_id; + let record = match run_state.get(scope, invocation_id).await { + Ok(Some(record)) => record, + Ok(None) => return, + Err(error) => { + warn!( + invocation_id = %invocation_id, + capability_id = %capability_id, + preflight_error_kind = error_kind, + lookup_error_kind = run_state_error_kind(&error), + "resume preflight failed, but run-state lookup failed; leaving run state unchanged", + ); + return; + } + }; + let matches = record.capability_id == *capability_id + && record.authenticated_actor_user_id == context.authenticated_actor_user_id + && match blocked { + BlockedResumeKind::Approval { + approval_request_id, + } => { + record.status == RunStatus::BlockedApproval + && record.approval_request_id == Some(approval_request_id) + } + BlockedResumeKind::Auth => record.status == RunStatus::BlockedAuth, + }; + if matches { + fail_run_if_configured(Some(run_state), scope, invocation_id, error_kind).await; + } + } + /// Pre-dispatch authority fold shared by `resume_json` and /// `auth_resume_json`, extracted per arch-simplification §9 step 2 / §5.3.2 /// exactly as [`Self::authorize`] does for invoke: run trust-aware @@ -2364,8 +2505,9 @@ where ) -> Result { // Kernel-computed trust (§5.3.2/§9): trust is classified here from the // resumed capability id rather than carried on the request. Runtime-policy - // planning is NOT re-run on resume (it ran at the original invoke/spawn); - // the `context.trust` stamp reproduces `open_pre_authorization`. + // planning already ran in the caller's `resume_preflight` (§5.3.2/§9, R-A, + // reversing #6386's "planning is NOT re-run on resume"); the `context.trust` + // stamp below reproduces host_runtime's deleted `open_pre_authorization`. let trust_decision = match self.evaluate_trust(¶ms.capability_id) { Ok(d) => d, Err(error) => { @@ -2953,6 +3095,22 @@ fn runtime_policy_error_to_invocation_error( } } +/// Internal (audit-only) `error_kind` for a runtime-policy planner refusal, kept +/// distinct from the sanitized model-visible `DenyReason::PolicyDenied` that +/// `runtime_policy_error_to_invocation_error` produces. Mirrors the strings +/// host_runtime's deleted `RuntimePolicyEvaluationError::kind` recorded on the +/// blocked-run failure so the run-state audit record is unchanged (e.g. +/// `"process_backend_none"`); the planner enum name never reaches the model. +fn planner_error_kind(error: &PlannerError) -> &'static str { + match error { + PlannerError::ProcessEffectsRequiredButProcessBackendIsNone { .. } => { + "process_backend_none" + } + PlannerError::NetworkRequiredButNetworkModeIsDeny { .. } => "network_denied", + PlannerError::SecretAccessRequiredButSecretModeIsDeny { .. } => "secret_denied", + } +} + fn add_capability_input_display_hint( reason: &mut String, capability_id: &CapabilityId, diff --git a/crates/ironclaw_capabilities/src/trust.rs b/crates/ironclaw_capabilities/src/trust.rs index cca08918c8e..30f35d1deee 100644 --- a/crates/ironclaw_capabilities/src/trust.rs +++ b/crates/ironclaw_capabilities/src/trust.rs @@ -100,3 +100,67 @@ fn local_manifest_source(package: &ExtensionPackage) -> PackageSource { ), } } + +#[cfg(test)] +mod tests { + use super::*; + use ironclaw_extensions::{ExtensionManifest, ManifestSource}; + use ironclaw_host_api::{HostPortCatalog, VirtualPath, sha256_digest_token}; + + // Relocated from `ironclaw_host_runtime::production` alongside the trust + // evaluation this crate now owns (§5.3.2/§9): the local-manifest trust + // policy input must carry the manifest path as `PackageSource::LocalManifest` + // and the manifest digest, so the host trust policy classifies the package + // from its on-disk identity. + #[test] + fn local_manifest_trust_input_includes_manifest_digest() { + const MANIFEST: &str = r#" +schema_version = "reborn.extension_manifest.v2" +id = "test" +name = "Test" +version = "0.1.0" +description = "test extension" +trust = "third_party" + +[runtime] +kind = "script" +runner = "sandboxed_process" +command = "echo" + +[[capabilities]] +id = "test.cap" +description = "Test capability" +effects = ["network"] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/test.input.json" +output_schema_ref = "schemas/test.output.json" +"#; + let manifest = ExtensionManifest::parse( + MANIFEST, + ManifestSource::HostBundled, + &HostPortCatalog::empty(), + ) + .unwrap(); + let package = ExtensionPackage::from_manifest_toml( + manifest, + VirtualPath::new("/system/extensions/test").unwrap(), + MANIFEST, + ) + .unwrap(); + + let input = trust_policy_input_for_local_manifest(&package).unwrap(); + + assert_eq!( + input.identity.source, + PackageSource::LocalManifest { + path: "/system/extensions/test/manifest.toml".to_string() + } + ); + let expected_digest = sha256_digest_token(MANIFEST.as_bytes()); + assert_eq!( + input.identity.digest.as_deref(), + Some(expected_digest.as_str()) + ); + } +} diff --git a/crates/ironclaw_capabilities/tests/capability_host_auth_resume_contract.rs b/crates/ironclaw_capabilities/tests/capability_host_auth_resume_contract.rs index 9ba9290f3a9..b96b4d6902c 100644 --- a/crates/ironclaw_capabilities/tests/capability_host_auth_resume_contract.rs +++ b/crates/ironclaw_capabilities/tests/capability_host_auth_resume_contract.rs @@ -509,8 +509,12 @@ async fn auth_resume_json_rejects_capability_id_mismatch_against_run_record() { let host = capability_host(®istry, &dispatcher, &authorizer).with_run_state(&run_state); - // Attempt auth_resume with a DIFFERENT capability_id. - let different_id = CapabilityId::new("other.capability").unwrap(); + // Attempt auth_resume with a DIFFERENT but KNOWN-and-plannable capability_id, + // so the run-state capability-mismatch check fires. (An unknown id would now + // short-circuit to `UnknownCapability` in `resume_preflight` before the + // mismatch check — existence-first, matching host_runtime's deleted + // pre-authorization; the unknown-capability precedence is covered separately.) + let different_id = other_capability_id(); let err = host .auth_resume_json(CapabilityAuthResumeRequest { context, diff --git a/crates/ironclaw_capabilities/tests/capability_host_run_state_contract.rs b/crates/ironclaw_capabilities/tests/capability_host_run_state_contract.rs index e8e7f339ab0..f2757daa927 100644 --- a/crates/ironclaw_capabilities/tests/capability_host_run_state_contract.rs +++ b/crates/ironclaw_capabilities/tests/capability_host_run_state_contract.rs @@ -531,11 +531,15 @@ async fn capability_host_returns_resume_business_error_when_run_state_fail_trans .with_run_state(&run_state) .with_approval_requests(&approval_requests) .with_capability_leases(&leases); + // Known-and-plannable but different capability, so the mismatch check fires + // and drives the run-state fail transition (which this store fails) while the + // business error is still returned. An unknown id would short-circuit to + // `UnknownCapability` in `resume_preflight` before reaching this path. let err = resume_host .resume_json(CapabilityResumeRequest { context, approval_request_id: approval_id, - capability_id: CapabilityId::new("echo.other").unwrap(), + capability_id: other_capability_id(), estimate, input, }) @@ -544,7 +548,7 @@ async fn capability_host_returns_resume_business_error_when_run_state_fail_trans match err { CapabilityInvocationError::ResumeContextMismatch { capability, kind } => { - assert_eq!(capability, CapabilityId::new("echo.other").unwrap()); + assert_eq!(capability, other_capability_id()); assert_eq!(kind, ResumeContextMismatchKind::CapabilityId); } other => panic!("expected ResumeContextMismatch, got {other:?}"), @@ -1236,7 +1240,11 @@ async fn capability_host_rejects_resume_with_mismatched_capability_id() { .with_run_state(&run_state) .with_approval_requests(&approval_requests) .with_capability_leases(&leases); - let wrong_capability = CapabilityId::new("echo.other").unwrap(); + // Known-and-plannable but different capability, so the run-state + // capability-mismatch check fires. (An unknown id would short-circuit to + // `UnknownCapability` in `resume_preflight` — existence-first; that + // precedence is covered by a dedicated test.) + let wrong_capability = other_capability_id(); let err = resume_host .resume_json(CapabilityResumeRequest { context, @@ -1263,6 +1271,79 @@ async fn capability_host_rejects_resume_with_mismatched_capability_id() { assert_eq!(run.error_kind.as_deref(), Some("ResumeContextMismatch")); } +// Existence-first precedence: when the resumed capability is UNKNOWN at resume +// time (here, unregistered between invoke and resume — same capability_id as the +// blocked run, but absent from the resume registry), `resume_preflight` rejects +// it as `UnknownCapability` (→ `MissingRuntime`) and fails the matching blocked +// run with the internal `error_kind` "unknown_capability", BEFORE the run-state +// path could turn a missing record into `Backend`. This reproduces host_runtime's +// deleted `open_pre_authorization` (which resolved the registry first) and pins +// the unknown-capability coverage the mismatch tests used to provide incidentally +// (they now use a known-but-different capability). +#[tokio::test] +async fn capability_host_resume_unknown_capability_fails_matching_blocked_run() { + let registry = registry_with_echo_capability(); + let empty_registry = ExtensionRegistry::new(); + let dispatcher = RecordingDispatcher::default(); + let run_state = ironclaw_run_state::in_memory_backed_run_state_store(); + let approval_requests = ironclaw_run_state::in_memory_backed_approval_request_store(); + let leases = in_memory_backed_capability_lease_store(); + let block_host = capability_host(®istry, &dispatcher, &ApprovalAuthorizer) + .with_run_state(&run_state) + .with_approval_requests(&approval_requests); + let context = execution_context(CapabilitySet::default()); + let scope = context.resource_scope.clone(); + let invocation_id = context.invocation_id; + let estimate = ResourceEstimate::default(); + let input = json!({"message": "approved"}); + + block_host + .invoke_json(CapabilityInvocationRequest { + context: context.clone(), + capability_id: capability_id(), + estimate: estimate.clone(), + input: input.clone(), + }) + .await + .unwrap_err(); + let approval_id = run_state + .get(&scope, invocation_id) + .await + .unwrap() + .unwrap() + .approval_request_id + .unwrap(); + + // Resume with the SAME capability_id, but against an empty registry so the + // capability is unknown at resume time. + let resume_authorizer = GrantAuthorizer::new(); + let resume_host = capability_host(&empty_registry, &dispatcher, &resume_authorizer) + .with_run_state(&run_state) + .with_approval_requests(&approval_requests) + .with_capability_leases(&leases); + let err = resume_host + .resume_json(CapabilityResumeRequest { + context, + approval_request_id: approval_id, + capability_id: capability_id(), + estimate, + input, + }) + .await + .unwrap_err(); + + match err { + CapabilityInvocationError::UnknownCapability { capability } => { + assert_eq!(capability, capability_id()); + } + other => panic!("expected UnknownCapability (existence-first), got {other:?}"), + } + assert!(!dispatcher.has_request()); + let run = run_state.get(&scope, invocation_id).await.unwrap().unwrap(); + assert_eq!(run.status, RunStatus::Failed); + assert_eq!(run.error_kind.as_deref(), Some("unknown_capability")); +} + #[tokio::test] async fn capability_host_rejects_resume_with_mismatched_approval_request_id() { let registry = registry_with_echo_capability(); diff --git a/crates/ironclaw_capabilities/tests/support/mod.rs b/crates/ironclaw_capabilities/tests/support/mod.rs index 95f38cedd7f..d8fabd81788 100644 --- a/crates/ironclaw_capabilities/tests/support/mod.rs +++ b/crates/ironclaw_capabilities/tests/support/mod.rs @@ -478,6 +478,16 @@ pub fn capability_id() -> CapabilityId { CapabilityId::new("echo.say").unwrap() } +/// A second, known-and-plannable echo-family capability. Used by the +/// capability-id-mismatch resume tests to trigger a mismatch with a capability +/// that EXISTS in the registry (and passes `plan_capability`), so the run-state +/// mismatch check fires — instead of an unknown id, which now short-circuits to +/// `UnknownCapability` in `resume_preflight` (existence-first, matching +/// host_runtime's deleted pre-authorization; see the resume-preflight change). +pub fn other_capability_id() -> CapabilityId { + CapabilityId::new("echo.other").unwrap() +} + pub fn github_comment_capability_id() -> CapabilityId { CapabilityId::new("github.comment_issue").unwrap() } @@ -506,6 +516,15 @@ default_permission = "allow" visibility = "host_internal" input_schema_ref = "schemas/echo/say.input.v1.json" output_schema_ref = "schemas/echo/say.output.v1.json" + +[[capabilities]] +id = "echo.other" +description = "A second echo capability, distinct from echo.say" +effects = ["dispatch_capability"] +default_permission = "allow" +visibility = "host_internal" +input_schema_ref = "schemas/echo/say.input.v1.json" +output_schema_ref = "schemas/echo/say.output.v1.json" "#; const GITHUB_COMMENT_MANIFEST: &str = r#" diff --git a/crates/ironclaw_host_runtime/src/production.rs b/crates/ironclaw_host_runtime/src/production.rs index 9d6ddb3da6c..f93ddb48033 100644 --- a/crates/ironclaw_host_runtime/src/production.rs +++ b/crates/ironclaw_host_runtime/src/production.rs @@ -7,11 +7,11 @@ //! run-state and approval stores, capability-lease store, and process //! manager. //! -//! This layer evaluates the package's manifest-derived trust input immediately -//! before invoking [`CapabilityHost`] so authorization consumes a host-owned -//! [`TrustDecision`](ironclaw_trust::TrustDecision) instead of caller-supplied -//! claims. The default fail-closed policy denies authority until composition -//! supplies a concrete host policy. +//! Trust classification and runtime-policy planning are computed inside the +//! capability kernel's `authorize()` fold ([`CapabilityHost`]); this layer +//! composes that kernel with the neutral services and maps its results back to +//! the [`HostRuntime`] contract. The default fail-closed trust policy denies +//! authority until composition supplies a concrete host policy. use std::{sync::Arc, time::Instant}; @@ -26,11 +26,11 @@ use ironclaw_capabilities::{ CapabilityInvocationRequest, CapabilityInvocationResult, CapabilityObligationHandler, CapabilityResumeRequest, CapabilitySpawnRequest, CapabilitySpawnResult, }; -use ironclaw_extensions::{ExtensionPackage, ExtensionRegistry, SharedExtensionRegistry}; +use ironclaw_extensions::{ExtensionRegistry, SharedExtensionRegistry}; use ironclaw_filesystem::RootFilesystem; use ironclaw_host_api::{ ApprovalRequestId, CapabilityDispatcher, CapabilityId, DenyReason, DispatchFailureKind, - InvocationId, PackageSource, Principal, ResourceScope, RuntimeCredentialAuthRequirement, + InvocationId, Principal, ResourceScope, RuntimeCredentialAuthRequirement, RuntimeDispatchErrorKind, RuntimeKind, SecretHandle, runtime_policy::EffectiveRuntimePolicy, sha256_digest_token, }; @@ -46,7 +46,7 @@ use ironclaw_run_state::{ ApprovalRequestStore, RunStateApprovalStore, RunStateError, RunStateStore, RunStatus, }; use ironclaw_secrets::SecretStore; -use ironclaw_trust::{HostTrustPolicy, TrustDecision, TrustError, TrustPolicy, TrustProvenance}; +use ironclaw_trust::{HostTrustPolicy, TrustPolicy}; use ironclaw_turns::run_profile::LoopSafeSummary; fn trace_capability_latency_ok( @@ -105,7 +105,6 @@ use crate::{ RuntimeStatusRequest, RuntimeWorkId, RuntimeWorkSummary, VisibleCapabilityRequest, VisibleCapabilitySurface, obligations::secret_owner_scope, surface::CapabilityCatalog, }; -use ironclaw_runtime_policy::{PlannerError, plan_capability}; /// Default production wiring for [`HostRuntime`]. pub struct DefaultHostRuntime { @@ -405,71 +404,6 @@ impl DefaultHostRuntime { capability_id, }) } - - /// §5.3.2 authority-as-a-fold — the runtime-policy + trust gates that open - /// the pre-authorize derivation, shared by all four dispatch entry points - /// (`invoke`/`spawn`/`resume`/`auth_resume`). Runs `enforce_runtime_policy` - /// then `evaluate_invocation_trust`, setting `context.trust` on success. On - /// rejection it logs the gate + error kind and returns the ready-to-surface - /// [`RuntimeCapabilityOutcome`] tagged by gate, plus the `error_kind` string: - /// the caller emits its own per-entry-point latency trace and, for - /// resume/auth-resume, drives its blocked-resume failure side effect - /// (`fail_matching_blocked_{,auth_}resume_on_preflight_error`) before - /// returning. Behavior-preserving apart from consolidating the paths' - /// `debug!` message text; de-dups the four inline copies and names the seam - /// a later slice lifts into the kernel `authorize()`. - fn open_pre_authorization( - &self, - context: &mut ironclaw_host_api::ExecutionContext, - capability_id: &CapabilityId, - ) -> Result { - if let Err(error) = self.enforce_runtime_policy(capability_id) { - let error_kind = error.kind(); - tracing::debug!( - capability_id = %capability_id, - runtime_policy_error_kind = error_kind, - "capability runtime policy rejected invocation before authorization" - ); - return Err(PreAuthorizationRejected::RuntimePolicy { - outcome: Box::new(runtime_policy_failure(capability_id.clone(), error)), - error_kind, - }); - } - let trust_decision = match self.evaluate_invocation_trust(capability_id) { - Ok(host_decision) => host_decision, - Err(error) => { - let error_kind = error.kind(); - tracing::debug!( - capability_id = %capability_id, - trust_error_kind = error_kind, - "capability trust evaluation failed before authorization" - ); - return Err(PreAuthorizationRejected::Trust { - outcome: Box::new(trust_evaluation_failure(capability_id.clone(), error)), - error_kind, - }); - } - }; - context.trust = trust_decision.effective_trust.class(); - Ok(trust_decision) - } -} - -/// Which pre-authorize gate rejected in -/// [`DefaultHostRuntime::open_pre_authorization`], carrying the ready-to-surface -/// `outcome` (so the caller picks its own per-entry-point latency label before -/// returning it) and the `error_kind` string (so the resume/auth-resume callers -/// can drive their blocked-resume failure side effect, which the invoke/spawn -/// callers ignore). -enum PreAuthorizationRejected { - RuntimePolicy { - outcome: Box, - error_kind: &'static str, - }, - Trust { - outcome: Box, - error_kind: &'static str, - }, } #[async_trait] @@ -479,7 +413,7 @@ impl HostRuntime for DefaultHostRuntime { request: RuntimeCapabilityRequest, ) -> Result { let RuntimeCapabilityRequest { - mut context, + context, capability_id, estimate, input, @@ -500,40 +434,15 @@ impl HostRuntime for DefaultHostRuntime { ); } - // Still runs `open_pre_authorization` for its context.trust stamp and - // fail-closed planning gate (redundant with the kernel's in-fold trust + - // planning now; cleaned up in a later slice). The returned - // `TrustDecision` is no longer consumed here — persistent-approval moved - // into the kernel's `authorize()` fold, which recomputes trust — so only - // the rejection is handled. - match self.open_pre_authorization(&mut context, &capability_id) { - Ok(_trust_decision) => {} - Err(PreAuthorizationRejected::RuntimePolicy { outcome, .. }) => { - trace_capability_latency_ok( - "invoke_capability_policy_rejected", - &capability_id, - &scope, - total_started_at, - ); - return Ok(*outcome); - } - Err(PreAuthorizationRejected::Trust { outcome, .. }) => { - trace_capability_latency_ok( - "invoke_capability_trust_rejected", - &capability_id, - &scope, - total_started_at, - ); - return Ok(*outcome); - } - } - let registry = self.registry.snapshot(); - // Validate the execution context before the credential pre-flight queries - // the secret store. Without this guard a malformed RuntimeCapabilityRequest - // could probe secret-store presence under a forged resource_scope that does - // not match the top-level tenant/user/agent/project fields. + // Validate the execution context before the kernel's credential pre-flight + // queries the secret store. Without this guard a malformed + // RuntimeCapabilityRequest could probe secret-store presence under a forged + // resource_scope that does not match the top-level + // tenant/user/agent/project fields. Trust classification and runtime-policy + // planning now run inside the kernel's `authorize()` fold — no host_runtime + // pre-authorization stamps `context.trust` before it. if let Err(error) = context.validate() { return Err(HostRuntimeError::invalid_request(error.to_string())); } @@ -621,7 +530,7 @@ impl HostRuntime for DefaultHostRuntime { request: RuntimeCapabilityRequest, ) -> Result { let RuntimeCapabilityRequest { - mut context, + context, capability_id, estimate, input, @@ -648,24 +557,15 @@ impl HostRuntime for DefaultHostRuntime { ); } - // Still runs `open_pre_authorization` for its context.trust stamp and - // fail-closed planning gate; the returned `TrustDecision` is no longer - // consumed here (persistent-approval moved into the kernel's spawn - // authorize fold, which recomputes trust). - if let Err( - PreAuthorizationRejected::RuntimePolicy { outcome, .. } - | PreAuthorizationRejected::Trust { outcome, .. }, - ) = self.open_pre_authorization(&mut context, &capability_id) - { - return Ok(*outcome); - } - let registry = self.registry.snapshot(); - // Validate the execution context before the credential pre-flight queries - // the secret store. Without this guard a malformed RuntimeCapabilityRequest - // could probe secret-store presence under a forged resource_scope that does - // not match the top-level tenant/user/agent/project fields. + // Validate the execution context before the kernel's credential pre-flight + // queries the secret store. Without this guard a malformed + // RuntimeCapabilityRequest could probe secret-store presence under a forged + // resource_scope that does not match the top-level + // tenant/user/agent/project fields. Trust classification and runtime-policy + // planning now run inside the kernel's spawn authorize fold — no + // host_runtime pre-authorization stamps `context.trust` before it. if let Err(error) = context.validate() { return Err(HostRuntimeError::invalid_request(error.to_string())); } @@ -706,7 +606,7 @@ impl HostRuntime for DefaultHostRuntime { request: RuntimeCapabilityResumeRequest, ) -> Result { let RuntimeCapabilityResumeRequest { - mut context, + context, approval_request_id, capability_id, estimate, @@ -729,32 +629,9 @@ impl HostRuntime for DefaultHostRuntime { ); } - // Still runs `open_pre_authorization` for its context.trust stamp and - // fail-closed preflight (redundant with the kernel's in-fold trust + - // planning now; cleaned up in a later slice). The returned - // `TrustDecision` is no longer carried on the request — the kernel - // recomputes it — so only the rejection is consumed here. - if let Err( - PreAuthorizationRejected::RuntimePolicy { - outcome, - error_kind, - } - | PreAuthorizationRejected::Trust { - outcome, - error_kind, - }, - ) = self.open_pre_authorization(&mut context, &capability_id) - { - self.fail_matching_blocked_resume_on_preflight_error( - &context, - &capability_id, - approval_request_id, - error_kind, - ) - .await; - return Ok(*outcome); - } - + // Trust classification runs inside the kernel's `authorize_resumed` fold, + // which fails the blocked run on a trust rejection (replacing the former + // host_runtime pre-authorization + `context.trust` stamp). let registry = self.registry.snapshot(); let host = self.capability_host(®istry); let resume = CapabilityResumeRequest { @@ -803,7 +680,7 @@ impl HostRuntime for DefaultHostRuntime { request: RuntimeCapabilityAuthResumeRequest, ) -> Result { let RuntimeCapabilityAuthResumeRequest { - mut context, + context, capability_id, estimate, input, @@ -826,34 +703,14 @@ impl HostRuntime for DefaultHostRuntime { ); } - // Still runs `open_pre_authorization` for its context.trust stamp and - // fail-closed planning gate; the returned `TrustDecision` is no longer - // consumed here. The persistent-approval re-application on auth-resume now - // lives in the kernel's `authorize_resumed` fold (§5.2.7/§5.3.2): a - // capability authorized only by a persistent grant (e.g. - // `extension_activate` under admin-config FirstParty trust) is re-authorized - // by the kernel injecting the candidate grant after the credential gate, - // instead of host_runtime mutating the context before re-dispatch. - if let Err( - PreAuthorizationRejected::RuntimePolicy { - outcome, - error_kind, - } - | PreAuthorizationRejected::Trust { - outcome, - error_kind, - }, - ) = self.open_pre_authorization(&mut context, &capability_id) - { - self.fail_matching_blocked_auth_resume_on_preflight_error( - &context, - &capability_id, - error_kind, - ) - .await; - return Ok(*outcome); - } - + // Trust classification and the persistent-approval re-application on + // auth-resume now live in the kernel's `authorize_resumed` fold + // (§5.2.7/§5.3.2): a capability authorized only by a persistent grant + // (e.g. `extension_activate` under admin-config FirstParty trust) is + // re-authorized by the kernel injecting the candidate grant after the + // credential gate, and a trust rejection fails the blocked run there — + // replacing the former host_runtime pre-authorization + `context.trust` + // stamp. let registry = self.registry.snapshot(); let host = self.capability_host(®istry); let auth_resume = CapabilityAuthResumeRequest { @@ -899,7 +756,7 @@ impl HostRuntime for DefaultHostRuntime { request: RuntimeCapabilityResumeRequest, ) -> Result { let RuntimeCapabilityResumeRequest { - mut context, + context, approval_request_id, capability_id, estimate, @@ -932,42 +789,10 @@ impl HostRuntime for DefaultHostRuntime { ); } - if let Err(error) = self.enforce_runtime_policy(&capability_id) { - tracing::debug!( - capability_id = %capability_id, - runtime_policy_error_kind = error.kind(), - "capability runtime policy rejected spawn resume before process start" - ); - self.fail_matching_blocked_resume_on_preflight_error( - &context, - &capability_id, - approval_request_id, - error.kind(), - ) - .await; - return Ok(runtime_policy_failure(capability_id, error)); - } - - let trust_decision = match self.evaluate_invocation_trust(&capability_id) { - Ok(host_decision) => host_decision, - Err(error) => { - tracing::debug!( - capability_id = %capability_id, - trust_error_kind = error.kind(), - "capability trust evaluation failed before spawn resume" - ); - self.fail_matching_blocked_resume_on_preflight_error( - &context, - &capability_id, - approval_request_id, - error.kind(), - ) - .await; - return Ok(trust_evaluation_failure(capability_id, error)); - } - }; - context.trust = trust_decision.effective_trust.class(); - + // Runtime-policy planning and trust classification run inside the kernel's + // `resume_spawn_json` fold, which fails the blocked run on rejection — + // replacing the former host_runtime pre-authorization + `context.trust` + // stamp. let registry = self.registry.snapshot(); let host = self.capability_host(®istry); let resume = CapabilityResumeRequest { @@ -1227,65 +1052,6 @@ impl DefaultHostRuntime { host } - fn evaluate_invocation_trust( - &self, - capability_id: &CapabilityId, - ) -> Result { - let policy = self.trust_policy.as_ref(); - - let registry = self.registry.snapshot(); - let descriptor = registry - .get_capability(capability_id) - .ok_or(TrustEvaluationError::UnknownCapability)?; - let package = registry - .get_extension(&descriptor.provider) - .ok_or(TrustEvaluationError::MissingPackage)?; - let package_descriptor = package - .capabilities - .iter() - .find(|candidate| candidate.id == *capability_id) - .ok_or(TrustEvaluationError::StalePackageDescriptor)?; - if package_descriptor != descriptor { - return Err(TrustEvaluationError::ConflictingPackageDescriptor); - } - - let input = trust_policy_input_for_local_manifest(package)?; - let decision = match policy.evaluate(&input) { - Ok(decision) => decision, - Err(error) => { - tracing::debug!( - capability_id = %capability_id, - trust_policy_error_kind = trust_error_label(&error), - "host trust policy evaluation returned an error" - ); - return Err(TrustEvaluationError::Policy); - } - }; - trace_trust_decision(capability_id, &decision); - Ok(decision) - } - - fn enforce_runtime_policy( - &self, - capability_id: &CapabilityId, - ) -> Result<(), RuntimePolicyEvaluationError> { - let registry = self.registry.snapshot(); - let descriptor = registry - .get_capability(capability_id) - .ok_or(RuntimePolicyEvaluationError::UnknownCapability)?; - let plan = plan_capability(descriptor, &self.runtime_policy) - .map_err(RuntimePolicyEvaluationError::Denied)?; - tracing::debug!( - capability_id = %capability_id, - filesystem_backend = ?plan.filesystem_backend, - process_backend = ?plan.process_backend, - network_mode = ?plan.network_mode, - secret_mode = ?plan.secret_mode, - "capability runtime policy planned invocation" - ); - Ok(()) - } - /// Rejects a resume whose sealed ingress actor differs from the actor that /// started the run. Callers invoke this before any preflight that can fail /// or mutate the blocked run; `CapabilityHost` repeats the check before @@ -1322,117 +1088,6 @@ impl DefaultHostRuntime { )))) } - async fn fail_matching_blocked_resume_on_preflight_error( - &self, - context: &ironclaw_host_api::ExecutionContext, - capability_id: &CapabilityId, - approval_request_id: ApprovalRequestId, - error_kind: &'static str, - ) { - if context.validate().is_err() { - return; - } - let Some(run_state) = self.run_state.as_ref() else { - return; - }; - let scope = &context.resource_scope; - let invocation_id = context.invocation_id; - let record = match run_state.get(scope, invocation_id).await { - Ok(Some(record)) => record, - Ok(None) => return, - Err(error) => { - tracing::warn!( - invocation_id = %invocation_id, - capability_id = %capability_id, - preflight_error_kind = error_kind, - transition_error = %unavailable_from_run_state(error), - "blocked resume preflight failed, but run-state lookup failed; leaving run state unchanged", - ); - return; - } - }; - if record.status != RunStatus::BlockedApproval - || &record.capability_id != capability_id - || record.approval_request_id != Some(approval_request_id) - || record.authenticated_actor_user_id != context.authenticated_actor_user_id - { - return; - } - if let Err(error) = run_state - .fail(scope, invocation_id, error_kind.to_string()) - .await - { - tracing::warn!( - invocation_id = %invocation_id, - capability_id = %capability_id, - approval_request_id = %approval_request_id, - preflight_error_kind = error_kind, - transition_error = %unavailable_from_run_state(error), - "blocked resume preflight failed, but run-state fail transition failed; original failure is returned to caller", - ); - } - } - - /// Mirrors `fail_matching_blocked_resume_on_preflight_error` for - /// `auth_resume_capability` preflight rejections. Checks for a - /// `BlockedAuth` run record matching the capability; if found, - /// transitions it to `Failed` so it is not left as a stale resumable - /// gate after the caller has returned a terminal failure outcome. - /// - /// The `approval_request_id` carried by the auth-resume request is - /// intentionally NOT compared here: the `BlockedAuth` transition always - /// clears `approval_request_id` to `None` on the persisted record, so - /// any equality check against `Some(id)` would always fail and silently - /// skip the fail-transition. `invocation_id` (embedded in `context`) - /// already uniquely identifies the run. - async fn fail_matching_blocked_auth_resume_on_preflight_error( - &self, - context: &ironclaw_host_api::ExecutionContext, - capability_id: &CapabilityId, - error_kind: &'static str, - ) { - if context.validate().is_err() { - return; - } - let Some(run_state) = self.run_state.as_ref() else { - return; - }; - let scope = &context.resource_scope; - let invocation_id = context.invocation_id; - let record = match run_state.get(scope, invocation_id).await { - Ok(Some(record)) => record, - Ok(None) => return, - Err(error) => { - tracing::warn!( - invocation_id = %invocation_id, - capability_id = %capability_id, - preflight_error_kind = error_kind, - transition_error = %unavailable_from_run_state(error), - "blocked auth-resume preflight failed, but run-state lookup failed; leaving run state unchanged", - ); - return; - } - }; - if record.status != RunStatus::BlockedAuth - || &record.capability_id != capability_id - || record.authenticated_actor_user_id != context.authenticated_actor_user_id - { - return; - } - if let Err(error) = run_state - .fail(scope, invocation_id, error_kind.to_string()) - .await - { - tracing::warn!( - invocation_id = %invocation_id, - capability_id = %capability_id, - preflight_error_kind = error_kind, - transition_error = %unavailable_from_run_state(error), - "blocked auth-resume preflight failed, but run-state fail transition failed; original failure is returned to caller", - ); - } - } - async fn translate_invocation_error( &self, error: CapabilityInvocationError, @@ -1666,159 +1321,6 @@ impl ironclaw_capabilities::HostPolicyFacts for DefaultHostRuntime { } } -#[derive(Debug, Clone, Copy)] -enum TrustEvaluationError { - UnknownCapability, - MissingPackage, - StalePackageDescriptor, - ConflictingPackageDescriptor, - TrustInput, - Policy, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum RuntimePolicyEvaluationError { - UnknownCapability, - Denied(PlannerError), -} - -impl RuntimePolicyEvaluationError { - fn kind(&self) -> &'static str { - match self { - Self::UnknownCapability => "unknown_capability", - Self::Denied(PlannerError::ProcessEffectsRequiredButProcessBackendIsNone { - .. - }) => "process_backend_none", - Self::Denied(PlannerError::NetworkRequiredButNetworkModeIsDeny { .. }) => { - "network_denied" - } - Self::Denied(PlannerError::SecretAccessRequiredButSecretModeIsDeny { .. }) => { - "secret_denied" - } - } - } - - fn message(&self) -> String { - match self { - Self::UnknownCapability => "unknown capability".to_string(), - Self::Denied(error) => format!("runtime policy denied capability: {error}"), - } - } -} - -impl TrustEvaluationError { - const fn kind(self) -> &'static str { - match self { - Self::UnknownCapability => "unknown_capability", - Self::MissingPackage => "missing_package", - Self::StalePackageDescriptor => "stale_package_descriptor", - Self::ConflictingPackageDescriptor => "conflicting_package_descriptor", - Self::TrustInput => "trust_input", - Self::Policy => "policy", - } - } - - const fn message(self) -> &'static str { - match self { - Self::UnknownCapability => "unknown capability", - Self::MissingPackage => "capability provider trust metadata is missing", - Self::StalePackageDescriptor | Self::ConflictingPackageDescriptor => { - "capability provider trust metadata is stale" - } - Self::TrustInput => "capability provider trust metadata is invalid", - Self::Policy => "capability provider trust policy evaluation failed", - } - } -} - -fn trust_policy_input_for_local_manifest( - package: &ExtensionPackage, -) -> Result { - package - .trust_policy_input( - local_manifest_source(package), - package.manifest_digest(), - None, - ) - .map_err(|_| TrustEvaluationError::TrustInput) -} - -fn local_manifest_source(package: &ExtensionPackage) -> PackageSource { - PackageSource::LocalManifest { - path: format!( - "{}/manifest.toml", - package.root.as_str().trim_end_matches('/') - ), - } -} - -fn trace_trust_decision(capability_id: &CapabilityId, decision: &TrustDecision) { - tracing::debug!( - capability_id = %capability_id, - effective_trust = ?decision.effective_trust.class(), - trust_provenance = trust_provenance_label(&decision.provenance), - trust_allowed_effect_count = decision.authority_ceiling.allowed_effects.len(), - trust_has_resource_ceiling = decision.authority_ceiling.max_resource_ceiling.is_some(), - "evaluated capability provider trust from host policy" - ); -} - -fn trust_provenance_label(provenance: &TrustProvenance) -> &'static str { - match provenance { - TrustProvenance::Default => "default", - TrustProvenance::Bundled => "bundled", - TrustProvenance::AdminConfig => "admin_config", - TrustProvenance::SignedRegistry { .. } => "signed_registry", - TrustProvenance::LocalManifest => "local_manifest", - } -} - -fn trust_error_label(error: &TrustError) -> &'static str { - match error { - TrustError::InvariantViolation { .. } => "invariant_violation", - } -} - -fn trust_evaluation_failure( - capability_id: CapabilityId, - error: TrustEvaluationError, -) -> RuntimeCapabilityOutcome { - RuntimeCapabilityOutcome::Failed(RuntimeCapabilityFailure::new( - capability_id, - trust_evaluation_failure_kind(error), - Some(error.message().to_string()), - )) -} - -fn runtime_policy_failure( - capability_id: CapabilityId, - error: RuntimePolicyEvaluationError, -) -> RuntimeCapabilityOutcome { - RuntimeCapabilityOutcome::Failed(RuntimeCapabilityFailure::new( - capability_id, - runtime_policy_failure_kind(&error), - Some(error.message()), - )) -} - -fn runtime_policy_failure_kind(error: &RuntimePolicyEvaluationError) -> RuntimeFailureKind { - match error { - RuntimePolicyEvaluationError::UnknownCapability => RuntimeFailureKind::MissingRuntime, - RuntimePolicyEvaluationError::Denied(_) => RuntimeFailureKind::Authorization, - } -} - -fn trust_evaluation_failure_kind(error: TrustEvaluationError) -> RuntimeFailureKind { - match error { - TrustEvaluationError::UnknownCapability => RuntimeFailureKind::MissingRuntime, - TrustEvaluationError::MissingPackage - | TrustEvaluationError::StalePackageDescriptor - | TrustEvaluationError::ConflictingPackageDescriptor - | TrustEvaluationError::TrustInput - | TrustEvaluationError::Policy => RuntimeFailureKind::Authorization, - } -} - /// Maps a [`RunStateError`] to a sanitized [`HostRuntimeError::Unavailable`]. /// /// `RunStateError::InvalidPath` and `Filesystem` carry raw filesystem @@ -2417,9 +1919,9 @@ mod tests { }; use ironclaw_filesystem::{FilesystemError, FilesystemOperation}; use ironclaw_host_api::{ - CapabilityId, DispatchFailureKind, ExtensionId, HostPortCatalog, PackageSource, + CapabilityId, DispatchFailureKind, ExtensionId, HostPortCatalog, RuntimeCredentialAccountProviderId, RuntimeCredentialAuthRequirement, - RuntimeDispatchErrorKind, SecretHandle, VirtualPath, sha256_digest_token, + RuntimeDispatchErrorKind, SecretHandle, VirtualPath, }; fn cap() -> CapabilityId { @@ -2445,58 +1947,6 @@ mod tests { } } - #[test] - fn local_manifest_trust_input_includes_manifest_digest() { - const MANIFEST: &str = r#" -schema_version = "reborn.extension_manifest.v2" -id = "test" -name = "Test" -version = "0.1.0" -description = "test extension" -trust = "third_party" - -[runtime] -kind = "script" -runner = "sandboxed_process" -command = "echo" - -[[capabilities]] -id = "test.cap" -description = "Test capability" -effects = ["network"] -default_permission = "ask" -visibility = "model" -input_schema_ref = "schemas/test.input.json" -output_schema_ref = "schemas/test.output.json" -"#; - let manifest = ExtensionManifest::parse( - MANIFEST, - ManifestSource::HostBundled, - &HostPortCatalog::empty(), - ) - .unwrap(); - let package = ExtensionPackage::from_manifest_toml( - manifest, - VirtualPath::new("/system/extensions/test").unwrap(), - MANIFEST, - ) - .unwrap(); - - let input = trust_policy_input_for_local_manifest(&package).unwrap(); - - assert_eq!( - input.identity.source, - PackageSource::LocalManifest { - path: "/system/extensions/test/manifest.toml".to_string() - } - ); - let expected_digest = sha256_digest_token(MANIFEST.as_bytes()); - assert_eq!( - input.identity.digest.as_deref(), - Some(expected_digest.as_str()) - ); - } - #[test] fn auth_required_outcome_uses_stable_gate_for_identical_requirements() { let capability_id = cap(); diff --git a/crates/ironclaw_host_runtime/tests/first_party_builtin_tools.rs b/crates/ironclaw_host_runtime/tests/first_party_builtin_tools.rs index 3c08b2da451..a7a5c31832d 100644 --- a/crates/ironclaw_host_runtime/tests/first_party_builtin_tools.rs +++ b/crates/ironclaw_host_runtime/tests/first_party_builtin_tools.rs @@ -6082,12 +6082,14 @@ async fn builtin_http_runtime_policy_denial_stops_before_egress() { }; assert_eq!(failure.kind, RuntimeFailureKind::Authorization); assert_eq!(failure.capability_id, capability_id(HTTP_CAPABILITY_ID)); + // Message is now the sanitized `DenyReason::PolicyDenied` form the kernel + // produces (see #6386): it conveys a policy denial and must not leak the + // internal `NetworkMode::` planner enum token. + let message = failure.message.as_deref().unwrap_or_default(); + assert!(message.contains("denied"), "unexpected message: {message}"); assert!( - failure - .message - .as_deref() - .unwrap_or_default() - .contains("NetworkMode::Deny") + !message.contains("NetworkMode::"), + "message leaked internal planner enum token: {message}" ); assert!(egress.requests().is_empty()); } diff --git a/crates/ironclaw_host_runtime/tests/tool_surface_contract.rs b/crates/ironclaw_host_runtime/tests/tool_surface_contract.rs index 680f7f19eb4..1b726109bbf 100644 --- a/crates/ironclaw_host_runtime/tests/tool_surface_contract.rs +++ b/crates/ironclaw_host_runtime/tests/tool_surface_contract.rs @@ -1574,12 +1574,14 @@ async fn runtime_policy_denied_extension_invoke_does_not_dispatch() { }; assert_eq!(failure.kind, RuntimeFailureKind::Authorization); assert_eq!(failure.capability_id, capability_id("echo.say")); + // Message is the sanitized `DenyReason::PolicyDenied` form the kernel produces + // (see #6386): it conveys a policy denial and must not leak the internal + // `NetworkMode::` planner enum token. + let message = failure.message.as_deref().unwrap_or_default(); + assert!(message.contains("denied"), "unexpected message: {message}"); assert!( - failure - .message - .as_deref() - .unwrap_or_default() - .contains("NetworkMode::Deny") + !message.contains("NetworkMode::"), + "message leaked internal planner enum token: {message}" ); assert!( !dispatcher.has_request(), @@ -1618,12 +1620,13 @@ async fn runtime_policy_denied_secret_invoke_does_not_dispatch() { }; assert_eq!(failure.kind, RuntimeFailureKind::Authorization); assert_eq!(failure.capability_id, capability_id("secret-tool.read")); + // Sanitized `DenyReason::PolicyDenied` message (see #6386): conveys a policy + // denial without leaking the internal `SecretMode::` planner enum token. + let message = failure.message.as_deref().unwrap_or_default(); + assert!(message.contains("denied"), "unexpected message: {message}"); assert!( - failure - .message - .as_deref() - .unwrap_or_default() - .contains("SecretMode::Deny") + !message.contains("SecretMode::"), + "message leaked internal planner enum token: {message}" ); assert!( !dispatcher.has_request(), @@ -1665,12 +1668,13 @@ async fn runtime_policy_denied_mcp_http_invoke_does_not_dispatch_when_effect_und }; assert_eq!(failure.kind, RuntimeFailureKind::Authorization); assert_eq!(failure.capability_id, capability_id("mcp.search")); + // Sanitized `DenyReason::PolicyDenied` message (see #6386): conveys a policy + // denial without leaking the internal `NetworkMode::` planner enum token. + let message = failure.message.as_deref().unwrap_or_default(); + assert!(message.contains("denied"), "unexpected message: {message}"); assert!( - failure - .message - .as_deref() - .unwrap_or_default() - .contains("NetworkMode::Deny") + !message.contains("NetworkMode::"), + "message leaked internal planner enum token: {message}" ); assert!( !dispatcher.has_request(),