From eb38f69daf587af0b46ac9611052765adb10af6d Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Fri, 7 Aug 2026 11:37:25 +0300 Subject: [PATCH] fix(host-runtime): wire WASM secret-exists to staged credentials (#7307) Third-party WASM guests (ironhub tools such as attio) gate on the secret-exists host import before issuing any request, but production wired the sandbox with the deny-all default, so the probe always returned false: attio aborted pre-network with "API key not configured" and the host classified the plain-string guest error as operation_failed, never auth_required. Introduce StagedWasmHostSecrets, a per-invocation WasmHostSecrets implementation over the staged secret injection store: exists(name) is true exactly when authorization leased and staged non-empty credential material for (scope, capability_id, handle), read non-destructively so the HTTP egress still receives the material. Wire it into WasmRuntimeAdapter::host_for_scope on every host variant and plumb the shared store through the builder. Credential staging now rejects empty resolved material as AuthRequired (obligation handler and host-driven staging), so a configured-but-blank key surfaces the typed re-auth signal instead of an opaque guest failure. No prose heuristics: the structured {"kind":"auth_required"} guest contract remains the fallback. Adds unit tests for the probe semantics and WASM contract tests with a secret-exists probe component (staged -> true, absent -> false, empty material -> AuthRequired staging error). --- .../src/obligations/handler.rs | 14 ++ .../ironclaw_host_runtime/src/services.rs | 13 ++ .../src/services/builder.rs | 1 + .../src/services/runtime_adapters.rs | 34 +++- .../src/services/wasm_secrets.rs | 190 ++++++++++++++++++ .../tests/host_runtime_services_contract.rs | 186 ++++++++++++++++- .../tests/support/host_runtime_harness.rs | 100 +++++++++ docs/reborn/contracts/wasm.md | 2 + 8 files changed, 533 insertions(+), 7 deletions(-) create mode 100644 crates/kernel/ironclaw_host_runtime/src/services/wasm_secrets.rs diff --git a/crates/kernel/ironclaw_host_runtime/src/obligations/handler.rs b/crates/kernel/ironclaw_host_runtime/src/obligations/handler.rs index ab7bf999dab..2871d0f53eb 100644 --- a/crates/kernel/ironclaw_host_runtime/src/obligations/handler.rs +++ b/crates/kernel/ironclaw_host_runtime/src/obligations/handler.rs @@ -35,6 +35,7 @@ use ironclaw_host_api::{ use ironclaw_resources::ResourceGovernor; use ironclaw_safety::LeakDetector; use ironclaw_secrets::{SecretStoreError, SecretStorePort}; +use secrecy::ExposeSecret; use super::staged_handoffs::{ NetworkObligationPolicyStore, RuntimeCredentialAccountRequest, @@ -849,6 +850,19 @@ async fn stage_credential_material( tracing::debug!(err = %e, "stage_credential_material: consume failed"); crate::services::stage_secret_error(e) })?; + // A "Configured" account whose resolved material is empty cannot + // authenticate anything; staging it would only let the guest fail later + // with an opaque `operation_failed` (e.g. an ironhub tool probing + // `secret-exists` sees an unusable slot and reports "API key not + // configured" as a generic domain failure). Surface the typed re-auth + // signal at authorization time instead so the model can act on it. + if secret.expose_secret().is_empty() { + tracing::debug!( + handle = %target.as_str(), + "stage_credential_material: resolved credential material is empty; requiring re-auth" + ); + return Err(CredentialStageError::AuthRequired); + } secret_injections .insert(target_scope, capability_id, target, secret) .map_err(|e| { diff --git a/crates/kernel/ironclaw_host_runtime/src/services.rs b/crates/kernel/ironclaw_host_runtime/src/services.rs index 2a6f54a0939..b38f214cc02 100644 --- a/crates/kernel/ironclaw_host_runtime/src/services.rs +++ b/crates/kernel/ironclaw_host_runtime/src/services.rs @@ -66,6 +66,7 @@ use ironclaw_wasm::{ WasmStagedRuntimeCredentials, WitToolExecution, WitToolHost, WitToolRequest, WitToolRuntime, WitToolRuntimeConfig, }; +use secrecy::ExposeSecret; use crate::obligations::{ NetworkObligationPolicyStore, RuntimeCredentialAccountResolver, RuntimeSecretInjectionStore, @@ -94,6 +95,7 @@ mod tool_resolver; mod wasm_blocking; mod wasm_diagnostics; mod wasm_execution; +mod wasm_secrets; use production_wiring::{ ProductionComponentType, ProductionComponentTypes, ProductionImplementationReadiness, @@ -369,6 +371,17 @@ impl ProductAuthProviderRuntimePorts { .consume(source_scope, lease.id) .await .map_err(stage_secret_error)?; + // A "Configured" account whose resolved material is empty cannot + // authenticate anything. Stage the typed re-auth signal instead of a + // slot the guest would fail opaquely on (#7307) — mirrors the + // obligation-handler `stage_credential_material` boundary. + if secret.expose_secret().is_empty() { + tracing::debug!( + secret_handle = %source_handle.as_str(), + "stage_material_once: resolved credential material is empty; requiring re-auth" + ); + return Err(ProductAuthCredentialStageError::AuthRequired); + } self.secret_injection_store .insert(target_scope, capability_id, target_handle, secret) .map_err(|_| ProductAuthCredentialStageError::Backend) diff --git a/crates/kernel/ironclaw_host_runtime/src/services/builder.rs b/crates/kernel/ironclaw_host_runtime/src/services/builder.rs index bd87f1afebb..b917cee75c3 100644 --- a/crates/kernel/ironclaw_host_runtime/src/services/builder.rs +++ b/crates/kernel/ironclaw_host_runtime/src/services/builder.rs @@ -877,6 +877,7 @@ where Arc::clone(&self.network_policy_store), Arc::clone(&self.runtime_http_egress), self.wasm_credential_provider.clone(), + Arc::clone(&self.secret_injection_store), )?); Ok(self.with_wasm_runtime(adapter)) } diff --git a/crates/kernel/ironclaw_host_runtime/src/services/runtime_adapters.rs b/crates/kernel/ironclaw_host_runtime/src/services/runtime_adapters.rs index 09a41e64cd4..3198beb2b2a 100644 --- a/crates/kernel/ironclaw_host_runtime/src/services/runtime_adapters.rs +++ b/crates/kernel/ironclaw_host_runtime/src/services/runtime_adapters.rs @@ -33,12 +33,14 @@ use super::{ WasmRuntimeCredentialProvider, WasmRuntimeHttpAdapter, WasmRuntimePolicyDiscarder, WitToolHost, WitToolRuntime, WitToolRuntimeConfig, plan_capability, runtime_http_egress, }; +use crate::obligations::RuntimeSecretInjectionStore; use crate::{ FirstPartyCapabilityError, latency::{ RuntimeLatencyFields, RuntimeLatencyMetrics, started_at as latency_started_at, trace_runtime_error, trace_runtime_ok, }, + services::wasm_secrets::StagedWasmHostSecrets, }; /// Per-invocation execution request handed to a runtime lane. @@ -902,6 +904,7 @@ pub(super) struct WasmRuntimeAdapter { network_policy_store: Arc, runtime_http_egress: SharedRuntimeHttpEgress, credential_provider: Option>, + secret_injections: Arc, prepared: Mutex>>, } @@ -912,6 +915,7 @@ impl WasmRuntimeAdapter { network_policy_store: Arc, runtime_http_egress: SharedRuntimeHttpEgress, credential_provider: Option>, + secret_injections: Arc, ) -> Self { Self { runtime, @@ -919,6 +923,7 @@ impl WasmRuntimeAdapter { network_policy_store, runtime_http_egress, credential_provider, + secret_injections, prepared: Mutex::new(HashMap::new()), } } @@ -929,6 +934,7 @@ impl WasmRuntimeAdapter { network_policy_store: Arc, runtime_http_egress: SharedRuntimeHttpEgress, credential_provider: Option>, + secret_injections: Arc, ) -> Result { Ok(Self::new( WitToolRuntime::new(config)?, @@ -936,6 +942,7 @@ impl WasmRuntimeAdapter { network_policy_store, runtime_http_egress, credential_provider, + secret_injections, )) } @@ -949,16 +956,32 @@ impl WasmRuntimeAdapter { } fn host_for_scope(&self, scope: &ResourceScope, capability_id: &CapabilityId) -> WitToolHost { + // Per-invocation `secret-exists` backing: every host variant below + // (denied HTTP or policy-routed) must answer the credential probe from + // the staged injection store, or third-party guests that gate on it + // abort with an opaque failure before issuing any request. + let secrets = StagedWasmHostSecrets::new( + Arc::clone(&self.secret_injections), + scope.clone(), + capability_id.clone(), + ); let egress = runtime_http_egress(&self.runtime_http_egress); let Some(policy) = self.network_policy_store.get(scope, capability_id) else { return if egress.is_some() { - self.host.clone().with_http(Arc::new(DenyWasmHostHttp)) + self.host + .clone() + .with_http(Arc::new(DenyWasmHostHttp)) + .with_secrets(Arc::new(secrets)) } else { - self.host.clone() + self.host.clone().with_secrets(Arc::new(secrets)) }; }; let Some(egress) = egress else { - return self.host.clone().with_http(Arc::new(DenyWasmHostHttp)); + return self + .host + .clone() + .with_http(Arc::new(DenyWasmHostHttp)) + .with_secrets(Arc::new(secrets)); }; let mut adapter = WasmRuntimeHttpAdapter::new(egress, scope.clone(), capability_id.clone(), policy) @@ -968,7 +991,10 @@ impl WasmRuntimeAdapter { if let Some(provider) = &self.credential_provider { adapter = adapter.with_credential_provider(Arc::clone(provider)); } - self.host.clone().with_http(Arc::new(adapter)) + self.host + .clone() + .with_http(Arc::new(adapter)) + .with_secrets(Arc::new(secrets)) } } diff --git a/crates/kernel/ironclaw_host_runtime/src/services/wasm_secrets.rs b/crates/kernel/ironclaw_host_runtime/src/services/wasm_secrets.rs new file mode 100644 index 00000000000..8cb01cdc205 --- /dev/null +++ b/crates/kernel/ironclaw_host_runtime/src/services/wasm_secrets.rs @@ -0,0 +1,190 @@ +//! Production backing for the WASM guest `secret-exists` host import. +//! +//! Guests (including third-party registry/ironhub tools such as `attio`) use +//! `secret-exists` as their only credential probe: they abort with a +//! "credential not configured" failure when it returns `false`. Historically +//! every production invocation ran with [`WasmHostSecrets`] left at the +//! [`DenyWasmHostSecrets`] default, so the probe returned `false` even when a +//! real, staged credential was available — every such tool failed with an +//! opaque `operation_failed` before ever issuing a request. +//! +//! This implementation answers the probe from the per-invocation staged +//! secret injection store ([`RuntimeSecretInjectionStore`]): authorization +//! stages granted secret material under `(scope, capability_id, handle)` (see +//! `obligations/handler.rs`), so `exists` reports `true` exactly when a +//! non-empty credential for this invocation was actually leased and consumed +//! — not merely because a manifest declares the handle. + +use std::sync::Arc; + +use ironclaw_host_api::{ + ids::{CapabilityId, SecretHandle}, + resource::ResourceScope, +}; +use ironclaw_wasm::WasmHostSecrets; +use secrecy::ExposeSecret; + +use crate::obligations::RuntimeSecretInjectionStore; + +/// Per-invocation `secret-exists` view over the staged secret injection store. +/// +/// The store is keyed by the invocation's scope, capability, and the slot +/// handle the guest is expected to probe, so the view is closed over exactly +/// those three values. Material is read non-destructively +/// (`clone_material`) — answering the probe must not consume the one-shot +/// staged secret that the HTTP egress still needs. +#[derive(Debug)] +pub(crate) struct StagedWasmHostSecrets { + store: Arc, + scope: ResourceScope, + capability_id: CapabilityId, +} + +impl StagedWasmHostSecrets { + pub(crate) fn new( + store: Arc, + scope: ResourceScope, + capability_id: CapabilityId, + ) -> Self { + Self { + store, + scope, + capability_id, + } + } +} + +impl WasmHostSecrets for StagedWasmHostSecrets { + fn exists(&self, name: &str) -> bool { + let Ok(handle) = SecretHandle::new(name) else { + // A guest probing a malformed handle name gets a truthful `false`. + return false; + }; + match self + .store + .clone_material(&self.scope, &self.capability_id, &handle) + { + // Fail closed: an empty staged credential is not a usable + // credential — the guest must surface its own re-auth path rather + // than send an empty key. + Ok(Some(material)) => !material.expose_secret().is_empty(), + // No staged material for this invocation (or a poisoned store + // lock) means the credential was never authorized for this call. + Ok(None) | Err(_) => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ironclaw_host_api::ids::InvocationId; + + fn store() -> Arc { + Arc::new(RuntimeSecretInjectionStore::new()) + } + + fn scope() -> ResourceScope { + ResourceScope { + tenant_id: ironclaw_host_api::ids::TenantId::new("test-tenant").unwrap(), + user_id: ironclaw_host_api::ids::UserId::new("test-user").unwrap(), + agent_id: None, + project_id: None, + mission_id: None, + thread_id: None, + invocation_id: InvocationId::new(), + } + } + + fn capability() -> CapabilityId { + CapabilityId::new("attio.invoke").unwrap() + } + + fn secrets() -> StagedWasmHostSecrets { + StagedWasmHostSecrets::new(store(), scope(), capability()) + } + + #[test] + fn exists_true_for_staged_non_empty_material() { + let store = store(); + let scope = scope(); + let handle = SecretHandle::new("attio_api_key").unwrap(); + store + .insert( + &scope, + &capability(), + &handle, + ironclaw_secrets::SecretMaterial::from("att-123"), + ) + .expect("staging should succeed"); + let secrets = StagedWasmHostSecrets::new(store, scope, capability()); + assert!(secrets.exists("attio_api_key")); + } + + #[test] + fn exists_false_for_staged_empty_material() { + let store = store(); + let scope = scope(); + let handle = SecretHandle::new("attio_api_key").unwrap(); + store + .insert( + &scope, + &capability(), + &handle, + ironclaw_secrets::SecretMaterial::from(""), + ) + .expect("staging should succeed"); + let secrets = StagedWasmHostSecrets::new(store, scope, capability()); + assert!(!secrets.exists("attio_api_key")); + } + + #[test] + fn exists_false_without_staged_material() { + assert!(!secrets().exists("attio_api_key")); + } + + #[test] + fn exists_false_for_other_capability_or_scope() { + let store = store(); + let scope = scope(); + let handle = SecretHandle::new("attio_api_key").unwrap(); + store + .insert( + &scope, + &capability(), + &handle, + ironclaw_secrets::SecretMaterial::from("att-123"), + ) + .expect("staging should succeed"); + let other_capability = CapabilityId::new("other.invoke").unwrap(); + assert!( + !StagedWasmHostSecrets::new(Arc::clone(&store), scope.clone(), other_capability) + .exists("attio_api_key") + ); + assert!(!StagedWasmHostSecrets::new(store, scope, capability()).exists("other_secret")); + } + + #[test] + fn exists_false_for_malformed_handle() { + assert!(!secrets().exists("not a valid handle")); + } + + #[test] + fn exists_reads_do_not_consume_staged_material() { + let store = store(); + let scope = scope(); + let handle = SecretHandle::new("attio_api_key").unwrap(); + store + .insert( + &scope, + &capability(), + &handle, + ironclaw_secrets::SecretMaterial::from("att-123"), + ) + .expect("staging should succeed"); + let secrets = StagedWasmHostSecrets::new(store, scope, capability()); + assert!(secrets.exists("attio_api_key")); + // The HTTP egress still needs the staged material after the probe. + assert!(secrets.exists("attio_api_key")); + } +} diff --git a/crates/kernel/ironclaw_host_runtime/tests/host_runtime_services_contract.rs b/crates/kernel/ironclaw_host_runtime/tests/host_runtime_services_contract.rs index c18de7e0c22..e18bd1f2d1e 100644 --- a/crates/kernel/ironclaw_host_runtime/tests/host_runtime_services_contract.rs +++ b/crates/kernel/ironclaw_host_runtime/tests/host_runtime_services_contract.rs @@ -57,9 +57,9 @@ use ironclaw_host_api::{ }; use ironclaw_host_runtime::{ BuiltinObligationServices, CancelReason, CancelRuntimeWorkRequest, CapabilitySurfaceVersion, - HostRuntime, HostRuntimeServices, ProductionWiringComponent, ProductionWiringConfig, - ProductionWiringIssueKind, RuntimeCapabilityOutcome, RuntimeStatusRequest, RuntimeWorkId, - TenantSandboxProcessPort, builtin_first_party_handlers, + HostRuntime, HostRuntimeServices, ProductAuthCredentialStageError, ProductionWiringComponent, + ProductionWiringConfig, ProductionWiringIssueKind, RuntimeCapabilityOutcome, + RuntimeStatusRequest, RuntimeWorkId, TenantSandboxProcessPort, builtin_first_party_handlers, }; use ironclaw_loop_contracts::InMemoryRunProfileResolver; use ironclaw_processes::{ @@ -5104,6 +5104,186 @@ async fn host_runtime_services_routes_wasm_http_through_per_invocation_policy_ha assert_eq!(requests[0].body, b"hello".to_vec()); } +#[tokio::test] +async fn host_runtime_services_wasm_secret_exists_reflects_staged_credential() { + // Regression (#7307): third-party WASM guests (ironhub tools such as + // `attio`) gate on the `secret-exists` host import before issuing any + // request. Production hosts left the probe at its deny-all default, so it + // returned `false` even when a real credential was staged for the + // invocation — every call failed with an opaque `operation_failed` + // ("API key not configured") and never surfaced `auth_required`. + let parsed_manifest = parse_manifest(WASM_SECRET_EXISTS_MANIFEST); + let component = tool_component(SECRET_EXISTS_TOOL_WAT); + let filesystem = Arc::new( + filesystem_with_wasm_component( + parsed_manifest.id.as_str(), + "wasm/secret-exists.wasm", + &component, + ) + .await, + ); + let governor = Arc::new(governor_with_default_limit(sample_account())); + let authorizer: Arc = + Arc::new(ObligatingAuthorizer::new(vec![])); + let egress = Arc::new(RecordingRuntimeHttpEgress::default()); + let secret_store = Arc::new(SecretStore::ephemeral()); + let services = HostRuntimeServices::new( + Arc::new(registry_with_manifest(WASM_SECRET_EXISTS_MANIFEST)), + filesystem, + governor, + authorizer, + ironclaw_processes::in_memory_backed_process_services(), + CapabilitySurfaceVersion::new("surface-v1").unwrap(), + ) + .with_secret_store(Arc::clone(&secret_store)) + .with_runtime_http_egress(Arc::clone(&egress)) + .try_with_wasm_runtime(WitToolRuntimeConfig::for_testing(), WitToolHost::deny_all()) + .unwrap(); + let capability_id = CapabilityId::new("wasm-secrets.secret_exists").unwrap(); + let scope = sample_scope(InvocationId::new()); + let handle = SecretHandle::new("attio_api_key").unwrap(); + secret_store + .put( + scope.clone(), + handle.clone(), + SecretMaterial::from("att-123"), + None, + ) + .await + .expect("test secret should store"); + services + .product_auth_provider_runtime_ports() + .expect("runtime ports should be configured") + .stage_secret_once(&scope, &capability_id, &handle) + .await + .expect("credential should stage"); + + let outcome = services + .host_runtime_for_local_testing() + .invoke_capability(wasm_runtime_request_for_scope( + capability_id.clone(), + scope.clone(), + json!({}), + )) + .await + .unwrap(); + + match outcome { + RuntimeCapabilityOutcome::Completed(completed) => { + assert_eq!(completed.capability_id, capability_id); + assert_eq!(completed.output, json!(true)); + } + other => panic!("expected completed outcome, got {other:?}"), + } +} + +#[tokio::test] +async fn host_runtime_services_wasm_secret_exists_false_without_staged_credential() { + let parsed_manifest = parse_manifest(WASM_SECRET_EXISTS_MANIFEST); + let component = tool_component(SECRET_EXISTS_TOOL_WAT); + let filesystem = Arc::new( + filesystem_with_wasm_component( + parsed_manifest.id.as_str(), + "wasm/secret-exists.wasm", + &component, + ) + .await, + ); + let governor = Arc::new(governor_with_default_limit(sample_account())); + let authorizer: Arc = + Arc::new(ObligatingAuthorizer::new(vec![])); + let egress = Arc::new(RecordingRuntimeHttpEgress::default()); + let services = HostRuntimeServices::new( + Arc::new(registry_with_manifest(WASM_SECRET_EXISTS_MANIFEST)), + filesystem, + governor, + authorizer, + ironclaw_processes::in_memory_backed_process_services(), + CapabilitySurfaceVersion::new("surface-v1").unwrap(), + ) + .with_secret_store(Arc::new(SecretStore::ephemeral())) + .with_runtime_http_egress(Arc::clone(&egress)) + .try_with_wasm_runtime(WitToolRuntimeConfig::for_testing(), WitToolHost::deny_all()) + .unwrap(); + let capability_id = CapabilityId::new("wasm-secrets.secret_exists").unwrap(); + let scope = sample_scope(InvocationId::new()); + + let outcome = services + .host_runtime_for_local_testing() + .invoke_capability(wasm_runtime_request_for_scope( + capability_id.clone(), + scope.clone(), + json!({}), + )) + .await + .unwrap(); + + match outcome { + RuntimeCapabilityOutcome::Completed(completed) => { + assert_eq!(completed.capability_id, capability_id); + assert_eq!(completed.output, json!(false)); + } + other => panic!("expected completed outcome, got {other:?}"), + } +} + +#[tokio::test] +async fn host_runtime_services_credential_staging_rejects_empty_material_as_auth_required() { + // A "Configured" account whose resolved material is empty must surface + // the typed re-auth signal at staging, not hand the guest an unusable + // slot that fails opaquely as `operation_failed` (#7307). + let parsed_manifest = parse_manifest(WASM_SECRET_EXISTS_MANIFEST); + let component = tool_component(SECRET_EXISTS_TOOL_WAT); + let filesystem = Arc::new( + filesystem_with_wasm_component( + parsed_manifest.id.as_str(), + "wasm/secret-exists.wasm", + &component, + ) + .await, + ); + let governor = Arc::new(governor_with_default_limit(sample_account())); + let authorizer: Arc = + Arc::new(ObligatingAuthorizer::new(vec![])); + let egress = Arc::new(RecordingRuntimeHttpEgress::default()); + let secret_store = Arc::new(SecretStore::ephemeral()); + let services = HostRuntimeServices::new( + Arc::new(registry_with_manifest(WASM_SECRET_EXISTS_MANIFEST)), + filesystem, + governor, + authorizer, + ironclaw_processes::in_memory_backed_process_services(), + CapabilitySurfaceVersion::new("surface-v1").unwrap(), + ) + .with_secret_store(Arc::clone(&secret_store)) + .with_runtime_http_egress(Arc::clone(&egress)) + .try_with_wasm_runtime(WitToolRuntimeConfig::for_testing(), WitToolHost::deny_all()) + .unwrap(); + let scope = sample_scope(InvocationId::new()); + let capability_id = CapabilityId::new("wasm-secrets.secret_exists").unwrap(); + let handle = SecretHandle::new("attio_api_key").unwrap(); + secret_store + .put( + scope.clone(), + handle.clone(), + SecretMaterial::from(""), + None, + ) + .await + .expect("test secret should store"); + + let result = services + .product_auth_provider_runtime_ports() + .expect("runtime ports should be configured") + .stage_secret_once(&scope, &capability_id, &handle) + .await; + + assert!( + matches!(result, Err(ProductAuthCredentialStageError::AuthRequired)), + "empty credential material must stage as auth-required, got {result:?}" + ); +} + #[tokio::test] async fn host_runtime_services_routes_cached_wasm_http_through_per_invocation_policy_handoff() { let parsed_manifest = parse_manifest(WASM_HTTP_SUCCESS_MANIFEST); diff --git a/crates/kernel/ironclaw_host_runtime/tests/support/host_runtime_harness.rs b/crates/kernel/ironclaw_host_runtime/tests/support/host_runtime_harness.rs index 4de526e6591..4d00f1d2572 100644 --- a/crates/kernel/ironclaw_host_runtime/tests/support/host_runtime_harness.rs +++ b/crates/kernel/ironclaw_host_runtime/tests/support/host_runtime_harness.rs @@ -2442,6 +2442,25 @@ default_permission = "allow" parameters_schema = { type = "object" } "#; +pub(crate) const WASM_SECRET_EXISTS_MANIFEST: &str = r#" +id = "wasm-secrets" +name = "WASM Secret Exists" +version = "0.1.0" +description = "WASM secret-exists probe extension" +trust = "untrusted" + +[runtime] +kind = "wasm" +module = "wasm/secret-exists.wasm" + +[[capabilities]] +id = "wasm-secrets.secret_exists" +description = "Probe secret-exists for attio_api_key" +effects = ["dispatch_capability", "network"] +default_permission = "allow" +parameters_schema = { type = "object" } +"#; + pub(crate) const WASM_OPERATION_FAILED_MANIFEST: &str = r#" id = "wasm-accounting" name = "WASM Accounting Operation Failed" @@ -2587,6 +2606,87 @@ pub(crate) const HTTP_TOOL_WAT: &str = r#" ) "#; +pub(crate) const SECRET_EXISTS_TOOL_WAT: &str = r#" +(module + (type (;0;) (func (param i32 i32 i32))) + (type (;1;) (func (param i32 i32) (result i32))) + (import "near:agent/host@0.3.0" "log" (func $log (type 0))) + (import "near:agent/host@0.3.0" "secret-exists" (func $secret_exists (type 1))) + (memory (export "memory") 1) + (global $heap (mut i32) (i32.const 4096)) + (data (i32.const 128) "attio_api_key") + (data (i32.const 1024) "{\"type\":\"object\"}") + (data (i32.const 2048) "fixture description") + (data (i32.const 3072) "true") + (data (i32.const 3104) "false") + (func $schema (result i32) + i32.const 16 + i32.const 1024 + i32.store + i32.const 20 + i32.const 17 + i32.store + i32.const 16) + (func $description (result i32) + i32.const 32 + i32.const 2048 + i32.store + i32.const 36 + i32.const 19 + i32.store + i32.const 32) + (func $execute (param i32 i32 i32 i32 i32) (result i32) + (local $ptr i32) + (local $len i32) + i32.const 128 + i32.const 13 + call $secret_exists + if + i32.const 3072 + local.set $ptr + i32.const 4 + local.set $len + else + i32.const 3104 + local.set $ptr + i32.const 5 + local.set $len + end + i32.const 48 + i32.const 1 + i32.store + i32.const 52 + local.get $ptr + i32.store + i32.const 56 + local.get $len + i32.store + i32.const 60 + i32.const 0 + i32.store + i32.const 48) + (func $post (param i32)) + (func $realloc (param $old i32) (param $old_align i32) (param $new_size i32) (param $new_align i32) (result i32) + (local $ret i32) + global.get $heap + local.set $ret + global.get $heap + local.get $new_size + i32.add + global.set $heap + local.get $ret) + (func $_initialize) + (export "near:agent/tool@0.3.0#execute" (func $execute)) + (export "cabi_post_near:agent/tool@0.3.0#execute" (func $post)) + (export "near:agent/tool@0.3.0#schema" (func $schema)) + (export "cabi_post_near:agent/tool@0.3.0#schema" (func $post)) + (export "near:agent/tool@0.3.0#description" (func $description)) + (export "cabi_post_near:agent/tool@0.3.0#description" (func $post)) + (export "cabi_realloc" (func $realloc)) + (export "_initialize" (func $_initialize)) +) +"#; + fn capability_provider_contracts() -> ironclaw_extension_registry::HostApiContractRegistry { let mut contracts = ironclaw_extension_registry::HostApiContractRegistry::new(); contracts diff --git a/docs/reborn/contracts/wasm.md b/docs/reborn/contracts/wasm.md index a5dd9110101..d4e0cbba955 100644 --- a/docs/reborn/contracts/wasm.md +++ b/docs/reborn/contracts/wasm.md @@ -38,6 +38,8 @@ All host capabilities are injected through explicit Rust seams. The default host Production HTTP is wired through `WasmRuntimeHttpAdapter`, a thin adapter from the WIT `http-request` import to the shared Reborn `RuntimeHttpEgress` service. `ironclaw_wasm` does not implement direct HTTP clients, DNS resolution, SSRF checks, credential injection, or response streaming. Host composition supplies scope, capability id, response limits, and a request-scoped credential provider before constructing the adapter; the shared runtime egress service consumes the scoped/capability `ApplyNetworkPolicy` handoff from `NetworkObligationPolicyStore` and passes that host-approved policy to `ironclaw_network`. Credential providers must derive credential injections from the actual method/URL/headers being requested, not from guest input alone or from a reusable adapter-wide grant. Shared runtime egress owns request leak checks, request sensitive-header handling, policy enforcement, credential injection, response redaction, and sanitized runtime-visible errors. The WASM adapter additionally strips sensitive response headers before encoding the WIT `headers-json` object using the shared runtime sensitive-header vocabulary. Because the WIT ABI defines headers as a JSON object string, duplicate non-sensitive response header names are combined case-insensitively with comma separators at this boundary after shared egress has already applied response safety checks. The WASM runtime applies the WIT HTTP default timeout when `timeout-ms` is omitted, caps it to the remaining execution deadline, forwards that cap through `RuntimeHttpEgress`, and reports a timeout if a host import returns after that deadline; injected synchronous host implementations must still honor the supplied timeout because they cannot be safely preempted mid-call. +Production `secret-exists` is wired through `StagedWasmHostSecrets` (`ironclaw_host_runtime::services::wasm_secrets`), a per-invocation view over the staged secret injection store: `exists(name)` returns `true` exactly when authorization leased and staged non-empty credential material for this invocation's `(scope, capability_id, handle)`, and `false` otherwise (fail-closed). The read is non-consuming (`clone_material`), so the probe does not steal the material the HTTP egress still needs. Credential staging rejects empty resolved material as `AuthRequired` so a configured-but-blank credential surfaces the typed re-auth signal instead of an opaque guest failure. + ## Network accounting `ResourceUsage.network_egress_bytes` counts outbound request body bytes only. Response body limits and response scanning are separate host-egress responsibilities and must not be recorded as egress usage. If the host reports that a request was sent but later failed during response handling, the request body still counts as egress; fail-closed denials before send count zero. Execution failures preserve the usage/log snapshot collected before the failure so callers can reconcile sent egress even when the guest traps.