Files
hermes-agent/hermes_cli/plugins_cmd_catalog.py
teknium1 77f80d31ee fix(plugins): catalog provenance is installer-owned; kill list covers update/enable/load; re-pin keeps user files
Catalog trust bugs from the 2026-09-21 plugin audit (lane 3, F1-F6, F9):

- F1 (high): a URL-installed repo shipping its own .hermes-catalog.json rendered
  as catalog:official everywhere and marked the real entry "installed". Provenance
  now lives on the installer-owned .install-metadata.json record (catalog block
  written by _install_plugin_core, sha = checked-out commit); read_catalog_sidecar
  never reads the tree. Pre-fix installs are adopted once when the installer
  record agrees (pinned at the sidecar sha, cloned from the entry's repo).
- F2: removed.yaml bypassed by git@/ssh:///http:///www. spellings. _normalize_repo
  canonicalises to host/owner/repo (scheme, user, www., .git, slashes dropped).
- F6: removed.yaml consulted for INSTALLED plugins too: git-pull update, enable and
  gate_manifest (load) refuse recalled plugins, offline (in-tree + cached live
  list). --allow-removed is recorded on the install record and exempts it.
- F5: install NAME --ref X recorded the catalog pin, so list/TUI/update claimed
  the reviewed pin while HEAD differed. Recorded sha is the checked-out one.
- F3: re-pin replaced the whole tree, losing the installer-created config.yaml,
  data and user patches with no warning. Untracked/ignored files are carried
  into the new tree; edits to tracked files are copied to
  <HERMES_HOME>/plugins-backup/<name>-<sha8>/ with a warning.
- F4: a manifest rename between pins left the OLD dir installed and enabled.
  The stale dir is removed and the enabled flag follows the new name.
- F9: dashboard payload `removed` list now includes live removals.

repin_catalog_plugin returns RepinResult(sha, changed, installed_name, warnings);
CLI, dashboard and TUI callers surface the warnings and the new name.
2026-09-21 23:17:05 -07:00

482 lines
24 KiB
Python

"""``hermes plugins`` catalog surface: resolution, provenance sidecar, search/browse/info/validate,
catalog-aware update, plus the dashboard/TUI-facing catalog payload helpers.
Sibling of :mod:`hermes_cli.plugins_cmd` (the installer core, enable/disable state and console helpers
live there and are imported late — this module is imported BY ``plugins_cmd``).
"""
from __future__ import annotations
import datetime
import json
import logging
import shutil
import sys
import tempfile
from pathlib import Path
from typing import Any, Dict, List, NamedTuple, Optional
from hermes_cli.plugin_catalog import (
PluginCatalogEntry, RemovedEntry, cached_removed_entries, entry_capability_summary, filter_entries,
find_removed, get_live_catalog_entry, load_catalog_live, match_removed, resolved_removed_entries,
_NAME_RE, _normalize_repo,
)
logger = logging.getLogger(__name__)
CATALOG_SIDECAR = ".hermes-catalog.json"
# ── Resolution / provenance ──────────────────────────────────────────────────
def looks_like_catalog_name(identifier: str) -> bool:
"""Bare ``[a-z0-9_-]`` token — not a URL, ``owner/repo`` or path."""
from hermes_cli.plugins_cmd import _URL_SCHEMES
return bool(identifier) and "/" not in identifier and "\\" not in identifier \
and not identifier.startswith(_URL_SCHEMES) and bool(_NAME_RE.match(identifier))
def raise_if_removed(*candidates: str) -> None:
"""``PluginOperationError`` when any candidate (name or repo URL) is on the kill list."""
from hermes_cli.plugins_cmd import PluginOperationError
for candidate in candidates:
removed = find_removed(candidate)
if removed is not None:
detail = removed.reason or "no reason recorded"
if removed.date:
detail += f" (removed {removed.date})"
raise PluginOperationError(
f"Plugin '{removed.name}' was removed from the Hermes plugin catalog and is blocked from "
f"installation: {detail}")
def resolve_catalog_name(identifier: str, console) -> PluginCatalogEntry:
"""Bare name → live catalog entry, or exit 1 with a pointer to ``search``."""
from hermes_cli.plugins_cmd import _fail
entry = get_live_catalog_entry(identifier)
if entry is None:
_fail(console, (
f"[red]Error:[/red] '{identifier}' is not in the Hermes plugin catalog and is not a Git URL or "
"owner/repo shorthand. Browse entries with `hermes plugins search`."))
raise SystemExit(1) # _fail exits; keeps type-checkers honest
return entry
def write_catalog_sidecar(target: Path, entry: PluginCatalogEntry, sha: Optional[str] = None) -> None:
"""Human/Desktop-readable ``.hermes-catalog.json`` inside the install dir. It is a CONVENIENCE COPY:
the authoritative provenance is the ``catalog`` block on the ``.install-metadata.json`` record (see
:func:`read_catalog_sidecar`), because anything inside the tree is under the repo's control."""
sidecar = {
"catalog_name": entry.name, "repo": entry.repo, "sha": sha or entry.sha, "tier": entry.tier,
"installed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
.replace("+00:00", "Z"),
}
try:
(target / CATALOG_SIDECAR).write_text(json.dumps(sidecar, indent=2) + "\n", encoding="utf-8")
except OSError as exc:
logger.warning("Failed to write catalog sidecar in %s: %s", target, exc)
def _install_record(plugin_dir: Path) -> Optional[dict]:
"""The installer-owned ``.install-metadata.json`` record for a dir under the plugins dir, else ``None``."""
from hermes_cli.plugins_cmd import PluginOperationError, _plugins_dir, _read_install_metadata
if plugin_dir.parent != _plugins_dir():
return None
try:
record = _read_install_metadata().get(plugin_dir.name)
except PluginOperationError:
return None
return record if isinstance(record, dict) else None
def _adopt_legacy_sidecar(plugin_dir: Path, record: dict) -> Optional[dict]:
"""Installs made before provenance moved out of the tree carry only the in-tree file. Trust it once —
only when the installer record agrees (pinned at that sha, cloned from that catalog entry's repo) —
and copy it onto the record so later reads never consult the tree again."""
from hermes_cli.plugins_cmd import _read_install_metadata, _write_install_metadata
path = plugin_dir / CATALOG_SIDECAR
try:
data = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
except Exception:
return None
if not isinstance(data, dict) or not data.get("catalog_name"):
return None
sha = str(data.get("sha") or "").lower()
entry = get_live_catalog_entry(str(data["catalog_name"]))
if entry is None or record.get("pinned") is not True or record.get("revision") != sha:
return None
source = str(record.get("source") or "").split("#", 1)[0]
if _normalize_repo(source) != _normalize_repo(entry.repo):
return None
block = {"name": entry.name, "repo": entry.repo, "tier": str(data.get("tier") or entry.tier), "pin": sha, "sha": sha}
metadata = _read_install_metadata()
metadata[plugin_dir.name] = {**record, "catalog": block}
_write_install_metadata(metadata)
return block
def read_catalog_sidecar(plugin_dir) -> Optional[dict]:
"""Catalog provenance of an installed plugin (``catalog_name``/``repo``/``sha``/``tier``), or ``None``
for a non-catalog install. Read from the installer-owned metadata record, never from the tree: a
URL-installed repo that ships its own ``.hermes-catalog.json`` must not render as a reviewed
catalog install nor mark the real entry installed."""
if not plugin_dir:
return None
plugin_dir = Path(plugin_dir)
record = _install_record(plugin_dir)
if record is None:
return None
block = record.get("catalog")
if not isinstance(block, dict):
block = _adopt_legacy_sidecar(plugin_dir, record)
if not block or not block.get("name"):
return None
return {"catalog_name": block["name"], "repo": block.get("repo", ""), "sha": block.get("sha", ""),
"tier": block.get("tier") or "community", "pin": block.get("pin", "")}
def catalog_annotation(dir_path) -> Optional[str]:
"""``catalog:<tier>@<sha8>`` for a catalog install (``list`` Source column), else ``None``."""
sidecar = read_catalog_sidecar(dir_path)
if not sidecar:
return None
return f"catalog:{sidecar.get('tier') or 'community'}@{str(sidecar.get('sha') or '')[:8]}"
def removed_annotation(name: str, dir_path, removed_entries: List[RemovedEntry]) -> Optional[str]:
"""Kill-list reason when an INSTALLED plugin matches by name, catalog name or repo, else ``None``.
``removed_entries`` is required: callers annotating many rows (``plugins list``, the dashboard hub)
resolve the kill list once with :func:`plugin_catalog.resolved_removed_entries` and pass it in.
Resolving per row cost one live-catalog fetch — one network timeout, offline — per plugin.
"""
sidecar = read_catalog_sidecar(dir_path) or {}
for candidate in (name, sidecar.get("catalog_name"), sidecar.get("repo")):
removed = match_removed(str(candidate), removed_entries) if candidate else None
if removed is not None:
return removed.reason or "no reason recorded"
return None
# ── Catalog-aware install / update ───────────────────────────────────────────
def install_catalog_entry(entry: PluginCatalogEntry, *, force: bool, ref: Optional[str] = None,
allow_removed: bool = False, scan_decision_cb=None, python_deps: bool = True) -> tuple:
"""``_install_plugin_core`` at the catalog pin (an explicit *ref* wins) + provenance recorded on the
install-metadata record at the sha ACTUALLY checked out (a ``--ref`` install is not at the reviewed
pin, so ``update_available`` must say so). Returns the core's ``(target, manifest, installed_name)``."""
from hermes_cli.plugins_cmd import _install_plugin_core, pinned_revision
if not allow_removed:
raise_if_removed(entry.name, entry.repo)
target, manifest, installed_name = _install_plugin_core(
entry.install_identifier, force=force, ref=ref or entry.sha, scan_decision_cb=scan_decision_cb,
reviewed_pin=entry.sha, python_deps=python_deps, allow_removed=allow_removed,
catalog={"name": entry.name, "repo": entry.repo, "tier": entry.tier, "pin": entry.sha})
write_catalog_sidecar(target, entry, sha=pinned_revision(target.name))
return target, manifest, installed_name
def installed_plugin_removal(name: str, plugin_dir) -> Optional[RemovedEntry]:
"""Kill-list verdict for an INSTALLED plugin (manifest name, dir name, catalog name or recorded
source), or ``None``. A record carrying ``allow_removed`` (the user bypassed the list at install) is
honoured; the check is offline (in-tree list + cached live copy) so load time never blocks on the
catalog host."""
plugin_dir = Path(plugin_dir) if plugin_dir else None
record = (_install_record(plugin_dir) if plugin_dir else None) or {}
if record.get("allow_removed") is True:
return None
block = record.get("catalog") if isinstance(record.get("catalog"), dict) else {}
source = str(record.get("source") or "").split("#", 1)[0]
candidates = [name, source, block.get("name"), block.get("repo"), plugin_dir.name if plugin_dir else None]
entries = cached_removed_entries()
for candidate in candidates:
removed = match_removed(str(candidate), entries) if candidate else None
if removed is not None:
return removed
return None
def refuse_if_installed_removed(name: str, plugin_dir) -> None:
"""``PluginOperationError`` form of :func:`installed_plugin_removal` for ``update``/``enable``, which
otherwise keep pulling and activating code the catalog recalled."""
from hermes_cli.plugins_cmd import PluginOperationError
removed = installed_plugin_removal(name, plugin_dir)
if removed is not None:
raise PluginOperationError(
f"Plugin '{name}' was removed from the Hermes plugin catalog: "
f"{removed.reason or 'no reason recorded'}. Remove it with `hermes plugins remove {name}`, "
"or reinstall with `hermes plugins install <source> --force --allow-removed` if you trust it.")
_PRESERVE_SKIP = ("__pycache__",)
def _local_changes(target: Path) -> tuple[list[str], list[str]]:
"""``(untracked_or_ignored, modified_tracked)`` relative paths in a git checkout; empty for a
non-git tree (subdir installs carry no ``.git``, so nothing can be told apart from the clone)."""
from hermes_cli.plugins_cmd import _resolve_git_executable, _run_plugin_git
git_exe = _resolve_git_executable()
if not git_exe or not (target / ".git").exists():
return [], []
status = _run_plugin_git(git_exe, target, "status", "--porcelain", "--ignored", "-z", "--untracked-files=all",
"--ignored=matching", timeout=30)
if status.returncode != 0:
return [], []
local, modified = [], []
for item in status.stdout.split("\0"):
if len(item) < 4:
continue
code, rel = item[:2], item[3:]
if any(part in _PRESERVE_SKIP or part.endswith(".pyc") for part in Path(rel).parts):
continue
(local if code in ("??", "!!") else modified).append(rel)
return local, modified
def _stash_local_files(target: Path, rels: list[str], stash: Path) -> None:
for rel in rels:
src = target / rel
if src.is_file():
dst = stash / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
class RepinResult(NamedTuple):
sha: str
changed: bool
installed_name: str
warnings: list[str]
def repin_catalog_plugin(target: Path, sidecar: dict) -> RepinResult:
"""Re-pin a catalog install to the current catalog SHA (never ``git pull``). The force reinstall
replaces the whole tree, so the user's untracked/ignored files (installer-created ``config.yaml``,
data) are carried into the new tree and edits to TRACKED files are saved under
``<HERMES_HOME>/plugins-backup/<name>-<sha8>/`` with a warning. A manifest rename between pins moves the
enabled flag and removes the stale dir. Raises ``PluginOperationError`` when the entry left the catalog."""
from hermes_cli.plugins_cmd import (
PluginOperationError, _get_enabled_set, _plugins_dir, _remove_plugin_core, _save_enabled_set)
catalog_name = str(sidecar["catalog_name"])
entry = get_live_catalog_entry(catalog_name)
if entry is None:
raise PluginOperationError(
f"Plugin '{catalog_name}' is no longer in the catalog — it may have been removed. "
"See `hermes plugins info` and the removed blocklist.")
if str(sidecar.get("sha") or "").strip().lower() == entry.sha:
return RepinResult(entry.sha, False, target.name, [])
was_enabled = _get_enabled_set() # the force reinstall must not flip activation state
local, modified = _local_changes(target)
old_sha8 = str(sidecar.get("sha") or "old")[:8]
with tempfile.TemporaryDirectory(prefix=".repin-", dir=_plugins_dir()) as tmp:
stash = Path(tmp) / "local"
_stash_local_files(target, local, stash)
# Outside the plugins dir: the discovery scanners recurse into every subdirectory there.
backup = _plugins_dir().parent / "plugins-backup" / f"{target.name}-{old_sha8}"
_stash_local_files(target, modified, backup)
new_target, _manifest, installed_name = install_catalog_entry(entry, force=True)
if stash.exists():
shutil.copytree(stash, new_target, dirs_exist_ok=True)
warnings: list[str] = []
if modified:
warnings.append(f"Local edits to {len(modified)} tracked file(s) were not carried over; copies are under "
f"{backup} (the previous version's files, re-apply by hand).")
if new_target != target and target.exists():
_remove_plugin_core(target)
if target.name in was_enabled:
was_enabled = (was_enabled - {target.name}) | {installed_name}
warnings.append(f"Plugin renamed itself from '{target.name}' to '{installed_name}'; the old directory was removed.")
_save_enabled_set(was_enabled)
return RepinResult(entry.sha, True, installed_name, warnings)
def cmd_update_catalog(name: str, target: Path, sidecar: dict, console) -> None:
from hermes_cli.plugins_cmd import PluginOperationError, _fail
console.print(f"[dim]Checking catalog pin for {name}...[/dim]")
try:
result = repin_catalog_plugin(target, sidecar)
except PluginOperationError as exc:
_fail(console, f"[red]Error:[/red] {exc}")
raise SystemExit(1)
verb = "updated to" if result.changed else "is already at catalog pin"
console.print(f"[green]✓[/green] Plugin [bold]{result.installed_name}[/bold] {verb} {result.sha[:8]}.")
for warning in result.warnings:
console.print(f"[yellow]⚠ {warning}[/yellow]")
if result.changed:
from hermes_cli.plugins_cmd import _install_python_dependencies
_install_python_dependencies(target.parent / result.installed_name, console)
# ── search / browse / info / validate ────────────────────────────────────────
def _capability_counts(entry: PluginCatalogEntry) -> str:
caps = entry.capabilities
parts = [f"{len(items)} {label}{'s' if len(items) != 1 and label != 'middleware' else ''}"
for items, label in ((caps.provides_tools, "tool"), (caps.provides_hooks, "hook"),
(caps.provides_middleware, "middleware")) if items]
if caps.requires_env:
parts.append(f"{len(caps.requires_env)} env")
return ", ".join(parts) or "—"
def pin_label(entry: PluginCatalogEntry) -> str:
"""``1.4.0 @ abcd1234`` when the entry carries a version label, else the short sha."""
return f"{entry.version} @ {entry.sha[:8]}" if entry.version else entry.sha[:8]
def _render_entries(entries: List[PluginCatalogEntry], console) -> None:
from hermes_cli.plugins_cmd import _table
table = _table(((("Name", "bold")), ("Category", None), ("Tier", None), ("Description", None),
("Pinned", "dim"), ("Capabilities", "dim")), title="Hermes Plugin Catalog (curated)")
for e in sorted(entries, key=lambda e: (e.category, e.tier != "official", e.name)):
tier = "[cyan]official[/cyan]" if e.tier == "official" else "[magenta]community[/magenta]"
desc = e.description if len(e.description) <= 60 else e.description[:57] + "..."
table.add_row(e.name, e.category, tier, desc, pin_label(e), _capability_counts(e))
console.print()
console.print(table)
console.print()
console.print("[dim]Details:[/dim] hermes plugins info <name> [dim]Install:[/dim] hermes plugins install <name>")
def cmd_search(term: str = "", *, json_output: bool = False) -> None:
"""Search the curated catalog (name/description/declared tools); empty term = browse everything."""
from hermes_cli.plugins_cmd import _console
matches = filter_entries(load_catalog_live(), term)
if json_output:
print(json.dumps({"query": term, "results": [e.to_dict() for e in matches]}, indent=2))
return
console = _console()
if not matches:
console.print(f"[yellow]No catalog entries matched '{term}'[/yellow]" if term
else "[dim]No catalog entries available.[/dim]")
return
_render_entries(matches, console)
def cmd_info(name: str) -> None:
"""Full catalog entry for *name*; falls back to installed-plugin details for non-catalog names."""
from hermes_cli.plugins_cmd import _console, cmd_show
entry = get_live_catalog_entry(name)
if entry is None:
cmd_show(name)
return
console = _console()
caps = entry.capabilities
console.print()
console.print(f"[bold]{entry.name}[/bold] [cyan]\\[{entry.tier}][/cyan]")
if entry.description:
console.print(entry.description)
console.print()
rows = [("Repo", entry.repo), ("Subdir", entry.subdir), ("Version", entry.version), ("Pinned SHA", entry.sha),
("Image", entry.image),
("Maintainer", entry.maintainer), ("Requires", f"hermes {entry.requires_hermes}" if entry.requires_hermes else ""),
("Platforms", ", ".join(entry.platforms)), ("Docs", entry.docs_url)]
for label, value in rows:
if value:
console.print(f"[dim]{label + ':':<12}[/dim] {value}")
console.print()
for label, items in (("Tools", caps.provides_tools), ("Hooks", caps.provides_hooks),
("Middleware", caps.provides_middleware), ("Env vars", caps.requires_env)):
console.print(f"[dim]{label + ':':<12}[/dim] {', '.join(items) or '(none)'}")
console.print()
removed = find_removed(entry.name) or find_removed(entry.repo)
if removed is not None:
console.print(f"[red bold]✗ REMOVED from catalog: {removed.reason or 'no reason recorded'}"
f"{f' ({removed.date})' if removed.date else ''}[/red bold]")
console.print()
console.print(f"[dim]Install:[/dim] hermes plugins install {entry.name}")
console.print()
def cmd_validate(path: str, as_json: bool = False, install_deps: bool = False) -> None:
"""Catalog-admission validation of a plugin directory (the CI gate); exits 0/1. *install_deps*
installs the declared Python deps first so the capability probe imports what an install would."""
from hermes_cli.plugin_validate import validate_plugin_dir
from hermes_cli.plugins_cmd import _console
if install_deps:
from hermes_cli.plugin_python_deps import install_for_plugin_dir
outcome = install_for_plugin_dir(Path(path))
if outcome.status in ("failed", "invalid"):
print(outcome.message, file=sys.stderr)
report = validate_plugin_dir(Path(path))
if as_json:
print(json.dumps(report.to_dict(), indent=2))
sys.exit(report.exit_code)
console = _console()
console.print()
for check_name, ok, detail in report.checks:
console.print(f"{'[green]✓[/green]' if ok else '[red]✗[/red]'} {check_name}"
+ (f" [dim]— {detail}[/dim]" if detail else ""))
for warning in report.warnings:
console.print(f"[yellow]⚠ {warning}[/yellow]")
console.print()
console.print("[green bold]Validation passed.[/green bold]" if report.ok else "[red bold]Validation failed.[/red bold]")
sys.exit(report.exit_code)
# ── Dashboard / TUI payloads ─────────────────────────────────────────────────
def installed_catalog_state(installed: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
"""Catalog entries merged with local state for the dashboard. *installed* maps every alias (name
and registry key) of a discovered plugin to ``{"dir", "runtime_status"}``. A catalog name rarely
equals the manifest name (``hermes-plugin-x`` vs ``x``), so installs are matched through the
sidecar's ``catalog_name`` first and by name only as a fallback."""
by_catalog_name: Dict[str, Dict[str, Any]] = {}
for local in installed.values():
sidecar = read_catalog_sidecar(local["dir"])
if sidecar:
by_catalog_name[str(sidecar["catalog_name"])] = {**local, "sidecar": sidecar}
entries = []
for entry in load_catalog_live():
local = by_catalog_name.get(entry.name) or installed.get(entry.name)
sidecar = local.get("sidecar") if local else None
installed_sha = str(sidecar["sha"]) if sidecar and sidecar.get("sha") else None
entries.append({
**entry.to_dict(), "sha_short": entry.sha[:7],
"capability_summary": entry_capability_summary(entry),
"installed": local is not None, "installed_sha": installed_sha,
"update_available": bool(installed_sha) and installed_sha != entry.sha,
"runtime_status": local["runtime_status"] if local else None,
})
return {
"entries": entries,
"removed": [{"name": r.name, "repo": r.repo, "reason": r.reason, "date": r.date} for r in resolved_removed_entries()],
"generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
}
def catalog_row_fields(dir_path, pins: Dict[str, str], versions: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Provenance fields for one installed-plugin row (TUI/desktop ``plugins.manage list``): catalog
name/tier/installed SHA and, when *pins* has the entry, the current pin (+ its version label from
*versions*) and ``update_available``."""
versions = versions or {}
sidecar = read_catalog_sidecar(dir_path)
if not sidecar:
return {}
installed_sha = str(sidecar.get("sha") or "").lower()
row: Dict[str, Any] = {
"catalog_name": sidecar["catalog_name"], "catalog_tier": str(sidecar.get("tier") or "community"),
"installed_sha": installed_sha}
pin = pins.get(str(sidecar["catalog_name"]))
if pin:
row["catalog_sha"] = pin
row["catalog_version"] = versions.get(str(sidecar["catalog_name"])) or None
row["update_available"] = bool(installed_sha) and installed_sha != pin
return row
def catalog_pins() -> Dict[str, str]:
"""``{catalog_name: pinned_sha}`` from the live catalog; empty on failure (best effort)."""
try:
return {e.name: e.sha for e in load_catalog_live()}
except Exception:
return {}
def catalog_versions() -> Dict[str, str]:
"""``{catalog_name: version_label}`` for entries that carry one; empty on failure (best effort)."""
try:
return {e.name: e.version for e in load_catalog_live() if e.version}
except Exception:
return {}