# NousResearch/hermes-agent · Dashboard plugin discovery, gating, and nav visibility

## Open now

- 2 issues, 0 PRs open on GitHub.
- #27631: \[Bug\]: Dashboard plugin loader bypasses plugins.enabled config
- #43081: Proposal: unify the two plugin loaders — dashboard tabs should be gated on a documented config block

These issues and PRs address how dashboard plugins are discovered, enabled via config, and shown in navigation — including fixes to ensure plugins respect the enabled config flag and that plugins without built bundles are hidden from the UI.

## TL;DR

- 7 member(s): 2 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #56282. Verify it addresses your case.
- **Recommended:** read PR #56282 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.22`: the share of member 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.

## Open related issues

- #27631: \[Bug\]: Dashboard plugin loader bypasses plugins.enabled config
- #43081: Proposal: unify the two plugin loaders — dashboard tabs should be gated on a documented config block

## Closed / duplicate issues

- #28609: \[Bug\]: example-dashboard plugin is discovered but has no built dist/, shows error banner on every install

## Prior & competing PRs

- **#28650: fix(dashboard): hide tab for plugins without a built entry (#28609)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#56282: fix(dashboard): gate user plugin asset serving + backend import on plugins.enabled**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/web_server.py`, `tests/hermes_cli/test_plugin_runtime_disable_gate.py`, `tests/hermes_cli/test_web_server.py`
  - fix hunks:
  - `hermes_cli/web_server.py`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)
         ]
     
     
    ```

    ```diff
    @@ -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()
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```
- **#19813: fix(dashboard): gate dashboard plugin discovery by config**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#56282`, `#47491`
- **#27638: fix(plugins): dashboard loader respects plugins.enabled opt-in config**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#56282`, `#47491`

## Fix status

- Merged to main in PR #56282 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-01`.
- Subsystem: `dashboard`
