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
5 changes: 5 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 @@ -464,5 +464,10 @@ mod tests {
None,
"model-visible IronHub install must not self-acknowledge unverified community content"
);
assert_eq!(
install["properties"].get("private_manifest_url"),
None,
"model-visible IronHub install must not choose a private manifest source"
);
}
}
8 changes: 5 additions & 3 deletions crates/ironclaw_product_workflow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,11 @@ pub use ironclaw_product_adapters::{
pub use reborn_services::{
AUTOMATION_LIST_DEFAULT_PAGE_SIZE, AUTOMATION_LIST_MAX_PAGE_SIZE, AutomationProductFacade,
CodexLoginStart, ConnectableChannelsProductFacade, ExtensionCredentialSetupService,
ExtensionCredentialStatusRequest, ExtensionCredentialSubmitRequest, LlmActiveSelection,
LlmConfigService, LlmConfigServiceError, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest,
LlmProbeResult, LlmProviderView, NearAiAuthProvider, NearAiLoginRequest, NearAiLoginStart,
ExtensionCredentialStatusRequest, ExtensionCredentialSubmitRequest,
IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, IronhubLinkError,
IronhubLinkService, IronhubRegisterRequest, LlmActiveSelection, LlmConfigService,
LlmConfigServiceError, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, LlmProbeResult,
LlmProviderView, NearAiAuthProvider, NearAiLoginRequest, NearAiLoginStart,
NearAiWalletLoginRequest, NearAiWalletLoginResult, ProductAgentBoundCaller,
RebornAutomationInfo, RebornAutomationRunStatus, RebornAutomationSource, RebornAutomationState,
RebornCancelRunResponse, RebornChannelConnectAction, RebornChannelConnectStrategy,
Expand Down
58 changes: 58 additions & 0 deletions crates/ironclaw_product_workflow/src/reborn_services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,16 @@ mod error;
mod extension_onboarding;
mod extension_setup_credentials;
mod extensions;
mod ironhub_link;
mod lifecycle_setup;
mod llm_config;
mod types;

pub use error::{RebornServicesError, RebornServicesErrorCode, RebornServicesErrorKind};
pub use ironhub_link::{
IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, IronhubLinkError,
IronhubLinkService, IronhubRegisterRequest,
};
pub use llm_config::{
CodexLoginStart, LlmActiveSelection, LlmConfigService, LlmConfigServiceError,
LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, LlmProbeResult, LlmProviderView,
Expand Down Expand Up @@ -465,6 +470,23 @@ pub trait RebornServicesApi: Send + Sync {
let _ = caller;
Err(llm_config::llm_config_unavailable())
}

async fn ironhub_register(
&self,
request: IronhubRegisterRequest,
) -> Result<(), RebornServicesError> {
let _ = request;
Err(ironhub_link::ironhub_link_unavailable())
}

async fn ironhub_deliver_install(
&self,
caller: WebUiAuthenticatedCaller,
request: IronhubInstallDeliveryRequest,
) -> Result<IronhubInstallDeliveryResult, RebornServicesError> {
let _ = (caller, request);
Err(ironhub_link::ironhub_link_unavailable())
}
}

/// Default facade implementation composed at the WebUI boundary.
Expand All @@ -482,6 +504,7 @@ pub struct RebornServices {
skill_activation_recorder: Option<Arc<SkillActivationRecorder>>,
skill_activation_clearer: Option<Arc<SkillActivationClearer>>,
llm_config: Option<Arc<dyn LlmConfigService>>,
ironhub_link: Option<Arc<dyn IronhubLinkService>>,
}

impl RebornServices {
Expand All @@ -504,6 +527,7 @@ impl RebornServices {
skill_activation_recorder: None,
skill_activation_clearer: None,
llm_config: None,
ironhub_link: None,
}
}

Expand All @@ -517,6 +541,11 @@ impl RebornServices {
self
}

pub fn with_ironhub_link_service(mut self, ironhub_link: Arc<dyn IronhubLinkService>) -> Self {
self.ironhub_link = Some(ironhub_link);
self
}

pub fn with_lifecycle_product_facade(
mut self,
lifecycle_facade: Arc<dyn LifecycleProductFacade>,
Expand Down Expand Up @@ -1270,6 +1299,35 @@ impl RebornServicesApi for RebornServices {
.await
.map_err(llm_config::map_llm_config_error)
}

async fn ironhub_register(
&self,
request: IronhubRegisterRequest,
) -> Result<(), RebornServicesError> {
let service = self
.ironhub_link
.as_ref()
.ok_or_else(ironhub_link::ironhub_link_unavailable)?;
service
.register(request)
.await
.map_err(ironhub_link::map_ironhub_link_error)
}

async fn ironhub_deliver_install(
&self,
caller: WebUiAuthenticatedCaller,
request: IronhubInstallDeliveryRequest,
) -> Result<IronhubInstallDeliveryResult, RebornServicesError> {
let service = self
.ironhub_link
.as_ref()
.ok_or_else(ironhub_link::ironhub_link_unavailable)?;
service
.deliver_install(caller.user_id, request)
.await
.map_err(ironhub_link::map_ironhub_link_error)
Comment on lines +1317 to +1329

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 | 🟠 Major | 🏗️ Heavy lift

Validate the signed install target against the authenticated caller.

This only forwards caller.user_id, so the service never sees the authenticated identity that the signed uid/aid payload is meant to bind. A captured install payload can therefore be consumed by a different authenticated caller before the nonce is spent, which is especially risky for private-manifest installs. Pass the full caller context (or explicit expected UserId/AgentId) into IronhubLinkService::deliver_install and reject mismatches before install. As per coding guidelines, "Fail closed for auth..." and "Preserve tenant/user/agent/project/mission/thread scope..."

🤖 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/ironclaw_product_workflow/src/reborn_services.rs` around lines 1317 -
1329, The `ironhub_deliver_install` flow only passes `caller.user_id` into
`IronhubLinkService::deliver_install`, so the signed install target is not
validated against the authenticated caller context. Update
`ironhub_deliver_install` and the `deliver_install` method on
`IronhubLinkService` to accept the full `WebUiAuthenticatedCaller` or explicit
expected `UserId`/`AgentId`, then compare the signed `uid`/`aid` payload against
the caller before consuming the nonce and reject any mismatch. Keep the auth
check fail-closed and preserve the caller’s tenant/user/agent scope throughout
the install path.

Source: Coding guidelines

}
}

fn automation_unavailable() -> RebornServicesError {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
use async_trait::async_trait;
use ironclaw_host_api::UserId;
use serde::{Deserialize, Serialize};

use super::error::{RebornServicesError, RebornServicesErrorCode};

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct IronhubRegisterRequest {
pub uid: String,
pub aid: String,
pub ts: u64,
pub nonce: String,
pub sig: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct IronhubInstallDeliveryRequest {
pub slug: String,
pub version: String,
pub uid: String,
pub aid: String,
pub ts: u64,
pub nonce: String,
pub artifact_digest: String,
pub sig: String,
#[serde(default)]
pub private_manifest_url: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct IronhubInstallDeliveryResult {
pub installed: bool,
pub slug: String,
pub message: String,
}

#[derive(Debug, thiserror::Error)]
pub enum IronhubLinkError {
#[error("invalid agent-link signature")]
InvalidSignature,
#[error("agent-link timestamp outside the accepted window")]
StaleTimestamp,
#[error("agent-link request replayed")]
Replay,
#[error("ironhub install failed: {reason}")]
Install { reason: String },
#[error("invalid ironhub install request: {reason}")]
InvalidInput { reason: String },
#[error("ironhub link service is unavailable")]
Unavailable,
}

#[async_trait]
pub trait IronhubLinkService: Send + Sync {
async fn register(&self, request: IronhubRegisterRequest) -> Result<(), IronhubLinkError>;

async fn deliver_install(
&self,
user_id: UserId,
request: IronhubInstallDeliveryRequest,
) -> Result<IronhubInstallDeliveryResult, IronhubLinkError>;
}

pub(super) fn ironhub_link_unavailable() -> RebornServicesError {
RebornServicesError::service_unavailable(false)
}

pub(super) fn map_ironhub_link_error(error: IronhubLinkError) -> RebornServicesError {
match error {
IronhubLinkError::InvalidSignature
| IronhubLinkError::StaleTimestamp
| IronhubLinkError::Replay => {
RebornServicesError::from_status(RebornServicesErrorCode::Forbidden, 403, false)
}
IronhubLinkError::Install { .. } => RebornServicesError::internal_invariant(),
IronhubLinkError::InvalidInput { .. } => {
RebornServicesError::from_status(RebornServicesErrorCode::InvalidRequest, 400, false)
}
IronhubLinkError::Unavailable => RebornServicesError::service_unavailable(false),
}
}
22 changes: 22 additions & 0 deletions crates/ironclaw_reborn_cli/src/commands/ironhub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ struct IronHubInstallCommand {
#[arg(long)]
expected_artifact_digest: Option<String>,

/// Install from a private org-scoped signed manifest URL read from this
/// file (keeps the tokenized URL off argv and out of shell history).
#[arg(long, value_name = "PATH")]
private_manifest_url_file: Option<std::path::PathBuf>,

/// Output the lifecycle response as JSON.
#[arg(long)]
json: bool,
Expand Down Expand Up @@ -136,6 +141,9 @@ impl IronHubCommand {
acknowledge_unverified: command.acknowledge_unverified,
expected_version: command.expected_version,
expected_artifact_digest: command.expected_artifact_digest,
private_manifest_url: read_private_manifest_url(
command.private_manifest_url_file.as_deref(),
)?,
},
},
command.json,
Expand All @@ -161,6 +169,20 @@ impl From<IronHubKindArg> for IronHubEntryKind {
}
}

fn read_private_manifest_url(path: Option<&std::path::Path>) -> anyhow::Result<Option<String>> {
let Some(path) = path else {
return Ok(None);
};
let url = std::fs::read_to_string(path)
.with_context(|| format!("reading private manifest URL file {}", path.display()))?
.trim()
.to_string();
if url.is_empty() {
anyhow::bail!("private manifest URL file {} is empty", path.display());
}
Ok(Some(url))
}

fn execute_ironhub_command(
context: RebornCliContext,
command: RebornIronHubCommand,
Expand Down
8 changes: 8 additions & 0 deletions crates/ironclaw_reborn_cli/src/commands/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ use ironclaw_reborn_composition::{
RebornRuntimeInput, RebornWebuiBundle, WebuiAuthenticator, WebuiServeConfig,
build_reborn_runtime, open_local_trigger_access_store, webui_v2_app_with_lifecycle,
};
#[cfg(feature = "webui-v2-beta")]
use ironclaw_reborn_composition::{IronhubRegisterRouteState, ironhub_register_route_mount};
#[cfg(feature = "slack-v2-host-beta")]
use ironclaw_reborn_composition::{
build_slack_host_beta_mounts, build_webui_services_with_slack_host_beta_mounts,
Expand Down Expand Up @@ -442,6 +444,12 @@ impl ServeCommand {
if let Some(nearai_mount) = runtime.nearai_login_callback_mount() {
serve_config = serve_config.with_public_route_mount(nearai_mount);
}
#[cfg(feature = "webui-v2-beta")]
if runtime.ironhub_register_enabled() {
let register_state = IronhubRegisterRouteState::new(Arc::clone(&bundle.api));
serve_config = serve_config
.with_public_route_mount(ironhub_register_route_mount(register_state));
}
if let Some(mount) = public_mount {
serve_config = serve_config.with_public_route_mount(mount);
}
Expand Down
11 changes: 11 additions & 0 deletions crates/ironclaw_reborn_cli/src/runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,17 @@ pub(crate) fn build_runtime_input_with_options(
}
}

#[cfg(feature = "webui-v2-beta")]
if let Ok(shared_key) = std::env::var("IRONHUB_AGENT_SHARED_KEY") {
let shared_key = shared_key.trim();
if !shared_key.is_empty() {
runtime_input = runtime_input.with_ironhub_agent_shared_key(
ironclaw_reborn_composition::IronhubSharedKey::new(shared_key)
.context("IRONHUB_AGENT_SHARED_KEY is invalid")?,
);
}
}

Ok(runtime_input)
}

Expand Down
8 changes: 7 additions & 1 deletion crates/ironclaw_reborn_cli/tests/extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,13 @@ transport = "stdio"
command = "zztest-mcp-server"
args = ["--stdio"]

[[capabilities]]
[[host_api]]
id = "ironclaw.capability_provider/v1"
section = "capability_provider.tools"

[capability_provider.tools]

[[capability_provider.tools.capabilities]]
id = "{extension_id}.search_issues"
description = "Search GitHub issues"
effects = ["network", "dispatch_capability"]
Expand Down
2 changes: 2 additions & 0 deletions crates/ironclaw_reborn_composition/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ deadpool-postgres = { version = "0.14", optional = true }
ed25519-dalek = "2.2.0"
futures = "0.3"
hex = "0.4.3"
hmac = "0.12"
sha2 = "0.10"
ironclaw_auth = { path = "../ironclaw_auth" }
ironclaw_common = { path = "../ironclaw_common" }
ironclaw_capabilities = { path = "../ironclaw_capabilities" }
Expand Down
15 changes: 12 additions & 3 deletions crates/ironclaw_reborn_composition/src/available_extensions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2149,7 +2149,13 @@ trust = "third_party"
kind = "wasm"
module = "wasm/fixture.wasm"

[[capabilities]]
[[host_api]]
id = "ironclaw.capability_provider/v1"
section = "capability_provider.tools"

[capability_provider.tools]

[[capability_provider.tools.capabilities]]
id = "fixture.search"
description = "Search"
effects = ["network"]
Expand All @@ -2158,7 +2164,7 @@ visibility = "model"
input_schema_ref = "schemas/search.input.json"
output_schema_ref = "schemas/search.output.json"

[[capabilities]]
[[capability_provider.tools.capabilities]]
id = "fixture.write"
description = "Write"
effects = ["external_write"]
Expand All @@ -2167,10 +2173,13 @@ visibility = "model"
input_schema_ref = "schemas/write.input.json"
output_schema_ref = "schemas/write.output.json"
"#;
let manifest = ExtensionManifest::parse(
let contracts = ironclaw_host_runtime::default_host_api_contract_registry()
.expect("default host api contracts");
let manifest = ExtensionManifest::parse_with_host_api_contracts(
MANIFEST,
ManifestSource::HostBundled,
&HostPortCatalog::empty(),
&contracts,
)
.expect("manifest");
let package = ExtensionPackage::from_manifest(
Expand Down
Loading
Loading