Skip to content
Closed
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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 8 additions & 3 deletions crates/ironclaw_host_runtime/src/egress/credential.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use ironclaw_host_api::{
CapabilityId, RuntimeCredentialInjection, RuntimeCredentialSource, RuntimeCredentialTarget,
RuntimeHttpEgressError, RuntimeHttpEgressRequest, SecretHandle,
RuntimeHttpEgressError, RuntimeHttpEgressRequest, RuntimeKind, SecretHandle,
};
use ironclaw_network::is_rfc3986_unreserved_segment;
use ironclaw_safety::redaction_values_for_secret;
Expand Down Expand Up @@ -52,7 +52,7 @@ impl<'a> CredentialSourceStrategy<'a> {
) -> Result<(), RuntimeHttpEgressError> {
match self {
Self::SecretStoreLease => Err(RuntimeHttpEgressError::Credential {
// Production egress accepts one-shot staged obligations only;
// Production egress accepts staged obligations only;
// direct store leases are retained behind crate-local tests for
// legacy mapping coverage, not as a runtime path.
reason: "direct secret-store leases are unavailable for production runtime egress"
Expand Down Expand Up @@ -234,7 +234,12 @@ fn staged_secret_for_injection(
let Some(secret_injections) = secret_injections else {
return missing_runtime_credential(injection.required);
};
match secret_injections.take(&request.scope, capability_id, &injection.handle) {
let material = if request.runtime == RuntimeKind::Wasm {
secret_injections.get(&request.scope, capability_id, &injection.handle)
} else {
secret_injections.take(&request.scope, capability_id, &injection.handle)
};
match material {
Ok(Some(material)) => Ok(Some(material)),
Ok(None) => missing_runtime_credential(injection.required),
Err(_) => Err(RuntimeHttpEgressError::Credential {
Expand Down
52 changes: 52 additions & 0 deletions crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,34 @@ pub(crate) fn resolve_builtin_input_schema_ref(reference: &str) -> Option<Value>
"required": ["extension_id"],
"additionalProperties": false
}),
"schemas/builtin/ironhub_search.input.v1.json" => json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Optional search query for the signed IronHub catalog. Omit to list all entries." }
},
"additionalProperties": false
}),
"schemas/builtin/ironhub_info.input.v1.json" => json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "IronHub tool or skill name." },
"kind": { "type": "string", "enum": ["tool", "skill"], "description": "Disambiguate when a name exists as both a tool and a skill." }
},
"required": ["name"],
"additionalProperties": false
}),
"schemas/builtin/ironhub_install.input.v1.json" => json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "IronHub tool or skill name." },
"kind": { "type": "string", "enum": ["tool", "skill"], "description": "Disambiguate when a name exists as both a tool and a skill." },
"force": { "type": "boolean", "description": "Replace an already installed package.", "default": false },
"expected_version": { "type": "string", "description": "Optional catalog version pin for signed install intents." },
"expected_artifact_digest": { "type": "string", "description": "Optional artifact digest pin for signed install intents." }
},
"required": ["name"],
"additionalProperties": false
}),
"schemas/builtin/skill_list.input.v1.json" => json!({
"type": "object",
"properties": {},
Expand Down Expand Up @@ -414,3 +442,27 @@ fn response_body_limit_schema(require_save_to: bool) -> Value {
"description": description
})
}

#[cfg(test)]
mod tests {
use super::resolve_builtin_input_schema_ref;

#[test]
fn ironhub_model_visible_schemas_preserve_host_trust_boundary() {
let info = resolve_builtin_input_schema_ref("schemas/builtin/ironhub_info.input.v1.json")
.expect("ironhub info schema");
assert_eq!(
info["properties"]["kind"]["enum"],
serde_json::json!(["tool", "skill"])
);

let install =
resolve_builtin_input_schema_ref("schemas/builtin/ironhub_install.input.v1.json")
.expect("ironhub install schema");
assert_eq!(
install["properties"].get("acknowledge_unverified"),
None,
"model-visible IronHub install must not self-acknowledge unverified community content"
);
}
}
18 changes: 18 additions & 0 deletions crates/ironclaw_host_runtime/src/obligations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,24 @@ impl RuntimeSecretInjectionStore {
.map(|entry| entry.material))
}

pub(crate) fn get(
&self,
scope: &ResourceScope,
capability_id: &CapabilityId,
handle: &SecretHandle,
) -> Result<Option<SecretMaterial>, RuntimeSecretInjectionStoreError> {
let now = Instant::now();
let mut secrets = self.lock()?;
prune_expired_entries(&mut secrets, now);
Ok(secrets
.get(&RuntimeSecretInjectionKey::new(
scope,
capability_id,
handle,
))
.map(|entry| entry.material.clone()))
}

/// Discard all staged secrets for a scoped capability before process ownership exists.
///
/// Background process lifecycle cleanup is guarded by a single-active-handoff
Expand Down
69 changes: 69 additions & 0 deletions crates/ironclaw_host_runtime/tests/runtime_http_egress_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,75 @@ async fn host_http_egress_consumes_staged_obligation_secret_once() {
assert_eq!(network_recorder.lock().unwrap().len(), 1);
}

#[tokio::test]
async fn wasm_host_http_egress_reuses_staged_obligation_secret_within_dispatch() {
let network = RecordingNetwork::ok(NetworkHttpResponse {
status: 200,
headers: vec![],
body: br#"{"ok":true}"#.to_vec(),
usage: NetworkUsage {
request_bytes: 5,
response_bytes: 11,
resolved_ip: None,
},
});
let network_recorder = network.requests.clone();
let scope = sample_scope();
let capability_id = sample_capability_id();
let handle = SecretHandle::new("api-token").unwrap();
let services = test_obligation_services();
stage_policy(&services, &scope, &capability_id, sample_policy()).await;
stage_secret(
&services,
&scope,
&capability_id,
&handle,
"sk-staged-wasm-secret",
)
.await;
let service = services.host_http_egress(network);
let request = RuntimeHttpEgressRequest {
runtime: RuntimeKind::Wasm,
scope,
capability_id: capability_id.clone(),
method: NetworkMethod::Post,
url: "https://api.example.test/v1/run".to_string(),
headers: vec![],
body: b"hello".to_vec(),
network_policy: sample_policy(),
credential_injections: vec![RuntimeCredentialInjection {
handle,
source: RuntimeCredentialSource::StagedObligation { capability_id },
target: RuntimeCredentialTarget::Header {
name: "authorization".to_string(),
prefix: Some("Bearer ".to_string()),
},
required: true,
}],
response_body_limit: Some(4096),
save_body_to: None,
timeout_ms: None,
};

service
.execute(request.clone())
.await
.expect("first WASM request should use staged credential");
service
.execute(request)
.await
.expect("second WASM request in the same dispatch should reuse staged credential");

let requests = network_recorder.lock().unwrap();
assert_eq!(requests.len(), 2);
assert!(requests.iter().all(|request| {
request
.headers
.iter()
.any(|(name, value)| name == "authorization" && value == "Bearer sk-staged-wasm-secret")
}));
}

#[test]
fn host_http_egress_records_injected_credentials_in_zeroizing_network_request() {
let network = RecordingNetwork::ok(NetworkHttpResponse {
Expand Down
9 changes: 9 additions & 0 deletions crates/ironclaw_product_workflow/src/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,11 @@ pub enum LifecycleProductPayload {
extensions: Vec<LifecycleExtensionSummary>,
count: usize,
},
CatalogSearch {
tools: Vec<LifecycleExtensionSummary>,
skills: Vec<LifecycleSkillSummary>,
count: usize,
},
ExtensionList {
extensions: Vec<LifecycleInstalledExtensionSummary>,
count: usize,
Expand Down Expand Up @@ -387,6 +392,8 @@ pub enum LifecycleExtensionCredentialSetup {
#[serde(rename_all = "snake_case")]
pub enum LifecycleExtensionSource {
HostBundled,
Installed,
Registry,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -426,6 +433,8 @@ pub struct LifecycleSkillSummary {
pub enum LifecycleSkillSource {
System,
User,
Installed,
Registry,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
Expand Down
10 changes: 5 additions & 5 deletions crates/ironclaw_reborn/tests/hooks_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ use ironclaw_reborn::hook_gate_refs::{
InMemoryHookGateRouter, RouterBackedHookGateRefFactory, hook_gate_arguments_digest,
};
use ironclaw_reborn::loop_driver_host::{
EventTriggeredHookSubscription, RebornLoopDriverHostFactory, RebornLoopDriverHostRequest,
TextOnlyLoopHostConfig,
EventTriggeredHookSubscription, RebornLoopDriverHostError, RebornLoopDriverHostFactory,
RebornLoopDriverHostRequest, TextOnlyLoopHostConfig,
};
use ironclaw_threads::{
AcceptInboundMessageRequest, EnsureThreadRequest, InMemorySessionThreadService, MessageContent,
Expand Down Expand Up @@ -649,7 +649,7 @@ fn predicate_deny_dispatcher() -> Arc<HookDispatcher> {
/// builder-factory path can attach a security-audit sink before sealing. This
/// is the only dispatcher-installation path that consumes
/// `RebornLoopDriverHostFactory::with_hook_security_audit_sink`.
fn predicate_deny_builder() -> HookDispatcherBuilder {
fn predicate_deny_builder() -> Result<HookDispatcherBuilder, RebornLoopDriverHostError> {
let hook_id = HookId::derive(
&ExtensionId::new("integration-tests").expect("valid ExtensionId in test"),
"0.0.1",
Expand All @@ -665,15 +665,15 @@ fn predicate_deny_builder() -> HookDispatcherBuilder {
let evaluator = Arc::new(PredicateEvaluator::new());
let hook = PredicateBackedBeforeCapabilityHook::new(hook_id, spec, evaluator);

HookDispatcherBuilder::new(HookRegistry::new())
Ok(HookDispatcherBuilder::new(HookRegistry::new())
.install_installed_before_capability(
hook_id,
HookPhase::Policy,
ironclaw_host_api::ExtensionId::new("integration-tests").expect("valid ext id"),
HookBindingScope::Global,
Box::new(hook),
)
.expect("Installed-tier predicate hook installs at policy phase")
.expect("Installed-tier predicate hook installs at policy phase"))
}

fn selective_deny_dispatcher(target: &str) -> Arc<HookDispatcher> {
Expand Down
1 change: 1 addition & 0 deletions crates/ironclaw_reborn/tests/loop_driver_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2666,6 +2666,7 @@ async fn build_runtime_host_with_optional_hooks(
model_policy_guard: None,
model_budget_accountant: None,
safety_context: None,
hook_security_audit_sink: None,
turn_event_sink: None,
hook_dispatcher_builder_factory: hook_factory,
})
Expand Down
Loading
Loading