diff --git a/AGENTS.md b/AGENTS.md index c704bb8c9d..53ffead645 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -323,6 +323,14 @@ May 2026). PyPI: `>=floor,=0.28.1,<1"`); pre-1.0: `<0.(min pip: `==exact`. A bare `>=X.Y.Z` is rejected by CI and reviewers. Run `uv lock` after changing `pyproject.toml`. Reference: #2810 (bounds), #9801 (SHA pinning + audit CI). +The `[tool.uv] exclude-newer = "14 days"` quarantine covers **Hermes's own dependencies only** +(`uv lock`/`sync`, `hermes update`, `tools.lazy_deps.ensure` extras — `install policy "core"`). +Plugin `python_dependencies` install under the plugin's own policy (`install_specs(policy="plugin")` +→ `uv --no-config`, still inside the core constraints file); Teknium's ruling: "plugins dont have to +abide by our 14 day rule … Only hermes' dependencies themselves have to." We recommend (not require) +plugin authors adopt their own quarantine — the developer guide and `plugin-catalog/README.md` carry +that guidance. + ## Commits, Merges, PRs - **Squash merges from stale branches silently revert recent fixes.** Before squash-merging, diff --git a/hermes_cli/plugin_python_deps.py b/hermes_cli/plugin_python_deps.py index 271d9fa33e..dfbca05e9a 100644 --- a/hermes_cli/plugin_python_deps.py +++ b/hermes_cli/plugin_python_deps.py @@ -266,7 +266,9 @@ def resolve(specs: list[str], constraints: list[str], *, dry_run: bool, timeout: """Run the shared installer ladder on *specs* under *constraints*. Raises ``DependencyConflict`` or ``DependencyInstallError``; returns the installer result on success.""" from tools.lazy_deps import install_specs - result = install_specs(specs, timeout=timeout, constraints=constraints, dry_run=dry_run) + # A plugin's declared deps follow the plugin's own security policy; Hermes's exclude-newer quarantine + # covers Hermes's packages only (core_constraints still keeps them in range). + result = install_specs(specs, timeout=timeout, constraints=constraints, dry_run=dry_run, policy="plugin") error = _classify(result) if error is not None: raise error diff --git a/plugin-catalog/README.md b/plugin-catalog/README.md index 1165c348bd..6e18bd33b2 100644 --- a/plugin-catalog/README.md +++ b/plugin-catalog/README.md @@ -54,6 +54,16 @@ meaningful: validate` refuses these at admission (`desktop surface` check); a plugin that needs a capability the SDK lacks asks for an SDK hook instead of patching around it. +9. **Dependency security policy is the plugin's.** Hermes's 14-day + `exclude-newer` quarantine covers Hermes's own dependencies only; a plugin's + `python_dependencies` / `pyproject.toml` install under the plugin's policy + (no quarantine, still inside Hermes's core constraints). Reviewers read the + dependency list at the pinned SHA: bare floors (`>=X` with no upper bound) + and floors on the newest release get a request for the oldest + API-compatible floor plus an upper bound, and authors are strongly + recommended to run their own release quarantine (`uv --exclude-newer` in + their CI) — see the developer guide's *Dependency security policy*. A + recent floor alone is not grounds to hold an entry. ## Entry schema diff --git a/tests/tools/test_lazy_deps.py b/tests/tools/test_lazy_deps.py index 2db629ccd7..a9caa18046 100644 --- a/tests/tools/test_lazy_deps.py +++ b/tests/tools/test_lazy_deps.py @@ -326,12 +326,9 @@ class TestRefreshActiveFeatures: class TestInstallSpecs: - def test_uv_tier_runs_from_the_checkout_so_exclude_newer_applies(self, monkeypatch, tmp_path): - """uv reads ``[tool.uv] exclude-newer`` from the cwd project only; a plugin-dep install launched from - $HOME or a gateway service must still run under the checkout's quarantine, so the uv invocation - carries the checkout root as cwd (#L1-3 of the 2026-09 plugin audit).""" + @staticmethod + def _capture_uv(monkeypatch): import subprocess - from pathlib import Path calls = [] @@ -343,18 +340,48 @@ class TestInstallSpecs: monkeypatch.setattr(ld, "_uv_binary", lambda: "/fake/uv") monkeypatch.setattr(ld, "_lazy_install_target", lambda: None) monkeypatch.setattr(ld, "_after_successful_install", lambda *a, **kw: None) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) + return calls + + def test_plugin_dependency_installs_do_not_inherit_hermes_exclude_newer(self, monkeypatch, tmp_path): + """Plugins follow their own dependency-security policy (maintainer ruling): a plugin's + ``python_dependencies`` install must not resolve under the checkout's ``[tool.uv] exclude-newer`` + quarantine, from any cwd — so uv runs with ``--no-config`` and never from the checkout root.""" + from pathlib import Path + + calls = self._capture_uv(monkeypatch) + project_root = Path(ld.__file__).resolve().parent.parent + monkeypatch.chdir(project_root) + + result = ld.install_specs(["hindsight-client>=0.10.1,<1"], constraints=["httpx>=0.28,<1"]) + + assert result.ok + (cmd, kw), = calls + assert cmd[:3] == ["/fake/uv", "pip", "install"] + assert "--no-config" in cmd + assert kw.get("cwd") is None + assert "--constraint" in cmd # core ranges still bound the plugin's resolution + + def test_hermes_own_lazy_installs_keep_the_checkout_quarantine(self, monkeypatch, tmp_path): + """Hermes's OWN optional deps (LAZY_DEPS via ``ensure``) stay under the 14-day quarantine even when + launched from ``$HOME`` or a service: the uv tier runs from the checkout root with its config.""" + from pathlib import Path + + calls = self._capture_uv(monkeypatch) monkeypatch.chdir(tmp_path) + monkeypatch.setitem(ld.LAZY_DEPS, "core.probe", ("requests>=2.32,<3",)) + monkeypatch.setattr(ld, "feature_missing", lambda feature: ("requests>=2.32,<3",) if not calls else ()) + monkeypatch.setattr(ld, "_unsupported_feature_reason", lambda feature: "") project_root = Path(ld.__file__).resolve().parent.parent assert (project_root / "pyproject.toml").is_file() - result = ld._venv_pip_install(("requests==2.32.0",)) + ld.ensure("core.probe", prompt=False) - assert result.success (cmd, kw), = calls assert cmd[:3] == ["/fake/uv", "pip", "install"] + assert "--no-config" not in cmd assert kw.get("cwd") == str(project_root) - def test_blank_specs_are_ignored(self, monkeypatch): monkeypatch.setattr( ld, "_venv_pip_install", diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index d6b309e1a0..303fcc0c13 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -528,13 +528,30 @@ def _run_installer(cmd: list[str], **kw) -> subprocess.CompletedProcess: return subprocess.run(cmd, **_SUBPROCESS_KW, creationflags=windows_hide_flags(), **kw) -def _uv_policy_cwd() -> Optional[str]: - """Directory uv must run from so the checkout's ``[tool.uv]`` policy (``exclude-newer`` quarantine and its - per-package exceptions) applies: uv reads it from the *current directory's* project only, so a lazy or - plugin install launched from ``$HOME``, a gateway service or the Desktop backend was never quarantined. - ``None`` (inherit cwd) when this is not a source checkout.""" +# Whose dependency-security policy an install runs under. ``core``: Hermes's own packages (LAZY_DEPS +# extras, refreshed by ``hermes update``) resolve under the checkout's ``[tool.uv]`` policy — the 14-day +# ``exclude-newer`` quarantine and its per-package exceptions. ``plugin``: a plugin's declared +# ``python_dependencies`` follow the PLUGIN's own policy (maintainer ruling: "plugins don't have to abide +# by our 14 day rule; they can have their own security policy on that. Only Hermes' dependencies themselves +# have to"), so Hermes's project config is not applied — a plugin floored on a release younger than 14 days +# would otherwise be uninstallable through Hermes while installing fine everywhere else. +INSTALL_POLICIES = ("core", "plugin") + + +def _uv_policy_args(policy: str) -> tuple[list[str], Optional[str]]: + """``(extra uv args, cwd)`` that pin the resolver to *policy* regardless of the caller's cwd. + + uv reads ``[tool.uv]`` from the project discovered at the *current directory*, so cwd is the seam: + ``core`` runs from the checkout root (a lazy install launched from ``$HOME``, a gateway service or the + Desktop backend still gets the quarantine); ``plugin`` passes ``--no-config`` so no project file is + discovered from any cwd (env knobs such as ``UV_INDEX_URL`` / ``UV_EXCLUDE_NEWER`` still apply, so an + operator can quarantine plugin deps themselves). Verified with ``uv pip install --show-settings``.""" + if policy not in INSTALL_POLICIES: + raise ValueError(f"unknown install policy {policy!r}; expected one of {INSTALL_POLICIES}") + if policy == "plugin": + return ["--no-config"], None root = Path(__file__).resolve().parent.parent - return str(root) if (root / "pyproject.toml").is_file() else None + return [], (str(root) if (root / "pyproject.toml").is_file() else None) def _uv_binary() -> Optional[str]: @@ -566,15 +583,17 @@ def _after_successful_install(specs: tuple[str, ...], target: Optional[Path], dr def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300, constraint_lines: tuple[str, ...] = (), - dry_run: bool = False) -> _InstallResult: + dry_run: bool = False, policy: str = "core") -> _InstallResult: """Install ``specs`` via the uv -> pip -> ensurepip ladder, venv-scoped or into the durable ``--target`` (constrained to core versions) when :data:`_LAZY_TARGET_ENV` is set. Independent of ``hermes_cli.tools_config._pip_install`` (no CLI dependency). *constraint_lines* pins the resolver (plugin installs pass Hermes' own declared ranges so a plugin - can never move a core package out of range); *dry_run* resolves without installing.""" + can never move a core package out of range); *dry_run* resolves without installing; *policy* is one + of :data:`INSTALL_POLICIES` (see :func:`_uv_policy_args`).""" if not specs: return _InstallResult(True, "", "") + policy_args, uv_cwd = _uv_policy_args(policy) target = _lazy_install_target() constraints: Optional[Path] = None extra_args: list[str] = ["--dry-run"] if dry_run else [] @@ -608,12 +627,13 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300, constraint_ if pip_index_url: uv_env["UV_INDEX_URL"] = pip_index_url try: - r = _run_installer([uv_bin, "pip", "install", "--compile-bytecode", *extra_args, *specs], - timeout=timeout, env=uv_env, cwd=_uv_policy_cwd()) + r = _run_installer([uv_bin, "pip", "install", "--compile-bytecode", *policy_args, *extra_args, *specs], + timeout=timeout, env=uv_env, cwd=uv_cwd) if r.returncode != 0: logger.debug("uv pip install failed: %s", r.stderr) # A uv resolver failure is authoritative: falling through to pip would discard uv - # policy (exclude-newer) and could install a quarantined release. + # policy (exclude-newer for core; the constraints file for both) and could install a + # quarantined or out-of-range release. return _finish(r) except subprocess.TimeoutExpired as e: logger.debug("uv invocation failed: %s", e) @@ -744,14 +764,19 @@ class InstallSpecsResult: def install_specs(specs: list[str] | tuple[str, ...], *, timeout: int = 300, - constraints: list[str] | tuple[str, ...] = (), dry_run: bool = False) -> InstallSpecsResult: - """Install data-driven pip specs (plugin manifest ``pip_dependencies``) with the same routing and + constraints: list[str] | tuple[str, ...] = (), dry_run: bool = False, + policy: str = "plugin") -> InstallSpecsResult: + """Install data-driven pip specs (plugin manifest ``python_dependencies``) with the same routing and gating as :func:`ensure`, but unknown packages are allowed — the caller owns manifest trust, this owns spec hygiene. *constraints* are requirement lines the resolver must honour; *dry_run* only - resolves. Never raises; inspect the :class:`InstallSpecsResult`.""" + resolves; *policy* defaults to ``"plugin"`` (the plugin's own dependency policy, not Hermes's + ``exclude-newer`` quarantine — :data:`INSTALL_POLICIES`). Never raises; inspect the + :class:`InstallSpecsResult`.""" cleaned = tuple(str(s).strip() for s in specs if str(s).strip()) if not cleaned: return InstallSpecsResult(ok=True, command="") + if policy not in INSTALL_POLICIES: + return InstallSpecsResult(ok=False, blocked=True, reason=f"unknown install policy {policy!r}") for spec in cleaned: if not _spec_is_safe(spec): return InstallSpecsResult(ok=False, blocked=True, reason=f"refusing to install unsafe spec {spec!r}") @@ -765,7 +790,8 @@ def install_specs(specs: list[str] | tuple[str, ...], *, timeout: int = 300, display = "uv pip install " + (f"--target {target} " if target is not None else "") + " ".join(cleaned) logger.info("%s pip specs %s (target=%s)", "Resolving" if dry_run else "Installing", " ".join(cleaned), target or "venv") try: - result = _venv_pip_install(cleaned, timeout=timeout, constraint_lines=tuple(constraints), dry_run=dry_run) + result = _venv_pip_install(cleaned, timeout=timeout, constraint_lines=tuple(constraints), dry_run=dry_run, + policy=policy) except Exception as exc: logger.warning("install_specs failed unexpectedly: %s", exc) return InstallSpecsResult(ok=False, command=display, stderr=f"install failed: {exc}") diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index b1e6e26eee..a9960bf59c 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -378,6 +378,34 @@ When both exist the `pyproject.toml` wins. What Hermes does with them: `HERMES_HOME/plugins/` survives `hermes update` and Desktop updates: the updater only rebuilds the venv and the checkout, never the home directory. +### Dependency security policy + +Hermes quarantines **its own** dependencies: the checkout's `[tool.uv] exclude-newer = "14 days"` +keeps a freshly published release of any package Hermes itself depends on out of `hermes update` +and the built-in lazy installs for two weeks, so a hijacked upload is caught upstream before it +reaches users. **That quarantine does not apply to your plugin's dependencies.** Plugin installs +run outside Hermes's project policy (`uv pip install --no-config`, still under the core constraints +file above), so a plugin can floor on a release published yesterday and install today — and the +plugin's author, not Hermes, is responsible for what that pulls in. + +Set your own policy and hold yourself to it. Strongly recommended: + +- **Upper bounds on every dependency** — `>=floor,=0.29,<0.32` for pre-1.0 ones. A bare `>=X.Y` adopts every future release unreviewed. +- **Floor on the oldest API-compatible version**, not the release of the week. A floor on a + fresh wheel forces every installer onto it the day it appears; `>=old,!=broken,