Skip to content

feat(ironhub): install tools and skills from IronHub (CLI, agent tools, gateway, live catalog) - #3737

Closed
neo-sky wants to merge 13 commits into
nearai:mainfrom
neo-sky:feat/tool-registry-installer
Closed

neo-sky wants to merge 13 commits into
nearai:mainfrom
neo-sky:feat/tool-registry-installer

Conversation

@neo-sky

@neo-sky neo-sky commented May 17, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Adds the ability to install tools and skills from IronHub by name, both from the CLI (ironclaw ironhub install/search/list/info) and as agent-callable tools, so a running agent can install at runtime instead of only at build time.
  • Adds gateway HTTP endpoints and an HMAC-verified deep-link receiver so a hosted agent (e.g. agent.near.ai) can install from a hub "Install to Agent" link, with per-user signing keys managed in agent Settings.
  • Points the default catalog at a unified manifest (the existing GitHub-release tools.json plus the live moderated Iliad catalog) with a per-entry provenance tier, defaulting to the least-trusted tier when the field is absent (fail-closed).
  • The catalog manifest is Ed25519-signed by IronHub and verified by the agent against an embedded public key before use, fail-closed: an unsigned or tampered manifest is rejected before any artifact is fetched. The private signing key lives only on the IronHub server, never in this repo or the binary.
  • Every artifact still passes the existing SHA-256 verify-before-write and a closed host allowlist; community content is labelled and gated, never silently trusted.

Change Type

  • New feature
  • Security

Linked Issue

None. This is net-new work brought to the team directly; happy to file a tracking issue if the team prefers one on record.

Validation

  • cargo fmt --all -- --check
  • cargo clippy --all --benches --tests --examples --all-features -- -D warnings (zero warnings)
  • cargo build / cargo check --no-default-features --features libsql
  • Relevant tests pass: full cargo test --lib (5205 passing, 0 failed), including the signed-manifest envelope verify suite (valid vector, tampered manifest, wrong key, unknown key id, embedded-key validity), the --release-tag rejection test, and the existing IronHub installer, provenance, host-allowlist, dispatch, and gateway handler suites
  • cargo test --features integration — N/A; no database-backed or integration behaviour changed on this side
  • Manual testing: full end-to-end pipeline against live staging through a signed catalog. The agent rejected the unsigned manifest fail-closed, then after signing was deployed installed all seven published tools via the CLI (Ed25519 verify, SHA-256, TLS host allowlist) and installed a tool via the HMAC deep-link receiver (intent, replay nonce, artifact-digest binding, then the signed-manifest path)

Security Impact

Yes. The catalog manifest is now Ed25519-signed and verified against an embedded public key before use, fail-closed: the agent will not install from an unsigned or tampered manifest, and the signing private key is held only by the IronHub server, never in this repo or binary. New outbound fetches (catalog manifest + artifacts) are constrained to a closed host allowlist (hub.ironclaw.com plus GitHub release infrastructure), HTTPS-only, IP literals and internal/bare hosts rejected. Every artifact is SHA-256-verified before write. Agent-callable install tools use strict parameter schemas (regex + additionalProperties:false), per-tool rate limiting, and an approval gate; unverified community content requires an explicit acknowledgement. The deep-link path is HMAC-verified with a short replay window and a one-shot nonce; signing keys are per-user in the secrets store. No credential is exposed to tool processes.

Database Impact

None. No migrations or schema changes on the ironclaw side.

Blast Radius

Registry installer, tool registry/dispatch, and the gateway web handlers + embedded SPA. The feature is additive (new tools/endpoints/surface). The behavioural changes are: the default IronHub manifest URL, the requirement that the manifest be signed (fail-closed), and --release-tag returning a clear error instead of fetching an unsigned pinned manifest.

Rollback Plan

Revert the branch. The change is additive with no migrations and no persisted state, so reverting removes the new tools, endpoints, default-URL change, and signature requirement cleanly with no data cleanup.

Review Follow-Through

The HMAC deep-link receiver is complete and tested on this side; the matching IronHub-side "Install to Agent" sender and profile key field is a separate follow-up in the IronHub repo and not part of this PR. Iliad-sourced entries are intentionally surfaced as verified (not official); official is reserved for GitHub-release artifacts.

--release-tag (pinning to a specific GitHub release) is disabled in this PR. It fetched an unsigned manifest directly from GitHub, which fail-closed verification rejects, so it now returns a clear error pointing at the default signed catalog. No user-facing capability is lost: the default catalog path (CLI and deep-link) already installs every published tool and skill, signed. Restoring pinning cleanly means routing --release-tag through the catalog endpoint with a tag parameter so the server signs on the fly, keeping the key server-only; this is tracked as a follow-up rather than signing in CI, which would require a second copy of the private key in GitHub Actions.

Happy to adjust scope or split commits if that helps review.


Review track: C (security/runtime)

@github-actions github-actions Bot added scope: channel/cli TUI / CLI channel scope: channel/web Web gateway channel scope: tool Tool infrastructure scope: tool/builtin Built-in tools size: XL 500+ changed lines risk: medium Business logic, config, or moderate-risk modules contributor: new First-time contributor labels May 17, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the IronHub integration, enabling users to browse and install WASM tools and skills directly from the IronHub catalog via both the CLI and a new web-based interface. The implementation includes new API handlers for installation, search, and signing key management, alongside a dedicated installer registry and frontend components. Feedback highlights several critical areas for improvement: a potential Denial of Service vulnerability in artifact downloading due to missing size limits, the need for atomic file operations during tool installation to prevent inconsistent states, and logic inconsistencies in name validation across different modules. Additionally, the review identifies issues with error handling during skill registry reloads and misleading error mapping in the extension manager.

Comment thread src/registry/installer.rs Outdated

/// Download an artifact from a URL.
async fn download_artifact(url: &str) -> Result<bytes::Bytes, RegistryError> {
pub(crate) async fn download_artifact(url: &str) -> Result<bytes::Bytes, RegistryError> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-high high

The download_artifact function downloads the entire response body into memory without a size limit. This can be exploited as a Denial of Service (DoS) vector. Consider adding a max_size parameter (using u64 to ensure infallible conversions from usize on all platforms) and using response.bytes_stream() with a limit to prevent unbounded memory consumption.

References
  1. Prefer using u64 for length prefixes and size limits to ensure that conversions from usize are infallible, avoiding panics and potential DoS vectors.

Comment thread src/registry/hub_installer.rs Outdated
Comment on lines +248 to +255
fs::write(&target_wasm, wasm_bytes)
.await
.map_err(RegistryError::Io)?;
confirm_written_size(&target_wasm, wasm_bytes.len()).await?;
fs::write(&target_caps, caps_bytes)
.await
.map_err(RegistryError::Io)?;
confirm_written_size(&target_caps, caps_bytes.len()).await?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The installation of WASM tools is not atomic. If writing the capabilities file fails after the WASM file has been written, the system is left in an inconsistent state. Consider writing to temporary files and using tokio::fs::rename to ensure both files are updated atomically, adhering to async I/O practices. If implementing a cleanup mechanism on failure, ensure any secondary errors are logged as warnings so the primary error is preserved and returned.

References
  1. In async functions, use asynchronous I/O operations like tokio::fs to avoid blocking the runtime.
  2. When a primary operation fails and a subsequent cleanup fails, log the secondary failure as a warning and return the original primary error.

Comment thread src/cli/hub_install.rs Outdated
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return false;
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

There is an inconsistency between this validation and validate_hub_artifact_name in src/registry/hub_installer.rs. This function only allows hyphens, while the registry validation also allows underscores. Aligning these prevents unexpected rejections. Additionally, validation at this public API boundary must strictly check for path traversal characters (/, , .., \0) to prevent security vulnerabilities.

References
  1. Path traversal characters in extension names must be validated at the public API boundary for all operations to prevent security vulnerabilities.

Comment thread src/tools/builtin/ironhub.rs Outdated
);
poison.into_inner()
});
handle.block_on(guard.reload());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The result of guard.reload() is ignored. If the skill registry fails to reload after a new skill is installed, the tool will still report success, but the new skill won't be available to the agent. The return value of block_on should be captured and handled.

Comment thread src/tools/builtin/ironhub.rs Outdated
.extension_manager
.list(Some(ExtensionKind::WasmTool), false, &ctx.user_id)
.await
.map_err(|_| catalog_unavailable())?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The error mapping catalog_unavailable() is misleading here. extension_manager.list is a local operation on the installed extensions; its failure indicates an internal system error or a problem with the local storage, not an issue with the remote IronHub catalog.

Comment thread src/channels/web/handlers/ironhub.rs Outdated
Comment on lines +196 to +205
if store
.exists(&user.user_id, IRONHUB_SIGNING_KEY_NAME)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
{
store
.delete(&user.user_id, IRONHUB_SIGNING_KEY_NAME)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The signing key regeneration process is not atomic. If the delete operation succeeds but the subsequent create operation fails, the user will be left without any signing key. While not critical, it forces the user to manually retry. Consider if the SecretsStore supports an atomic replacement or ensure the old key is preserved until the new one is successfully created.

@neo-sky

neo-sky commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Addressed in 9d6ca674:

  • download_artifact now streams with a hard per-call size cap (content-length early-reject plus per-chunk enforcement); every caller passes its existing limit. (security-high)
  • Tool install cleans up the wasm if the capabilities write fails; a secondary cleanup failure is logged as a warning and the original error is returned.
  • looks_like_hub_name now accepts _ so the by-name path matches validate_hub_artifact_name.
  • Signing-key regeneration captures and best-effort restores the previous key if create fails.
  • The remove read-back uses a local error class instead of the catalog-unavailable message.

On the reload() comment: SkillRegistry::reload(&mut self) -> Vec<String> returns the rediscovered skill names, not a Result, so there is no error path to capture. The reload runs after the artifact is written and SHA-256-verified. Left as-is; happy to add a debug log of the reloaded count if that is preferred.

fmt and clippy (all features) are clean and the targeted tests pass.

@matiasbenary

Copy link
Copy Markdown
Contributor

Hi @neo-sky, I'm testing the Ironclaw skill and I've noticed that the command shown on the page is different from the PR. Also, this PR doesn't install the Fly skills as is
Captura de pantalla 2026-05-20 a la(s) 12 09 23 p  m
https://fly.storage.tigris.dev/iliad-storage/marketplace/projects/skills/rag-architecture/1.0.0/rag-architecture.md?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=tid_bDxLVWTuIQXzxXmQpEqrKgJjAUQcP_rtLdbRikRKIHOdaVdOOO%2F20260520%2Fauto%2Fs3%2Faws4_request&X-Amz-Date=20260520T142015Z&X-Amz-Expires=900&X-Amz-Signature=27b3a4b494bfaa6d42eb37ea677ef1c13f11a3980df314f7e001c508db669cf0&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject

And then there's this type of command that I didn't see implemented: ironclaw hub collection install agent-builder

We should standardize this, and I can help you do it, but I'm not sure what the best approach is to solve this.

@neo-sky

neo-sky commented May 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks Matias, good catches. Quick rundown on each.

The command mismatch is the actual install blocker. The CLI is currently ironclaw ironhub install, but the UI says ironclaw hub install. The hub form reads cleaner and matches what the UI already advertises, so I'll rename the CLI verb to hub and call it good. Both surfaces will say the same thing.

Same story with collection install (and loadout install is the same concept). Both exist on the IronHub side but the CLI verbs aren't built yet. I'll either ship them or strip those snippets from the UI until they are.

The Fly URL is a separate issue. The catalog manifest the CLI uses tokenizes through hub.ironclaw.com correctly, but the data path the marketplace UI uses (the public-skills endpoint) passes Iliad URLs straight through. I'll tokenize that path too so the marketplace UI shows clean hub.ironclaw.com URLs everywhere and nothing raw leaks out.

All three sit on the IronHub side, not in this PR's diff. I'll get the cleanup in and ping you when there's something fresh to retest.

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Reviewed PR #3737 at 9d6ca674163663b2522ed5a0d529792c9b84d9a5 with the multi-agent code-review skill. The diff was larger than the reviewer bundle cap, so reviewers used the detached worktree for full file context and line verification.

Outcome: REQUEST_CHANGES

Findings kept after aggregation: 13

  • Security/supply-chain: 5
  • Bugs/behavior: 3
  • Performance/concurrency: 2
  • Tests: 5
  • Conventions/docs: 1

Highest-priority issues are the manifest re-fetch after the community-content gate, the CLI install path bypassing that gate, ineffective rate limiting on gateway-dispatched IronHub fetches, and missing caller-facing tests for the new signing-key/deep-link/agent-tool paths.

Reviewers failed: none.

Comment thread src/tools/builtin/ironhub.rs Outdated

match kind {
HubEntryKind::Tool => {
let outcome = installer

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The install gate validates one live manifest, but this call fetches the manifest again inside install_tool_by_name / install_skill_by_name. Since the default catalog is now live and mutable, the artifact/provenance installed can differ from the entry that passed acknowledge_unverified, which can bypass the community-content gate. Install from the already-vetted manifest with install_tool_from_manifest / install_skill_from_manifest instead.

Comment thread src/cli/hub.rs
match kind {
Kind::Tool => {
println!("Installing tool '{}' from IronHub...", name);
let outcome = installer

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The CLI install path classifies the entry and immediately installs it without checking provenance.is_community_unverified() or requiring an explicit acknowledgement flag. That leaves ironclaw ironhub install less protected than the agent-callable tool for the same remote supply-chain operation. Please require a dedicated acknowledgement for community/unverified entries and install from the same checked manifest.

Comment thread src/channels/web/handlers/ironhub.rs Outdated
if let Some(tag) = req.release_tag {
params.insert("release_tag".into(), serde_json::Value::String(tag));
}
params.insert("force".into(), serde_json::Value::Bool(req.force));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The gateway request never forwards acknowledge_unverified, and the DTO rejects unknown fields, so web/deep-link installs of community entries cannot complete even after the user explicitly accepts the risk. Add acknowledge_unverified to the request/UI path and forward it into ironhub_install.

Comment thread src/tools/builtin/ironhub.rs Outdated
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9-]*$",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This schema rejects underscores, but validate_hub_name and installer tests accept names like microsoft_365. Because dispatcher schema validation runs before execute, valid catalog entries can work through CLI/installer paths but fail through agent and gateway dispatch. Align the schema with the shared validator or tighten the shared validator to match.

serde_json::Value::Object(obj)
}

fn tool_entry_json(entry: &HubToolEntry) -> serde_json::Value {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Search/list JSON rows omit provenance even though the manifest carries it and install output surfaces it. Agent and gateway catalog browsing can therefore present community entries without the required trust/community label until a later info/install step. Include provenance and an unverified/trust label in these row helpers, and mirror the label in CLI list/search output.

return;
}

apiFetch('/api/ironhub/verify-intent', {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The new deep-link install route has no browser/e2e coverage for hash parsing, verify-intent, info fetch, and confirm POST behavior. Handler tests will not catch regressions in this user-visible flow. Add an e2e/browser test that drives #/install/<name>?ts=<ts>&sig=<sig>, including invalid links and successful confirm.

Comment thread src/tools/builtin/ironhub.rs Outdated
}
.unwrap_or_default();

if provenance.is_community_unverified() && !acknowledge_unverified {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The actual ironhub_install execution path has no test proving community content is rejected unless acknowledge_unverified=true. Existing coverage exercises helper output/schema behavior, but not the side-effecting tool call that gates installation. Add a tool execution test for a Provenance::New entry with both false and true acknowledgement values.

})
}

async fn execute(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ironhub_remove is a new side-effecting agent-callable tool, but no adjacent test calls its execute path. Please add coverage for a successful remove and for the still-present verification failure so name validation, extension-manager error mapping, and post-remove verification cannot regress silently.

Comment thread src/tools/registry.rs
tracing::debug!("Registered 4 skill management tools");
}

pub fn register_ironhub_tools(&self, deps: crate::tools::builtin::IronhubDeps) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This registration function is the composition point that makes the five IronHub built-ins available, but registry tests do not assert the resulting tool names. Add a registry test that calls register_ironhub_tools and verifies ironhub_install, ironhub_remove, ironhub_search, ironhub_list, and ironhub_info are registered.

axum::routing::delete(skills_remove_handler),
)
// IronHub catalog
.route("/api/ironhub/install", post(ironhub_install_handler))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This adds a new /api/ironhub/* route family, but src/channels/web/CLAUDE.md still has no IronHub handler entry or route table coverage. The repo rules require behavior changes to update the relevant subsystem docs/specs in the same branch. Please document the handler file and install/search/list/info/signing-key/verify-intent routes there.

Adds the IronHub install pipeline end-to-end: registry-driven install of WASM tools and skills by name with provenance gating and TOCTOU-safe writes, agent-callable install/search/list/info/remove tools, gateway routes for the same surface plus a Settings UI for the shared signing key, HMAC-verified deep-link install flow, and a CLI namespace under `ironclaw hub`. Closes the Firat review cluster (caller-level Provenance::New test, ironhub_remove still-present failure, ack flow forwarded through gateway and UI, schema regex aligned with the shared validator, signing-key handler tests, deep-link Playwright e2e, web/CLAUDE.md docs).
@neo-sky
neo-sky force-pushed the feat/tool-registry-installer branch from 9d6ca67 to 463c73e Compare May 21, 2026 02:43
@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) scope: channel Channel infrastructure scope: channel/wasm WASM channel runtime scope: tool/wasm WASM tool sandbox scope: tool/mcp MCP client scope: tool/builder Dynamic tool builder scope: db Database trait / abstraction scope: db/postgres PostgreSQL backend scope: workspace Persistent memory / workspace scope: orchestrator Container orchestrator scope: worker Container worker scope: config Configuration scope: extensions Extension management scope: setup Onboarding / setup scope: hooks Git/event hooks scope: pairing Pairing mode labels May 21, 2026
@github-actions github-actions Bot added scope: docs Documentation scope: dependencies Dependency updates DB MIGRATION PR adds or modifies PostgreSQL or libSQL migration definitions risk: high Safety, secrets, auth, or critical infrastructure and removed risk: medium Business logic, config, or moderate-risk modules labels May 21, 2026
@matiasbenary

Copy link
Copy Markdown
Contributor

Thanks Matias, good catches. Quick rundown on each.

The command mismatch is the actual install blocker. The CLI is currently ironclaw ironhub install, but the UI says ironclaw hub install. The hub form reads cleaner and matches what the UI already advertises, so I'll rename the CLI verb to hub and call it good. Both surfaces will say the same thing.

Same story with collection install (and loadout install is the same concept). Both exist on the IronHub side but the CLI verbs aren't built yet. I'll either ship them or strip those snippets from the UI until they are.

The Fly URL is a separate issue. The catalog manifest the CLI uses tokenizes through hub.ironclaw.com correctly, but the data path the marketplace UI uses (the public-skills endpoint) passes Iliad URLs straight through. I'll tokenize that path too so the marketplace UI shows clean hub.ironclaw.com URLs everywhere and nothing raw leaks out.

All three sit on the IronHub side, not in this PR's diff. I'll get the cleanup in and ping you when there's something fresh to retest.

The Fly URL is a separate issue. The catalog manifest the CLI uses tokenizes through hub.ironclaw.com correctly, but the data path the marketplace UI uses (the public-skills endpoint) passes Iliad URLs straight through. I'll tokenize that path too so the marketplace UI shows clean hub.ironclaw.com URLs everywhere and nothing raw leaks out.

All three sit on the IronHub side, not in this PR's diff. I'll get the cleanup in and ping you when there's something fresh to retest.

Thank you very much. I have one last question: how do you recommend I upload updates to the skills on IronHub, such as this one:
https://hub.ironclaw.com/marketplace/iliad-WyJpbGlhZC1zZWVkIiwidWktdXgtYWR2aXNvciIsIjEuMC4wIl0

Should I create an issue and a pull request on IronHub? Or how else could I do it?
The thing is, they’re hosted on Fly and Iliad.codes

Skill installs now read back the registry after reload and report whether the skill actually loaded, instead of reloading blind. Checksum comparison is case-insensitive so a hash that differs only in casing no longer fails every install, and release tags containing '..' are rejected outright. Adds regression tests for each.
@neo-sky
neo-sky changed the base branch from staging to main May 21, 2026 23:22
@neo-sky

neo-sky commented May 21, 2026

Copy link
Copy Markdown
Contributor Author

@serrrfirat retargeted this to main as you asked. Addressed the full review pass. The skill-install path now reads back the registry after reload, checksum verify is case-insensitive, and release-tag validation rejects traversal. Green on fmt/clippy/tests locally. Ready for another look when you have a moment.

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Multi-Agent Code Review

Reviewers: security, bugs, performance/concurrency, tests, conventions
Verdict: ❌ Request Changes

Stats

Severity Count
High 7
Medium 8
Reviewer Findings
tests 5
performance 5
security 3
bugs 2

Security

[High] Deep-link verification is decoupled from install — admin can install without HMAC proof (confidence: 85%)

File: src/channels/web/handlers/ironhub.rs:117-145

The verify-intent endpoint validates the HMAC signature and records a nonce, then returns {valid:true}. The browser subsequently calls /api/ironhub/install in a completely separate, unlinked request. The install handler only requires AdminUser auth and dispatches directly to the ironhub_install tool — it never re-checks a verification token or proves the caller went through verify-intent. Any admin session can POST /api/ironhub/install with arbitrary names and acknowledge_unverified=true, bypassing the deep-link HMAC gate entirely. The two-step flow (verify then install) provides UX but no security binding.

Fix: Return a short-lived, single-use token from verify-intent; require it in the install request and validate server-side before dispatching.

[Medium] Signing key set handler leaks old key plaintext during error-path restore (confidence: 72%)

File: src/channels/web/handlers/ironhub.rs:204-217

In ironhub_signing_key_set_handler, after deleting the old key and failing to create the new one, the handler attempts to restore the old key. The old key was retrieved as plaintext via get_decrypted().expose().to_string() and stored in the previous variable. If both create and restore fail, only a tracing::warn is emitted. More critically, the old key material lives as a String on the stack longer than necessary and could appear in core dumps.

Fix: Use secrecy::SecretString for the previous key material and zeroize on drop. Minimize the window where the old key is in cleartext.

Also flagged by: tests

[Medium] HMAC uses raw shared-key UTF-8 bytes as MAC key — low-entropy keys accepted (confidence: 70%)

File: src/channels/web/handlers/ironhub.rs:241-248

hmac_hex uses shared_key.as_bytes() directly as the HMAC key. The validate_shared_key function only requires a minimum length of 32 characters and the ihub_sk_ prefix. A key like ihub_sk_aaaaaaaaaaaaaaaaaaaaaaaaa (8 prefix + 24 'a's = 32 chars) has extremely low entropy. HMAC-SHA256's security relies on key entropy; with a predictable key, an attacker can forge install signatures.

Fix: Enforce minimum entropy (e.g., reject keys that are all the same character, or require at least N distinct characters), or derive the HMAC key from the shared key via HKDF to normalize entropy.

Bugs

[High] Signing key set/get/delete allows any authenticated user (confidence: 75%)

File: src/channels/web/handlers/ironhub.rs:266-269

ironhub_signing_key_set_handler, _get_handler, and _delete_handler accept AuthenticatedUser while ironhub_install_handler requires AdminUser. Any non-admin authenticated user can set/replace/revoke the signing key that controls deep-link install verification, potentially disabling HMAC verification for an admin's install flow by deleting or replacing the key. The signing key is per-user so one user cannot directly affect another's key, but a compromised regular account can manipulate its own signing key to forge valid deep-link installs.

Fix: Change AuthenticatedUser to AdminUser for all three signing-key handlers, or document that per-user keys are intentional.

Also flagged by: performance

[Medium] install_tool_entry downloads before checking AlreadyInstalled (confidence: 72%)

File: src/registry/hub_installer.rs:193-199

install_tool_entry downloads both wasm and capabilities artifacts before calling install_tool_from_bytes, which only then acquires the lock and checks if target_wasm.exists(). For force=false installs of already-installed tools, this wastes network bandwidth and time downloading artifacts that will never be written. The same issue exists in install_skill_entry.

Fix: Move the already-installed check (or at least an early-exit probe) before download_artifact calls in install_tool_entry and install_skill_entry.

Performance

[High] INSTALL_LOCKS HashMap grows without bound — Arc entries never removed (confidence: 90%)

File: src/registry/hub_installer.rs:17-27

acquire_install_lock inserts an Arc<AsyncMutex<()>> per tool/skill name but never removes entries. A long-lived process installing many distinct names (or a tool name generator) causes unbounded HashMap growth, leaking the mutex + key string forever.

Fix: Add cleanup after install completes (drop + remove if Arc refcount==1), or use an LRU/weak-ref map.

Also flagged by: security, performance

[High] Nonce cache prune+sort under global Mutex blocks all verify-intent requests (confidence: 85%)

File: src/channels/web/handlers/ironhub.rs:22-46

nonce_seen_or_record holds a global std::sync::Mutex while doing O(n) retain, O(n log n) sort, and O(n/4) removes when over capacity. Under load, every verify-intent and register call serializes on this lock, and the eviction path (sort+drain) can take milliseconds with 16K entries, stalling all concurrent HMAC verifications.

Fix: Replace with DashMap or RwLock so reads/writes to different keys proceed concurrently; move eviction to a background task or use an LRU crate with O(1) eviction.

Also flagged by: tests, security, performance, conventions, performance, conventions, security, bugs

[Medium] Every catalog tool call fetches manifest from remote — no caching (confidence: 80%)

File: src/tools/builtin/ironhub.rs:254-260

IronhubInstallTool::execute, IronhubSearchTool, IronhubListTool, and IronhubInfoTool all call fetch_manifest() on every invocation. Under sustained agent-driven catalog queries (e.g. LLM iterating search→info→install), this produces one HTTPS GET per tool call with no TTL or in-memory cache. Multiple concurrent agent turns amplify this.

Fix: Add a time-based cached manifest (e.g. Arc<RwLock<Option<(Instant, HubManifest)>>>) with a TTL of ~60s shared across all four tools.

[Medium] Skill registry reload spawns blocking task holding RwLock write via unwrap_or_else (confidence: 80%)

File: src/tools/builtin/ironhub.rs:284-296

In IronhubInstallTool::install_from_manifest (skill branch), a tokio::task::spawn_blocking call acquires an std::sync::RwLock write guard and then calls handle.block_on(guard.reload()). If reload() is slow or panics, the std::sync::RwLock is poisoned for all future skill registry access. Also, handle.block_on inside spawn_blocking can deadlock if the runtime is saturated.

Fix: Use tokio::sync::RwLock instead of std::sync::RwLock for the skill registry so reload can be awaited directly without spawn_blocking + block_on.

[Medium] AlreadyInstalled check via exists() then write is TOCTOU within the async mutex (confidence: 75%)

File: src/registry/hub_installer.rs:208-215

target_wasm.exists() is checked under the per-name async mutex, but verify_sha256 + write happen after the check. If two install calls for the same name arrive and the first fails after writing the wasm (due to caps write failure), the second may also see AlreadyInstalled from a partial/leftover file. The lock serializes, but the exists() check only guards the happy path.

Fix: Use a rename-from-temp pattern exclusively; check existence of the temp target rather than the final path, or track install state in the lock guard.

Tests

[High] verify-intent returns valid=false when no signing key configured (confidence: 90%)

File: src/channels/web/handlers/ironhub.rs:394-400

ironhub_verify_intent_handler returns {valid: false, reason: 'no signing key configured'} when the user has no signing key. Only the signing-key GET 404 path is tested, but the verify-intent-specific 'no signing key' soft-failure path (returns 200 with valid=false instead of an HTTP error) has no test. This is a distinct security-sensitive path that could regress to a 500 or leak info.

Fix: tests::ironhub::verify_intent_returns_invalid_when_no_signing_key

Also flagged by: conventions, tests, tests

[High] register returns 503 when no signing key configured (confidence: 90%)

File: src/channels/web/handlers/ironhub.rs:458-464

ironhub_register_handler returns 503 SERVICE_UNAVAILABLE with 'no signing key configured on this agent' when the user lacks a signing key. This is distinct from the verify-intent soft-failure and has no test. A regression here would break the IronHub registration handshake silently.

Fix: tests::ironhub::register_rejects_when_no_signing_key_configured

[High] register rejects replayed nonce with 409 CONFLICT (confidence: 88%)

File: src/channels/web/handlers/ironhub.rs:475-482

nonce_seen_or_record is called in ironhub_register_handler and returns 409 CONFLICT on nonce replay. The verify-intent handler's nonce replay is tested, but the register handler's nonce replay path is not. Both handlers share the same global NONCE_CACHE but use different payload formats, so register's nonce rejection is independent and untested.

Fix: tests::ironhub::register_rejects_replayed_nonce_with_409

[Medium] signing-key-set replace-with-existing-key path untested (confidence: 78%)

File: src/channels/web/handlers/ironhub.rs:286-322

ironhub_signing_key_set_handler reads and deletes the existing key before creating the new one. When create fails, it attempts to restore the old key. Neither the success path (replace existing) nor the failure-restore path is tested. The existing tests only cover setting a key when none exists. A regression in the replace logic could silently lose the user's signing key.

Fix: tests::ironhub::signing_key_set_replaces_existing_key

Also flagged by: conventions, bugs, security

[Medium] tool_error_to_http untested branches: Timeout, RateLimited, Sandbox (confidence: 75%)

File: src/channels/web/handlers/ironhub.rs:70-88

tool_error_to_http maps ToolError::Timeout -> 504, RateLimited -> 429, Sandbox -> 500, ExternalService -> 502. Only InvalidParameters and ExecutionFailed are exercised indirectly via the dispatch stub. The Timeout, RateLimited, Sandbox, and ExternalService branches have no unit test. A typo in the match (e.g. swapped status codes) would go undetected.

Fix: tests::ironhub::tool_error_to_http_maps_each_variant

Comment thread src/channels/web/handlers/ironhub.rs Outdated
.get_decrypted(&user.user_id, IRONHUB_SIGNING_KEY_NAME)
.await
{
Ok(s) => s,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[High] verify-intent returns valid=false when no signing key configured (confidence: 90%, reviewer: tests)

ironhub_verify_intent_handler returns {valid: false, reason: 'no signing key configured'} when the user has no signing key. Only the signing-key GET 404 path is tested, but the verify-intent-specific 'no signing key' soft-failure path (returns 200 with valid=false instead of an HTTP error) has no test. This is a distinct security-sensitive path that could regress to a 500 or leak info.

Fix: tests::ironhub::verify_intent_returns_invalid_when_no_signing_key

#[tokio::test]
async fn verify_intent_returns_invalid_when_no_signing_key() {
    let state = TestGatewayBuilder::new()
        .user_id("test-user")
        .build();
    let app = Router::new()
        .route("/api/ironhub/verify-intent", post(ironhub_verify_intent_handler))
        .with_state(state);
    let body = serde_json::json!({"slug":"x","version":"1","uid":"u","aid":"a","ts":now_unix(),"nonce":"n","sig":"00".repeat(32)});
    let req = verify_req(body);
    let resp = oneshot(app, req).await;
    assert_eq!(resp.status(), StatusCode::OK);
    let json = body_json(resp).await;
    assert_eq!(json["valid"], false);
    assert!(json["reason"].as_str().unwrap().contains("no signing key"));
}

Also flagged by: conventions, tests, tests

let decrypted = match store
.get_decrypted(&user.user_id, IRONHUB_SIGNING_KEY_NAME)
.await
{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[High] register returns 503 when no signing key configured (confidence: 90%, reviewer: tests)

ironhub_register_handler returns 503 SERVICE_UNAVAILABLE with 'no signing key configured on this agent' when the user lacks a signing key. This is distinct from the verify-intent soft-failure and has no test. A regression here would break the IronHub registration handshake silently.

Fix: tests::ironhub::register_rejects_when_no_signing_key_configured

#[tokio::test]
async fn register_rejects_when_no_signing_key_configured() {
    let state = TestGatewayBuilder::new().user_id("test-user").build();
    let app = Router::new()
        .route("/api/ironhub/register", post(ironhub_register_handler))
        .with_state(state);
    let body = serde_json::json!({"uid":"u","aid":"a","ts":now_unix(),"nonce":"n","sig":"00".repeat(32)});
    let resp = oneshot(app, register_req(body)).await;
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}


const MAX_MANIFEST_BYTES: usize = 1024 * 1024;
const MAX_METADATA_BYTES: usize = 1024 * 1024;
const MAX_WASM_BYTES: usize = 16 * 1024 * 1024;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[High] INSTALL_LOCKS HashMap grows without bound — Arc entries never removed (confidence: 90%, reviewer: performance)

acquire_install_lock inserts an Arc<AsyncMutex<()>> per tool/skill name but never removes entries. A long-lived process installing many distinct names (or a tool name generator) causes unbounded HashMap growth, leaking the mutex + key string forever.

Fix: Add cleanup after install completes (drop + remove if Arc refcount==1), or use an LRU/weak-ref map.

let lock = acquire_install_lock(&key);
let guard = lock.lock().await;
let result = { ... };
let mut map = INSTALL_LOCKS.lock().unwrap();
if let Some(existing) = map.get(&key) {
  if Arc::strong_count(existing) == 1 { map.remove(&key); }
}
result

Also flagged by: security, performance


use subtle::ConstantTimeEq;
let supplied = req.sig.as_bytes();
let sig_valid: bool = expected.as_bytes().ct_eq(supplied).into();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[High] register rejects replayed nonce with 409 CONFLICT (confidence: 88%, reviewer: tests)

nonce_seen_or_record is called in ironhub_register_handler and returns 409 CONFLICT on nonce replay. The verify-intent handler's nonce replay is tested, but the register handler's nonce replay path is not. Both handlers share the same global NONCE_CACHE but use different payload formats, so register's nonce rejection is independent and untested.

Fix: tests::ironhub::register_rejects_replayed_nonce_with_409

#[tokio::test]
async fn register_rejects_replayed_nonce_with_409() {
    let (app, _, _, ts) = verify_app().await;
    let nonce = uuid::Uuid::new_v4().to_string();
    let payload = register_payload("u1", "a1", ts, &nonce);
    let sig = hmac_hex(TEST_SHARED_KEY, &payload).unwrap();
    let body = serde_json::json!({"uid":"u1","aid":"a1","ts":ts,"nonce":nonce,"sig":sig});
    let first = oneshot(app.clone(), register_req(body.clone())).await;
    assert_eq!(first.status(), StatusCode::OK);
    let replay = oneshot(app, register_req(body)).await;
    assert_eq!(replay.status(), StatusCode::CONFLICT);
}

State(state): State<Arc<GatewayState>>,
AdminUser(user): AdminUser,
Json(req): Json<IronhubInstallRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[High] Deep-link verification is decoupled from install — admin can install without HMAC proof (confidence: 85%, reviewer: security)

The verify-intent endpoint validates the HMAC signature and records a nonce, then returns {valid:true}. The browser subsequently calls /api/ironhub/install in a completely separate, unlinked request. The install handler only requires AdminUser auth and dispatches directly to the ironhub_install tool — it never re-checks a verification token or proves the caller went through verify-intent. Any admin session can POST /api/ironhub/install with arbitrary names and acknowledge_unverified=true, bypassing the deep-link HMAC gate entirely. The two-step flow (verify then install) provides UX but no security binding.

Fix: Return a short-lived, single-use token from verify-intent; require it in the install request and validate server-side before dispatching.

// verify-intent returns: { valid: true, install_token: "<random>" }
// install handler checks: token is valid, not used, and matches the slug
// Single-use token stored in NONCE_CACHE or a separate map

Ok(())
}

fn tool_error_to_http(err: ToolError) -> (StatusCode, String) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Medium] tool_error_to_http untested branches: Timeout, RateLimited, Sandbox (confidence: 75%, reviewer: tests)

tool_error_to_http maps ToolError::Timeout -> 504, RateLimited -> 429, Sandbox -> 500, ExternalService -> 502. Only InvalidParameters and ExecutionFailed are exercised indirectly via the dispatch stub. The Timeout, RateLimited, Sandbox, and ExternalService branches have no unit test. A typo in the match (e.g. swapped status codes) would go undetected.

Fix: tests::ironhub::tool_error_to_http_maps_each_variant

#[test]
fn tool_error_to_http_maps_each_variant() {
    assert_eq!(tool_error_to_http(ToolError::Timeout(Duration::from_secs(5))).0, StatusCode::GATEWAY_TIMEOUT);
    assert_eq!(tool_error_to_http(ToolError::RateLimited(None)).0, StatusCode::TOO_MANY_REQUESTS);
    assert_eq!(tool_error_to_http(ToolError::ExternalService("x".into())).0, StatusCode::BAD_GATEWAY);
    assert_eq!(tool_error_to_http(ToolError::Sandbox("x".into())).0, StatusCode::INTERNAL_SERVER_ERROR);
    assert_eq!(tool_error_to_http(ToolError::NotAuthorized("x".into())).0, StatusCode::UNAUTHORIZED);
}

force: bool,
) -> Result<HubInstallOutcome, RegistryError> {
validate_skill_entry(entry)?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Medium] AlreadyInstalled check via exists() then write is TOCTOU within the async mutex (confidence: 75%, reviewer: performance)

target_wasm.exists() is checked under the per-name async mutex, but verify_sha256 + write happen after the check. If two install calls for the same name arrive and the first fails after writing the wasm (due to caps write failure), the second may also see AlreadyInstalled from a partial/leftover file. The lock serializes, but the exists() check only guards the happy path.

Fix: Use a rename-from-temp pattern exclusively; check existence of the temp target rather than the final path, or track install state in the lock guard.

params.insert("name".into(), serde_json::Value::String(q.name));
if let Some(tag) = q.release_tag {
params.insert("release_tag".into(), serde_json::Value::String(tag));
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Medium] Signing key set handler leaks old key plaintext during error-path restore (confidence: 72%, reviewer: security)

In ironhub_signing_key_set_handler, after deleting the old key and failing to create the new one, the handler attempts to restore the old key. The old key was retrieved as plaintext via get_decrypted().expose().to_string() and stored in the previous variable. If both create and restore fail, only a tracing::warn is emitted. More critically, the old key material lives as a String on the stack longer than necessary and could appear in core dumps.

Fix: Use secrecy::SecretString for the previous key material and zeroize on drop. Minimize the window where the old key is in cleartext.

Also flagged by: tests

) -> Result<HubInstallOutcome, RegistryError> {
validate_tool_entry(entry)?;

let wasm_bytes = download_artifact(&entry.wasm.url, MAX_WASM_BYTES as u64).await?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Medium] install_tool_entry downloads before checking AlreadyInstalled (confidence: 72%, reviewer: bugs)

install_tool_entry downloads both wasm and capabilities artifacts before calling install_tool_from_bytes, which only then acquires the lock and checks if target_wasm.exists(). For force=false installs of already-installed tools, this wastes network bandwidth and time downloading artifacts that will never be written. The same issue exists in install_skill_entry.

Fix: Move the already-installed check (or at least an early-exit probe) before download_artifact calls in install_tool_entry and install_skill_entry.

pub async fn install_tool_entry(&self, entry: &HubToolEntry, release_tag: &str, force: bool) -> Result<HubInstallOutcome, RegistryError> {
    validate_tool_entry(entry)?;
    let target_wasm = self.tools_dir.join(format!("{}.wasm", entry.name));
    if target_wasm.exists() && !force {
        return Err(RegistryError::AlreadyInstalled { name: entry.name.clone(), path: target_wasm });
    }
    let wasm_bytes = download_artifact(&entry.wasm.url, MAX_WASM_BYTES as u64).await?;
    let caps_bytes = download_artifact(&entry.capabilities.url, MAX_METADATA_BYTES as u64).await?;
    self.install_tool_from_bytes(entry, release_tag, &wasm_bytes, &caps_bytes, force).await
}

.unwrap_or(0)
}

fn hmac_hex(shared_key: &str, msg: &str) -> Result<String, String> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Medium] HMAC uses raw shared-key UTF-8 bytes as MAC key — low-entropy keys accepted (confidence: 70%, reviewer: security)

hmac_hex uses shared_key.as_bytes() directly as the HMAC key. The validate_shared_key function only requires a minimum length of 32 characters and the ihub_sk_ prefix. A key like ihub_sk_aaaaaaaaaaaaaaaaaaaaaaaaa (8 prefix + 24 'a's = 32 chars) has extremely low entropy. HMAC-SHA256's security relies on key entropy; with a predictable key, an attacker can forge install signatures.

Fix: Enforce minimum entropy (e.g., reject keys that are all the same character, or require at least N distinct characters), or derive the HMAC key from the shared key via HKDF to normalize entropy.

Re-verify the signed deep-link HMAC and consume its nonce at the install boundary, gate signing-key set/get/delete behind admin, replace delete-then-restore with an atomic key upsert, and generate shared keys server-side with an entropy floor. Bound the install lock map, move the nonce cache to a FIFO VecDeque, add a TTL manifest cache, isolate skill reloads on a dedicated runtime, and make installs atomic with write-temp plus rename. Adds regression tests covering the install, nonce, key, admin, manifest-cache, remove, registry, and CLI paths.
@github-actions github-actions Bot added risk: medium Business logic, config, or moderate-risk modules and removed risk: high Safety, secrets, auth, or critical infrastructure labels May 23, 2026
neo-sky added 2 commits May 23, 2026 12:09
The gateway now forwards the signed version into ironhub_install, and install_from_manifest refuses to install unless the current catalog entry matches it, so a catalog roll inside the freshness window cannot swap the artifact out from under a signed intent. ironhub_info now emits trust_label via shared info_tool_json/info_skill_json helpers so the deep-link confirm renders the same trust tier as search and list. Adds regression tests for version forward, match, mismatch, and the info trust_label output.
The signed deep-link now carries an artifact_digest, so an install is tied to the exact artifact, not just the version. The agent folds it into the HMAC, recomputes it from the manifest before installing, and bails on mismatch, which catches a same-version content swap. Rust and TS share fixed test vectors so the hash can't drift.
neo-sky added 6 commits May 24, 2026 02:07
IronHub calls it server-to-server, so the session gate rejected every handshake.
Was a one-off card, now a normal settings row.
The consent-card buttons and the signing-key form used inline onclick/onsubmit handlers that the gateway CSP silently blocks, so Save and the install and cancel buttons did nothing. Moved them to addEventListener bound after render, and fixed the install card rendering bottom-left and leaking across tab switches where an #tab-install rule was overriding the .tab-panel display via ID specificity.
Adds IRONHUB_MANIFEST_URL and IRONHUB_EXTRA_ARTIFACT_HOSTS overrides so the agent can install against staging or a partner catalog without recompiling, both falling back to the production defaults and still validated against the host allowlist. Also logs the underlying RegistryError before the generic catalog-unavailable mapping so operators can tell a 404 from a timeout. Tests cover URL resolution, host parsing, and allowlist-with-extras.
Provenance defaults to New (unverified) instead of Official, so an unknown or absent tier warns instead of installing silently. The artifact-host check now rejects IP literals, loopback, internal DNS, and bare names for every host, including the env-supplied extra hosts.
The agent now requires an Ed25519-signed manifest envelope from the catalog and verifies it against an embedded public key, fail-closed: an unsigned or tampered manifest is rejected before any artifact is fetched, and the private signing key lives only on the IronHub server. --release-tag is disabled because pinning to a GitHub release fetches an unsigned manifest; pinned installs return a clear error pointing at the default signed catalog.
@neo-sky

neo-sky commented May 29, 2026

Copy link
Copy Markdown
Contributor Author

@serrrfirat ready for another look when you get a chance, and thanks again for taking the time on this one.

The main change since your May 22 review: the catalog manifest is now signed. IronHub returns an Ed25519 envelope and the agent verifies it against an embedded public key before using it, so an unsigned or tampered manifest is rejected before any artifact is fetched. The private key stays on the IronHub server and never lands in this repo or the binary, and a shared test vector keeps the Node signer and the Rust verifier checked against the same bytes. One consequence: --release-tag is disabled, since it pulled an unsigned manifest straight from GitHub. It now returns a clear error pointing at the default catalog, and the PR body notes how we'd restore pinning through the catalog endpoint later.

Also tightened since you last looked: provenance defaults to the least-trusted tier when the field is missing, the artifact-host check rejects IP literals, localhost, and internal or bare hostnames (HTTPS-only, closed allowlist), signed installs are bound to version and artifact digest with a one-shot nonce, and /register is HMAC-authed rather than session-gated.

Validation: fmt and the full clippy gate are clean, and cargo test --lib passes (5205, 0 failed) including the new signature tests and the release-tag rejection. I also ran it against staging end to end, the agent rejected the unsigned manifest first, then once signing was live it installed all seven published tools from the CLI and one through the deep-link receiver.

One flag before merge: verification is fail-closed and the default catalog is prod (hub.ironclaw.com), which isn't signing yet, only staging is. We'll deploy prod with the signing key before this ships so agents on the default URL don't reject the unsigned prod manifest.

neo-sky added 2 commits June 3, 2026 12:41
…ationale

MANIFEST_VERIFY_KEYS now has the rotation procedure inline above the slice (add new key, deploy signer, drop old key in a follow-up release). The skill-registry reload in ironhub_install carries a short comment explaining why it stays on std::sync::RwLock and uses spawn_blocking plus a fresh current-thread runtime plus poison recovery rather than migrating the repo-wide lock type.

[skip-regression-check]
Replaces nine INTERNAL_SERVER_ERROR mappings that passed raw SecretsStore / HMAC error strings through to the user; the new internal_err helper logs the detail via tracing::error! and surfaces a generic operation message per the channel-boundary rule. Adds assert_no_delimiter at the verify_signed_install and register entry points to reject any payload field containing ':' so future fields admitting colons cannot create a canonical-serialization ambiguity. Three regression tests cover the helper, the delimiter check, and verify_signed_install through the caller.
@serrrfirat

Copy link
Copy Markdown
Collaborator

there is a reborn version of this as a separate PR now.

@serrrfirat serrrfirat closed this Jun 27, 2026
@neo-sky

neo-sky commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author

Yeah, that's #4479, I'm tracking it. I've been building on that branch: ported the deep-link register/install gateway onto the Reborn surface and added the private-manifest install path, with agent-side e2e passing against staging. I'll open it as a sub-PR into codex/ironhub-reborn-port once it's through my own review, and tag you then.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: new First-time contributor DB MIGRATION PR adds or modifies PostgreSQL or libSQL migration definitions risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel/cli TUI / CLI channel scope: channel/wasm WASM channel runtime scope: channel/web Web gateway channel scope: channel Channel infrastructure scope: ci CI/CD workflows scope: config Configuration scope: db/postgres PostgreSQL backend scope: db Database trait / abstraction scope: dependencies Dependency updates scope: docs Documentation scope: extensions Extension management scope: hooks Git/event hooks scope: orchestrator Container orchestrator scope: pairing Pairing mode scope: setup Onboarding / setup scope: tool/builder Dynamic tool builder scope: tool/builtin Built-in tools scope: tool/mcp MCP client scope: tool/wasm WASM tool sandbox scope: tool Tool infrastructure scope: worker Container worker scope: workspace Persistent memory / workspace size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants