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