A desktop/plugin.js is evaluated in the Electron renderer with the app's
full authority; the runtime loader documents itself as error isolation
only and says a remote source must not reuse it without a boundary. The
catalog is that remote source, so admission now fails a plugin whose
desktop code patches prototypes, uses eval/new Function, dynamically
imports anything but the SDK (app chunks, blob/http URLs), or injects
script tags. Against the 18 desktop plugins in the current sweep the lint
passes 17 and fails the one the security review flagged for exactly these
moves. Policy written down in plugin-catalog/README.md rule 8 and the
user-facing catalog doc.
A plugin.yaml with no __init__.py, desktop/plugin.js or plugin.json beside it
installs "successfully" and does nothing (pip-layout repos whose code sits under
src/ behind an entry point). The validator and catalog CI now fail that shape
and check declared Python dependencies parse and are index specs; direct-URL
requirements warn.
Tests: conflicting candidate refused with peers untouched; post-update re-apply
drops only the culprit and keeps the memory provider. Loader wording test
updated to the new hint.
Symptom: a third of catalog entries (23 of 71 pinned trees scanned today) could not be
installed from the Desktop. The install path runs plugin_guard on every clone and a
caution verdict needs a confirmation; the CLI prompts, the Desktop/dashboard path
passes no decision callback, so caution became "Security scan blocked".
Root cause: admission (hermes plugins validate + catalog CI) never ran the scanner, so a
pin could be approved by a human and still trip the installer.
Fix, both halves:
- validate_plugin_dir gains a "security scan" check: dangerous fails the entry, caution
is reported as warnings so the reviewer reads the findings before merging the pin.
pinned-source-validate in plugin-catalog-ci.yml therefore runs the identical scanner.
- _install_plugin_core accepts reviewed_pin=<catalog sha>; when the checked-out revision
equals it, caution is accepted without a prompt. dangerous still blocks (a signature
added after review is what the backstop is for). Raw-URL installs, --ref overrides
and a checkout at any other revision keep the current behaviour.
Live: dashboard_install_plugin('weather') on origin/main -> BLOCKED caution; after -> ok.
Two false positives in `hermes plugins validate` that block catalog admission
for plugins that are correct at runtime:
- `kind: model-provider` plugins register at import via
providers.register_provider(ProviderProfile); the PluginManager never calls a
register(ctx) on them (plugins_discovery skips the kind). The probe demanded
register() anyway, so every provider plugin -- including the in-tree
plugins/model-providers/* -- failed with "no register() function". The probe
now records register_provider calls for that kind and fails only when the
import registers nothing.
- RecordingContext returned a no-op callable for ANY attribute, so
`getattr(ctx, "profile_path", None)` was truthy under validation alone and
register() crashed with an error the real PluginContext never produces. The
parent now passes the real PluginContext method names into the probe; other
names raise AttributeError exactly like the real object.
Surfaced by the 2026-09-15 catalog sweep (Gondola provider, hermes-persona).
- hermes_cli/plugins_cmd_catalog.py: new sibling owning resolution, the
.hermes-catalog.json provenance sidecar, search/info/validate, re-pin on
update, and the dashboard/TUI payload builders. plugins_cmd.py only
gains the hooks (cmd_install catalog branch, cmd_update / dashboard
update re-pin, dashboard_install_plugin catalog_name + kill list,
dispatch entries); the community index (plugin_index.py) is gone.
- hermes_cli/plugin_catalog.py: catalog_dir parameter replaces the
test-only HERMES_PLUGIN_CATALOG_DIR env var; live refresh reads ONE
published document (/docs/api/plugin-catalog.json, 6h cache, in-tree
fallback) instead of the unauthenticated GitHub contents API (60 req/h,
1 request per entry); in-tree and live removals are unioned so a stale
cache can never un-block.
- Catalog route lives in web_routers/dashboard_ui.py (the facade is off
limits); _plugin_runtime_status shared from web_server_dashboard.py;
hub rows carry removed_reason. TUI plugins.manage gains catalog_name
install, catalog row fields and an update action.
- plugin_validate: the probe context honours ctx.get_config defaults
(real plugins do int(ctx.get_config("timeout", 180)) in register()).
- Installed-state merge matches through the sidecar's catalog_name
first — catalog names rarely equal manifest names.
- extract-plugins.py emits plugin-catalog.json; deploy-site triggers on
plugin-catalog/** so entry merges republish it.
New hermes_cli/plugin_validate.py — the checks behind
'hermes plugins validate <dir>': manifest fields, strict requires_hermes
spec parsing, config: shape, UPPER_SNAKE requires_env, a capability
probe that imports the plugin and calls register(ctx) against a
recording stub in a scratch subprocess (throwaway HERMES_HOME, 30s
timeout) and diffs actual registrations against provides_* (undeclared
= fail, unregistered = warn), plus built-in tool collision checks via
the discovered tool registry.