mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +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
|
|
@ -1060,20 +1060,124 @@ install_deps() {
|
|||
fi
|
||||
|
||||
# Install the main package in editable mode with all extras.
|
||||
# Try [all] first, fall back to base install if extras have issues.
|
||||
ALL_INSTALL_LOG=$(mktemp)
|
||||
if ! $UV_CMD pip install -e ".[all]" 2>"$ALL_INSTALL_LOG"; then
|
||||
log_warn "Full install (.[all]) failed, trying base install..."
|
||||
log_info "Reason: $(tail -5 "$ALL_INSTALL_LOG" | head -3)"
|
||||
rm -f "$ALL_INSTALL_LOG"
|
||||
if ! $UV_CMD pip install -e "."; then
|
||||
log_error "Package installation failed."
|
||||
log_info "Check that build tools are installed: sudo apt install build-essential python3-dev"
|
||||
log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'"
|
||||
exit 1
|
||||
#
|
||||
# Hash-verified install (Tier 0) — when uv.lock is present, prefer
|
||||
# `uv sync --locked`. The lockfile records SHA256 hashes for every
|
||||
# transitive, so a compromised transitive (different hash than what
|
||||
# we shipped) is REJECTED by the resolver. This is the *only* path
|
||||
# that protects against the "direct dep is fine, but the dep's dep
|
||||
# got worm-poisoned overnight" failure mode. All `uv pip install`
|
||||
# tiers below re-resolve transitives fresh from PyPI without any
|
||||
# hash verification — they exist to keep installs working when the
|
||||
# lockfile is stale, missing, or out-of-sync with the current
|
||||
# extras spec, NOT because they're equivalent in posture.
|
||||
if [ -f "uv.lock" ]; then
|
||||
log_info "Trying tier: hash-verified (uv.lock) ..."
|
||||
if UV_PROJECT_ENVIRONMENT="$INSTALL_DIR/venv" $UV_CMD sync --all-extras --locked 2>"$(mktemp)"; then
|
||||
log_success "Main package installed (hash-verified via uv.lock)"
|
||||
log_success "All dependencies installed"
|
||||
return 0
|
||||
fi
|
||||
log_warn "uv.lock sync failed (lockfile may be stale), falling back to PyPI resolve..."
|
||||
else
|
||||
rm -f "$ALL_INSTALL_LOG"
|
||||
log_info "uv.lock not found — falling back to PyPI resolve (no hash verification)"
|
||||
fi
|
||||
|
||||
# Multi-tier fallback. The point of the tiers is that ONE compromised
|
||||
# PyPI package (a worm-poisoned release that gets quarantined, like
|
||||
# mistralai 2.4.6 in May 2026) shouldn't be able to silently demote a
|
||||
# fresh install all the way down to "core only" — the user should keep
|
||||
# everything else they signed up for.
|
||||
#
|
||||
# Tier 1: [all] — everything, including RL git+https deps (best case).
|
||||
# Tier 2: [all] minus the currently-broken extras list. Edit
|
||||
# _BROKEN_EXTRAS below when something on PyPI breaks; this lets
|
||||
# users keep voice/honcho/google/slack/matrix/etc. even when
|
||||
# one transitive is unavailable. List the extras here as bare
|
||||
# names from pyproject.toml [project.optional-dependencies] —
|
||||
# the script translates them to `[a,b,c]` form below.
|
||||
# Tier 3: PyPI-only extras (no git deps) — drops [rl] / [yc-bench]
|
||||
# which are git+https and may fail in restricted networks.
|
||||
# Tier 4: dashboard + core platforms — minimum viable interactive set.
|
||||
# Tier 5: bare `.` — last-resort so at least the core CLI launches.
|
||||
#
|
||||
# Each tier's stderr is captured to a tempfile so we can show the user
|
||||
# WHY the higher tier failed instead of silently dropping support.
|
||||
local _BROKEN_EXTRAS=() # populate when an extra becomes unresolvable
|
||||
local _ALL_EXTRAS=(
|
||||
modal daytona vercel messaging matrix cron cli dev tts-premium slack
|
||||
pty honcho mcp homeassistant sms acp voice dingtalk feishu google
|
||||
bedrock web youtube
|
||||
)
|
||||
# Tier 2: all extras minus _BROKEN_EXTRAS
|
||||
local _SAFE_EXTRAS=()
|
||||
local _e _b _skip
|
||||
for _e in "${_ALL_EXTRAS[@]}"; do
|
||||
_skip=false
|
||||
for _b in "${_BROKEN_EXTRAS[@]}"; do
|
||||
if [ "$_e" = "$_b" ]; then _skip=true; break; fi
|
||||
done
|
||||
if [ "$_skip" = false ]; then _SAFE_EXTRAS+=("$_e"); fi
|
||||
done
|
||||
local _SAFE_SPEC
|
||||
_SAFE_SPEC=".[$(IFS=,; echo "${_SAFE_EXTRAS[*]}")]"
|
||||
# Tier 3: PyPI-only extras (no git deps), still skipping broken ones.
|
||||
# Mirrors the install.ps1 list but excludes [rl] / [yc-bench] / [matrix]
|
||||
# (matrix needs python-olm which fails to build on some hosts).
|
||||
local _PYPI_EXTRAS=(
|
||||
web mcp cron cli voice messaging slack dev acp pty homeassistant sms
|
||||
tts-premium honcho google bedrock dingtalk feishu modal daytona vercel
|
||||
youtube
|
||||
)
|
||||
local _PYPI_SAFE=()
|
||||
for _e in "${_PYPI_EXTRAS[@]}"; do
|
||||
_skip=false
|
||||
for _b in "${_BROKEN_EXTRAS[@]}"; do
|
||||
if [ "$_e" = "$_b" ]; then _skip=true; break; fi
|
||||
done
|
||||
if [ "$_skip" = false ]; then _PYPI_SAFE+=("$_e"); fi
|
||||
done
|
||||
local _PYPI_SPEC
|
||||
_PYPI_SPEC=".[$(IFS=,; echo "${_PYPI_SAFE[*]}")]"
|
||||
local _TIER4_SPEC=".[web,mcp,cron,cli,messaging,dev]"
|
||||
|
||||
ALL_INSTALL_LOG=$(mktemp)
|
||||
local _installed=false
|
||||
local _tier_name=""
|
||||
|
||||
install_tier() {
|
||||
local name="$1"; local spec="$2"
|
||||
log_info "Trying tier: $name ..."
|
||||
if $UV_CMD pip install -e "$spec" 2>"$ALL_INSTALL_LOG"; then
|
||||
log_success "Main package installed ($name)"
|
||||
_installed=true
|
||||
_tier_name="$name"
|
||||
return 0
|
||||
fi
|
||||
log_warn "Tier '$name' failed. Top of pip output:"
|
||||
head -5 "$ALL_INSTALL_LOG" | sed 's/^/ /' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
install_tier "all (with RL/matrix extras)" ".[all]" \
|
||||
|| install_tier "all minus known-broken (${_BROKEN_EXTRAS[*]:-none})" "$_SAFE_SPEC" \
|
||||
|| install_tier "PyPI-only extras (no git deps)" "$_PYPI_SPEC" \
|
||||
|| install_tier "dashboard + core platforms" "$_TIER4_SPEC" \
|
||||
|| install_tier "core only (no extras)" "."
|
||||
|
||||
rm -f "$ALL_INSTALL_LOG"
|
||||
|
||||
if [ "$_installed" = false ]; then
|
||||
log_error "Package installation failed even with no extras."
|
||||
log_info "Check that build tools are installed: sudo apt install build-essential python3-dev"
|
||||
log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$_tier_name" != "all (with RL/matrix extras)" ]; then
|
||||
log_warn "Note: installed via fallback tier ($_tier_name)."
|
||||
log_info "Some optional features may be missing. After resolving any"
|
||||
log_info "PyPI/network issue, re-run: $UV_CMD pip install -e '.[all]'"
|
||||
fi
|
||||
|
||||
log_success "Main package installed"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue