From 463c73e25b2208a9d81d0b157fe4a56a8b23bf7d Mon Sep 17 00:00:00 2001 From: neo-sky Date: Wed, 20 May 2026 19:43:50 -0700 Subject: [PATCH 01/13] feat(ironhub): install pipeline, deep-link signing keys, gateway routes 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). --- crates/ironclaw_gateway/src/assets.rs | 4 + crates/ironclaw_gateway/static/i18n/en.js | 36 + crates/ironclaw_gateway/static/index.html | 4 + .../static/js/core/routing.js | 17 + .../static/js/surfaces/install.js | 179 ++ .../static/js/surfaces/settings.js | 86 + .../static/styles/surfaces/install.css | 49 + src/app.rs | 7 + src/channels/web/CLAUDE.md | 25 +- src/channels/web/features/settings/mod.rs | 2 + src/channels/web/handlers/ironhub.rs | 1499 +++++++++++++++ src/channels/web/handlers/memory.rs | 1 + src/channels/web/handlers/mod.rs | 1 + src/channels/web/mod.rs | 2 + src/channels/web/platform/router.rs | 21 + src/channels/web/platform/state.rs | 4 + src/channels/web/platform/ws.rs | 2 + src/channels/web/test_helpers.rs | 37 +- src/channels/web/tests/multi_tenant.rs | 2 + src/channels/web/types.rs | 78 + src/cli/hub.rs | 581 ++++++ src/cli/hub_install.rs | 124 ++ src/cli/mod.rs | 11 + src/cli/skills.rs | 312 +++- ...li__tests__help_output_without_import.snap | 2 + ...ests__long_help_output_without_import.snap | 2 + src/cli/tool.rs | 9 +- src/main.rs | 4 + src/registry/hub_installer.rs | 1155 ++++++++++++ src/registry/hub_manifest.rs | 234 +++ src/registry/installer.rs | 88 +- src/registry/mod.rs | 6 + src/tools/builtin/extension_tools.rs | 2 +- src/tools/builtin/ironhub.rs | 1617 +++++++++++++++++ src/tools/builtin/mod.rs | 5 + src/tools/registry.rs | 46 + tests/cross_tenant_resource_isolation.rs | 1 + .../test_ironhub_deep_link_install.py | 186 ++ tests/multi_tenant_integration.rs | 3 + tests/oauth_greeting_integration.rs | 1 + tests/openai_compat_integration.rs | 4 + tests/support/gateway_workflow_harness.rs | 1 + tests/thread_isolation_integration.rs | 1 + tests/ws_gateway_integration.rs | 2 + 44 files changed, 6424 insertions(+), 29 deletions(-) create mode 100644 crates/ironclaw_gateway/static/js/surfaces/install.js create mode 100644 crates/ironclaw_gateway/static/styles/surfaces/install.css create mode 100644 src/channels/web/handlers/ironhub.rs create mode 100644 src/cli/hub.rs create mode 100644 src/cli/hub_install.rs create mode 100644 src/registry/hub_installer.rs create mode 100644 src/registry/hub_manifest.rs create mode 100644 src/tools/builtin/ironhub.rs create mode 100644 tests/e2e/scenarios/test_ironhub_deep_link_install.py diff --git a/crates/ironclaw_gateway/src/assets.rs b/crates/ironclaw_gateway/src/assets.rs index 10708fc6c5e..d0d54ef4baf 100644 --- a/crates/ironclaw_gateway/src/assets.rs +++ b/crates/ironclaw_gateway/src/assets.rs @@ -63,6 +63,8 @@ pub const APP_JS: &str = concat!( "\n", include_str!("../static/js/surfaces/settings.js"), "\n", + include_str!("../static/js/surfaces/install.js"), + "\n", include_str!("../static/js/core/ui-helpers.js"), "\n", include_str!("../static/js/surfaces/config.js"), @@ -121,6 +123,8 @@ pub const STYLE_CSS: &str = concat!( include_str!("../static/styles/surfaces/tool-permissions.css"), "\n", include_str!("../static/styles/surfaces/projects.css"), + "\n", + include_str!("../static/styles/surfaces/install.css"), ); /// Theme initialization script (runs synchronously in `` to prevent FOUC). diff --git a/crates/ironclaw_gateway/static/i18n/en.js b/crates/ironclaw_gateway/static/i18n/en.js index ab673b8cd6a..b9a388b74b2 100644 --- a/crates/ironclaw_gateway/static/i18n/en.js +++ b/crates/ironclaw_gateway/static/i18n/en.js @@ -841,6 +841,42 @@ I18n.register('en', { 'thread.heartbeatAlerts': 'Heartbeat Alerts', 'thread.routine': 'Routine', + // IronHub deep-link install + 'ironhub.install.verifyingTitle': 'Verifying install request', + 'ironhub.install.verifying': 'Checking the signature on this install link...', + 'ironhub.install.confirmTitle': 'Install from IronHub?', + 'ironhub.install.fromHub': 'This install was requested from IronHub. Review it before installing.', + 'ironhub.install.name': 'Name', + 'ironhub.install.kind': 'Type', + 'ironhub.install.version': 'Version', + 'ironhub.install.release': 'Release', + 'ironhub.install.confirm': 'Install', + 'ironhub.install.cancel': 'Cancel', + 'ironhub.install.close': 'Close', + 'ironhub.install.installing': 'Installing...', + 'ironhub.install.doneTitle': 'Installed', + 'ironhub.install.success': 'Installed {name}', + 'ironhub.install.failed': 'Install failed: {message}', + 'ironhub.install.unverifiedTitle': 'Install link could not be verified', + 'ironhub.install.unverified': 'This install link could not be verified. It may be expired, tampered with, or sent from a source your agent does not trust.', + 'ironhub.install.missingParams': 'This install link is missing required parameters.', + 'ironhub.install.trustLabel': 'Trust', + 'ironhub.install.communityWarning': 'This is community-submitted content that has not been verified by the NEAR team. Review it carefully before installing.', + 'ironhub.install.ackUnverified': 'I understand this is unverified community content and want to install anyway.', + 'ironhub.signingKey.title': 'IronHub Integration', + 'ironhub.signingKey.description': 'Generate a key in your IronHub profile, then paste it here so install requests from IronHub can be verified by this agent.', + 'ironhub.signingKey.placeholder': 'ihub_sk_...', + 'ironhub.signingKey.save': 'Save Key', + 'ironhub.signingKey.replace': 'Replace Key', + 'ironhub.signingKey.revoke': 'Revoke Key', + 'ironhub.signingKey.none': 'No signing key configured.', + 'ironhub.signingKey.active': 'Active key fingerprint: {fingerprint} (created {created})', + 'ironhub.signingKey.saved': 'Signing key saved.', + 'ironhub.signingKey.revoked': 'Signing key revoked.', + 'ironhub.signingKey.invalidPrefix': 'Key must start with ihub_sk_', + 'ironhub.signingKey.invalidLength': 'Key must be at least 32 characters', + 'ironhub.signingKey.error': 'Signing key operation failed: {message}', + // Extensions (dynamic) 'extensions.openingAuth': 'Opening authentication for {name}', 'extensions.installFailed': 'Install failed: {message}', diff --git a/crates/ironclaw_gateway/static/index.html b/crates/ironclaw_gateway/static/index.html index 223717ce7cf..48c58e5917e 100644 --- a/crates/ironclaw_gateway/static/index.html +++ b/crates/ironclaw_gateway/static/index.html @@ -457,6 +457,7 @@

Model Providers

Loading settings...
+
@@ -570,6 +571,9 @@

Tool Permissions

+ + +
diff --git a/crates/ironclaw_gateway/static/js/core/routing.js b/crates/ironclaw_gateway/static/js/core/routing.js index 7c3a5ca861e..0498f222500 100644 --- a/crates/ironclaw_gateway/static/js/core/routing.js +++ b/crates/ironclaw_gateway/static/js/core/routing.js @@ -122,6 +122,23 @@ function restoreFromHash() { case 'settings': switchSettingsSubtab(state.detail); break; + case 'install': { + var raw = state.detail || ''; + var qIdx = raw.indexOf('?'); + var pathSlug = qIdx >= 0 ? raw.substring(0, qIdx) : raw; + var qs = qIdx >= 0 ? raw.substring(qIdx + 1) : ''; + var params = new URLSearchParams(qs); + startIronhubInstall({ + slug: params.get('slug') || decodeURIComponent(pathSlug), + version: params.get('version'), + uid: params.get('uid'), + aid: params.get('aid'), + ts: params.get('ts'), + nonce: params.get('nonce'), + sig: params.get('sig'), + }); + break; + } } } diff --git a/crates/ironclaw_gateway/static/js/surfaces/install.js b/crates/ironclaw_gateway/static/js/surfaces/install.js new file mode 100644 index 00000000000..f8a36f686b5 --- /dev/null +++ b/crates/ironclaw_gateway/static/js/surfaces/install.js @@ -0,0 +1,179 @@ +function ironhubInstallPanel() { + return document.getElementById('tab-install'); +} + +function renderIronhubInstallState(html) { + var panel = ironhubInstallPanel(); + if (panel) panel.innerHTML = '
' + html + '
'; +} + +function ironhubInstallCancel() { + switchTab('chat'); +} + +function renderIronhubInstallError(message) { + renderIronhubInstallState( + '

' + escapeHtml(I18n.t('ironhub.install.unverifiedTitle')) + '

' + + '

' + escapeHtml(message) + '

' + + '' + ); +} + +function renderIronhubConfirm(tool, info) { + var kind = info && info.kind ? info.kind : 'tool'; + var version = info && info.version ? info.version : ''; + var description = info && info.description ? info.description : ''; + var release = info && info.release_tag ? info.release_tag : ''; + var provenance = info && info.provenance ? info.provenance : ''; + var trustLabel = info && info.trust_label ? info.trust_label : ''; + var isCommunityUnverified = provenance === 'new'; + var rows = ''; + rows += '
' + + escapeHtml(I18n.t('ironhub.install.name')) + '' + + escapeHtml(tool) + '
'; + rows += '
' + + escapeHtml(I18n.t('ironhub.install.kind')) + '' + + escapeHtml(kind) + '
'; + if (version) { + rows += '
' + + escapeHtml(I18n.t('ironhub.install.version')) + '' + + escapeHtml(version) + '
'; + } + if (release) { + rows += '
' + + escapeHtml(I18n.t('ironhub.install.release')) + '' + + escapeHtml(release) + '
'; + } + if (trustLabel) { + rows += '
' + + escapeHtml(I18n.t('ironhub.install.trustLabel')) + '' + + escapeHtml(trustLabel) + '
'; + } + if (description) { + rows += '

' + escapeHtml(description) + '

'; + } + + var warning = ''; + var ackCheckbox = ''; + var confirmDisabled = ''; + if (isCommunityUnverified) { + warning = '

' + + escapeHtml(I18n.t('ironhub.install.communityWarning')) + '

'; + ackCheckbox = + ''; + confirmDisabled = ' disabled'; + } + + renderIronhubInstallState( + '

' + escapeHtml(I18n.t('ironhub.install.confirmTitle')) + '

' + + '

' + + escapeHtml(I18n.t('ironhub.install.fromHub')) + '

' + + rows + + warning + + ackCheckbox + + '
' + + '' + + '' + + '
' + ); + var btn = document.getElementById('ironhub-install-confirm-btn'); + if (btn) { + btn.addEventListener('click', function() { + ironhubInstallConfirm(tool, isCommunityUnverified); + }); + } +} + +function ironhubAckChanged() { + var cb = document.getElementById('ironhub-install-ack-cb'); + var btn = document.getElementById('ironhub-install-confirm-btn'); + if (cb && btn) { + btn.disabled = !cb.checked; + } +} + +function ironhubInstallConfirm(tool, requireAck) { + var btn = document.getElementById('ironhub-install-confirm-btn'); + if (btn) { + btn.disabled = true; + btn.textContent = I18n.t('ironhub.install.installing'); + } + var body = { name: tool }; + if (requireAck) { + body.acknowledge_unverified = true; + } + apiFetch('/api/ironhub/install', { + method: 'POST', + body: body, + }).then(function(res) { + var name = res && res.name ? res.name : tool; + showToast(I18n.t('ironhub.install.success', { name: name }), 'success'); + renderIronhubInstallState( + '

' + escapeHtml(I18n.t('ironhub.install.doneTitle')) + '

' + + '

' + escapeHtml(I18n.t('ironhub.install.success', { name: name })) + '

' + + '' + ); + }).catch(function(err) { + var msg = err && err.message ? err.message : 'unknown error'; + showToast(I18n.t('ironhub.install.failed', { message: msg }), 'error'); + if (btn) { + btn.disabled = false; + btn.textContent = I18n.t('ironhub.install.confirm'); + } + }); +} + +function startIronhubInstall(params) { + renderIronhubInstallState( + '

' + escapeHtml(I18n.t('ironhub.install.verifyingTitle')) + '

' + + '

' + escapeHtml(I18n.t('ironhub.install.verifying')) + '

' + ); + + var slug = params && params.slug; + var version = params && params.version; + var uid = params && params.uid; + var aid = params && params.aid; + var ts = params && params.ts; + var nonce = params && params.nonce; + var sig = params && params.sig; + + if (!slug || !version || !uid || !aid || !ts || !nonce || !sig) { + renderIronhubInstallError(I18n.t('ironhub.install.missingParams')); + return; + } + + apiFetch('/api/ironhub/verify-intent', { + method: 'POST', + body: { + slug: slug, + version: version, + uid: uid, + aid: aid, + ts: parseInt(ts, 10), + nonce: nonce, + sig: sig, + }, + }).then(function(res) { + if (!res || !res.valid) { + var reason = res && res.reason ? res.reason : I18n.t('ironhub.install.unverified'); + renderIronhubInstallError(reason); + return; + } + return apiFetch('/api/ironhub/info?name=' + encodeURIComponent(slug)) + .then(function(info) { + renderIronhubConfirm(slug, info); + }) + .catch(function() { + renderIronhubConfirm(slug, null); + }); + }).catch(function(err) { + var msg = err && err.message ? err.message : I18n.t('ironhub.install.unverified'); + renderIronhubInstallError(msg); + }); +} diff --git a/crates/ironclaw_gateway/static/js/surfaces/settings.js b/crates/ironclaw_gateway/static/js/surfaces/settings.js index 72aaa619dc1..f68a706ce59 100644 --- a/crates/ironclaw_gateway/static/js/surfaces/settings.js +++ b/crates/ironclaw_gateway/static/js/surfaces/settings.js @@ -223,6 +223,92 @@ function loadInferenceSettings() { function loadAgentSettings() { loadStructuredSettings('settings-agent-content', AGENT_SETTINGS); + loadIronhubSigningKeyCard(); +} + +function renderIronhubSigningKeyCard(bodyHtml) { + var card = document.getElementById('settings-ironhub-card'); + if (!card) return; + card.innerHTML = + '
' + + '

' + escapeHtml(I18n.t('ironhub.signingKey.title')) + '

' + + '

' + + escapeHtml(I18n.t('ironhub.signingKey.description')) + '

' + + bodyHtml + + '
'; +} + +function ironhubSigningKeyFormHtml(hasKey) { + var submitLabel = hasKey + ? I18n.t('ironhub.signingKey.replace') + : I18n.t('ironhub.signingKey.save'); + var revoke = hasKey + ? '' + : ''; + return '' + + '
' + + '' + + '' + + revoke + + '
'; +} + +function loadIronhubSigningKeyCard() { + renderIronhubSigningKeyCard( + '
' + escapeHtml(I18n.t('extensions.loading')) + '
' + ); + apiFetch('/api/ironhub/signing-key').then(function(meta) { + var status = + '

' + escapeHtml(I18n.t('ironhub.signingKey.active', { + fingerprint: meta.fingerprint, + created: meta.created_at, + })) + '

'; + renderIronhubSigningKeyCard(status + ironhubSigningKeyFormHtml(true)); + }).catch(function() { + renderIronhubSigningKeyCard( + '

' + escapeHtml(I18n.t('ironhub.signingKey.none')) + '

' + + ironhubSigningKeyFormHtml(false) + ); + }); +} + +function ironhubSaveSigningKey(evt) { + if (evt && evt.preventDefault) evt.preventDefault(); + var input = document.getElementById('ironhub-key-input'); + if (!input) return; + var key = (input.value || '').trim(); + if (key.indexOf('ihub_sk_') !== 0) { + showToast(I18n.t('ironhub.signingKey.invalidPrefix'), 'error'); + return; + } + if (key.length < 32) { + showToast(I18n.t('ironhub.signingKey.invalidLength'), 'error'); + return; + } + apiFetch('/api/ironhub/signing-key', { + method: 'POST', + body: { shared_key: key }, + }).then(function() { + showToast(I18n.t('ironhub.signingKey.saved'), 'success'); + loadIronhubSigningKeyCard(); + }).catch(function(err) { + showToast(I18n.t('ironhub.signingKey.error', { + message: err && err.message ? err.message : 'unknown error', + }), 'error'); + }); +} + +function ironhubRevokeSigningKey() { + apiFetch('/api/ironhub/signing-key', { method: 'DELETE' }).then(function() { + showToast(I18n.t('ironhub.signingKey.revoked'), 'success'); + loadIronhubSigningKeyCard(); + }).catch(function(err) { + showToast(I18n.t('ironhub.signingKey.error', { + message: err && err.message ? err.message : 'unknown error', + }), 'error'); + }); } function loadStructuredSettings(containerId, settingsDefs) { diff --git a/crates/ironclaw_gateway/static/styles/surfaces/install.css b/crates/ironclaw_gateway/static/styles/surfaces/install.css new file mode 100644 index 00000000000..be4ea40625f --- /dev/null +++ b/crates/ironclaw_gateway/static/styles/surfaces/install.css @@ -0,0 +1,49 @@ +#tab-install { + display: flex; + justify-content: center; + padding: 48px 16px; +} + +.ironhub-install-card { + max-width: 480px; + width: 100%; +} + +.ironhub-install-card h2 { + margin: 0 0 12px; +} + +.ironhub-install-source { + color: var(--text-muted); + font-size: 13px; + margin: 0 0 16px; +} + +.ironhub-install-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 6px 0; + border-bottom: 1px solid var(--border); +} + +.ironhub-install-row span:first-child { + color: var(--text-muted); +} + +.ironhub-install-desc { + margin: 16px 0 0; + color: var(--text); + font-size: 14px; +} + +.ironhub-install-error { + color: var(--danger, #d33); + margin: 0 0 16px; +} + +.ironhub-install-actions { + display: flex; + gap: 12px; + margin-top: 24px; +} diff --git a/src/app.rs b/src/app.rs index 4d9ad8dd281..3fa256829c0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1323,6 +1323,13 @@ impl AppBuilder { (None, None) }; + if let Some(manager) = extension_manager.as_ref() { + tools.register_ironhub_tools(crate::tools::builtin::IronhubDeps { + extension_manager: Arc::clone(manager), + skill_registry: skill_registry.clone(), + }); + } + let context_manager = Arc::new(ContextManager::new(self.config.agent.max_parallel_jobs)); let cost_guard = Arc::new(crate::agent::cost_guard::CostGuard::new( crate::agent::cost_guard::CostGuardConfig { diff --git a/src/channels/web/CLAUDE.md b/src/channels/web/CLAUDE.md index e405c2bce75..c492a24d5d1 100644 --- a/src/channels/web/CLAUDE.md +++ b/src/channels/web/CLAUDE.md @@ -26,7 +26,8 @@ Browser-facing HTTP API and SSE/WebSocket real-time streaming. Axum-based, singl | `features/oauth/` | First feature slice landed per ironclaw#2599 stage 4a: OAuth callback (`/oauth/callback`), channel-relay event webhook (`/relay/events`), and the Slack-specific relay OAuth completion flow (`/oauth/slack/callback`). Owns its private helpers (`oauth_error_page`, `redact_oauth_state_for_logs`). | | `features/pairing/` | `GET /api/pairing/{channel}` + `POST /api/pairing/{channel}/approve` — WASM channel pairing approvals. Validates the URL path through `ExtensionName::new` at the handler boundary so invalid channel names reject with 400 instead of silently routing to a lookup-miss. Migrated from `server.rs` in ironclaw#2599 stage 4b. | | `features/status/` | `GET /api/gateway/status` — runtime snapshot for the admin dashboard (uptime, SSE/WS counts, cost / usage aggregates, active config). Owns the `GatewayStatusResponse` DTO. Migrated from `server.rs` in ironclaw#2599 stage 4b. | -| `handlers/` | Transitional feature handlers that haven't migrated to `features//` yet: `auth`, `engine`, `frontend`, `llm`, `memory`, `secrets`, `skills`, `system_prompt`, `tokens`, `tool_policy`, `users`, `webhooks`. Targeted for migration per ironclaw#2599 if churn / slice-boundary pressure justifies it. | +| `handlers/` | Transitional feature handlers that haven't migrated to `features//` yet: `auth`, `engine`, `frontend`, `ironhub`, `llm`, `memory`, `secrets`, `skills`, `system_prompt`, `tokens`, `tool_policy`, `users`, `webhooks`. Targeted for migration per ironclaw#2599 if churn / slice-boundary pressure justifies it. | +| `handlers/ironhub.rs` | IronHub catalog dispatch (`install`, `search`, `list`, `info`) and the deep-link install protocol surface (`signing-key` CRUD, `register`, `verify-intent`). Owns `hmac_hex`, `install_payload`, `register_payload`, the bounded process-global nonce cache (`NONCE_CACHE_MAX_ENTRIES = 16,384`, TTL = `VERIFY_INTENT_WINDOW_SECS`), and `validate_shared_key` (enforces `ihub_sk_` prefix and at-least-32 character length). Catalog dispatch routes through `ToolDispatcher`; signing-key + register + verify-intent are direct handlers operating on `SecretsStore` under the `ironhub_signing_key` name. | | `openai_compat.rs` | OpenAI-compatible proxy (`/v1/chat/completions`, `/v1/models`) | | `util.rs` | Shared helpers (`web_incoming_message`, `build_turns_from_db_messages`, `images_to_attachments`, `truncate_preview`) | | `test_helpers.rs` | Always-compiled test utilities. `TestGatewayBuilder` (public) — the `tests/` crate's entry point for spinning up a `GatewayState` + optional Axum server on a random port. Plus seven `pub(crate)` `#[cfg(test)]`-gated cross-slice fixtures — `test_gateway_state(ext_mgr)`, `test_gateway_state_with_dependencies(ext_mgr, store, db_auth, pairing_store)`, `test_gateway_state_with_store_and_session_manager(store, session_manager)`, `insert_test_user`, `test_secrets_store`, `test_ext_mgr`, `test_ext_mgr_with_db` — landed in ironclaw#2599 stages 6a+6 so the chat / extensions / oauth / pairing / users slice test modules can share construction helpers without a central mega-tests block. | @@ -201,6 +202,28 @@ Legacy cleanup note: - That path exists solely for prompts that do not carry a gate `request_id`. - Do not expand it. When v1 auth mode is removed, delete these endpoints and the corresponding no-`request_id` branch in `static/js/core/onboarding.js`. +### IronHub Catalog & Install Protocol +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/ironhub/install` | Install a tool/skill from the IronHub catalog (admin-only, dispatches `ironhub_install` tool) | +| GET | `/api/ironhub/search` | Search the catalog (dispatches `ironhub_search` tool) | +| GET | `/api/ironhub/list` | List catalog entries (dispatches `ironhub_list` tool) | +| GET | `/api/ironhub/info` | Catalog entry detail (dispatches `ironhub_info` tool) | +| POST | `/api/ironhub/signing-key` | Set the shared install key (body: `{shared_key}`; validates `ihub_sk_` prefix and minimum 32 characters) | +| GET | `/api/ironhub/signing-key` | Get fingerprint + created_at for the stored key (never returns the key) | +| DELETE | `/api/ironhub/signing-key` | Remove the stored key | +| POST | `/api/ironhub/register` | IronHub-side handshake confirming the agent owns the shared key (body: `{uid, aid, ts, nonce, sig}`; sig over `register:{uid}:{aid}:{ts}:{nonce}`; returns 200 on valid, 401 on bad sig, 408 on stale timestamp, 409 on replayed nonce) | +| POST | `/api/ironhub/verify-intent` | Browser deep-link install verification (body: `{slug, version, uid, aid, ts, nonce, sig}`; sig over `install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}`; returns `{valid, reason}`) | + +**Install protocol contract** (canonical shape that matches IronHub PR #43 and replaces the earlier `{tool}:{ts}` minimal verify shape): + +- **Shared key:** user-generated on the IronHub side (per `AgentInstallation` row, encrypted at rest with AES-256-GCM in IronHub's DB), pasted into the agent via `POST /api/ironhub/signing-key`. Format: `ihub_sk_` prefix, minimum 32 characters total. +- **HMAC:** SHA-256 with the shared key's UTF-8 bytes as the MAC key (no hex-decode). Output is lowercase hex, 64 chars. +- **Install payload:** `install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}` (literal `install` lead, colon-separated, `ts` decimal seconds). +- **Register payload:** `register:{uid}:{aid}:{ts}:{nonce}`. +- **Deep-link URL:** `https:///#/install/?slug=&version=&uid=&aid=&ts=&nonce=&sig=` parsed by `static/js/core/routing.js` and consumed by `static/js/surfaces/install.js`. +- **Freshness window:** 300s timestamp drift + one-shot nonce enforcement via the in-memory `NONCE_CACHE`. Replay-after-window is already blocked by drift; in-window replay is blocked by the cache. Nonces are scoped per `user_id`. Cache prunes expired entries on every insert and aggressively evicts the oldest 25% when over capacity. + ### Routines | Method | Path | Description | |--------|------|-------------| diff --git a/src/channels/web/features/settings/mod.rs b/src/channels/web/features/settings/mod.rs index 413ce02c7d7..9c725f17868 100644 --- a/src/channels/web/features/settings/mod.rs +++ b/src/channels/web/features/settings/mod.rs @@ -1312,6 +1312,8 @@ mod tests { oauth_rate_limiter: crate::channels::web::platform::state::PerUserRateLimiter::new( 20, 60, ), + ironhub_catalog_rate_limiter: + crate::channels::web::platform::state::PerUserRateLimiter::new(30, 60), webhook_rate_limiter: crate::channels::web::platform::state::RateLimiter::new(10, 60), registry_entries: Vec::new(), cost_guard: None, diff --git a/src/channels/web/handlers/ironhub.rs b/src/channels/web/handlers/ironhub.rs new file mode 100644 index 00000000000..8f1faabcb3f --- /dev/null +++ b/src/channels/web/handlers/ironhub.rs @@ -0,0 +1,1499 @@ +use std::collections::HashMap; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +use axum::{ + Json, + extract::{Query, State}, + http::StatusCode, +}; + +use crate::channels::web::auth::{AdminUser, AuthenticatedUser}; +use crate::channels::web::platform::state::GatewayState; +use crate::channels::web::types::{ + IronhubInfoQuery, IronhubInstallRequest, IronhubListQuery, IronhubRegisterRequest, + IronhubSearchQuery, IronhubSigningKeyMetadata, IronhubSigningKeySetRequest, + IronhubVerifyIntentRequest, IronhubVerifyIntentResponse, +}; +use crate::secrets::{CreateSecretParams, SecretError, SecretsStore}; +use crate::tools::ToolError; +use crate::tools::dispatch::DispatchSource; + +const IRONHUB_SIGNING_KEY_NAME: &str = "ironhub_signing_key"; +const VERIFY_INTENT_WINDOW_SECS: u64 = 300; +const SHARED_KEY_PREFIX: &str = "ihub_sk_"; +const SHARED_KEY_MIN_LEN: usize = 32; +const NONCE_CACHE_MAX_ENTRIES: usize = 16_384; + +static NONCE_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +fn nonce_seen_or_record(uid: &str, nonce: &str) -> bool { + let key = format!("{uid}:{nonce}"); + let ttl = Duration::from_secs(VERIFY_INTENT_WINDOW_SECS); + let now = Instant::now(); + let mut guard = match NONCE_CACHE.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + guard.retain(|_, t| now.duration_since(*t) <= ttl); + if guard.contains_key(&key) { + return true; + } + if guard.len() >= NONCE_CACHE_MAX_ENTRIES { + let cutoff = guard.len() / 4; + let mut sorted: Vec<(String, Instant)> = + guard.iter().map(|(k, v)| (k.clone(), *v)).collect(); + sorted.sort_by_key(|(_, t)| *t); + for (k, _) in sorted.into_iter().take(cutoff) { + guard.remove(&k); + } + } + guard.insert(key, now); + false +} + +fn validate_shared_key(value: &str) -> Result<(), String> { + if !value.starts_with(SHARED_KEY_PREFIX) { + return Err(format!( + "shared key must start with {SHARED_KEY_PREFIX} prefix" + )); + } + if value.len() < SHARED_KEY_MIN_LEN { + return Err(format!( + "shared key must be at least {SHARED_KEY_MIN_LEN} characters" + )); + } + Ok(()) +} + +fn tool_error_to_http(err: ToolError) -> (StatusCode, String) { + match err { + ToolError::InvalidParameters(msg) => (StatusCode::BAD_REQUEST, msg), + ToolError::NotAuthorized(msg) => (StatusCode::UNAUTHORIZED, msg), + ToolError::RateLimited(retry) => ( + StatusCode::TOO_MANY_REQUESTS, + retry + .map(|d: Duration| format!("rate limited; retry after {}s", d.as_secs())) + .unwrap_or_else(|| "rate limited".to_string()), + ), + ToolError::Timeout(d) => ( + StatusCode::GATEWAY_TIMEOUT, + format!("execution timed out after {}s", d.as_secs()), + ), + ToolError::ExternalService(msg) => (StatusCode::BAD_GATEWAY, msg), + ToolError::ExecutionFailed(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), + ToolError::Sandbox(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), + } +} + +fn dispatcher_or_503( + state: &Arc, +) -> Result, (StatusCode, String)> { + state.tool_dispatcher.as_ref().map(Arc::clone).ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "tool dispatcher not available".to_string(), + )) +} + +fn catalog_rate_limit( + state: &Arc, + user_id: &str, +) -> Result<(), (StatusCode, String)> { + if state.ironhub_catalog_rate_limiter.check(user_id) { + Ok(()) + } else { + Err(( + StatusCode::TOO_MANY_REQUESTS, + "IronHub catalog rate limit exceeded; try again later".to_string(), + )) + } +} + +pub async fn ironhub_install_handler( + State(state): State>, + AdminUser(user): AdminUser, + Json(req): Json, +) -> Result, (StatusCode, String)> { + catalog_rate_limit(&state, &user.user_id)?; + let dispatcher = dispatcher_or_503(&state)?; + let mut params = serde_json::Map::new(); + params.insert("name".into(), serde_json::Value::String(req.name)); + if let Some(kind) = req.kind { + params.insert("kind".into(), serde_json::Value::String(kind)); + } + 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)); + params.insert( + "acknowledge_unverified".into(), + serde_json::Value::Bool(req.acknowledge_unverified), + ); + + let output = dispatcher + .dispatch( + "ironhub_install", + serde_json::Value::Object(params), + &user.user_id, + DispatchSource::Channel("gateway".into()), + ) + .await + .map_err(tool_error_to_http)?; + Ok(Json(output.result)) +} + +pub async fn ironhub_search_handler( + State(state): State>, + AuthenticatedUser(user): AuthenticatedUser, + Query(q): Query, +) -> Result, (StatusCode, String)> { + catalog_rate_limit(&state, &user.user_id)?; + let dispatcher = dispatcher_or_503(&state)?; + let mut params = serde_json::Map::new(); + params.insert("query".into(), serde_json::Value::String(q.query)); + if let Some(tag) = q.release_tag { + params.insert("release_tag".into(), serde_json::Value::String(tag)); + } + let output = dispatcher + .dispatch( + "ironhub_search", + serde_json::Value::Object(params), + &user.user_id, + DispatchSource::Channel("gateway".into()), + ) + .await + .map_err(tool_error_to_http)?; + Ok(Json(output.result)) +} + +pub async fn ironhub_list_handler( + State(state): State>, + AuthenticatedUser(user): AuthenticatedUser, + Query(q): Query, +) -> Result, (StatusCode, String)> { + catalog_rate_limit(&state, &user.user_id)?; + let dispatcher = dispatcher_or_503(&state)?; + let mut params = serde_json::Map::new(); + if let Some(tag) = q.release_tag { + params.insert("release_tag".into(), serde_json::Value::String(tag)); + } + let output = dispatcher + .dispatch( + "ironhub_list", + serde_json::Value::Object(params), + &user.user_id, + DispatchSource::Channel("gateway".into()), + ) + .await + .map_err(tool_error_to_http)?; + Ok(Json(output.result)) +} + +pub async fn ironhub_info_handler( + State(state): State>, + AuthenticatedUser(user): AuthenticatedUser, + Query(q): Query, +) -> Result, (StatusCode, String)> { + catalog_rate_limit(&state, &user.user_id)?; + let dispatcher = dispatcher_or_503(&state)?; + let mut params = serde_json::Map::new(); + 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)); + } + let output = dispatcher + .dispatch( + "ironhub_info", + serde_json::Value::Object(params), + &user.user_id, + DispatchSource::Channel("gateway".into()), + ) + .await + .map_err(tool_error_to_http)?; + Ok(Json(output.result)) +} + +fn secrets_store_or_503( + state: &Arc, +) -> Result, (StatusCode, String)> { + state.secrets_store.as_ref().map(Arc::clone).ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "secrets store not available".to_string(), + )) +} + +fn fingerprint(key_hex: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(key_hex.as_bytes()); + let digest = hasher.finalize(); + hex::encode(&digest[..6]) +} + +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn hmac_hex(shared_key: &str, msg: &str) -> Result { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + type HmacSha256 = Hmac; + let mut mac = HmacSha256::new_from_slice(shared_key.as_bytes()) + .map_err(|e| format!("hmac initialization failed: {e}"))?; + mac.update(msg.as_bytes()); + Ok(hex::encode(mac.finalize().into_bytes())) +} + +fn install_payload( + slug: &str, + version: &str, + uid: &str, + aid: &str, + ts: u64, + nonce: &str, +) -> String { + format!("install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}") +} + +fn register_payload(uid: &str, aid: &str, ts: u64, nonce: &str) -> String { + format!("register:{uid}:{aid}:{ts}:{nonce}") +} + +pub async fn ironhub_signing_key_set_handler( + State(state): State>, + AuthenticatedUser(user): AuthenticatedUser, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let shared_key = req.shared_key.trim(); + if let Err(e) = validate_shared_key(shared_key) { + return Err((StatusCode::BAD_REQUEST, e)); + } + + let store = secrets_store_or_503(&state)?; + + let previous = if store + .exists(&user.user_id, IRONHUB_SIGNING_KEY_NAME) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + { + let old = store + .get_decrypted(&user.user_id, IRONHUB_SIGNING_KEY_NAME) + .await + .map(|d| d.expose().to_string()) + .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()))?; + Some(old) + } else { + None + }; + + let secret = match store + .create( + &user.user_id, + CreateSecretParams::new(IRONHUB_SIGNING_KEY_NAME, shared_key), + ) + .await + { + Ok(s) => s, + Err(e) => { + if let Some(old) = previous + && let Err(restore_err) = store + .create( + &user.user_id, + CreateSecretParams::new(IRONHUB_SIGNING_KEY_NAME, &old), + ) + .await + { + tracing::warn!( + "failed to restore previous IronHub signing key after set error: {restore_err}" + ); + } + return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())); + } + }; + + Ok(Json(IronhubSigningKeyMetadata { + fingerprint: fingerprint(shared_key), + created_at: secret.created_at.to_rfc3339(), + })) +} + +pub async fn ironhub_signing_key_get_handler( + State(state): State>, + AuthenticatedUser(user): AuthenticatedUser, +) -> Result, (StatusCode, String)> { + let store = secrets_store_or_503(&state)?; + let meta = match store.get(&user.user_id, IRONHUB_SIGNING_KEY_NAME).await { + Ok(s) => s, + Err(SecretError::NotFound(_)) => { + return Err((StatusCode::NOT_FOUND, "no signing key set".to_string())); + } + Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), + }; + let decrypted = store + .get_decrypted(&user.user_id, IRONHUB_SIGNING_KEY_NAME) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + Ok(Json(IronhubSigningKeyMetadata { + fingerprint: fingerprint(decrypted.expose()), + created_at: meta.created_at.to_rfc3339(), + })) +} + +pub async fn ironhub_signing_key_delete_handler( + State(state): State>, + AuthenticatedUser(user): AuthenticatedUser, +) -> Result { + let store = secrets_store_or_503(&state)?; + let removed = store + .delete(&user.user_id, IRONHUB_SIGNING_KEY_NAME) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + if removed { + Ok(StatusCode::NO_CONTENT) + } else { + Err((StatusCode::NOT_FOUND, "no signing key set".to_string())) + } +} + +pub async fn ironhub_verify_intent_handler( + State(state): State>, + AuthenticatedUser(user): AuthenticatedUser, + Json(req): Json, +) -> Result, (StatusCode, String)> { + if let Err(e) = crate::cli::hub_install::validate_hub_name(&req.slug) { + return Ok(Json(IronhubVerifyIntentResponse { + valid: false, + reason: Some(format!("invalid slug: {e}")), + })); + } + + let now = now_unix(); + let drift = now.abs_diff(req.ts); + if drift > VERIFY_INTENT_WINDOW_SECS { + return Ok(Json(IronhubVerifyIntentResponse { + valid: false, + reason: Some(format!( + "timestamp drift {drift}s exceeds window {VERIFY_INTENT_WINDOW_SECS}s" + )), + })); + } + + let store = secrets_store_or_503(&state)?; + let decrypted = match store + .get_decrypted(&user.user_id, IRONHUB_SIGNING_KEY_NAME) + .await + { + Ok(s) => s, + Err(SecretError::NotFound(_)) => { + return Ok(Json(IronhubVerifyIntentResponse { + valid: false, + reason: Some("no signing key configured on this agent".to_string()), + })); + } + Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), + }; + + let payload = install_payload( + &req.slug, + &req.version, + &req.uid, + &req.aid, + req.ts, + &req.nonce, + ); + let expected = match hmac_hex(decrypted.expose(), &payload) { + Ok(v) => v, + Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e)), + }; + + use subtle::ConstantTimeEq; + let supplied = req.sig.as_bytes(); + let sig_valid: bool = expected.as_bytes().ct_eq(supplied).into(); + if !sig_valid { + return Ok(Json(IronhubVerifyIntentResponse { + valid: false, + reason: Some("signature mismatch".to_string()), + })); + } + + if nonce_seen_or_record(&user.user_id, &req.nonce) { + return Ok(Json(IronhubVerifyIntentResponse { + valid: false, + reason: Some("nonce already used".to_string()), + })); + } + + Ok(Json(IronhubVerifyIntentResponse { + valid: true, + reason: None, + })) +} + +pub async fn ironhub_register_handler( + State(state): State>, + AuthenticatedUser(user): AuthenticatedUser, + Json(req): Json, +) -> Result { + let now = now_unix(); + let drift = now.abs_diff(req.ts); + if drift > VERIFY_INTENT_WINDOW_SECS { + return Err(( + StatusCode::REQUEST_TIMEOUT, + format!("timestamp drift {drift}s exceeds window {VERIFY_INTENT_WINDOW_SECS}s"), + )); + } + + let store = secrets_store_or_503(&state)?; + let decrypted = match store + .get_decrypted(&user.user_id, IRONHUB_SIGNING_KEY_NAME) + .await + { + Ok(s) => s, + Err(SecretError::NotFound(_)) => { + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + "no signing key configured on this agent".to_string(), + )); + } + Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), + }; + + let payload = register_payload(&req.uid, &req.aid, req.ts, &req.nonce); + let expected = hmac_hex(decrypted.expose(), &payload) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + + use subtle::ConstantTimeEq; + let supplied = req.sig.as_bytes(); + let sig_valid: bool = expected.as_bytes().ct_eq(supplied).into(); + if !sig_valid { + return Err((StatusCode::UNAUTHORIZED, "signature mismatch".to_string())); + } + + if nonce_seen_or_record(&user.user_id, &req.nonce) { + return Err((StatusCode::CONFLICT, "nonce already used".to_string())); + } + + Ok(StatusCode::OK) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::channels::web::platform::auth::UserIdentity; + use crate::config::SafetyConfig; + use crate::db::Database; + use crate::db::UserRecord; + use crate::db::libsql::LibSqlBackend; + use crate::tools::dispatch::ToolDispatcher; + use crate::tools::{ApprovalRequirement, Tool, ToolOutput, ToolRegistry}; + use async_trait::async_trait; + use axum::Router; + use axum::body::Body; + use axum::routing::{get, post}; + use ironclaw_safety::SafetyLayer; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tower::ServiceExt; + + struct StubIronhubTool { + name: &'static str, + schema: serde_json::Value, + approval: ApprovalRequirement, + calls: Arc, + response: serde_json::Value, + } + + #[async_trait] + impl Tool for StubIronhubTool { + fn name(&self) -> &str { + self.name + } + fn description(&self) -> &str { + "stub" + } + fn parameters_schema(&self) -> serde_json::Value { + self.schema.clone() + } + fn requires_approval(&self, _: &serde_json::Value) -> ApprovalRequirement { + self.approval + } + async fn execute( + &self, + _params: serde_json::Value, + _ctx: &crate::context::JobContext, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(ToolOutput::success( + self.response.clone(), + std::time::Duration::from_millis(1), + )) + } + } + + fn install_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "name": { "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]*$", "minLength": 1, "maxLength": 64 }, + "kind": { "type": "string", "enum": ["tool", "skill"] }, + "release_tag": { "type": "string", "pattern": "^[A-Za-z0-9._-]+$", "minLength": 1, "maxLength": 128 }, + "force": { "type": "boolean", "default": false }, + "acknowledge_unverified": { "type": "boolean", "default": false } + }, + "required": ["name"] + }) + } + + fn search_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "query": { "type": "string", "minLength": 1, "maxLength": 128 }, + "release_tag": { "type": "string", "pattern": "^[A-Za-z0-9._-]+$", "minLength": 1, "maxLength": 128 } + }, + "required": ["query"] + }) + } + + fn list_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "release_tag": { "type": "string", "pattern": "^[A-Za-z0-9._-]+$", "minLength": 1, "maxLength": 128 } + } + }) + } + + fn info_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "name": { "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]*$", "minLength": 1, "maxLength": 64 }, + "release_tag": { "type": "string", "pattern": "^[A-Za-z0-9._-]+$", "minLength": 1, "maxLength": 128 } + }, + "required": ["name"] + }) + } + + async fn build_state_with_stubs() -> (Arc, Arc) { + let dir = tempfile::tempdir().expect("tempdir"); + let backend = Arc::new( + LibSqlBackend::new_local(&dir.path().join("test.db")) + .await + .expect("libsql backend"), + ); + backend.run_migrations().await.expect("migrations"); + let db: Arc = Arc::clone(&backend) as Arc; + let now = chrono::Utc::now(); + for id in ["test-admin", "test-user"] { + db.create_user(&UserRecord { + id: id.into(), + email: None, + display_name: id.into(), + status: "active".into(), + role: if id == "test-admin" { + "admin".into() + } else { + "regular".into() + }, + created_at: now, + updated_at: now, + last_login_at: None, + created_by: None, + metadata: serde_json::json!({}), + }) + .await + .expect("create user"); + } + let registry = Arc::new(ToolRegistry::new()); + let calls = Arc::new(AtomicUsize::new(0)); + registry + .register(Arc::new(StubIronhubTool { + name: "ironhub_install", + schema: install_schema(), + approval: ApprovalRequirement::Never, + calls: Arc::clone(&calls), + response: serde_json::json!({"status": "installed", "name": "clickup"}), + })) + .await; + registry + .register(Arc::new(StubIronhubTool { + name: "ironhub_search", + schema: search_schema(), + approval: ApprovalRequirement::Never, + calls: Arc::clone(&calls), + response: serde_json::json!({"results": []}), + })) + .await; + registry + .register(Arc::new(StubIronhubTool { + name: "ironhub_list", + schema: list_schema(), + approval: ApprovalRequirement::Never, + calls: Arc::clone(&calls), + response: serde_json::json!({"tools": [], "skills": []}), + })) + .await; + registry + .register(Arc::new(StubIronhubTool { + name: "ironhub_info", + schema: info_schema(), + approval: ApprovalRequirement::Never, + calls: Arc::clone(&calls), + response: serde_json::json!({"kind": "tool", "name": "clickup"}), + })) + .await; + + let safety = Arc::new(SafetyLayer::new(&SafetyConfig { + max_output_length: 65_536, + injection_check_enabled: false, + })); + let dispatcher = Arc::new(ToolDispatcher::new(registry, safety, db)); + std::mem::forget(dir); + + let state = crate::channels::web::test_helpers::TestGatewayBuilder::new() + .with_tool_dispatcher(dispatcher) + .build(); + (state, calls) + } + + fn req_with_identity( + method: &str, + uri: &str, + body: Body, + role: &str, + ) -> axum::http::Request { + let mut req = axum::http::Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json") + .body(body) + .expect("request"); + req.extensions_mut().insert(UserIdentity { + user_id: if role == "admin" { + "test-admin".into() + } else { + "test-user".into() + }, + role: role.into(), + workspace_read_scopes: Vec::new(), + }); + req + } + + fn req_no_identity(method: &str, uri: &str, body: Body) -> axum::http::Request { + axum::http::Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json") + .body(body) + .expect("request") + } + + #[tokio::test] + async fn ironhub_install_rejects_unauthenticated() { + let (state, _calls) = build_state_with_stubs().await; + let app = Router::new() + .route("/api/ironhub/install", post(ironhub_install_handler)) + .with_state(state); + let req = req_no_identity( + "POST", + "/api/ironhub/install", + Body::from(serde_json::json!({"name": "clickup"}).to_string()), + ); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn ironhub_install_rejects_non_admin() { + let (state, _calls) = build_state_with_stubs().await; + let app = Router::new() + .route("/api/ironhub/install", post(ironhub_install_handler)) + .with_state(state); + let req = req_with_identity( + "POST", + "/api/ironhub/install", + Body::from(serde_json::json!({"name": "clickup"}).to_string()), + "regular", + ); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn ironhub_install_admin_dispatches_tool() { + let (state, calls) = build_state_with_stubs().await; + let app = Router::new() + .route("/api/ironhub/install", post(ironhub_install_handler)) + .with_state(state); + let req = req_with_identity( + "POST", + "/api/ironhub/install", + Body::from(serde_json::json!({"name": "clickup"}).to_string()), + "admin", + ); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn ironhub_install_handler_forwards_acknowledge_unverified() { + use std::sync::Mutex as StdMutex; + let dir = tempfile::tempdir().expect("tempdir"); + let backend = Arc::new( + LibSqlBackend::new_local(&dir.path().join("test.db")) + .await + .expect("libsql backend"), + ); + backend.run_migrations().await.expect("migrations"); + let db: Arc = Arc::clone(&backend) as Arc; + let now = chrono::Utc::now(); + db.create_user(&UserRecord { + id: "test-admin".into(), + email: None, + display_name: "test-admin".into(), + status: "active".into(), + role: "admin".into(), + created_at: now, + updated_at: now, + last_login_at: None, + created_by: None, + metadata: serde_json::json!({}), + }) + .await + .expect("create user"); + + struct ParamCaptureStub { + captured: Arc>>, + } + #[async_trait] + impl Tool for ParamCaptureStub { + fn name(&self) -> &str { + "ironhub_install" + } + fn description(&self) -> &str { + "stub" + } + fn parameters_schema(&self) -> serde_json::Value { + install_schema() + } + async fn execute( + &self, + params: serde_json::Value, + _ctx: &crate::context::JobContext, + ) -> Result { + *self.captured.lock().unwrap() = Some(params); + Ok(ToolOutput::success( + serde_json::json!({"status": "installed"}), + std::time::Duration::from_millis(1), + )) + } + } + + let captured = Arc::new(StdMutex::new(None)); + let registry = Arc::new(ToolRegistry::new()); + registry + .register(Arc::new(ParamCaptureStub { + captured: Arc::clone(&captured), + })) + .await; + let safety = Arc::new(SafetyLayer::new(&SafetyConfig { + max_output_length: 65_536, + injection_check_enabled: false, + })); + let dispatcher = Arc::new(ToolDispatcher::new(registry, safety, db)); + std::mem::forget(dir); + + let state = crate::channels::web::test_helpers::TestGatewayBuilder::new() + .with_tool_dispatcher(dispatcher) + .build(); + let app = Router::new() + .route("/api/ironhub/install", post(ironhub_install_handler)) + .with_state(state); + let req = req_with_identity( + "POST", + "/api/ironhub/install", + Body::from( + serde_json::json!({"name": "clickup", "acknowledge_unverified": true}).to_string(), + ), + "admin", + ); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::OK); + let params = captured.lock().unwrap().clone().expect("params captured"); + assert_eq!( + params + .get("acknowledge_unverified") + .and_then(|v| v.as_bool()), + Some(true), + "gateway handler must forward acknowledge_unverified to the ironhub_install tool" + ); + } + + #[tokio::test] + async fn ironhub_catalog_handlers_rate_limit_per_user() { + let dir = tempfile::tempdir().expect("tempdir"); + let backend = Arc::new( + LibSqlBackend::new_local(&dir.path().join("test.db")) + .await + .expect("libsql backend"), + ); + backend.run_migrations().await.expect("migrations"); + let db: Arc = Arc::clone(&backend) as Arc; + let now = chrono::Utc::now(); + db.create_user(&UserRecord { + id: "test-user".into(), + email: None, + display_name: "test-user".into(), + status: "active".into(), + role: "regular".into(), + created_at: now, + updated_at: now, + last_login_at: None, + created_by: None, + metadata: serde_json::json!({}), + }) + .await + .expect("create user"); + let registry = Arc::new(ToolRegistry::new()); + registry + .register(Arc::new(StubIronhubTool { + name: "ironhub_search", + schema: search_schema(), + approval: ApprovalRequirement::Never, + calls: Arc::new(AtomicUsize::new(0)), + response: serde_json::json!({"results": []}), + })) + .await; + let safety = Arc::new(SafetyLayer::new(&SafetyConfig { + max_output_length: 65_536, + injection_check_enabled: false, + })); + let dispatcher = Arc::new(ToolDispatcher::new(registry, safety, db)); + std::mem::forget(dir); + + let state = crate::channels::web::test_helpers::TestGatewayBuilder::new() + .with_tool_dispatcher(dispatcher) + .with_ironhub_catalog_rate_limit(1, 60) + .build(); + let app = Router::new() + .route("/api/ironhub/search", get(ironhub_search_handler)) + .with_state(state); + + let first = req_with_identity( + "GET", + "/api/ironhub/search?query=rpc", + Body::empty(), + "regular", + ); + let resp1 = ServiceExt::>::oneshot(app.clone(), first) + .await + .expect("first"); + assert_eq!(resp1.status(), StatusCode::OK); + + let second = req_with_identity( + "GET", + "/api/ironhub/search?query=rpc", + Body::empty(), + "regular", + ); + let resp2 = ServiceExt::>::oneshot(app, second) + .await + .expect("second"); + assert_eq!( + resp2.status(), + StatusCode::TOO_MANY_REQUESTS, + "catalog dispatch must rate-limit per user" + ); + } + + #[tokio::test] + async fn ironhub_install_accepts_underscore_in_name() { + let (state, calls) = build_state_with_stubs().await; + let app = Router::new() + .route("/api/ironhub/install", post(ironhub_install_handler)) + .with_state(state); + let req = req_with_identity( + "POST", + "/api/ironhub/install", + Body::from(serde_json::json!({"name": "microsoft_365"}).to_string()), + "admin", + ); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!( + resp.status(), + StatusCode::OK, + "underscore names like microsoft_365 must pass the schema regex" + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn ironhub_install_rejects_path_traversal_in_name() { + let (state, calls) = build_state_with_stubs().await; + let app = Router::new() + .route("/api/ironhub/install", post(ironhub_install_handler)) + .with_state(state); + let req = req_with_identity( + "POST", + "/api/ironhub/install", + Body::from(serde_json::json!({"name": "../etc/passwd"}).to_string()), + "admin", + ); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert_eq!( + calls.load(Ordering::SeqCst), + 0, + "tool execute must NOT run when schema rejects" + ); + } + + #[tokio::test] + async fn ironhub_search_rejects_unauthenticated() { + let (state, _calls) = build_state_with_stubs().await; + let app = Router::new() + .route("/api/ironhub/search", get(ironhub_search_handler)) + .with_state(state); + let req = req_no_identity("GET", "/api/ironhub/search?query=rpc", Body::empty()); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn ironhub_search_returns_dispatch_result_for_authenticated_user() { + let (state, calls) = build_state_with_stubs().await; + let app = Router::new() + .route("/api/ironhub/search", get(ironhub_search_handler)) + .with_state(state); + let req = req_with_identity( + "GET", + "/api/ironhub/search?query=rpc", + Body::empty(), + "regular", + ); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn ironhub_list_passes_release_tag_query_to_dispatch() { + let (state, calls) = build_state_with_stubs().await; + let app = Router::new() + .route("/api/ironhub/list", get(ironhub_list_handler)) + .with_state(state); + let req = req_with_identity( + "GET", + "/api/ironhub/list?release_tag=release-test", + Body::empty(), + "regular", + ); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn ironhub_info_rejects_path_traversal_in_query_name() { + let (state, calls) = build_state_with_stubs().await; + let app = Router::new() + .route("/api/ironhub/info", get(ironhub_info_handler)) + .with_state(state); + let req = req_with_identity( + "GET", + "/api/ironhub/info?name=..%2Fetc%2Fpasswd", + Body::empty(), + "regular", + ); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert_eq!( + calls.load(Ordering::SeqCst), + 0, + "tool execute must NOT run when schema rejects" + ); + } + + #[tokio::test] + async fn ironhub_install_rejects_unknown_field() { + let (state, calls) = build_state_with_stubs().await; + let app = Router::new() + .route("/api/ironhub/install", post(ironhub_install_handler)) + .with_state(state); + let req = req_with_identity( + "POST", + "/api/ironhub/install", + Body::from(serde_json::json!({"name": "clickup", "evil": "exfil"}).to_string()), + "admin", + ); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert!( + resp.status() == StatusCode::BAD_REQUEST + || resp.status() == StatusCode::UNPROCESSABLE_ENTITY, + "expected 400 or 422 for unknown field, got {:?}", + resp.status() + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 0, + "tool execute must NOT run when extra field rejected" + ); + } + + #[test] + fn install_hmac_is_deterministic_and_payload_sensitive() { + let key_a = "ihub_sk_aaaaaaaaaaaaaaaaaaaaaaaa"; + let key_b = "ihub_sk_bbbbbbbbbbbbbbbbbbbbbbbb"; + let p1 = install_payload("clickup", "1.0.0", "u1", "a1", 1_700_000_000, "n1"); + let p_other_slug = install_payload("evm-rpc", "1.0.0", "u1", "a1", 1_700_000_000, "n1"); + let p_other_nonce = install_payload("clickup", "1.0.0", "u1", "a1", 1_700_000_000, "n2"); + let s1 = hmac_hex(key_a, &p1).expect("hmac"); + let s2 = hmac_hex(key_a, &p1).expect("hmac"); + let s3 = hmac_hex(key_b, &p1).expect("hmac"); + let s4 = hmac_hex(key_a, &p_other_slug).expect("hmac"); + let s5 = hmac_hex(key_a, &p_other_nonce).expect("hmac"); + assert_eq!(s1, s2, "same inputs must produce same signature"); + assert_ne!(s1, s3, "different key must produce different signature"); + assert_ne!(s1, s4, "different slug must produce different signature"); + assert_ne!(s1, s5, "different nonce must produce different signature"); + assert_eq!(s1.len(), 64, "hex sha256 hmac is 64 chars"); + } + + #[test] + fn install_payload_format_is_stable() { + let p = install_payload("clickup", "1.0.0", "u1", "a1", 1_700_000_000, "n1"); + assert_eq!(p, "install:clickup:1.0.0:u1:a1:1700000000:n1"); + } + + #[test] + fn register_payload_format_is_stable() { + let p = register_payload("u1", "a1", 1_700_000_000, "n1"); + assert_eq!(p, "register:u1:a1:1700000000:n1"); + } + + #[test] + fn validate_shared_key_enforces_prefix_and_length() { + assert!(validate_shared_key("ihub_sk_aaaaaaaaaaaaaaaaaaaaaaaa").is_ok()); + assert!(validate_shared_key("ihub_sk_short").is_err()); + assert!(validate_shared_key("not_prefixed_keykey_keykey_keykey").is_err()); + } + + #[test] + fn fingerprint_is_stable_and_short() { + let key = "ihub_sk_aaaaaaaaaaaaaaaaaaaaaaaa"; + let f1 = fingerprint(key); + let f2 = fingerprint(key); + assert_eq!(f1, f2); + assert_eq!(f1.len(), 12, "6 bytes -> 12 hex chars"); + assert_ne!(f1, fingerprint("ihub_sk_bbbbbbbbbbbbbbbbbbbbbbbb")); + } + + const TEST_SHARED_KEY: &str = "ihub_sk_test_kkkkkkkkkkkkkkkkkkkk"; + + async fn verify_app() -> (Router, String, String, u64) { + use crate::secrets::CreateSecretParams; + + let secrets = crate::channels::web::test_helpers::test_secrets_store(); + secrets + .create( + "test-user", + CreateSecretParams::new("ironhub_signing_key", TEST_SHARED_KEY), + ) + .await + .expect("seed signing key"); + + let state = crate::channels::web::test_helpers::TestGatewayBuilder::new() + .user_id("test-user") + .with_secrets_store(secrets) + .build(); + let app = Router::new() + .route( + "/api/ironhub/verify-intent", + post(ironhub_verify_intent_handler), + ) + .route("/api/ironhub/register", post(ironhub_register_handler)) + .with_state(state); + let ts = now_unix(); + let nonce = uuid::Uuid::new_v4().to_string(); + let payload = install_payload("clickup", "1.0.0", "u1", "a1", ts, &nonce); + let sig = hmac_hex(TEST_SHARED_KEY, &payload).expect("sign"); + (app, sig, nonce, ts) + } + + fn verify_body(slug: &str, ts: u64, nonce: &str, sig: &str) -> serde_json::Value { + serde_json::json!({ + "slug": slug, + "version": "1.0.0", + "uid": "u1", + "aid": "a1", + "ts": ts, + "nonce": nonce, + "sig": sig, + }) + } + + fn verify_req(body: serde_json::Value) -> axum::http::Request { + let mut req = axum::http::Request::builder() + .method("POST") + .uri("/api/ironhub/verify-intent") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("request"); + req.extensions_mut().insert(UserIdentity { + user_id: "test-user".into(), + role: "regular".into(), + workspace_read_scopes: Vec::new(), + }); + req + } + + fn register_req(body: serde_json::Value) -> axum::http::Request { + let mut req = axum::http::Request::builder() + .method("POST") + .uri("/api/ironhub/register") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("request"); + req.extensions_mut().insert(UserIdentity { + user_id: "test-user".into(), + role: "regular".into(), + workspace_read_scopes: Vec::new(), + }); + req + } + + async fn body_json(resp: axum::response::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(resp.into_body(), 65536) + .await + .expect("body"); + serde_json::from_slice(&bytes).expect("json") + } + + #[tokio::test] + async fn verify_intent_accepts_valid_signature() { + let (app, sig, nonce, ts) = verify_app().await; + let req = verify_req(verify_body("clickup", ts, &nonce, &sig)); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::OK); + let json = body_json(resp).await; + assert_eq!(json["valid"], true, "valid sig must verify: {json:?}"); + } + + #[tokio::test] + async fn verify_intent_rejects_tampered_signature() { + let (app, _sig, nonce, ts) = verify_app().await; + let req = verify_req(verify_body("clickup", ts, &nonce, &"deadbeef".repeat(8))); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + let json = body_json(resp).await; + assert_eq!(json["valid"], false); + assert!(json["reason"].as_str().unwrap().contains("mismatch")); + } + + #[tokio::test] + async fn verify_intent_rejects_expired_timestamp() { + let (app, _sig, _nonce, _ts) = verify_app().await; + let stale_ts = now_unix() - 4000; + let req = verify_req(verify_body( + "clickup", + stale_ts, + "n_stale", + &"00".repeat(32), + )); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + let json = body_json(resp).await; + assert_eq!(json["valid"], false); + assert!(json["reason"].as_str().unwrap().contains("drift")); + } + + #[tokio::test] + async fn verify_intent_rejects_invalid_slug() { + let (app, _sig, nonce, ts) = verify_app().await; + let req = verify_req(verify_body("../etc/passwd", ts, &nonce, &"00".repeat(32))); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + let json = body_json(resp).await; + assert_eq!(json["valid"], false); + assert!(json["reason"].as_str().unwrap().contains("invalid slug")); + } + + #[tokio::test] + async fn verify_intent_rejects_replayed_nonce() { + let (app, sig, nonce, ts) = verify_app().await; + let first = verify_req(verify_body("clickup", ts, &nonce, &sig)); + let resp1 = ServiceExt::>::oneshot(app.clone(), first) + .await + .expect("first response"); + let json1 = body_json(resp1).await; + assert_eq!(json1["valid"], true); + + let replay = verify_req(verify_body("clickup", ts, &nonce, &sig)); + let resp2 = ServiceExt::>::oneshot(app, replay) + .await + .expect("replay response"); + let json2 = body_json(resp2).await; + assert_eq!(json2["valid"], false); + assert!(json2["reason"].as_str().unwrap().contains("nonce")); + } + + #[tokio::test] + async fn verify_intent_requires_authentication() { + let (app, sig, nonce, ts) = verify_app().await; + let req = axum::http::Request::builder() + .method("POST") + .uri("/api/ironhub/verify-intent") + .header("content-type", "application/json") + .body(Body::from( + verify_body("clickup", ts, &nonce, &sig).to_string(), + )) + .expect("request"); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + async fn signing_key_app() -> Router { + let secrets = crate::channels::web::test_helpers::test_secrets_store(); + let state = crate::channels::web::test_helpers::TestGatewayBuilder::new() + .user_id("test-user") + .with_secrets_store(secrets) + .build(); + Router::new() + .route( + "/api/ironhub/signing-key", + post(ironhub_signing_key_set_handler) + .get(ironhub_signing_key_get_handler) + .delete(ironhub_signing_key_delete_handler), + ) + .with_state(state) + } + + fn signing_key_req(method: &str, body: Body) -> axum::http::Request { + let mut req = axum::http::Request::builder() + .method(method) + .uri("/api/ironhub/signing-key") + .header("content-type", "application/json") + .body(body) + .expect("request"); + req.extensions_mut().insert(UserIdentity { + user_id: "test-user".into(), + role: "regular".into(), + workspace_read_scopes: Vec::new(), + }); + req + } + + #[tokio::test] + async fn signing_key_set_accepts_valid_prefix_and_returns_metadata() { + let app = signing_key_app().await; + let body = serde_json::json!({"shared_key": TEST_SHARED_KEY}).to_string(); + let resp = ServiceExt::>::oneshot( + app, + signing_key_req("POST", Body::from(body)), + ) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::OK); + let meta = body_json(resp).await; + assert_eq!(meta["fingerprint"].as_str().unwrap().len(), 12); + assert!(meta["created_at"].as_str().is_some()); + assert!( + meta.get("shared_key").is_none(), + "set response must NOT echo the key" + ); + } + + #[tokio::test] + async fn signing_key_set_rejects_missing_prefix() { + let app = signing_key_app().await; + let body = + serde_json::json!({"shared_key": "no_prefix_key_with_enough_length_xx"}).to_string(); + let resp = ServiceExt::>::oneshot( + app, + signing_key_req("POST", Body::from(body)), + ) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn signing_key_set_rejects_too_short() { + let app = signing_key_app().await; + let body = serde_json::json!({"shared_key": "ihub_sk_short"}).to_string(); + let resp = ServiceExt::>::oneshot( + app, + signing_key_req("POST", Body::from(body)), + ) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn signing_key_get_returns_404_when_unset() { + let app = signing_key_app().await; + let resp = ServiceExt::>::oneshot( + app, + signing_key_req("GET", Body::empty()), + ) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn signing_key_get_returns_metadata_without_exposing_key() { + let app = signing_key_app().await; + let set_body = serde_json::json!({"shared_key": TEST_SHARED_KEY}).to_string(); + let set_resp = ServiceExt::>::oneshot( + app.clone(), + signing_key_req("POST", Body::from(set_body)), + ) + .await + .expect("set response"); + assert_eq!(set_resp.status(), StatusCode::OK); + + let get_resp = ServiceExt::>::oneshot( + app, + signing_key_req("GET", Body::empty()), + ) + .await + .expect("get response"); + assert_eq!(get_resp.status(), StatusCode::OK); + let meta = body_json(get_resp).await; + assert!(meta["fingerprint"].as_str().is_some()); + let serialized = serde_json::to_string(&meta).unwrap(); + assert!( + !serialized.contains(TEST_SHARED_KEY), + "GET response must never echo the raw key: {serialized}" + ); + } + + #[tokio::test] + async fn signing_key_delete_removes_existing_and_404s_on_missing() { + let app = signing_key_app().await; + let set_body = serde_json::json!({"shared_key": TEST_SHARED_KEY}).to_string(); + ServiceExt::>::oneshot( + app.clone(), + signing_key_req("POST", Body::from(set_body)), + ) + .await + .expect("set"); + + let del1 = ServiceExt::>::oneshot( + app.clone(), + signing_key_req("DELETE", Body::empty()), + ) + .await + .expect("delete"); + assert_eq!(del1.status(), StatusCode::NO_CONTENT); + + let del2 = ServiceExt::>::oneshot( + app, + signing_key_req("DELETE", Body::empty()), + ) + .await + .expect("second delete"); + assert_eq!(del2.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn register_accepts_valid_signature() { + let (app, _intent_sig, _intent_nonce, 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).expect("sign"); + let body = serde_json::json!({ + "uid": "u1", + "aid": "a1", + "ts": ts, + "nonce": nonce, + "sig": sig, + }); + let resp = ServiceExt::>::oneshot(app, register_req(body)) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn register_rejects_bad_signature() { + let (app, _intent_sig, _intent_nonce, ts) = verify_app().await; + let nonce = uuid::Uuid::new_v4().to_string(); + let body = serde_json::json!({ + "uid": "u1", + "aid": "a1", + "ts": ts, + "nonce": nonce, + "sig": "deadbeef".repeat(8), + }); + let resp = ServiceExt::>::oneshot(app, register_req(body)) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn register_rejects_expired_timestamp() { + let (app, _intent_sig, _intent_nonce, _ts) = verify_app().await; + let stale_ts = now_unix() - 4000; + let nonce = uuid::Uuid::new_v4().to_string(); + let payload = register_payload("u1", "a1", stale_ts, &nonce); + let sig = hmac_hex(TEST_SHARED_KEY, &payload).expect("sign"); + let body = serde_json::json!({ + "uid": "u1", + "aid": "a1", + "ts": stale_ts, + "nonce": nonce, + "sig": sig, + }); + let resp = ServiceExt::>::oneshot(app, register_req(body)) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::REQUEST_TIMEOUT); + } +} diff --git a/src/channels/web/handlers/memory.rs b/src/channels/web/handlers/memory.rs index 2a220c9c210..63ff45279c0 100644 --- a/src/channels/web/handlers/memory.rs +++ b/src/channels/web/handlers/memory.rs @@ -294,6 +294,7 @@ mod tests { auth_manager: None, chat_rate_limiter: PerUserRateLimiter::new(30, 60), oauth_rate_limiter: PerUserRateLimiter::new(20, 60), + ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60), webhook_rate_limiter: RateLimiter::new(10, 60), registry_entries: Vec::new(), cost_guard: None, diff --git a/src/channels/web/handlers/mod.rs b/src/channels/web/handlers/mod.rs index 0684b104db1..ddf39465918 100644 --- a/src/channels/web/handlers/mod.rs +++ b/src/channels/web/handlers/mod.rs @@ -4,6 +4,7 @@ pub mod auth; pub mod engine; +pub mod ironhub; pub mod llm; pub mod memory; pub mod secrets; diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index 0247c54ef0f..af89d894185 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -176,6 +176,7 @@ impl GatewayChannel { auth_manager: None, chat_rate_limiter: platform::state::PerUserRateLimiter::new(30, 60), oauth_rate_limiter: platform::state::PerUserRateLimiter::new(20, 60), + ironhub_catalog_rate_limiter: platform::state::PerUserRateLimiter::new(30, 60), webhook_rate_limiter: platform::state::RateLimiter::new(10, 60), registry_entries: Vec::new(), cost_guard: None, @@ -242,6 +243,7 @@ impl GatewayChannel { auth_manager: self.state.auth_manager.clone(), chat_rate_limiter: platform::state::PerUserRateLimiter::new(30, 60), oauth_rate_limiter: platform::state::PerUserRateLimiter::new(20, 60), + ironhub_catalog_rate_limiter: platform::state::PerUserRateLimiter::new(30, 60), webhook_rate_limiter: platform::state::RateLimiter::new(10, 60), registry_entries: self.state.registry_entries.clone(), cost_guard: self.state.cost_guard.clone(), diff --git a/src/channels/web/platform/router.rs b/src/channels/web/platform/router.rs index 684f9a84076..6fd41821e40 100644 --- a/src/channels/web/platform/router.rs +++ b/src/channels/web/platform/router.rs @@ -46,6 +46,11 @@ use crate::channels::web::handlers::frontend::{ frontend_layout_handler, frontend_layout_update_handler, frontend_widget_file_handler, frontend_widgets_handler, }; +use crate::channels::web::handlers::ironhub::{ + ironhub_info_handler, ironhub_install_handler, ironhub_list_handler, ironhub_register_handler, + ironhub_search_handler, ironhub_signing_key_delete_handler, ironhub_signing_key_get_handler, + ironhub_signing_key_set_handler, ironhub_verify_intent_handler, +}; use crate::channels::web::handlers::llm::{ llm_list_models_handler, llm_providers_handler, llm_test_connection_handler, }; @@ -307,6 +312,22 @@ pub async fn start_server( "/api/skills/{name}", axum::routing::delete(skills_remove_handler), ) + // IronHub catalog + .route("/api/ironhub/install", post(ironhub_install_handler)) + .route("/api/ironhub/search", get(ironhub_search_handler)) + .route("/api/ironhub/list", get(ironhub_list_handler)) + .route("/api/ironhub/info", get(ironhub_info_handler)) + .route( + "/api/ironhub/signing-key", + post(ironhub_signing_key_set_handler) + .get(ironhub_signing_key_get_handler) + .delete(ironhub_signing_key_delete_handler), + ) + .route( + "/api/ironhub/verify-intent", + post(ironhub_verify_intent_handler), + ) + .route("/api/ironhub/register", post(ironhub_register_handler)) // Settings .route("/api/settings", get(settings_list_handler)) .route("/api/settings/export", get(settings_export_handler)) diff --git a/src/channels/web/platform/state.rs b/src/channels/web/platform/state.rs index a5581a9f2de..e85b3cc1338 100644 --- a/src/channels/web/platform/state.rs +++ b/src/channels/web/platform/state.rs @@ -414,6 +414,10 @@ pub struct GatewayState { pub chat_rate_limiter: PerUserRateLimiter, /// Per-IP rate limiter for OAuth/auth endpoints (20 requests per 60 seconds per IP). pub oauth_rate_limiter: PerUserRateLimiter, + /// Per-user rate limiter for IronHub catalog dispatch (30 requests per 60 seconds per user). + /// Each search/list/info/install triggers a remote manifest fetch + audit write; this prevents + /// an authenticated user from fanning out unbounded outbound work via the gateway. + pub ironhub_catalog_rate_limiter: PerUserRateLimiter, /// Rate limiter for webhook trigger endpoints (10 requests per 60 seconds). pub webhook_rate_limiter: RateLimiter, /// Registry catalog entries for the available extensions API. diff --git a/src/channels/web/platform/ws.rs b/src/channels/web/platform/ws.rs index 8d64ae665eb..7f43f32f878 100644 --- a/src/channels/web/platform/ws.rs +++ b/src/channels/web/platform/ws.rs @@ -589,6 +589,8 @@ mod tests { oauth_rate_limiter: crate::channels::web::platform::state::PerUserRateLimiter::new( 20, 60, ), + ironhub_catalog_rate_limiter: + crate::channels::web::platform::state::PerUserRateLimiter::new(30, 60), webhook_rate_limiter: crate::channels::web::platform::state::RateLimiter::new(10, 60), registry_entries: Vec::new(), cost_guard: None, diff --git a/src/channels/web/test_helpers.rs b/src/channels/web/test_helpers.rs index d3875f7f360..a5c4d2ee975 100644 --- a/src/channels/web/test_helpers.rs +++ b/src/channels/web/test_helpers.rs @@ -48,6 +48,9 @@ pub struct TestGatewayBuilder { llm_provider: Option>, user_id: String, tool_registry: Option>, + tool_dispatcher: Option>, + secrets_store: Option>, + ironhub_catalog_rate_limit: Option<(u64, u64)>, } impl Default for TestGatewayBuilder { @@ -57,6 +60,9 @@ impl Default for TestGatewayBuilder { llm_provider: None, user_id: "test-user".to_string(), tool_registry: None, + tool_dispatcher: None, + secrets_store: None, + ironhub_catalog_rate_limit: None, } } } @@ -94,6 +100,27 @@ impl TestGatewayBuilder { self } + pub fn with_tool_dispatcher( + mut self, + dispatcher: Arc, + ) -> Self { + self.tool_dispatcher = Some(dispatcher); + self + } + + pub fn with_secrets_store( + mut self, + store: Arc, + ) -> Self { + self.secrets_store = Some(store); + self + } + + pub fn with_ironhub_catalog_rate_limit(mut self, max_requests: u64, window_secs: u64) -> Self { + self.ironhub_catalog_rate_limit = Some((max_requests, window_secs)); + self + } + /// Build the `Arc` without starting a server. pub fn build(self) -> Arc { Arc::new(GatewayState { @@ -124,6 +151,10 @@ impl TestGatewayBuilder { scheduler: None, chat_rate_limiter: PerUserRateLimiter::new(30, 60), oauth_rate_limiter: PerUserRateLimiter::new(20, 60), + ironhub_catalog_rate_limiter: { + let (max, window) = self.ironhub_catalog_rate_limit.unwrap_or((30, 60)); + PerUserRateLimiter::new(max, window) + }, webhook_rate_limiter: RateLimiter::new(10, 60), registry_entries: Vec::new(), cost_guard: None, @@ -132,7 +163,7 @@ impl TestGatewayBuilder { active_config: Arc::new(tokio::sync::RwLock::new( crate::channels::web::platform::state::ActiveConfigSnapshot::default(), )), - secrets_store: None, + secrets_store: self.secrets_store, db_auth: None, pairing_store: None, oauth_providers: None, @@ -144,7 +175,7 @@ impl TestGatewayBuilder { near_network: None, oauth_sweep_shutdown: None, frontend_html_cache: Arc::new(tokio::sync::RwLock::new(None)), - tool_dispatcher: None, + tool_dispatcher: self.tool_dispatcher, }) } @@ -240,6 +271,7 @@ pub(crate) fn test_gateway_state_with_dependencies( scheduler: None, chat_rate_limiter: PerUserRateLimiter::new(30, 60), oauth_rate_limiter: PerUserRateLimiter::new(20, 60), + ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60), webhook_rate_limiter: RateLimiter::new(10, 60), registry_entries: vec![], cost_guard: None, @@ -297,6 +329,7 @@ pub(crate) fn test_gateway_state_with_store_and_session_manager( scheduler: None, chat_rate_limiter: PerUserRateLimiter::new(30, 60), oauth_rate_limiter: PerUserRateLimiter::new(20, 60), + ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60), webhook_rate_limiter: RateLimiter::new(10, 60), registry_entries: vec![], cost_guard: None, diff --git a/src/channels/web/tests/multi_tenant.rs b/src/channels/web/tests/multi_tenant.rs index 1186c62f352..a850a147e9b 100644 --- a/src/channels/web/tests/multi_tenant.rs +++ b/src/channels/web/tests/multi_tenant.rs @@ -81,6 +81,7 @@ fn build_state( scheduler: None, chat_rate_limiter: PerUserRateLimiter::new(30, 60), oauth_rate_limiter: PerUserRateLimiter::new(20, 60), + ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60), webhook_rate_limiter: RateLimiter::new(10, 60), registry_entries: Vec::new(), cost_guard: None, @@ -1399,6 +1400,7 @@ mod admin_tool_policy { scheduler: None, chat_rate_limiter: PerUserRateLimiter::new(30, 60), oauth_rate_limiter: PerUserRateLimiter::new(20, 60), + ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60), webhook_rate_limiter: RateLimiter::new(10, 60), registry_entries: Vec::new(), cost_guard: None, diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index 7b645f3ecd9..8757abde6c9 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -1242,6 +1242,84 @@ pub struct EngineActionResponse { pub ok: bool, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IronhubInstallRequest { + pub name: String, + #[serde(default)] + pub kind: Option, + #[serde(default)] + pub release_tag: Option, + #[serde(default)] + pub force: bool, + #[serde(default)] + pub acknowledge_unverified: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IronhubSearchQuery { + pub query: String, + #[serde(default)] + pub release_tag: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IronhubListQuery { + #[serde(default)] + pub release_tag: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IronhubInfoQuery { + pub name: String, + #[serde(default)] + pub release_tag: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IronhubVerifyIntentRequest { + pub slug: String, + pub version: String, + pub uid: String, + pub aid: String, + pub ts: u64, + pub nonce: String, + pub sig: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IronhubRegisterRequest { + pub uid: String, + pub aid: String, + pub ts: u64, + pub nonce: String, + pub sig: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IronhubSigningKeySetRequest { + pub shared_key: String, +} + +#[derive(Debug, Serialize)] +pub struct IronhubSigningKeyMetadata { + pub fingerprint: String, + pub created_at: String, +} + +#[derive(Debug, Serialize)] +pub struct IronhubVerifyIntentResponse { + pub valid: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/cli/hub.rs b/src/cli/hub.rs new file mode 100644 index 00000000000..58623a52588 --- /dev/null +++ b/src/cli/hub.rs @@ -0,0 +1,581 @@ +use std::path::PathBuf; + +use clap::Subcommand; + +use crate::cli::hub_install::{hub_manifest_url_for_tag, validate_hub_name}; +use crate::registry::{HubInstaller, HubManifest, HubSkillEntry, HubToolEntry}; + +#[derive(Subcommand, Debug, Clone)] +pub enum HubCommand { + /// Install a tool or skill from IronHub by name + Install { + name: String, + + #[arg(long, conflicts_with = "tool")] + skill: bool, + + #[arg(long, conflicts_with = "skill")] + tool: bool, + + #[arg(long)] + release_tag: Option, + + #[arg(long)] + target: Option, + + #[arg(short, long)] + force: bool, + + /// Acknowledge installing UNVERIFIED community content (required when the entry is not NEAR-vetted). + #[arg(long)] + acknowledge_unverified: bool, + }, + + /// Search the IronHub catalog by name or description + Search { + query: String, + + #[arg(long)] + release_tag: Option, + }, + + /// List everything available in IronHub + List { + #[arg(long, conflicts_with = "skills")] + tools: bool, + + #[arg(long, conflicts_with = "tools")] + skills: bool, + + #[arg(long)] + release_tag: Option, + }, + + /// Show detailed metadata for an entry + Info { + name: String, + + #[arg(long)] + release_tag: Option, + }, +} + +pub async fn run_hub_command(cmd: HubCommand) -> anyhow::Result<()> { + match cmd { + HubCommand::Install { + name, + skill, + tool, + release_tag, + target, + force, + acknowledge_unverified, + } => { + install( + &name, + skill, + tool, + release_tag, + target, + force, + acknowledge_unverified, + ) + .await + } + HubCommand::Search { query, release_tag } => search(&query, release_tag).await, + HubCommand::List { + tools, + skills, + release_tag, + } => list(tools, skills, release_tag).await, + HubCommand::Info { name, release_tag } => info(&name, release_tag).await, + } +} + +fn build_installer( + release_tag: Option, + tools_dir_override: Option, + skills_dir_override: Option, +) -> anyhow::Result { + let mut installer = HubInstaller::with_defaults(); + if let Some(dir) = tools_dir_override { + installer = HubInstaller::new( + installer.manifest_url().to_string(), + dir, + installer.skills_dir().to_path_buf(), + ); + } + if let Some(dir) = skills_dir_override { + installer = HubInstaller::new( + installer.manifest_url().to_string(), + installer.tools_dir().to_path_buf(), + dir, + ); + } + if let Some(tag) = release_tag.as_deref() { + installer = installer.with_manifest_url(hub_manifest_url_for_tag(tag)?); + } + Ok(installer) +} + +#[derive(Debug)] +enum Kind { + Tool, + Skill, +} + +fn classify( + manifest: &HubManifest, + name: &str, + force_tool: bool, + force_skill: bool, +) -> anyhow::Result { + let in_tools = manifest.find_tool(name).is_some(); + let in_skills = manifest.find_skill(name).is_some(); + + if force_tool { + if !in_tools { + anyhow::bail!("'{}' is not a tool in this IronHub release", name); + } + return Ok(Kind::Tool); + } + if force_skill { + if !in_skills { + anyhow::bail!("'{}' is not a skill in this IronHub release", name); + } + return Ok(Kind::Skill); + } + + match (in_tools, in_skills) { + (true, false) => Ok(Kind::Tool), + (false, true) => Ok(Kind::Skill), + (true, true) => anyhow::bail!( + "'{}' exists as both a tool and a skill in this release; pass --tool or --skill to disambiguate", + name + ), + (false, false) => { + let suggestions = nearest_matches(manifest, name); + if suggestions.is_empty() { + anyhow::bail!("'{}' is not in this IronHub release", name); + } + anyhow::bail!( + "'{}' is not in this IronHub release. Did you mean: {}?", + name, + suggestions.join(", ") + ) + } + } +} + +fn nearest_matches(manifest: &HubManifest, query: &str) -> Vec { + let q = query.to_ascii_lowercase(); + let mut out: Vec = manifest + .tools + .iter() + .map(|t| t.name.clone()) + .chain(manifest.skills.iter().map(|s| s.name.clone())) + .filter(|n| { + let nl = n.to_ascii_lowercase(); + nl.contains(&q) || q.contains(&nl) + }) + .collect(); + out.sort(); + out.truncate(5); + out +} + +async fn install( + name: &str, + force_skill: bool, + force_tool: bool, + release_tag: Option, + target: Option, + force: bool, + acknowledge_unverified: bool, +) -> anyhow::Result<()> { + validate_hub_name(name)?; + + let probe = build_installer(release_tag.clone(), None, None)?; + println!("Fetching IronHub manifest from {}", probe.manifest_url()); + let manifest = probe + .fetch_manifest() + .await + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + + let kind = classify(&manifest, name, force_tool, force_skill)?; + + let provenance = match kind { + Kind::Tool => manifest.find_tool(name).map(|t| t.provenance), + Kind::Skill => manifest.find_skill(name).map(|s| s.provenance), + } + .unwrap_or_default(); + + if provenance.is_community_unverified() && !acknowledge_unverified { + anyhow::bail!( + "'{name}' is UNVERIFIED community content (trust tier: {}). \ + Not NEAR-vetted. Re-run with --acknowledge-unverified to install at your own risk.", + provenance.as_wire() + ); + } + + let (tools_override, skills_override) = match (&kind, &target) { + (Kind::Tool, Some(dir)) => (Some(dir.clone()), None), + (Kind::Skill, Some(dir)) => (None, Some(dir.clone())), + _ => (None, None), + }; + let installer = build_installer(release_tag, tools_override, skills_override)?; + + match kind { + Kind::Tool => { + println!( + "Installing tool '{}' ({}) from IronHub...", + name, + provenance.as_wire() + ); + let outcome = installer + .install_tool_from_manifest(&manifest, name, force) + .await + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + println!("\nInstalled successfully:"); + println!(" Kind: tool"); + println!(" Name: {}", outcome.name); + println!(" Version: {}", outcome.version); + println!(" Release: {}", outcome.release_tag); + println!(" Provenance: {}", provenance.as_wire()); + println!(" WASM: {}", outcome.primary_path.display()); + if let Some(caps) = outcome.metadata_path { + println!(" Caps: {}", caps.display()); + } + } + Kind::Skill => { + println!( + "Installing skill '{}' ({}) from IronHub...", + name, + provenance.as_wire() + ); + let outcome = installer + .install_skill_from_manifest(&manifest, name, force) + .await + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + println!("\nInstalled successfully:"); + println!(" Kind: skill"); + println!(" Name: {}", outcome.name); + println!(" Version: {}", outcome.version); + println!(" Release: {}", outcome.release_tag); + println!(" Provenance: {}", provenance.as_wire()); + println!(" SKILL.md: {}", outcome.primary_path.display()); + } + } + Ok(()) +} + +async fn search(query: &str, release_tag: Option) -> anyhow::Result<()> { + let installer = build_installer(release_tag, None, None)?; + let manifest = installer + .fetch_manifest() + .await + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + + let q = query.to_ascii_lowercase(); + let tool_hits: Vec<&HubToolEntry> = manifest + .tools + .iter() + .filter(|t| entry_matches(&t.name, &t.description, &q)) + .collect(); + let skill_hits: Vec<&HubSkillEntry> = manifest + .skills + .iter() + .filter(|s| entry_matches(&s.name, &s.description, &q)) + .collect(); + + if tool_hits.is_empty() && skill_hits.is_empty() { + println!( + "No matches for '{}' in release {}", + query, manifest.release_tag + ); + return Ok(()); + } + + println!("Release: {}", manifest.release_tag); + if !tool_hits.is_empty() { + println!("\nTools:"); + for t in &tool_hits { + print_tool_row(t); + } + } + if !skill_hits.is_empty() { + println!("\nSkills:"); + for s in &skill_hits { + print_skill_row(s); + } + } + Ok(()) +} + +async fn list( + tools_only: bool, + skills_only: bool, + release_tag: Option, +) -> anyhow::Result<()> { + let installer = build_installer(release_tag, None, None)?; + let manifest = installer + .fetch_manifest() + .await + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + + println!("Release: {}", manifest.release_tag); + println!("Repo: {}", manifest.repo); + + let show_tools = !skills_only; + let show_skills = !tools_only; + + if show_tools { + if manifest.tools.is_empty() { + println!("\nTools: (none in this release)"); + } else { + println!("\nTools ({}):", manifest.tools.len()); + for t in &manifest.tools { + print_tool_row(t); + } + } + } + + if show_skills { + if manifest.skills.is_empty() { + println!("\nSkills: (none in this release)"); + } else { + println!("\nSkills ({}):", manifest.skills.len()); + for s in &manifest.skills { + print_skill_row(s); + } + } + } + Ok(()) +} + +async fn info(name: &str, release_tag: Option) -> anyhow::Result<()> { + validate_hub_name(name)?; + let installer = build_installer(release_tag, None, None)?; + let manifest = installer + .fetch_manifest() + .await + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + + if let Some(t) = manifest.find_tool(name) { + println!("Kind: tool"); + println!("Name: {}", t.name); + println!("Crate: {}", t.crate_name); + println!("Version: {}", t.version); + println!("Description: {}", display_or_dash(&t.description)); + println!("Release: {}", manifest.release_tag); + println!("\nWASM artifact:"); + println!(" URL: {}", t.wasm.url); + println!(" Size: {} bytes", t.wasm.size_bytes); + println!(" SHA256: {}", t.wasm.sha256); + println!("\nCapabilities artifact:"); + println!(" URL: {}", t.capabilities.url); + println!(" Size: {} bytes", t.capabilities.size_bytes); + println!(" SHA256: {}", t.capabilities.sha256); + return Ok(()); + } + if let Some(s) = manifest.find_skill(name) { + println!("Kind: skill"); + println!("Name: {}", s.name); + if !s.trunk.is_empty() { + println!("Trunk: {}", s.trunk); + } + println!("Version: {}", display_or_dash(&s.version)); + println!("Description: {}", display_or_dash(&s.description)); + println!("Release: {}", manifest.release_tag); + println!("\nSKILL.md artifact:"); + println!(" URL: {}", s.skill_md.url); + println!(" Size: {} bytes", s.skill_md.size_bytes); + println!(" SHA256: {}", s.skill_md.sha256); + return Ok(()); + } + + let suggestions = nearest_matches(&manifest, name); + if suggestions.is_empty() { + anyhow::bail!("'{}' is not in this IronHub release", name); + } + anyhow::bail!( + "'{}' is not in this IronHub release. Did you mean: {}?", + name, + suggestions.join(", ") + ); +} + +fn entry_matches(name: &str, description: &str, query_lower: &str) -> bool { + name.to_ascii_lowercase().contains(query_lower) + || description.to_ascii_lowercase().contains(query_lower) +} + +fn print_tool_row(t: &HubToolEntry) { + println!( + " {:<24} {:<10} {:<12} {}", + t.name, + t.version, + t.provenance.trust_label(), + display_or_dash(&t.description) + ); +} + +fn print_skill_row(s: &HubSkillEntry) { + let version = if s.version.is_empty() { + "-" + } else { + s.version.as_str() + }; + println!( + " {:<24} {:<10} {:<12} {}", + s.name, + version, + s.provenance.trust_label(), + display_or_dash(&s.description) + ); +} + +fn display_or_dash(s: &str) -> &str { + if s.is_empty() { "-" } else { s } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::{HubArtifact, HubSkillEntry, HubToolEntry, Provenance}; + + fn art(name: &str, ext: &str) -> HubArtifact { + HubArtifact { + url: format!( + "https://github.com/nearai/ironhub/releases/download/test/{}.{}", + name, ext + ), + size_bytes: 1024, + sha256: "a".repeat(64), + } + } + + fn manifest_with(tools: Vec<&str>, skills: Vec<&str>) -> HubManifest { + HubManifest { + version: "1".into(), + generated_at: "2026-05-14T00:00:00Z".into(), + release_tag: "release-test".into(), + repo: "nearai/ironhub".into(), + tools: tools + .into_iter() + .map(|n| HubToolEntry { + name: n.into(), + crate_name: format!("{}-tool", n), + version: "0.1.0".into(), + description: format!("{} tool", n), + provenance: Provenance::Official, + wasm: art(n, "wasm"), + capabilities: art(n, "capabilities.json"), + }) + .collect(), + skills: skills + .into_iter() + .map(|n| HubSkillEntry { + name: n.into(), + trunk: String::new(), + version: "0.1.0".into(), + description: format!("{} skill", n), + provenance: Provenance::Official, + skill_md: art(n, "SKILL.md"), + }) + .collect(), + } + } + + #[test] + fn classify_picks_tool_when_only_in_tools() { + let m = manifest_with(vec!["clickup"], vec!["chief-of-staff"]); + assert!(matches!( + classify(&m, "clickup", false, false).unwrap(), + Kind::Tool + )); + } + + #[test] + fn classify_picks_skill_when_only_in_skills() { + let m = manifest_with(vec!["clickup"], vec!["chief-of-staff"]); + assert!(matches!( + classify(&m, "chief-of-staff", false, false).unwrap(), + Kind::Skill + )); + } + + #[test] + fn classify_requires_disambiguation_when_in_both() { + let m = manifest_with(vec!["overlap"], vec!["overlap"]); + let err = classify(&m, "overlap", false, false).expect_err("must error"); + assert!(err.to_string().contains("disambiguate")); + } + + #[test] + fn classify_honors_force_tool_flag() { + let m = manifest_with(vec!["overlap"], vec!["overlap"]); + assert!(matches!( + classify(&m, "overlap", true, false).unwrap(), + Kind::Tool + )); + } + + #[test] + fn classify_honors_force_skill_flag() { + let m = manifest_with(vec!["overlap"], vec!["overlap"]); + assert!(matches!( + classify(&m, "overlap", false, true).unwrap(), + Kind::Skill + )); + } + + #[test] + fn classify_force_tool_rejects_skill_only_name() { + let m = manifest_with(vec![], vec!["chief-of-staff"]); + let err = classify(&m, "chief-of-staff", true, false).expect_err("must error"); + assert!(err.to_string().contains("not a tool")); + } + + #[test] + fn classify_force_skill_rejects_tool_only_name() { + let m = manifest_with(vec!["clickup"], vec![]); + let err = classify(&m, "clickup", false, true).expect_err("must error"); + assert!(err.to_string().contains("not a skill")); + } + + #[test] + fn classify_unknown_name_suggests_nearest() { + let m = manifest_with(vec!["clickup", "evm-rpc"], vec![]); + let err = classify(&m, "click", false, false).expect_err("must error"); + let msg = err.to_string(); + assert!(msg.contains("Did you mean")); + assert!(msg.contains("clickup")); + } + + #[test] + fn entry_matches_lowercases_name_against_already_lowercased_query() { + assert!(entry_matches("ClickUp", "Task tracking", "clickup")); + assert!(entry_matches("clickup", "Task tracking", "click")); + assert!(!entry_matches("clickup", "Task tracking", "CLICK")); + } + + #[test] + fn entry_matches_searches_description() { + assert!(entry_matches( + "evm-rpc", + "Ethereum RPC bindings", + "ethereum" + )); + assert!(!entry_matches("evm-rpc", "Ethereum RPC bindings", "solana")); + } + + #[test] + fn nearest_matches_filters_by_substring_both_directions() { + let m = manifest_with(vec!["clickup", "evm-rpc", "near-rpc"], vec![]); + let hits = nearest_matches(&m, "rpc"); + assert!(hits.contains(&"evm-rpc".to_string())); + assert!(hits.contains(&"near-rpc".to_string())); + assert!(!hits.contains(&"clickup".to_string())); + } +} diff --git a/src/cli/hub_install.rs b/src/cli/hub_install.rs new file mode 100644 index 00000000000..5aeacb17052 --- /dev/null +++ b/src/cli/hub_install.rs @@ -0,0 +1,124 @@ +pub fn looks_like_hub_name(s: &str) -> bool { + if s.is_empty() { + return false; + } + let mut chars = s.chars(); + let first = chars.next().unwrap_or('\0'); + if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { + return false; + } + chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') +} + +pub fn validate_hub_name(s: &str) -> anyhow::Result<()> { + if looks_like_hub_name(s) { + Ok(()) + } else { + anyhow::bail!( + "'{}' is not a valid IronHub name (lowercase letters, digits, hyphens, underscores; must start with a letter or digit).", + s + ) + } +} + +pub fn hub_manifest_url_for_tag(tag: &str) -> anyhow::Result { + validate_release_tag(tag)?; + Ok(format!( + "https://github.com/nearai/ironhub/releases/download/{}/tools.json", + tag + )) +} + +pub fn validate_release_tag(tag: &str) -> anyhow::Result<()> { + if tag.is_empty() { + anyhow::bail!("release tag must not be empty"); + } + let valid = tag + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_'); + if !valid { + anyhow::bail!( + "release tag '{}' contains characters outside [A-Za-z0-9._-]", + tag + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn looks_like_hub_name_accepts_simple_names() { + assert!(looks_like_hub_name("clickup")); + assert!(looks_like_hub_name("evm-rpc")); + assert!(looks_like_hub_name("near-rpc")); + assert!(looks_like_hub_name("microsoft-365")); + assert!(looks_like_hub_name("microsoft_365")); + assert!(looks_like_hub_name("a1-b2_c3")); + assert!(looks_like_hub_name("a")); + } + + #[test] + fn looks_like_hub_name_rejects_paths_and_extensions() { + assert!(!looks_like_hub_name("")); + assert!(!looks_like_hub_name("./local")); + assert!(!looks_like_hub_name("/abs/path")); + assert!(!looks_like_hub_name("tools/clickup")); + assert!(!looks_like_hub_name("name.wasm")); + assert!(!looks_like_hub_name("Name")); + assert!(!looks_like_hub_name("-leading-hyphen")); + assert!(!looks_like_hub_name("_leading-underscore")); + assert!(!looks_like_hub_name("name with space")); + } + + #[test] + fn hub_manifest_url_for_tag_renders_release_url() { + let url = hub_manifest_url_for_tag("release-2026-05-12-24").expect("valid tag"); + assert_eq!( + url, + "https://github.com/nearai/ironhub/releases/download/release-2026-05-12-24/tools.json" + ); + } + + #[test] + fn validate_release_tag_accepts_real_tags() { + for tag in [ + "release-2026-05-12-24", + "v1.0.0", + "release_2026_05_12", + "RC1", + "0", + ] { + assert!(validate_release_tag(tag).is_ok(), "expected {:?}", tag); + } + } + + #[test] + fn validate_release_tag_rejects_unsafe_input() { + for tag in [ + "", + "../etc", + "release/../other", + "release@evil.com", + "release with space", + "release\nnewline", + "release\0null", + "release?query=1", + "release#frag", + ] { + assert!( + validate_release_tag(tag).is_err(), + "expected {:?} to fail", + tag + ); + } + } + + #[test] + fn hub_manifest_url_for_tag_propagates_validation_failure() { + let err = hub_manifest_url_for_tag("../etc").expect_err("traversal must fail"); + assert!(err.to_string().contains("characters outside")); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7dd097dfb12..4d768196c4f 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -22,6 +22,8 @@ mod config; mod doctor; pub mod fmt; mod hooks; +mod hub; +pub(crate) mod hub_install; #[cfg(feature = "import")] pub mod import; mod logs; @@ -43,6 +45,7 @@ pub use completion::Completion; pub use config::{ConfigCommand, run_config_command}; pub use doctor::run_doctor_command; pub use hooks::{HooksCommand, run_hooks_command}; +pub use hub::{HubCommand, run_hub_command}; #[cfg(feature = "import")] pub use import::{ImportCommand, run_import_command}; pub use logs::{LogsCommand, run_logs_command}; @@ -183,6 +186,14 @@ pub enum Command { )] Tool(ToolCommand), + /// Browse and install tools and skills from IronHub + #[command( + subcommand, + about = "Browse and install tools and skills from IronHub", + long_about = "Catalog at hub.ironclaw.com.\nExample: ironclaw hub install clickup" + )] + Hub(HubCommand), + /// Browse and install extensions from the registry #[command( subcommand, diff --git a/src/cli/skills.rs b/src/cli/skills.rs index b0cdbe6232d..593626d535a 100644 --- a/src/cli/skills.rs +++ b/src/cli/skills.rs @@ -1,15 +1,23 @@ //! Skills management CLI commands. //! -//! Commands for listing, searching, and inspecting SKILL.md-based skills. -//! List and info operate on the filesystem only; search queries the ClawHub registry. +//! Commands for listing, searching, inspecting, and installing SKILL.md-based skills. +//! List and info operate on the filesystem only; search queries the ClawHub registry; +//! install pulls from the IronHub release manifest or copies from a local path. -use std::path::Path; +use std::path::{Path, PathBuf}; use clap::Subcommand; +use tokio::fs; +use crate::bootstrap::ironclaw_base_dir; +use crate::cli::hub_install::looks_like_hub_name; use crate::config::SkillsConfig; use ironclaw_skills::catalog::SkillCatalog; -use ironclaw_skills::{SkillRegistry, SkillSource}; +use ironclaw_skills::{SkillRegistry, SkillSource, parse_skill_md}; + +fn default_skills_dir() -> PathBuf { + ironclaw_base_dir().join("skills") +} #[derive(Subcommand, Debug, Clone)] pub enum SkillsCommand { @@ -43,6 +51,17 @@ pub enum SkillsCommand { #[arg(long)] json: bool, }, + + /// Install a skill from a local SKILL.md or directory. For IronHub installs use `ironclaw hub install `. + Install { + path: PathBuf, + + #[arg(short, long)] + target: Option, + + #[arg(short, long)] + force: bool, + }, } /// Run the skills CLI subcommand. @@ -63,7 +82,106 @@ pub async fn run_skills_command( SkillsCommand::List { verbose, json } => cmd_list(&config, verbose, json).await, SkillsCommand::Search { query, json } => cmd_search(&query, json).await, SkillsCommand::Info { name, json } => cmd_info(&config, &name, json).await, + SkillsCommand::Install { + path, + target, + force, + } => install_skill_local(&path, target, force).await, + } +} + +async fn install_skill_local( + source_path: &Path, + target: Option, + force: bool, +) -> anyhow::Result<()> { + let skills_dir = target.unwrap_or_else(default_skills_dir); + + let metadata = fs::metadata(source_path) + .await + .map_err(|e| anyhow::anyhow!("Cannot read {}: {}", source_path.display(), e))?; + + let (skill_md_src, skill_name) = if metadata.is_dir() { + let candidate = source_path.join("SKILL.md"); + if !candidate.exists() { + anyhow::bail!("No SKILL.md found in directory {}.", source_path.display()); + } + let name = source_path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| { + anyhow::anyhow!("Cannot derive skill name from {}", source_path.display()) + })? + .to_string(); + (candidate, name) + } else if source_path.file_name().and_then(|n| n.to_str()) == Some("SKILL.md") { + let parent = source_path + .parent() + .ok_or_else(|| anyhow::anyhow!("SKILL.md has no parent directory"))?; + let name = parent + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| anyhow::anyhow!("Cannot derive skill name from {}", parent.display()))? + .to_string(); + (source_path.to_path_buf(), name) + } else { + anyhow::bail!( + "Expected a SKILL.md file or a directory containing one, got: {}", + source_path.display() + ); + }; + + if !looks_like_hub_name(&skill_name) { + anyhow::bail!( + "Skill directory name '{}' is not a valid identifier (lowercase letters, digits, hyphens; must start with a letter or digit).", + skill_name + ); + } + + let content = fs::read_to_string(&skill_md_src) + .await + .map_err(|e| anyhow::anyhow!("Cannot read {}: {}", skill_md_src.display(), e))?; + let parsed = parse_skill_md(&content) + .map_err(|e| anyhow::anyhow!("Invalid SKILL.md at {}: {}", skill_md_src.display(), e))?; + if parsed.manifest.name != skill_name { + anyhow::bail!( + "SKILL.md manifest name '{}' does not match directory '{}'.", + parsed.manifest.name, + skill_name + ); + } + + let target_dir = skills_dir.join(&skill_name); + let target_md = target_dir.join("SKILL.md"); + + if target_md.exists() && !force { + anyhow::bail!( + "Skill '{}' already exists at {}. Use --force to overwrite.", + skill_name, + target_md.display() + ); + } + + fs::create_dir_all(&target_dir).await?; + fs::write(&target_md, content.as_bytes()).await?; + + let on_disk = fs::metadata(&target_md).await?; + if on_disk.len() as usize != content.len() { + anyhow::bail!( + "On-disk size mismatch after write at {} (expected {} bytes, got {}).", + target_md.display(), + content.len(), + on_disk.len() + ); } + + println!("\nInstalled successfully:"); + println!(" Name: {}", skill_name); + println!(" Version: {}", parsed.manifest.version); + println!(" Path: {}", target_md.display()); + println!(" Size: {} bytes", content.len()); + + Ok(()) } /// Discover skills from all configured directories. @@ -373,4 +491,190 @@ mod tests { "bundled" ); } + + fn skill_md_fixture(name: &str, version: &str) -> String { + format!( + "---\nname: {}\nversion: {}\ndescription: test fixture\n---\n\nBody content for {}.\n", + name, version, name + ) + } + + #[tokio::test] + async fn install_skill_local_copies_skill_md_from_directory() { + let src = tempfile::tempdir().expect("src tempdir"); + let skill_dir = src.path().join("test-skill"); + fs::create_dir(&skill_dir).await.expect("mkdir"); + let body = skill_md_fixture("test-skill", "0.1.0"); + fs::write(skill_dir.join("SKILL.md"), &body) + .await + .expect("write"); + + let dest = tempfile::tempdir().expect("dest tempdir"); + install_skill_local(&skill_dir, Some(dest.path().to_path_buf()), false) + .await + .expect("install ok"); + + let installed = dest.path().join("test-skill/SKILL.md"); + assert!(installed.exists()); + let on_disk = fs::read_to_string(&installed).await.expect("read"); + assert_eq!(on_disk, body); + } + + #[tokio::test] + async fn install_skill_local_accepts_direct_skill_md_path() { + let src = tempfile::tempdir().expect("src tempdir"); + let skill_dir = src.path().join("test-skill"); + fs::create_dir(&skill_dir).await.expect("mkdir"); + let md_path = skill_dir.join("SKILL.md"); + let body = skill_md_fixture("test-skill", "0.1.0"); + fs::write(&md_path, &body).await.expect("write"); + + let dest = tempfile::tempdir().expect("dest tempdir"); + install_skill_local(&md_path, Some(dest.path().to_path_buf()), false) + .await + .expect("install ok"); + + assert!(dest.path().join("test-skill/SKILL.md").exists()); + } + + #[tokio::test] + async fn install_skill_local_refuses_overwrite_without_force() { + let src = tempfile::tempdir().expect("src tempdir"); + let skill_dir = src.path().join("test-skill"); + fs::create_dir(&skill_dir).await.expect("mkdir"); + fs::write( + skill_dir.join("SKILL.md"), + skill_md_fixture("test-skill", "0.1.0"), + ) + .await + .expect("write"); + + let dest = tempfile::tempdir().expect("dest tempdir"); + install_skill_local(&skill_dir, Some(dest.path().to_path_buf()), false) + .await + .expect("first install"); + + let err = install_skill_local(&skill_dir, Some(dest.path().to_path_buf()), false) + .await + .expect_err("second install must fail without force"); + assert!(err.to_string().contains("already exists")); + } + + #[tokio::test] + async fn install_skill_local_overwrites_with_force() { + let src = tempfile::tempdir().expect("src tempdir"); + let skill_dir = src.path().join("test-skill"); + fs::create_dir(&skill_dir).await.expect("mkdir"); + fs::write( + skill_dir.join("SKILL.md"), + skill_md_fixture("test-skill", "0.1.0"), + ) + .await + .expect("write"); + + let dest = tempfile::tempdir().expect("dest tempdir"); + install_skill_local(&skill_dir, Some(dest.path().to_path_buf()), false) + .await + .expect("first install"); + + let updated = skill_md_fixture("test-skill", "0.2.0"); + fs::write(skill_dir.join("SKILL.md"), &updated) + .await + .expect("update src"); + install_skill_local(&skill_dir, Some(dest.path().to_path_buf()), true) + .await + .expect("forced reinstall"); + + let on_disk = fs::read_to_string(dest.path().join("test-skill/SKILL.md")) + .await + .expect("read"); + assert_eq!(on_disk, updated); + } + + #[tokio::test] + async fn install_skill_local_rejects_directory_without_skill_md() { + let src = tempfile::tempdir().expect("src tempdir"); + let bare = src.path().join("empty-skill"); + fs::create_dir(&bare).await.expect("mkdir"); + + let dest = tempfile::tempdir().expect("dest tempdir"); + let err = install_skill_local(&bare, Some(dest.path().to_path_buf()), false) + .await + .expect_err("missing SKILL.md must fail"); + assert!(err.to_string().contains("No SKILL.md")); + } + + #[tokio::test] + async fn install_skill_local_rejects_arbitrary_file() { + let src = tempfile::tempdir().expect("src tempdir"); + let stray = src.path().join("README.md"); + fs::write(&stray, b"# not a skill").await.expect("write"); + + let dest = tempfile::tempdir().expect("dest tempdir"); + let err = install_skill_local(&stray, Some(dest.path().to_path_buf()), false) + .await + .expect_err("arbitrary file must fail"); + assert!(err.to_string().contains("SKILL.md")); + } + + #[tokio::test] + async fn install_skill_local_rejects_bad_dir_name() { + let src = tempfile::tempdir().expect("src tempdir"); + let bad_dir = src.path().join("Bad Name!"); + fs::create_dir(&bad_dir).await.expect("mkdir"); + fs::write( + bad_dir.join("SKILL.md"), + skill_md_fixture("bad-name", "0.1.0"), + ) + .await + .expect("write"); + + let dest = tempfile::tempdir().expect("dest tempdir"); + let err = install_skill_local(&bad_dir, Some(dest.path().to_path_buf()), false) + .await + .expect_err("bad dir name must fail"); + assert!(err.to_string().contains("not a valid identifier")); + } + + #[tokio::test] + async fn install_skill_local_rejects_malformed_skill_md() { + let src = tempfile::tempdir().expect("src tempdir"); + let skill_dir = src.path().join("test-skill"); + fs::create_dir(&skill_dir).await.expect("mkdir"); + fs::write(skill_dir.join("SKILL.md"), b"no frontmatter here at all") + .await + .expect("write"); + + let dest = tempfile::tempdir().expect("dest tempdir"); + let err = install_skill_local(&skill_dir, Some(dest.path().to_path_buf()), false) + .await + .expect_err("malformed SKILL.md must fail"); + assert!(err.to_string().contains("Invalid SKILL.md")); + } + + #[tokio::test] + async fn install_skill_local_rejects_manifest_name_mismatch() { + let src = tempfile::tempdir().expect("src tempdir"); + let skill_dir = src.path().join("test-skill"); + fs::create_dir(&skill_dir).await.expect("mkdir"); + fs::write( + skill_dir.join("SKILL.md"), + skill_md_fixture("different-name", "0.1.0"), + ) + .await + .expect("write"); + + let dest = tempfile::tempdir().expect("dest tempdir"); + let err = install_skill_local(&skill_dir, Some(dest.path().to_path_buf()), false) + .await + .expect_err("name mismatch must fail"); + assert!(err.to_string().contains("does not match")); + } + + #[test] + fn default_skills_dir_under_ironclaw_base() { + let dir = default_skills_dir(); + assert!(dir.to_string_lossy().contains(".ironclaw")); + assert!(dir.to_string_lossy().contains("skills")); + } } diff --git a/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap b/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap index 7d1d8afc6a3..696238a97d7 100644 --- a/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap +++ b/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap @@ -1,5 +1,6 @@ --- source: src/cli/mod.rs +assertion_line: 505 expression: help --- Secure personal AI assistant that protects your data and expands its capabilities @@ -11,6 +12,7 @@ Commands: onboard Run interactive setup wizard (start here if new to IronClaw) config Manage app configuration settings tool Manage WASM tools + hub Browse and install tools and skills from IronHub registry Browse/install extensions channels Manage channels routines Manage routines diff --git a/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap b/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap index 0aa6e16739e..863dfe4ba67 100644 --- a/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap +++ b/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap @@ -1,5 +1,6 @@ --- source: src/cli/mod.rs +assertion_line: 521 expression: help --- IronClaw is a secure AI assistant. @@ -25,6 +26,7 @@ Commands: onboard Run interactive setup wizard (start here if new to IronClaw) config Manage app configuration settings tool Manage WASM tools + hub Browse and install tools and skills from IronHub registry Browse/install extensions channels Manage channels routines Manage routines diff --git a/src/cli/tool.rs b/src/cli/tool.rs index dfe94ae31b1..986637d763a 100644 --- a/src/cli/tool.rs +++ b/src/cli/tool.rs @@ -21,32 +21,25 @@ fn default_tools_dir() -> PathBuf { #[derive(Subcommand, Debug, Clone)] pub enum ToolCommand { - /// Install a WASM tool from source directory or .wasm file + /// Install a WASM tool from a source dir or .wasm file. For IronHub installs use `ironclaw hub install `. Install { - /// Path to tool source directory (with Cargo.toml) or .wasm file path: PathBuf, - /// Tool name (defaults to directory/file name) #[arg(short, long)] name: Option, - /// Path to capabilities JSON file (auto-detected if not specified) #[arg(long)] capabilities: Option, - /// Target directory for installation (default: ~/.ironclaw/tools/) #[arg(short, long)] target: Option, - /// Build in release mode (default: true) #[arg(long, default_value = "true")] release: bool, - /// Skip compilation (use existing .wasm file) #[arg(long)] skip_build: bool, - /// Force overwrite if tool already exists #[arg(short, long)] force: bool, }, diff --git a/src/main.rs b/src/main.rs index 5a4021f4e6e..cc9e881aea1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -148,6 +148,10 @@ async fn async_main() -> anyhow::Result<()> { init_cli_tracing(); return run_tool_command(tool_cmd.clone()).await; } + Some(Command::Hub(hub_cmd)) => { + init_cli_tracing(); + return ironclaw::cli::run_hub_command(hub_cmd.clone()).await; + } Some(Command::Config(config_cmd)) => { init_cli_tracing(); return ironclaw::cli::run_config_command(config_cmd.clone()).await; diff --git a/src/registry/hub_installer.rs b/src/registry/hub_installer.rs new file mode 100644 index 00000000000..2fa0154aa62 --- /dev/null +++ b/src/registry/hub_installer.rs @@ -0,0 +1,1155 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock}; + +use tokio::fs; +use tokio::sync::Mutex as AsyncMutex; + +use crate::bootstrap::ironclaw_base_dir; +use crate::registry::catalog::RegistryError; +use crate::registry::hub_manifest::{ + DEFAULT_HUB_MANIFEST_URL, HubManifest, HubSkillEntry, HubToolEntry, Provenance, +}; +use crate::registry::installer::{download_artifact, validate_artifact_url, verify_sha256}; + +const MAX_MANIFEST_BYTES: usize = 1024 * 1024; +const MAX_METADATA_BYTES: usize = 1024 * 1024; +const MAX_WASM_BYTES: usize = 16 * 1024 * 1024; + +static INSTALL_LOCKS: LazyLock>>>> = + LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + +fn acquire_install_lock(key: &str) -> Arc> { + let mut guard = INSTALL_LOCKS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(lock) = guard.get(key) { + return Arc::clone(lock); + } + let lock = Arc::new(AsyncMutex::new(())); + guard.insert(key.to_string(), Arc::clone(&lock)); + lock +} + +async fn write_atomic(target: &Path, bytes: &[u8]) -> Result<(), RegistryError> { + let file_name = target + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("artifact"); + let temp_name = format!("{}.tmp.{}", file_name, uuid::Uuid::new_v4()); + let temp = target.with_file_name(temp_name); + if let Err(e) = fs::write(&temp, bytes).await { + cleanup_partial_artifact(&temp).await; + return Err(RegistryError::Io(e)); + } + if let Err(e) = confirm_written_size(&temp, bytes.len()).await { + cleanup_partial_artifact(&temp).await; + return Err(e); + } + if let Err(e) = fs::rename(&temp, target).await { + cleanup_partial_artifact(&temp).await; + return Err(RegistryError::Io(e)); + } + Ok(()) +} + +pub(crate) fn validate_hub_artifact_name( + name: &str, + field: &'static str, +) -> Result<(), RegistryError> { + let is_valid = !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'); + + if is_valid { + Ok(()) + } else { + Err(RegistryError::InvalidManifest { + name: name.to_string(), + field, + reason: "name must be non-empty and contain only lowercase letters, digits, '-', '_'" + .to_string(), + }) + } +} + +#[derive(Debug)] +pub struct HubInstallOutcome { + pub name: String, + pub version: String, + pub release_tag: String, + pub provenance: Provenance, + pub primary_path: PathBuf, + pub metadata_path: Option, +} + +pub struct HubInstaller { + manifest_url: String, + tools_dir: PathBuf, + skills_dir: PathBuf, +} + +impl HubInstaller { + pub fn new(manifest_url: String, tools_dir: PathBuf, skills_dir: PathBuf) -> Self { + Self { + manifest_url, + tools_dir, + skills_dir, + } + } + + pub fn with_defaults() -> Self { + let base = ironclaw_base_dir(); + Self::new( + DEFAULT_HUB_MANIFEST_URL.to_string(), + base.join("tools"), + base.join("skills"), + ) + } + + pub fn with_manifest_url(mut self, url: String) -> Self { + self.manifest_url = url; + self + } + + pub fn with_tools_dir(mut self, dir: PathBuf) -> Self { + self.tools_dir = dir; + self + } + + pub fn with_skills_dir(mut self, dir: PathBuf) -> Self { + self.skills_dir = dir; + self + } + + pub fn manifest_url(&self) -> &str { + &self.manifest_url + } + + pub fn tools_dir(&self) -> &Path { + &self.tools_dir + } + + pub fn skills_dir(&self) -> &Path { + &self.skills_dir + } + + pub async fn fetch_manifest(&self) -> Result { + validate_artifact_url("hub-manifest", "manifest_url", &self.manifest_url)?; + + let bytes = download_artifact(&self.manifest_url, MAX_MANIFEST_BYTES as u64).await?; + if bytes.len() > MAX_MANIFEST_BYTES { + return Err(RegistryError::DownloadFailed { + url: self.manifest_url.clone(), + reason: format!( + "manifest exceeds {} byte cap (got {})", + MAX_MANIFEST_BYTES, + bytes.len() + ), + }); + } + + serde_json::from_slice::(&bytes).map_err(|e| RegistryError::ManifestParse { + path: PathBuf::from(&self.manifest_url), + reason: e.to_string(), + }) + } + + pub async fn install_tool_by_name( + &self, + name: &str, + force: bool, + ) -> Result { + let manifest = self.fetch_manifest().await?; + self.install_tool_from_manifest(&manifest, name, force) + .await + } + + pub async fn install_skill_by_name( + &self, + name: &str, + force: bool, + ) -> Result { + let manifest = self.fetch_manifest().await?; + self.install_skill_from_manifest(&manifest, name, force) + .await + } + + pub async fn install_tool_from_manifest( + &self, + manifest: &HubManifest, + name: &str, + force: bool, + ) -> Result { + let entry = manifest + .find_tool(name) + .ok_or_else(|| RegistryError::ExtensionNotFound(format!("tool '{}'", name)))?; + self.install_tool_entry(entry, &manifest.release_tag, force) + .await + } + + pub async fn install_skill_from_manifest( + &self, + manifest: &HubManifest, + name: &str, + force: bool, + ) -> Result { + let entry = manifest + .find_skill(name) + .ok_or_else(|| RegistryError::ExtensionNotFound(format!("skill '{}'", name)))?; + self.install_skill_entry(entry, &manifest.release_tag, force) + .await + } + + pub async fn install_tool_entry( + &self, + entry: &HubToolEntry, + release_tag: &str, + force: bool, + ) -> Result { + validate_tool_entry(entry)?; + + 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 + } + + pub async fn install_skill_entry( + &self, + entry: &HubSkillEntry, + release_tag: &str, + force: bool, + ) -> Result { + validate_skill_entry(entry)?; + + let md_bytes = download_artifact(&entry.skill_md.url, MAX_METADATA_BYTES as u64).await?; + + self.install_skill_from_bytes(entry, release_tag, &md_bytes, force) + .await + } + + pub async fn install_tool_from_bytes( + &self, + entry: &HubToolEntry, + release_tag: &str, + wasm_bytes: &[u8], + caps_bytes: &[u8], + force: bool, + ) -> Result { + validate_hub_artifact_name(&entry.name, "tools[].name")?; + + if wasm_bytes.len() > MAX_WASM_BYTES { + return Err(RegistryError::DownloadFailed { + url: entry.wasm.url.clone(), + reason: format!( + "wasm exceeds {} byte cap (got {})", + MAX_WASM_BYTES, + wasm_bytes.len() + ), + }); + } + if caps_bytes.len() > MAX_METADATA_BYTES { + return Err(RegistryError::DownloadFailed { + url: entry.capabilities.url.clone(), + reason: format!( + "capabilities exceeds {} byte cap (got {})", + MAX_METADATA_BYTES, + caps_bytes.len() + ), + }); + } + + verify_sha256(wasm_bytes, &entry.wasm.sha256, &entry.wasm.url)?; + verify_sha256( + caps_bytes, + &entry.capabilities.sha256, + &entry.capabilities.url, + )?; + + fs::create_dir_all(&self.tools_dir) + .await + .map_err(RegistryError::Io)?; + + let target_wasm = self.tools_dir.join(format!("{}.wasm", entry.name)); + let target_caps = self + .tools_dir + .join(format!("{}.capabilities.json", entry.name)); + + let lock = acquire_install_lock(&format!("tool:{}", entry.name)); + let _guard = lock.lock().await; + + if target_wasm.exists() && !force { + return Err(RegistryError::AlreadyInstalled { + name: entry.name.clone(), + path: target_wasm, + }); + } + + write_atomic(&target_wasm, wasm_bytes).await?; + if let Err(e) = write_atomic(&target_caps, caps_bytes).await { + cleanup_partial_artifact(&target_wasm).await; + return Err(e); + } + + Ok(HubInstallOutcome { + name: entry.name.clone(), + version: entry.version.clone(), + release_tag: release_tag.to_string(), + provenance: entry.provenance, + primary_path: target_wasm, + metadata_path: Some(target_caps), + }) + } + + pub async fn install_skill_from_bytes( + &self, + entry: &HubSkillEntry, + release_tag: &str, + md_bytes: &[u8], + force: bool, + ) -> Result { + validate_hub_artifact_name(&entry.name, "skills[].name")?; + + if md_bytes.len() > MAX_METADATA_BYTES { + return Err(RegistryError::DownloadFailed { + url: entry.skill_md.url.clone(), + reason: format!( + "SKILL.md exceeds {} byte cap (got {})", + MAX_METADATA_BYTES, + md_bytes.len() + ), + }); + } + + verify_sha256(md_bytes, &entry.skill_md.sha256, &entry.skill_md.url)?; + + let skill_dir = self.skills_dir.join(&entry.name); + fs::create_dir_all(&skill_dir) + .await + .map_err(RegistryError::Io)?; + + let target_md = skill_dir.join("SKILL.md"); + + let lock = acquire_install_lock(&format!("skill:{}", entry.name)); + let _guard = lock.lock().await; + + if target_md.exists() && !force { + return Err(RegistryError::AlreadyInstalled { + name: entry.name.clone(), + path: target_md, + }); + } + + write_atomic(&target_md, md_bytes).await?; + + Ok(HubInstallOutcome { + name: entry.name.clone(), + version: entry.version.clone(), + release_tag: release_tag.to_string(), + provenance: entry.provenance, + primary_path: target_md, + metadata_path: None, + }) + } +} + +async fn confirm_written_size(path: &Path, expected: usize) -> Result<(), RegistryError> { + let metadata = fs::metadata(path).await.map_err(RegistryError::Io)?; + let actual = metadata.len() as usize; + if actual != expected { + return Err(RegistryError::DownloadFailed { + url: path.display().to_string(), + reason: format!( + "on-disk size mismatch after write: expected {} bytes, got {}", + expected, actual + ), + }); + } + Ok(()) +} + +async fn cleanup_partial_artifact(path: &Path) { + match fs::remove_file(path).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => tracing::warn!("failed to remove partial artifact {}: {e}", path.display()), + } +} + +fn validate_tool_entry(entry: &HubToolEntry) -> Result<(), RegistryError> { + validate_hub_artifact_name(&entry.name, "tools[].name")?; + validate_artifact_url(&entry.name, "tools[].wasm.url", &entry.wasm.url)?; + validate_artifact_url( + &entry.name, + "tools[].capabilities.url", + &entry.capabilities.url, + )?; + Ok(()) +} + +fn validate_skill_entry(entry: &HubSkillEntry) -> Result<(), RegistryError> { + validate_hub_artifact_name(&entry.name, "skills[].name")?; + validate_artifact_url(&entry.name, "skills[].skill_md.url", &entry.skill_md.url) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::hub_manifest::{ + HubArtifact, HubManifest, HubSkillEntry, HubToolEntry, Provenance, + }; + use sha2::{Digest, Sha256}; + use tempfile::TempDir; + + fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) + } + + fn build_tool_entry(name: &str, wasm_bytes: &[u8], caps_bytes: &[u8]) -> HubToolEntry { + HubToolEntry { + name: name.to_string(), + crate_name: format!("{}-tool", name), + version: "0.1.0".to_string(), + description: format!("{} integration", name), + provenance: Provenance::Official, + wasm: HubArtifact { + url: format!( + "https://github.com/nearai/ironhub/releases/download/test/{}.wasm", + name + ), + size_bytes: wasm_bytes.len() as u64, + sha256: sha256_hex(wasm_bytes), + }, + capabilities: HubArtifact { + url: format!( + "https://github.com/nearai/ironhub/releases/download/test/{}.capabilities.json", + name + ), + size_bytes: caps_bytes.len() as u64, + sha256: sha256_hex(caps_bytes), + }, + } + } + + fn build_skill_entry(name: &str, md_bytes: &[u8]) -> HubSkillEntry { + HubSkillEntry { + name: name.to_string(), + trunk: "test-tool".to_string(), + version: "1.0.0".to_string(), + description: format!("{} skill", name), + provenance: Provenance::Official, + skill_md: HubArtifact { + url: format!( + "https://github.com/nearai/ironhub/releases/download/test/{}.SKILL.md", + name + ), + size_bytes: md_bytes.len() as u64, + sha256: sha256_hex(md_bytes), + }, + } + } + + fn build_manifest(tools: Vec, skills: Vec) -> HubManifest { + HubManifest { + version: "1".to_string(), + generated_at: "2026-05-13T00:00:00Z".to_string(), + release_tag: "test-release".to_string(), + repo: "nearai/ironhub".to_string(), + tools, + skills, + } + } + + fn installer_in(tmp: &TempDir) -> HubInstaller { + HubInstaller::new( + DEFAULT_HUB_MANIFEST_URL.to_string(), + tmp.path().join("tools"), + tmp.path().join("skills"), + ) + } + + #[test] + fn defaults_use_ironclaw_base_dirs() { + let installer = HubInstaller::with_defaults(); + let base = ironclaw_base_dir(); + assert_eq!(installer.tools_dir(), base.join("tools")); + assert_eq!(installer.skills_dir(), base.join("skills")); + assert_eq!(installer.manifest_url(), DEFAULT_HUB_MANIFEST_URL); + } + + #[test] + fn pinned_manifest_url_replaces_default() { + let pinned = + "https://github.com/nearai/ironhub/releases/download/test/tools.json".to_string(); + let installer = HubInstaller::with_defaults().with_manifest_url(pinned.clone()); + assert_eq!(installer.manifest_url(), pinned); + } + + #[test] + fn with_tools_dir_overrides_default() { + let custom = PathBuf::from("/custom/tools"); + let installer = HubInstaller::with_defaults().with_tools_dir(custom.clone()); + assert_eq!(installer.tools_dir(), custom); + let base = ironclaw_base_dir(); + assert_eq!(installer.skills_dir(), base.join("skills")); + } + + #[test] + fn with_skills_dir_overrides_default() { + let custom = PathBuf::from("/custom/skills"); + let installer = HubInstaller::with_defaults().with_skills_dir(custom.clone()); + assert_eq!(installer.skills_dir(), custom); + let base = ironclaw_base_dir(); + assert_eq!(installer.tools_dir(), base.join("tools")); + } + + #[tokio::test] + async fn install_tool_from_bytes_serializes_concurrent_same_name_installs() { + let tmp = TempDir::new().expect("tempdir"); + let installer = Arc::new(installer_in(&tmp)); + let wasm = b"fake-wasm-bytes"; + let caps = br#"{"name":"clickup"}"#; + let entry_a = build_tool_entry("clickup", wasm, caps); + let entry_b = entry_a.clone(); + let a = Arc::clone(&installer); + let b = Arc::clone(&installer); + let (r1, r2) = tokio::join!( + async move { + a.install_tool_from_bytes(&entry_a, "test-release", wasm, caps, false) + .await + }, + async move { + b.install_tool_from_bytes(&entry_b, "test-release", wasm, caps, false) + .await + }, + ); + + let outcomes = [r1, r2]; + let oks = outcomes.iter().filter(|r| r.is_ok()).count(); + let already_installed = outcomes + .iter() + .filter(|r| matches!(r, Err(RegistryError::AlreadyInstalled { .. }))) + .count(); + assert_eq!(oks, 1, "exactly one concurrent install must succeed"); + assert_eq!( + already_installed, 1, + "the other must see AlreadyInstalled, not a race-induced failure" + ); + + let target_wasm = tmp.path().join("tools/clickup.wasm"); + let target_caps = tmp.path().join("tools/clickup.capabilities.json"); + assert!( + target_wasm.exists(), + "wasm must survive: losing install's cleanup must not delete the winner's artifact" + ); + assert!( + target_caps.exists(), + "capabilities must survive the loser's cleanup" + ); + let stray: Vec<_> = std::fs::read_dir(tmp.path().join("tools")) + .expect("read tools dir") + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().contains(".tmp.")) + .collect(); + assert!( + stray.is_empty(), + "no temp files must remain on disk after install" + ); + } + + #[tokio::test] + async fn install_tool_from_bytes_writes_artifacts_and_returns_outcome() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let wasm = b"fake-wasm-bytes"; + let caps = br#"{"name":"clickup"}"#; + let entry = build_tool_entry("clickup", wasm, caps); + + let outcome = installer + .install_tool_from_bytes(&entry, "test-release", wasm, caps, false) + .await + .expect("install succeeds"); + + assert_eq!(outcome.name, "clickup"); + assert_eq!(outcome.version, "0.1.0"); + assert_eq!(outcome.release_tag, "test-release"); + assert_eq!(outcome.primary_path, tmp.path().join("tools/clickup.wasm")); + assert_eq!( + outcome.metadata_path, + Some(tmp.path().join("tools/clickup.capabilities.json")) + ); + + let wasm_on_disk = fs::read(&outcome.primary_path).await.expect("read wasm"); + assert_eq!(wasm_on_disk, wasm); + let caps_on_disk = fs::read(outcome.metadata_path.as_ref().unwrap()) + .await + .expect("read caps"); + assert_eq!(caps_on_disk, caps); + } + + #[tokio::test] + async fn install_tool_from_bytes_rejects_wasm_sha_mismatch() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let wasm = b"original-wasm"; + let caps = br#"{"name":"clickup"}"#; + let entry = build_tool_entry("clickup", wasm, caps); + + let tampered_wasm = b"tampered-wasm"; + let err = installer + .install_tool_from_bytes(&entry, "test-release", tampered_wasm, caps, false) + .await + .expect_err("sha mismatch must fail"); + assert!(matches!(err, RegistryError::ChecksumMismatch { .. })); + assert!(!tmp.path().join("tools/clickup.wasm").exists()); + } + + #[tokio::test] + async fn install_tool_from_bytes_rejects_caps_sha_mismatch() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let wasm = b"original-wasm"; + let caps = br#"{"name":"clickup"}"#; + let entry = build_tool_entry("clickup", wasm, caps); + + let tampered_caps = br#"{"name":"tampered"}"#; + let err = installer + .install_tool_from_bytes(&entry, "test-release", wasm, tampered_caps, false) + .await + .expect_err("sha mismatch must fail"); + assert!(matches!(err, RegistryError::ChecksumMismatch { .. })); + assert!(!tmp.path().join("tools/clickup.wasm").exists()); + assert!(!tmp.path().join("tools/clickup.capabilities.json").exists()); + } + + #[tokio::test] + async fn install_tool_from_bytes_rejects_oversized_caps() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let wasm = b"fake-wasm"; + let big_caps = vec![b'x'; MAX_METADATA_BYTES + 1]; + let entry = build_tool_entry("clickup", wasm, &big_caps); + + let err = installer + .install_tool_from_bytes(&entry, "test-release", wasm, &big_caps, false) + .await + .expect_err("oversized caps must fail"); + match err { + RegistryError::DownloadFailed { reason, .. } => { + assert!(reason.contains("capabilities exceeds")); + } + other => panic!("expected DownloadFailed, got {:?}", other), + } + assert!(!tmp.path().join("tools/clickup.wasm").exists()); + } + + #[tokio::test] + async fn install_tool_from_bytes_rejects_oversized_wasm() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let big_wasm = vec![b'x'; MAX_WASM_BYTES + 1]; + let caps = br#"{"name":"clickup"}"#; + let entry = build_tool_entry("clickup", &big_wasm, caps); + + let err = installer + .install_tool_from_bytes(&entry, "test-release", &big_wasm, caps, false) + .await + .expect_err("oversized wasm must fail"); + match err { + RegistryError::DownloadFailed { reason, .. } => { + assert!(reason.contains("wasm exceeds")); + } + other => panic!("expected DownloadFailed, got {:?}", other), + } + assert!(!tmp.path().join("tools/clickup.wasm").exists()); + } + + #[tokio::test] + async fn install_tool_from_bytes_refuses_overwrite_without_force() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let wasm = b"wasm"; + let caps = br#"{"name":"clickup"}"#; + let entry = build_tool_entry("clickup", wasm, caps); + + installer + .install_tool_from_bytes(&entry, "test-release", wasm, caps, false) + .await + .expect("first install"); + + let err = installer + .install_tool_from_bytes(&entry, "test-release", wasm, caps, false) + .await + .expect_err("second install must fail without force"); + assert!(matches!(err, RegistryError::AlreadyInstalled { .. })); + } + + #[tokio::test] + async fn install_tool_from_bytes_overwrites_with_force() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let original = b"original-wasm"; + let caps = br#"{"name":"clickup"}"#; + let entry_v1 = build_tool_entry("clickup", original, caps); + + installer + .install_tool_from_bytes(&entry_v1, "test-release", original, caps, false) + .await + .expect("first install"); + + let updated = b"updated-wasm-bytes"; + let entry_v2 = build_tool_entry("clickup", updated, caps); + installer + .install_tool_from_bytes(&entry_v2, "test-release", updated, caps, true) + .await + .expect("forced reinstall"); + + let on_disk = fs::read(tmp.path().join("tools/clickup.wasm")) + .await + .expect("read"); + assert_eq!(on_disk, updated); + } + + #[tokio::test] + async fn install_skill_from_bytes_writes_skill_md() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let md = b"# Chief of Staff\n\nactivation:\n keywords:\n - briefing\n"; + let entry = build_skill_entry("chief-of-staff", md); + + let outcome = installer + .install_skill_from_bytes(&entry, "test-release", md, false) + .await + .expect("skill install"); + assert_eq!(outcome.name, "chief-of-staff"); + assert_eq!( + outcome.primary_path, + tmp.path().join("skills/chief-of-staff/SKILL.md") + ); + assert!(outcome.metadata_path.is_none()); + + let on_disk = fs::read(outcome.primary_path).await.expect("read"); + assert_eq!(on_disk, md); + } + + #[tokio::test] + async fn install_skill_from_bytes_rejects_sha_mismatch() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let md = b"# original"; + let entry = build_skill_entry("test-skill", md); + + let tampered = b"# tampered"; + let err = installer + .install_skill_from_bytes(&entry, "test-release", tampered, false) + .await + .expect_err("sha mismatch must fail"); + assert!(matches!(err, RegistryError::ChecksumMismatch { .. })); + assert!(!tmp.path().join("skills/test-skill/SKILL.md").exists()); + } + + #[tokio::test] + async fn install_skill_from_bytes_rejects_oversized() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let big = vec![b'x'; MAX_METADATA_BYTES + 1]; + let entry = build_skill_entry("test-skill", &big); + + let err = installer + .install_skill_from_bytes(&entry, "test-release", &big, false) + .await + .expect_err("oversized must fail"); + match err { + RegistryError::DownloadFailed { reason, .. } => { + assert!(reason.contains("SKILL.md exceeds")); + } + other => panic!("expected DownloadFailed, got {:?}", other), + } + } + + #[tokio::test] + async fn install_skill_from_bytes_refuses_overwrite_without_force() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let md = b"# skill"; + let entry = build_skill_entry("test-skill", md); + + installer + .install_skill_from_bytes(&entry, "test-release", md, false) + .await + .expect("first install"); + + let err = installer + .install_skill_from_bytes(&entry, "test-release", md, false) + .await + .expect_err("second install must fail"); + assert!(matches!(err, RegistryError::AlreadyInstalled { .. })); + } + + #[tokio::test] + async fn install_skill_from_bytes_overwrites_with_force() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let original = b"# v1"; + let entry_v1 = build_skill_entry("test-skill", original); + installer + .install_skill_from_bytes(&entry_v1, "test-release", original, false) + .await + .expect("first install"); + + let updated = b"# v2"; + let entry_v2 = build_skill_entry("test-skill", updated); + installer + .install_skill_from_bytes(&entry_v2, "test-release", updated, true) + .await + .expect("forced reinstall"); + + let on_disk = fs::read(tmp.path().join("skills/test-skill/SKILL.md")) + .await + .expect("read"); + assert_eq!(on_disk, updated); + } + + #[tokio::test] + async fn install_tool_entry_rejects_non_https_url() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let wasm = b"wasm"; + let caps = br#"{}"#; + let mut entry = build_tool_entry("clickup", wasm, caps); + entry.wasm.url = + "http://github.com/nearai/ironhub/releases/download/test/clickup.wasm".to_string(); + + let err = installer + .install_tool_entry(&entry, "test-release", false) + .await + .expect_err("non-https must fail before fetch"); + match err { + RegistryError::InvalidManifest { reason, .. } => { + assert!(reason.contains("https")); + } + other => panic!("expected InvalidManifest, got {:?}", other), + } + } + + #[tokio::test] + async fn install_tool_entry_rejects_disallowed_host() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let wasm = b"wasm"; + let caps = br#"{}"#; + let mut entry = build_tool_entry("clickup", wasm, caps); + entry.wasm.url = "https://evil.example.com/clickup.wasm".to_string(); + + let err = installer + .install_tool_entry(&entry, "test-release", false) + .await + .expect_err("disallowed host must fail before fetch"); + match err { + RegistryError::InvalidManifest { reason, .. } => { + assert!(reason.contains("not allowed")); + } + other => panic!("expected InvalidManifest, got {:?}", other), + } + } + + #[tokio::test] + async fn install_tool_entry_rejects_disallowed_caps_host() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let wasm = b"wasm"; + let caps = br#"{}"#; + let mut entry = build_tool_entry("clickup", wasm, caps); + entry.capabilities.url = "https://evil.example.com/clickup.capabilities.json".to_string(); + + let err = installer + .install_tool_entry(&entry, "test-release", false) + .await + .expect_err("disallowed caps host must fail before fetch"); + assert!(matches!(err, RegistryError::InvalidManifest { .. })); + } + + #[tokio::test] + async fn install_skill_entry_rejects_disallowed_host() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let md = b"# skill"; + let mut entry = build_skill_entry("test-skill", md); + entry.skill_md.url = "https://evil.example.com/test-skill.SKILL.md".to_string(); + + let err = installer + .install_skill_entry(&entry, "test-release", false) + .await + .expect_err("disallowed skill host must fail before fetch"); + assert!(matches!(err, RegistryError::InvalidManifest { .. })); + } + + #[tokio::test] + async fn fetch_manifest_rejects_non_https_url() { + let installer = HubInstaller::with_defaults().with_manifest_url( + "http://github.com/nearai/ironhub/releases/latest/download/tools.json".to_string(), + ); + + let err = installer + .fetch_manifest() + .await + .expect_err("non-https manifest url must fail"); + assert!(matches!(err, RegistryError::InvalidManifest { .. })); + } + + #[tokio::test] + async fn fetch_manifest_rejects_disallowed_host() { + let installer = HubInstaller::with_defaults() + .with_manifest_url("https://evil.example.com/tools.json".to_string()); + + let err = installer + .fetch_manifest() + .await + .expect_err("disallowed manifest host must fail"); + assert!(matches!(err, RegistryError::InvalidManifest { .. })); + } + + #[tokio::test] + async fn install_tool_from_manifest_reports_missing_tool() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let manifest = build_manifest(vec![build_tool_entry("clickup", b"wasm", br#"{}"#)], vec![]); + + let err = installer + .install_tool_from_manifest(&manifest, "absent", false) + .await + .expect_err("missing tool must fail"); + match err { + RegistryError::ExtensionNotFound(msg) => { + assert!(msg.contains("absent")); + assert!(msg.contains("tool")); + } + other => panic!("expected ExtensionNotFound, got {:?}", other), + } + } + + #[tokio::test] + async fn install_skill_from_manifest_reports_missing_skill() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let manifest = build_manifest(vec![], vec![build_skill_entry("present", b"# md")]); + + let err = installer + .install_skill_from_manifest(&manifest, "absent", false) + .await + .expect_err("missing skill must fail"); + match err { + RegistryError::ExtensionNotFound(msg) => { + assert!(msg.contains("absent")); + assert!(msg.contains("skill")); + } + other => panic!("expected ExtensionNotFound, got {:?}", other), + } + } + + #[tokio::test] + async fn install_tool_from_manifest_delegates_to_entry_validation() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let wasm = b"wasm"; + let caps = br#"{"name":"clickup"}"#; + let mut entry = build_tool_entry("clickup", wasm, caps); + entry.wasm.url = "https://evil.example.com/clickup.wasm".to_string(); + let manifest = build_manifest(vec![entry], vec![]); + + let err = installer + .install_tool_from_manifest(&manifest, "clickup", false) + .await + .expect_err("delegation must surface entry validation error"); + assert!(matches!(err, RegistryError::InvalidManifest { .. })); + } + + #[tokio::test] + async fn install_skill_from_manifest_delegates_to_entry_validation() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let md = b"# skill"; + let mut entry = build_skill_entry("test-skill", md); + entry.skill_md.url = "https://evil.example.com/test-skill.SKILL.md".to_string(); + let manifest = build_manifest(vec![], vec![entry]); + + let err = installer + .install_skill_from_manifest(&manifest, "test-skill", false) + .await + .expect_err("delegation must surface entry validation error"); + assert!(matches!(err, RegistryError::InvalidManifest { .. })); + } + + #[test] + fn validate_hub_artifact_name_accepts_valid_names() { + assert!(validate_hub_artifact_name("clickup", "test").is_ok()); + assert!(validate_hub_artifact_name("evm-rpc", "test").is_ok()); + assert!(validate_hub_artifact_name("microsoft_365", "test").is_ok()); + assert!(validate_hub_artifact_name("a1-b2_c3", "test").is_ok()); + } + + #[test] + fn validate_hub_artifact_name_rejects_traversal_and_unsafe_chars() { + for bad in [ + "", + "..", + "../evil", + "/etc/passwd", + "evil/sub", + "evil\\sub", + "evil.wasm", + "Uppercase", + "name with space", + "name\nnewline", + "name\0null", + "name@host", + ] { + assert!( + validate_hub_artifact_name(bad, "test").is_err(), + "expected rejection for {:?}", + bad + ); + } + } + + #[tokio::test] + async fn install_tool_from_bytes_rejects_traversal_in_entry_name() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let wasm = b"wasm"; + let caps = br#"{}"#; + let mut entry = build_tool_entry("clickup", wasm, caps); + entry.name = "../evil".to_string(); + + let err = installer + .install_tool_from_bytes(&entry, "test-release", wasm, caps, false) + .await + .expect_err("traversal in entry name must fail"); + assert!(matches!(err, RegistryError::InvalidManifest { .. })); + assert!(!tmp.path().join("tools/../evil.wasm").exists()); + } + + #[tokio::test] + async fn install_tool_from_bytes_rejects_absolute_path_in_entry_name() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let wasm = b"wasm"; + let caps = br#"{}"#; + let mut entry = build_tool_entry("clickup", wasm, caps); + entry.name = "/tmp/escape-me".to_string(); + + let err = installer + .install_tool_from_bytes(&entry, "test-release", wasm, caps, false) + .await + .expect_err("absolute name must fail"); + assert!(matches!(err, RegistryError::InvalidManifest { .. })); + assert!(!std::path::Path::new("/tmp/escape-me.wasm").exists()); + } + + #[tokio::test] + async fn install_tool_entry_rejects_bad_name_before_fetch() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let wasm = b"wasm"; + let caps = br#"{}"#; + let mut entry = build_tool_entry("clickup", wasm, caps); + entry.name = "../evil".to_string(); + + let err = installer + .install_tool_entry(&entry, "test-release", false) + .await + .expect_err("bad name must fail before any HTTP fetch"); + assert!(matches!(err, RegistryError::InvalidManifest { .. })); + } + + #[tokio::test] + async fn install_skill_from_bytes_rejects_traversal_in_entry_name() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let md = b"# skill"; + let mut entry = build_skill_entry("test-skill", md); + entry.name = "../evil".to_string(); + + let err = installer + .install_skill_from_bytes(&entry, "test-release", md, false) + .await + .expect_err("traversal in skill name must fail"); + assert!(matches!(err, RegistryError::InvalidManifest { .. })); + assert!(!tmp.path().join("skills/../evil/SKILL.md").exists()); + } + + #[tokio::test] + async fn install_skill_from_bytes_rejects_absolute_path_in_entry_name() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let md = b"# skill"; + let mut entry = build_skill_entry("test-skill", md); + entry.name = "/tmp/escape-skill".to_string(); + + let err = installer + .install_skill_from_bytes(&entry, "test-release", md, false) + .await + .expect_err("absolute skill name must fail"); + assert!(matches!(err, RegistryError::InvalidManifest { .. })); + } + + #[tokio::test] + async fn install_skill_entry_rejects_bad_name_before_fetch() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let md = b"# skill"; + let mut entry = build_skill_entry("test-skill", md); + entry.name = "../evil".to_string(); + + let err = installer + .install_skill_entry(&entry, "test-release", false) + .await + .expect_err("bad name must fail before any HTTP fetch"); + assert!(matches!(err, RegistryError::InvalidManifest { .. })); + } + + #[tokio::test] + async fn confirm_written_size_passes_on_match() { + let tmp = TempDir::new().expect("tempdir"); + let path = tmp.path().join("artifact.bin"); + let bytes = b"hello world"; + fs::write(&path, bytes).await.expect("write"); + confirm_written_size(&path, bytes.len()) + .await + .expect("matching size must pass"); + } + + #[tokio::test] + async fn confirm_written_size_rejects_truncation() { + let tmp = TempDir::new().expect("tempdir"); + let path = tmp.path().join("artifact.bin"); + fs::write(&path, b"actual").await.expect("write"); + let err = confirm_written_size(&path, 9999) + .await + .expect_err("size mismatch must fail"); + match err { + RegistryError::DownloadFailed { reason, .. } => { + assert!(reason.contains("on-disk size mismatch")); + } + other => panic!("expected DownloadFailed, got {:?}", other), + } + } + + #[tokio::test] + async fn cleanup_partial_artifact_removes_file_and_tolerates_missing() { + let tmp = TempDir::new().expect("tempdir"); + let path = tmp.path().join("orphan.wasm"); + fs::write(&path, b"partial").await.expect("write"); + assert!(path.exists()); + cleanup_partial_artifact(&path).await; + assert!(!path.exists(), "partial artifact must be removed"); + cleanup_partial_artifact(&path).await; + assert!(!path.exists(), "second call on missing path is a no-op"); + } +} diff --git a/src/registry/hub_manifest.rs b/src/registry/hub_manifest.rs new file mode 100644 index 00000000000..e32243dcbdf --- /dev/null +++ b/src/registry/hub_manifest.rs @@ -0,0 +1,234 @@ +use serde::{Deserialize, Serialize}; + +pub const DEFAULT_HUB_MANIFEST_URL: &str = "https://hub.ironclaw.com/api/catalog/manifest.json"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Provenance { + #[default] + #[serde(alias = "repo")] + Official, + Trusted, + Verified, + #[serde(alias = "community")] + New, +} + +impl Provenance { + pub fn as_wire(&self) -> &'static str { + match self { + Provenance::Official => "official", + Provenance::Trusted => "trusted", + Provenance::Verified => "verified", + Provenance::New => "new", + } + } + + pub fn is_community_unverified(&self) -> bool { + matches!(self, Provenance::New) + } + + pub fn trust_label(&self) -> &'static str { + match self { + Provenance::Official => "NEAR-vetted (official)", + Provenance::Trusted => "community, trusted publisher", + Provenance::Verified => "community, verified publisher", + Provenance::New => "UNVERIFIED community (new author)", + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct HubManifest { + pub version: String, + pub generated_at: String, + pub release_tag: String, + pub repo: String, + #[serde(default)] + pub tools: Vec, + #[serde(default)] + pub skills: Vec, +} + +impl HubManifest { + pub fn find_tool(&self, name: &str) -> Option<&HubToolEntry> { + self.tools.iter().find(|t| t.name == name) + } + + pub fn find_skill(&self, name: &str) -> Option<&HubSkillEntry> { + self.skills.iter().find(|s| s.name == name) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct HubToolEntry { + pub name: String, + pub crate_name: String, + pub version: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub provenance: Provenance, + pub wasm: HubArtifact, + pub capabilities: HubArtifact, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct HubSkillEntry { + pub name: String, + #[serde(default)] + pub trunk: String, + #[serde(default)] + pub version: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub provenance: Provenance, + pub skill_md: HubArtifact, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct HubArtifact { + pub url: String, + pub size_bytes: u64, + pub sha256: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_MANIFEST: &str = r#"{ + "version": "1", + "generated_at": "2026-05-12T23:43:46Z", + "release_tag": "release-2026-05-12-24", + "repo": "nearai/ironhub", + "tools": [ + { + "name": "clickup", + "crate_name": "clickup-tool", + "version": "0.1.0", + "description": "ClickUp integration", + "wasm": { + "url": "https://github.com/nearai/ironhub/releases/download/release-2026-05-12-24/clickup.wasm", + "size_bytes": 433139, + "sha256": "f96f9f24c379a9bcf714e3fb7692a712b1ffd8432884af0a2120f1ad1bb8c619" + }, + "capabilities": { + "url": "https://github.com/nearai/ironhub/releases/download/release-2026-05-12-24/clickup.capabilities.json", + "size_bytes": 3287, + "sha256": "1815aa5019cf4b329ee3269a5a3bbd301f690c4d9505c3fd4e5062983cedc4ef" + } + } + ], + "skills": [ + { + "name": "microsoft-365-workflow", + "trunk": "microsoft-365", + "version": "1.0.0", + "description": "Microsoft 365 business workflow patterns", + "skill_md": { + "url": "https://github.com/nearai/ironhub/releases/download/release-2026-05-12-24/microsoft-365-workflow.SKILL.md", + "size_bytes": 14000, + "sha256": "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd" + } + } + ] + }"#; + + #[test] + fn parses_sample_manifest() { + let manifest: HubManifest = serde_json::from_str(SAMPLE_MANIFEST).expect("valid manifest"); + assert_eq!(manifest.version, "1"); + assert_eq!(manifest.release_tag, "release-2026-05-12-24"); + assert_eq!(manifest.repo, "nearai/ironhub"); + assert_eq!(manifest.tools.len(), 1); + assert_eq!(manifest.skills.len(), 1); + } + + #[test] + fn find_tool_returns_entry_by_name() { + let manifest: HubManifest = serde_json::from_str(SAMPLE_MANIFEST).expect("valid manifest"); + let tool = manifest.find_tool("clickup").expect("clickup present"); + assert_eq!(tool.crate_name, "clickup-tool"); + assert_eq!(tool.wasm.size_bytes, 433139); + assert!(manifest.find_tool("nonexistent").is_none()); + } + + #[test] + fn find_skill_returns_entry_by_name() { + let manifest: HubManifest = serde_json::from_str(SAMPLE_MANIFEST).expect("valid manifest"); + let skill = manifest + .find_skill("microsoft-365-workflow") + .expect("skill present"); + assert_eq!(skill.trunk, "microsoft-365"); + assert!(manifest.find_skill("nonexistent").is_none()); + } + + #[test] + fn default_manifest_url_is_https_hub_endpoint() { + assert_eq!( + DEFAULT_HUB_MANIFEST_URL, + "https://hub.ironclaw.com/api/catalog/manifest.json" + ); + } + + #[test] + fn provenance_defaults_to_official_when_field_absent() { + let manifest: HubManifest = serde_json::from_str(SAMPLE_MANIFEST).expect("valid manifest"); + assert_eq!(manifest.tools[0].provenance, Provenance::Official); + assert_eq!(manifest.skills[0].provenance, Provenance::Official); + } + + #[test] + fn provenance_parses_each_tier_and_aliases() { + let cases = [ + (r#""official""#, Provenance::Official), + (r#""repo""#, Provenance::Official), + (r#""trusted""#, Provenance::Trusted), + (r#""verified""#, Provenance::Verified), + (r#""new""#, Provenance::New), + (r#""community""#, Provenance::New), + ]; + for (raw, expected) in cases { + let got: Provenance = serde_json::from_str(raw).expect("valid provenance"); + assert_eq!(got, expected, "input {raw}"); + } + } + + #[test] + fn provenance_rejects_unknown_string() { + assert!(serde_json::from_str::(r#""banned""#).is_err()); + assert!(serde_json::from_str::(r#""whatever""#).is_err()); + } + + #[test] + fn provenance_wire_round_trips() { + for p in [ + Provenance::Official, + Provenance::Trusted, + Provenance::Verified, + Provenance::New, + ] { + let json = serde_json::to_string(&p).expect("serialize"); + assert_eq!(json, format!("\"{}\"", p.as_wire())); + let back: Provenance = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back, p); + } + assert!(Provenance::New.is_community_unverified()); + assert!(!Provenance::Official.is_community_unverified()); + } + + #[test] + fn manifest_with_no_tools_or_skills_parses() { + let raw = r#"{ + "version": "1", + "generated_at": "2026-05-12T23:43:46Z", + "release_tag": "release-2026-05-12-24", + "repo": "nearai/ironhub" + }"#; + let manifest: HubManifest = serde_json::from_str(raw).expect("valid manifest"); + assert!(manifest.tools.is_empty()); + assert!(manifest.skills.is_empty()); + } +} diff --git a/src/registry/installer.rs b/src/registry/installer.rs index 68a664d8ae0..b75147f670b 100644 --- a/src/registry/installer.rs +++ b/src/registry/installer.rs @@ -9,10 +9,8 @@ use crate::bootstrap::ironclaw_base_dir; use crate::registry::catalog::RegistryError; use crate::registry::manifest::{BundleDefinition, ExtensionManifest, ManifestKind, SourceSpec}; -// GitHub-only by design. New trusted hosts (e.g. a NEAR AI CDN) must be -// explicitly added here; unknown hosts fall back to source build with a -// warning rather than surfacing a clear "host not allowed" error. const ALLOWED_ARTIFACT_HOSTS: &[&str] = &[ + "hub.ironclaw.com", "github.com", "objects.githubusercontent.com", "github-releases.githubusercontent.com", @@ -45,7 +43,7 @@ fn is_allowed_artifact_host(host: &str) -> bool { || host.ends_with(".githubusercontent.com") } -fn validate_artifact_url( +pub(crate) fn validate_artifact_url( manifest_name: &str, field: &'static str, url: &str, @@ -468,7 +466,8 @@ impl RegistryInstaller { "Downloading {} '{}'...", manifest.kind, manifest.display_name ); - let bytes = download_artifact(url).await?; + const MAX_REGISTRY_ARTIFACT_BYTES: u64 = 64 * 1024 * 1024; + let bytes = download_artifact(url, MAX_REGISTRY_ARTIFACT_BYTES).await?; verify_sha256(&bytes, expected_sha, url)?; let target_caps = target_dir.join(format!("{}.capabilities.json", manifest.name)); @@ -495,7 +494,7 @@ impl RegistryInstaller { caps_url, )?; const MAX_CAPS_SIZE: usize = 1024 * 1024; // 1 MB - match download_artifact(caps_url).await { + match download_artifact(caps_url, MAX_CAPS_SIZE as u64).await { Ok(caps_bytes) if caps_bytes.len() <= MAX_CAPS_SIZE => { fs::write(&target_caps, &caps_bytes) .await @@ -633,8 +632,22 @@ impl RegistryInstaller { } } -/// Download an artifact from a URL. -async fn download_artifact(url: &str) -> Result { +fn enforce_size_cap(observed: u64, max_bytes: u64, url: &str) -> Result<(), RegistryError> { + if observed > max_bytes { + return Err(RegistryError::DownloadFailed { + url: url.to_string(), + reason: format!("response exceeds {max_bytes} byte size cap"), + }); + } + Ok(()) +} + +/// Download an artifact from a URL, streaming with a hard size cap so a +/// malicious or oversized response cannot exhaust memory. +pub(crate) async fn download_artifact( + url: &str, + max_bytes: u64, +) -> Result { let response = reqwest::get(url) .await .map_err(|e| RegistryError::DownloadFailed { @@ -642,7 +655,7 @@ async fn download_artifact(url: &str) -> Result { reason: download_failure_reason(&e), })?; - let response = response + let mut response = response .error_for_status() .map_err(|e| RegistryError::DownloadFailed { url: url.to_string(), @@ -653,17 +666,30 @@ async fn download_artifact(url: &str) -> Result { ), })?; - response - .bytes() + if let Some(len) = response.content_length() { + enforce_size_cap(len, max_bytes, url)?; + } + + let mut buf: Vec = Vec::new(); + let mut total: u64 = 0; + while let Some(chunk) = response + .chunk() .await .map_err(|e| RegistryError::DownloadFailed { url: url.to_string(), reason: format!("failed to read response body: {}", e), - }) + })? + { + total += chunk.len() as u64; + enforce_size_cap(total, max_bytes, url)?; + buf.extend_from_slice(&chunk); + } + + Ok(bytes::Bytes::from(buf)) } /// Verify SHA256 of downloaded bytes. -fn verify_sha256(bytes: &[u8], expected: &str, url: &str) -> Result<(), RegistryError> { +pub(crate) fn verify_sha256(bytes: &[u8], expected: &str, url: &str) -> Result<(), RegistryError> { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(bytes); @@ -1370,4 +1396,40 @@ mod tests { other => panic!("expected ManifestRead for channel, got: {:?}", other), } } + + #[test] + fn allowlist_includes_hub_and_github() { + assert!(is_allowed_artifact_host("hub.ironclaw.com")); + assert!(is_allowed_artifact_host("github.com")); + assert!(is_allowed_artifact_host("objects.githubusercontent.com")); + } + + #[test] + fn allowlist_excludes_tigris_and_spoofs() { + assert!(!is_allowed_artifact_host("fly.storage.tigris.dev")); + assert!(!is_allowed_artifact_host("hub.ironclaw.com.evil.com")); + assert!(!is_allowed_artifact_host("evil.hub.ironclaw.com")); + assert!(!is_allowed_artifact_host("ironclaw.com")); + } + + #[test] + fn validate_artifact_url_rejects_non_https_ip_and_unknown_host() { + assert!(validate_artifact_url("m", "f", "http://hub.ironclaw.com/x").is_err()); + assert!(validate_artifact_url("m", "f", "https://1.2.3.4/x").is_err()); + assert!(validate_artifact_url("m", "f", "https://evil.example.com/x").is_err()); + assert!(validate_artifact_url("m", "f", "https://hub.ironclaw.com/catalog/x.wasm").is_ok()); + } + + #[test] + fn enforce_size_cap_allows_within_and_rejects_over() { + assert!(enforce_size_cap(0, 1024, "u").is_ok()); + assert!(enforce_size_cap(1024, 1024, "u").is_ok()); + let err = enforce_size_cap(1025, 1024, "u").expect_err("over cap must fail"); + match err { + RegistryError::DownloadFailed { reason, .. } => { + assert!(reason.contains("exceeds 1024 byte size cap")); + } + other => panic!("expected DownloadFailed, got {:?}", other), + } + } } diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 5649f821072..c8a449c4467 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -14,10 +14,16 @@ pub mod artifacts; pub mod catalog; pub mod embedded; +pub mod hub_installer; +pub mod hub_manifest; pub mod installer; pub mod manifest; pub use catalog::{RegistryCatalog, RegistryError}; +pub use hub_installer::{HubInstallOutcome, HubInstaller}; +pub use hub_manifest::{ + DEFAULT_HUB_MANIFEST_URL, HubArtifact, HubManifest, HubSkillEntry, HubToolEntry, Provenance, +}; pub use installer::RegistryInstaller; pub use manifest::{ ArtifactSpec, AuthSummary, BundleDefinition, BundlesFile, ExtensionManifest, ManifestKind, diff --git a/src/tools/builtin/extension_tools.rs b/src/tools/builtin/extension_tools.rs index aff9f327b8c..49429076ff7 100644 --- a/src/tools/builtin/extension_tools.rs +++ b/src/tools/builtin/extension_tools.rs @@ -33,7 +33,7 @@ fn activation_error_requires_auth(err: &str) -> bool { || err.contains("401") } -fn output_from_ensure_ready(outcome: EnsureReadyOutcome) -> serde_json::Value { +pub(crate) fn output_from_ensure_ready(outcome: EnsureReadyOutcome) -> serde_json::Value { match outcome { EnsureReadyOutcome::Ready { name, diff --git a/src/tools/builtin/ironhub.rs b/src/tools/builtin/ironhub.rs new file mode 100644 index 00000000000..7a484bf0633 --- /dev/null +++ b/src/tools/builtin/ironhub.rs @@ -0,0 +1,1617 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use ironclaw_skills::SkillRegistry; + +use crate::cli::hub_install::{hub_manifest_url_for_tag, validate_hub_name}; +use crate::context::JobContext; +use crate::extensions::{EnsureReadyIntent, ExtensionKind, ExtensionManager}; +use crate::registry::{ + HubInstallOutcome, HubInstaller, HubManifest, HubSkillEntry, HubToolEntry, Provenance, +}; +use crate::tools::builtin::extension_tools::output_from_ensure_ready; +use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HubEntryKind { + Tool, + Skill, +} + +impl HubEntryKind { + fn as_str(self) -> &'static str { + match self { + HubEntryKind::Tool => "tool", + HubEntryKind::Skill => "skill", + } + } + + fn from_param(s: &str) -> Result { + match s { + "tool" => Ok(Self::Tool), + "skill" => Ok(Self::Skill), + other => Err(ToolError::InvalidParameters(format!( + "kind must be 'tool' or 'skill', got '{other}'" + ))), + } + } +} + +#[derive(Clone)] +pub struct IronhubDeps { + pub extension_manager: Arc, + pub skill_registry: Option>>, +} + +fn build_installer( + release_tag: Option<&str>, + skills_dir_override: Option, +) -> Result { + let mut installer = HubInstaller::with_defaults(); + if let Some(dir) = skills_dir_override { + installer = installer.with_skills_dir(dir); + } + if let Some(tag) = release_tag { + let url = hub_manifest_url_for_tag(tag) + .map_err(|e: anyhow::Error| ToolError::InvalidParameters(e.to_string()))?; + installer = installer.with_manifest_url(url); + } + Ok(installer) +} + +fn catalog_unavailable() -> ToolError { + ToolError::ExternalService("IronHub catalog is temporarily unavailable".into()) +} + +fn classify_and_gate( + manifest: &HubManifest, + name: &str, + hint: Option, + acknowledge_unverified: bool, +) -> Result<(HubEntryKind, Provenance), ToolError> { + let kind = classify(manifest, name, hint)?; + let provenance = match kind { + HubEntryKind::Tool => manifest.find_tool(name).map(|t| t.provenance), + HubEntryKind::Skill => manifest.find_skill(name).map(|s| s.provenance), + } + .unwrap_or_default(); + if provenance.is_community_unverified() && !acknowledge_unverified { + return Err(ToolError::InvalidParameters(format!( + "'{name}' is UNVERIFIED community content (trust tier: {}). \ + Not NEAR-vetted. Re-run with acknowledge_unverified=true to \ + install at your own risk.", + provenance.as_wire() + ))); + } + Ok((kind, provenance)) +} + +fn classify( + manifest: &HubManifest, + name: &str, + hint: Option, +) -> Result { + let in_tools = manifest.find_tool(name).is_some(); + let in_skills = manifest.find_skill(name).is_some(); + + if let Some(HubEntryKind::Tool) = hint { + if !in_tools { + return Err(ToolError::InvalidParameters(format!( + "'{name}' is not a tool in this IronHub release" + ))); + } + return Ok(HubEntryKind::Tool); + } + if let Some(HubEntryKind::Skill) = hint { + if !in_skills { + return Err(ToolError::InvalidParameters(format!( + "'{name}' is not a skill in this IronHub release" + ))); + } + return Ok(HubEntryKind::Skill); + } + + match (in_tools, in_skills) { + (true, false) => Ok(HubEntryKind::Tool), + (false, true) => Ok(HubEntryKind::Skill), + (true, true) => Err(ToolError::InvalidParameters(format!( + "'{name}' exists as both a tool and a skill in this release; pass kind='tool' or kind='skill' to disambiguate" + ))), + (false, false) => { + let suggestions = nearest_matches(manifest, name); + if suggestions.is_empty() { + Err(ToolError::InvalidParameters(format!( + "'{name}' is not in this IronHub release" + ))) + } else { + Err(ToolError::InvalidParameters(format!( + "'{name}' is not in this IronHub release. Did you mean: {}?", + suggestions.join(", ") + ))) + } + } + } +} + +fn nearest_matches(manifest: &HubManifest, query: &str) -> Vec { + let q = query.to_ascii_lowercase(); + let mut out: Vec = manifest + .tools + .iter() + .map(|t| t.name.clone()) + .chain(manifest.skills.iter().map(|s| s.name.clone())) + .filter(|n| { + let nl = n.to_ascii_lowercase(); + nl.contains(&q) || q.contains(&nl) + }) + .collect(); + out.sort(); + out.truncate(5); + out +} + +fn entry_matches(name: &str, description: &str, query_lower: &str) -> bool { + name.to_ascii_lowercase().contains(query_lower) + || description.to_ascii_lowercase().contains(query_lower) +} + +fn install_outcome_to_json(kind: HubEntryKind, outcome: &HubInstallOutcome) -> serde_json::Value { + let mut obj = serde_json::Map::new(); + obj.insert( + "status".into(), + serde_json::Value::String("installed".into()), + ); + obj.insert( + "kind".into(), + serde_json::Value::String(kind.as_str().into()), + ); + obj.insert( + "name".into(), + serde_json::Value::String(outcome.name.clone()), + ); + obj.insert( + "version".into(), + serde_json::Value::String(outcome.version.clone()), + ); + obj.insert( + "release_tag".into(), + serde_json::Value::String(outcome.release_tag.clone()), + ); + obj.insert( + "primary_path".into(), + serde_json::Value::String(outcome.primary_path.display().to_string()), + ); + if let Some(meta) = &outcome.metadata_path { + obj.insert( + "metadata_path".into(), + serde_json::Value::String(meta.display().to_string()), + ); + } + obj.insert( + "provenance".into(), + serde_json::Value::String(outcome.provenance.as_wire().into()), + ); + if outcome.provenance.is_community_unverified() { + obj.insert("unverified".into(), serde_json::Value::Bool(true)); + obj.insert( + "warning".into(), + serde_json::Value::String(format!( + "{} - not NEAR-vetted", + outcome.provenance.trust_label() + )), + ); + } + serde_json::Value::Object(obj) +} + +fn tool_entry_json(entry: &HubToolEntry) -> serde_json::Value { + serde_json::json!({ + "kind": "tool", + "name": entry.name, + "version": entry.version, + "description": entry.description, + "provenance": entry.provenance.as_wire(), + "trust_label": entry.provenance.trust_label(), + }) +} + +fn skill_entry_json(entry: &HubSkillEntry) -> serde_json::Value { + serde_json::json!({ + "kind": "skill", + "name": entry.name, + "version": entry.version, + "description": entry.description, + "provenance": entry.provenance.as_wire(), + "trust_label": entry.provenance.trust_label(), + }) +} + +fn skill_install_dir( + registry: &Option>>, +) -> Option { + let registry = registry.as_ref()?; + let guard = registry.read().unwrap_or_else(|poison| { + tracing::error!( + "skill registry RwLock was poisoned (a previous writer panicked); recovering" + ); + poison.into_inner() + }); + Some( + guard + .installed_dir() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| guard.install_target_dir().to_path_buf()), + ) +} + +pub struct IronhubInstallTool { + deps: IronhubDeps, +} + +impl IronhubInstallTool { + pub fn new(deps: IronhubDeps) -> Self { + Self { deps } + } + + async fn install_from_manifest( + &self, + start: std::time::Instant, + manifest: HubManifest, + parsed: InstallParams, + ctx: &JobContext, + ) -> Result { + let (kind, _provenance) = classify_and_gate( + &manifest, + &parsed.name, + parsed.kind_hint, + parsed.acknowledge_unverified, + )?; + + let skills_dir = match kind { + HubEntryKind::Skill => skill_install_dir(&self.deps.skill_registry), + HubEntryKind::Tool => None, + }; + let installer = build_installer(parsed.release_tag.as_deref(), skills_dir)?; + + match kind { + HubEntryKind::Tool => { + let outcome = installer + .install_tool_from_manifest(&manifest, &parsed.name, parsed.force) + .await + .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + let ready = self + .deps + .extension_manager + .ensure_extension_ready( + &parsed.name, + &ctx.user_id, + EnsureReadyIntent::PostInstall, + ) + .await + .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + let mut json = install_outcome_to_json(kind, &outcome); + if let Some(obj) = json.as_object_mut() { + obj.insert("activation".into(), output_from_ensure_ready(ready)); + } + Ok(ToolOutput::success(json, start.elapsed())) + } + HubEntryKind::Skill => { + let outcome = installer + .install_skill_from_manifest(&manifest, &parsed.name, parsed.force) + .await + .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + if let Some(reg) = &self.deps.skill_registry { + let reg = Arc::clone(reg); + let handle = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || { + let mut guard = reg.write().unwrap_or_else(|poison| { + tracing::error!( + "skill registry RwLock was poisoned (a previous writer panicked); recovering" + ); + poison.into_inner() + }); + handle.block_on(guard.reload()); + }) + .await + .map_err(|e| { + ToolError::ExecutionFailed(format!("skill registry reload join: {e}")) + })?; + } + Ok(ToolOutput::success( + install_outcome_to_json(kind, &outcome), + start.elapsed(), + )) + } + } + } +} + +struct InstallParams { + name: String, + kind_hint: Option, + release_tag: Option, + force: bool, + acknowledge_unverified: bool, +} + +impl InstallParams { + fn from_json(params: &serde_json::Value) -> Result { + let name = require_str(params, "name")?.to_string(); + validate_hub_name(&name) + .map_err(|e: anyhow::Error| ToolError::InvalidParameters(e.to_string()))?; + let kind_hint = params + .get("kind") + .and_then(|v| v.as_str()) + .map(HubEntryKind::from_param) + .transpose()?; + let release_tag = params + .get("release_tag") + .and_then(|v| v.as_str()) + .map(str::to_string); + let force = params + .get("force") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let acknowledge_unverified = params + .get("acknowledge_unverified") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + Ok(Self { + name, + kind_hint, + release_tag, + force, + acknowledge_unverified, + }) + } +} + +#[async_trait] +impl Tool for IronhubInstallTool { + fn name(&self) -> &str { + "ironhub_install" + } + + fn description(&self) -> &str { + "Install a tool or skill from the IronHub catalog by name. \ + Auto-detects whether the name refers to a tool or skill from the published manifest; \ + pass kind='tool' or kind='skill' only when the same name exists in both. \ + Pin release_tag to install from a specific IronHub release (default: latest)." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "minLength": 1, + "maxLength": 64, + "description": "IronHub entry name, e.g. 'clickup' or 'chief-of-staff'" + }, + "kind": { "type": "string", "enum": ["tool", "skill"] }, + "release_tag": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "minLength": 1, + "maxLength": 128 + }, + "force": { "type": "boolean", "default": false }, + "acknowledge_unverified": { "type": "boolean", "default": false } + }, + "required": ["name"] + }) + } + + fn rate_limit_config(&self) -> Option { + Some(crate::tools::tool::ToolRateLimitConfig { + requests_per_minute: 6, + requests_per_hour: 30, + }) + } + + async fn execute( + &self, + params: serde_json::Value, + ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + let parsed = InstallParams::from_json(¶ms)?; + let probe = build_installer(parsed.release_tag.as_deref(), None)?; + let manifest = probe + .fetch_manifest() + .await + .map_err(|_| catalog_unavailable())?; + self.install_from_manifest(start, manifest, parsed, ctx) + .await + } + + fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement { + ApprovalRequirement::UnlessAutoApproved + } +} + +pub struct IronhubSearchTool; + +impl IronhubSearchTool { + pub fn new() -> Self { + Self + } +} + +impl Default for IronhubSearchTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for IronhubSearchTool { + fn name(&self) -> &str { + "ironhub_search" + } + + fn description(&self) -> &str { + "Search the IronHub catalog by substring against entry names and descriptions. \ + Returns matching tools and skills." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "query": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "release_tag": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "minLength": 1, + "maxLength": 128 + } + }, + "required": ["query"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + let query = require_str(¶ms, "query")?; + let release_tag = params + .get("release_tag") + .and_then(|v| v.as_str()) + .map(str::to_string); + + let installer = build_installer(release_tag.as_deref(), None)?; + let manifest = installer + .fetch_manifest() + .await + .map_err(|_| catalog_unavailable())?; + + let q = query.to_ascii_lowercase(); + let mut results: Vec = manifest + .tools + .iter() + .filter(|t| entry_matches(&t.name, &t.description, &q)) + .map(tool_entry_json) + .collect(); + results.extend( + manifest + .skills + .iter() + .filter(|s| entry_matches(&s.name, &s.description, &q)) + .map(skill_entry_json), + ); + + let json = serde_json::json!({ + "query": query, + "release_tag": manifest.release_tag, + "count": results.len(), + "results": results, + }); + Ok(ToolOutput::success(json, start.elapsed())) + } +} + +pub struct IronhubListTool; + +impl IronhubListTool { + pub fn new() -> Self { + Self + } +} + +impl Default for IronhubListTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for IronhubListTool { + fn name(&self) -> &str { + "ironhub_list" + } + + fn description(&self) -> &str { + "List everything available in the IronHub catalog grouped by tools and skills." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "release_tag": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "minLength": 1, + "maxLength": 128 + } + } + }) + } + + async fn execute( + &self, + params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + let release_tag = params + .get("release_tag") + .and_then(|v| v.as_str()) + .map(str::to_string); + + let installer = build_installer(release_tag.as_deref(), None)?; + let manifest = installer + .fetch_manifest() + .await + .map_err(|_| catalog_unavailable())?; + + let tools: Vec = manifest.tools.iter().map(tool_entry_json).collect(); + let skills: Vec = manifest.skills.iter().map(skill_entry_json).collect(); + let json = serde_json::json!({ + "release_tag": manifest.release_tag, + "repo": manifest.repo, + "counts": { + "tools": tools.len(), + "skills": skills.len(), + }, + "tools": tools, + "skills": skills, + }); + Ok(ToolOutput::success(json, start.elapsed())) + } +} + +pub struct IronhubInfoTool; + +impl IronhubInfoTool { + pub fn new() -> Self { + Self + } +} + +impl Default for IronhubInfoTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for IronhubInfoTool { + fn name(&self) -> &str { + "ironhub_info" + } + + fn description(&self) -> &str { + "Show detailed metadata for one IronHub entry (tool or skill) including version, \ + description, artifact URLs, and SHA-256 checksums." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "minLength": 1, + "maxLength": 64 + }, + "release_tag": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "minLength": 1, + "maxLength": 128 + } + }, + "required": ["name"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + let name = require_str(¶ms, "name")?; + validate_hub_name(name) + .map_err(|e: anyhow::Error| ToolError::InvalidParameters(e.to_string()))?; + let release_tag = params + .get("release_tag") + .and_then(|v| v.as_str()) + .map(str::to_string); + + let installer = build_installer(release_tag.as_deref(), None)?; + let manifest = installer + .fetch_manifest() + .await + .map_err(|_| catalog_unavailable())?; + + if let Some(t) = manifest.find_tool(name) { + let json = serde_json::json!({ + "kind": "tool", + "name": t.name, + "crate_name": t.crate_name, + "version": t.version, + "description": t.description, + "provenance": t.provenance.as_wire(), + "release_tag": manifest.release_tag, + "wasm": { + "url": t.wasm.url, + "size_bytes": t.wasm.size_bytes, + "sha256": t.wasm.sha256, + }, + "capabilities": { + "url": t.capabilities.url, + "size_bytes": t.capabilities.size_bytes, + "sha256": t.capabilities.sha256, + } + }); + return Ok(ToolOutput::success(json, start.elapsed())); + } + if let Some(s) = manifest.find_skill(name) { + let json = serde_json::json!({ + "kind": "skill", + "name": s.name, + "trunk": s.trunk, + "version": s.version, + "description": s.description, + "provenance": s.provenance.as_wire(), + "release_tag": manifest.release_tag, + "skill_md": { + "url": s.skill_md.url, + "size_bytes": s.skill_md.size_bytes, + "sha256": s.skill_md.sha256, + } + }); + return Ok(ToolOutput::success(json, start.elapsed())); + } + + let suggestions = nearest_matches(&manifest, name); + if suggestions.is_empty() { + Err(ToolError::InvalidParameters(format!( + "'{name}' is not in this IronHub release" + ))) + } else { + Err(ToolError::InvalidParameters(format!( + "'{name}' is not in this IronHub release. Did you mean: {}?", + suggestions.join(", ") + ))) + } + } +} + +pub struct IronhubRemoveTool { + deps: IronhubDeps, +} + +impl IronhubRemoveTool { + pub fn new(deps: IronhubDeps) -> Self { + Self { deps } + } +} + +#[async_trait] +impl Tool for IronhubRemoveTool { + fn name(&self) -> &str { + "ironhub_remove" + } + + fn description(&self) -> &str { + "Remove an installed IronHub tool by name. Deletes the tool's files and \ + unregisters it. Skills are removed with the skill_remove tool." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "minLength": 1, + "maxLength": 64 + } + }, + "required": ["name"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + let name = require_str(¶ms, "name")?; + validate_hub_name(name) + .map_err(|e: anyhow::Error| ToolError::InvalidParameters(e.to_string()))?; + + let message = self + .deps + .extension_manager + .remove(name, &ctx.user_id) + .await + .map_err(|_| { + ToolError::InvalidParameters(format!( + "'{name}' is not an installed IronHub tool. \ + If it is a skill, remove it with the skill_remove tool." + )) + })?; + + let still_present = self + .deps + .extension_manager + .list(Some(ExtensionKind::WasmTool), false, &ctx.user_id) + .await + .map_err(|_| { + ToolError::ExecutionFailed(format!( + "could not verify '{name}' removal from installed extensions" + )) + })? + .iter() + .any(|e| e.name.eq_ignore_ascii_case(name)); + if still_present { + return Err(ToolError::ExecutionFailed(format!( + "'{name}' is still present after removal" + ))); + } + + Ok(ToolOutput::success( + serde_json::json!({ + "status": "removed", + "name": name, + "message": message, + }), + start.elapsed(), + )) + } + + fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement { + ApprovalRequirement::Always + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::{HubArtifact, HubSkillEntry, HubToolEntry, Provenance}; + + fn art(name: &str, ext: &str) -> HubArtifact { + HubArtifact { + url: format!( + "https://github.com/nearai/ironhub/releases/download/test/{}.{}", + name, ext + ), + size_bytes: 1024, + sha256: "a".repeat(64), + } + } + + fn manifest_with(tools: Vec<&str>, skills: Vec<&str>) -> HubManifest { + HubManifest { + version: "1".into(), + generated_at: "2026-05-14T00:00:00Z".into(), + release_tag: "release-test".into(), + repo: "nearai/ironhub".into(), + tools: tools + .into_iter() + .map(|n| HubToolEntry { + name: n.into(), + crate_name: format!("{}-tool", n), + version: "0.1.0".into(), + description: format!("{} tool", n), + provenance: Provenance::Official, + wasm: art(n, "wasm"), + capabilities: art(n, "capabilities.json"), + }) + .collect(), + skills: skills + .into_iter() + .map(|n| HubSkillEntry { + name: n.into(), + trunk: String::new(), + version: "0.1.0".into(), + description: format!("{} skill", n), + provenance: Provenance::Official, + skill_md: art(n, "SKILL.md"), + }) + .collect(), + } + } + + fn outcome(name: &str, with_meta: bool) -> HubInstallOutcome { + outcome_prov(name, with_meta, Provenance::Official) + } + + fn outcome_prov(name: &str, with_meta: bool, provenance: Provenance) -> HubInstallOutcome { + HubInstallOutcome { + name: name.into(), + version: "0.1.0".into(), + release_tag: "release-test".into(), + provenance, + primary_path: std::path::PathBuf::from(format!("/install/{name}.wasm")), + metadata_path: if with_meta { + Some(std::path::PathBuf::from(format!( + "/install/{name}.capabilities.json" + ))) + } else { + None + }, + } + } + + #[test] + fn search_schema_requires_query() { + let tool = IronhubSearchTool::new(); + let schema = tool.parameters_schema(); + assert_eq!(schema["required"], serde_json::json!(["query"])); + } + + #[test] + fn list_schema_has_no_required_fields() { + let tool = IronhubListTool::new(); + let schema = tool.parameters_schema(); + assert!( + schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty() + ); + } + + #[test] + fn info_schema_requires_name() { + let tool = IronhubInfoTool::new(); + let schema = tool.parameters_schema(); + assert_eq!(schema["required"], serde_json::json!(["name"])); + } + + #[test] + fn read_only_tools_default_to_never_approval() { + let params = serde_json::json!({}); + assert!(matches!( + IronhubSearchTool::new().requires_approval(¶ms), + ApprovalRequirement::Never + )); + assert!(matches!( + IronhubListTool::new().requires_approval(¶ms), + ApprovalRequirement::Never + )); + assert!(matches!( + IronhubInfoTool::new().requires_approval(¶ms), + ApprovalRequirement::Never + )); + } + + #[test] + fn classify_picks_tool_when_only_in_tools() { + let m = manifest_with(vec!["clickup"], vec!["chief-of-staff"]); + assert_eq!(classify(&m, "clickup", None).unwrap(), HubEntryKind::Tool); + } + + #[test] + fn classify_picks_skill_when_only_in_skills() { + let m = manifest_with(vec!["clickup"], vec!["chief-of-staff"]); + assert_eq!( + classify(&m, "chief-of-staff", None).unwrap(), + HubEntryKind::Skill + ); + } + + #[test] + fn classify_returns_invalid_parameters_for_ambiguous() { + let m = manifest_with(vec!["overlap"], vec!["overlap"]); + let err = classify(&m, "overlap", None).expect_err("must error"); + match err { + ToolError::InvalidParameters(msg) => assert!(msg.contains("disambiguate")), + other => panic!("expected InvalidParameters, got {other:?}"), + } + } + + #[test] + fn classify_honors_kind_tool_override() { + let m = manifest_with(vec!["overlap"], vec!["overlap"]); + assert_eq!( + classify(&m, "overlap", Some(HubEntryKind::Tool)).unwrap(), + HubEntryKind::Tool + ); + } + + #[test] + fn classify_honors_kind_skill_override() { + let m = manifest_with(vec!["overlap"], vec!["overlap"]); + assert_eq!( + classify(&m, "overlap", Some(HubEntryKind::Skill)).unwrap(), + HubEntryKind::Skill + ); + } + + #[test] + fn classify_kind_tool_rejects_skill_only_name() { + let m = manifest_with(vec![], vec!["chief-of-staff"]); + let err = classify(&m, "chief-of-staff", Some(HubEntryKind::Tool)).expect_err("must error"); + match err { + ToolError::InvalidParameters(msg) => assert!(msg.contains("not a tool")), + other => panic!("expected InvalidParameters, got {other:?}"), + } + } + + #[test] + fn classify_returns_invalid_parameters_with_suggestions_for_typos() { + let m = manifest_with(vec!["clickup", "evm-rpc"], vec![]); + let err = classify(&m, "click", None).expect_err("must error"); + match err { + ToolError::InvalidParameters(msg) => { + assert!(msg.contains("Did you mean")); + assert!(msg.contains("clickup")); + } + other => panic!("expected InvalidParameters, got {other:?}"), + } + } + + #[test] + fn kind_param_rejects_invalid_string() { + let err = HubEntryKind::from_param("channel").expect_err("must error"); + match err { + ToolError::InvalidParameters(msg) => assert!(msg.contains("kind must be")), + other => panic!("expected InvalidParameters, got {other:?}"), + } + } + + #[test] + fn kind_param_accepts_tool_and_skill() { + assert_eq!( + HubEntryKind::from_param("tool").unwrap(), + HubEntryKind::Tool + ); + assert_eq!( + HubEntryKind::from_param("skill").unwrap(), + HubEntryKind::Skill + ); + } + + #[test] + fn install_outcome_to_json_includes_required_fields() { + let json = install_outcome_to_json(HubEntryKind::Tool, &outcome("clickup", true)); + assert_eq!(json["status"], "installed"); + assert_eq!(json["kind"], "tool"); + assert_eq!(json["name"], "clickup"); + assert_eq!(json["version"], "0.1.0"); + assert_eq!(json["release_tag"], "release-test"); + assert!( + json["primary_path"] + .as_str() + .unwrap() + .contains("clickup.wasm") + ); + assert!(json["metadata_path"].as_str().is_some()); + } + + #[test] + fn install_outcome_to_json_omits_metadata_path_when_none() { + let json = install_outcome_to_json(HubEntryKind::Skill, &outcome("chief-of-staff", false)); + assert_eq!(json["kind"], "skill"); + assert!(json.get("metadata_path").is_none()); + } + + #[test] + fn install_outcome_to_json_official_has_provenance_no_warning() { + let json = install_outcome_to_json(HubEntryKind::Tool, &outcome("clickup", true)); + assert_eq!(json["provenance"], "official"); + assert!(json.get("warning").is_none()); + assert!(json.get("unverified").is_none()); + } + + #[test] + fn install_outcome_to_json_new_provenance_warns_and_flags_unverified() { + let json = install_outcome_to_json( + HubEntryKind::Skill, + &outcome_prov("indie-skill", false, Provenance::New), + ); + assert_eq!(json["provenance"], "new"); + assert_eq!(json["unverified"], true); + assert!( + json["warning"] + .as_str() + .unwrap() + .contains("not NEAR-vetted") + ); + } + + #[test] + fn tool_entry_json_surfaces_provenance_and_trust_label() { + let entry = HubToolEntry { + name: "indie-tool".into(), + crate_name: "indie-tool".into(), + version: "0.1.0".into(), + description: "Community tool".into(), + provenance: Provenance::New, + wasm: art("indie-tool", "wasm"), + capabilities: art("indie-tool", "capabilities.json"), + }; + let json = tool_entry_json(&entry); + assert_eq!(json["provenance"], "new"); + assert_eq!(json["trust_label"], Provenance::New.trust_label()); + assert_eq!(json["name"], "indie-tool"); + assert_eq!(json["kind"], "tool"); + } + + #[test] + fn skill_entry_json_surfaces_provenance_and_trust_label() { + let entry = HubSkillEntry { + name: "indie-skill".into(), + trunk: String::new(), + version: "0.1.0".into(), + description: "Community skill".into(), + provenance: Provenance::Verified, + skill_md: art("indie-skill", "SKILL.md"), + }; + let json = skill_entry_json(&entry); + assert_eq!(json["provenance"], "verified"); + assert_eq!(json["trust_label"], Provenance::Verified.trust_label()); + assert_eq!(json["name"], "indie-skill"); + assert_eq!(json["kind"], "skill"); + } + + fn manifest_with_provenance( + tool_name: &str, + tool_provenance: Provenance, + skill_name: Option<&str>, + skill_provenance: Option, + ) -> HubManifest { + let mut manifest = manifest_with(vec![tool_name], skill_name.into_iter().collect()); + if let Some(tool) = manifest.tools.first_mut() { + tool.provenance = tool_provenance; + } + if let (Some(skill), Some(prov)) = (manifest.skills.first_mut(), skill_provenance) { + skill.provenance = prov; + } + manifest + } + + #[test] + fn classify_and_gate_rejects_community_unverified_without_acknowledgement() { + let manifest = manifest_with_provenance("indie-tool", Provenance::New, None, None); + let err = classify_and_gate(&manifest, "indie-tool", None, false) + .expect_err("community-unverified without ack must be rejected"); + match err { + ToolError::InvalidParameters(msg) => { + assert!( + msg.contains("UNVERIFIED") && msg.contains("acknowledge_unverified"), + "error must name the gate: {msg}" + ); + } + other => panic!("expected InvalidParameters, got {other:?}"), + } + } + + #[test] + fn classify_and_gate_accepts_community_unverified_with_acknowledgement() { + let manifest = manifest_with_provenance("indie-tool", Provenance::New, None, None); + let (kind, provenance) = classify_and_gate(&manifest, "indie-tool", None, true) + .expect("community-unverified with ack must pass the gate"); + assert_eq!(kind, HubEntryKind::Tool); + assert_eq!(provenance, Provenance::New); + } + + #[test] + fn classify_and_gate_accepts_official_without_acknowledgement() { + let manifest = manifest_with_provenance("clickup", Provenance::Official, None, None); + let (kind, provenance) = classify_and_gate(&manifest, "clickup", None, false) + .expect("official content must pass the gate without ack"); + assert_eq!(kind, HubEntryKind::Tool); + assert_eq!(provenance, Provenance::Official); + } + + #[test] + fn classify_and_gate_rejects_community_unverified_skill_without_acknowledgement() { + let manifest = manifest_with_provenance( + "official-tool", + Provenance::Official, + Some("indie-skill"), + Some(Provenance::New), + ); + let err = classify_and_gate(&manifest, "indie-skill", None, false) + .expect_err("community-unverified skill must be gated too"); + match err { + ToolError::InvalidParameters(msg) => assert!(msg.contains("UNVERIFIED")), + other => panic!("expected InvalidParameters, got {other:?}"), + } + } + + fn install_tool_with_ext_mgr() -> (IronhubInstallTool, tempfile::TempDir, tempfile::TempDir) { + let secrets = crate::channels::web::test_helpers::test_secrets_store(); + let (ext_mgr, tools_dir, channels_dir) = + crate::channels::web::test_helpers::test_ext_mgr(secrets); + let tool = IronhubInstallTool::new(IronhubDeps { + extension_manager: ext_mgr, + skill_registry: None, + }); + (tool, tools_dir, channels_dir) + } + + #[tokio::test] + async fn install_from_manifest_rejects_provenance_new_without_acknowledgement() { + let (tool, _tools_dir, _channels_dir) = install_tool_with_ext_mgr(); + let manifest = manifest_with_provenance("indie-tool", Provenance::New, None, None); + let parsed = InstallParams { + name: "indie-tool".into(), + kind_hint: None, + release_tag: None, + force: false, + acknowledge_unverified: false, + }; + let ctx = JobContext::with_user("test", "install gate test", ""); + let err = tool + .install_from_manifest(std::time::Instant::now(), manifest, parsed, &ctx) + .await + .expect_err( + "Provenance::New without ack must be rejected at the execute caller boundary", + ); + match err { + ToolError::InvalidParameters(msg) => { + assert!( + msg.contains("UNVERIFIED") && msg.contains("acknowledge_unverified"), + "caller-level gate error must name the flag the user has to set, got: {msg}" + ); + } + other => panic!( + "execute caller must surface the gate as InvalidParameters; got {other:?} \ + (a different error type means the gate fired downstream of the install side effect)" + ), + } + } + + #[tokio::test] + async fn install_params_from_json_propagates_acknowledge_unverified() { + let parsed = InstallParams::from_json(&serde_json::json!({ + "name": "indie-tool", + "acknowledge_unverified": true, + })) + .expect("valid params"); + assert!( + parsed.acknowledge_unverified, + "ack flag must reach the install caller; without this, the UI ack flow is a no-op" + ); + let default = InstallParams::from_json(&serde_json::json!({"name": "indie-tool"})) + .expect("valid params"); + assert!( + !default.acknowledge_unverified, + "omitted ack must default to false so community content stays gated" + ); + } + + #[test] + fn catalog_unavailable_is_user_safe_external_service() { + let err = catalog_unavailable(); + assert!(matches!(err, ToolError::ExternalService(_)), "got {err:?}"); + let msg = err.to_string(); + assert!(!msg.contains("http")); + assert!(!msg.contains("hub.ironclaw.com")); + assert!(!msg.contains('/')); + } + + #[test] + fn entry_matches_lowercases_name_against_already_lowercased_query() { + assert!(entry_matches("ClickUp", "Task tracking", "clickup")); + assert!(entry_matches("clickup", "Task tracking", "click")); + assert!(!entry_matches("clickup", "Task tracking", "CLICK")); + } + + #[test] + fn entry_matches_searches_description() { + assert!(entry_matches( + "evm-rpc", + "Ethereum RPC bindings", + "ethereum" + )); + assert!(!entry_matches("evm-rpc", "Ethereum RPC bindings", "solana")); + } + + #[test] + fn nearest_matches_filters_by_substring_both_directions() { + let m = manifest_with(vec!["clickup", "evm-rpc", "near-rpc"], vec![]); + let hits = nearest_matches(&m, "rpc"); + assert!(hits.contains(&"evm-rpc".to_string())); + assert!(hits.contains(&"near-rpc".to_string())); + assert!(!hits.contains(&"clickup".to_string())); + } + + #[test] + fn install_schema_pattern_blocks_path_traversal() { + let tool_schema = IronhubSearchTool::new().parameters_schema(); + let pattern = tool_schema["properties"]["release_tag"]["pattern"] + .as_str() + .expect("release_tag pattern"); + let re = regex::Regex::new(pattern).expect("valid regex"); + assert!(!re.is_match("../etc/passwd")); + assert!(!re.is_match("release with space")); + assert!(!re.is_match("release\nnewline")); + assert!(re.is_match("release-2026-05-12-24")); + } + + #[test] + fn install_schema_declares_required_name() { + let schema = IronhubInfoTool::new().parameters_schema(); + assert_eq!(schema["required"], serde_json::json!(["name"])); + let name_pattern = schema["properties"]["name"]["pattern"] + .as_str() + .expect("name pattern"); + let re = regex::Regex::new(name_pattern).expect("valid regex"); + assert!(re.is_match("clickup")); + assert!(re.is_match("chief-of-staff")); + assert!(!re.is_match("../etc")); + assert!(!re.is_match("Name")); + assert!(!re.is_match("")); + } + + #[test] + fn schemas_reject_unknown_fields() { + for schema in [ + IronhubSearchTool::new().parameters_schema(), + IronhubListTool::new().parameters_schema(), + IronhubInfoTool::new().parameters_schema(), + ] { + assert_eq!( + schema["additionalProperties"], + serde_json::Value::Bool(false), + "additionalProperties: false required to reject LLM injection of unknown fields" + ); + } + } + + async fn dispatcher_with(tool: Arc) -> Arc { + use crate::config::SafetyConfig; + use crate::db::Database; + use crate::db::UserRecord; + use crate::db::libsql::LibSqlBackend; + use crate::tools::dispatch::ToolDispatcher; + use crate::tools::registry::ToolRegistry; + use ironclaw_safety::SafetyLayer; + + let dir = tempfile::tempdir().expect("tempdir"); + let backend = Arc::new( + LibSqlBackend::new_local(&dir.path().join("test.db")) + .await + .expect("libsql backend"), + ); + backend.run_migrations().await.expect("migrations"); + let db: Arc = Arc::clone(&backend) as Arc; + let now = chrono::Utc::now(); + db.create_user(&UserRecord { + id: "tester".to_string(), + email: None, + display_name: "tester".to_string(), + status: "active".to_string(), + role: "admin".to_string(), + created_at: now, + updated_at: now, + last_login_at: None, + created_by: None, + metadata: serde_json::json!({}), + }) + .await + .expect("create user"); + + let registry = Arc::new(ToolRegistry::new()); + registry.register(tool).await; + let safety = Arc::new(SafetyLayer::new(&SafetyConfig { + max_output_length: 65_536, + injection_check_enabled: false, + })); + std::mem::forget(dir); + Arc::new(ToolDispatcher::new(registry, safety, db)) + } + + #[tokio::test] + async fn dispatch_ironhub_search_rejects_empty_query() { + let dispatcher = dispatcher_with(Arc::new(IronhubSearchTool::new())).await; + let err = dispatcher + .dispatch( + "ironhub_search", + serde_json::json!({ "query": "" }), + "tester", + crate::tools::dispatch::DispatchSource::Channel("gateway".into()), + ) + .await + .expect_err("empty query must fail schema validation"); + assert!( + matches!(err, ToolError::InvalidParameters(_)), + "expected InvalidParameters, got {err:?}" + ); + } + + #[tokio::test] + async fn dispatch_ironhub_search_rejects_unknown_field() { + let dispatcher = dispatcher_with(Arc::new(IronhubSearchTool::new())).await; + let err = dispatcher + .dispatch( + "ironhub_search", + serde_json::json!({ "query": "rpc", "evil_extra_field": "exfil" }), + "tester", + crate::tools::dispatch::DispatchSource::Channel("gateway".into()), + ) + .await + .expect_err("unknown field must fail schema validation"); + assert!(matches!(err, ToolError::InvalidParameters(_))); + } + + #[tokio::test] + async fn dispatch_ironhub_info_rejects_path_traversal_in_name() { + let dispatcher = dispatcher_with(Arc::new(IronhubInfoTool::new())).await; + let err = dispatcher + .dispatch( + "ironhub_info", + serde_json::json!({ "name": "../etc/passwd" }), + "tester", + crate::tools::dispatch::DispatchSource::Channel("gateway".into()), + ) + .await + .expect_err("path traversal must fail schema validation"); + assert!(matches!(err, ToolError::InvalidParameters(_))); + } + + #[tokio::test] + async fn dispatch_ironhub_info_rejects_missing_required_name() { + let dispatcher = dispatcher_with(Arc::new(IronhubInfoTool::new())).await; + let err = dispatcher + .dispatch( + "ironhub_info", + serde_json::json!({}), + "tester", + crate::tools::dispatch::DispatchSource::Channel("gateway".into()), + ) + .await + .expect_err("missing required name must fail schema validation"); + assert!(matches!(err, ToolError::InvalidParameters(_))); + } + + #[tokio::test] + async fn dispatch_ironhub_info_rejects_malformed_release_tag() { + let dispatcher = dispatcher_with(Arc::new(IronhubInfoTool::new())).await; + let err = dispatcher + .dispatch( + "ironhub_info", + serde_json::json!({ "name": "clickup", "release_tag": "release with space" }), + "tester", + crate::tools::dispatch::DispatchSource::Channel("gateway".into()), + ) + .await + .expect_err("bad release_tag must fail schema validation"); + assert!(matches!(err, ToolError::InvalidParameters(_))); + } + + #[tokio::test] + async fn skill_install_dir_prefers_installed_dir_over_user_dir() { + let tmp = tempfile::tempdir().expect("tempdir"); + let installed = tmp.path().join("installed"); + let user = tmp.path().join("user"); + std::fs::create_dir_all(&installed).expect("mkdir installed"); + std::fs::create_dir_all(&user).expect("mkdir user"); + let mut registry = SkillRegistry::new(user.clone()) + .with_installed_dir(installed.clone()) + .with_max_scan_depth(2); + registry.discover_all().await; + let registry = Arc::new(std::sync::RwLock::new(registry)); + + let resolved = skill_install_dir(&Some(registry.clone())).expect("resolved"); + assert_eq!( + resolved, installed, + "IronHub skill installs must land in the Installed bucket, not the Trusted user_dir" + ); + assert_ne!( + resolved, user, + "regression: skill_install_dir must not return user_dir" + ); + } + + fn write_fake_tool_on_disk(tools_dir: &std::path::Path, name: &str) { + std::fs::create_dir_all(tools_dir).expect("tools dir"); + std::fs::write(tools_dir.join(format!("{name}.wasm")), b"not-a-real-wasm") + .expect("write wasm stub"); + std::fs::write( + tools_dir.join(format!("{name}.capabilities.json")), + r#"{"description":"test stub"}"#, + ) + .expect("write capabilities stub"); + } + + fn remove_tool_with_ext_mgr(ext_mgr: Arc) -> IronhubRemoveTool { + IronhubRemoveTool::new(IronhubDeps { + extension_manager: ext_mgr, + skill_registry: None, + }) + } + + #[tokio::test] + async fn ironhub_remove_execute_returns_removed_status_and_deletes_files() { + let secrets = crate::channels::web::test_helpers::test_secrets_store(); + let (ext_mgr, tools_dir, _channels_dir) = + crate::channels::web::test_helpers::test_ext_mgr(secrets); + write_fake_tool_on_disk(tools_dir.path(), "test_remove_target"); + + let tool = remove_tool_with_ext_mgr(Arc::clone(&ext_mgr)); + let ctx = JobContext::with_user("test", "remove test", ""); + + let output = tool + .execute(serde_json::json!({ "name": "test_remove_target" }), &ctx) + .await + .expect("remove of installed tool must succeed"); + + assert_eq!(output.result["status"], "removed"); + assert_eq!(output.result["name"], "test_remove_target"); + assert!( + output.result["message"].as_str().is_some(), + "message field must be present so the agent surfaces the manager's report" + ); + assert!( + !tools_dir.path().join("test_remove_target.wasm").exists(), + "remove must delete the .wasm artifact from disk" + ); + assert!( + !tools_dir + .path() + .join("test_remove_target.capabilities.json") + .exists(), + "remove must delete the .capabilities.json artifact from disk" + ); + } + + #[tokio::test] + async fn ironhub_remove_execute_rejects_invalid_name_before_touching_manager() { + let secrets = crate::channels::web::test_helpers::test_secrets_store(); + let (ext_mgr, _tools_dir, _channels_dir) = + crate::channels::web::test_helpers::test_ext_mgr(secrets); + let tool = remove_tool_with_ext_mgr(ext_mgr); + let ctx = JobContext::with_user("test", "remove test", ""); + + let err = tool + .execute( + serde_json::json!({ "name": "Invalid Name With Spaces" }), + &ctx, + ) + .await + .expect_err("malformed name must be rejected by validate_hub_name"); + + match err { + ToolError::InvalidParameters(msg) => assert!( + msg.contains("not a valid IronHub name"), + "validator message must surface to the caller, got: {msg}" + ), + other => panic!("expected InvalidParameters, got {other:?}"), + } + } + + #[tokio::test] + async fn ironhub_remove_execute_maps_not_installed_to_actionable_error() { + let secrets = crate::channels::web::test_helpers::test_secrets_store(); + let (ext_mgr, _tools_dir, _channels_dir) = + crate::channels::web::test_helpers::test_ext_mgr(secrets); + let tool = remove_tool_with_ext_mgr(ext_mgr); + let ctx = JobContext::with_user("test", "remove test", ""); + + let err = tool + .execute(serde_json::json!({ "name": "never_installed_tool" }), &ctx) + .await + .expect_err("removing a tool that is not installed must surface as an error"); + + match err { + ToolError::InvalidParameters(msg) => { + assert!( + msg.contains("not an installed IronHub tool"), + "error must point the caller at the right surface, got: {msg}" + ); + assert!( + msg.contains("skill_remove"), + "error must hint at skill_remove for the wrong-tool case, got: {msg}" + ); + } + other => panic!("expected InvalidParameters, got {other:?}"), + } + } + + #[tokio::test] + async fn ironhub_remove_execute_passes_post_remove_list_verification() { + let secrets = crate::channels::web::test_helpers::test_secrets_store(); + let (ext_mgr, tools_dir, _channels_dir) = + crate::channels::web::test_helpers::test_ext_mgr(secrets); + write_fake_tool_on_disk(tools_dir.path(), "verifier_target"); + + let pre_remove = ext_mgr + .list(Some(ExtensionKind::WasmTool), false, "test") + .await + .expect("pre-remove list must succeed"); + assert!( + pre_remove + .iter() + .any(|e| e.name.eq_ignore_ascii_case("verifier_target")), + "fixture must seed verifier_target before remove runs" + ); + + let tool = remove_tool_with_ext_mgr(Arc::clone(&ext_mgr)); + let ctx = JobContext::with_user("test", "remove test", ""); + tool.execute(serde_json::json!({ "name": "verifier_target" }), &ctx) + .await + .expect("remove must succeed when the post-remove list is empty"); + + let post_remove = ext_mgr + .list(Some(ExtensionKind::WasmTool), false, "test") + .await + .expect("post-remove list must succeed"); + assert!( + !post_remove + .iter() + .any(|e| e.name.eq_ignore_ascii_case("verifier_target")), + "post-remove list must be empty so the still-present guard stays silent" + ); + } + + #[tokio::test] + async fn ironhub_remove_execute_surfaces_still_present_when_orphan_remains() { + let secrets = crate::channels::web::test_helpers::test_secrets_store(); + let (ext_mgr, tools_dir, _channels_dir) = + crate::channels::web::test_helpers::test_ext_mgr(secrets); + write_fake_tool_on_disk(tools_dir.path(), "stuck_tool"); + std::fs::write( + tools_dir.path().join("STUCK_TOOL.wasm"), + b"orphan-uppercase-twin", + ) + .expect("write orphan twin"); + + let tool = remove_tool_with_ext_mgr(Arc::clone(&ext_mgr)); + let ctx = JobContext::with_user("test", "remove test", ""); + let err = tool + .execute(serde_json::json!({ "name": "stuck_tool" }), &ctx) + .await + .expect_err("orphan twin under a different case must trip the still-present guard"); + + match err { + ToolError::ExecutionFailed(msg) => { + assert!( + msg.contains("still present after removal"), + "still-present guard must surface a clear message, got: {msg}" + ); + assert!( + msg.contains("stuck_tool"), + "error must name the offending tool, got: {msg}" + ); + } + other => panic!( + "still-present must surface as ExecutionFailed, not the manager's not-installed mapping; got {other:?}" + ), + } + } +} diff --git a/src/tools/builtin/mod.rs b/src/tools/builtin/mod.rs index 666e5094817..32338cbe54e 100644 --- a/src/tools/builtin/mod.rs +++ b/src/tools/builtin/mod.rs @@ -8,6 +8,7 @@ pub mod file_history; mod glob_tool; mod grep_tool; mod http; +pub mod ironhub; mod job; mod json; pub mod memory; @@ -34,6 +35,10 @@ pub use file_history::{FileHistory, FileUndoTool, SharedFileHistory, shared_file pub use glob_tool::GlobTool; pub use grep_tool::GrepTool; pub use http::{HttpTool, extract_host_from_params, extract_path_from_params}; +pub use ironhub::{ + IronhubDeps, IronhubInfoTool, IronhubInstallTool, IronhubListTool, IronhubRemoveTool, + IronhubSearchTool, +}; pub use job::{ CancelJobTool, CreateJobTool, JobEventsTool, JobPromptTool, JobStatusTool, ListJobsTool, PromptQueue, SchedulerSlot, diff --git a/src/tools/registry.rs b/src/tools/registry.rs index a3db7bd36b0..832301cf7e2 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -97,6 +97,12 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[ "skill_search", "skill_install", "skill_remove", + // IronHub catalog tools + "ironhub_install", + "ironhub_remove", + "ironhub_search", + "ironhub_list", + "ironhub_info", // Secret tools "secret_list", "secret_delete", @@ -771,6 +777,19 @@ impl ToolRegistry { tracing::debug!("Registered 4 skill management tools"); } + pub fn register_ironhub_tools(&self, deps: crate::tools::builtin::IronhubDeps) { + use crate::tools::builtin::{ + IronhubInfoTool, IronhubInstallTool, IronhubListTool, IronhubRemoveTool, + IronhubSearchTool, + }; + self.register_sync(Arc::new(IronhubInstallTool::new(deps.clone()))); + self.register_sync(Arc::new(IronhubRemoveTool::new(deps))); + self.register_sync(Arc::new(IronhubSearchTool::new())); + self.register_sync(Arc::new(IronhubListTool::new())); + self.register_sync(Arc::new(IronhubInfoTool::new())); + tracing::debug!("Registered 5 IronHub catalog tools"); + } + /// Register routine management tools. /// /// These allow the LLM to create, list, update, delete, and view history @@ -1753,4 +1772,31 @@ mod tests { assert!(removed.is_some(), "unregister must resolve hyphen alias"); assert!(!registry.has("my_mcp_search").await, "tool should be gone"); } + + #[tokio::test] + async fn register_ironhub_tools_exposes_all_five_names() { + let secrets = crate::channels::web::test_helpers::test_secrets_store(); + let (ext_mgr, _tools_dir, _channels_dir) = + crate::channels::web::test_helpers::test_ext_mgr(secrets); + let deps = crate::tools::builtin::IronhubDeps { + extension_manager: ext_mgr, + skill_registry: None, + }; + let registry = ToolRegistry::new(); + registry.register_ironhub_tools(deps); + + let names = registry.list().await; + for required in [ + "ironhub_install", + "ironhub_remove", + "ironhub_search", + "ironhub_list", + "ironhub_info", + ] { + assert!( + names.contains(&required.to_string()), + "register_ironhub_tools must register {required}; got {names:?}" + ); + } + } } diff --git a/tests/cross_tenant_resource_isolation.rs b/tests/cross_tenant_resource_isolation.rs index 020953cd0f5..4305c9843ab 100644 --- a/tests/cross_tenant_resource_isolation.rs +++ b/tests/cross_tenant_resource_isolation.rs @@ -109,6 +109,7 @@ async fn start_server_with_db() -> ( auth_manager: None, chat_rate_limiter: PerUserRateLimiter::new(30, 60), oauth_rate_limiter: PerUserRateLimiter::new(20, 60), + ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60), registry_entries: Vec::new(), cost_guard: None, routine_engine: Arc::new(tokio::sync::RwLock::new(None)), diff --git a/tests/e2e/scenarios/test_ironhub_deep_link_install.py b/tests/e2e/scenarios/test_ironhub_deep_link_install.py new file mode 100644 index 00000000000..bf3f5221e07 --- /dev/null +++ b/tests/e2e/scenarios/test_ironhub_deep_link_install.py @@ -0,0 +1,186 @@ +"""Deep-link install flow: hash parsing, verify-intent, error surfaces. + +Covers the browser-side flow that the in-process handler tests cannot reach: +the user lands on `#/install/?slug=&version=&uid=&aid=&ts=&nonce=&sig=`, +the JS in `static/js/core/routing.js` parses the params, `install.js` +calls `POST /api/ironhub/verify-intent`, and the install card renders the +verify result. This locks in the wire contract end to end against +regressions in either the URL parser or the verify-intent handler. +""" + +import hashlib +import hmac +import time +import uuid + +from helpers import AUTH_TOKEN, api_post + +SHARED_KEY = "ihub_sk_e2e_test_shared_key_padding_xx" +SLUG = "clickup" +VERSION = "0.1.0" +UID = "e2e-user" +AID = "e2e-agent" + + +def install_payload(slug: str, version: str, uid: str, aid: str, ts: int, nonce: str) -> str: + return f"install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}" + + +def sign_install(key: str, slug: str, version: str, uid: str, aid: str, ts: int, nonce: str) -> str: + msg = install_payload(slug, version, uid, aid, ts, nonce) + return hmac.new(key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).hexdigest() + + +def install_hash(*, slug: str, version: str, uid: str, aid: str, ts: int, nonce: str, sig: str) -> str: + return ( + f"#/install/{slug}" + f"?slug={slug}" + f"&version={version}" + f"&uid={uid}" + f"&aid={aid}" + f"&ts={ts}" + f"&nonce={nonce}" + f"&sig={sig}" + ) + + +async def _seed_signing_key(server: str) -> None: + """Idempotently set the IronHub signing key for the test user.""" + response = await api_post( + server, + "/api/ironhub/signing-key", + json={"shared_key": SHARED_KEY}, + timeout=10, + ) + assert response.status_code == 200, ( + f"signing-key POST failed: {response.status_code} {response.text}" + ) + + +async def test_deep_link_install_valid_signature_renders_confirm(page, ironclaw_server): + await _seed_signing_key(ironclaw_server) + + ts = int(time.time()) + nonce = uuid.uuid4().hex + sig = sign_install(SHARED_KEY, SLUG, VERSION, UID, AID, ts, nonce) + hash_fragment = install_hash( + slug=SLUG, version=VERSION, uid=UID, aid=AID, ts=ts, nonce=nonce, sig=sig + ) + + await page.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}{hash_fragment}") + + confirm_btn = page.locator("#ironhub-install-confirm-btn") + await confirm_btn.wait_for(state="visible", timeout=10000) + assert await confirm_btn.is_enabled(), "valid sig must yield an enabled confirm button" + + card = page.locator("#tab-install .ironhub-install-card") + card_text = (await card.inner_text()).lower() + assert SLUG in card_text, f"expected slug '{SLUG}' on confirm card, got: {card_text!r}" + + +async def test_deep_link_install_confirm_click_posts_to_install_endpoint(page, ironclaw_server): + await _seed_signing_key(ironclaw_server) + + ts = int(time.time()) + nonce = uuid.uuid4().hex + sig = sign_install(SHARED_KEY, SLUG, VERSION, UID, AID, ts, nonce) + hash_fragment = install_hash( + slug=SLUG, version=VERSION, uid=UID, aid=AID, ts=ts, nonce=nonce, sig=sig + ) + + install_requests = [] + + async def capture_install(route): + install_requests.append(route.request) + await route.fulfill( + status=502, + content_type="application/json", + body='{"error": "catalog unreachable in e2e"}', + ) + + await page.route("**/api/ironhub/install", capture_install) + await page.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}{hash_fragment}") + + confirm_btn = page.locator("#ironhub-install-confirm-btn") + await confirm_btn.wait_for(state="visible", timeout=10000) + assert await confirm_btn.is_enabled(), "verified deep-link must yield an enabled confirm button" + + await confirm_btn.click() + + deadline = time.time() + 5 + while not install_requests and time.time() < deadline: + await page.wait_for_timeout(100) + + assert install_requests, "clicking confirm must POST /api/ironhub/install" + body = install_requests[0].post_data_json or {} + assert body.get("name") == SLUG, f"install body must carry slug, got: {body!r}" + + +async def test_deep_link_install_tampered_signature_shows_mismatch(page, ironclaw_server): + await _seed_signing_key(ironclaw_server) + + ts = int(time.time()) + nonce = uuid.uuid4().hex + bad_sig = "deadbeef" * 8 + hash_fragment = install_hash( + slug=SLUG, version=VERSION, uid=UID, aid=AID, ts=ts, nonce=nonce, sig=bad_sig + ) + + await page.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}{hash_fragment}") + + error = page.locator("#tab-install .ironhub-install-error") + await error.wait_for(state="visible", timeout=10000) + text = (await error.inner_text()).lower() + assert "mismatch" in text, f"expected 'mismatch' in error, got: {text!r}" + + +async def test_deep_link_install_stale_timestamp_shows_drift(page, ironclaw_server): + await _seed_signing_key(ironclaw_server) + + ts = int(time.time()) - 4000 + nonce = uuid.uuid4().hex + sig = sign_install(SHARED_KEY, SLUG, VERSION, UID, AID, ts, nonce) + hash_fragment = install_hash( + slug=SLUG, version=VERSION, uid=UID, aid=AID, ts=ts, nonce=nonce, sig=sig + ) + + await page.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}{hash_fragment}") + + error = page.locator("#tab-install .ironhub-install-error") + await error.wait_for(state="visible", timeout=10000) + text = (await error.inner_text()).lower() + assert "drift" in text, f"expected 'drift' in error, got: {text!r}" + + +async def test_deep_link_install_missing_params_shows_error(page, ironclaw_server): + """JS short-circuits before calling verify-intent when any required param is absent.""" + await page.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}#/install/{SLUG}") + + error = page.locator("#tab-install .ironhub-install-error") + await error.wait_for(state="visible", timeout=10000) + confirm_btn = page.locator("#ironhub-install-confirm-btn") + assert not await confirm_btn.is_visible(), ( + "confirm button must NOT render when params are missing" + ) + + +async def test_deep_link_install_replayed_nonce_is_rejected(page, ironclaw_server): + """The same nonce can only succeed once per user; replay returns the nonce error.""" + await _seed_signing_key(ironclaw_server) + + ts = int(time.time()) + nonce = uuid.uuid4().hex + sig = sign_install(SHARED_KEY, SLUG, VERSION, UID, AID, ts, nonce) + hash_fragment = install_hash( + slug=SLUG, version=VERSION, uid=UID, aid=AID, ts=ts, nonce=nonce, sig=sig + ) + + await page.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}{hash_fragment}") + confirm_btn = page.locator("#ironhub-install-confirm-btn") + await confirm_btn.wait_for(state="visible", timeout=10000) + + await page.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}{hash_fragment}") + error = page.locator("#tab-install .ironhub-install-error") + await error.wait_for(state="visible", timeout=10000) + text = (await error.inner_text()).lower() + assert "nonce" in text, f"expected 'nonce' in replay error, got: {text!r}" diff --git a/tests/multi_tenant_integration.rs b/tests/multi_tenant_integration.rs index c89eba3d9b5..eb63743441d 100644 --- a/tests/multi_tenant_integration.rs +++ b/tests/multi_tenant_integration.rs @@ -559,6 +559,7 @@ fn gateway_state_has_multi_tenant_fields() { auth_manager: None, chat_rate_limiter: PerUserRateLimiter::new(30, 60), // Multi-tenant: per-user oauth_rate_limiter: PerUserRateLimiter::new(20, 60), + ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60), registry_entries: Vec::new(), cost_guard: None, routine_engine: Arc::new(tokio::sync::RwLock::new(None)), @@ -652,6 +653,7 @@ async fn start_owner_scoped_sender_server() -> ( auth_manager: None, chat_rate_limiter: PerUserRateLimiter::new(30, 60), oauth_rate_limiter: PerUserRateLimiter::new(20, 60), + ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60), webhook_rate_limiter: RateLimiter::new(10, 60), registry_entries: Vec::new(), cost_guard: None, @@ -1134,6 +1136,7 @@ async fn start_multi_user_server_with_db() -> ( auth_manager: None, chat_rate_limiter: PerUserRateLimiter::new(30, 60), oauth_rate_limiter: PerUserRateLimiter::new(20, 60), + ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60), registry_entries: Vec::new(), cost_guard: None, routine_engine: Arc::new(tokio::sync::RwLock::new(None)), diff --git a/tests/oauth_greeting_integration.rs b/tests/oauth_greeting_integration.rs index a9971e3e910..097390722f6 100644 --- a/tests/oauth_greeting_integration.rs +++ b/tests/oauth_greeting_integration.rs @@ -86,6 +86,7 @@ mod tests { auth_manager: None, chat_rate_limiter: PerUserRateLimiter::new(30, 60), oauth_rate_limiter: PerUserRateLimiter::new(20, 60), + ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60), webhook_rate_limiter: RateLimiter::new(10, 60), registry_entries: Vec::new(), cost_guard: None, diff --git a/tests/openai_compat_integration.rs b/tests/openai_compat_integration.rs index e2ca3613792..3a6ee95d921 100644 --- a/tests/openai_compat_integration.rs +++ b/tests/openai_compat_integration.rs @@ -228,6 +228,8 @@ async fn start_test_server_with_provider( oauth_rate_limiter: ironclaw::channels::web::platform::state::PerUserRateLimiter::new( 20, 60, ), + ironhub_catalog_rate_limiter: + ironclaw::channels::web::platform::state::PerUserRateLimiter::new(30, 60), webhook_rate_limiter: ironclaw::channels::web::platform::state::RateLimiter::new(10, 60), registry_entries: Vec::new(), cost_guard: None, @@ -751,6 +753,8 @@ async fn test_no_llm_provider_returns_503() { oauth_rate_limiter: ironclaw::channels::web::platform::state::PerUserRateLimiter::new( 20, 60, ), + ironhub_catalog_rate_limiter: + ironclaw::channels::web::platform::state::PerUserRateLimiter::new(30, 60), webhook_rate_limiter: ironclaw::channels::web::platform::state::RateLimiter::new(10, 60), registry_entries: Vec::new(), cost_guard: None, diff --git a/tests/support/gateway_workflow_harness.rs b/tests/support/gateway_workflow_harness.rs index 71e35f6f5ed..0320f139dae 100644 --- a/tests/support/gateway_workflow_harness.rs +++ b/tests/support/gateway_workflow_harness.rs @@ -239,6 +239,7 @@ impl GatewayWorkflowHarness { auth_manager: None, chat_rate_limiter: PerUserRateLimiter::new(120, 60), oauth_rate_limiter: PerUserRateLimiter::new(20, 60), + ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60), webhook_rate_limiter: RateLimiter::new(10, 60), registry_entries: Vec::new(), cost_guard: Some(Arc::clone(&components.cost_guard)), diff --git a/tests/thread_isolation_integration.rs b/tests/thread_isolation_integration.rs index 820d3dc9010..92efc94368c 100644 --- a/tests/thread_isolation_integration.rs +++ b/tests/thread_isolation_integration.rs @@ -101,6 +101,7 @@ async fn start_server_with_db() -> ( auth_manager: None, chat_rate_limiter: PerUserRateLimiter::new(30, 60), oauth_rate_limiter: PerUserRateLimiter::new(20, 60), + ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60), registry_entries: Vec::new(), cost_guard: None, routine_engine: Arc::new(tokio::sync::RwLock::new(None)), diff --git a/tests/ws_gateway_integration.rs b/tests/ws_gateway_integration.rs index e1cf9570239..175eba280f1 100644 --- a/tests/ws_gateway_integration.rs +++ b/tests/ws_gateway_integration.rs @@ -70,6 +70,8 @@ async fn start_test_server() -> ( oauth_rate_limiter: ironclaw::channels::web::platform::state::PerUserRateLimiter::new( 20, 60, ), + ironhub_catalog_rate_limiter: + ironclaw::channels::web::platform::state::PerUserRateLimiter::new(30, 60), webhook_rate_limiter: ironclaw::channels::web::platform::state::RateLimiter::new(10, 60), registry_entries: Vec::new(), cost_guard: None, From 8767e81b29c0386e1300fcd421f09bb945b7efae Mon Sep 17 00:00:00 2001 From: neo-sky Date: Thu, 21 May 2026 16:09:50 -0700 Subject: [PATCH 02/13] fix(ironhub): verify skill installs, harden checksum and tag checks 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. --- src/cli/hub.rs | 84 ++++++++++++++++++++++ src/cli/hub_install.rs | 5 ++ src/registry/hub_installer.rs | 20 ------ src/registry/installer.rs | 12 +++- src/tools/builtin/ironhub.rs | 130 ++++++++++++++++++++++++++++++++-- 5 files changed, 224 insertions(+), 27 deletions(-) diff --git a/src/cli/hub.rs b/src/cli/hub.rs index 58623a52588..7e9fd40d2c9 100644 --- a/src/cli/hub.rs +++ b/src/cli/hub.rs @@ -202,6 +202,30 @@ async fn install( .await .map_err(|e| anyhow::anyhow!(e.to_string()))?; + install_with_manifest( + manifest, + name, + force_skill, + force_tool, + release_tag, + target, + force, + acknowledge_unverified, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn install_with_manifest( + manifest: HubManifest, + name: &str, + force_skill: bool, + force_tool: bool, + release_tag: Option, + target: Option, + force: bool, + acknowledge_unverified: bool, +) -> anyhow::Result<()> { let kind = classify(&manifest, name, force_tool, force_skill)?; let provenance = match kind { @@ -578,4 +602,64 @@ mod tests { assert!(hits.contains(&"near-rpc".to_string())); assert!(!hits.contains(&"clickup".to_string())); } + + fn manifest_with_one_new_tool(name: &str) -> HubManifest { + let mut m = manifest_with(vec![name], vec![]); + if let Some(tool) = m.tools.first_mut() { + tool.provenance = Provenance::New; + } + m + } + + #[tokio::test] + async fn install_with_manifest_rejects_provenance_new_without_acknowledgement() { + let manifest = manifest_with_one_new_tool("indie-tool"); + let err = install_with_manifest( + manifest, + "indie-tool", + false, + false, + None, + None, + false, + false, + ) + .await + .expect_err("community-unverified entry without --acknowledge-unverified must bail"); + let msg = err.to_string(); + assert!( + msg.contains("UNVERIFIED") && msg.contains("--acknowledge-unverified"), + "CLI gate error must name the flag the operator needs to set, got: {msg}" + ); + } + + #[tokio::test] + async fn install_with_manifest_passes_gate_with_acknowledgement() { + let manifest = manifest_with_one_new_tool("indie-tool"); + let result = install_with_manifest( + manifest, + "indie-tool", + false, + false, + None, + None, + false, + true, + ) + .await; + match result { + Err(e) => { + let msg = e.to_string(); + assert!( + !msg.contains("UNVERIFIED"), + "ack=true must clear the gate; any UNVERIFIED bail here means the gate \ + fired despite acknowledgement, got: {msg}" + ); + } + Ok(_) => panic!( + "test-fixture artifact URLs cannot resolve in a unit-test environment; \ + an Ok result means the install pipeline silently bypassed network entirely" + ), + } + } } diff --git a/src/cli/hub_install.rs b/src/cli/hub_install.rs index 5aeacb17052..8760264f7ce 100644 --- a/src/cli/hub_install.rs +++ b/src/cli/hub_install.rs @@ -42,6 +42,9 @@ pub fn validate_release_tag(tag: &str) -> anyhow::Result<()> { tag ); } + if tag.contains("..") { + anyhow::bail!("release tag '{}' must not contain '..'", tag); + } Ok(()) } @@ -99,6 +102,8 @@ mod tests { fn validate_release_tag_rejects_unsafe_input() { for tag in [ "", + "..", + "v1..0", "../etc", "release/../other", "release@evil.com", diff --git a/src/registry/hub_installer.rs b/src/registry/hub_installer.rs index 2fa0154aa62..21ad05f16a9 100644 --- a/src/registry/hub_installer.rs +++ b/src/registry/hub_installer.rs @@ -156,26 +156,6 @@ impl HubInstaller { }) } - pub async fn install_tool_by_name( - &self, - name: &str, - force: bool, - ) -> Result { - let manifest = self.fetch_manifest().await?; - self.install_tool_from_manifest(&manifest, name, force) - .await - } - - pub async fn install_skill_by_name( - &self, - name: &str, - force: bool, - ) -> Result { - let manifest = self.fetch_manifest().await?; - self.install_skill_from_manifest(&manifest, name, force) - .await - } - pub async fn install_tool_from_manifest( &self, manifest: &HubManifest, diff --git a/src/registry/installer.rs b/src/registry/installer.rs index b75147f670b..4a45827da4e 100644 --- a/src/registry/installer.rs +++ b/src/registry/installer.rs @@ -695,7 +695,7 @@ pub(crate) fn verify_sha256(bytes: &[u8], expected: &str, url: &str) -> Result<( hasher.update(bytes); let actual = format!("{:x}", hasher.finalize()); - if actual != expected { + if !actual.eq_ignore_ascii_case(expected) { return Err(RegistryError::ChecksumMismatch { url: url.to_string(), expected_sha256: expected.to_string(), @@ -907,6 +907,16 @@ mod tests { assert!(matches!(err, RegistryError::ChecksumMismatch { .. })); } + #[test] + fn test_verify_sha256_accepts_uppercase_expected() { + use sha2::{Digest, Sha256}; + let data = b"hello world"; + let mut hasher = Sha256::new(); + hasher.update(data); + let hash = format!("{:X}", hasher.finalize()); + assert!(verify_sha256(data, &hash, "test://url").is_ok()); + } + #[tokio::test] async fn test_install_from_source_rejects_path_traversal_name() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/src/tools/builtin/ironhub.rs b/src/tools/builtin/ironhub.rs index 7a484bf0633..eaad0d8f3fd 100644 --- a/src/tools/builtin/ironhub.rs +++ b/src/tools/builtin/ironhub.rs @@ -204,6 +204,22 @@ fn install_outcome_to_json(kind: HubEntryKind, outcome: &HubInstallOutcome) -> s serde_json::Value::Object(obj) } +fn annotate_reload_verification(json: &mut serde_json::Value, name: &str, loaded: &[String]) { + let verified = loaded.iter().any(|n| n.eq_ignore_ascii_case(name)); + let Some(obj) = json.as_object_mut() else { + return; + }; + obj.insert("reload_verified".into(), serde_json::Value::Bool(verified)); + if !verified { + obj.insert( + "reload_warning".into(), + serde_json::Value::String(format!( + "skill '{name}' written to disk but not present in the registry after reload" + )), + ); + } +} + fn tool_entry_json(entry: &HubToolEntry) -> serde_json::Value { serde_json::json!({ "kind": "tool", @@ -300,27 +316,26 @@ impl IronhubInstallTool { .install_skill_from_manifest(&manifest, &parsed.name, parsed.force) .await .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + let mut json = install_outcome_to_json(kind, &outcome); if let Some(reg) = &self.deps.skill_registry { let reg = Arc::clone(reg); let handle = tokio::runtime::Handle::current(); - tokio::task::spawn_blocking(move || { + let loaded = tokio::task::spawn_blocking(move || { let mut guard = reg.write().unwrap_or_else(|poison| { tracing::error!( "skill registry RwLock was poisoned (a previous writer panicked); recovering" ); poison.into_inner() }); - handle.block_on(guard.reload()); + handle.block_on(guard.reload()) }) .await .map_err(|e| { ToolError::ExecutionFailed(format!("skill registry reload join: {e}")) })?; + annotate_reload_verification(&mut json, &outcome.name, &loaded); } - Ok(ToolOutput::success( - install_outcome_to_json(kind, &outcome), - start.elapsed(), - )) + Ok(ToolOutput::success(json, start.elapsed())) } } } @@ -1051,6 +1066,41 @@ mod tests { ); } + #[test] + fn annotate_reload_verification_flags_present_skill() { + let mut json = + install_outcome_to_json(HubEntryKind::Skill, &outcome("chief-of-staff", false)); + annotate_reload_verification( + &mut json, + "chief-of-staff", + &["chief-of-staff".to_string(), "other-skill".to_string()], + ); + assert_eq!(json["reload_verified"], true); + assert!(json.get("reload_warning").is_none()); + } + + #[test] + fn annotate_reload_verification_matches_case_insensitively() { + let mut json = + install_outcome_to_json(HubEntryKind::Skill, &outcome("Chief-Of-Staff", false)); + annotate_reload_verification(&mut json, "Chief-Of-Staff", &["chief-of-staff".to_string()]); + assert_eq!(json["reload_verified"], true); + } + + #[test] + fn annotate_reload_verification_warns_when_absent() { + let mut json = + install_outcome_to_json(HubEntryKind::Skill, &outcome("chief-of-staff", false)); + annotate_reload_verification(&mut json, "chief-of-staff", &["other-skill".to_string()]); + assert_eq!(json["reload_verified"], false); + assert!( + json["reload_warning"] + .as_str() + .unwrap() + .contains("not present in the registry after reload") + ); + } + #[test] fn tool_entry_json_surfaces_provenance_and_trust_label() { let entry = HubToolEntry { @@ -1278,6 +1328,74 @@ mod tests { assert!(!re.is_match("")); } + fn ironhub_deps_for_schema_check() -> IronhubDeps { + let secrets = crate::channels::web::test_helpers::test_secrets_store(); + let (ext_mgr, tools_dir, _channels_dir) = + crate::channels::web::test_helpers::test_ext_mgr(secrets); + std::mem::forget(tools_dir); + IronhubDeps { + extension_manager: ext_mgr, + skill_registry: None, + } + } + + #[test] + fn every_name_carrying_schema_accepts_underscore_and_matches_validate_hub_name() { + let schemas: [(&str, serde_json::Value); 3] = [ + ( + "ironhub_install", + IronhubInstallTool::new(ironhub_deps_for_schema_check()).parameters_schema(), + ), + ("ironhub_info", IronhubInfoTool::new().parameters_schema()), + ( + "ironhub_remove", + IronhubRemoveTool::new(ironhub_deps_for_schema_check()).parameters_schema(), + ), + ]; + for (tool_name, schema) in schemas { + let pattern = schema["properties"]["name"]["pattern"] + .as_str() + .unwrap_or_else(|| panic!("{tool_name}: missing properties.name.pattern")); + let re = regex::Regex::new(pattern) + .unwrap_or_else(|e| panic!("{tool_name}: pattern is not a valid regex: {e}")); + + assert!( + re.is_match("microsoft_365"), + "{tool_name}: schema must accept underscore names like microsoft_365 \ + (validate_hub_name accepts them; schema must not be tighter)" + ); + assert!( + re.is_match("chief-of-staff"), + "{tool_name}: schema must accept hyphen names" + ); + assert!( + re.is_match("clickup"), + "{tool_name}: schema must accept plain lowercase names" + ); + assert!( + !re.is_match("Microsoft365"), + "{tool_name}: schema must reject uppercase" + ); + assert!( + !re.is_match("../etc/passwd"), + "{tool_name}: schema must reject path traversal" + ); + assert!( + !re.is_match("name with space"), + "{tool_name}: schema must reject spaces" + ); + assert!( + !re.is_match(""), + "{tool_name}: schema must reject empty name" + ); + + assert!( + crate::cli::hub_install::looks_like_hub_name("microsoft_365"), + "shared validator must agree the schema's accepted name is also valid" + ); + } + } + #[test] fn schemas_reject_unknown_fields() { for schema in [ From 9bc1a7593ef97c92c7e3d1cb7fc8a48faa57aae8 Mon Sep 17 00:00:00 2001 From: neo-sky Date: Fri, 22 May 2026 21:46:36 -0700 Subject: [PATCH 03/13] fix(ironhub): harden install auth, key handling, and concurrency 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. --- .../static/js/surfaces/install.js | 43 +- src/channels/web/CLAUDE.md | 13 +- src/channels/web/handlers/ironhub.rs | 770 ++++++++++++++---- src/channels/web/types.rs | 14 +- src/registry/hub_installer.rs | 181 +++- src/tools/builtin/ironhub.rs | 132 ++- src/tools/dispatch.rs | 78 +- 7 files changed, 1024 insertions(+), 207 deletions(-) diff --git a/crates/ironclaw_gateway/static/js/surfaces/install.js b/crates/ironclaw_gateway/static/js/surfaces/install.js index f8a36f686b5..b470b253a9d 100644 --- a/crates/ironclaw_gateway/static/js/surfaces/install.js +++ b/crates/ironclaw_gateway/static/js/surfaces/install.js @@ -20,7 +20,8 @@ function renderIronhubInstallError(message) { ); } -function renderIronhubConfirm(tool, info) { +function renderIronhubConfirm(signed, info) { + var tool = signed && signed.slug ? signed.slug : ''; var kind = info && info.kind ? info.kind : 'tool'; var version = info && info.version ? info.version : ''; var description = info && info.description ? info.description : ''; @@ -84,7 +85,7 @@ function renderIronhubConfirm(tool, info) { var btn = document.getElementById('ironhub-install-confirm-btn'); if (btn) { btn.addEventListener('click', function() { - ironhubInstallConfirm(tool, isCommunityUnverified); + ironhubInstallConfirm(signed, isCommunityUnverified); }); } } @@ -97,13 +98,21 @@ function ironhubAckChanged() { } } -function ironhubInstallConfirm(tool, requireAck) { +function ironhubInstallConfirm(signed, requireAck) { var btn = document.getElementById('ironhub-install-confirm-btn'); if (btn) { btn.disabled = true; btn.textContent = I18n.t('ironhub.install.installing'); } - var body = { name: tool }; + var body = { + slug: signed.slug, + version: signed.version, + uid: signed.uid, + aid: signed.aid, + ts: parseInt(signed.ts, 10), + nonce: signed.nonce, + sig: signed.sig, + }; if (requireAck) { body.acknowledge_unverified = true; } @@ -111,7 +120,7 @@ function ironhubInstallConfirm(tool, requireAck) { method: 'POST', body: body, }).then(function(res) { - var name = res && res.name ? res.name : tool; + var name = res && res.name ? res.name : signed.slug; showToast(I18n.t('ironhub.install.success', { name: name }), 'success'); renderIronhubInstallState( '

' + escapeHtml(I18n.t('ironhub.install.doneTitle')) + '

' + @@ -148,17 +157,19 @@ function startIronhubInstall(params) { return; } + var signed = { + slug: slug, + version: version, + uid: uid, + aid: aid, + ts: parseInt(ts, 10), + nonce: nonce, + sig: sig, + }; + apiFetch('/api/ironhub/verify-intent', { method: 'POST', - body: { - slug: slug, - version: version, - uid: uid, - aid: aid, - ts: parseInt(ts, 10), - nonce: nonce, - sig: sig, - }, + body: signed, }).then(function(res) { if (!res || !res.valid) { var reason = res && res.reason ? res.reason : I18n.t('ironhub.install.unverified'); @@ -167,10 +178,10 @@ function startIronhubInstall(params) { } return apiFetch('/api/ironhub/info?name=' + encodeURIComponent(slug)) .then(function(info) { - renderIronhubConfirm(slug, info); + renderIronhubConfirm(signed, info); }) .catch(function() { - renderIronhubConfirm(slug, null); + renderIronhubConfirm(signed, null); }); }).catch(function(err) { var msg = err && err.message ? err.message : I18n.t('ironhub.install.unverified'); diff --git a/src/channels/web/CLAUDE.md b/src/channels/web/CLAUDE.md index c492a24d5d1..b96e0ccf09f 100644 --- a/src/channels/web/CLAUDE.md +++ b/src/channels/web/CLAUDE.md @@ -27,7 +27,7 @@ Browser-facing HTTP API and SSE/WebSocket real-time streaming. Axum-based, singl | `features/pairing/` | `GET /api/pairing/{channel}` + `POST /api/pairing/{channel}/approve` — WASM channel pairing approvals. Validates the URL path through `ExtensionName::new` at the handler boundary so invalid channel names reject with 400 instead of silently routing to a lookup-miss. Migrated from `server.rs` in ironclaw#2599 stage 4b. | | `features/status/` | `GET /api/gateway/status` — runtime snapshot for the admin dashboard (uptime, SSE/WS counts, cost / usage aggregates, active config). Owns the `GatewayStatusResponse` DTO. Migrated from `server.rs` in ironclaw#2599 stage 4b. | | `handlers/` | Transitional feature handlers that haven't migrated to `features//` yet: `auth`, `engine`, `frontend`, `ironhub`, `llm`, `memory`, `secrets`, `skills`, `system_prompt`, `tokens`, `tool_policy`, `users`, `webhooks`. Targeted for migration per ironclaw#2599 if churn / slice-boundary pressure justifies it. | -| `handlers/ironhub.rs` | IronHub catalog dispatch (`install`, `search`, `list`, `info`) and the deep-link install protocol surface (`signing-key` CRUD, `register`, `verify-intent`). Owns `hmac_hex`, `install_payload`, `register_payload`, the bounded process-global nonce cache (`NONCE_CACHE_MAX_ENTRIES = 16,384`, TTL = `VERIFY_INTENT_WINDOW_SECS`), and `validate_shared_key` (enforces `ihub_sk_` prefix and at-least-32 character length). Catalog dispatch routes through `ToolDispatcher`; signing-key + register + verify-intent are direct handlers operating on `SecretsStore` under the `ironhub_signing_key` name. | +| `handlers/ironhub.rs` | IronHub catalog dispatch (`install`, `search`, `list`, `info`) and the deep-link install protocol surface (`signing-key` CRUD, `register`, `verify-intent`). Owns `hmac_hex`, `install_payload`, `register_payload`, the bounded process-global nonce cache (`NONCE_CACHE_MAX_ENTRIES = 16,384`, TTL = `VERIFY_INTENT_WINDOW_SECS`), and `validate_shared_key` (enforces `ihub_sk_` prefix, at-least-32 character length, and a minimum distinct-character floor so trivially low-entropy keys are rejected). Catalog dispatch routes through `ToolDispatcher`; signing-key + register + verify-intent are direct handlers operating on `SecretsStore` under the `ironhub_signing_key` name. `install` is also signature-gated: it runs the shared `verify_signed_install` check (slug, freshness, HMAC) and burns the one-shot nonce before routing to `ToolDispatcher`. | | `openai_compat.rs` | OpenAI-compatible proxy (`/v1/chat/completions`, `/v1/models`) | | `util.rs` | Shared helpers (`web_incoming_message`, `build_turns_from_db_messages`, `images_to_attachments`, `truncate_preview`) | | `test_helpers.rs` | Always-compiled test utilities. `TestGatewayBuilder` (public) — the `tests/` crate's entry point for spinning up a `GatewayState` + optional Axum server on a random port. Plus seven `pub(crate)` `#[cfg(test)]`-gated cross-slice fixtures — `test_gateway_state(ext_mgr)`, `test_gateway_state_with_dependencies(ext_mgr, store, db_auth, pairing_store)`, `test_gateway_state_with_store_and_session_manager(store, session_manager)`, `insert_test_user`, `test_secrets_store`, `test_ext_mgr`, `test_ext_mgr_with_db` — landed in ironclaw#2599 stages 6a+6 so the chat / extensions / oauth / pairing / users slice test modules can share construction helpers without a central mega-tests block. | @@ -205,24 +205,25 @@ Legacy cleanup note: ### IronHub Catalog & Install Protocol | Method | Path | Description | |--------|------|-------------| -| POST | `/api/ironhub/install` | Install a tool/skill from the IronHub catalog (admin-only, dispatches `ironhub_install` tool) | +| POST | `/api/ironhub/install` | Install a tool/skill from the IronHub catalog (admin-only; body is the full signed deep-link payload `{slug, version, uid, aid, ts, nonce, sig, acknowledge_unverified?}`; re-verifies the install signature and burns the one-shot nonce before dispatching `ironhub_install`; 403 on bad/forged signature, 409 on replayed nonce, 503 when no signing key) | | GET | `/api/ironhub/search` | Search the catalog (dispatches `ironhub_search` tool) | | GET | `/api/ironhub/list` | List catalog entries (dispatches `ironhub_list` tool) | | GET | `/api/ironhub/info` | Catalog entry detail (dispatches `ironhub_info` tool) | -| POST | `/api/ironhub/signing-key` | Set the shared install key (body: `{shared_key}`; validates `ihub_sk_` prefix and minimum 32 characters) | +| POST | `/api/ironhub/signing-key` | Set the shared install key (body: `{shared_key}`; validates `ihub_sk_` prefix, minimum 32 characters, and a distinct-character floor) | | GET | `/api/ironhub/signing-key` | Get fingerprint + created_at for the stored key (never returns the key) | | DELETE | `/api/ironhub/signing-key` | Remove the stored key | | POST | `/api/ironhub/register` | IronHub-side handshake confirming the agent owns the shared key (body: `{uid, aid, ts, nonce, sig}`; sig over `register:{uid}:{aid}:{ts}:{nonce}`; returns 200 on valid, 401 on bad sig, 408 on stale timestamp, 409 on replayed nonce) | -| POST | `/api/ironhub/verify-intent` | Browser deep-link install verification (body: `{slug, version, uid, aid, ts, nonce, sig}`; sig over `install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}`; returns `{valid, reason}`) | +| POST | `/api/ironhub/verify-intent` | Browser deep-link install preview (body: `{slug, version, uid, aid, ts, nonce, sig}`; sig over `install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}`; returns `{valid, reason}`). Repeatable: it checks slug, freshness, and signature but does NOT consume the nonce; the nonce is burned only by `/api/ironhub/install`. | **Install protocol contract** (canonical shape that matches IronHub PR #43 and replaces the earlier `{tool}:{ts}` minimal verify shape): -- **Shared key:** user-generated on the IronHub side (per `AgentInstallation` row, encrypted at rest with AES-256-GCM in IronHub's DB), pasted into the agent via `POST /api/ironhub/signing-key`. Format: `ihub_sk_` prefix, minimum 32 characters total. +- **Shared key:** user-generated on the IronHub side (per `AgentInstallation` row, encrypted at rest with AES-256-GCM in IronHub's DB), pasted into the agent via `POST /api/ironhub/signing-key`. Format: `ihub_sk_` prefix, minimum 32 characters total, generated by IronHub as `ihub_sk_` + base64url(32 random bytes). The agent rejects trivially low-entropy keys via a distinct-character floor; the security guarantee comes from IronHub generating the key, the floor is a sanity guard. - **HMAC:** SHA-256 with the shared key's UTF-8 bytes as the MAC key (no hex-decode). Output is lowercase hex, 64 chars. - **Install payload:** `install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}` (literal `install` lead, colon-separated, `ts` decimal seconds). - **Register payload:** `register:{uid}:{aid}:{ts}:{nonce}`. - **Deep-link URL:** `https:///#/install/?slug=&version=&uid=&aid=&ts=&nonce=&sig=` parsed by `static/js/core/routing.js` and consumed by `static/js/surfaces/install.js`. -- **Freshness window:** 300s timestamp drift + one-shot nonce enforcement via the in-memory `NONCE_CACHE`. Replay-after-window is already blocked by drift; in-window replay is blocked by the cache. Nonces are scoped per `user_id`. Cache prunes expired entries on every insert and aggressively evicts the oldest 25% when over capacity. +- **Two-step binding:** `/verify-intent` is a repeatable preview (slug + freshness + signature, no side effect). `/install` re-verifies the same signed payload against the stored key and only then burns the nonce and dispatches `ironhub_install`. The signature is the security gate on both endpoints; the nonce makes the install single-use. An admin session cannot install without a valid signature (403), and a signed payload cannot be replayed within the window (409). +- **Freshness window:** 300s timestamp drift, enforced on both endpoints. Nonces are scoped per `user_id` and stored in the in-memory `NONCE_CACHE`. Replay-after-window is blocked by drift; in-window replay is blocked by the nonce (consumed at `/install`). The cache evicts entries past TTL (`VERIFY_INTENT_WINDOW_SECS`) from the front on every insert, and evicts the oldest entry when at `NONCE_CACHE_MAX_ENTRIES` capacity (FIFO via `VecDeque`). ### Routines | Method | Path | Description | diff --git a/src/channels/web/handlers/ironhub.rs b/src/channels/web/handlers/ironhub.rs index 8f1faabcb3f..d4e98dc6a95 100644 --- a/src/channels/web/handlers/ironhub.rs +++ b/src/channels/web/handlers/ironhub.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::{Arc, LazyLock, Mutex}; use std::time::{Duration, Instant}; @@ -23,10 +23,53 @@ const IRONHUB_SIGNING_KEY_NAME: &str = "ironhub_signing_key"; const VERIFY_INTENT_WINDOW_SECS: u64 = 300; const SHARED_KEY_PREFIX: &str = "ihub_sk_"; const SHARED_KEY_MIN_LEN: usize = 32; +const SHARED_KEY_MIN_DISTINCT: usize = 12; const NONCE_CACHE_MAX_ENTRIES: usize = 16_384; -static NONCE_CACHE: LazyLock>> = - LazyLock::new(|| Mutex::new(HashMap::new())); +struct NonceCache { + seen: HashMap, + order: VecDeque, +} + +impl NonceCache { + fn new() -> Self { + Self { + seen: HashMap::new(), + order: VecDeque::new(), + } + } + + fn front_is_expired(&self, now: Instant, ttl: Duration) -> bool { + match self.order.front() { + Some(front) => match self.seen.get(front) { + Some(seen_at) => now.duration_since(*seen_at) > ttl, + None => true, + }, + None => false, + } + } + + fn record_or_seen(&mut self, key: String, now: Instant, ttl: Duration) -> bool { + while self.front_is_expired(now, ttl) { + if let Some(evicted) = self.order.pop_front() { + self.seen.remove(&evicted); + } + } + if self.seen.contains_key(&key) { + return true; + } + if self.seen.len() >= NONCE_CACHE_MAX_ENTRIES + && let Some(evicted) = self.order.pop_front() + { + self.seen.remove(&evicted); + } + self.seen.insert(key.clone(), now); + self.order.push_back(key); + false + } +} + +static NONCE_CACHE: LazyLock> = LazyLock::new(|| Mutex::new(NonceCache::new())); fn nonce_seen_or_record(uid: &str, nonce: &str) -> bool { let key = format!("{uid}:{nonce}"); @@ -36,21 +79,7 @@ fn nonce_seen_or_record(uid: &str, nonce: &str) -> bool { Ok(g) => g, Err(poisoned) => poisoned.into_inner(), }; - guard.retain(|_, t| now.duration_since(*t) <= ttl); - if guard.contains_key(&key) { - return true; - } - if guard.len() >= NONCE_CACHE_MAX_ENTRIES { - let cutoff = guard.len() / 4; - let mut sorted: Vec<(String, Instant)> = - guard.iter().map(|(k, v)| (k.clone(), *v)).collect(); - sorted.sort_by_key(|(_, t)| *t); - for (k, _) in sorted.into_iter().take(cutoff) { - guard.remove(&k); - } - } - guard.insert(key, now); - false + guard.record_or_seen(key, now, ttl) } fn validate_shared_key(value: &str) -> Result<(), String> { @@ -64,6 +93,13 @@ fn validate_shared_key(value: &str) -> Result<(), String> { "shared key must be at least {SHARED_KEY_MIN_LEN} characters" )); } + let body = &value[SHARED_KEY_PREFIX.len()..]; + let distinct = body.chars().collect::>().len(); + if distinct < SHARED_KEY_MIN_DISTINCT { + return Err(format!( + "shared key is too low-entropy; the part after {SHARED_KEY_PREFIX} must contain at least {SHARED_KEY_MIN_DISTINCT} distinct characters" + )); + } Ok(()) } @@ -116,16 +152,38 @@ pub async fn ironhub_install_handler( Json(req): Json, ) -> Result, (StatusCode, String)> { catalog_rate_limit(&state, &user.user_id)?; - let dispatcher = dispatcher_or_503(&state)?; - let mut params = serde_json::Map::new(); - params.insert("name".into(), serde_json::Value::String(req.name)); - if let Some(kind) = req.kind { - params.insert("kind".into(), serde_json::Value::String(kind)); + + let store = secrets_store_or_503(&state)?; + let signed = SignedInstall { + slug: &req.slug, + version: &req.version, + uid: &req.uid, + aid: &req.aid, + ts: req.ts, + nonce: &req.nonce, + sig: &req.sig, + }; + match verify_signed_install(store.as_ref(), &user.user_id, &signed).await { + Ok(()) => {} + Err(SignedInstallError::Rejected(reason)) => return Err((StatusCode::FORBIDDEN, reason)), + Err(SignedInstallError::NoSigningKey) => { + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + "no signing key configured on this agent".to_string(), + )); + } + Err(SignedInstallError::Internal(e)) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e)), } - if let Some(tag) = req.release_tag { - params.insert("release_tag".into(), serde_json::Value::String(tag)); + + // verify-intent is a repeatable preview, so the one-shot nonce is only burned + // here, at the install that actually mutates state. + if nonce_seen_or_record(&user.user_id, &req.nonce) { + return Err((StatusCode::CONFLICT, "nonce already used".to_string())); } - params.insert("force".into(), serde_json::Value::Bool(req.force)); + + let dispatcher = dispatcher_or_503(&state)?; + let mut params = serde_json::Map::new(); + params.insert("name".into(), serde_json::Value::String(req.slug)); params.insert( "acknowledge_unverified".into(), serde_json::Value::Bool(req.acknowledge_unverified), @@ -263,9 +321,68 @@ fn register_payload(uid: &str, aid: &str, ts: u64, nonce: &str) -> String { format!("register:{uid}:{aid}:{ts}:{nonce}") } +struct SignedInstall<'a> { + slug: &'a str, + version: &'a str, + uid: &'a str, + aid: &'a str, + ts: u64, + nonce: &'a str, + sig: &'a str, +} + +enum SignedInstallError { + Rejected(String), + NoSigningKey, + Internal(String), +} + +async fn verify_signed_install( + store: &(dyn SecretsStore + Send + Sync), + user_id: &str, + signed: &SignedInstall<'_>, +) -> Result<(), SignedInstallError> { + if let Err(e) = crate::cli::hub_install::validate_hub_name(signed.slug) { + return Err(SignedInstallError::Rejected(format!("invalid slug: {e}"))); + } + + let drift = now_unix().abs_diff(signed.ts); + if drift > VERIFY_INTENT_WINDOW_SECS { + return Err(SignedInstallError::Rejected(format!( + "timestamp drift {drift}s exceeds window {VERIFY_INTENT_WINDOW_SECS}s" + ))); + } + + let decrypted = match store.get_decrypted(user_id, IRONHUB_SIGNING_KEY_NAME).await { + Ok(s) => s, + Err(SecretError::NotFound(_)) => return Err(SignedInstallError::NoSigningKey), + Err(e) => return Err(SignedInstallError::Internal(e.to_string())), + }; + + let payload = install_payload( + signed.slug, + signed.version, + signed.uid, + signed.aid, + signed.ts, + signed.nonce, + ); + let expected = hmac_hex(decrypted.expose(), &payload).map_err(SignedInstallError::Internal)?; + + use subtle::ConstantTimeEq; + let sig_valid: bool = expected.as_bytes().ct_eq(signed.sig.as_bytes()).into(); + if sig_valid { + Ok(()) + } else { + Err(SignedInstallError::Rejected( + "signature mismatch".to_string(), + )) + } +} + pub async fn ironhub_signing_key_set_handler( State(state): State>, - AuthenticatedUser(user): AuthenticatedUser, + AdminUser(user): AdminUser, Json(req): Json, ) -> Result, (StatusCode, String)> { let shared_key = req.shared_key.trim(); @@ -274,60 +391,28 @@ pub async fn ironhub_signing_key_set_handler( } let store = secrets_store_or_503(&state)?; - - let previous = if store - .exists(&user.user_id, IRONHUB_SIGNING_KEY_NAME) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - { - let old = store - .get_decrypted(&user.user_id, IRONHUB_SIGNING_KEY_NAME) - .await - .map(|d| d.expose().to_string()) - .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()))?; - Some(old) - } else { - None - }; - - let secret = match store + let secret = store .create( &user.user_id, CreateSecretParams::new(IRONHUB_SIGNING_KEY_NAME, shared_key), ) .await - { - Ok(s) => s, - Err(e) => { - if let Some(old) = previous - && let Err(restore_err) = store - .create( - &user.user_id, - CreateSecretParams::new(IRONHUB_SIGNING_KEY_NAME, &old), - ) - .await - { - tracing::warn!( - "failed to restore previous IronHub signing key after set error: {restore_err}" - ); - } - return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())); - } - }; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let stored = store + .get_decrypted(&user.user_id, IRONHUB_SIGNING_KEY_NAME) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(IronhubSigningKeyMetadata { - fingerprint: fingerprint(shared_key), + fingerprint: fingerprint(stored.expose()), created_at: secret.created_at.to_rfc3339(), })) } pub async fn ironhub_signing_key_get_handler( State(state): State>, - AuthenticatedUser(user): AuthenticatedUser, + AdminUser(user): AdminUser, ) -> Result, (StatusCode, String)> { let store = secrets_store_or_503(&state)?; let meta = match store.get(&user.user_id, IRONHUB_SIGNING_KEY_NAME).await { @@ -349,7 +434,7 @@ pub async fn ironhub_signing_key_get_handler( pub async fn ironhub_signing_key_delete_handler( State(state): State>, - AuthenticatedUser(user): AuthenticatedUser, + AdminUser(user): AdminUser, ) -> Result { let store = secrets_store_or_503(&state)?; let removed = store @@ -368,73 +453,32 @@ pub async fn ironhub_verify_intent_handler( AuthenticatedUser(user): AuthenticatedUser, Json(req): Json, ) -> Result, (StatusCode, String)> { - if let Err(e) = crate::cli::hub_install::validate_hub_name(&req.slug) { - return Ok(Json(IronhubVerifyIntentResponse { - valid: false, - reason: Some(format!("invalid slug: {e}")), - })); - } - - let now = now_unix(); - let drift = now.abs_diff(req.ts); - if drift > VERIFY_INTENT_WINDOW_SECS { - return Ok(Json(IronhubVerifyIntentResponse { - valid: false, - reason: Some(format!( - "timestamp drift {drift}s exceeds window {VERIFY_INTENT_WINDOW_SECS}s" - )), - })); - } - + catalog_rate_limit(&state, &user.user_id)?; let store = secrets_store_or_503(&state)?; - let decrypted = match store - .get_decrypted(&user.user_id, IRONHUB_SIGNING_KEY_NAME) - .await - { - Ok(s) => s, - Err(SecretError::NotFound(_)) => { - return Ok(Json(IronhubVerifyIntentResponse { - valid: false, - reason: Some("no signing key configured on this agent".to_string()), - })); - } - Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), - }; - - let payload = install_payload( - &req.slug, - &req.version, - &req.uid, - &req.aid, - req.ts, - &req.nonce, - ); - let expected = match hmac_hex(decrypted.expose(), &payload) { - Ok(v) => v, - Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e)), + let signed = SignedInstall { + slug: &req.slug, + version: &req.version, + uid: &req.uid, + aid: &req.aid, + ts: req.ts, + nonce: &req.nonce, + sig: &req.sig, }; - - use subtle::ConstantTimeEq; - let supplied = req.sig.as_bytes(); - let sig_valid: bool = expected.as_bytes().ct_eq(supplied).into(); - if !sig_valid { - return Ok(Json(IronhubVerifyIntentResponse { + match verify_signed_install(store.as_ref(), &user.user_id, &signed).await { + Ok(()) => Ok(Json(IronhubVerifyIntentResponse { + valid: true, + reason: None, + })), + Err(SignedInstallError::Rejected(reason)) => Ok(Json(IronhubVerifyIntentResponse { valid: false, - reason: Some("signature mismatch".to_string()), - })); - } - - if nonce_seen_or_record(&user.user_id, &req.nonce) { - return Ok(Json(IronhubVerifyIntentResponse { + reason: Some(reason), + })), + Err(SignedInstallError::NoSigningKey) => Ok(Json(IronhubVerifyIntentResponse { valid: false, - reason: Some("nonce already used".to_string()), - })); + reason: Some("no signing key configured on this agent".to_string()), + })), + Err(SignedInstallError::Internal(e)) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)), } - - Ok(Json(IronhubVerifyIntentResponse { - valid: true, - reason: None, - })) } pub async fn ironhub_register_handler( @@ -442,6 +486,7 @@ pub async fn ironhub_register_handler( AuthenticatedUser(user): AuthenticatedUser, Json(req): Json, ) -> Result { + catalog_rate_limit(&state, &user.user_id)?; let now = now_unix(); let drift = now.abs_diff(req.ts); if drift > VERIFY_INTENT_WINDOW_SECS { @@ -662,8 +707,18 @@ mod tests { let dispatcher = Arc::new(ToolDispatcher::new(registry, safety, db)); std::mem::forget(dir); + let secrets = crate::channels::web::test_helpers::test_secrets_store(); + secrets + .create( + "test-admin", + CreateSecretParams::new(IRONHUB_SIGNING_KEY_NAME, TEST_SHARED_KEY), + ) + .await + .expect("seed signing key"); + let state = crate::channels::web::test_helpers::TestGatewayBuilder::new() .with_tool_dispatcher(dispatcher) + .with_secrets_store(secrets) .build(); (state, calls) } @@ -701,6 +756,58 @@ mod tests { .expect("request") } + fn install_signed_body(slug: &str, nonce: &str, ts: u64) -> serde_json::Value { + let payload = install_payload(slug, "1.0.0", "u1", "a1", ts, nonce); + let sig = hmac_hex(TEST_SHARED_KEY, &payload).expect("sign"); + serde_json::json!({ + "slug": slug, + "version": "1.0.0", + "uid": "u1", + "aid": "a1", + "ts": ts, + "nonce": nonce, + "sig": sig, + }) + } + + #[test] + fn nonce_cache_records_then_detects_replay() { + let mut cache = NonceCache::new(); + let now = Instant::now(); + let ttl = Duration::from_secs(VERIFY_INTENT_WINDOW_SECS); + assert!(!cache.record_or_seen("u:n1".into(), now, ttl)); + assert!(cache.record_or_seen("u:n1".into(), now, ttl)); + assert!(!cache.record_or_seen("u:n2".into(), now, ttl)); + } + + #[test] + fn nonce_cache_evicts_expired_before_recording() { + let mut cache = NonceCache::new(); + let ttl = Duration::from_secs(VERIFY_INTENT_WINDOW_SECS); + let base = Instant::now(); + let later = base + ttl + Duration::from_secs(10); + assert!(!cache.record_or_seen("u:old".into(), base, ttl)); + assert!(!cache.record_or_seen("u:fresh".into(), later, ttl)); + assert!( + !cache.record_or_seen("u:old".into(), later, ttl), + "an expired nonce must be evicted, so re-recording it is new, not a replay" + ); + } + + #[test] + fn nonce_cache_stays_bounded_and_evicts_oldest() { + let mut cache = NonceCache::new(); + let now = Instant::now(); + let ttl = Duration::from_secs(VERIFY_INTENT_WINDOW_SECS); + for i in 0..NONCE_CACHE_MAX_ENTRIES { + assert!(!cache.record_or_seen(format!("u:{i}"), now, ttl)); + } + assert_eq!(cache.seen.len(), NONCE_CACHE_MAX_ENTRIES); + assert!(!cache.record_or_seen("u:overflow".into(), now, ttl)); + assert_eq!(cache.seen.len(), NONCE_CACHE_MAX_ENTRIES); + assert!(!cache.seen.contains_key("u:0")); + } + #[tokio::test] async fn ironhub_install_rejects_unauthenticated() { let (state, _calls) = build_state_with_stubs().await; @@ -742,10 +849,12 @@ mod tests { let app = Router::new() .route("/api/ironhub/install", post(ironhub_install_handler)) .with_state(state); + let nonce = uuid::Uuid::new_v4().to_string(); + let body = install_signed_body("clickup", &nonce, now_unix()); let req = req_with_identity( "POST", "/api/ironhub/install", - Body::from(serde_json::json!({"name": "clickup"}).to_string()), + Body::from(body.to_string()), "admin", ); let resp = ServiceExt::>::oneshot(app, req) @@ -823,18 +932,29 @@ mod tests { let dispatcher = Arc::new(ToolDispatcher::new(registry, safety, db)); std::mem::forget(dir); + let secrets = crate::channels::web::test_helpers::test_secrets_store(); + secrets + .create( + "test-admin", + CreateSecretParams::new(IRONHUB_SIGNING_KEY_NAME, TEST_SHARED_KEY), + ) + .await + .expect("seed signing key"); + let state = crate::channels::web::test_helpers::TestGatewayBuilder::new() .with_tool_dispatcher(dispatcher) + .with_secrets_store(secrets) .build(); let app = Router::new() .route("/api/ironhub/install", post(ironhub_install_handler)) .with_state(state); + let nonce = uuid::Uuid::new_v4().to_string(); + let mut body = install_signed_body("clickup", &nonce, now_unix()); + body["acknowledge_unverified"] = serde_json::json!(true); let req = req_with_identity( "POST", "/api/ironhub/install", - Body::from( - serde_json::json!({"name": "clickup", "acknowledge_unverified": true}).to_string(), - ), + Body::from(body.to_string()), "admin", ); let resp = ServiceExt::>::oneshot(app, req) @@ -849,6 +969,11 @@ mod tests { Some(true), "gateway handler must forward acknowledge_unverified to the ironhub_install tool" ); + assert_eq!( + params.get("name").and_then(|v| v.as_str()), + Some("clickup"), + "gateway handler must forward the signed slug as the install name" + ); } #[tokio::test] @@ -934,10 +1059,12 @@ mod tests { let app = Router::new() .route("/api/ironhub/install", post(ironhub_install_handler)) .with_state(state); + let nonce = uuid::Uuid::new_v4().to_string(); + let body = install_signed_body("microsoft_365", &nonce, now_unix()); let req = req_with_identity( "POST", "/api/ironhub/install", - Body::from(serde_json::json!({"name": "microsoft_365"}).to_string()), + Body::from(body.to_string()), "admin", ); let resp = ServiceExt::>::oneshot(app, req) @@ -957,20 +1084,22 @@ mod tests { let app = Router::new() .route("/api/ironhub/install", post(ironhub_install_handler)) .with_state(state); + let nonce = uuid::Uuid::new_v4().to_string(); + let body = install_signed_body("../etc/passwd", &nonce, now_unix()); let req = req_with_identity( "POST", "/api/ironhub/install", - Body::from(serde_json::json!({"name": "../etc/passwd"}).to_string()), + Body::from(body.to_string()), "admin", ); let resp = ServiceExt::>::oneshot(app, req) .await .expect("response"); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); assert_eq!( calls.load(Ordering::SeqCst), 0, - "tool execute must NOT run when schema rejects" + "tool execute must NOT run when the signed slug is rejected" ); } @@ -1054,10 +1183,13 @@ mod tests { let app = Router::new() .route("/api/ironhub/install", post(ironhub_install_handler)) .with_state(state); + let nonce = uuid::Uuid::new_v4().to_string(); + let mut body = install_signed_body("clickup", &nonce, now_unix()); + body["evil"] = serde_json::json!("exfil"); let req = req_with_identity( "POST", "/api/ironhub/install", - Body::from(serde_json::json!({"name": "clickup", "evil": "exfil"}).to_string()), + Body::from(body.to_string()), "admin", ); let resp = ServiceExt::>::oneshot(app, req) @@ -1076,6 +1208,75 @@ mod tests { ); } + #[tokio::test] + async fn ironhub_install_rejects_bad_signature() { + let (state, calls) = build_state_with_stubs().await; + let app = Router::new() + .route("/api/ironhub/install", post(ironhub_install_handler)) + .with_state(state); + let nonce = uuid::Uuid::new_v4().to_string(); + let body = serde_json::json!({ + "slug": "clickup", + "version": "1.0.0", + "uid": "u1", + "aid": "a1", + "ts": now_unix(), + "nonce": nonce, + "sig": "deadbeef".repeat(8), + }); + let req = req_with_identity( + "POST", + "/api/ironhub/install", + Body::from(body.to_string()), + "admin", + ); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert_eq!( + calls.load(Ordering::SeqCst), + 0, + "install must reject an unsigned/forged request before dispatching" + ); + } + + #[tokio::test] + async fn ironhub_install_rejects_replayed_nonce() { + let (state, calls) = build_state_with_stubs().await; + let app = Router::new() + .route("/api/ironhub/install", post(ironhub_install_handler)) + .with_state(state); + let nonce = uuid::Uuid::new_v4().to_string(); + let body = install_signed_body("clickup", &nonce, now_unix()); + let first = req_with_identity( + "POST", + "/api/ironhub/install", + Body::from(body.to_string()), + "admin", + ); + let r1 = ServiceExt::>::oneshot(app.clone(), first) + .await + .expect("first"); + assert_eq!(r1.status(), StatusCode::OK); + + let replay = req_with_identity( + "POST", + "/api/ironhub/install", + Body::from(body.to_string()), + "admin", + ); + let r2 = ServiceExt::>::oneshot(app, replay) + .await + .expect("replay"); + assert_eq!(r2.status(), StatusCode::CONFLICT); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "a replayed signed install must not dispatch the tool twice" + ); + } + #[test] fn install_hmac_is_deterministic_and_payload_sensitive() { let key_a = "ihub_sk_aaaaaaaaaaaaaaaaaaaaaaaa"; @@ -1108,10 +1309,14 @@ mod tests { } #[test] - fn validate_shared_key_enforces_prefix_and_length() { - assert!(validate_shared_key("ihub_sk_aaaaaaaaaaaaaaaaaaaaaaaa").is_ok()); + fn validate_shared_key_enforces_prefix_length_and_entropy() { + assert!(validate_shared_key(TEST_SHARED_KEY).is_ok()); assert!(validate_shared_key("ihub_sk_short").is_err()); assert!(validate_shared_key("not_prefixed_keykey_keykey_keykey").is_err()); + assert!( + validate_shared_key("ihub_sk_aaaaaaaaaaaaaaaaaaaaaaaa").is_err(), + "a 32-char key whose body is one repeated character must be rejected as low-entropy" + ); } #[test] @@ -1124,7 +1329,90 @@ mod tests { assert_ne!(f1, fingerprint("ihub_sk_bbbbbbbbbbbbbbbbbbbbbbbb")); } - const TEST_SHARED_KEY: &str = "ihub_sk_test_kkkkkkkkkkkkkkkkkkkk"; + const TEST_SHARED_KEY: &str = "ihub_sk_x7K2p9mQ4vR8tL3nB6wZ1yD5cF0jH"; + + #[tokio::test] + async fn verify_intent_is_rate_limited_per_user() { + let secrets = crate::channels::web::test_helpers::test_secrets_store(); + let state = crate::channels::web::test_helpers::TestGatewayBuilder::new() + .user_id("test-user") + .with_secrets_store(secrets) + .with_ironhub_catalog_rate_limit(1, 60) + .build(); + let app = Router::new() + .route( + "/api/ironhub/verify-intent", + post(ironhub_verify_intent_handler), + ) + .with_state(state); + let body = serde_json::json!({ + "slug": "clickup", + "version": "1.0.0", + "uid": "u1", + "aid": "a1", + "ts": now_unix(), + "nonce": "rl-verify-1", + "sig": "deadbeef" + }) + .to_string(); + let first = req_with_identity( + "POST", + "/api/ironhub/verify-intent", + Body::from(body.clone()), + "regular", + ); + let r1 = ServiceExt::>::oneshot(app.clone(), first) + .await + .expect("first"); + assert_eq!(r1.status(), StatusCode::OK); + let second = req_with_identity( + "POST", + "/api/ironhub/verify-intent", + Body::from(body), + "regular", + ); + let r2 = ServiceExt::>::oneshot(app, second) + .await + .expect("second"); + assert_eq!(r2.status(), StatusCode::TOO_MANY_REQUESTS); + } + + #[tokio::test] + async fn register_is_rate_limited_per_user() { + let secrets = crate::channels::web::test_helpers::test_secrets_store(); + let state = crate::channels::web::test_helpers::TestGatewayBuilder::new() + .user_id("test-user") + .with_secrets_store(secrets) + .with_ironhub_catalog_rate_limit(1, 60) + .build(); + let app = Router::new() + .route("/api/ironhub/register", post(ironhub_register_handler)) + .with_state(state); + let body = serde_json::json!({ + "uid": "u1", + "aid": "a1", + "ts": now_unix(), + "nonce": "rl-register-1", + "sig": "deadbeef" + }) + .to_string(); + let first = req_with_identity( + "POST", + "/api/ironhub/register", + Body::from(body.clone()), + "regular", + ); + let r1 = ServiceExt::>::oneshot(app.clone(), first) + .await + .expect("first"); + assert_eq!(r1.status(), StatusCode::SERVICE_UNAVAILABLE); + let second = + req_with_identity("POST", "/api/ironhub/register", Body::from(body), "regular"); + let r2 = ServiceExt::>::oneshot(app, second) + .await + .expect("second"); + assert_eq!(r2.status(), StatusCode::TOO_MANY_REQUESTS); + } async fn verify_app() -> (Router, String, String, u64) { use crate::secrets::CreateSecretParams; @@ -1205,6 +1493,103 @@ mod tests { serde_json::from_slice(&bytes).expect("json") } + #[test] + fn tool_error_to_http_maps_all_variants() { + use std::time::Duration; + assert_eq!( + tool_error_to_http(ToolError::InvalidParameters("x".into())).0, + StatusCode::BAD_REQUEST + ); + assert_eq!( + tool_error_to_http(ToolError::NotAuthorized("x".into())).0, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + tool_error_to_http(ToolError::RateLimited(None)).0, + StatusCode::TOO_MANY_REQUESTS + ); + assert_eq!( + tool_error_to_http(ToolError::RateLimited(Some(Duration::from_secs(5)))).0, + StatusCode::TOO_MANY_REQUESTS + ); + assert_eq!( + tool_error_to_http(ToolError::Timeout(Duration::from_secs(3))).0, + StatusCode::GATEWAY_TIMEOUT + ); + assert_eq!( + tool_error_to_http(ToolError::ExternalService("x".into())).0, + StatusCode::BAD_GATEWAY + ); + assert_eq!( + tool_error_to_http(ToolError::ExecutionFailed("x".into())).0, + StatusCode::INTERNAL_SERVER_ERROR + ); + assert_eq!( + tool_error_to_http(ToolError::Sandbox("x".into())).0, + StatusCode::INTERNAL_SERVER_ERROR + ); + } + + #[tokio::test] + async fn verify_intent_returns_invalid_when_no_signing_key() { + let secrets = crate::channels::web::test_helpers::test_secrets_store(); + let state = crate::channels::web::test_helpers::TestGatewayBuilder::new() + .user_id("test-user") + .with_secrets_store(secrets) + .build(); + let app = Router::new() + .route( + "/api/ironhub/verify-intent", + post(ironhub_verify_intent_handler), + ) + .with_state(state); + let req = verify_req(verify_body( + "clickup", + now_unix(), + "nokey-nonce", + "deadbeef", + )); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::OK); + let json = body_json(resp).await; + assert_eq!(json["valid"], false); + assert!( + json["reason"] + .as_str() + .unwrap_or("") + .contains("no signing key"), + "{json:?}" + ); + } + + #[tokio::test] + async fn register_rejects_replayed_nonce() { + let (app, _sig, _nonce, ts) = verify_app().await; + let nonce = "register-replay-nonce"; + let payload = register_payload("u1", "a1", ts, nonce); + let sig = hmac_hex(TEST_SHARED_KEY, &payload).expect("sign"); + let body = serde_json::json!({ + "uid": "u1", + "aid": "a1", + "ts": ts, + "nonce": nonce, + "sig": sig, + }); + let first = ServiceExt::>::oneshot( + app.clone(), + register_req(body.clone()), + ) + .await + .expect("first"); + assert_eq!(first.status(), StatusCode::OK); + let second = ServiceExt::>::oneshot(app, register_req(body)) + .await + .expect("second"); + assert_eq!(second.status(), StatusCode::CONFLICT); + } + #[tokio::test] async fn verify_intent_accepts_valid_signature() { let (app, sig, nonce, ts) = verify_app().await; @@ -1260,22 +1645,25 @@ mod tests { } #[tokio::test] - async fn verify_intent_rejects_replayed_nonce() { + async fn verify_intent_is_repeatable_preview() { let (app, sig, nonce, ts) = verify_app().await; let first = verify_req(verify_body("clickup", ts, &nonce, &sig)); let resp1 = ServiceExt::>::oneshot(app.clone(), first) .await .expect("first response"); - let json1 = body_json(resp1).await; - assert_eq!(json1["valid"], true); + assert_eq!(resp1.status(), StatusCode::OK); + assert_eq!(body_json(resp1).await["valid"], true); - let replay = verify_req(verify_body("clickup", ts, &nonce, &sig)); - let resp2 = ServiceExt::>::oneshot(app, replay) + let again = verify_req(verify_body("clickup", ts, &nonce, &sig)); + let resp2 = ServiceExt::>::oneshot(app, again) .await - .expect("replay response"); - let json2 = body_json(resp2).await; - assert_eq!(json2["valid"], false); - assert!(json2["reason"].as_str().unwrap().contains("nonce")); + .expect("second response"); + assert_eq!(resp2.status(), StatusCode::OK); + assert_eq!( + body_json(resp2).await["valid"], + true, + "verify-intent is a preview and must not consume the one-shot nonce" + ); } #[tokio::test] @@ -1312,6 +1700,10 @@ mod tests { } fn signing_key_req(method: &str, body: Body) -> axum::http::Request { + signing_key_req_as(method, body, "admin") + } + + fn signing_key_req_as(method: &str, body: Body, role: &str) -> axum::http::Request { let mut req = axum::http::Request::builder() .method(method) .uri("/api/ironhub/signing-key") @@ -1320,7 +1712,7 @@ mod tests { .expect("request"); req.extensions_mut().insert(UserIdentity { user_id: "test-user".into(), - role: "regular".into(), + role: role.into(), workspace_read_scopes: Vec::new(), }); req @@ -1441,6 +1833,55 @@ mod tests { assert_eq!(del2.status(), StatusCode::NOT_FOUND); } + #[tokio::test] + async fn signing_key_set_rejects_non_admin() { + let app = signing_key_app().await; + let body = serde_json::json!({"shared_key": TEST_SHARED_KEY}).to_string(); + let resp = ServiceExt::>::oneshot( + app, + signing_key_req_as("POST", Body::from(body), "regular"), + ) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn signing_key_set_replaces_existing_and_updates_fingerprint() { + let app = signing_key_app().await; + let first = serde_json::json!({"shared_key": TEST_SHARED_KEY}).to_string(); + let first_resp = ServiceExt::>::oneshot( + app.clone(), + signing_key_req("POST", Body::from(first)), + ) + .await + .expect("first set"); + assert_eq!(first_resp.status(), StatusCode::OK); + let first_fp = body_json(first_resp).await["fingerprint"] + .as_str() + .expect("fingerprint") + .to_string(); + + let rotated = format!("{TEST_SHARED_KEY}_rotated"); + let second = serde_json::json!({"shared_key": rotated}).to_string(); + let second_resp = ServiceExt::>::oneshot( + app, + signing_key_req("POST", Body::from(second)), + ) + .await + .expect("second set"); + assert_eq!(second_resp.status(), StatusCode::OK); + let second_fp = body_json(second_resp).await["fingerprint"] + .as_str() + .expect("fingerprint") + .to_string(); + + assert_ne!( + first_fp, second_fp, + "replacing the signing key must change the returned fingerprint" + ); + } + #[tokio::test] async fn register_accepts_valid_signature() { let (app, _intent_sig, _intent_nonce, ts) = verify_app().await; @@ -1496,4 +1937,27 @@ mod tests { .expect("response"); assert_eq!(resp.status(), StatusCode::REQUEST_TIMEOUT); } + + #[tokio::test] + async fn register_rejects_when_no_signing_key_configured() { + let secrets = crate::channels::web::test_helpers::test_secrets_store(); + let state = crate::channels::web::test_helpers::TestGatewayBuilder::new() + .user_id("test-user") + .with_secrets_store(secrets) + .build(); + let app = Router::new() + .route("/api/ironhub/register", post(ironhub_register_handler)) + .with_state(state); + let body = serde_json::json!({ + "uid": "u1", + "aid": "a1", + "ts": now_unix(), + "nonce": uuid::Uuid::new_v4().to_string(), + "sig": "deadbeef".repeat(8), + }); + let resp = ServiceExt::>::oneshot(app, register_req(body)) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + } } diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index 8757abde6c9..ce532ceb49d 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -1245,13 +1245,13 @@ pub struct EngineActionResponse { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct IronhubInstallRequest { - pub name: String, - #[serde(default)] - pub kind: Option, - #[serde(default)] - pub release_tag: Option, - #[serde(default)] - pub force: bool, + pub slug: String, + pub version: String, + pub uid: String, + pub aid: String, + pub ts: u64, + pub nonce: String, + pub sig: String, #[serde(default)] pub acknowledge_unverified: bool, } diff --git a/src/registry/hub_installer.rs b/src/registry/hub_installer.rs index 21ad05f16a9..c036be0823c 100644 --- a/src/registry/hub_installer.rs +++ b/src/registry/hub_installer.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock}; +use std::time::{Duration, Instant}; use tokio::fs; use tokio::sync::Mutex as AsyncMutex; @@ -19,16 +20,87 @@ const MAX_WASM_BYTES: usize = 16 * 1024 * 1024; static INSTALL_LOCKS: LazyLock>>>> = LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); -fn acquire_install_lock(key: &str) -> Arc> { +struct InstallLock { + key: String, + mutex: Arc>, +} + +impl InstallLock { + async fn lock(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.mutex.lock().await + } +} + +impl Drop for InstallLock { + fn drop(&mut self) { + let mut guard = INSTALL_LOCKS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(existing) = guard.get(&self.key) + && Arc::ptr_eq(existing, &self.mutex) + && Arc::strong_count(&self.mutex) <= 2 + { + guard.remove(&self.key); + } + } +} + +fn acquire_install_lock(key: &str) -> InstallLock { let mut guard = INSTALL_LOCKS .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some(lock) = guard.get(key) { - return Arc::clone(lock); + let mutex = if let Some(existing) = guard.get(key) { + Arc::clone(existing) + } else { + let fresh = Arc::new(AsyncMutex::new(())); + guard.insert(key.to_string(), Arc::clone(&fresh)); + fresh + }; + InstallLock { + key: key.to_string(), + mutex, + } +} + +const MANIFEST_CACHE_TTL: Duration = Duration::from_secs(60); +const MANIFEST_CACHE_MAX_ENTRIES: usize = 64; + +struct CachedManifest { + manifest: Arc, + fetched_at: Instant, +} + +static MANIFEST_CACHE: LazyLock>> = + LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + +fn manifest_cache_get(url: &str, now: Instant) -> Option> { + let guard = MANIFEST_CACHE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let entry = guard.get(url)?; + (now.duration_since(entry.fetched_at) <= MANIFEST_CACHE_TTL) + .then(|| Arc::clone(&entry.manifest)) +} + +fn manifest_cache_put(url: &str, manifest: Arc, now: Instant) { + let mut guard = MANIFEST_CACHE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if guard.len() >= MANIFEST_CACHE_MAX_ENTRIES && !guard.contains_key(url) { + guard.retain(|_, e| now.duration_since(e.fetched_at) <= MANIFEST_CACHE_TTL); + if guard.len() >= MANIFEST_CACHE_MAX_ENTRIES + && let Some(victim) = guard.keys().next().cloned() + { + guard.remove(&victim); + } } - let lock = Arc::new(AsyncMutex::new(())); - guard.insert(key.to_string(), Arc::clone(&lock)); - lock + guard.insert( + url.to_string(), + CachedManifest { + manifest, + fetched_at: now, + }, + ); } async fn write_atomic(target: &Path, bytes: &[u8]) -> Result<(), RegistryError> { @@ -135,6 +207,16 @@ impl HubInstaller { &self.skills_dir } + pub async fn fetch_manifest_cached(&self) -> Result { + let now = Instant::now(); + if let Some(hit) = manifest_cache_get(&self.manifest_url, now) { + return Ok((*hit).clone()); + } + let manifest = Arc::new(self.fetch_manifest().await?); + manifest_cache_put(&self.manifest_url, Arc::clone(&manifest), now); + Ok((*manifest).clone()) + } + pub async fn fetch_manifest(&self) -> Result { validate_artifact_url("hub-manifest", "manifest_url", &self.manifest_url)?; @@ -182,6 +264,14 @@ impl HubInstaller { .await } + fn tool_wasm_path(&self, name: &str) -> PathBuf { + self.tools_dir.join(format!("{name}.wasm")) + } + + fn skill_md_path(&self, name: &str) -> PathBuf { + self.skills_dir.join(name).join("SKILL.md") + } + pub async fn install_tool_entry( &self, entry: &HubToolEntry, @@ -190,6 +280,16 @@ impl HubInstaller { ) -> Result { validate_tool_entry(entry)?; + if !force { + let target = self.tool_wasm_path(&entry.name); + if target.exists() { + return Err(RegistryError::AlreadyInstalled { + name: entry.name.clone(), + path: target, + }); + } + } + 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?; @@ -206,6 +306,16 @@ impl HubInstaller { ) -> Result { validate_skill_entry(entry)?; + if !force { + let target = self.skill_md_path(&entry.name); + if target.exists() { + return Err(RegistryError::AlreadyInstalled { + name: entry.name.clone(), + path: target, + }); + } + } + let md_bytes = download_artifact(&entry.skill_md.url, MAX_METADATA_BYTES as u64).await?; self.install_skill_from_bytes(entry, release_tag, &md_bytes, force) @@ -254,7 +364,7 @@ impl HubInstaller { .await .map_err(RegistryError::Io)?; - let target_wasm = self.tools_dir.join(format!("{}.wasm", entry.name)); + let target_wasm = self.tool_wasm_path(&entry.name); let target_caps = self .tools_dir .join(format!("{}.capabilities.json", entry.name)); @@ -312,7 +422,7 @@ impl HubInstaller { .await .map_err(RegistryError::Io)?; - let target_md = skill_dir.join("SKILL.md"); + let target_md = self.skill_md_path(&entry.name); let lock = acquire_install_lock(&format!("skill:{}", entry.name)); let _guard = lock.lock().await; @@ -543,6 +653,61 @@ mod tests { ); } + #[test] + fn manifest_cache_hits_within_ttl_and_expires_after() { + let url = "https://hub.ironclaw.com/manifest-cache-test.json"; + let manifest = Arc::new(build_manifest(vec![], vec![])); + let base = Instant::now(); + manifest_cache_put(url, Arc::clone(&manifest), base); + assert!( + manifest_cache_get(url, base).is_some(), + "a fresh entry must be a cache hit" + ); + let later = base + MANIFEST_CACHE_TTL + Duration::from_secs(1); + assert!( + manifest_cache_get(url, later).is_none(), + "an entry past its TTL must miss" + ); + } + + #[tokio::test] + async fn install_lock_entry_reclaimed_after_install() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + let wasm = b"fake-wasm-bytes"; + let caps = br#"{"name":"reclaim-probe"}"#; + let entry = build_tool_entry("reclaim-probe", wasm, caps); + installer + .install_tool_from_bytes(&entry, "test-release", wasm, caps, false) + .await + .expect("install succeeds"); + let retained = INSTALL_LOCKS + .lock() + .unwrap_or_else(|p| p.into_inner()) + .contains_key("tool:reclaim-probe"); + assert!( + !retained, + "install lock entry must be reclaimed after the install completes" + ); + } + + #[tokio::test] + async fn install_tool_entry_skips_download_when_already_installed() { + let tmp = TempDir::new().expect("tempdir"); + let installer = installer_in(&tmp); + std::fs::create_dir_all(tmp.path().join("tools")).expect("tools dir"); + std::fs::write(tmp.path().join("tools/clickup.wasm"), b"already here").expect("seed"); + + let entry = build_tool_entry("clickup", b"fake-wasm-bytes", br#"{"name":"clickup"}"#); + let result = installer + .install_tool_entry(&entry, "test-release", false) + .await; + assert!( + matches!(result, Err(RegistryError::AlreadyInstalled { .. })), + "force=false re-install must short-circuit before downloading, got {result:?}" + ); + } + #[tokio::test] async fn install_tool_from_bytes_writes_artifacts_and_returns_outcome() { let tmp = TempDir::new().expect("tempdir"); diff --git a/src/tools/builtin/ironhub.rs b/src/tools/builtin/ironhub.rs index eaad0d8f3fd..ed8d7cd04af 100644 --- a/src/tools/builtin/ironhub.rs +++ b/src/tools/builtin/ironhub.rs @@ -8,6 +8,7 @@ use crate::context::JobContext; use crate::extensions::{EnsureReadyIntent, ExtensionKind, ExtensionManager}; use crate::registry::{ HubInstallOutcome, HubInstaller, HubManifest, HubSkillEntry, HubToolEntry, Provenance, + RegistryError, }; use crate::tools::builtin::extension_tools::output_from_ensure_ready; use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str}; @@ -220,6 +221,21 @@ fn annotate_reload_verification(json: &mut serde_json::Value, name: &str, loaded } } +fn install_error_to_tool_error(name: &str, err: RegistryError) -> ToolError { + match err { + RegistryError::AlreadyInstalled { .. } => ToolError::InvalidParameters(format!( + "'{name}' is already installed; pass force=true to reinstall" + )), + RegistryError::ChecksumMismatch { .. } => { + ToolError::ExternalService(format!("'{name}' failed artifact integrity verification")) + } + other => { + tracing::debug!("IronHub install failed for '{name}': {other}"); + ToolError::ExecutionFailed(format!("install of '{name}' failed")) + } + } +} + fn tool_entry_json(entry: &HubToolEntry) -> serde_json::Value { serde_json::json!({ "kind": "tool", @@ -294,7 +310,7 @@ impl IronhubInstallTool { let outcome = installer .install_tool_from_manifest(&manifest, &parsed.name, parsed.force) .await - .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + .map_err(|e| install_error_to_tool_error(&parsed.name, e))?; let ready = self .deps .extension_manager @@ -315,24 +331,33 @@ impl IronhubInstallTool { let outcome = installer .install_skill_from_manifest(&manifest, &parsed.name, parsed.force) .await - .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + .map_err(|e| install_error_to_tool_error(&parsed.name, e))?; let mut json = install_outcome_to_json(kind, &outcome); if let Some(reg) = &self.deps.skill_registry { let reg = Arc::clone(reg); - let handle = tokio::runtime::Handle::current(); - let loaded = tokio::task::spawn_blocking(move || { - let mut guard = reg.write().unwrap_or_else(|poison| { - tracing::error!( - "skill registry RwLock was poisoned (a previous writer panicked); recovering" - ); - poison.into_inner() - }); - handle.block_on(guard.reload()) - }) + let loaded = tokio::task::spawn_blocking( + move || -> Result, ToolError> { + let mut guard = reg.write().unwrap_or_else(|poison| { + tracing::error!( + "skill registry RwLock was poisoned (a previous writer panicked); recovering" + ); + poison.into_inner() + }); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| { + ToolError::ExecutionFailed(format!( + "skill registry reload runtime: {e}" + )) + })?; + Ok(rt.block_on(guard.reload())) + }, + ) .await .map_err(|e| { ToolError::ExecutionFailed(format!("skill registry reload join: {e}")) - })?; + })??; annotate_reload_verification(&mut json, &outcome.name, &loaded); } Ok(ToolOutput::success(json, start.elapsed())) @@ -473,6 +498,10 @@ impl Tool for IronhubSearchTool { Returns matching tools and skills." } + fn requires_sanitization(&self) -> bool { + true // IronHub catalog entries are external data + } + fn parameters_schema(&self) -> serde_json::Value { serde_json::json!({ "type": "object", @@ -508,7 +537,7 @@ impl Tool for IronhubSearchTool { let installer = build_installer(release_tag.as_deref(), None)?; let manifest = installer - .fetch_manifest() + .fetch_manifest_cached() .await .map_err(|_| catalog_unavailable())?; @@ -561,6 +590,10 @@ impl Tool for IronhubListTool { "List everything available in the IronHub catalog grouped by tools and skills." } + fn requires_sanitization(&self) -> bool { + true // IronHub catalog entries are external data + } + fn parameters_schema(&self) -> serde_json::Value { serde_json::json!({ "type": "object", @@ -589,7 +622,7 @@ impl Tool for IronhubListTool { let installer = build_installer(release_tag.as_deref(), None)?; let manifest = installer - .fetch_manifest() + .fetch_manifest_cached() .await .map_err(|_| catalog_unavailable())?; @@ -634,6 +667,10 @@ impl Tool for IronhubInfoTool { description, artifact URLs, and SHA-256 checksums." } + fn requires_sanitization(&self) -> bool { + true // IronHub catalog entries are external data + } + fn parameters_schema(&self) -> serde_json::Value { serde_json::json!({ "type": "object", @@ -672,7 +709,7 @@ impl Tool for IronhubInfoTool { let installer = build_installer(release_tag.as_deref(), None)?; let manifest = installer - .fetch_manifest() + .fetch_manifest_cached() .await .map_err(|_| catalog_unavailable())?; @@ -898,6 +935,22 @@ mod tests { assert_eq!(schema["required"], serde_json::json!(["query"])); } + #[test] + fn catalog_tools_sanitize_external_output() { + assert!( + IronhubSearchTool::new().requires_sanitization(), + "search surfaces external catalog text and must be sanitized" + ); + assert!( + IronhubListTool::new().requires_sanitization(), + "list surfaces external catalog text and must be sanitized" + ); + assert!( + IronhubInfoTool::new().requires_sanitization(), + "info surfaces external catalog text and must be sanitized" + ); + } + #[test] fn list_schema_has_no_required_fields() { let tool = IronhubListTool::new(); @@ -1245,6 +1298,28 @@ mod tests { } } + #[tokio::test] + async fn install_from_manifest_passes_gate_for_new_when_acknowledged() { + let (tool, _tools_dir, _channels_dir) = install_tool_with_ext_mgr(); + let manifest = manifest_with_provenance("indie-tool", Provenance::New, None, None); + let parsed = InstallParams { + name: "indie-tool".into(), + kind_hint: None, + release_tag: None, + force: false, + acknowledge_unverified: true, + }; + let ctx = JobContext::with_user("test", "install gate ack test", ""); + let err = tool + .install_from_manifest(std::time::Instant::now(), manifest, parsed, &ctx) + .await + .expect_err("fake artifact URLs make the install fail downstream of the gate"); + assert!( + !matches!(&err, ToolError::InvalidParameters(m) if m.contains("UNVERIFIED")), + "acknowledge_unverified=true must clear the gate; got the gate rejection instead: {err:?}" + ); + } + #[tokio::test] async fn install_params_from_json_propagates_acknowledge_unverified() { let parsed = InstallParams::from_json(&serde_json::json!({ @@ -1274,6 +1349,31 @@ mod tests { assert!(!msg.contains('/')); } + #[test] + fn install_error_to_tool_error_never_leaks_paths_or_detail() { + let already = RegistryError::AlreadyInstalled { + name: "clickup".into(), + path: std::path::PathBuf::from("/home/someone/.ironclaw/tools/clickup.wasm"), + }; + let msg = install_error_to_tool_error("clickup", already).to_string(); + assert!(!msg.contains('/'), "must not leak a path: {msg}"); + assert!( + msg.contains("force=true"), + "AlreadyInstalled must stay actionable: {msg}" + ); + + let download = RegistryError::DownloadFailed { + url: "https://hub.ironclaw.com/api/catalog/artifact/secret-token".into(), + reason: "connection refused at /var/run/internal.sock".into(), + }; + let msg = install_error_to_tool_error("clickup", download).to_string(); + assert!( + !msg.contains("secret-token"), + "must not leak the upstream url: {msg}" + ); + assert!(!msg.contains('/'), "must not leak a path or url: {msg}"); + } + #[test] fn entry_matches_lowercases_name_against_already_lowercased_query() { assert!(entry_matches("ClickUp", "Task tracking", "clickup")); diff --git a/src/tools/dispatch.rs b/src/tools/dispatch.rs index 663cc45953f..72fdb180d69 100644 --- a/src/tools/dispatch.rs +++ b/src/tools/dispatch.rs @@ -22,6 +22,7 @@ use uuid::Uuid; use crate::context::{ActionRecord, JobContext}; use crate::db::Database; +use crate::tools::rate_limiter::RateLimitResult; use crate::tools::registry::ToolRegistry; use crate::tools::tool::{ToolError, ToolOutput}; use crate::tools::{prepare_tool_params, redact_params}; @@ -125,6 +126,19 @@ impl ToolDispatcher { ToolError::ExecutionFailed(format!("tool not found: {tool_name}")) })?; + // Per-tool rate limit, using the same registry limiter as the agent + // loop so a tool's declared rate_limit_config is honored on the + // gateway/CLI dispatch door too, not just agent-initiated calls. + if let Some(config) = tool.rate_limit_config() + && let RateLimitResult::Limited { retry_after, .. } = self + .registry + .rate_limiter() + .check_and_record(user_id, &resolved_name, &config) + .await + { + return Err(ToolError::RateLimited(Some(retry_after))); + } + // 1. Normalize parameters (coerce types, fill defaults). let normalized_params = prepare_tool_params(tool.as_ref(), ¶ms); @@ -283,7 +297,7 @@ mod integration_tests { use crate::context::JobContext; use crate::db::Database; use crate::db::libsql::LibSqlBackend; - use crate::tools::tool::{Tool, ToolError, ToolOutput}; + use crate::tools::tool::{Tool, ToolError, ToolOutput, ToolRateLimitConfig}; use async_trait::async_trait; use ironclaw_safety::SafetyLayer; use std::time::Duration; @@ -361,6 +375,36 @@ mod integration_tests { } } + /// Declares a 1-per-minute rate limit so the dispatcher's enforcement of + /// `rate_limit_config` can be exercised. + struct RateLimitedTool; + + #[async_trait] + impl Tool for RateLimitedTool { + fn name(&self) -> &str { + "rate_limited_stub" + } + fn description(&self) -> &str { + "Test stub that declares a 1-per-minute rate limit." + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ "type": "object" }) + } + fn rate_limit_config(&self) -> Option { + Some(ToolRateLimitConfig::new(1, 60)) + } + async fn execute( + &self, + _params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + Ok(ToolOutput::success( + serde_json::json!({ "ok": true }), + Duration::from_millis(1), + )) + } + } + // ── Fixtures ──────────────────────────────────────────── async fn test_dispatcher() -> ( @@ -445,6 +489,38 @@ mod integration_tests { // ── Tests ─────────────────────────────────────────────── + #[tokio::test] + async fn dispatch_enforces_per_tool_rate_limit() { + let (dispatcher, _backend, _db, registry, _dir) = test_dispatcher().await; + registry.register(Arc::new(RateLimitedTool)).await; + + let first = dispatcher + .dispatch( + "rate_limited_stub", + serde_json::json!({}), + "tester", + DispatchSource::Channel("gateway".into()), + ) + .await; + assert!( + first.is_ok(), + "first call within the limit must succeed: {first:?}" + ); + + let second = dispatcher + .dispatch( + "rate_limited_stub", + serde_json::json!({}), + "tester", + DispatchSource::Channel("gateway".into()), + ) + .await; + assert!( + matches!(second, Err(ToolError::RateLimited(_))), + "second call must be rejected by the dispatcher rate limit: {second:?}" + ); + } + #[tokio::test] async fn dispatch_persists_action_record_with_redacted_sensitive_params() { let (dispatcher, backend, db, registry, _dir) = test_dispatcher().await; From 74293e27a62c2c05b3b1ae02e30d820987001e45 Mon Sep 17 00:00:00 2001 From: neo-sky Date: Sat, 23 May 2026 12:09:48 -0700 Subject: [PATCH 04/13] fix(ironhub): bind signed install to version; add trust_label to info 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. --- src/channels/web/CLAUDE.md | 2 +- src/channels/web/handlers/ironhub.rs | 7 + src/tools/builtin/ironhub.rs | 215 +++++++++++++++++++++++---- 3 files changed, 190 insertions(+), 34 deletions(-) diff --git a/src/channels/web/CLAUDE.md b/src/channels/web/CLAUDE.md index b96e0ccf09f..5bc365229c3 100644 --- a/src/channels/web/CLAUDE.md +++ b/src/channels/web/CLAUDE.md @@ -222,7 +222,7 @@ Legacy cleanup note: - **Install payload:** `install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}` (literal `install` lead, colon-separated, `ts` decimal seconds). - **Register payload:** `register:{uid}:{aid}:{ts}:{nonce}`. - **Deep-link URL:** `https:///#/install/?slug=&version=&uid=&aid=&ts=&nonce=&sig=` parsed by `static/js/core/routing.js` and consumed by `static/js/surfaces/install.js`. -- **Two-step binding:** `/verify-intent` is a repeatable preview (slug + freshness + signature, no side effect). `/install` re-verifies the same signed payload against the stored key and only then burns the nonce and dispatches `ironhub_install`. The signature is the security gate on both endpoints; the nonce makes the install single-use. An admin session cannot install without a valid signature (403), and a signed payload cannot be replayed within the window (409). +- **Two-step binding:** `/verify-intent` is a repeatable preview (slug + freshness + signature, no side effect). `/install` re-verifies the same signed payload against the stored key and only then burns the nonce and dispatches `ironhub_install`, which binds the install to the signed `version` (the current catalog entry must still match that version or the install is refused, so a catalog roll inside the window cannot swap the artifact). The signature is the security gate on both endpoints; the nonce makes the install single-use. An admin session cannot install without a valid signature (403), and a signed payload cannot be replayed within the window (409). - **Freshness window:** 300s timestamp drift, enforced on both endpoints. Nonces are scoped per `user_id` and stored in the in-memory `NONCE_CACHE`. Replay-after-window is blocked by drift; in-window replay is blocked by the nonce (consumed at `/install`). The cache evicts entries past TTL (`VERIFY_INTENT_WINDOW_SECS`) from the front on every insert, and evicts the oldest entry when at `NONCE_CACHE_MAX_ENTRIES` capacity (FIFO via `VecDeque`). ### Routines diff --git a/src/channels/web/handlers/ironhub.rs b/src/channels/web/handlers/ironhub.rs index d4e98dc6a95..f7957704771 100644 --- a/src/channels/web/handlers/ironhub.rs +++ b/src/channels/web/handlers/ironhub.rs @@ -184,6 +184,7 @@ pub async fn ironhub_install_handler( let dispatcher = dispatcher_or_503(&state)?; let mut params = serde_json::Map::new(); params.insert("name".into(), serde_json::Value::String(req.slug)); + params.insert("version".into(), serde_json::Value::String(req.version)); params.insert( "acknowledge_unverified".into(), serde_json::Value::Bool(req.acknowledge_unverified), @@ -590,6 +591,7 @@ mod tests { "name": { "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]*$", "minLength": 1, "maxLength": 64 }, "kind": { "type": "string", "enum": ["tool", "skill"] }, "release_tag": { "type": "string", "pattern": "^[A-Za-z0-9._-]+$", "minLength": 1, "maxLength": 128 }, + "version": { "type": "string", "minLength": 1, "maxLength": 128 }, "force": { "type": "boolean", "default": false }, "acknowledge_unverified": { "type": "boolean", "default": false } }, @@ -974,6 +976,11 @@ mod tests { Some("clickup"), "gateway handler must forward the signed slug as the install name" ); + assert_eq!( + params.get("version").and_then(|v| v.as_str()), + Some("1.0.0"), + "gateway handler must forward the signed version so the tool binds the install to it" + ); } #[tokio::test] diff --git a/src/tools/builtin/ironhub.rs b/src/tools/builtin/ironhub.rs index ed8d7cd04af..cbbf7b47b25 100644 --- a/src/tools/builtin/ironhub.rs +++ b/src/tools/builtin/ironhub.rs @@ -258,6 +258,47 @@ fn skill_entry_json(entry: &HubSkillEntry) -> serde_json::Value { }) } +fn info_tool_json(entry: &HubToolEntry, release_tag: &str) -> serde_json::Value { + serde_json::json!({ + "kind": "tool", + "name": entry.name, + "crate_name": entry.crate_name, + "version": entry.version, + "description": entry.description, + "provenance": entry.provenance.as_wire(), + "trust_label": entry.provenance.trust_label(), + "release_tag": release_tag, + "wasm": { + "url": entry.wasm.url, + "size_bytes": entry.wasm.size_bytes, + "sha256": entry.wasm.sha256, + }, + "capabilities": { + "url": entry.capabilities.url, + "size_bytes": entry.capabilities.size_bytes, + "sha256": entry.capabilities.sha256, + } + }) +} + +fn info_skill_json(entry: &HubSkillEntry, release_tag: &str) -> serde_json::Value { + serde_json::json!({ + "kind": "skill", + "name": entry.name, + "trunk": entry.trunk, + "version": entry.version, + "description": entry.description, + "provenance": entry.provenance.as_wire(), + "trust_label": entry.provenance.trust_label(), + "release_tag": release_tag, + "skill_md": { + "url": entry.skill_md.url, + "size_bytes": entry.skill_md.size_bytes, + "sha256": entry.skill_md.sha256, + } + }) +} + fn skill_install_dir( registry: &Option>>, ) -> Option { @@ -299,6 +340,22 @@ impl IronhubInstallTool { parsed.acknowledge_unverified, )?; + if let Some(expected) = parsed.version.as_deref() { + let actual = match kind { + HubEntryKind::Tool => manifest.find_tool(&parsed.name).map(|t| t.version.as_str()), + HubEntryKind::Skill => manifest + .find_skill(&parsed.name) + .map(|s| s.version.as_str()), + }; + if actual != Some(expected) { + let current = actual.unwrap_or("unknown"); + return Err(ToolError::InvalidParameters(format!( + "signed install version '{expected}' does not match current IronHub catalog version '{current}' for '{}'; the catalog changed since this install was approved", + parsed.name + ))); + } + } + let skills_dir = match kind { HubEntryKind::Skill => skill_install_dir(&self.deps.skill_registry), HubEntryKind::Tool => None, @@ -370,6 +427,7 @@ struct InstallParams { name: String, kind_hint: Option, release_tag: Option, + version: Option, force: bool, acknowledge_unverified: bool, } @@ -388,6 +446,17 @@ impl InstallParams { .get("release_tag") .and_then(|v| v.as_str()) .map(str::to_string); + let version = params + .get("version") + .and_then(|v| v.as_str()) + .map(str::to_string); + if let Some(v) = &version + && (v.is_empty() || v.len() > 128) + { + return Err(ToolError::InvalidParameters( + "version must be 1 to 128 characters".into(), + )); + } let force = params .get("force") .and_then(|v| v.as_bool()) @@ -400,6 +469,7 @@ impl InstallParams { name, kind_hint, release_tag, + version, force, acknowledge_unverified, }) @@ -438,6 +508,12 @@ impl Tool for IronhubInstallTool { "minLength": 1, "maxLength": 128 }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Exact catalog version this install was signed for; if set, the install fails unless the current IronHub entry matches" + }, "force": { "type": "boolean", "default": false }, "acknowledge_unverified": { "type": "boolean", "default": false } }, @@ -714,42 +790,11 @@ impl Tool for IronhubInfoTool { .map_err(|_| catalog_unavailable())?; if let Some(t) = manifest.find_tool(name) { - let json = serde_json::json!({ - "kind": "tool", - "name": t.name, - "crate_name": t.crate_name, - "version": t.version, - "description": t.description, - "provenance": t.provenance.as_wire(), - "release_tag": manifest.release_tag, - "wasm": { - "url": t.wasm.url, - "size_bytes": t.wasm.size_bytes, - "sha256": t.wasm.sha256, - }, - "capabilities": { - "url": t.capabilities.url, - "size_bytes": t.capabilities.size_bytes, - "sha256": t.capabilities.sha256, - } - }); + let json = info_tool_json(t, &manifest.release_tag); return Ok(ToolOutput::success(json, start.elapsed())); } if let Some(s) = manifest.find_skill(name) { - let json = serde_json::json!({ - "kind": "skill", - "name": s.name, - "trunk": s.trunk, - "version": s.version, - "description": s.description, - "provenance": s.provenance.as_wire(), - "release_tag": manifest.release_tag, - "skill_md": { - "url": s.skill_md.url, - "size_bytes": s.skill_md.size_bytes, - "sha256": s.skill_md.sha256, - } - }); + let json = info_skill_json(s, &manifest.release_tag); return Ok(ToolOutput::success(json, start.elapsed())); } @@ -1274,6 +1319,7 @@ mod tests { name: "indie-tool".into(), kind_hint: None, release_tag: None, + version: None, force: false, acknowledge_unverified: false, }; @@ -1306,6 +1352,7 @@ mod tests { name: "indie-tool".into(), kind_hint: None, release_tag: None, + version: None, force: false, acknowledge_unverified: true, }; @@ -1339,6 +1386,108 @@ mod tests { ); } + #[test] + fn install_params_from_json_propagates_version() { + let parsed = InstallParams::from_json(&serde_json::json!({ + "name": "clickup", + "version": "1.2.3", + })) + .expect("valid params"); + assert_eq!( + parsed.version.as_deref(), + Some("1.2.3"), + "signed version must reach the install caller so the catalog entry can be bound to it" + ); + let default = InstallParams::from_json(&serde_json::json!({"name": "clickup"})) + .expect("valid params"); + assert!( + default.version.is_none(), + "omitted version must stay None so agent/CLI installs remain version-agnostic" + ); + } + + #[tokio::test] + async fn install_from_manifest_rejects_version_mismatch() { + let (tool, _tools_dir, _channels_dir) = install_tool_with_ext_mgr(); + let manifest = manifest_with_provenance("clickup", Provenance::Official, None, None); + let parsed = InstallParams { + name: "clickup".into(), + kind_hint: None, + release_tag: None, + version: Some("9.9.9".into()), + force: false, + acknowledge_unverified: false, + }; + let ctx = JobContext::with_user("test", "version bind mismatch test", ""); + let err = tool + .install_from_manifest(std::time::Instant::now(), manifest, parsed, &ctx) + .await + .expect_err("a signed version that does not match the catalog entry must be rejected before install"); + match err { + ToolError::InvalidParameters(msg) => assert!( + msg.contains("does not match") && msg.contains("9.9.9"), + "version-bind rejection must name the mismatch so the gap is debuggable: {msg}" + ), + other => panic!( + "version mismatch must reject as InvalidParameters before the install side effect; got {other:?}" + ), + } + } + + #[tokio::test] + async fn install_from_manifest_accepts_matching_version() { + let (tool, _tools_dir, _channels_dir) = install_tool_with_ext_mgr(); + let manifest = manifest_with_provenance("clickup", Provenance::Official, None, None); + let parsed = InstallParams { + name: "clickup".into(), + kind_hint: None, + release_tag: None, + version: Some("0.1.0".into()), + force: false, + acknowledge_unverified: false, + }; + let ctx = JobContext::with_user("test", "version bind match test", ""); + let err = tool + .install_from_manifest(std::time::Instant::now(), manifest, parsed, &ctx) + .await + .expect_err("fake artifact URLs make the install fail downstream of the version gate"); + assert!( + !matches!(&err, ToolError::InvalidParameters(m) if m.contains("does not match")), + "a matching version must clear the bind check; got the bind rejection instead: {err:?}" + ); + } + + #[test] + fn info_tool_json_includes_trust_label() { + let manifest = manifest_with_provenance("indie-tool", Provenance::New, None, None); + let entry = manifest.find_tool("indie-tool").expect("tool present"); + let json = info_tool_json(entry, &manifest.release_tag); + assert_eq!( + json["trust_label"], + serde_json::json!(Provenance::New.trust_label()), + "ironhub_info tool detail must carry trust_label so the deep-link confirm can render it" + ); + assert_eq!(json["provenance"], "new"); + assert_eq!(json["version"], "0.1.0"); + } + + #[test] + fn info_skill_json_includes_trust_label() { + let manifest = manifest_with_provenance( + "official-tool", + Provenance::Official, + Some("indie-skill"), + Some(Provenance::New), + ); + let entry = manifest.find_skill("indie-skill").expect("skill present"); + let json = info_skill_json(entry, &manifest.release_tag); + assert_eq!( + json["trust_label"], + serde_json::json!(Provenance::New.trust_label()), + "ironhub_info skill detail must carry trust_label to match search/list output" + ); + } + #[test] fn catalog_unavailable_is_user_safe_external_service() { let err = catalog_unavailable(); From 2581d6ce6dbfb3c081fbbf6a8290d70837977e10 Mon Sep 17 00:00:00 2001 From: neo-sky Date: Sat, 23 May 2026 18:37:42 -0700 Subject: [PATCH 05/13] fix(ironhub): bind signed install to artifact digest 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. --- .../static/js/core/routing.js | 1 + .../static/js/surfaces/install.js | 5 +- src/channels/web/CLAUDE.md | 9 +- src/channels/web/handlers/ironhub.rs | 57 ++++++-- src/channels/web/types.rs | 2 + src/tools/builtin/ironhub.rs | 127 ++++++++++++++++++ .../test_ironhub_deep_link_install.py | 47 +++++-- 7 files changed, 224 insertions(+), 24 deletions(-) diff --git a/crates/ironclaw_gateway/static/js/core/routing.js b/crates/ironclaw_gateway/static/js/core/routing.js index 0498f222500..90145769a90 100644 --- a/crates/ironclaw_gateway/static/js/core/routing.js +++ b/crates/ironclaw_gateway/static/js/core/routing.js @@ -136,6 +136,7 @@ function restoreFromHash() { ts: params.get('ts'), nonce: params.get('nonce'), sig: params.get('sig'), + artifact_digest: params.get('artifact_digest'), }); break; } diff --git a/crates/ironclaw_gateway/static/js/surfaces/install.js b/crates/ironclaw_gateway/static/js/surfaces/install.js index b470b253a9d..ebbab1586cb 100644 --- a/crates/ironclaw_gateway/static/js/surfaces/install.js +++ b/crates/ironclaw_gateway/static/js/surfaces/install.js @@ -112,6 +112,7 @@ function ironhubInstallConfirm(signed, requireAck) { ts: parseInt(signed.ts, 10), nonce: signed.nonce, sig: signed.sig, + artifact_digest: signed.artifact_digest, }; if (requireAck) { body.acknowledge_unverified = true; @@ -151,8 +152,9 @@ function startIronhubInstall(params) { var ts = params && params.ts; var nonce = params && params.nonce; var sig = params && params.sig; + var artifactDigest = params && params.artifact_digest; - if (!slug || !version || !uid || !aid || !ts || !nonce || !sig) { + if (!slug || !version || !uid || !aid || !ts || !nonce || !sig || !artifactDigest) { renderIronhubInstallError(I18n.t('ironhub.install.missingParams')); return; } @@ -165,6 +167,7 @@ function startIronhubInstall(params) { ts: parseInt(ts, 10), nonce: nonce, sig: sig, + artifact_digest: artifactDigest, }; apiFetch('/api/ironhub/verify-intent', { diff --git a/src/channels/web/CLAUDE.md b/src/channels/web/CLAUDE.md index 5bc365229c3..eb602411123 100644 --- a/src/channels/web/CLAUDE.md +++ b/src/channels/web/CLAUDE.md @@ -205,7 +205,7 @@ Legacy cleanup note: ### IronHub Catalog & Install Protocol | Method | Path | Description | |--------|------|-------------| -| POST | `/api/ironhub/install` | Install a tool/skill from the IronHub catalog (admin-only; body is the full signed deep-link payload `{slug, version, uid, aid, ts, nonce, sig, acknowledge_unverified?}`; re-verifies the install signature and burns the one-shot nonce before dispatching `ironhub_install`; 403 on bad/forged signature, 409 on replayed nonce, 503 when no signing key) | +| POST | `/api/ironhub/install` | Install a tool/skill from the IronHub catalog (admin-only; body is the full signed deep-link payload `{slug, version, uid, aid, ts, nonce, sig, artifact_digest, acknowledge_unverified?}`; re-verifies the install signature (which binds the artifact digest) and burns the one-shot nonce, then dispatches `ironhub_install`, which refuses unless the current catalog entry's version and artifact digest both match the signed values; 403 on bad/forged signature, 409 on replayed nonce, 503 when no signing key) | | GET | `/api/ironhub/search` | Search the catalog (dispatches `ironhub_search` tool) | | GET | `/api/ironhub/list` | List catalog entries (dispatches `ironhub_list` tool) | | GET | `/api/ironhub/info` | Catalog entry detail (dispatches `ironhub_info` tool) | @@ -213,15 +213,16 @@ Legacy cleanup note: | GET | `/api/ironhub/signing-key` | Get fingerprint + created_at for the stored key (never returns the key) | | DELETE | `/api/ironhub/signing-key` | Remove the stored key | | POST | `/api/ironhub/register` | IronHub-side handshake confirming the agent owns the shared key (body: `{uid, aid, ts, nonce, sig}`; sig over `register:{uid}:{aid}:{ts}:{nonce}`; returns 200 on valid, 401 on bad sig, 408 on stale timestamp, 409 on replayed nonce) | -| POST | `/api/ironhub/verify-intent` | Browser deep-link install preview (body: `{slug, version, uid, aid, ts, nonce, sig}`; sig over `install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}`; returns `{valid, reason}`). Repeatable: it checks slug, freshness, and signature but does NOT consume the nonce; the nonce is burned only by `/api/ironhub/install`. | +| POST | `/api/ironhub/verify-intent` | Browser deep-link install preview (body: `{slug, version, uid, aid, ts, nonce, sig, artifact_digest}`; sig over `install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}:{artifact_digest}`; returns `{valid, reason}`). Repeatable: it checks slug, freshness, and signature but does NOT consume the nonce; the nonce is burned only by `/api/ironhub/install`. | **Install protocol contract** (canonical shape that matches IronHub PR #43 and replaces the earlier `{tool}:{ts}` minimal verify shape): - **Shared key:** user-generated on the IronHub side (per `AgentInstallation` row, encrypted at rest with AES-256-GCM in IronHub's DB), pasted into the agent via `POST /api/ironhub/signing-key`. Format: `ihub_sk_` prefix, minimum 32 characters total, generated by IronHub as `ihub_sk_` + base64url(32 random bytes). The agent rejects trivially low-entropy keys via a distinct-character floor; the security guarantee comes from IronHub generating the key, the floor is a sanity guard. - **HMAC:** SHA-256 with the shared key's UTF-8 bytes as the MAC key (no hex-decode). Output is lowercase hex, 64 chars. -- **Install payload:** `install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}` (literal `install` lead, colon-separated, `ts` decimal seconds). +- **Install payload:** `install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}:{artifact_digest}` (literal `install` lead, colon-separated, `ts` decimal seconds). +- **Artifact digest:** lowercase hex `sha256` that binds the entry's content, computed identically on both sides. Tool: `sha256(wasm.sha256 + ":" + capabilities.sha256)`. Skill: `sha256(skill_md.sha256)` (manifest hashes are lowercase hex). The agent re-verifies the HMAC over the digest, then recomputes the digest from the manifest entry it is about to install and requires equality, so a same-`version` content swap inside the freshness window is rejected. IronHub and IronClaw share fixed test vectors to lock cross-language agreement. - **Register payload:** `register:{uid}:{aid}:{ts}:{nonce}`. -- **Deep-link URL:** `https:///#/install/?slug=&version=&uid=&aid=&ts=&nonce=&sig=` parsed by `static/js/core/routing.js` and consumed by `static/js/surfaces/install.js`. +- **Deep-link URL:** `https:///#/install/?slug=&version=&uid=&aid=&ts=&nonce=&sig=&artifact_digest=` parsed by `static/js/core/routing.js` and consumed by `static/js/surfaces/install.js`. - **Two-step binding:** `/verify-intent` is a repeatable preview (slug + freshness + signature, no side effect). `/install` re-verifies the same signed payload against the stored key and only then burns the nonce and dispatches `ironhub_install`, which binds the install to the signed `version` (the current catalog entry must still match that version or the install is refused, so a catalog roll inside the window cannot swap the artifact). The signature is the security gate on both endpoints; the nonce makes the install single-use. An admin session cannot install without a valid signature (403), and a signed payload cannot be replayed within the window (409). - **Freshness window:** 300s timestamp drift, enforced on both endpoints. Nonces are scoped per `user_id` and stored in the in-memory `NONCE_CACHE`. Replay-after-window is blocked by drift; in-window replay is blocked by the nonce (consumed at `/install`). The cache evicts entries past TTL (`VERIFY_INTENT_WINDOW_SECS`) from the front on every insert, and evicts the oldest entry when at `NONCE_CACHE_MAX_ENTRIES` capacity (FIFO via `VecDeque`). diff --git a/src/channels/web/handlers/ironhub.rs b/src/channels/web/handlers/ironhub.rs index f7957704771..a5527b5bbab 100644 --- a/src/channels/web/handlers/ironhub.rs +++ b/src/channels/web/handlers/ironhub.rs @@ -162,6 +162,7 @@ pub async fn ironhub_install_handler( ts: req.ts, nonce: &req.nonce, sig: &req.sig, + artifact_digest: &req.artifact_digest, }; match verify_signed_install(store.as_ref(), &user.user_id, &signed).await { Ok(()) => {} @@ -185,6 +186,10 @@ pub async fn ironhub_install_handler( let mut params = serde_json::Map::new(); params.insert("name".into(), serde_json::Value::String(req.slug)); params.insert("version".into(), serde_json::Value::String(req.version)); + params.insert( + "artifact_digest".into(), + serde_json::Value::String(req.artifact_digest), + ); params.insert( "acknowledge_unverified".into(), serde_json::Value::Bool(req.acknowledge_unverified), @@ -314,8 +319,9 @@ fn install_payload( aid: &str, ts: u64, nonce: &str, + artifact_digest: &str, ) -> String { - format!("install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}") + format!("install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}:{artifact_digest}") } fn register_payload(uid: &str, aid: &str, ts: u64, nonce: &str) -> String { @@ -330,6 +336,7 @@ struct SignedInstall<'a> { ts: u64, nonce: &'a str, sig: &'a str, + artifact_digest: &'a str, } enum SignedInstallError { @@ -367,6 +374,7 @@ async fn verify_signed_install( signed.aid, signed.ts, signed.nonce, + signed.artifact_digest, ); let expected = hmac_hex(decrypted.expose(), &payload).map_err(SignedInstallError::Internal)?; @@ -464,6 +472,7 @@ pub async fn ironhub_verify_intent_handler( ts: req.ts, nonce: &req.nonce, sig: &req.sig, + artifact_digest: &req.artifact_digest, }; match verify_signed_install(store.as_ref(), &user.user_id, &signed).await { Ok(()) => Ok(Json(IronhubVerifyIntentResponse { @@ -592,6 +601,7 @@ mod tests { "kind": { "type": "string", "enum": ["tool", "skill"] }, "release_tag": { "type": "string", "pattern": "^[A-Za-z0-9._-]+$", "minLength": 1, "maxLength": 128 }, "version": { "type": "string", "minLength": 1, "maxLength": 128 }, + "artifact_digest": { "type": "string", "minLength": 1, "maxLength": 128 }, "force": { "type": "boolean", "default": false }, "acknowledge_unverified": { "type": "boolean", "default": false } }, @@ -759,7 +769,7 @@ mod tests { } fn install_signed_body(slug: &str, nonce: &str, ts: u64) -> serde_json::Value { - let payload = install_payload(slug, "1.0.0", "u1", "a1", ts, nonce); + let payload = install_payload(slug, "1.0.0", "u1", "a1", ts, nonce, TEST_ARTIFACT_DIGEST); let sig = hmac_hex(TEST_SHARED_KEY, &payload).expect("sign"); serde_json::json!({ "slug": slug, @@ -769,6 +779,7 @@ mod tests { "ts": ts, "nonce": nonce, "sig": sig, + "artifact_digest": TEST_ARTIFACT_DIGEST, }) } @@ -981,6 +992,11 @@ mod tests { Some("1.0.0"), "gateway handler must forward the signed version so the tool binds the install to it" ); + assert_eq!( + params.get("artifact_digest").and_then(|v| v.as_str()), + Some(TEST_ARTIFACT_DIGEST), + "gateway handler must forward the signed artifact_digest so the tool binds the install to the artifact content" + ); } #[tokio::test] @@ -1230,6 +1246,7 @@ mod tests { "ts": now_unix(), "nonce": nonce, "sig": "deadbeef".repeat(8), + "artifact_digest": "d0", }); let req = req_with_identity( "POST", @@ -1288,9 +1305,11 @@ mod tests { fn install_hmac_is_deterministic_and_payload_sensitive() { let key_a = "ihub_sk_aaaaaaaaaaaaaaaaaaaaaaaa"; let key_b = "ihub_sk_bbbbbbbbbbbbbbbbbbbbbbbb"; - let p1 = install_payload("clickup", "1.0.0", "u1", "a1", 1_700_000_000, "n1"); - let p_other_slug = install_payload("evm-rpc", "1.0.0", "u1", "a1", 1_700_000_000, "n1"); - let p_other_nonce = install_payload("clickup", "1.0.0", "u1", "a1", 1_700_000_000, "n2"); + let p1 = install_payload("clickup", "1.0.0", "u1", "a1", 1_700_000_000, "n1", "d0"); + let p_other_slug = + install_payload("evm-rpc", "1.0.0", "u1", "a1", 1_700_000_000, "n1", "d0"); + let p_other_nonce = + install_payload("clickup", "1.0.0", "u1", "a1", 1_700_000_000, "n2", "d0"); let s1 = hmac_hex(key_a, &p1).expect("hmac"); let s2 = hmac_hex(key_a, &p1).expect("hmac"); let s3 = hmac_hex(key_b, &p1).expect("hmac"); @@ -1305,8 +1324,16 @@ mod tests { #[test] fn install_payload_format_is_stable() { - let p = install_payload("clickup", "1.0.0", "u1", "a1", 1_700_000_000, "n1"); - assert_eq!(p, "install:clickup:1.0.0:u1:a1:1700000000:n1"); + let p = install_payload( + "clickup", + "1.0.0", + "u1", + "a1", + 1_700_000_000, + "n1", + "deadbeef", + ); + assert_eq!(p, "install:clickup:1.0.0:u1:a1:1700000000:n1:deadbeef"); } #[test] @@ -1337,6 +1364,8 @@ mod tests { } const TEST_SHARED_KEY: &str = "ihub_sk_x7K2p9mQ4vR8tL3nB6wZ1yD5cF0jH"; + const TEST_ARTIFACT_DIGEST: &str = + "4e205e4f8061512d5bca40ebe50acbb93d44afa3e083981a7b434f9ee3bab6a3"; #[tokio::test] async fn verify_intent_is_rate_limited_per_user() { @@ -1359,7 +1388,8 @@ mod tests { "aid": "a1", "ts": now_unix(), "nonce": "rl-verify-1", - "sig": "deadbeef" + "sig": "deadbeef", + "artifact_digest": "d0" }) .to_string(); let first = req_with_identity( @@ -1446,7 +1476,15 @@ mod tests { .with_state(state); let ts = now_unix(); let nonce = uuid::Uuid::new_v4().to_string(); - let payload = install_payload("clickup", "1.0.0", "u1", "a1", ts, &nonce); + let payload = install_payload( + "clickup", + "1.0.0", + "u1", + "a1", + ts, + &nonce, + TEST_ARTIFACT_DIGEST, + ); let sig = hmac_hex(TEST_SHARED_KEY, &payload).expect("sign"); (app, sig, nonce, ts) } @@ -1460,6 +1498,7 @@ mod tests { "ts": ts, "nonce": nonce, "sig": sig, + "artifact_digest": TEST_ARTIFACT_DIGEST, }) } diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index ce532ceb49d..95d0aa2f658 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -1252,6 +1252,7 @@ pub struct IronhubInstallRequest { pub ts: u64, pub nonce: String, pub sig: String, + pub artifact_digest: String, #[serde(default)] pub acknowledge_unverified: bool, } @@ -1289,6 +1290,7 @@ pub struct IronhubVerifyIntentRequest { pub ts: u64, pub nonce: String, pub sig: String, + pub artifact_digest: String, } #[derive(Debug, Deserialize)] diff --git a/src/tools/builtin/ironhub.rs b/src/tools/builtin/ironhub.rs index cbbf7b47b25..18dd55fcc2d 100644 --- a/src/tools/builtin/ironhub.rs +++ b/src/tools/builtin/ironhub.rs @@ -87,6 +87,20 @@ fn classify_and_gate( Ok((kind, provenance)) } +fn entry_artifact_digest(kind: HubEntryKind, manifest: &HubManifest, name: &str) -> Option { + use sha2::{Digest, Sha256}; + let input = match kind { + HubEntryKind::Tool => { + let t = manifest.find_tool(name)?; + format!("{}:{}", t.wasm.sha256, t.capabilities.sha256) + } + HubEntryKind::Skill => manifest.find_skill(name)?.skill_md.sha256.clone(), + }; + let mut hasher = Sha256::new(); + hasher.update(input.as_bytes()); + Some(hex::encode(hasher.finalize())) +} + fn classify( manifest: &HubManifest, name: &str, @@ -356,6 +370,17 @@ impl IronhubInstallTool { } } + if let Some(expected) = parsed.artifact_digest.as_deref() { + let actual = entry_artifact_digest(kind, &manifest, &parsed.name); + if actual.as_deref() != Some(expected) { + let current = actual.as_deref().unwrap_or("unknown"); + return Err(ToolError::InvalidParameters(format!( + "signed artifact digest '{expected}' does not match the current IronHub catalog artifact for '{}' (computed '{current}'); the artifact changed since this install was approved", + parsed.name + ))); + } + } + let skills_dir = match kind { HubEntryKind::Skill => skill_install_dir(&self.deps.skill_registry), HubEntryKind::Tool => None, @@ -428,6 +453,7 @@ struct InstallParams { kind_hint: Option, release_tag: Option, version: Option, + artifact_digest: Option, force: bool, acknowledge_unverified: bool, } @@ -457,6 +483,17 @@ impl InstallParams { "version must be 1 to 128 characters".into(), )); } + let artifact_digest = params + .get("artifact_digest") + .and_then(|v| v.as_str()) + .map(str::to_string); + if let Some(d) = &artifact_digest + && (d.is_empty() || d.len() > 128) + { + return Err(ToolError::InvalidParameters( + "artifact_digest must be 1 to 128 characters".into(), + )); + } let force = params .get("force") .and_then(|v| v.as_bool()) @@ -470,6 +507,7 @@ impl InstallParams { kind_hint, release_tag, version, + artifact_digest, force, acknowledge_unverified, }) @@ -514,6 +552,12 @@ impl Tool for IronhubInstallTool { "maxLength": 128, "description": "Exact catalog version this install was signed for; if set, the install fails unless the current IronHub entry matches" }, + "artifact_digest": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Signed sha256 over the entry's artifact hashes; if set, the install fails unless the current IronHub artifact matches" + }, "force": { "type": "boolean", "default": false }, "acknowledge_unverified": { "type": "boolean", "default": false } }, @@ -1320,6 +1364,7 @@ mod tests { kind_hint: None, release_tag: None, version: None, + artifact_digest: None, force: false, acknowledge_unverified: false, }; @@ -1353,6 +1398,7 @@ mod tests { kind_hint: None, release_tag: None, version: None, + artifact_digest: None, force: false, acknowledge_unverified: true, }; @@ -1415,6 +1461,7 @@ mod tests { kind_hint: None, release_tag: None, version: Some("9.9.9".into()), + artifact_digest: None, force: false, acknowledge_unverified: false, }; @@ -1443,6 +1490,7 @@ mod tests { kind_hint: None, release_tag: None, version: Some("0.1.0".into()), + artifact_digest: None, force: false, acknowledge_unverified: false, }; @@ -1457,6 +1505,85 @@ mod tests { ); } + #[test] + fn entry_artifact_digest_matches_cross_language_vectors() { + let tool_manifest = manifest_with_provenance("clickup", Provenance::Official, None, None); + let tool_digest = entry_artifact_digest(HubEntryKind::Tool, &tool_manifest, "clickup") + .expect("tool entry present"); + assert_eq!( + tool_digest, "3bef8b777d4b0dc782fbba98c00b622da35e098642e8e103b94e395679b5499a", + "tool digest must equal sha256(wasm_sha:capabilities_sha); drift here breaks IronHub signing" + ); + let skill_manifest = manifest_with_provenance( + "official-tool", + Provenance::Official, + Some("indie-skill"), + Some(Provenance::New), + ); + let skill_digest = + entry_artifact_digest(HubEntryKind::Skill, &skill_manifest, "indie-skill") + .expect("skill entry present"); + assert_eq!( + skill_digest, "ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb", + "skill digest must equal sha256(skill_md_sha); drift here breaks IronHub signing" + ); + } + + #[tokio::test] + async fn install_from_manifest_rejects_artifact_digest_mismatch() { + let (tool, _tools_dir, _channels_dir) = install_tool_with_ext_mgr(); + let manifest = manifest_with_provenance("clickup", Provenance::Official, None, None); + let parsed = InstallParams { + name: "clickup".into(), + kind_hint: None, + release_tag: None, + version: None, + artifact_digest: Some("0".repeat(64)), + force: false, + acknowledge_unverified: false, + }; + let ctx = JobContext::with_user("test", "digest mismatch test", ""); + let err = tool + .install_from_manifest(std::time::Instant::now(), manifest, parsed, &ctx) + .await + .expect_err("a signed artifact digest that does not match the catalog must be rejected before install"); + match err { + ToolError::InvalidParameters(msg) => assert!( + msg.contains("artifact digest") && msg.contains("does not match"), + "digest-bind rejection must name the mismatch: {msg}" + ), + other => panic!( + "artifact digest mismatch must reject as InvalidParameters before the install side effect; got {other:?}" + ), + } + } + + #[tokio::test] + async fn install_from_manifest_accepts_matching_artifact_digest() { + let (tool, _tools_dir, _channels_dir) = install_tool_with_ext_mgr(); + let manifest = manifest_with_provenance("clickup", Provenance::Official, None, None); + let parsed = InstallParams { + name: "clickup".into(), + kind_hint: None, + release_tag: None, + version: None, + artifact_digest: Some( + "3bef8b777d4b0dc782fbba98c00b622da35e098642e8e103b94e395679b5499a".into(), + ), + force: false, + acknowledge_unverified: false, + }; + let ctx = JobContext::with_user("test", "digest match test", ""); + let err = tool + .install_from_manifest(std::time::Instant::now(), manifest, parsed, &ctx) + .await + .expect_err("fake artifact URLs make the install fail downstream of the digest gate"); + assert!( + !matches!(&err, ToolError::InvalidParameters(m) if m.contains("artifact digest")), + "a matching artifact digest must clear the bind check; got the bind rejection instead: {err:?}" + ); + } + #[test] fn info_tool_json_includes_trust_label() { let manifest = manifest_with_provenance("indie-tool", Provenance::New, None, None); diff --git a/tests/e2e/scenarios/test_ironhub_deep_link_install.py b/tests/e2e/scenarios/test_ironhub_deep_link_install.py index bf3f5221e07..f9a2c337c6c 100644 --- a/tests/e2e/scenarios/test_ironhub_deep_link_install.py +++ b/tests/e2e/scenarios/test_ironhub_deep_link_install.py @@ -20,18 +20,40 @@ VERSION = "0.1.0" UID = "e2e-user" AID = "e2e-agent" - - -def install_payload(slug: str, version: str, uid: str, aid: str, ts: int, nonce: str) -> str: - return f"install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}" - - -def sign_install(key: str, slug: str, version: str, uid: str, aid: str, ts: int, nonce: str) -> str: - msg = install_payload(slug, version, uid, aid, ts, nonce) +ARTIFACT_DIGEST = "4e205e4f8061512d5bca40ebe50acbb93d44afa3e083981a7b434f9ee3bab6a3" + + +def install_payload( + slug: str, version: str, uid: str, aid: str, ts: int, nonce: str, artifact_digest: str +) -> str: + return f"install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}:{artifact_digest}" + + +def sign_install( + key: str, + slug: str, + version: str, + uid: str, + aid: str, + ts: int, + nonce: str, + artifact_digest: str = ARTIFACT_DIGEST, +) -> str: + msg = install_payload(slug, version, uid, aid, ts, nonce, artifact_digest) return hmac.new(key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).hexdigest() -def install_hash(*, slug: str, version: str, uid: str, aid: str, ts: int, nonce: str, sig: str) -> str: +def install_hash( + *, + slug: str, + version: str, + uid: str, + aid: str, + ts: int, + nonce: str, + sig: str, + artifact_digest: str = ARTIFACT_DIGEST, +) -> str: return ( f"#/install/{slug}" f"?slug={slug}" @@ -41,6 +63,7 @@ def install_hash(*, slug: str, version: str, uid: str, aid: str, ts: int, nonce: f"&ts={ts}" f"&nonce={nonce}" f"&sig={sig}" + f"&artifact_digest={artifact_digest}" ) @@ -113,7 +136,11 @@ async def capture_install(route): assert install_requests, "clicking confirm must POST /api/ironhub/install" body = install_requests[0].post_data_json or {} - assert body.get("name") == SLUG, f"install body must carry slug, got: {body!r}" + assert body.get("slug") == SLUG, f"install body must carry slug, got: {body!r}" + assert body.get("version") == VERSION, f"install body must carry version, got: {body!r}" + assert body.get("artifact_digest") == ARTIFACT_DIGEST, ( + f"install body must carry the signed artifact_digest, got: {body!r}" + ) async def test_deep_link_install_tampered_signature_shows_mismatch(page, ironclaw_server): From c936cf39c575195fee4593e9593aa3ee88d12ff0 Mon Sep 17 00:00:00 2001 From: neo-sky Date: Sun, 24 May 2026 02:07:41 -0700 Subject: [PATCH 06/13] fix(ironhub): make /register HMAC-authed, not session-gated IronHub calls it server-to-server, so the session gate rejected every handshake. --- src/channels/web/CLAUDE.md | 3 ++- src/channels/web/handlers/ironhub.rs | 36 ++++++++++++++++++++++++---- src/channels/web/platform/router.rs | 2 +- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/channels/web/CLAUDE.md b/src/channels/web/CLAUDE.md index eb602411123..0880aacb3d9 100644 --- a/src/channels/web/CLAUDE.md +++ b/src/channels/web/CLAUDE.md @@ -69,6 +69,7 @@ subset that can later be replaced by a typed `Deps` alias. |--------|------|-------------| | GET | `/api/health` | Health check | | GET | `/oauth/callback` | OAuth callback for extension auth | +| POST | `/api/ironhub/register` | IronHub deep-link handshake (HMAC-authenticated, no session) | ### Chat | Method | Path | Description | @@ -212,7 +213,7 @@ Legacy cleanup note: | POST | `/api/ironhub/signing-key` | Set the shared install key (body: `{shared_key}`; validates `ihub_sk_` prefix, minimum 32 characters, and a distinct-character floor) | | GET | `/api/ironhub/signing-key` | Get fingerprint + created_at for the stored key (never returns the key) | | DELETE | `/api/ironhub/signing-key` | Remove the stored key | -| POST | `/api/ironhub/register` | IronHub-side handshake confirming the agent owns the shared key (body: `{uid, aid, ts, nonce, sig}`; sig over `register:{uid}:{aid}:{ts}:{nonce}`; returns 200 on valid, 401 on bad sig, 408 on stale timestamp, 409 on replayed nonce) | +| POST | `/api/ironhub/register` | IronHub-side handshake confirming the agent owns the shared key. **Public route, no session** (lives on the `public` router): IronHub calls it server-to-server, authenticated solely by the HMAC `sig` verified against the owner's stored key (`state.owner_id`). Body `{uid, aid, ts, nonce, sig}`; sig over `register:{uid}:{aid}:{ts}:{nonce}`; returns 200 on valid, 401 on bad sig, 408 on stale timestamp, 409 on replayed nonce | | POST | `/api/ironhub/verify-intent` | Browser deep-link install preview (body: `{slug, version, uid, aid, ts, nonce, sig, artifact_digest}`; sig over `install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}:{artifact_digest}`; returns `{valid, reason}`). Repeatable: it checks slug, freshness, and signature but does NOT consume the nonce; the nonce is burned only by `/api/ironhub/install`. | **Install protocol contract** (canonical shape that matches IronHub PR #43 and replaces the earlier `{tool}:{ts}` minimal verify shape): diff --git a/src/channels/web/handlers/ironhub.rs b/src/channels/web/handlers/ironhub.rs index a5527b5bbab..7b36e251779 100644 --- a/src/channels/web/handlers/ironhub.rs +++ b/src/channels/web/handlers/ironhub.rs @@ -493,10 +493,9 @@ pub async fn ironhub_verify_intent_handler( pub async fn ironhub_register_handler( State(state): State>, - AuthenticatedUser(user): AuthenticatedUser, Json(req): Json, ) -> Result { - catalog_rate_limit(&state, &user.user_id)?; + catalog_rate_limit(&state, &state.owner_id)?; let now = now_unix(); let drift = now.abs_diff(req.ts); if drift > VERIFY_INTENT_WINDOW_SECS { @@ -508,7 +507,7 @@ pub async fn ironhub_register_handler( let store = secrets_store_or_503(&state)?; let decrypted = match store - .get_decrypted(&user.user_id, IRONHUB_SIGNING_KEY_NAME) + .get_decrypted(&state.owner_id, IRONHUB_SIGNING_KEY_NAME) .await { Ok(s) => s, @@ -532,7 +531,7 @@ pub async fn ironhub_register_handler( return Err((StatusCode::UNAUTHORIZED, "signature mismatch".to_string())); } - if nonce_seen_or_record(&user.user_id, &req.nonce) { + if nonce_seen_or_record(&state.owner_id, &req.nonce) { return Err((StatusCode::CONFLICT, "nonce already used".to_string())); } @@ -2006,4 +2005,33 @@ mod tests { .expect("response"); assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); } + + #[tokio::test] + async fn register_succeeds_without_session_via_hmac() { + let (app, _intent_sig, _intent_nonce, 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).expect("sign"); + let body = serde_json::json!({ + "uid": "u1", + "aid": "a1", + "ts": ts, + "nonce": nonce, + "sig": sig, + }); + let req = axum::http::Request::builder() + .method("POST") + .uri("/api/ironhub/register") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("request"); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!( + resp.status(), + StatusCode::OK, + "register must authenticate by HMAC alone with no session; IronHub calls it server-to-server" + ); + } } diff --git a/src/channels/web/platform/router.rs b/src/channels/web/platform/router.rs index 6fd41821e40..5278bdade31 100644 --- a/src/channels/web/platform/router.rs +++ b/src/channels/web/platform/router.rs @@ -136,6 +136,7 @@ pub async fn start_server( get(slack_relay_oauth_callback_handler), ) .route("/relay/events", post(relay_events_handler)) + .route("/api/ironhub/register", post(ironhub_register_handler)) .route( "/api/webhooks/{path}", post(crate::channels::web::handlers::webhooks::webhook_trigger_handler), @@ -327,7 +328,6 @@ pub async fn start_server( "/api/ironhub/verify-intent", post(ironhub_verify_intent_handler), ) - .route("/api/ironhub/register", post(ironhub_register_handler)) // Settings .route("/api/settings", get(settings_list_handler)) .route("/api/settings/export", get(settings_export_handler)) From 98394ed66237a8bae989250c174d3f0986bcdefd Mon Sep 17 00:00:00 2001 From: neo-sky Date: Sun, 24 May 2026 02:07:41 -0700 Subject: [PATCH 07/13] fix(ironhub): make the signing-key setting match the other rows Was a one-off card, now a normal settings row. --- crates/ironclaw_gateway/static/i18n/en.js | 1 + .../static/js/surfaces/settings.js | 48 ++++++++++++------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/crates/ironclaw_gateway/static/i18n/en.js b/crates/ironclaw_gateway/static/i18n/en.js index b9a388b74b2..bda746b4711 100644 --- a/crates/ironclaw_gateway/static/i18n/en.js +++ b/crates/ironclaw_gateway/static/i18n/en.js @@ -864,6 +864,7 @@ I18n.register('en', { 'ironhub.install.communityWarning': 'This is community-submitted content that has not been verified by the NEAR team. Review it carefully before installing.', 'ironhub.install.ackUnverified': 'I understand this is unverified community content and want to install anyway.', 'ironhub.signingKey.title': 'IronHub Integration', + 'ironhub.signingKey.label': 'Shared key', 'ironhub.signingKey.description': 'Generate a key in your IronHub profile, then paste it here so install requests from IronHub can be verified by this agent.', 'ironhub.signingKey.placeholder': 'ihub_sk_...', 'ironhub.signingKey.save': 'Save Key', diff --git a/crates/ironclaw_gateway/static/js/surfaces/settings.js b/crates/ironclaw_gateway/static/js/surfaces/settings.js index f68a706ce59..4b654d67779 100644 --- a/crates/ironclaw_gateway/static/js/surfaces/settings.js +++ b/crates/ironclaw_gateway/static/js/surfaces/settings.js @@ -226,19 +226,18 @@ function loadAgentSettings() { loadIronhubSigningKeyCard(); } -function renderIronhubSigningKeyCard(bodyHtml) { +function renderIronhubSigningKeyCard(rowHtml) { var card = document.getElementById('settings-ironhub-card'); if (!card) return; card.innerHTML = - '
' + - '

' + escapeHtml(I18n.t('ironhub.signingKey.title')) + '

' + - '

' + - escapeHtml(I18n.t('ironhub.signingKey.description')) + '

' + - bodyHtml + + '
' + + '
' + + escapeHtml(I18n.t('ironhub.signingKey.title')) + '
' + + rowHtml + '
'; } -function ironhubSigningKeyFormHtml(hasKey) { +function ironhubSigningKeyRowHtml(statusHtml, hasKey) { var submitLabel = hasKey ? I18n.t('ironhub.signingKey.replace') : I18n.t('ironhub.signingKey.save'); @@ -247,30 +246,43 @@ function ironhubSigningKeyFormHtml(hasKey) { escapeHtml(I18n.t('ironhub.signingKey.revoke')) + '' : ''; return '' + - '
' + - '' + + '
' + + '' + + '
' + + escapeHtml(I18n.t('ironhub.signingKey.description')) + '
' + + statusHtml + + '
' + + '' + + '' + '' + revoke + - '
'; + '' + + '
'; } function loadIronhubSigningKeyCard() { renderIronhubSigningKeyCard( - '
' + escapeHtml(I18n.t('extensions.loading')) + '
' + '
' + + '
' + escapeHtml(I18n.t('extensions.loading')) + '
' + + '
' ); apiFetch('/api/ironhub/signing-key').then(function(meta) { var status = - '

' + escapeHtml(I18n.t('ironhub.signingKey.active', { + '

' + + escapeHtml(I18n.t('ironhub.signingKey.active', { fingerprint: meta.fingerprint, created: meta.created_at, - })) + '

'; - renderIronhubSigningKeyCard(status + ironhubSigningKeyFormHtml(true)); + })) + '
'; + renderIronhubSigningKeyCard(ironhubSigningKeyRowHtml(status, true)); }).catch(function() { - renderIronhubSigningKeyCard( - '

' + escapeHtml(I18n.t('ironhub.signingKey.none')) + '

' + - ironhubSigningKeyFormHtml(false) - ); + var status = + '
' + + escapeHtml(I18n.t('ironhub.signingKey.none')) + '
'; + renderIronhubSigningKeyCard(ironhubSigningKeyRowHtml(status, false)); }); } From 3b1bcd1abcfa9f6d2c44c16b0d802ca3a817a5dc Mon Sep 17 00:00:00 2001 From: neo-sky Date: Thu, 28 May 2026 09:54:10 -0400 Subject: [PATCH 08/13] fix(ironhub): unblock install/settings UI handlers under CSP 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. --- .../static/js/surfaces/install.js | 20 ++++++++++++++----- .../static/js/surfaces/settings.js | 9 ++++++--- .../static/styles/surfaces/install.css | 2 +- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/ironclaw_gateway/static/js/surfaces/install.js b/crates/ironclaw_gateway/static/js/surfaces/install.js index ebbab1586cb..b697d332cb1 100644 --- a/crates/ironclaw_gateway/static/js/surfaces/install.js +++ b/crates/ironclaw_gateway/static/js/surfaces/install.js @@ -4,7 +4,17 @@ function ironhubInstallPanel() { function renderIronhubInstallState(html) { var panel = ironhubInstallPanel(); - if (panel) panel.innerHTML = '
' + html + '
'; + if (!panel) return; + panel.innerHTML = '
' + html + '
'; + wireInstallActions(); +} + +function wireInstallActions() { + document.querySelectorAll('.ironhub-install-cancel').forEach(function(btn) { + btn.addEventListener('click', ironhubInstallCancel); + }); + var ackCb = document.getElementById('ironhub-install-ack-cb'); + if (ackCb) ackCb.addEventListener('change', ironhubAckChanged); } function ironhubInstallCancel() { @@ -15,7 +25,7 @@ function renderIronhubInstallError(message) { renderIronhubInstallState( '

' + escapeHtml(I18n.t('ironhub.install.unverifiedTitle')) + '

' + '

' + escapeHtml(message) + '

' + - '' ); } @@ -63,7 +73,7 @@ function renderIronhubConfirm(signed, info) { escapeHtml(I18n.t('ironhub.install.communityWarning')) + '

'; ackCheckbox = ''; confirmDisabled = ' disabled'; } @@ -78,7 +88,7 @@ function renderIronhubConfirm(signed, info) { '
' + '' + - '' + '
' ); @@ -126,7 +136,7 @@ function ironhubInstallConfirm(signed, requireAck) { renderIronhubInstallState( '

' + escapeHtml(I18n.t('ironhub.install.doneTitle')) + '

' + '

' + escapeHtml(I18n.t('ironhub.install.success', { name: name })) + '

' + - '' ); }).catch(function(err) { diff --git a/crates/ironclaw_gateway/static/js/surfaces/settings.js b/crates/ironclaw_gateway/static/js/surfaces/settings.js index 4b654d67779..6d740088abd 100644 --- a/crates/ironclaw_gateway/static/js/surfaces/settings.js +++ b/crates/ironclaw_gateway/static/js/surfaces/settings.js @@ -235,6 +235,10 @@ function renderIronhubSigningKeyCard(rowHtml) { escapeHtml(I18n.t('ironhub.signingKey.title')) + '' + rowHtml + ''; + var form = document.getElementById('ironhub-key-form'); + if (form) form.addEventListener('submit', ironhubSaveSigningKey); + var revokeBtn = document.getElementById('ironhub-revoke-btn'); + if (revokeBtn) revokeBtn.addEventListener('click', ironhubRevokeSigningKey); } function ironhubSigningKeyRowHtml(statusHtml, hasKey) { @@ -242,7 +246,7 @@ function ironhubSigningKeyRowHtml(statusHtml, hasKey) { ? I18n.t('ironhub.signingKey.replace') : I18n.t('ironhub.signingKey.save'); var revoke = hasKey - ? '' : ''; return '' + @@ -254,8 +258,7 @@ function ironhubSigningKeyRowHtml(statusHtml, hasKey) { escapeHtml(I18n.t('ironhub.signingKey.description')) + '' + statusHtml + '' + - '
' + + '' + '' + '' + diff --git a/crates/ironclaw_gateway/static/styles/surfaces/install.css b/crates/ironclaw_gateway/static/styles/surfaces/install.css index be4ea40625f..c9d0f2e7c31 100644 --- a/crates/ironclaw_gateway/static/styles/surfaces/install.css +++ b/crates/ironclaw_gateway/static/styles/surfaces/install.css @@ -1,6 +1,6 @@ #tab-install { - display: flex; justify-content: center; + align-items: center; padding: 48px 16px; } From 0e0c20b4edf4c17ad55bb74991f85549c520aa0f Mon Sep 17 00:00:00 2001 From: neo-sky Date: Thu, 28 May 2026 09:58:08 -0400 Subject: [PATCH 09/13] fix(ironhub): make manifest URL and artifact hosts env-configurable 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. --- src/registry/hub_installer.rs | 32 ++++++++++++++--- src/registry/installer.rs | 66 +++++++++++++++++++++++++++++++++++ src/tools/builtin/ironhub.rs | 13 ++++--- 3 files changed, 102 insertions(+), 9 deletions(-) diff --git a/src/registry/hub_installer.rs b/src/registry/hub_installer.rs index c036be0823c..1cb66219b93 100644 --- a/src/registry/hub_installer.rs +++ b/src/registry/hub_installer.rs @@ -162,6 +162,12 @@ pub struct HubInstaller { skills_dir: PathBuf, } +fn resolve_manifest_url(env_value: Option) -> String { + env_value + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_HUB_MANIFEST_URL.to_string()) +} + impl HubInstaller { pub fn new(manifest_url: String, tools_dir: PathBuf, skills_dir: PathBuf) -> Self { Self { @@ -173,11 +179,8 @@ impl HubInstaller { pub fn with_defaults() -> Self { let base = ironclaw_base_dir(); - Self::new( - DEFAULT_HUB_MANIFEST_URL.to_string(), - base.join("tools"), - base.join("skills"), - ) + let manifest_url = resolve_manifest_url(std::env::var("IRONHUB_MANIFEST_URL").ok()); + Self::new(manifest_url, base.join("tools"), base.join("skills")) } pub fn with_manifest_url(mut self, url: String) -> Self { @@ -581,6 +584,25 @@ mod tests { assert_eq!(installer.manifest_url(), pinned); } + #[test] + fn resolve_manifest_url_prefers_env_then_falls_back() { + assert_eq!( + resolve_manifest_url(Some( + "https://ironhub-staging.up.railway.app/api/catalog/manifest.json".to_string() + )), + "https://ironhub-staging.up.railway.app/api/catalog/manifest.json" + ); + assert_eq!(resolve_manifest_url(None), DEFAULT_HUB_MANIFEST_URL); + assert_eq!( + resolve_manifest_url(Some(String::new())), + DEFAULT_HUB_MANIFEST_URL + ); + assert_eq!( + resolve_manifest_url(Some(" ".to_string())), + DEFAULT_HUB_MANIFEST_URL + ); + } + #[test] fn with_tools_dir_overrides_default() { let custom = PathBuf::from("/custom/tools"); diff --git a/src/registry/installer.rs b/src/registry/installer.rs index 4a45827da4e..7f28927bf65 100644 --- a/src/registry/installer.rs +++ b/src/registry/installer.rs @@ -17,6 +17,27 @@ const ALLOWED_ARTIFACT_HOSTS: &[&str] = &[ "raw.githubusercontent.com", ]; +fn extra_artifact_hosts() -> &'static [String] { + use std::sync::OnceLock; + static EXTRA: OnceLock> = OnceLock::new(); + EXTRA.get_or_init(|| { + parse_extra_artifact_hosts( + std::env::var("IRONHUB_EXTRA_ARTIFACT_HOSTS") + .ok() + .as_deref(), + ) + }) +} + +fn parse_extra_artifact_hosts(env_value: Option<&str>) -> Vec { + env_value + .unwrap_or("") + .split(',') + .map(|h| h.trim().to_ascii_lowercase()) + .filter(|h| !h.is_empty()) + .collect() +} + fn should_attempt_source_fallback(err: &RegistryError) -> bool { match err { // `releases/latest` is a moving target: every new release rebuilds WASM @@ -37,10 +58,15 @@ fn should_attempt_source_fallback(err: &RegistryError) -> bool { } fn is_allowed_artifact_host(host: &str) -> bool { + is_allowed_artifact_host_with_extras(host, extra_artifact_hosts()) +} + +fn is_allowed_artifact_host_with_extras(host: &str, extras: &[String]) -> bool { ALLOWED_ARTIFACT_HOSTS .iter() .any(|allowed| host.eq_ignore_ascii_case(allowed)) || host.ends_with(".githubusercontent.com") + || extras.iter().any(|h| host.eq_ignore_ascii_case(h)) } pub(crate) fn validate_artifact_url( @@ -1422,6 +1448,46 @@ mod tests { assert!(!is_allowed_artifact_host("ironclaw.com")); } + #[test] + fn parse_extra_artifact_hosts_handles_unset_empty_and_comma_list() { + assert!(parse_extra_artifact_hosts(None).is_empty()); + assert!(parse_extra_artifact_hosts(Some("")).is_empty()); + assert!(parse_extra_artifact_hosts(Some(" , , ")).is_empty()); + assert_eq!( + parse_extra_artifact_hosts(Some("ironhub-staging.up.railway.app")), + vec!["ironhub-staging.up.railway.app".to_string()] + ); + assert_eq!( + parse_extra_artifact_hosts(Some(" Foo.Example , bar.example , ")), + vec!["foo.example".to_string(), "bar.example".to_string()] + ); + } + + #[test] + fn extras_allow_listed_host_without_widening_const_allowlist() { + let extras = vec!["ironhub-staging.up.railway.app".to_string()]; + assert!(is_allowed_artifact_host_with_extras( + "ironhub-staging.up.railway.app", + &extras + )); + assert!(is_allowed_artifact_host_with_extras( + "IRONHUB-STAGING.up.railway.app", + &extras + )); + assert!(!is_allowed_artifact_host_with_extras( + "evil.example.com", + &extras + )); + assert!(is_allowed_artifact_host_with_extras( + "hub.ironclaw.com", + &extras + )); + assert!(!is_allowed_artifact_host_with_extras( + "ironhub-staging.up.railway.app", + &[] + )); + } + #[test] fn validate_artifact_url_rejects_non_https_ip_and_unknown_host() { assert!(validate_artifact_url("m", "f", "http://hub.ironclaw.com/x").is_err()); diff --git a/src/tools/builtin/ironhub.rs b/src/tools/builtin/ironhub.rs index 18dd55fcc2d..1952b34fc72 100644 --- a/src/tools/builtin/ironhub.rs +++ b/src/tools/builtin/ironhub.rs @@ -64,6 +64,11 @@ fn catalog_unavailable() -> ToolError { ToolError::ExternalService("IronHub catalog is temporarily unavailable".into()) } +fn catalog_unavailable_from(err: RegistryError) -> ToolError { + tracing::debug!("IronHub catalog fetch failed: {err}"); + catalog_unavailable() +} + fn classify_and_gate( manifest: &HubManifest, name: &str, @@ -583,7 +588,7 @@ impl Tool for IronhubInstallTool { let manifest = probe .fetch_manifest() .await - .map_err(|_| catalog_unavailable())?; + .map_err(catalog_unavailable_from)?; self.install_from_manifest(start, manifest, parsed, ctx) .await } @@ -659,7 +664,7 @@ impl Tool for IronhubSearchTool { let manifest = installer .fetch_manifest_cached() .await - .map_err(|_| catalog_unavailable())?; + .map_err(catalog_unavailable_from)?; let q = query.to_ascii_lowercase(); let mut results: Vec = manifest @@ -744,7 +749,7 @@ impl Tool for IronhubListTool { let manifest = installer .fetch_manifest_cached() .await - .map_err(|_| catalog_unavailable())?; + .map_err(catalog_unavailable_from)?; let tools: Vec = manifest.tools.iter().map(tool_entry_json).collect(); let skills: Vec = manifest.skills.iter().map(skill_entry_json).collect(); @@ -831,7 +836,7 @@ impl Tool for IronhubInfoTool { let manifest = installer .fetch_manifest_cached() .await - .map_err(|_| catalog_unavailable())?; + .map_err(catalog_unavailable_from)?; if let Some(t) = manifest.find_tool(name) { let json = info_tool_json(t, &manifest.release_tag); From 544a6caf7e314eb55c5a5233bcc7a1e8eceb34ab Mon Sep 17 00:00:00 2001 From: neo-sky Date: Thu, 28 May 2026 14:00:59 -0400 Subject: [PATCH 10/13] fix(ironhub): fail-closed provenance + harden artifact-host SSRF guard 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. --- src/channels/web/handlers/ironhub.rs | 3 ++ src/cli/hub.rs | 4 +- src/registry/hub_manifest.rs | 8 ++-- src/registry/installer.rs | 65 +++++++++++++++++++++++++++- src/tools/builtin/ironhub.rs | 2 +- 5 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/channels/web/handlers/ironhub.rs b/src/channels/web/handlers/ironhub.rs index 7b36e251779..493e1efb522 100644 --- a/src/channels/web/handlers/ironhub.rs +++ b/src/channels/web/handlers/ironhub.rs @@ -190,6 +190,9 @@ pub async fn ironhub_install_handler( "artifact_digest".into(), serde_json::Value::String(req.artifact_digest), ); + // Local owner consent, deliberately outside the HMAC: the signature already binds the + // artifact digest, so this can only downgrade the owner's own unverified-content warning, + // never change which artifact installs. params.insert( "acknowledge_unverified".into(), serde_json::Value::Bool(req.acknowledge_unverified), diff --git a/src/cli/hub.rs b/src/cli/hub.rs index 7e9fd40d2c9..76db747e012 100644 --- a/src/cli/hub.rs +++ b/src/cli/hub.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use clap::Subcommand; use crate::cli::hub_install::{hub_manifest_url_for_tag, validate_hub_name}; -use crate::registry::{HubInstaller, HubManifest, HubSkillEntry, HubToolEntry}; +use crate::registry::{HubInstaller, HubManifest, HubSkillEntry, HubToolEntry, Provenance}; #[derive(Subcommand, Debug, Clone)] pub enum HubCommand { @@ -232,7 +232,7 @@ async fn install_with_manifest( Kind::Tool => manifest.find_tool(name).map(|t| t.provenance), Kind::Skill => manifest.find_skill(name).map(|s| s.provenance), } - .unwrap_or_default(); + .unwrap_or(Provenance::New); if provenance.is_community_unverified() && !acknowledge_unverified { anyhow::bail!( diff --git a/src/registry/hub_manifest.rs b/src/registry/hub_manifest.rs index e32243dcbdf..6ee5c075c10 100644 --- a/src/registry/hub_manifest.rs +++ b/src/registry/hub_manifest.rs @@ -5,11 +5,11 @@ pub const DEFAULT_HUB_MANIFEST_URL: &str = "https://hub.ironclaw.com/api/catalog #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Provenance { - #[default] #[serde(alias = "repo")] Official, Trusted, Verified, + #[default] #[serde(alias = "community")] New, } @@ -174,10 +174,10 @@ mod tests { } #[test] - fn provenance_defaults_to_official_when_field_absent() { + fn provenance_defaults_to_new_when_field_absent() { let manifest: HubManifest = serde_json::from_str(SAMPLE_MANIFEST).expect("valid manifest"); - assert_eq!(manifest.tools[0].provenance, Provenance::Official); - assert_eq!(manifest.skills[0].provenance, Provenance::Official); + assert_eq!(manifest.tools[0].provenance, Provenance::New); + assert_eq!(manifest.skills[0].provenance, Provenance::New); } #[test] diff --git a/src/registry/installer.rs b/src/registry/installer.rs index 7f28927bf65..9cf88e03545 100644 --- a/src/registry/installer.rs +++ b/src/registry/installer.rs @@ -35,9 +35,38 @@ fn parse_extra_artifact_hosts(env_value: Option<&str>) -> Vec { .split(',') .map(|h| h.trim().to_ascii_lowercase()) .filter(|h| !h.is_empty()) + .filter(|h| !host_is_disallowed_target(h)) .collect() } +fn host_is_disallowed_target(host: &str) -> bool { + let h = host.strip_suffix('.').unwrap_or(host); + let ip_form = h + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .unwrap_or(h); + if ip_form.parse::().is_ok() { + return true; + } + if h == "localhost" { + return true; + } + const INTERNAL_SUFFIXES: &[&str] = &[ + ".localhost", + ".local", + ".internal", + ".intranet", + ".lan", + ".home", + ".corp", + ".private", + ]; + if INTERNAL_SUFFIXES.iter().any(|s| h.ends_with(s)) { + return true; + } + !h.contains('.') +} + fn should_attempt_source_fallback(err: &RegistryError) -> bool { match err { // `releases/latest` is a moving target: every new release rebuilds WASM @@ -96,7 +125,7 @@ pub(crate) fn validate_artifact_url( reason: "URL host is missing".to_string(), })?; - if host.parse::().is_ok() || !is_allowed_artifact_host(host) { + if host_is_disallowed_target(host) || !is_allowed_artifact_host(host) { return Err(RegistryError::InvalidManifest { name: manifest_name.to_string(), field, @@ -1463,6 +1492,19 @@ mod tests { ); } + #[test] + fn parse_extra_artifact_hosts_filters_unsafe_targets() { + assert_eq!( + parse_extra_artifact_hosts(Some( + "ironhub-staging.up.railway.app, localhost, 127.0.0.1, metadata.internal, intranet, partner.example.com" + )), + vec![ + "ironhub-staging.up.railway.app".to_string(), + "partner.example.com".to_string() + ] + ); + } + #[test] fn extras_allow_listed_host_without_widening_const_allowlist() { let extras = vec!["ironhub-staging.up.railway.app".to_string()]; @@ -1492,10 +1534,31 @@ mod tests { fn validate_artifact_url_rejects_non_https_ip_and_unknown_host() { assert!(validate_artifact_url("m", "f", "http://hub.ironclaw.com/x").is_err()); assert!(validate_artifact_url("m", "f", "https://1.2.3.4/x").is_err()); + assert!(validate_artifact_url("m", "f", "https://[::1]/x").is_err()); + assert!(validate_artifact_url("m", "f", "https://[fd00::1]/x").is_err()); + assert!(validate_artifact_url("m", "f", "https://localhost/x").is_err()); + assert!(validate_artifact_url("m", "f", "https://metadata.internal/x").is_err()); + assert!(validate_artifact_url("m", "f", "https://intranet/x").is_err()); assert!(validate_artifact_url("m", "f", "https://evil.example.com/x").is_err()); assert!(validate_artifact_url("m", "f", "https://hub.ironclaw.com/catalog/x.wasm").is_ok()); } + #[test] + fn host_is_disallowed_target_rejects_ip_internal_and_bare() { + assert!(host_is_disallowed_target("127.0.0.1")); + assert!(host_is_disallowed_target("169.254.169.254")); + assert!(host_is_disallowed_target("[::1]")); + assert!(host_is_disallowed_target("localhost")); + assert!(host_is_disallowed_target("foo.localhost")); + assert!(host_is_disallowed_target("svc.internal")); + assert!(host_is_disallowed_target("db.local")); + assert!(host_is_disallowed_target("intranet")); + assert!(host_is_disallowed_target("router.lan.")); + assert!(!host_is_disallowed_target("hub.ironclaw.com")); + assert!(!host_is_disallowed_target("ironhub-staging.up.railway.app")); + assert!(!host_is_disallowed_target("objects.githubusercontent.com")); + } + #[test] fn enforce_size_cap_allows_within_and_rejects_over() { assert!(enforce_size_cap(0, 1024, "u").is_ok()); diff --git a/src/tools/builtin/ironhub.rs b/src/tools/builtin/ironhub.rs index 1952b34fc72..2d3d40c90b1 100644 --- a/src/tools/builtin/ironhub.rs +++ b/src/tools/builtin/ironhub.rs @@ -80,7 +80,7 @@ fn classify_and_gate( HubEntryKind::Tool => manifest.find_tool(name).map(|t| t.provenance), HubEntryKind::Skill => manifest.find_skill(name).map(|s| s.provenance), } - .unwrap_or_default(); + .unwrap_or(Provenance::New); if provenance.is_community_unverified() && !acknowledge_unverified { return Err(ToolError::InvalidParameters(format!( "'{name}' is UNVERIFIED community content (trust tier: {}). \ From c7574cb97d0260ef075e1103f86cfc50b2553576 Mon Sep 17 00:00:00 2001 From: neo-sky Date: Thu, 28 May 2026 20:53:11 -0400 Subject: [PATCH 11/13] feat(ironhub): verify the catalog manifest signature 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. --- src/cli/hub.rs | 22 ++++-- src/registry/hub_installer.rs | 25 ++++++- src/registry/hub_manifest.rs | 124 ++++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 6 deletions(-) diff --git a/src/cli/hub.rs b/src/cli/hub.rs index 76db747e012..72b509435fb 100644 --- a/src/cli/hub.rs +++ b/src/cli/hub.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use clap::Subcommand; -use crate::cli::hub_install::{hub_manifest_url_for_tag, validate_hub_name}; +use crate::cli::hub_install::validate_hub_name; use crate::registry::{HubInstaller, HubManifest, HubSkillEntry, HubToolEntry, Provenance}; #[derive(Subcommand, Debug, Clone)] @@ -97,6 +97,13 @@ fn build_installer( tools_dir_override: Option, skills_dir_override: Option, ) -> anyhow::Result { + if let Some(tag) = release_tag.as_deref() { + anyhow::bail!( + "--release-tag ('{tag}') is not supported: pinning to a GitHub release fetches an \ + unsigned manifest, which the agent rejects fail-closed. Install from the default \ + signed catalog by omitting --release-tag." + ); + } let mut installer = HubInstaller::with_defaults(); if let Some(dir) = tools_dir_override { installer = HubInstaller::new( @@ -112,9 +119,6 @@ fn build_installer( dir, ); } - if let Some(tag) = release_tag.as_deref() { - installer = installer.with_manifest_url(hub_manifest_url_for_tag(tag)?); - } Ok(installer) } @@ -479,6 +483,16 @@ mod tests { } } + #[test] + fn build_installer_rejects_release_tag() { + let msg = match build_installer(Some("release-2026-05-12-24".into()), None, None) { + Ok(_) => panic!("--release-tag must be rejected under signed manifests"), + Err(e) => e.to_string(), + }; + assert!(msg.contains("--release-tag"), "got: {msg}"); + assert!(msg.contains("signed catalog"), "got: {msg}"); + } + fn manifest_with(tools: Vec<&str>, skills: Vec<&str>) -> HubManifest { HubManifest { version: "1".into(), diff --git a/src/registry/hub_installer.rs b/src/registry/hub_installer.rs index 1cb66219b93..ac392c1a79c 100644 --- a/src/registry/hub_installer.rs +++ b/src/registry/hub_installer.rs @@ -9,11 +9,13 @@ use tokio::sync::Mutex as AsyncMutex; use crate::bootstrap::ironclaw_base_dir; use crate::registry::catalog::RegistryError; use crate::registry::hub_manifest::{ - DEFAULT_HUB_MANIFEST_URL, HubManifest, HubSkillEntry, HubToolEntry, Provenance, + DEFAULT_HUB_MANIFEST_URL, HubManifest, HubSkillEntry, HubToolEntry, MANIFEST_VERIFY_KEYS, + Provenance, verify_signed_manifest, }; use crate::registry::installer::{download_artifact, validate_artifact_url, verify_sha256}; const MAX_MANIFEST_BYTES: usize = 1024 * 1024; +const MAX_SIGNED_MANIFEST_BYTES: usize = MAX_MANIFEST_BYTES * 2; const MAX_METADATA_BYTES: usize = 1024 * 1024; const MAX_WASM_BYTES: usize = 16 * 1024 * 1024; @@ -223,7 +225,26 @@ impl HubInstaller { pub async fn fetch_manifest(&self) -> Result { validate_artifact_url("hub-manifest", "manifest_url", &self.manifest_url)?; - let bytes = download_artifact(&self.manifest_url, MAX_MANIFEST_BYTES as u64).await?; + let envelope = + download_artifact(&self.manifest_url, MAX_SIGNED_MANIFEST_BYTES as u64).await?; + if envelope.len() > MAX_SIGNED_MANIFEST_BYTES { + return Err(RegistryError::DownloadFailed { + url: self.manifest_url.clone(), + reason: format!( + "signed manifest exceeds {} byte cap (got {})", + MAX_SIGNED_MANIFEST_BYTES, + envelope.len() + ), + }); + } + + let bytes = verify_signed_manifest(&envelope, MANIFEST_VERIFY_KEYS).map_err(|reason| { + RegistryError::InvalidManifest { + name: "hub-manifest".to_string(), + field: "signature", + reason, + } + })?; if bytes.len() > MAX_MANIFEST_BYTES { return Err(RegistryError::DownloadFailed { url: self.manifest_url.clone(), diff --git a/src/registry/hub_manifest.rs b/src/registry/hub_manifest.rs index 6ee5c075c10..55b23b65d35 100644 --- a/src/registry/hub_manifest.rs +++ b/src/registry/hub_manifest.rs @@ -1,7 +1,72 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ed25519_dalek::{Signature, VerifyingKey}; use serde::{Deserialize, Serialize}; pub const DEFAULT_HUB_MANIFEST_URL: &str = "https://hub.ironclaw.com/api/catalog/manifest.json"; +// Ed25519 PUBLIC keys trusted to verify the catalog manifest signature, by key_id. +// PUBLIC KEYS ONLY. The private signing key lives off the catalog host (IronHub +// IRONHUB_MANIFEST_SIGNING_KEY) and must never appear in this repo or binary. +// Embedding the public half is deliberate: changing which key the agent trusts +// requires changing this source, not compromising the catalog host. +pub const MANIFEST_VERIFY_KEYS: &[(&str, &str)] = &[( + "5895a21abea89672", + "f64d2d3a3228b16ca59450364d26b278071a1a425544f242504033341d8459bd", +)]; + +#[derive(Debug, Deserialize)] +struct SignedManifestEnvelope { + v: u8, + key_id: String, + manifest_b64: String, + sig: String, +} + +fn verifying_key_from_hex(hex: &str) -> Result { + if hex.len() != 64 { + return Err("verify key must be 64 hex chars".to_string()); + } + let mut raw = [0u8; 32]; + for (i, byte) in raw.iter_mut().enumerate() { + *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16) + .map_err(|_| "verify key is not valid hex".to_string())?; + } + VerifyingKey::from_bytes(&raw).map_err(|e| format!("invalid ed25519 verify key: {e}")) +} + +/// Verifies a signed-manifest envelope and returns the exact inner manifest bytes +/// the signature covers. Fail-closed: any decode, key-lookup, or signature failure +/// returns Err and never yields partial bytes. +pub fn verify_signed_manifest( + envelope_bytes: &[u8], + keys: &[(&str, &str)], +) -> Result, String> { + let env: SignedManifestEnvelope = serde_json::from_slice(envelope_bytes) + .map_err(|e| format!("signed-manifest envelope parse failed: {e}"))?; + if env.v != 1 { + return Err(format!("unsupported signed-manifest version {}", env.v)); + } + let key_hex = keys + .iter() + .find(|(id, _)| *id == env.key_id) + .map(|(_, hex)| *hex) + .ok_or_else(|| format!("unknown manifest signing key_id '{}'", env.key_id))?; + let verifying_key = verifying_key_from_hex(key_hex)?; + let manifest_bytes = URL_SAFE_NO_PAD + .decode(env.manifest_b64.as_bytes()) + .map_err(|e| format!("manifest_b64 decode failed: {e}"))?; + let sig_bytes = URL_SAFE_NO_PAD + .decode(env.sig.as_bytes()) + .map_err(|e| format!("signature decode failed: {e}"))?; + let signature = + Signature::from_slice(&sig_bytes).map_err(|e| format!("signature malformed: {e}"))?; + verifying_key + .verify_strict(&manifest_bytes, &signature) + .map_err(|_| "manifest signature verification failed".to_string())?; + Ok(manifest_bytes) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Provenance { @@ -98,6 +163,65 @@ pub struct HubArtifact { mod tests { use super::*; + // Shared cross-language vector: a fixed manifest signed with a throwaway test + // Ed25519 key. The same (pubkey, manifest_b64, sig) tuple is asserted in the + // IronHub signer's tests. All public; the test private key was discarded. + const VEC_KEY_ID: &str = "test-vector"; + const VEC_PUBKEY_HEX: &str = "ca46572f4dcd485599cdf95442934a3e3c86e2cae766a85fbffc8d6540959928"; + const VEC_MANIFEST_B64: &str = "eyJ2ZXJzaW9uIjoiMSIsImdlbmVyYXRlZF9hdCI6IjIwMjYtMDEtMDFUMDA6MDA6MDBaIiwicmVsZWFzZV90YWciOiJ0ZXN0IiwicmVwbyI6Im5lYXJhaS9pcm9uaHViIiwidG9vbHMiOltdLCJza2lsbHMiOltdfQ"; + const VEC_SIG: &str = + "KjsUDgi1enj3iTPNQI6gU1Bwxf01hIUItlFvX9PxgWNybPPrJNIV7vFG-G8hJOalFMwFs5zQHrxbtFDZAlgtBg"; + const VEC_MANIFEST_BYTES: &str = r#"{"version":"1","generated_at":"2026-01-01T00:00:00Z","release_tag":"test","repo":"nearai/ironhub","tools":[],"skills":[]}"#; + + fn vec_keys() -> Vec<(&'static str, &'static str)> { + vec![(VEC_KEY_ID, VEC_PUBKEY_HEX)] + } + + fn vec_envelope(manifest_b64: &str, sig: &str) -> String { + format!(r#"{{"v":1,"key_id":"test-vector","manifest_b64":"{manifest_b64}","sig":"{sig}"}}"#) + } + + #[test] + fn verify_signed_manifest_accepts_valid_vector() { + let env = vec_envelope(VEC_MANIFEST_B64, VEC_SIG); + let bytes = + verify_signed_manifest(env.as_bytes(), &vec_keys()).expect("valid vector must verify"); + assert_eq!(bytes, VEC_MANIFEST_BYTES.as_bytes()); + } + + #[test] + fn verify_signed_manifest_rejects_tampered_manifest() { + let tampered = URL_SAFE_NO_PAD.encode(br#"{"version":"1","tools":[{"name":"evil"}]}"#); + let env = vec_envelope(&tampered, VEC_SIG); + assert!(verify_signed_manifest(env.as_bytes(), &vec_keys()).is_err()); + } + + #[test] + fn verify_signed_manifest_rejects_wrong_key() { + let wrong = vec![(VEC_KEY_ID, MANIFEST_VERIFY_KEYS[0].1)]; + let env = vec_envelope(VEC_MANIFEST_B64, VEC_SIG); + assert!(verify_signed_manifest(env.as_bytes(), &wrong).is_err()); + } + + #[test] + fn verify_signed_manifest_rejects_unknown_key_id() { + let env = format!( + r#"{{"v":1,"key_id":"nope","manifest_b64":"{VEC_MANIFEST_B64}","sig":"{VEC_SIG}"}}"# + ); + assert!(verify_signed_manifest(env.as_bytes(), &vec_keys()).is_err()); + } + + #[test] + fn embedded_manifest_verify_keys_are_valid_public_keys() { + for (key_id, hex) in MANIFEST_VERIFY_KEYS { + assert_eq!(hex.len(), 64, "key {key_id} must be 64 hex chars"); + assert!( + verifying_key_from_hex(hex).is_ok(), + "embedded key {key_id} must decode to a valid ed25519 public key" + ); + } + } + const SAMPLE_MANIFEST: &str = r#"{ "version": "1", "generated_at": "2026-05-12T23:43:46Z", From 1868325ae7f4d19c2895d189887de0a7cb984f2f Mon Sep 17 00:00:00 2001 From: neo-sky Date: Wed, 3 Jun 2026 12:41:03 -0400 Subject: [PATCH 12/13] docs(ironhub): document manifest key rotation + skill-registry lock rationale 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] --- src/registry/hub_manifest.rs | 12 ++++++++++++ src/tools/builtin/ironhub.rs | 3 +++ 2 files changed, 15 insertions(+) diff --git a/src/registry/hub_manifest.rs b/src/registry/hub_manifest.rs index 55b23b65d35..c1f79505b1e 100644 --- a/src/registry/hub_manifest.rs +++ b/src/registry/hub_manifest.rs @@ -10,6 +10,18 @@ pub const DEFAULT_HUB_MANIFEST_URL: &str = "https://hub.ironclaw.com/api/catalog // IRONHUB_MANIFEST_SIGNING_KEY) and must never appear in this repo or binary. // Embedding the public half is deliberate: changing which key the agent trusts // requires changing this source, not compromising the catalog host. +// +// Rotation procedure (when rolling the signing keypair): +// 1. Generate the new keypair off-host. Put the new public key here as a +// second entry alongside the existing one; ship a release. Both old and +// new manifests verify during the rollout window. +// 2. Deploy the new private key to IronHub so it signs with the new key_id. +// `verify_signed_manifest` selects the entry by `key_id`, so signing +// switches over atomically without breaking agents still on the old +// release. +// 3. After agents have updated past step 1, ship a follow-up release that +// removes the old entry from this slice. Older agents continue to +// verify the older signed manifests until they update. pub const MANIFEST_VERIFY_KEYS: &[(&str, &str)] = &[( "5895a21abea89672", "f64d2d3a3228b16ca59450364d26b278071a1a425544f242504033341d8459bd", diff --git a/src/tools/builtin/ironhub.rs b/src/tools/builtin/ironhub.rs index 2d3d40c90b1..93b3132bbd2 100644 --- a/src/tools/builtin/ironhub.rs +++ b/src/tools/builtin/ironhub.rs @@ -420,6 +420,9 @@ impl IronhubInstallTool { .await .map_err(|e| install_error_to_tool_error(&parsed.name, e))?; let mut json = install_outcome_to_json(kind, &outcome); + // std::sync::RwLock matches the rest of the repo. spawn_blocking with + // a fresh current-thread runtime avoids deadlock under outer-runtime + // saturation; poison recovery keeps a prior writer panic from sticking. if let Some(reg) = &self.deps.skill_registry { let reg = Arc::clone(reg); let loaded = tokio::task::spawn_blocking( From b3ed5099896dec95fd8e3155c4cb2f6c79ef9d07 Mon Sep 17 00:00:00 2001 From: neo-sky Date: Wed, 3 Jun 2026 13:46:14 -0400 Subject: [PATCH 13/13] fix(ironhub): channel-boundary error leak + payload delimiter 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. --- src/channels/web/handlers/ironhub.rs | 118 +++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 9 deletions(-) diff --git a/src/channels/web/handlers/ironhub.rs b/src/channels/web/handlers/ironhub.rs index 493e1efb522..6cb965e9e9e 100644 --- a/src/channels/web/handlers/ironhub.rs +++ b/src/channels/web/handlers/ironhub.rs @@ -146,6 +146,15 @@ fn catalog_rate_limit( } } +/// Map an internal error to a generic INTERNAL_SERVER_ERROR response while +/// logging the underlying detail server-side. Per the channel-boundary rule +/// in `.claude/rules/error-handling.md`, raw error strings from `SecretsStore`, +/// `hmac`, or any other internal source must not cross to the user. +fn internal_err(context: &'static str, err: impl std::fmt::Display) -> (StatusCode, String) { + tracing::error!(error = %err, "{context}"); + (StatusCode::INTERNAL_SERVER_ERROR, context.to_string()) +} + pub async fn ironhub_install_handler( State(state): State>, AdminUser(user): AdminUser, @@ -173,7 +182,9 @@ pub async fn ironhub_install_handler( "no signing key configured on this agent".to_string(), )); } - Err(SignedInstallError::Internal(e)) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e)), + Err(SignedInstallError::Internal(e)) => { + return Err(internal_err("install request failed", e)); + } } // verify-intent is a repeatable preview, so the one-shot nonce is only burned @@ -315,6 +326,22 @@ fn hmac_hex(shared_key: &str, msg: &str) -> Result { Ok(hex::encode(mac.finalize().into_bytes())) } +/// Reject any payload field that contains the canonical ':' delimiter used by +/// `install_payload` and `register_payload`. Without escaping, two distinct +/// field tuples could canonical-serialize to the same HMAC input, so the +/// verifier rejects colons in any field. `slug` is independently validated by +/// `validate_hub_name`, which already excludes ':'. +fn assert_no_delimiter(fields: &[(&'static str, &str)]) -> Result<(), String> { + for (name, value) in fields { + if value.contains(':') { + return Err(format!( + "field '{name}' contains ':' delimiter; ambiguous canonical payload" + )); + } + } + Ok(()) +} + fn install_payload( slug: &str, version: &str, @@ -357,6 +384,15 @@ async fn verify_signed_install( return Err(SignedInstallError::Rejected(format!("invalid slug: {e}"))); } + assert_no_delimiter(&[ + ("version", signed.version), + ("uid", signed.uid), + ("aid", signed.aid), + ("nonce", signed.nonce), + ("artifact_digest", signed.artifact_digest), + ]) + .map_err(SignedInstallError::Rejected)?; + let drift = now_unix().abs_diff(signed.ts); if drift > VERIFY_INTENT_WINDOW_SECS { return Err(SignedInstallError::Rejected(format!( @@ -409,12 +445,12 @@ pub async fn ironhub_signing_key_set_handler( CreateSecretParams::new(IRONHUB_SIGNING_KEY_NAME, shared_key), ) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + .map_err(|e| internal_err("signing-key set failed", e))?; let stored = store .get_decrypted(&user.user_id, IRONHUB_SIGNING_KEY_NAME) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + .map_err(|e| internal_err("signing-key set failed", e))?; Ok(Json(IronhubSigningKeyMetadata { fingerprint: fingerprint(stored.expose()), @@ -432,12 +468,12 @@ pub async fn ironhub_signing_key_get_handler( Err(SecretError::NotFound(_)) => { return Err((StatusCode::NOT_FOUND, "no signing key set".to_string())); } - Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), + Err(e) => return Err(internal_err("signing-key read failed", e)), }; let decrypted = store .get_decrypted(&user.user_id, IRONHUB_SIGNING_KEY_NAME) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + .map_err(|e| internal_err("signing-key read failed", e))?; Ok(Json(IronhubSigningKeyMetadata { fingerprint: fingerprint(decrypted.expose()), created_at: meta.created_at.to_rfc3339(), @@ -452,7 +488,7 @@ pub async fn ironhub_signing_key_delete_handler( let removed = store .delete(&user.user_id, IRONHUB_SIGNING_KEY_NAME) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + .map_err(|e| internal_err("signing-key delete failed", e))?; if removed { Ok(StatusCode::NO_CONTENT) } else { @@ -490,7 +526,9 @@ pub async fn ironhub_verify_intent_handler( valid: false, reason: Some("no signing key configured on this agent".to_string()), })), - Err(SignedInstallError::Internal(e)) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)), + Err(SignedInstallError::Internal(e)) => { + Err(internal_err("install-intent verification failed", e)) + } } } @@ -508,6 +546,12 @@ pub async fn ironhub_register_handler( )); } + if let Err(reason) = + assert_no_delimiter(&[("uid", &req.uid), ("aid", &req.aid), ("nonce", &req.nonce)]) + { + return Err((StatusCode::BAD_REQUEST, reason)); + } + let store = secrets_store_or_503(&state)?; let decrypted = match store .get_decrypted(&state.owner_id, IRONHUB_SIGNING_KEY_NAME) @@ -520,12 +564,12 @@ pub async fn ironhub_register_handler( "no signing key configured on this agent".to_string(), )); } - Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), + Err(e) => return Err(internal_err("register request failed", e)), }; let payload = register_payload(&req.uid, &req.aid, req.ts, &req.nonce); let expected = hmac_hex(decrypted.expose(), &payload) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + .map_err(|e| internal_err("register request failed", e))?; use subtle::ConstantTimeEq; let supplied = req.sig.as_bytes(); @@ -1303,6 +1347,62 @@ mod tests { ); } + #[test] + fn assert_no_delimiter_rejects_colon_in_any_field() { + assert!(assert_no_delimiter(&[("version", "1.0.0")]).is_ok()); + assert!(assert_no_delimiter(&[("uid", "user-1")]).is_ok()); + assert!(assert_no_delimiter(&[("nonce", "n1"), ("aid", "a1")]).is_ok()); + let err = assert_no_delimiter(&[("version", "1.0:malicious")]) + .expect_err("colon in version must be rejected"); + assert!( + err.contains("version") && err.contains("':' delimiter"), + "expected named-field delimiter message, got: {err}" + ); + } + + #[test] + fn internal_err_returns_generic_message_without_inner_detail() { + let (status, body) = internal_err("widget failed", "secret /path/internal/file"); + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body, "widget failed"); + assert!( + !body.contains("secret") && !body.contains("/path"), + "internal detail must not cross the channel boundary: {body}" + ); + } + + #[tokio::test] + async fn verify_signed_install_rejects_version_with_colon() { + let store = crate::channels::web::test_helpers::test_secrets_store(); + let signed = SignedInstall { + slug: "clickup", + version: "1.0:malicious", + uid: "u1", + aid: "a1", + ts: now_unix(), + nonce: "n1", + sig: "deadbeef", + artifact_digest: "deadbeef", + }; + let err = verify_signed_install(store.as_ref(), "test-user", &signed) + .await + .expect_err("colon in version must be rejected"); + match err { + SignedInstallError::Rejected(msg) => { + assert!( + msg.contains("version") && msg.contains("delimiter"), + "expected delimiter rejection through verify_signed_install, got: {msg}" + ); + } + SignedInstallError::NoSigningKey => { + panic!("delimiter check must reject before the store is touched") + } + SignedInstallError::Internal(msg) => { + panic!("expected Rejected, got Internal({msg})") + } + } + } + #[test] fn install_hmac_is_deterministic_and_payload_sensitive() { let key_a = "ihub_sk_aaaaaaaaaaaaaaaaaaaaaaaa";