Plugin execution gated behind plugins.enabled config flag

NousResearch/hermes-agent · cluster 46435 · dashboard

Data as of

Nothing open on GitHub. Fix merged in #51950. Check it covers your case.

The short answer

0open issues
0open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #51950. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

A security issue was identified where user plugins could be loaded and executed via the dashboard without checking the plugins.enabled configuration gate. These changes add that gate to plugin manifest loading, asset serving, and backend API mounting.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #51950 revert(plugins): restore user dashboard plugin backend API auto-import (#43719)

    merged to main

    files: hermes_cli/web_server.py, plugins/hermes-achievements/README.md, tests/hermes_cli/test_project_plugin_rce_bypass.py, tests/hermes_cli/test_web_server.py, website/docs/reference/environment-variables.md, website/docs/user-guide/features/extending-the-dashboard.md

    Show the diff
    • hermes_cli/web_server.py
      @@ -12277,20 +12277,12 @@ def _safe_plugin_api_relpath(api_field: Any, *, dashboard_dir: Path) -> Optional
           return api_field
       
       
      -# Plugin sources whose Python backend (dashboard manifest `api` file) must NEVER
      -# be auto-imported by the dashboard web server — only bundled plugins may. Shared
      -# by the discovery-time scrub and the mount-time refuse guards so a typo in one
      -# site cannot silently disable a security gate (GHSA-5qr3-c538-wm9j / #43719).
      -_NON_BUNDLED_PLUGIN_SOURCES = frozenset({"user", "project"})
      -
      -
       def _discover_dashboard_plugins() -> list:
           """Scan plugins/*/dashboard/manifest.json for dashboard extensions.
       
      -    Checks three plugin sources. Bundled dashboard plugins win name conflicts
      -    so non-bundled plugins cannot shadow trusted backend-capable routes:
      -    1. Bundled plugins: <repo>/plugins/<name>/dashboard/manifest.json  (memory/, etc.)
      -    2. User plugins:    ~/.hermes/plugins/<name>/dashboard/manifest.json
      +    Checks three plugin sources (same as hermes_cli.plugins):
      +    1. User plugins:    ~/.hermes/plugins/<name>/dashboard/manifest.json
      +    2. Bundled plugins: <repo>/plugins/<name>/dashboard/manifest.json  (memory/, etc.)
           3. Project plugins: ./.hermes/plugins/  (only if HERMES_ENABLE_PROJECT_PLUGINS)
           """
           plugins = []
      @@ -12299,9 +12291,9 @@ def _discover_dashboard_plugins() -> list:
           from hermes_cli.plugins import get_bundled_plugins_dir
           bundled_root = get_bundled_plugins_dir()
           search_dirs = [
      +        (get_hermes_home() / "plugins", "user"),
               (bundled_root / "memory", "bundled"),
               (bundled_root, "bundled"),
      -        (get_hermes_home() / "plugins", "user"),
           ]
           # GHSA-5qr3-c538-wm9j (#29156): the previous ``os.environ.get(...)``
           # check treated *any* non-empty string as truthy, so ``=0``, ``=false``,
      @@ -12360,20 +12352,10 @@ def _discover_dashboard_plugins() -> list:
                       raw_api = data.get("api")
                       dashboard_dir = child / "dashboard"
                       safe_api = _safe_plugin_api_relpath(raw_api, dashboard_dir=dashboard_dir)
      -                if source in _NON_BUNDLED_PLUGIN_SOURCES and safe_api:
      -                    _log.warning(
      -                        "Plugin %s: refusing dashboard backend api=%s "
      -                        "(only bundled plugins may auto-import Python "
      -                        "backend routes; non-bundled plugins may extend "
      -                        "the dashboard with static UI assets only)",
      -                        name, safe_api,
      -                    )
      -                    safe_api = None
      -                    raw_api = None
                       if raw_api and safe_api is None:
                           _log.warning(
                               "Plugin %s: refusing unsafe api path %r (must be a "
      -                        "relative file inside a bundled plugin's dashboard/ "
      +                        "relative file inside the plugin's dashboard/ "
                               "directory); backend routes from this plugin will "
                               "not be mounted",
                               name, raw_api,
      @@ -12780,36 +12762,23 @@ def _mount_plugin_api_routes():
           a ``router`` (FastAPI APIRouter).  Routes are mounted under
           ``/api/plugins/<name>/``.
       
      -    Backend import is restricted to bundled plugins. User and project
      -    plugins can extend the dashboard UI via static JS/CSS, but their
      -    Python ``api`` files are never auto-imported by the web server.
      -    See GHSA-5qr3-c538-wm9j (#29156) and #43719.
      +    Backend import is restricted to ``bundled`` and ``user`` sources.
      +    Project plugins (``./.hermes/plugins/``) ship with the CWD and are
      +    therefore attacker-controlled in any threat model where the user
      +    opens a malicious repo; they can extend the dashboard UI via
      +    static JS/CSS but their Python ``api`` file is never auto-imported
      +    by the web server.  See GHSA-5qr3-c538-wm9j (#29156).
           """
           for plugin in _get_dashboard_plugins():
               api_file_name = plugin.get("_api_file")
               if not api_file_name:
                   continue
      -        source = plugin.get("source")
      -        if source in _NON_BUNDLED_PLUGIN_SOURCES:
      -            # Backend Python auto-import is reserved for bundled plugins; user
      -            # and project plugins extend the dashboard with static UI assets
      -            # only (GHSA-5qr3-c538-wm9j / #43719). Defence-in-depth: discovery
      -            # already nulls _api_file for these sources, but re-refusing here —
      -            # at the actual importlib call site — keeps the import primitive
      -            # contained even if a future caller or a tampered cache entry slips
      -            # a non-bundled plugin through with an _api_file set.
      -            _reason = {
      -                "user": (
      -                    "user-installed plugins may not auto-import Python code"
      -                ),
      -                "project": (
      -                    "project plugins may not auto-import Python code; backend "
      -                    "auto-import is reserved for bundled plugins"
      -                ),
      -            }.get(source, "only bundled plugins may auto-import Python code")
      +        if plugin.get("source") == "project":
                   _log.warning(
      -                "Plugin %s: ignoring backend api=%s (%s)",
      -                plugin["name"], api_file_name, _reason,
      +                "Plugin %s: ignoring backend api=%s (project plugins may "
      +                "not auto-import Python code; move the plugin to "
      +                "~/.hermes/plugins/ if you trust it)",
      +                plugin["name"], api_file_name,
                   )
                   continue
               dashboard_dir = Path(plugin["_dir"])
    • plugins/hermes-achievements/README.md
      @@ -77,9 +77,7 @@ Then rescan dashboard plugins:
       curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
       ```
       
      -When installed as a user plugin, the dashboard UI loads but Python backend API
      -routes are not auto-imported. Backend routes are available when this plugin is
      -bundled with Hermes.
      +If backend API routes 404, restart `hermes dashboard`; plugin APIs are mounted at dashboard startup.
       
       ## Updating
       
      @@ -91,11 +89,7 @@ git pull --ff-only
       curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
       ```
       
      -For a user-installed plugin at `~/.hermes/plugins/hermes-achievements`, a plugin
      -rescan is enough because Python backend routes are not auto-imported. If you
      -update the bundled plugin by pulling changes in the hermes-agent repository, and
      -that bundled plugin update changes backend routes or `plugin_api.py`, restart
      -`hermes dashboard` after pulling.
      +If the update changes backend routes or `plugin_api.py`, restart `hermes dashboard` after pulling.
       
       As of 2026-04-29, updating is strongly recommended because scan performance changed significantly:
       - removed duplicate `/overview` scan path
      @@ -124,9 +118,6 @@ dashboard/
       
       ## API
       
      -These backend routes are mounted for the bundled plugin. User-installed copies
      -load their dashboard UI but do not auto-import Python backend routes.
      -
       Routes are mounted under:
       
       ```text
    • tests/hermes_cli/test_project_plugin_rce_bypass.py
      @@ -24,7 +24,7 @@
       * ``_safe_plugin_api_relpath`` rejects absolute paths, ``..``
         traversal, and non-string / empty values.
       * ``_mount_plugin_api_routes`` re-validates at import time and
      -  refuses user/project-source plugin backend code outright.
      +  refuses project-source plugins outright.
       * End-to-end the original PoC manifest no longer triggers
         ``importlib`` for ``/tmp/payload.py``.
       """
      @@ -216,7 +216,7 @@ def test_traversal_api_path_in_manifest_is_scrubbed(self, user_plugin_factory):
               assert entry["_api_file"] is None
               assert entry["has_api"] is False
       
      -    def test_user_safe_api_path_is_scrubbed(self, user_plugin_factory, tmp_path):
      +    def test_safe_api_path_survives(self, user_plugin_factory, tmp_path):
               user_plugin_factory("safe", {
                   "name": "safe",
                   "label": "Safe",
      @@ -230,86 +230,6 @@ def test_user_safe_api_path_is_scrubbed(self, user_plugin_factory, tmp_path):
               )
               plugins = web_server._get_dashboard_plugins(force_rescan=True)
               entry = next(p for p in plugins if p["name"] == "safe")
      -        assert entry["_api_file"] is None
      -        assert entry["has_api"] is False
      -
      -    def test_project_safe_api_path_is_scrubbed(self, tmp_path, monkeypatch):
      -        monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
      -        (tmp_path / "home").mkdir()
      -        monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "1")
      -        cwd = tmp_path / "project"
      -        cwd.mkdir()
      -        monkeypatch.chdir(cwd)
      -        dashboard = _write_plugin_manifest(
      -            cwd / ".hermes" / "plugins",
      -            "safe-project",
      -            {
      -                "name": "safe-project",
      -                "label": "Safe Project",
      -                "api": "api.py",
      -                "entry": "dist/index.js",
      -            },
      -        )
      -        (dashboard / "api.py").write_text("router = None\n")
      -
      -        plugins = web_server._get_dashboard_plugins(force_rescan=True)
      -        entry = next(p for p in plugins if p["name"] == "safe-project")
      -        assert entry["_api_file"] is None
      -        assert entry["has_api"] is False
      -
      -    def test_bundled_safe_api_path_survives(self, tmp_path, monkeypatch):
      -        hermes_home = tmp_path / "home"
      -        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      -        hermes_home.mkdir()
      -        monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", str(tmp_path / "bundled"))
      -        dashboard = _write_plugin_manifest(
      -            tmp_path / "bundled",
      -            "safe-bundled",
      -            {
      -                "name": "safe-bundled",
      -                "label": "Safe Bundled",
      -                "api": "api.py",
      -                "entry": "dist/index.js",
      -            },
      -        )
      -        (dashboard / "api.py").write_text("router = None\n")
      -
      -        plugins = web_server._get_dashboard_plugins(force_rescan=True)
      -        entry = next(p for p in plugins if p["name"] == "safe-bundled")
      -        assert entry["_api_file"] == "api.py"
      -        assert entry["has_api"] is True
      -
      -    def test_user_plugin_does_not_shadow_bundled_backend(self, tmp_path, monkeypatch):
      -        hermes_home = tmp_path / "home"
      -        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      -        hermes_home.mkdir()
      -        monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", str(tmp_path / "bundled"))
      -
      -        bundled_dashboard = _write_plugin_manifest(
      -            tmp_path / "bundled",
      -            "shadowed",
      -            {
      -                "name": "shadowed",
      -                "label": "Bundled Shadowed",
      -                "api": "api.py",
      -                "entry": "dist/index.js",
      -            },
      -        )
      -        (bundled_dashboard / "api.py").write_text("router = None\n")
      -        _write_plugin_manifest(
      -            hermes_home / "plugins",
      -            "shadowed",
      -            {
      -                "name": "shadowed",
      -                "label": "User Shadowed",
      -                "api": "api.py",
      -                "entry": "dist/index.js",
      -            },
      -        )
      -
      -        plugins = web_server._get_dashboard_plugins(force_rescan=True)
      -        entry = next(p for p in plugins if p["name"] == "shadowed")
      -        assert entry["source"] == "bundled"
               assert entry["_api_file"] == "api.py"
               assert entry["has_api"] is True
       
      @@ -356,16 +276,6 @@ def test_project_source_api_is_not_imported(self, tmp_path):
                   "GHSA-5qr3-c538-wm9j defence-in-depth regression"
               )
       
      -    def test_user_source_api_is_not_imported(self, tmp_path):
      -        plugin = self._payload_plugin(tmp_path, source="user")
      -        web_server._dashboard_plugins_cache = [plugin]
      -        with patch("importlib.util.spec_from_file_location") as spec:
      -            web_server._mount_plugin_api_routes()
      -        assert spec.call_count == 0, (
      -            "user-installed plugin api file was imported — "
      -            "third-party dashboard plugin backend code must stay inert"
      -        )
      -
           def test_bundled_source_api_imports_normally(self, tmp_path):
               plugin = self._payload_plugin(tmp_path, source="bundled")
               web_server._dashboard_plugins_cache = [plugin]
    • tests/hermes_cli/test_web_server.py
      @@ -5095,8 +5095,14 @@ class TestPluginAPIAuth:
           """Tests that plugin API routes require the session token (issue #19533)."""
       
           @pytest.fixture(autouse=True)
      -    def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
      -        """Create TestClients with and without the session token header."""
      +    def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin):
      +        """Create a TestClient without the session token header.
      +
      +        Pulls in ``_install_example_plugin`` so ``test_plugin_route_allows_auth``
      +        has the ``/api/plugins/example/hello`` endpoint available — the
      +        example plugin is no longer a bundled plugin, so the fixture
      +        installs it into the per-test ``HERMES_HOME``.
      +        """
               try:
                   from starlette.testclient import TestClient
               except ImportError:
      @@ -5121,15 +5127,19 @@ def test_plugin_route_requires_auth(self):
           def test_plugin_route_allows_auth(self):
               """Plugin API routes should work with a valid session token.
       
      -        Uses a bundled plugin route so the test covers authenticated plugin
      -        API access without relying on user-installed plugin backend imports.
      +        Uses ``/api/plugins/example/hello`` from the example-dashboard
      +        test fixture (installed into HERMES_HOME by the class-level
      +        ``_install_example_plugin`` fixture) — a stable, side-effect-free
      +        GET that's only loaded for tests. With a valid token the handler
      +        should run (200); without one the middleware should 401 before
      +        the handler is reached.
               """
               # Without auth: middleware blocks before reaching the handler.
      -        resp = self.client.get("/api/plugins/kanban/board")
      +        resp = self.client.get("/api/plugins/example/hello")
               assert resp.status_code == 401
       
               # With auth: handler runs.
      -        resp = self.auth_client.get("/api/plugins/kanban/board")
      +        resp = self.auth_client.get("/api/plugins/example/hello")
               assert resp.status_code == 200
       
           def test_plugin_post_requires_auth(self):
    • website/docs/reference/environment-variables.md
      @@ -625,7 +625,7 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us
       | `HERMES_AGENT_NOTIFY_INTERVAL` | Gateway: interval in seconds between progress notifications on long-running agent turns. |
       | `HERMES_CHECKPOINT_TIMEOUT` | Timeout for filesystem checkpoint creation in seconds (default: `30`). |
       | `HERMES_EXEC_ASK` | Enable execution approval prompts in gateway mode (`true`/`false`) |
      -| `HERMES_ENABLE_PROJECT_PLUGINS` | Enable auto-discovery of repo-local plugins from `./.hermes/plugins/` for both the agent loader and the dashboard web server. Accepts the standard truthy set: `1` / `true` / `yes` / `on` (case-insensitive). Everything else — including `0`, `false`, `no`, `off`, and the empty string — is treated as **disabled** (default). Note: as of GHSA-5qr3-c538-wm9j (#29156) and #43719, the dashboard web server refuses to auto-import Python `api` files from project or user-installed plugins — they may extend the UI via static JS/CSS, while backend routes are reserved for bundled plugins. |
      +| `HERMES_ENABLE_PROJECT_PLUGINS` | Enable auto-discovery of repo-local plugins from `./.hermes/plugins/` for both the agent loader and the dashboard web server. Accepts the standard truthy set: `1` / `true` / `yes` / `on` (case-insensitive). Everything else — including `0`, `false`, `no`, `off`, and the empty string — is treated as **disabled** (default). Note: as of GHSA-5qr3-c538-wm9j (#29156) the dashboard web server refuses to auto-import a project plugin's Python `api` file even when this var is enabled — project plugins may extend the UI via static JS/CSS but their backend routes are only loaded when moved under `~/.hermes/plugins/`. |
       | `HERMES_PLUGINS_DEBUG` | `1`/`true` to surface verbose plugin-discovery logs on stderr — directories scanned, manifests parsed, skip reasons, and full tracebacks on parse or `register()` failure. Aimed at plugin authors. |
       | `HERMES_BACKGROUND_NOTIFICATIONS` | Background process notification mode in gateway: `all` (default), `result`, `error`, `off` |
       | `HERMES_EPHEMERAL_SYSTEM_PROMPT` | Ephemeral system prompt injected at API-call time (never persisted to sessions) |
    • website/docs/user-guide/features/extending-the-dashboard.md
      @@ -431,14 +431,14 @@ If you prefer JSX, use any bundler (esbuild, Vite, rollup) with React as an exte
           ├── dist/
           │   ├── index.js         # required — pre-built JS bundle (IIFE)
           │   └── style.css        # optional — custom CSS
      -    └── plugin_api.py        # bundled plugins only — backend API routes (FastAPI)
      +    └── plugin_api.py        # optional — backend API routes (FastAPI)
       ```
       
       A single plugin directory can carry three orthogonal extensions:
       
       - `plugin.yaml` + `__init__.py` — CLI/gateway plugin ([see plugins page](./plugins)).
       - `dashboard/manifest.json` + `dashboard/dist/index.js` — dashboard UI plugin.
      -- `dashboard/plugin_api.py` — bundled plugins only; backend API routes.
      +- `dashboard/plugin_api.py` — dashboard backend routes.
       
       None of them are required; include only the layers you need.
       
      @@ -743,10 +743,7 @@ Routes are mounted under `/api/plugins/<name>/`, so the above becomes:
       - `GET  /api/plugins/my-plugin/data`
       - `POST /api/plugins/my-plugin/action`
       
      -Security notes:
      -
      -- Bundled plugin API routes bypass session-token authentication. The dashboard server binds to localhost by default, which mitigates the risks of this bypass.
      -- User-installed and project dashboard plugins may still extend the UI with static JS/CSS, but their Python `api` files are not auto-imported by the dashboard server. Backend routes are reserved for bundled plugins.
      +Plugin API routes bypass session-token authentication since the dashboard server binds to localhost by default. **Don't expose the dashboard on a public interface with `--host 0.0.0.0` if you run untrusted plugins** — their routes become reachable too.
       
       #### Accessing Hermes internals
       
      @@ -807,14 +804,11 @@ The dashboard scans three directories for `dashboard/manifest.json`:
       
       | Priority | Directory | Source label |
       |----------|-----------|--------------|
      -| 1 (wins on conflict) | `<repo>/plugins/memory/<name>/dashboard/` | `bundled` |
      -| 1 (wins on conflict) | `<repo>/plugins/<name>/dashboard/` | `bundled` |
      -| 2 | `~/.hermes/plugins/<name>/dashboard/` | `user` |
      +| 1 (wins on conflict) | `~/.hermes/plugins/<name>/dashboard/` | `user` |
      +| 2 | `<repo>/plugins/memory/<name>/dashboard/` | `bundled` |
      +| 2 | `<repo>/plugins/<name>/dashboard/` | `bundled` |
       | 3 | `./.hermes/plugins/<name>/dashboard/` | `project` — only when `HERMES_ENABLE_PROJECT_PLUGINS` is set |
       
      -Bundled dashboard plugins win name conflicts because only bundled plugins may
      -register backend routes. Give user and project dashboard plugins unique names.
      -
       Discovery results are cached per dashboard process. After adding a new plugin, either:
       
       ```bash
      @@ -914,11 +908,10 @@ Check that the file is in `~/.hermes/dashboard-themes/` and ends in `.yaml` or `
       The `sidebar` slot only renders when the active theme has `layoutVariant: cockpit`. Other slots always render. If you're registering into a slot with no hits, add `console.log` inside `registerSlot` to confirm the plugin bundle ran at all.
       
       **Plugin backend routes return 404.**
      -1. Confirm the plugin is bundled with Hermes. User-installed and project dashboard plugins can extend the UI, but their Python backend routes are not auto-imported.
      -2. Confirm the manifest has `"api": "plugin_api.py"` pointing to an existing file inside `dashboard/`.
      -3. Restart `hermes dashboard` — plugin API routes are mounted once at startup, **not** on rescan.
      -4. Check that `plugin_api.py` exports a module-level `router = APIRouter()`. Other export names are not picked up.
      -5. Tail `~/.hermes/logs/errors.log` for `Failed to load plugin <name> API routes` — import errors are logged there.
      +1. Confirm the manifest has `"api": "plugin_api.py"` pointing to an existing file inside `dashboard/`.
      +2. Restart `hermes dashboard` — plugin API routes are mounted once at startup, **not** on rescan.
      +3. Check that `plugin_api.py` exports a module-level `router = APIRouter()`. Other export names are not picked up.
      +4. Tail `~/.hermes/logs/errors.log` for `Failed to load plugin <name> API routes` — import errors are logged there.
       
       **Theme change drops my color overrides.**
       `colorOverrides` are scoped to the active theme and cleared on theme switch — that's by design. If you want overrides that persist, put them in your theme's YAML, not in the live switcher.
  • #56282 fix(dashboard): gate user plugin asset serving + backend import on plugins.enabled

    merged to main

    files: hermes_cli/web_server.py, tests/hermes_cli/test_plugin_runtime_disable_gate.py, tests/hermes_cli/test_web_server.py

    Show the diff
    • hermes_cli/web_server.py
      @@ -477,6 +477,73 @@ async def host_header_middleware(request: Request, call_next):
           return await call_next(request)
       
       
      [redacted:email]("http")
      +async def _plugin_api_runtime_gate(request: Request, call_next):
      +    """Block requests to disabled plugin API routes at request time.
      +
      +    :func:`_mount_plugin_api_routes` gates at import time, but if a plugin
      +    is disabled *after* the dashboard is already running, its FastAPI router
      +    remains mounted until restart.  This middleware enforces the enabled/
      +    disabled policy on every request to ``/api/plugins/{name}/...`` so that
      +    runtime config changes take effect immediately.
      +
      +    Registered BEFORE the auth middlewares (so it executes AFTER them): a
      +    request that hasn't cleared auth must get auth's 401 first, never this
      +    gate's 404 — otherwise an unauthenticated caller could fingerprint which
      +    plugins are installed/enabled by reading the status code. We only reach
      +    the enabled/disabled check for a request that auth already let through.
      +    """
      +    path = request.url.path
      +    if path.startswith("/api/plugins/"):
      +        # Only gate authenticated requests. Unauthenticated ones fall
      +        # through so auth_middleware / the OAuth gate return 401 first and
      +        # this route can't be used as a plugin-name oracle.
      +        _authed = (
      +            getattr(request.state, "token_authenticated", False)
      +            or getattr(request.app.state, "auth_required", False)
      +            or _has_valid_session_token(request)
      +            or _has_valid_query_token(request, path)
      +        )
      +        if _authed:
      +            # Extract plugin name from /api/plugins/<name>/...
      +            parts = path.split("/")
      +            # parts: ['', 'api', 'plugins', '<name>', ...]
      +            if len(parts) >= 4:
      +                plugin_name = parts[3]
      +                if plugin_name:
      +                    try:
      +                        from hermes_cli.plugins_cmd import (
      +                            _get_enabled_set,
      +                            _get_disabled_set,
      +                        )
      +                        enabled_set = _get_enabled_set()
      +                        disabled_set = _get_disabled_set()
      +                    except Exception:
      +                        enabled_set = set()
      +                        disabled_set = set()
      +                    # Determine plugin source.  Check the cached plugin list;
      +                    # if not found, assume user plugin (safe default — blocks).
      +                    plugins = _get_dashboard_plugins()
      +                    plugin = next(
      +                        (p for p in plugins if p.get("name") == plugin_name),
      +                        None,
      +                    )
      +                    source = plugin.get("source") if plugin else "user"
      +                    if source == "user":
      +                        if plugin_name in disabled_set or plugin_name not in enabled_set:
      +                            return JSONResponse(
      +                                status_code=404,
      +                                content={"detail": "Plugin not found"},
      +                            )
      +                    elif source == "bundled":
      +                        if plugin_name in disabled_set:
      +                            return JSONResponse(
      +                                status_code=404,
      +                                content={"detail": "Plugin not found"},
      +                            )
      +    return await call_next(request)
      +
      +
       # ---------------------------------------------------------------------------
       # Dashboard OAuth auth gate — engaged only when start_server flags the
       # bind as non-loopback-without-insecure.  No-op pass-through in loopback
      @@ -13453,16 +13520,41 @@ def _get_dashboard_plugins(force_rescan: bool = False) -> list:
       
       @app.get("/api/dashboard/plugins")
       async def get_dashboard_plugins():
      -    """Return discovered dashboard plugins (excludes user-hidden ones)."""
      +    """Return discovered dashboard plugins (excludes user-hidden and non-enabled ones)."""
           plugins = _get_dashboard_plugins()
           # Read user's hidden plugins list from config.
           config = load_config()
           hidden: list = cfg_get(config, "dashboard", "hidden_plugins", default=[]) or []
      -    # Strip internal fields before sending to frontend and filter out hidden.
      +    # Gate: only serve user plugins that are in plugins.enabled and not
      +    # in plugins.disabled.  This prevents the frontend from loading JS/CSS
      +    # from plugins the user has not explicitly activated.  (#46435)
      +    try:
      +        from hermes_cli.plugins_cmd import _get_enabled_set, _get_disabled_set
      +        enabled_set = _get_enabled_set()
      +        disabled_set = _get_disabled_set()
      +    except Exception:
      +        enabled_set = set()
      +        disabled_set = set()
      +
      +    def _is_active(p: dict) -> bool:
      +        name = p.get("name", "")
      +        if name in hidden:
      +            return False
      +        if p.get("source") == "user":
      +            if name in disabled_set:
      +                return False
      +            if name not in enabled_set:
      +                return False
      +        elif p.get("source") == "bundled":
      +            if name in disabled_set:
      +                return False
      +        return True
      +
      +    # Strip internal fields before sending to frontend.
           return [
               {k: v for k, v in p.items() if not k.startswith("_")}
               for p in plugins
      -        if p["name"] not in hidden
      +        if _is_active(p)
           ]
       
       
      @@ -13759,12 +13851,31 @@ async def serve_plugin_asset(plugin_name: str, file_path: str):
           allowlist, anyone on the loopback port can curl the ``.py`` source
           of a private third-party plugin. Reject everything outside the
           browser-asset set.
      +
      +    User plugins must be in plugins.enabled before their assets are
      +    served. (#46435, GHSA-mcfc-hp25-cjv7)
           """
           plugins = _get_dashboard_plugins()
           plugin = next((p for p in plugins if p["name"] == plugin_name), None)
           if not plugin:
               raise HTTPException(status_code=404, detail="Plugin not found")
       
      +    # Gate: user plugins must be enabled to serve assets;
      +    # bundled plugins must not be explicitly disabled.
      +    try:
      +        from hermes_cli.plugins_cmd import _get_enabled_set, _get_disabled_set
      +        enabled_set = _get_enabled_set()
      +        disabled_set = _get_disabled_set()
      +    except Exception:
      +        enabled_set = set()
      +        disabled_set = set()
      +    if plugin.get("source") == "user":
      +        if plugin_name in disabled_set or plugin_name not in enabled_set:
      +            raise HTTPException(status_code=404, detail="Plugin not found")
      +    elif plugin.get("source") == "bundled":
      +        if plugin_name in disabled_set:
      +            raise HTTPException(status_code=404, detail="Plugin not found")
      +
           base = Path(plugin["_dir"])
           target = (base / file_path).resolve()
       
      @@ -13824,11 +13935,52 @@ def _mount_plugin_api_routes():
           opens a malicious repo; they can extend the dashboard UI via
           static JS/CSS but their Python ``api`` file is never auto-imported
           by the web server.  See GHSA-5qr3-c538-wm9j (#29156).
      +
      +    Additionally, user plugins must be explicitly enabled via the
      +    ``plugins.enabled`` allow-list in config.yaml before their backend
      +    code is imported. Without this gate, an installed-but-not-enabled
      +    plugin's Python code would execute at dashboard startup — a code
      +    execution vector that bypasses the user's intent. (#46435,
      +    GHSA-mcfc-hp25-cjv7)
           """
      +    # Load the enabled/disabled sets once for the loop.
      +    try:
      +        from hermes_cli.plugins_cmd import _get_enabled_set, _get_disabled_set
      +        enabled_set = _get_enabled_set()
      +        disabled_set = _get_disabled_set()
      +    except Exception:
      +        enabled_set = set()
      +        disabled_set = set()
      +
           for plugin in _get_dashboard_plugins():
               api_file_name = plugin.get("_api_file")
               if not api_file_name:
                   continue
      +        plugin_name = plugin.get("name", "")
      +        # Gate: user plugins must be in plugins.enabled and not in
      +        # plugins.disabled before we import their Python code.
      +        # Bundled plugins are trusted (they ship with the release) but
      +        # still respect an explicit disable.
      +        if plugin.get("source") == "user":
      +            if plugin_name in disabled_set:
      +                _log.debug(
      +                    "Plugin %s: skipping API mount (explicitly disabled)",
      +                    plugin_name,
      +                )
      +                continue
      +            if plugin_name not in enabled_set:
      +                _log.debug(
      +                    "Plugin %s: skipping API mount (not in plugins.enabled)",
      +                    plugin_name,
      +                )
      +                continue
      +        elif plugin.get("source") == "bundled":
      +            if plugin_name in disabled_set:
      +                _log.debug(
      +                    "Plugin %s: skipping API mount (explicitly disabled)",
      +                    plugin_name,
      +                )
      +                continue
               if plugin.get("source") == "project":
                   _log.warning(
                       "Plugin %s: ignoring backend api=%s (project plugins may "
    • tests/hermes_cli/test_plugin_runtime_disable_gate.py
      @@ -0,0 +1,401 @@
      +"""Regression tests for runtime plugin disable gating.
      +
      +Covers two residual bypasses addressed in the PR:
      +
      +1. Plugin API routes mounted at startup remain callable even after the
      +   plugin is added to ``plugins.disabled`` at runtime.  The new
      +   ``_plugin_api_runtime_gate`` middleware blocks these requests.
      +
      +2. Bundled plugin assets were served from the unauthenticated
      +   ``/dashboard-plugins/{name}/{path}`` route even when the bundled
      +   plugin was in ``plugins.disabled``.  The updated ``serve_plugin_asset``
      +   now applies the disabled check to bundled plugins too.
      +"""
      +from __future__ import annotations
      +
      +import json
      +from pathlib import Path
      +from unittest.mock import patch, AsyncMock
      +
      +import pytest
      +
      +from hermes_cli import web_server
      +
      +
      [redacted:email](autouse=True)
      +def _reset_plugin_cache():
      +    """Bust the plugin cache before and after each test."""
      +    web_server._dashboard_plugins_cache = None
      +    yield
      +    web_server._dashboard_plugins_cache = None
      +
      +
      [redacted:email]
      +def test_client(monkeypatch, tmp_path):
      +    """Set up a Starlette TestClient with auth bypassed."""
      +    try:
      +        from starlette.testclient import TestClient
      +    except ImportError:
      +        pytest.skip("fastapi/starlette not installed")
      +
      +    from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
      +
      +    # Isolate HERMES_HOME so config reads go to our tmp.
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
      +    (tmp_path / "home").mkdir(parents=True)
      +
      +    client = TestClient(app)
      +    client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
      +    return client
      +
      +
      +# ---------------------------------------------------------------------------
      +# Helpers
      +# ---------------------------------------------------------------------------
      +
      +def _make_user_plugin(tmp_path, name="hot"):
      +    """Create a minimal user plugin with a JS asset."""
      +    dashboard_dir = tmp_path / "plugins" / name / "dashboard"
      +    dashboard_dir.mkdir(parents=True)
      +    dist_dir = dashboard_dir / "dist"
      +    dist_dir.mkdir()
      +    (dist_dir / "index.js").write_text("console.log('hello');")
      +    (dashboard_dir / "manifest.json").write_text(json.dumps({
      +        "name": name,
      +        "label": name.title(),
      +        "entry": "dist/index.js",
      +    }))
      +    return dashboard_dir
      +
      +
      +def _make_bundled_plugin(tmp_path, name="bundledx"):
      +    """Create a minimal bundled plugin with a JS asset."""
      +    dashboard_dir = tmp_path / "bundled" / name / "dashboard"
      +    dashboard_dir.mkdir(parents=True)
      +    dist_dir = dashboard_dir / "dist"
      +    dist_dir.mkdir()
      +    (dist_dir / "index.js").write_text("console.log('bundled');")
      +    (dashboard_dir / "manifest.json").write_text(json.dumps({
      +        "name": name,
      +        "label": name.title(),
      +        "entry": "dist/index.js",
      +    }))
      +    return dashboard_dir
      +
      +
      +# ---------------------------------------------------------------------------
      +# Test 1: Runtime-disabled user plugin API routes return 404
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestPluginApiRuntimeGate:
      +    """After a user plugin is disabled at runtime, its mounted API routes
      +    must return 404 — not 200 — even though the router was already
      +    included at startup.  The _plugin_api_runtime_gate middleware enforces
      +    this at request time."""
      +
      +    @pytest.mark.asyncio
      +    async def test_middleware_blocks_disabled_user_plugin(self):
      +        """Middleware returns 404 for a user plugin added to disabled set."""
      +        from starlette.requests import Request
      +        from starlette.responses import JSONResponse
      +
      +        fake_plugin = {
      +            "name": "hot",
      +            "source": "user",
      +        }
      +
      +        # Simulate a request to /api/plugins/hot/probe
      +        scope = {
      +            "type": "http",
      +            "method": "GET",
      +            "path": "/api/plugins/hot/probe",
      +            "query_string": b"",
      +            "headers": [],
      +            "state": {"token_authenticated": True},
      +        }
      +        request = Request(scope)
      +
      +        call_next = AsyncMock(return_value=JSONResponse({"ok": True}))
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]), \
      +             patch("hermes_cli.plugins_cmd._get_enabled_set", return_value={"hot"}), \
      +             patch("hermes_cli.plugins_cmd._get_disabled_set", return_value={"hot"}):
      +            response = await web_server._plugin_api_runtime_gate(request, call_next)
      +
      +        assert response.status_code == 404
      +        call_next.assert_not_called()
      +
      +    @pytest.mark.asyncio
      +    async def test_middleware_blocks_unenabled_user_plugin(self):
      +        """Middleware returns 404 when user plugin not in enabled set."""
      +        from starlette.requests import Request
      +        from starlette.responses import JSONResponse
      +
      +        fake_plugin = {
      +            "name": "hot",
      +            "source": "user",
      +        }
      +
      +        scope = {
      +            "type": "http",
      +            "method": "GET",
      +            "path": "/api/plugins/hot/probe",
      +            "query_string": b"",
      +            "headers": [],
      +            "state": {"token_authenticated": True},
      +        }
      +        request = Request(scope)
      +
      +        call_next = AsyncMock(return_value=JSONResponse({"ok": True}))
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]), \
      +             patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set()), \
      +             patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set()):
      +            response = await web_server._plugin_api_runtime_gate(request, call_next)
      +
      +        assert response.status_code == 404
      +        call_next.assert_not_called()
      +
      +    @pytest.mark.asyncio
      +    async def test_middleware_passes_enabled_user_plugin(self):
      +        """Middleware passes through for an enabled user plugin."""
      +        from starlette.requests import Request
      +        from starlette.responses import JSONResponse
      +
      +        fake_plugin = {
      +            "name": "hot",
      +            "source": "user",
      +        }
      +
      +        scope = {
      +            "type": "http",
      +            "method": "GET",
      +            "path": "/api/plugins/hot/probe",
      +            "query_string": b"",
      +            "headers": [],
      +            "state": {"token_authenticated": True},
      +        }
      +        request = Request(scope)
      +
      +        expected_resp = JSONResponse({"ok": True})
      +        call_next = AsyncMock(return_value=expected_resp)
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]), \
      +             patch("hermes_cli.plugins_cmd._get_enabled_set", return_value={"hot"}), \
      +             patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set()):
      +            response = await web_server._plugin_api_runtime_gate(request, call_next)
      +
      +        assert response is expected_resp
      +        call_next.assert_called_once()
      +
      +    @pytest.mark.asyncio
      +    async def test_middleware_blocks_disabled_bundled_plugin(self):
      +        """Middleware returns 404 for a bundled plugin in disabled set."""
      +        from starlette.requests import Request
      +        from starlette.responses import JSONResponse
      +
      +        fake_plugin = {
      +            "name": "bundledx",
      +            "source": "bundled",
      +        }
      +
      +        scope = {
      +            "type": "http",
      +            "method": "GET",
      +            "path": "/api/plugins/bundledx/probe",
      +            "query_string": b"",
      +            "headers": [],
      +            "state": {"token_authenticated": True},
      +        }
      +        request = Request(scope)
      +
      +        call_next = AsyncMock(return_value=JSONResponse({"ok": True}))
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]), \
      +             patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set()), \
      +             patch("hermes_cli.plugins_cmd._get_disabled_set", return_value={"bundledx"}):
      +            response = await web_server._plugin_api_runtime_gate(request, call_next)
      +
      +        assert response.status_code == 404
      +        call_next.assert_not_called()
      +
      +    @pytest.mark.asyncio
      +    async def test_middleware_passes_enabled_bundled_plugin(self):
      +        """Middleware passes through for a bundled plugin not in disabled set."""
      +        from starlette.requests import Request
      +        from starlette.responses import JSONResponse
      +
      +        fake_plugin = {
      +            "name": "bundledx",
      +            "source": "bundled",
      +        }
      +
      +        scope = {
      +            "type": "http",
      +            "method": "GET",
      +            "path": "/api/plugins/bundledx/probe",
      +            "query_string": b"",
      +            "headers": [],
      +            "state": {"token_authenticated": True},
      +        }
      +        request = Request(scope)
      +
      +        expected_resp = JSONResponse({"ok": True})
      +        call_next = AsyncMock(return_value=expected_resp)
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]), \
      +             patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set()), \
      +             patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set()):
      +            response = await web_server._plugin_api_runtime_gate(request, call_next)
      +
      +        assert response is expected_resp
      +        call_next.assert_called_once()
      +
      +    @pytest.mark.asyncio
      +    async def test_middleware_passes_non_plugin_api_routes(self):
      +        """Middleware ignores non-plugin API routes."""
      +        from starlette.requests import Request
      +        from starlette.responses import JSONResponse
      +
      +        scope = {
      +            "type": "http",
      +            "method": "GET",
      +            "path": "/api/status",
      +            "query_string": b"",
      +            "headers": [],
      +            "state": {"token_authenticated": True},
      +        }
      +        request = Request(scope)
      +
      +        expected_resp = JSONResponse({"ok": True})
      +        call_next = AsyncMock(return_value=expected_resp)
      +
      +        response = await web_server._plugin_api_runtime_gate(request, call_next)
      +
      +        assert response is expected_resp
      +        call_next.assert_called_once()
      +
      +    @pytest.mark.asyncio
      +    async def test_middleware_unknown_plugin_defaults_to_user_blocks(self):
      +        """Unknown plugin name (not in discovery cache) is treated as user
      +        plugin and blocked when not enabled."""
      +        from starlette.requests import Request
      +        from starlette.responses import JSONResponse
      +
      +        scope = {
      +            "type": "http",
      +            "method": "GET",
      +            "path": "/api/plugins/unknown/action",
      +            "query_string": b"",
      +            "headers": [],
      +            "state": {"token_authenticated": True},
      +        }
      +        request = Request(scope)
      +
      +        call_next = AsyncMock(return_value=JSONResponse({"ok": True}))
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[]), \
      +             patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set()), \
      +             patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set()):
      +            response = await web_server._plugin_api_runtime_gate(request, call_next)
      +
      +        assert response.status_code == 404
      +        call_next.assert_not_called()
      +
      +
      +# ---------------------------------------------------------------------------
      +# Test 2: Disabled bundled plugin assets return 404
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestBundledPluginAssetGate:
      +    """Bundled plugins in ``plugins.disabled`` must have their static
      +    assets blocked — not just hidden from the listing endpoint."""
      +
      +    def test_bundled_asset_returns_404_when_disabled(self, test_client, tmp_path, monkeypatch):
      +        """A disabled bundled plugin's JS asset must return 404."""
      +        plugin_dir = _make_bundled_plugin(tmp_path, "bundledx")
      +
      +        fake_plugin = {
      +            "name": "bundledx",
      +            "label": "Bundledx",
      +            "source": "bundled",
      +            "entry": "dist/index.js",
      +            "_dir": str(plugin_dir),
      +        }
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]):
      +            # Sanity: asset is served when not disabled.
      +            with patch(
      +                "hermes_cli.plugins_cmd._get_enabled_set", return_value=set()
      +            ), patch(
      +                "hermes_cli.plugins_cmd._get_disabled_set", return_value=set()
      +            ):
      +                resp = test_client.get("/dashboard-plugins/bundledx/dist/index.js")
      +                assert resp.status_code == 200, (
      +                    "Sanity: bundled plugin asset should be served when not disabled"
      +                )
      +
      +            # Disable it.
      +            with patch(
      +                "hermes_cli.plugins_cmd._get_enabled_set", return_value=set()
      +            ), patch(
      +                "hermes_cli.plugins_cmd._get_disabled_set", return_value={"bundledx"}
      +            ):
      +                resp = test_client.get("/dashboard-plugins/bundledx/dist/index.js")
      +                assert resp.status_code == 404, (
      +                    "Disabled bundled plugin asset must return 404"
      +                )
      +
      +    def test_bundled_asset_served_when_not_disabled(self, test_client, tmp_path, monkeypatch):
      +        """Bundled plugin assets are served normally when not in disabled set."""
      +        plugin_dir = _make_bundled_plugin(tmp_path, "goodbundled")
      +
      +        fake_plugin = {
      +            "name": "goodbundled",
      +            "label": "Good Bundled",
      +            "source": "bundled",
      +            "entry": "dist/index.js",
      +            "_dir": str(plugin_dir),
      +        }
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]):
      +            with patch(
      +                "hermes_cli.plugins_cmd._get_enabled_set", return_value=set()
      +            ), patch(
      +                "hermes_cli.plugins_cmd._get_disabled_set", return_value=set()
      +            ):
      +                resp = test_client.get("/dashboard-plugins/goodbundled/dist/index.js")
      +                assert resp.status_code == 200
      +
      +    def test_user_plugin_asset_still_gated(self, test_client, tmp_path, monkeypatch):
      +        """User plugins still require enabled set membership for assets."""
      +        plugin_dir = _make_user_plugin(tmp_path, "userplugin")
      +
      +        fake_plugin = {
      +            "name": "userplugin",
      +            "label": "User Plugin",
      +            "source": "user",
      +            "entry": "dist/index.js",
      +            "_dir": str(plugin_dir),
      +        }
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]):
      +            # Not in enabled set → 404.
      +            with patch(
      +                "hermes_cli.plugins_cmd._get_enabled_set", return_value=set()
      +            ), patch(
      +                "hermes_cli.plugins_cmd._get_disabled_set", return_value=set()
      +            ):
      +                resp = test_client.get("/dashboard-plugins/userplugin/dist/index.js")
      +                assert resp.status_code == 404
      +
      +            # In enabled set → 200.
      +            with patch(
      +                "hermes_cli.plugins_cmd._get_enabled_set", return_value={"userplugin"}
      +            ), patch(
      +                "hermes_cli.plugins_cmd._get_disabled_set", return_value=set()
      +            ):
      +                resp = test_client.get("/dashboard-plugins/userplugin/dist/index.js")
      +                assert resp.status_code == 200
    • tests/hermes_cli/test_web_server.py
      @@ -66,6 +66,24 @@ def _install_example_plugin(_isolate_hermes_home):
               shutil.rmtree(dst)
           shutil.copytree(_EXAMPLE_PLUGIN_FIXTURE, dst)
       
      +    # The dashboard now gates user-plugin asset serving + backend import
      +    # behind the ``plugins.enabled`` allow-list (GHSA-mcfc-hp25-cjv7).
      +    # An installed-but-not-enabled user plugin has its API mount skipped
      +    # and its assets 404'd — which is the whole point of the gate. These
      +    # fixtures exist to exercise the *serving* paths, so opt the example
      +    # plugin in exactly as a real operator would with `hermes plugins
      +    # enable example`.
      +    from hermes_cli.config import load_config, save_config
      +    _cfg = load_config()
      +    _plugins_cfg = _cfg.setdefault("plugins", {})
      +    _enabled = _plugins_cfg.get("enabled")
      +    if not isinstance(_enabled, list):
      +        _enabled = []
      +    if "example" not in _enabled:
      +        _enabled.append("example")
      +    _plugins_cfg["enabled"] = _enabled
      +    save_config(_cfg)
      +
           # Snapshot the existing routes BEFORE mounting so we can:
           #   1. Identify the routes the mount call appends.
           #   2. Restore the original list on teardown — otherwise leftover

3 PRs closed without merging

From the close comments: duplicate 1 · merged via another change 1 · superseded by another PR 1

  • #46714 fix(dashboard): gate user plugin manifests by config · duplicate → #19813, #37943, #46435
  • #47491 fix(dashboard): require plugins.enabled for user plugin asset serving and API mount · merged via another change → 7cff956, b2e0086, #56282, #43719, #51950
  • #72181 fix(plugins): run git clone in stable cwd so dashboard install survives purged TMPDIR · superseded by another PR → #73243
Closed issues · 1
  • Closed#46435[Security tracking] Unreviewed Plugin Code Execution via Dashboard Loader Missing `plugins.enabled` Gate (GHSA-mcfc-hp25-cjv7)
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.33: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
dashboard
Merged PRs
#51950, #56282
Closed issues
1
Attempts
3 PRs closed without merging
Fix link checked
1 Jul 2026

For your agent

The same facts, typed.