NousResearch/hermes-agent

Gate dashboard plugin API imports on plugins enabled setting

Cluster 37942 · dashboard. Data as of

Nothing open on GitHub. Fix merged in #56282 on 1 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #56282. 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.

The three members appear to address the same theme: preventing dashboard plugin backend imports (and, in one title, user plugin asset serving) when plugins are disabled. Two titles are identical, and the third mentions gating on plugins.enabled; titles alone do not show implementation details or outcomes.

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.

  • #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": [redacted:secret-assignment]
      +        }
      +        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": [redacted:secret-assignment]
      +        }
      +        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": [redacted:secret-assignment]
      +        }
      +        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": [redacted:secret-assignment]
      +        }
      +        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": [redacted:secret-assignment]
      +        }
      +        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": [redacted:secret-assignment]
      +        }
      +        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": [redacted:secret-assignment]
      +        }
      +        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

1 PR closed without merging

From the close comments: done elsewhere 1

  • #37943 fix(plugins): prevent disabled dashboard plugin API imports · done elsewhere → #56282, #47491
Closed issues · 1
  • Closed#37942fix(plugins): prevent disabled dashboard plugin API imports
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.67: 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
#56282
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
1 Jul 2026

For your agent

The same facts, typed.