Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions crates/kernel/ironclaw_host_runtime/src/obligations/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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| {
Expand Down
13 changes: 13 additions & 0 deletions crates/kernel/ironclaw_host_runtime/src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ use ironclaw_wasm::{
WasmStagedRuntimeCredentials, WitToolExecution, WitToolHost, WitToolRequest, WitToolRuntime,
WitToolRuntimeConfig,
};
use secrecy::ExposeSecret;

use crate::obligations::{
NetworkObligationPolicyStore, RuntimeCredentialAccountResolver, RuntimeSecretInjectionStore,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -902,6 +904,7 @@ pub(super) struct WasmRuntimeAdapter {
network_policy_store: Arc<NetworkObligationPolicyStore>,
runtime_http_egress: SharedRuntimeHttpEgress,
credential_provider: Option<Arc<dyn WasmRuntimeCredentialProvider>>,
secret_injections: Arc<RuntimeSecretInjectionStore>,
prepared: Mutex<HashMap<String, Arc<PreparedWitTool>>>,
}

Expand All @@ -912,13 +915,15 @@ impl WasmRuntimeAdapter {
network_policy_store: Arc<NetworkObligationPolicyStore>,
runtime_http_egress: SharedRuntimeHttpEgress,
credential_provider: Option<Arc<dyn WasmRuntimeCredentialProvider>>,
secret_injections: Arc<RuntimeSecretInjectionStore>,
) -> Self {
Self {
runtime,
host,
network_policy_store,
runtime_http_egress,
credential_provider,
secret_injections,
prepared: Mutex::new(HashMap::new()),
}
}
Expand All @@ -929,13 +934,15 @@ impl WasmRuntimeAdapter {
network_policy_store: Arc<NetworkObligationPolicyStore>,
runtime_http_egress: SharedRuntimeHttpEgress,
credential_provider: Option<Arc<dyn WasmRuntimeCredentialProvider>>,
secret_injections: Arc<RuntimeSecretInjectionStore>,
) -> Result<Self, WasmError> {
Ok(Self::new(
WitToolRuntime::new(config)?,
host,
network_policy_store,
runtime_http_egress,
credential_provider,
secret_injections,
))
}

Expand All @@ -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)
Expand All @@ -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))
}
}

Expand Down
190 changes: 190 additions & 0 deletions crates/kernel/ironclaw_host_runtime/src/services/wasm_secrets.rs
Original file line number Diff line number Diff line change
@@ -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<RuntimeSecretInjectionStore>,
scope: ResourceScope,
capability_id: CapabilityId,
}

impl StagedWasmHostSecrets {
pub(crate) fn new(
store: Arc<RuntimeSecretInjectionStore>,
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<RuntimeSecretInjectionStore> {
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"));
}
Comment on lines +147 to +165

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Test a mismatched invocation scope.

exists_false_for_other_capability_or_scope does not create another ResourceScope. It tests a different capability and handle only.

Add an assertion with a fresh scope. This verifies that one invocation cannot observe staged credential presence from another invocation.

Proposed test addition
         let other_capability = CapabilityId::new("other.invoke").unwrap();
         assert!(
             !StagedWasmHostSecrets::new(Arc::clone(&store), scope.clone(), other_capability)
                 .exists("attio_api_key")
         );
+        let other_scope = scope();
+        assert!(
+            !StagedWasmHostSecrets::new(Arc::clone(&store), other_scope, capability())
+                .exists("attio_api_key")
+        );
         assert!(!StagedWasmHostSecrets::new(store, scope, capability()).exists("other_secret"));

As per coding guidelines, credentials must be resolved and injected at the narrowest egress boundary.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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"));
}
let other_capability = CapabilityId::new("other.invoke").unwrap();
assert!(
!StagedWasmHostSecrets::new(Arc::clone(&store), scope.clone(), other_capability)
.exists("attio_api_key")
);
let other_scope = scope();
assert!(
!StagedWasmHostSecrets::new(Arc::clone(&store), other_scope, capability())
.exists("attio_api_key")
);
assert!(!StagedWasmHostSecrets::new(store, scope, capability()).exists("other_secret"));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/kernel/ironclaw_host_runtime/src/services/wasm_secrets.rs` around
lines 147 - 165, Extend exists_false_for_other_capability_or_scope by creating a
fresh ResourceScope and asserting that StagedWasmHostSecrets::new with that
scope cannot observe the staged “attio_api_key”. Keep the existing
mismatched-capability and handle assertions unchanged.

Source: Coding guidelines


#[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"));
}
}
Loading
Loading