2026-01-31 21:42:15 -08:00
|
|
|
#!/bin/bash
|
2026-02-02 19:01:51 -08:00
|
|
|
# ============================================================================
|
2026-01-31 21:42:15 -08:00
|
|
|
# Hermes Agent Setup Script
|
2026-02-02 19:01:51 -08:00
|
|
|
# ============================================================================
|
|
|
|
|
# Quick setup for developers who cloned the repo manually.
|
2026-04-09 10:41:58 +02:00
|
|
|
# Uses uv for desktop/server setup and Python's stdlib venv + pip on Termux.
|
2026-02-02 19:01:51 -08:00
|
|
|
#
|
|
|
|
|
# Usage:
|
|
|
|
|
# ./setup-hermes.sh
|
|
|
|
|
#
|
|
|
|
|
# This script:
|
2026-04-09 10:41:58 +02:00
|
|
|
# 1. Detects desktop/server vs Android/Termux setup path
|
|
|
|
|
# 2. Creates a Python 3.11 virtual environment
|
|
|
|
|
# 3. Installs the appropriate dependency set for the platform
|
2026-02-07 23:54:53 +00:00
|
|
|
# 4. Creates .env from template (if not exists)
|
2026-04-09 10:41:58 +02:00
|
|
|
# 5. Symlinks the 'hermes' CLI command into a user-facing bin dir
|
2026-02-07 23:54:53 +00:00
|
|
|
# 6. Runs the setup wizard (optional)
|
2026-02-02 19:01:51 -08:00
|
|
|
# ============================================================================
|
2026-01-31 21:42:15 -08:00
|
|
|
|
|
|
|
|
set -e
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
# Colors
|
|
|
|
|
GREEN='\033[0;32m'
|
|
|
|
|
YELLOW='\033[0;33m'
|
|
|
|
|
CYAN='\033[0;36m'
|
2026-02-07 23:54:53 +00:00
|
|
|
RED='\033[0;31m'
|
2026-02-02 19:01:51 -08:00
|
|
|
NC='\033[0m'
|
2026-01-31 21:42:15 -08:00
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
|
cd "$SCRIPT_DIR"
|
2026-01-31 21:42:15 -08:00
|
|
|
|
2026-05-07 21:30:52 +08:00
|
|
|
# Prevent uv from discovering config files (uv.toml, pyproject.toml) from the
|
|
|
|
|
# wrong user's home directory when running under sudo -u <user>. See #21269.
|
|
|
|
|
export UV_NO_CONFIG=1
|
|
|
|
|
|
2026-02-07 23:54:53 +00:00
|
|
|
PYTHON_VERSION="3.11"
|
|
|
|
|
|
2026-04-09 10:41:58 +02:00
|
|
|
is_termux() {
|
|
|
|
|
[ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get_command_link_dir() {
|
|
|
|
|
if is_termux && [ -n "${PREFIX:-}" ]; then
|
|
|
|
|
echo "$PREFIX/bin"
|
|
|
|
|
else
|
|
|
|
|
echo "$HOME/.local/bin"
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get_command_link_display_dir() {
|
|
|
|
|
if is_termux && [ -n "${PREFIX:-}" ]; then
|
|
|
|
|
echo '$PREFIX/bin'
|
|
|
|
|
else
|
|
|
|
|
echo '~/.local/bin'
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
echo ""
|
2026-02-20 21:25:04 -08:00
|
|
|
echo -e "${CYAN}⚕ Hermes Agent Setup${NC}"
|
2026-01-31 21:42:15 -08:00
|
|
|
echo ""
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
# ============================================================================
|
2026-02-07 23:54:53 +00:00
|
|
|
# Install / locate uv
|
2026-02-02 19:01:51 -08:00
|
|
|
# ============================================================================
|
|
|
|
|
|
2026-02-07 23:54:53 +00:00
|
|
|
echo -e "${CYAN}→${NC} Checking for uv..."
|
|
|
|
|
|
|
|
|
|
UV_CMD=""
|
2026-04-09 10:41:58 +02:00
|
|
|
if is_termux; then
|
|
|
|
|
echo -e "${CYAN}→${NC} Termux detected — using Python's stdlib venv + pip instead of uv"
|
2026-02-07 23:54:53 +00:00
|
|
|
else
|
2026-04-09 10:41:58 +02:00
|
|
|
if command -v uv &> /dev/null; then
|
|
|
|
|
UV_CMD="uv"
|
|
|
|
|
elif [ -x "$HOME/.local/bin/uv" ]; then
|
|
|
|
|
UV_CMD="$HOME/.local/bin/uv"
|
|
|
|
|
elif [ -x "$HOME/.cargo/bin/uv" ]; then
|
|
|
|
|
UV_CMD="$HOME/.cargo/bin/uv"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [ -n "$UV_CMD" ]; then
|
|
|
|
|
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
|
|
|
|
echo -e "${GREEN}✓${NC} uv found ($UV_VERSION)"
|
|
|
|
|
else
|
|
|
|
|
echo -e "${CYAN}→${NC} Installing uv..."
|
fix(install): surface uv install + uv.lock sync errors instead of silently hanging (#24504)
The c1eb2dcda tiered installer made two install paths look frozen on
slow networks or broken environments because both swallowed the
underlying tool's stderr.
scripts/install.sh, setup-hermes.sh:
curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null
printed only '✗ Failed to install uv' on failure with no diagnostic.
Common real causes (glibc mismatch on old distros, corp proxy / TLS
interception, missing curl, ~/.local/bin not writable, disk full)
were invisible. Also: piping curl into sh masks curl failures under
set -e (no pipefail) — sh exits 0 on empty stdin, so a network error
succeeded silently.
Fix: download installer to a tempfile first, then run it. Capture
curl + installer output to a log; on failure, indent and print it.
scripts/install.sh hash-verified tier:
uv sync --all-extras --locked 2>"$(mktemp)" silenced uv's progress
output, making a fresh-venv install (~50 transitives including
torch-class deps) look hung for 1-5 minutes — users see 'Trying tier:
hash-verified (uv.lock) ...' and assume it's frozen. The mktemp
substitution also wasn't saved to a variable, so the uv error on
failure was unreachable.
Fix: stream uv's stderr directly so users see live 'Resolved N /
Prepared / Installed' progress. Print an upfront note that the first
run takes 1-5 minutes.
2026-05-12 12:11:16 -07:00
|
|
|
# Capture installer output so a failure shows the user WHY
|
|
|
|
|
# (network, glibc mismatch on old distros, missing curl, disk
|
|
|
|
|
# full, etc.) instead of "✗ Failed to install uv" with zero
|
|
|
|
|
# diagnostic. Two-stage to avoid `curl | sh` masking curl
|
|
|
|
|
# failures (sh exits 0 on empty stdin under no pipefail).
|
|
|
|
|
_uv_log="$(mktemp 2>/dev/null || echo "/tmp/hermes-uv-install.$$.log")"
|
|
|
|
|
_uv_installer="$(mktemp 2>/dev/null || echo "/tmp/hermes-uv-installer.$$.sh")"
|
|
|
|
|
if ! curl -LsSf https://astral.sh/uv/install.sh -o "$_uv_installer" 2>"$_uv_log"; then
|
|
|
|
|
echo -e "${RED}✗${NC} Failed to download uv installer."
|
|
|
|
|
sed 's/^/ /' "$_uv_log" >&2
|
|
|
|
|
echo -e "${CYAN}→${NC} Install manually: https://docs.astral.sh/uv/"
|
|
|
|
|
rm -f "$_uv_log" "$_uv_installer"
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
if sh "$_uv_installer" >>"$_uv_log" 2>&1; then
|
|
|
|
|
rm -f "$_uv_installer"
|
2026-04-09 10:41:58 +02:00
|
|
|
if [ -x "$HOME/.local/bin/uv" ]; then
|
|
|
|
|
UV_CMD="$HOME/.local/bin/uv"
|
|
|
|
|
elif [ -x "$HOME/.cargo/bin/uv" ]; then
|
|
|
|
|
UV_CMD="$HOME/.cargo/bin/uv"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [ -n "$UV_CMD" ]; then
|
fix(install): surface uv install + uv.lock sync errors instead of silently hanging (#24504)
The c1eb2dcda tiered installer made two install paths look frozen on
slow networks or broken environments because both swallowed the
underlying tool's stderr.
scripts/install.sh, setup-hermes.sh:
curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null
printed only '✗ Failed to install uv' on failure with no diagnostic.
Common real causes (glibc mismatch on old distros, corp proxy / TLS
interception, missing curl, ~/.local/bin not writable, disk full)
were invisible. Also: piping curl into sh masks curl failures under
set -e (no pipefail) — sh exits 0 on empty stdin, so a network error
succeeded silently.
Fix: download installer to a tempfile first, then run it. Capture
curl + installer output to a log; on failure, indent and print it.
scripts/install.sh hash-verified tier:
uv sync --all-extras --locked 2>"$(mktemp)" silenced uv's progress
output, making a fresh-venv install (~50 transitives including
torch-class deps) look hung for 1-5 minutes — users see 'Trying tier:
hash-verified (uv.lock) ...' and assume it's frozen. The mktemp
substitution also wasn't saved to a variable, so the uv error on
failure was unreachable.
Fix: stream uv's stderr directly so users see live 'Resolved N /
Prepared / Installed' progress. Print an upfront note that the first
run takes 1-5 minutes.
2026-05-12 12:11:16 -07:00
|
|
|
rm -f "$_uv_log"
|
2026-04-09 10:41:58 +02:00
|
|
|
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
|
|
|
|
echo -e "${GREEN}✓${NC} uv installed ($UV_VERSION)"
|
|
|
|
|
else
|
fix(install): surface uv install + uv.lock sync errors instead of silently hanging (#24504)
The c1eb2dcda tiered installer made two install paths look frozen on
slow networks or broken environments because both swallowed the
underlying tool's stderr.
scripts/install.sh, setup-hermes.sh:
curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null
printed only '✗ Failed to install uv' on failure with no diagnostic.
Common real causes (glibc mismatch on old distros, corp proxy / TLS
interception, missing curl, ~/.local/bin not writable, disk full)
were invisible. Also: piping curl into sh masks curl failures under
set -e (no pipefail) — sh exits 0 on empty stdin, so a network error
succeeded silently.
Fix: download installer to a tempfile first, then run it. Capture
curl + installer output to a log; on failure, indent and print it.
scripts/install.sh hash-verified tier:
uv sync --all-extras --locked 2>"$(mktemp)" silenced uv's progress
output, making a fresh-venv install (~50 transitives including
torch-class deps) look hung for 1-5 minutes — users see 'Trying tier:
hash-verified (uv.lock) ...' and assume it's frozen. The mktemp
substitution also wasn't saved to a variable, so the uv error on
failure was unreachable.
Fix: stream uv's stderr directly so users see live 'Resolved N /
Prepared / Installed' progress. Print an upfront note that the first
run takes 1-5 minutes.
2026-05-12 12:11:16 -07:00
|
|
|
echo -e "${RED}✗${NC} uv installer reported success but binary not found. Add ~/.local/bin to PATH and retry."
|
|
|
|
|
echo -e "${CYAN}→${NC} Installer output:"
|
|
|
|
|
sed 's/^/ /' "$_uv_log" >&2
|
|
|
|
|
rm -f "$_uv_log"
|
2026-04-09 10:41:58 +02:00
|
|
|
exit 1
|
|
|
|
|
fi
|
2026-02-07 23:54:53 +00:00
|
|
|
else
|
fix(install): surface uv install + uv.lock sync errors instead of silently hanging (#24504)
The c1eb2dcda tiered installer made two install paths look frozen on
slow networks or broken environments because both swallowed the
underlying tool's stderr.
scripts/install.sh, setup-hermes.sh:
curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null
printed only '✗ Failed to install uv' on failure with no diagnostic.
Common real causes (glibc mismatch on old distros, corp proxy / TLS
interception, missing curl, ~/.local/bin not writable, disk full)
were invisible. Also: piping curl into sh masks curl failures under
set -e (no pipefail) — sh exits 0 on empty stdin, so a network error
succeeded silently.
Fix: download installer to a tempfile first, then run it. Capture
curl + installer output to a log; on failure, indent and print it.
scripts/install.sh hash-verified tier:
uv sync --all-extras --locked 2>"$(mktemp)" silenced uv's progress
output, making a fresh-venv install (~50 transitives including
torch-class deps) look hung for 1-5 minutes — users see 'Trying tier:
hash-verified (uv.lock) ...' and assume it's frozen. The mktemp
substitution also wasn't saved to a variable, so the uv error on
failure was unreachable.
Fix: stream uv's stderr directly so users see live 'Resolved N /
Prepared / Installed' progress. Print an upfront note that the first
run takes 1-5 minutes.
2026-05-12 12:11:16 -07:00
|
|
|
echo -e "${RED}✗${NC} Failed to install uv."
|
|
|
|
|
echo -e "${CYAN}→${NC} Installer output:"
|
|
|
|
|
sed 's/^/ /' "$_uv_log" >&2
|
|
|
|
|
echo -e "${CYAN}→${NC} Install manually: https://docs.astral.sh/uv/"
|
|
|
|
|
rm -f "$_uv_log" "$_uv_installer"
|
2026-02-07 23:54:53 +00:00
|
|
|
exit 1
|
2026-02-02 19:01:51 -08:00
|
|
|
fi
|
|
|
|
|
fi
|
2026-01-31 21:42:15 -08:00
|
|
|
fi
|
|
|
|
|
|
2026-02-07 23:54:53 +00:00
|
|
|
# ============================================================================
|
|
|
|
|
# Python check (uv can provision it automatically)
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
echo -e "${CYAN}→${NC} Checking Python $PYTHON_VERSION..."
|
2026-02-02 19:01:51 -08:00
|
|
|
|
2026-04-09 10:41:58 +02:00
|
|
|
if is_termux; then
|
|
|
|
|
if command -v python >/dev/null 2>&1; then
|
|
|
|
|
PYTHON_PATH="$(command -v python)"
|
|
|
|
|
if "$PYTHON_PATH" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then
|
|
|
|
|
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
|
|
|
|
|
echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION found"
|
|
|
|
|
else
|
|
|
|
|
echo -e "${RED}✗${NC} Termux Python must be 3.11+"
|
|
|
|
|
echo " Run: pkg install python"
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
else
|
|
|
|
|
echo -e "${RED}✗${NC} Python not found in Termux"
|
|
|
|
|
echo " Run: pkg install python"
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
2026-02-07 23:54:53 +00:00
|
|
|
else
|
2026-04-09 10:41:58 +02:00
|
|
|
if $UV_CMD python find "$PYTHON_VERSION" &> /dev/null; then
|
|
|
|
|
PYTHON_PATH=$($UV_CMD python find "$PYTHON_VERSION")
|
|
|
|
|
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
|
|
|
|
|
echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION found"
|
|
|
|
|
else
|
|
|
|
|
echo -e "${CYAN}→${NC} Python $PYTHON_VERSION not found, installing via uv..."
|
|
|
|
|
$UV_CMD python install "$PYTHON_VERSION"
|
|
|
|
|
PYTHON_PATH=$($UV_CMD python find "$PYTHON_VERSION")
|
|
|
|
|
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
|
|
|
|
|
echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION installed"
|
|
|
|
|
fi
|
2026-02-07 00:05:04 +00:00
|
|
|
fi
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
# ============================================================================
|
|
|
|
|
# Virtual environment
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
echo -e "${CYAN}→${NC} Setting up virtual environment..."
|
|
|
|
|
|
2026-02-07 23:54:53 +00:00
|
|
|
if [ -d "venv" ]; then
|
|
|
|
|
echo -e "${CYAN}→${NC} Removing old venv..."
|
|
|
|
|
rm -rf venv
|
2026-01-31 21:42:15 -08:00
|
|
|
fi
|
|
|
|
|
|
2026-04-09 10:41:58 +02:00
|
|
|
if is_termux; then
|
|
|
|
|
"$PYTHON_PATH" -m venv venv
|
|
|
|
|
echo -e "${GREEN}✓${NC} venv created with stdlib venv"
|
|
|
|
|
else
|
|
|
|
|
$UV_CMD venv venv --python "$PYTHON_VERSION"
|
|
|
|
|
echo -e "${GREEN}✓${NC} venv created (Python $PYTHON_VERSION)"
|
|
|
|
|
fi
|
2026-02-07 23:54:53 +00:00
|
|
|
|
|
|
|
|
export VIRTUAL_ENV="$SCRIPT_DIR/venv"
|
2026-04-09 10:41:58 +02:00
|
|
|
SETUP_PYTHON="$SCRIPT_DIR/venv/bin/python"
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Dependencies
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
echo -e "${CYAN}→${NC} Installing dependencies..."
|
|
|
|
|
|
2026-04-09 10:41:58 +02:00
|
|
|
if is_termux; then
|
|
|
|
|
export ANDROID_API_LEVEL="$(getprop ro.build.version.sdk 2>/dev/null || printf '%s' "${ANDROID_API_LEVEL:-}")"
|
|
|
|
|
echo -e "${CYAN}→${NC} Termux detected — installing the tested Android bundle"
|
|
|
|
|
"$SETUP_PYTHON" -m pip install --upgrade pip setuptools wheel
|
|
|
|
|
if [ -f "constraints-termux.txt" ]; then
|
|
|
|
|
"$SETUP_PYTHON" -m pip install -e ".[termux]" -c constraints-termux.txt || {
|
|
|
|
|
echo -e "${YELLOW}⚠${NC} Termux bundle install failed, falling back to base install..."
|
|
|
|
|
"$SETUP_PYTHON" -m pip install -e "." -c constraints-termux.txt
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
"$SETUP_PYTHON" -m pip install -e ".[termux]" || "$SETUP_PYTHON" -m pip install -e "."
|
|
|
|
|
fi
|
|
|
|
|
echo -e "${GREEN}✓${NC} Dependencies installed"
|
|
|
|
|
else
|
|
|
|
|
# Prefer uv sync with lockfile (hash-verified installs) when available,
|
|
|
|
|
# fall back to pip install for compatibility or when lockfile is stale.
|
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
2026-05-12 01:02:25 -07:00
|
|
|
#
|
|
|
|
|
# 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 "."
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 10:41:58 +02:00
|
|
|
if [ -f "uv.lock" ]; then
|
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
2026-05-12 01:02:25 -07:00
|
|
|
# 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).
|
2026-04-09 10:41:58 +02:00
|
|
|
echo -e "${CYAN}→${NC} Using uv.lock for hash-verified installation..."
|
fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] (#24515)
* fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all]
Two coupled fixes for the Windows install hang where uv sync built
python-olm from sdist and failed on missing make.
# Root cause: --all-extras vs --extra all (credit: ethernet)
`uv sync --all-extras` installs every key in [project.optional-
dependencies], bypassing the curated [all] extra entirely. So even
when [all] excluded [matrix], [rl], [yc-bench], etc., the installer
pulled them anyway because they were still defined as extras. On
Windows that meant python-olm (no wheel, needs make to build from
sdist) and the install died there.
The right flag is `--extra all` — install just the [all] extra's
contents, respecting curation. Empirically verified via dry-run:
--all-extras: pulls python-olm, mautrix, ctranslate2, onnxruntime,
atroposlib, tinker, wandb, modal, daytona, vercel,
python-telegram-bot, discord.py, slack-bolt,
dingtalk-stream, lark-oapi, anthropic, boto3,
edge-tts, elevenlabs, exa-py, fal-client, faster-
whisper, firecrawl-py, honcho-ai, parallel-web
--extra all: pulls none of those — just [all]'s curated set
Dockerfile already uses `--extra all` (with comment explaining the
gotcha) — knowledge existed; the gap was install.sh / install.ps1 /
setup-hermes.sh.
Sites fixed: scripts/install.sh L1118, scripts/install.ps1 L809,
setup-hermes.sh L245.
# Companion fix: drop lazy-covered extras from [all]
`tools/lazy_deps.py` already covers anthropic, bedrock, exa,
firecrawl, parallel-web, fal, edge-tts, elevenlabs, modal, daytona,
vercel, all messaging platforms (telegram/discord/slack/matrix/
dingtalk/feishu), honcho, and faster-whisper. They were ALSO in
[all], which defeats the whole point of lazy-install — fresh
installs eager-pulled them and inherited whatever was broken
upstream (the matrix → python-olm → no Windows wheel chain being
the proximate symptom).
[all] now contains only what genuinely can't be lazy-installed:
cron, cli, dev, pty, mcp, homeassistant, sms, acp, google, web,
youtube. Same trim applied to [termux-all]. New regression test
asserts the contract: every extra in LAZY_DEPS must NOT also appear
in [all].
# Companion fix: surface uv progress + errors
setup-hermes.sh's hash-verified path swallowed uv's stderr to a
tempfile, identical to the install.sh bug fixed in PR #24504. Same
fix applied: stream stderr through directly so users see live
progress instead of staring at a frozen prompt.
# Files
- pyproject.toml: trim [all] and [termux-all] to non-lazy extras only.
- scripts/install.sh: --all-extras → --extra all; trim _ALL_EXTRAS /
_PYPI_EXTRAS to match.
- scripts/install.ps1: --all-extras → --extra all; trim $allExtras /
$pypiExtras to match.
- setup-hermes.sh: --all-extras → --extra all; stream stderr.
- tests/test_project_metadata.py: invert matrix-in-[all] assertion;
add lazy-coverage contract test.
- uv.lock: regenerated.
# Validation
5/5 metadata tests pass. 37/37 in update_autostash + tool_token_
estimation. `uv lock --check` passes. Empirical dry-run confirms
`--extra all` excludes python-olm + RL chain on the new lockfile.
* fix(install): parse [all] from pyproject.toml instead of mirroring it
ethernet's review point: the previous patch left two hand-mirrored
copies of [all]'s contents (in install.sh's $_ALL_EXTRAS and
install.ps1's $allExtras). That guarantees future drift the next
time pyproject.toml's [all] changes.
Now both scripts parse pyproject.toml at install time using stdlib
tomllib (Python 3.11+, which the bootstrap step already requires).
Single source of truth. The only purpose of the parsed list is to
build the 'Tier 2: [all] minus broken extras' fallback spec — so we
parse, filter against $brokenExtras, and rebuild the .[a,b,c] spec.
Also: removed redundant fallback tiers.
Before: Tier 1 [all]
Tier 2 [all] minus broken
Tier 3 PyPI-only extras (no git deps)
Tier 4 [web,mcp,cron,cli,messaging,dev]
Tier 5 .
After: Tier 1 [all]
Tier 2 [all] minus broken
Tier 3 .
Tier 3 (PyPI-only) and Tier 4 (dashboard+core) used to dodge the [rl]
git+sdist deps and the [matrix] python-olm build. Both are no longer
in [all] post-2026-05-12 lazy-install migration, so the carve-out
tiers had no remaining content. Tier 4 also referenced [messaging],
which is now lazy-installed — the hardcoded fallback was actually
inconsistent with the new policy.
Defensive fallback: if tomllib parse fails (corrupted pyproject,
unexpected schema), Tier 2 collapses to '.[all]' (same as Tier 1) so
the broken-extras path becomes a no-op rather than crashing.
* fix(gateway): hide Matrix from setup picker on Windows
Matrix is the one messaging platform that has no working install path
on Windows: [matrix] -> mautrix[encryption] -> python-olm, which has
Linux-only wheels and needs make + libolm to build from sdist. The
[all] cleanup in this PR keeps mautrix out of fresh installs, but a
user who picked Matrix in 'hermes setup gateway' would still walk
into the same sdist build failure when the wizard tried to install
the extra.
Hide the option at the picker so users never get the chance to try.
The gate lives in _all_platforms() — single source of truth for the
setup wizard, the curses gateway-config menu, and any future picker.
Adapter loading at runtime is intentionally NOT gated: users who
already have MATRIX_* env vars set (e.g. config copied from a Linux
install) keep working if they somehow have python-olm available.
This is the lowest-friction fix — picker visibility only.
Tests cover linux/darwin/win32 and verify other platforms aren't
collateral damage.
2026-05-12 15:06:25 -07:00
|
|
|
echo -e "${CYAN}→${NC} (first run on a fresh venv can take 1-5 minutes; uv prints progress below)"
|
|
|
|
|
# Critical flag choice: `--extra all`, NOT `--all-extras`. The
|
|
|
|
|
# latter installs every [project.optional-dependencies] key,
|
|
|
|
|
# bypassing the curated [all] extra and pulling backends like
|
|
|
|
|
# [matrix] (python-olm needs make on Windows) and [rl] (git+https
|
|
|
|
|
# deps that fail offline). See pyproject.toml's [all] for the
|
|
|
|
|
# curated set, and tools/lazy_deps.py for backends that install
|
|
|
|
|
# at first use.
|
|
|
|
|
# Also: stream stderr through directly so the user sees uv's
|
|
|
|
|
# progress UI instead of staring at a frozen prompt.
|
|
|
|
|
if UV_PROJECT_ENVIRONMENT="$SCRIPT_DIR/venv" $UV_CMD sync --extra all --locked; then
|
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
2026-05-12 01:02:25 -07:00
|
|
|
echo -e "${GREEN}✓${NC} Dependencies installed (hash-verified via uv.lock)"
|
|
|
|
|
else
|
fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] (#24515)
* fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all]
Two coupled fixes for the Windows install hang where uv sync built
python-olm from sdist and failed on missing make.
# Root cause: --all-extras vs --extra all (credit: ethernet)
`uv sync --all-extras` installs every key in [project.optional-
dependencies], bypassing the curated [all] extra entirely. So even
when [all] excluded [matrix], [rl], [yc-bench], etc., the installer
pulled them anyway because they were still defined as extras. On
Windows that meant python-olm (no wheel, needs make to build from
sdist) and the install died there.
The right flag is `--extra all` — install just the [all] extra's
contents, respecting curation. Empirically verified via dry-run:
--all-extras: pulls python-olm, mautrix, ctranslate2, onnxruntime,
atroposlib, tinker, wandb, modal, daytona, vercel,
python-telegram-bot, discord.py, slack-bolt,
dingtalk-stream, lark-oapi, anthropic, boto3,
edge-tts, elevenlabs, exa-py, fal-client, faster-
whisper, firecrawl-py, honcho-ai, parallel-web
--extra all: pulls none of those — just [all]'s curated set
Dockerfile already uses `--extra all` (with comment explaining the
gotcha) — knowledge existed; the gap was install.sh / install.ps1 /
setup-hermes.sh.
Sites fixed: scripts/install.sh L1118, scripts/install.ps1 L809,
setup-hermes.sh L245.
# Companion fix: drop lazy-covered extras from [all]
`tools/lazy_deps.py` already covers anthropic, bedrock, exa,
firecrawl, parallel-web, fal, edge-tts, elevenlabs, modal, daytona,
vercel, all messaging platforms (telegram/discord/slack/matrix/
dingtalk/feishu), honcho, and faster-whisper. They were ALSO in
[all], which defeats the whole point of lazy-install — fresh
installs eager-pulled them and inherited whatever was broken
upstream (the matrix → python-olm → no Windows wheel chain being
the proximate symptom).
[all] now contains only what genuinely can't be lazy-installed:
cron, cli, dev, pty, mcp, homeassistant, sms, acp, google, web,
youtube. Same trim applied to [termux-all]. New regression test
asserts the contract: every extra in LAZY_DEPS must NOT also appear
in [all].
# Companion fix: surface uv progress + errors
setup-hermes.sh's hash-verified path swallowed uv's stderr to a
tempfile, identical to the install.sh bug fixed in PR #24504. Same
fix applied: stream stderr through directly so users see live
progress instead of staring at a frozen prompt.
# Files
- pyproject.toml: trim [all] and [termux-all] to non-lazy extras only.
- scripts/install.sh: --all-extras → --extra all; trim _ALL_EXTRAS /
_PYPI_EXTRAS to match.
- scripts/install.ps1: --all-extras → --extra all; trim $allExtras /
$pypiExtras to match.
- setup-hermes.sh: --all-extras → --extra all; stream stderr.
- tests/test_project_metadata.py: invert matrix-in-[all] assertion;
add lazy-coverage contract test.
- uv.lock: regenerated.
# Validation
5/5 metadata tests pass. 37/37 in update_autostash + tool_token_
estimation. `uv lock --check` passes. Empirical dry-run confirms
`--extra all` excludes python-olm + RL chain on the new lockfile.
* fix(install): parse [all] from pyproject.toml instead of mirroring it
ethernet's review point: the previous patch left two hand-mirrored
copies of [all]'s contents (in install.sh's $_ALL_EXTRAS and
install.ps1's $allExtras). That guarantees future drift the next
time pyproject.toml's [all] changes.
Now both scripts parse pyproject.toml at install time using stdlib
tomllib (Python 3.11+, which the bootstrap step already requires).
Single source of truth. The only purpose of the parsed list is to
build the 'Tier 2: [all] minus broken extras' fallback spec — so we
parse, filter against $brokenExtras, and rebuild the .[a,b,c] spec.
Also: removed redundant fallback tiers.
Before: Tier 1 [all]
Tier 2 [all] minus broken
Tier 3 PyPI-only extras (no git deps)
Tier 4 [web,mcp,cron,cli,messaging,dev]
Tier 5 .
After: Tier 1 [all]
Tier 2 [all] minus broken
Tier 3 .
Tier 3 (PyPI-only) and Tier 4 (dashboard+core) used to dodge the [rl]
git+sdist deps and the [matrix] python-olm build. Both are no longer
in [all] post-2026-05-12 lazy-install migration, so the carve-out
tiers had no remaining content. Tier 4 also referenced [messaging],
which is now lazy-installed — the hardcoded fallback was actually
inconsistent with the new policy.
Defensive fallback: if tomllib parse fails (corrupted pyproject,
unexpected schema), Tier 2 collapses to '.[all]' (same as Tier 1) so
the broken-extras path becomes a no-op rather than crashing.
* fix(gateway): hide Matrix from setup picker on Windows
Matrix is the one messaging platform that has no working install path
on Windows: [matrix] -> mautrix[encryption] -> python-olm, which has
Linux-only wheels and needs make + libolm to build from sdist. The
[all] cleanup in this PR keeps mautrix out of fresh installs, but a
user who picked Matrix in 'hermes setup gateway' would still walk
into the same sdist build failure when the wizard tried to install
the extra.
Hide the option at the picker so users never get the chance to try.
The gate lives in _all_platforms() — single source of truth for the
setup wizard, the curses gateway-config menu, and any future picker.
Adapter loading at runtime is intentionally NOT gated: users who
already have MATRIX_* env vars set (e.g. config copied from a Linux
install) keep working if they somehow have python-olm available.
This is the lowest-friction fix — picker visibility only.
Tests cover linux/darwin/win32 and verify other platforms aren't
collateral damage.
2026-05-12 15:06:25 -07:00
|
|
|
echo -e "${YELLOW}⚠${NC} Lockfile sync failed (see uv output above)."
|
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
2026-05-12 01:02:25 -07:00
|
|
|
echo -e "${YELLOW}⚠${NC} Falling back to PyPI resolve — transitives will NOT be hash-verified."
|
|
|
|
|
_try_install
|
|
|
|
|
echo -e "${GREEN}✓${NC} Dependencies installed (transitives re-resolved, not hash-verified)"
|
|
|
|
|
fi
|
2026-04-09 10:41:58 +02:00
|
|
|
else
|
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
2026-05-12 01:02:25 -07:00
|
|
|
echo -e "${YELLOW}⚠${NC} uv.lock not found — installing without hash verification of transitives."
|
|
|
|
|
_try_install
|
|
|
|
|
echo -e "${GREEN}✓${NC} Dependencies installed (transitives re-resolved, not hash-verified)"
|
2026-04-09 10:41:58 +02:00
|
|
|
fi
|
2026-03-24 08:42:45 -07:00
|
|
|
fi
|
2026-02-02 19:01:51 -08:00
|
|
|
|
2026-02-05 03:49:46 -08:00
|
|
|
# ============================================================================
|
2026-02-07 00:05:04 +00:00
|
|
|
# ============================================================================
|
2026-02-05 03:49:46 -08:00
|
|
|
# Optional: ripgrep (for faster file search)
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
echo -e "${CYAN}→${NC} Checking ripgrep (optional, for faster search)..."
|
|
|
|
|
|
|
|
|
|
if command -v rg &> /dev/null; then
|
|
|
|
|
echo -e "${GREEN}✓${NC} ripgrep found"
|
|
|
|
|
else
|
|
|
|
|
echo -e "${YELLOW}⚠${NC} ripgrep not found (file search will use grep fallback)"
|
|
|
|
|
read -p "Install ripgrep for faster search? [Y/n] " -n 1 -r
|
|
|
|
|
echo
|
|
|
|
|
if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then
|
|
|
|
|
INSTALLED=false
|
2026-04-09 10:41:58 +02:00
|
|
|
|
|
|
|
|
if is_termux; then
|
|
|
|
|
pkg install -y ripgrep && INSTALLED=true
|
|
|
|
|
else
|
|
|
|
|
# Check if sudo is available
|
|
|
|
|
if command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then
|
|
|
|
|
if command -v apt &> /dev/null; then
|
|
|
|
|
sudo apt install -y ripgrep && INSTALLED=true
|
|
|
|
|
elif command -v dnf &> /dev/null; then
|
|
|
|
|
sudo dnf install -y ripgrep && INSTALLED=true
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# Try brew (no sudo needed)
|
|
|
|
|
if [ "$INSTALLED" = false ] && command -v brew &> /dev/null; then
|
|
|
|
|
brew install ripgrep && INSTALLED=true
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# Try cargo (no sudo needed)
|
|
|
|
|
if [ "$INSTALLED" = false ] && command -v cargo &> /dev/null; then
|
|
|
|
|
echo -e "${CYAN}→${NC} Trying cargo install (no sudo required)..."
|
|
|
|
|
cargo install ripgrep && INSTALLED=true
|
2026-02-05 03:49:46 -08:00
|
|
|
fi
|
|
|
|
|
fi
|
2026-04-09 10:41:58 +02:00
|
|
|
|
2026-02-05 03:49:46 -08:00
|
|
|
if [ "$INSTALLED" = true ]; then
|
|
|
|
|
echo -e "${GREEN}✓${NC} ripgrep installed"
|
|
|
|
|
else
|
|
|
|
|
echo -e "${YELLOW}⚠${NC} Auto-install failed. Install options:"
|
2026-04-09 10:41:58 +02:00
|
|
|
if is_termux; then
|
|
|
|
|
echo " pkg install ripgrep # Termux / Android"
|
|
|
|
|
else
|
|
|
|
|
echo " sudo apt install ripgrep # Debian/Ubuntu"
|
|
|
|
|
echo " brew install ripgrep # macOS"
|
|
|
|
|
echo " cargo install ripgrep # With Rust (no sudo)"
|
|
|
|
|
fi
|
2026-02-05 03:49:46 -08:00
|
|
|
echo " https://github.com/BurntSushi/ripgrep#installation"
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
# ============================================================================
|
|
|
|
|
# Environment file
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
if [ ! -f ".env" ]; then
|
|
|
|
|
if [ -f ".env.example" ]; then
|
|
|
|
|
cp .env.example .env
|
2026-05-25 03:38:11 -07:00
|
|
|
# .env holds API keys — restrict to owner-only access (matches
|
|
|
|
|
# scripts/install.sh which already chmods 600 after creation).
|
|
|
|
|
chmod 600 .env 2>/dev/null || true
|
2026-02-02 19:01:51 -08:00
|
|
|
echo -e "${GREEN}✓${NC} Created .env from template"
|
|
|
|
|
fi
|
2026-01-31 21:42:15 -08:00
|
|
|
else
|
2026-05-25 03:38:11 -07:00
|
|
|
# Tighten an existing .env's perms in case it was created elsewhere
|
|
|
|
|
# under a permissive umask.
|
|
|
|
|
chmod 600 .env 2>/dev/null || true
|
2026-02-02 19:01:51 -08:00
|
|
|
echo -e "${GREEN}✓${NC} .env exists"
|
2026-01-31 21:42:15 -08:00
|
|
|
fi
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
# ============================================================================
|
2026-04-09 10:41:58 +02:00
|
|
|
# PATH setup — symlink hermes into a user-facing bin dir
|
2026-02-02 19:01:51 -08:00
|
|
|
# ============================================================================
|
2026-01-31 21:42:15 -08:00
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
echo -e "${CYAN}→${NC} Setting up hermes command..."
|
2026-01-31 21:42:15 -08:00
|
|
|
|
2026-02-07 23:54:53 +00:00
|
|
|
HERMES_BIN="$SCRIPT_DIR/venv/bin/hermes"
|
2026-04-09 10:41:58 +02:00
|
|
|
COMMAND_LINK_DIR="$(get_command_link_dir)"
|
|
|
|
|
COMMAND_LINK_DISPLAY_DIR="$(get_command_link_display_dir)"
|
|
|
|
|
mkdir -p "$COMMAND_LINK_DIR"
|
|
|
|
|
ln -sf "$HERMES_BIN" "$COMMAND_LINK_DIR/hermes"
|
|
|
|
|
echo -e "${GREEN}✓${NC} Symlinked hermes → $COMMAND_LINK_DISPLAY_DIR/hermes"
|
|
|
|
|
|
|
|
|
|
if is_termux; then
|
|
|
|
|
export PATH="$COMMAND_LINK_DIR:$PATH"
|
|
|
|
|
echo -e "${GREEN}✓${NC} $COMMAND_LINK_DISPLAY_DIR is already on PATH in Termux"
|
2026-03-03 14:39:46 +03:00
|
|
|
else
|
2026-04-09 10:41:58 +02:00
|
|
|
# Determine the appropriate shell config file
|
|
|
|
|
SHELL_CONFIG=""
|
|
|
|
|
if [[ "$SHELL" == *"zsh"* ]]; then
|
2026-03-03 14:39:46 +03:00
|
|
|
SHELL_CONFIG="$HOME/.zshrc"
|
2026-04-09 10:41:58 +02:00
|
|
|
elif [[ "$SHELL" == *"bash"* ]]; then
|
2026-03-03 14:39:46 +03:00
|
|
|
SHELL_CONFIG="$HOME/.bashrc"
|
2026-04-09 10:41:58 +02:00
|
|
|
[ ! -f "$SHELL_CONFIG" ] && SHELL_CONFIG="$HOME/.bash_profile"
|
|
|
|
|
else
|
|
|
|
|
# Fallback to checking existing files
|
|
|
|
|
if [ -f "$HOME/.zshrc" ]; then
|
|
|
|
|
SHELL_CONFIG="$HOME/.zshrc"
|
|
|
|
|
elif [ -f "$HOME/.bashrc" ]; then
|
|
|
|
|
SHELL_CONFIG="$HOME/.bashrc"
|
|
|
|
|
elif [ -f "$HOME/.bash_profile" ]; then
|
|
|
|
|
SHELL_CONFIG="$HOME/.bash_profile"
|
|
|
|
|
fi
|
2026-03-03 14:39:46 +03:00
|
|
|
fi
|
2026-01-31 21:42:15 -08:00
|
|
|
|
2026-04-09 10:41:58 +02:00
|
|
|
if [ -n "$SHELL_CONFIG" ]; then
|
|
|
|
|
# Touch the file just in case it doesn't exist yet but was selected
|
|
|
|
|
touch "$SHELL_CONFIG" 2>/dev/null || true
|
|
|
|
|
|
|
|
|
|
if ! echo "$PATH" | tr ':' '\n' | grep -q "^$HOME/.local/bin$"; then
|
|
|
|
|
if ! grep -q '\.local/bin' "$SHELL_CONFIG" 2>/dev/null; then
|
|
|
|
|
echo "" >> "$SHELL_CONFIG"
|
|
|
|
|
echo "# Hermes Agent — ensure ~/.local/bin is on PATH" >> "$SHELL_CONFIG"
|
|
|
|
|
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$SHELL_CONFIG"
|
|
|
|
|
echo -e "${GREEN}✓${NC} Added ~/.local/bin to PATH in $SHELL_CONFIG"
|
|
|
|
|
else
|
|
|
|
|
echo -e "${GREEN}✓${NC} ~/.local/bin already in $SHELL_CONFIG"
|
|
|
|
|
fi
|
2026-02-07 23:54:53 +00:00
|
|
|
else
|
2026-04-09 10:41:58 +02:00
|
|
|
echo -e "${GREEN}✓${NC} ~/.local/bin already on PATH"
|
2026-02-07 23:54:53 +00:00
|
|
|
fi
|
2026-02-02 19:01:51 -08:00
|
|
|
fi
|
2026-01-31 21:42:15 -08:00
|
|
|
fi
|
|
|
|
|
|
2026-02-19 18:25:53 -08:00
|
|
|
# ============================================================================
|
|
|
|
|
# Seed bundled skills into ~/.hermes/skills/
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
HERMES_SKILLS_DIR="${HERMES_HOME:-$HOME/.hermes}/skills"
|
|
|
|
|
mkdir -p "$HERMES_SKILLS_DIR"
|
|
|
|
|
|
|
|
|
|
echo ""
|
|
|
|
|
echo "Syncing bundled skills to ~/.hermes/skills/ ..."
|
|
|
|
|
if "$SCRIPT_DIR/venv/bin/python" "$SCRIPT_DIR/tools/skills_sync.py" 2>/dev/null; then
|
|
|
|
|
echo -e "${GREEN}✓${NC} Skills synced"
|
|
|
|
|
else
|
|
|
|
|
# Fallback: copy if sync script fails (missing deps, etc.)
|
|
|
|
|
if [ -d "$SCRIPT_DIR/skills" ]; then
|
|
|
|
|
cp -rn "$SCRIPT_DIR/skills/"* "$HERMES_SKILLS_DIR/" 2>/dev/null || true
|
|
|
|
|
echo -e "${GREEN}✓${NC} Skills copied"
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
# ============================================================================
|
|
|
|
|
# Done
|
|
|
|
|
# ============================================================================
|
2026-01-31 21:42:15 -08:00
|
|
|
|
|
|
|
|
echo ""
|
2026-02-02 19:01:51 -08:00
|
|
|
echo -e "${GREEN}✓ Setup complete!${NC}"
|
2026-01-31 21:42:15 -08:00
|
|
|
echo ""
|
2026-02-02 19:01:51 -08:00
|
|
|
echo "Next steps:"
|
2026-01-31 21:42:15 -08:00
|
|
|
echo ""
|
2026-04-09 10:41:58 +02:00
|
|
|
if is_termux; then
|
|
|
|
|
echo " 1. Run the setup wizard to configure API keys:"
|
|
|
|
|
echo " hermes setup"
|
|
|
|
|
echo ""
|
|
|
|
|
echo " 2. Start chatting:"
|
|
|
|
|
echo " hermes"
|
|
|
|
|
echo ""
|
|
|
|
|
else
|
|
|
|
|
echo " 1. Reload your shell:"
|
|
|
|
|
echo " source $SHELL_CONFIG"
|
|
|
|
|
echo ""
|
|
|
|
|
echo " 2. Run the setup wizard to configure API keys:"
|
|
|
|
|
echo " hermes setup"
|
|
|
|
|
echo ""
|
|
|
|
|
echo " 3. Start chatting:"
|
|
|
|
|
echo " hermes"
|
|
|
|
|
echo ""
|
|
|
|
|
fi
|
2026-02-02 19:01:51 -08:00
|
|
|
echo "Other commands:"
|
|
|
|
|
echo " hermes status # Check configuration"
|
2026-04-09 10:41:58 +02:00
|
|
|
if is_termux; then
|
|
|
|
|
echo " hermes gateway # Run gateway in foreground"
|
|
|
|
|
else
|
|
|
|
|
echo " hermes gateway install # Install gateway service (messaging + cron)"
|
|
|
|
|
fi
|
2026-02-21 16:21:19 -08:00
|
|
|
echo " hermes cron list # View scheduled jobs"
|
2026-02-02 19:01:51 -08:00
|
|
|
echo " hermes doctor # Diagnose issues"
|
2026-01-31 21:42:15 -08:00
|
|
|
echo ""
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
# Ask if they want to run setup wizard now
|
|
|
|
|
read -p "Would you like to run the setup wizard now? [Y/n] " -n 1 -r
|
|
|
|
|
echo
|
|
|
|
|
if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then
|
|
|
|
|
echo ""
|
2026-02-07 23:54:53 +00:00
|
|
|
# Run directly with venv Python (no activation needed)
|
|
|
|
|
"$SCRIPT_DIR/venv/bin/python" -m hermes_cli.main setup
|
2026-02-02 19:01:51 -08:00
|
|
|
fi
|