From 358d50ca6dcb01b93ed226ce27f7f0de6d142111 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 23 Sep 2026 05:58:26 -0700 Subject: [PATCH] fix(plugins): plugin dependencies follow the plugin's own security policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes's 14-day `[tool.uv] exclude-newer` quarantine applies to Hermes's own dependencies only (uv lock/sync, `hermes update`, LAZY_DEPS extras via `ensure()`). A plugin's declared `python_dependencies` install under the PLUGIN's policy: `install_specs(policy="plugin")` runs uv with `--no-config` from any cwd, still inside the core constraints file. Reverses item 3 of #118841, which ran the uv tier with cwd= for every install so the quarantine reached plugin deps from any cwd. That made catalog re-pins floored on a <14-day release uninstallable (#120076: "only hindsight-client<=0.9.2 is available"; #114530 held on the same gate). Maintainer ruling (Teknium): "plugins dont have to abide by our 14 day rule btw. They can have their own security policy on that. Only hermes' dependencies themselves have to. We should recommend that they do this for their plugins and we should give guidance to plugin devs that they should though." - tools/lazy_deps.py: INSTALL_POLICIES ("core" | "plugin"); `_uv_policy_args` replaces `_uv_policy_cwd`; `_venv_pip_install(policy=)` defaults to core (ensure/LAZY_DEPS), `install_specs(policy=)` defaults to plugin. - hermes_cli/plugin_python_deps.py: `resolve()` passes policy="plugin". - Docs: developer guide "Dependency security policy" section, catalog README admission rule 9, AGENTS.md pinning policy — plugin authors are responsible for their deps and strongly recommended to pin upper bounds, floor on the oldest API-compatible version and run their own release quarantine (`uv --exclude-newer` in their CI); operators can set UV_EXCLUDE_NEWER. - Tests: the #118841 cwd test is replaced by two invariants — a plugin install carries `--no-config` and no checkout cwd (red on base), a core lazy install keeps the checkout cwd and no `--no-config`. --- AGENTS.md | 8 +++ hermes_cli/plugin_python_deps.py | 4 +- plugin-catalog/README.md | 10 ++++ tests/tools/test_lazy_deps.py | 43 +++++++++++--- tools/lazy_deps.py | 56 ++++++++++++++----- website/docs/developer-guide/plugins/index.md | 28 ++++++++++ 6 files changed, 125 insertions(+), 24 deletions(-) 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,