hermes-agent/tests/test_packaging_metadata.py

322 lines
13 KiB
Python

import re
import tomllib
from pathlib import Path
import pytest
from tools import lazy_deps
REPO_ROOT = Path(__file__).resolve().parents[1]
def _extras_table() -> dict:
"""``[project.optional-dependencies]`` as declared (unresolved)."""
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
return data["project"]["optional-dependencies"]
def _distribution_name(requirement: str) -> str:
"""Extract the PEP 508 distribution name from a requirement string.
Robust to markers (``; python_version < '3.12'``), direct references
(``name @ https://...``), extras (``name[extra]``) and every version
operator (``==``, ``>=``, ``<=``, ``~=``, ``!=``, ``<``, ``>``), so a
future dep declared with any valid specifier shape doesn't silently
mis-parse here.
"""
spec = requirement.split(";", 1)[0] # drop environment markers
spec = spec.split("@", 1)[0] # drop direct-reference URLs
spec = spec.split("[", 1)[0] # drop extras
spec = re.split(r"[=<>!~]", spec, maxsplit=1)[0] # drop any version operator
return spec.strip().lower()
def test_packaging_declared_as_core_dependency():
"""Regression for #40503.
``packaging`` is imported directly on three production paths
(plugins/memory/hindsight/__init__.py, tools/lazy_deps.py,
hermes_cli/main.py) yet was undeclared, so it only reached users
transitively. The slim Docker image shipped without it, silently
disabling Hindsight append-mode and version-constraint checks. It must
be a declared core dependency so it installs everywhere and the
update-repair step (``_verify_core_dependencies_installed``) guards it.
"""
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
core = data["project"]["dependencies"]
names = {_distribution_name(dep) for dep in core}
assert "packaging" in names, (
"packaging is imported on production paths (hindsight version compare, "
"lazy_deps version constraints, requirement parsing) and must be a "
"declared core dependency, not a transitive — see #40503"
)
def test_faster_whisper_is_not_a_base_dependency():
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
deps = data["project"]["dependencies"]
assert not any(dep.startswith("faster-whisper") for dep in deps)
# Resolved, not literal: [voice] now carries faster-whisper by composing
# [stt-whisper] (the file-transcription half the Docker image bakes).
voice_specs = lazy_deps.extra_specs("voice")
assert any(dep.startswith("faster-whisper") for dep in voice_specs)
# Minimum non-vulnerable Starlette: CVE-2026-48710 ("BadHost") was fixed in
# 1.0.1. Anything below that lets a malformed Host header desync
# ``request.url.path`` from the dispatched ASGI path, bypassing path-based
# authz in middleware/endpoints that gate on ``request.url``. Starlette is a
# transitive dep (fastapi in [web]; sse-starlette/mcp in [mcp]/[computer-use]/
# [dev]) so we pin it directly in every extra that exposes a server surface and
# enforce the floor in both pyproject and the committed lockfile.
_STARLETTE_CVE_FLOOR = (1, 0, 1)
_UPDATE_DOWNGRADE_GUARD_FLOORS = {
# `hermes update` reinstalls exact pins from pyproject/lazy_deps. These
# reviewed CVE pins must not slide back to stale versions that downgrade
# already-patched user environments.
"cryptography": (50, 0, 0),
"starlette": (1, 3, 1),
"python-multipart": (0, 0, 32),
}
def _version_tuple(spec: str) -> tuple[int, ...]:
# "1.0.1" -> (1, 0, 1); tolerant of pre/post suffixes by truncating.
head = spec.split("+", 1)[0]
parts = []
for chunk in head.split("."):
digits = "".join(ch for ch in chunk if ch.isdigit())
if not digits:
break
parts.append(int(digits))
return tuple(parts)
def test_starlette_pinned_above_cve_2026_48710_floor_in_pyproject():
"""Every extra that pulls Starlette must resolve a patched (>=1.0.1) version.
Regression guard for #35067 / CVE-2026-48710. A future edit that drops the
pin (re-exposing the unbounded transitive ``starlette>=0.27`` from mcp /
``>=0.40.0`` from fastapi) or pins a pre-1.0.1 version fails here instead of
shipping a Host-header auth-bypass to dashboard / MCP-HTTP users.
Checked against the RESOLVED specs, not the literal text of each extra: an
extra may now carry the pin by composing another (``[computer-use]`` is
``hermes-agent[mcp]``). What matters is the version that actually gets
installed, which is what composition resolves to.
"""
found = {}
for extra in _extras_table():
for spec in lazy_deps.extra_specs(extra):
name = spec.split("==", 1)[0].split(">", 1)[0].split("<", 1)[0].split("[", 1)[0].strip()
if name.lower() == "starlette":
assert "==" in spec, f"[{extra}] must exact-pin starlette, got {spec!r}"
ver = spec.split("==", 1)[1].split(";", 1)[0].strip()
found[extra] = ver
# The four server-surface extras must each resolve the pin.
for extra in ("web", "mcp", "computer-use", "dev"):
assert extra in found, (
f"[{extra}] does not resolve a starlette pin. CVE-2026-48710 "
f"regression risk (mcp/fastapi pull it transitively with no upper bound)"
)
for extra, ver in found.items():
assert _version_tuple(ver) >= _STARLETTE_CVE_FLOOR, (
f"[{extra}] pins starlette=={ver}, below the CVE-2026-48710 fix "
f"floor {'.'.join(map(str, _STARLETTE_CVE_FLOOR))}"
)
def test_locked_starlette_is_not_vulnerable_to_cve_2026_48710():
"""The committed uv.lock must resolve starlette to a patched version.
pyproject pins protect the declared extras, but the lockfile is what
hash-verified installs (``uv sync --locked``) actually pull. Assert the
resolved version is >= the CVE-2026-48710 fix floor so a stale-lock
regression can't ship a vulnerable Starlette to users.
"""
lock = (REPO_ROOT / "uv.lock").read_text(encoding="utf-8")
versions = []
in_starlette = False
for line in lock.splitlines():
if line.startswith("[[package]]"):
in_starlette = False
elif line.strip() == 'name = "starlette"':
in_starlette = True
elif in_starlette and line.startswith("version = "):
versions.append(line.split("=", 1)[1].strip().strip('"'))
in_starlette = False
assert versions, "starlette not found in uv.lock"
for ver in versions:
assert _version_tuple(ver) >= _STARLETTE_CVE_FLOOR, (
f"uv.lock resolves starlette=={ver}, below the CVE-2026-48710 fix "
f"floor {'.'.join(map(str, _STARLETTE_CVE_FLOOR))} — regenerate the "
f"lockfile after bumping the pin"
)
# ---------------------------------------------------------------------------
# Dependency-pin consistency across every install path.
#
# Do not put the pins in two places written by hand. That was #31817: the
# [project.optional-dependencies] extras in pyproject.toml and a LAZY_DEPS
# table in tools/lazy_deps.py — and they silently drifted more than once: the
# aiohttp Slack pin (3.13.3 vs 3.13.4) and the anthropic pin (0.86.0 vs
# 0.87.0). The version a user ended up with depended on whether the backend
# was installed eagerly or lazily, which for a one-sided CVE bump is a latent
# security regression.
#
# lazy_deps now READS the extras, so there is one source of truth and that
# particular drift can't recur. These tests keep the surrounding invariants:
# pyproject must be internally consistent, and the pins a lazy feature
# resolves must be present on every mirrored install path.
# ---------------------------------------------------------------------------
# Matches "name==version" and "name[extra]==version", ignoring any trailing
# environment marker / comment. Only exact pins are collected; ranged specs
# (">=", "<") can't be compared for equality and are skipped.
_PIN_RE = re.compile(
r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*==\s*([^\s;,#]+)"
)
def _canonical(name: str) -> str:
# PEP 503 normalization so e.g. discord.py / discord-py compare equal.
return re.sub(r"[-_.]+", "-", name).lower()
def _pins_from_specs(specs):
"""Map canonical package name -> set of exact-pinned versions seen."""
pins: dict[str, set[str]] = {}
for spec in specs:
m = _PIN_RE.match(spec)
if not m:
continue
pins.setdefault(_canonical(m.group(1)), set()).add(m.group(2))
return pins
def _locked_versions(package: str) -> set[str]:
lock = tomllib.loads((REPO_ROOT / "uv.lock").read_text(encoding="utf-8"))
return {
pkg["version"]
for pkg in lock.get("package", [])
if _canonical(pkg["name"]) == _canonical(package)
}
def _pyproject_pinned_specs():
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
specs = list(data["project"].get("dependencies", []))
for extra in data["project"].get("optional-dependencies", {}).values():
specs.extend(extra)
return specs
def _lazy_deps_pinned_specs():
"""Every spec reachable through a lazy feature.
Do not read the source text of tools/lazy_deps.py to get these specs.
The specs now live in pyproject.toml and lazy_deps reads them, so calling
the real resolver both removes a source-reading test and exercises the
code path users actually hit.
"""
specs: list[str] = []
for feature in lazy_deps.LAZY_DEPS:
specs.extend(lazy_deps.feature_specs(feature))
assert specs, "no lazy feature resolved any specs"
return specs
def test_pyproject_pins_are_internally_consistent():
"""No package may be exact-pinned to two different versions in pyproject.
A package legitimately appearing in several extras (e.g. aiohttp in
messaging/slack/homeassistant/sms) must use the SAME version everywhere.
"""
pins = _pins_from_specs(_pyproject_pinned_specs())
conflicts = {name: sorted(v) for name, v in pins.items() if len(v) > 1}
assert not conflicts, (
"pyproject.toml exact-pins the same package to different versions "
"across [project.dependencies] / extras: " + str(conflicts)
)
def _lazy_deps_by_feature():
"""``{feature_name: [spec, ...]}`` as the installer resolves it."""
by_feature = {
feature: list(lazy_deps.feature_specs(feature))
for feature in lazy_deps.LAZY_DEPS
}
assert by_feature, "no lazy features are registered"
return by_feature
# Security-critical packages whose patched floor must be enforced on EVERY
# install path, eager and lazy. test_pyproject_and_lazy_deps_pins_agree only
# fires when a package is pinned in BOTH sources, so it cannot catch a lazy
# feature that omits the pin entirely — the exact gap that left platform.slack
# carrying aiohttp==3.14.0 while platform.discord (whose discord.py dep pulls
# aiohttp transitively as its HTTP backbone) shipped without it, so the lazy
# Discord path could keep an already-installed vulnerable aiohttp. A fully
# general "no mirrored feature drops a pin" check is impossible statically
# (it can't see transitive deps), so this is the explicit coverage contract:
# each security package -> the lazy features that bundle an SDK pulling it and
# must therefore carry the same pin as the pyproject extra.
_REQUIRED_SECURITY_PINS = {
# Every lazy messaging feature whose SDK pulls aiohttp transitively must
# carry the patched floor directly: discord.py (aiohttp<4), slack-bolt,
# mautrix/aiohttp-socks (aiohttp<4 / >=3.10), and microsoft-teams-apps —
# none of those upper/lower bounds excludes a vulnerable already-installed
# aiohttp, so the lazy path would not upgrade it without an explicit pin.
"aiohttp": {
"platform.discord",
"platform.slack",
"platform.matrix",
"platform.teams",
},
}
def test_security_pins_present_in_mirrored_lazy_features():
"""Curated security pins must be present (not just version-consistent) in
every lazy feature that bundles an SDK pulling that package transitively.
"""
py = _pins_from_specs(_pyproject_pinned_specs())
by_feature = _lazy_deps_by_feature()
problems = []
for pkg, features in _REQUIRED_SECURITY_PINS.items():
canon = _canonical(pkg)
expected = py.get(canon)
assert expected, (
f"{pkg} is listed in _REQUIRED_SECURITY_PINS but is not exact-pinned "
f"in pyproject.toml — update the map or the pin."
)
for feature in sorted(features):
specs = by_feature.get(feature)
assert specs is not None, (
f"lazy feature {feature!r} named in _REQUIRED_SECURITY_PINS no "
f"longer exists in LAZY_DEPS — update the map."
)
got = _pins_from_specs(specs).get(canon)
if got != expected:
problems.append(
f"{feature}: {pkg}="
f"{sorted(got) if got else 'MISSING'}, expected {sorted(expected)}"
)
assert not problems, (
"a lazy feature is missing a security pin it must mirror from the "
"pyproject extras — the lazy install path would not enforce the "
"CVE-patched floor:\n " + "\n ".join(problems)
)