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