# NousResearch/hermes-agent · Restrict plugin access on public insecure dashboards

## Open now

- Nothing open on GitHub.

These items address security controls around dashboard plugins, focusing on suppressing or blocking plugin APIs, installs, and backend auto-imports in public or insecure dashboard contexts. A related report describes malicious third-party plugins targeting dashboards.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #50773. Verify it addresses your case.
- **Recommended:** read PR #50773 (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_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: 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.

## Closed / duplicate issues

- #43719: Security: Malicious third-party plugins targeting Hermes dashboards (jellyfinuser/gitea.com)

## Prior & competing PRs

- **#50773: fix(security): restrict dashboard plugin backend auto-import to bundled plugins — defense-in-depth (#43719)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/web_server.py`, `plugins/hermes-achievements/README.md`, `tests/hermes_cli/test_project_plugin_rce_bypass.py`, `tests/hermes_cli/test_web_server.py`, `website/docs/reference/environment-variables.md`, `website/docs/user-guide/features/extending-the-dashboard.md`
  - fix hunks:
  - `hermes_cli/web_server.py`

    ```diff
    @@ -12178,12 +12178,20 @@ def _safe_plugin_api_relpath(api_field: Any, *, dashboard_dir: Path) -> Optional
         return api_field
     
     
    +# Plugin sources whose Python backend (dashboard manifest `api` file) must NEVER
    +# be auto-imported by the dashboard web server — only bundled plugins may. Shared
    +# by the discovery-time scrub and the mount-time refuse guards so a typo in one
    +# site cannot silently disable a security gate (GHSA-5qr3-c538-wm9j / #43719).
    +_NON_BUNDLED_PLUGIN_SOURCES = frozenset({"user", "project"})
    +
    +
     def _discover_dashboard_plugins() -> list:
         """Scan plugins/*/dashboard/manifest.json for dashboard extensions.
     
    -    Checks three plugin sources (same as hermes_cli.plugins):
    -    1. User plugins:    ~/.hermes/plugins/<name>/dashboard/manifest.json
    -    2. Bundled plugins: <repo>/plugins/<name>/dashboard/manifest.json  (memory/, etc.)
    +    Checks three plugin sources. Bundled dashboard plugins win name conflicts
    +    so non-bundled plugins cannot shadow trusted backend-capable routes:
    +    1. Bundled plugins: <repo>/plugins/<name>/dashboard/manifest.json  (memory/, etc.)
    +    2. User plugins:    ~/.hermes/plugins/<name>/dashboard/manifest.json
         3. Project plugins: ./.hermes/plugins/  (only if HERMES_ENABLE_PROJECT_PLUGINS)
         """
         plugins = []
    ```

    ```diff
    @@ -12192,9 +12200,9 @@ def _discover_dashboard_plugins() -> list:
         from hermes_cli.plugins import get_bundled_plugins_dir
         bundled_root = get_bundled_plugins_dir()
         search_dirs = [
    -        (get_hermes_home() / "plugins", "user"),
             (bundled_root / "memory", "bundled"),
             (bundled_root, "bundled"),
    +        (get_hermes_home() / "plugins", "user"),
         ]
         # GHSA-5qr3-c538-wm9j (#29156): the previous ``os.environ.get(...)``
         # check treated *any* non-empty string as truthy, so ``=0``, ``=false``,
    ```

    ```diff
    @@ -12253,10 +12261,20 @@ def _discover_dashboard_plugins() -> list:
                     raw_api = data.get("api")
                     dashboard_dir = child / "dashboard"
                     safe_api = _safe_plugin_api_relpath(raw_api, dashboard_dir=dashboard_dir)
    +                if source in _NON_BUNDLED_PLUGIN_SOURCES and safe_api:
    +                    _log.warning(
    +                        "Plugin %s: refusing dashboard backend api=%s "
    +                        "(only bundled plugins may auto-import Python "
    +                        "backend routes; non-bundled plugins may extend "
    +                        "the dashboard with static UI assets only)",
    +                        name, safe_api,
    +                    )
    +                    safe_api = None
    +                    raw_api = None
                     if raw_api and safe_api is None:
                         _log.warning(
                             "Plugin %s: refusing unsafe api path %r (must be a "
    -                        "relative file inside the plugin's dashboard/ "
    +                        "relative file inside a bundled plugin's dashboard/ "
                             "directory); backend routes from this plugin will "
                             "not be mounted",
                             name, raw_api,
    ```

    ```diff
    @@ -12663,23 +12681,36 @@ def _mount_plugin_api_routes():
         a ``router`` (FastAPI APIRouter).  Routes are mounted under
         ``/api/plugins/<name>/``.
     
    -    Backend import is restricted to ``bundled`` and ``user`` sources.
    -    Project plugins (``./.hermes/plugins/``) ship with the CWD and are
    -    therefore attacker-controlled in any threat model where the user
    -    opens a malicious repo; they can extend the dashboard UI via
    -    static JS/CSS but their Python ``api`` file is never auto-imported
    -    by the web server.  See GHSA-5qr3-c538-wm9j (#29156).
    +    Backend import is restricted to bundled plugins. User and project
    +    plugins can extend the dashboard UI via static JS/CSS, but their
    +    Python ``api`` files are never auto-imported by the web server.
    +    See GHSA-5qr3-c538-wm9j (#29156) and #43719.
         """
         for plugin in _get_dashboard_plugins():
             api_file_name = plugin.get("_api_file")
             if not api_file_name:
                 continue
    -        if plugin.get("source") == "project":
    +        source = plugin.get("source")
    +        if source in _NON_BUNDLED_PLUGIN_SOURCES:
    +            # Backend Python auto-import is reserved for bundled plugins; user
    +            # and project plugins extend the dashboard with static UI assets
    +            # only (GHSA-5qr3-c538-wm9j / #43719). Defence-in-depth: discovery
    +            # already nulls _api_file for these sources, but re-refusing here —
    +            # at the actual importlib call site — keeps the import primitive
    +            # contained even if a future caller or a tampered cache entry slips
    +            # a non-bundled plugin through with an _api_file set.
    +            _reason = {
    +                "user": (
    +                    "user-installed plugins may not auto-import Python code"
    +                ),
    +                "project": (
    +                    "project plugins may not auto-import Python code; backend "
    +                    "auto-import is reserved for bundled plugins"
    +                ),
    +            }.get(source, "only bundled plugins may auto-import Python code")
                 _log.warning(
    -                "Plugin %s: ignoring backend api=%s (project plugins may "
    -                "not auto-import Python code; move the plugin to "
    -                "~/.hermes/plugins/ if you trust it)",
    -                plugin["name"], api_file_name,
    +                "Plugin %s: ignoring backend api=%s (%s)",
    +                plugin["name"], api_file_name, _reason,
                 )
                 continue
             dashboard_dir = Path(plugin["_dir"])
    ```
  - `plugins/hermes-achievements/README.md`

    ```diff
    @@ -77,7 +77,9 @@ Then rescan dashboard plugins:
     curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
     ```
     
    -If backend API routes 404, restart `hermes dashboard`; plugin APIs are mounted at dashboard startup.
    +When installed as a user plugin, the dashboard UI loads but Python backend API
    +routes are not auto-imported. Backend routes are available when this plugin is
    +bundled with Hermes.
     
     ## Updating
     
    ```

    ```diff
    @@ -89,7 +91,11 @@ git pull --ff-only
     curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
     ```
     
    -If the update changes backend routes or `plugin_api.py`, restart `hermes dashboard` after pulling.
    +For a user-installed plugin at `~/.hermes/plugins/hermes-achievements`, a plugin
    +rescan is enough because Python backend routes are not auto-imported. If you
    +update the bundled plugin by pulling changes in the hermes-agent repository, and
    +that bundled plugin update changes backend routes or `plugin_api.py`, restart
    +`hermes dashboard` after pulling.
     
     As of 2026-04-29, updating is strongly recommended because scan performance changed significantly:
     - removed duplicate `/overview` scan path
    ```

    ```diff
    @@ -118,6 +124,9 @@ dashboard/
     
     ## API
     
    +These backend routes are mounted for the bundled plugin. User-installed copies
    +load their dashboard UI but do not auto-import Python backend routes.
    +
     Routes are mounted under:
     
     ```text
    ```
  - `tests/hermes_cli/test_project_plugin_rce_bypass.py`

    ```diff
    @@ -24,7 +24,7 @@
     * ``_safe_plugin_api_relpath`` rejects absolute paths, ``..``
       traversal, and non-string / empty values.
     * ``_mount_plugin_api_routes`` re-validates at import time and
    -  refuses project-source plugins outright.
    +  refuses user/project-source plugin backend code outright.
     * End-to-end the original PoC manifest no longer triggers
       ``importlib`` for ``/tmp/payload.py``.
     """
    ```

    ```diff
    @@ -216,7 +216,7 @@ def test_traversal_api_path_in_manifest_is_scrubbed(self, user_plugin_factory):
             assert entry["_api_file"] is None
             assert entry["has_api"] is False
     
    -    def test_safe_api_path_survives(self, user_plugin_factory, tmp_path):
    +    def test_user_safe_api_path_is_scrubbed(self, user_plugin_factory, tmp_path):
             user_plugin_factory("safe", {
                 "name": "safe",
                 "label": "Safe",
    ```

    ```diff
    @@ -230,6 +230,86 @@ def test_safe_api_path_survives(self, user_plugin_factory, tmp_path):
             )
             plugins = web_server._get_dashboard_plugins(force_rescan=True)
             entry = next(p for p in plugins if p["name"] == "safe")
    +        assert entry["_api_file"] is None
    +        assert entry["has_api"] is False
    +
    +    def test_project_safe_api_path_is_scrubbed(self, tmp_path, monkeypatch):
    +        monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
    +        (tmp_path / "home").mkdir()
    +        monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "1")
    +        cwd = tmp_path / "project"
    +        cwd.mkdir()
    +        monkeypatch.chdir(cwd)
    +        dashboard = _write_plugin_manifest(
    +            cwd / ".hermes" / "plugins",
    +            "safe-project",
    +            {
    +                "name": "safe-project",
    +                "label": "Safe Project",
    +                "api": "api.py",
    +                "entry": "dist/index.js",
    +            },
    +        )
    +        (dashboard / "api.py").write_text("router = None\n")
    +
    +        plugins = web_server._get_dashboard_plugins(force_rescan=True)
    +        entry = next(p for p in plugins if p["name"] == "safe-project")
    +        assert entry["_api_file"] is None
    +        assert entry["has_api"] is False
    +
    +    def test_bundled_safe_api_path_survives(self, tmp_path, monkeypatch):
    +        hermes_home = tmp_path / "home"
    +        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
    +        hermes_home.mkdir()
    +        monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", str(tmp_path / "bundled"))
    +        dashboard = _write_plugin_manifest(
    +            tmp_path / "bundled",
    +            "safe-bundled",
    +            {
    +                "name": "safe-bundled",
    +                "label": "Safe Bundled",
    +                "api": "api.py",
    +                "entry": "dist/index.js",
    +            },
    +        )
    +        (dashboard / "api.py").write_text("router = None\n")
    +
    +        plugins = web_server._get_dashboard_plugins(force_rescan=True)
    +        entry = next(p for p in plugins if p["name"] == "safe-bundled")
    +        assert entry["_api_file"] == "api.py"
    +        assert entry["has_api"] is True
    +
    +    def test_user_plugin_does_not_shadow_bundled_backend(self, tmp_path, monkeypatch):
    +        hermes_home = tmp_path / "home"
    +        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
    +        hermes_home.mkdir()
    +        monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", str(tmp_path / "bundled"))
    +
    +        bundled_dashboard = _write_plugin_manifest(
    +            tmp_path / "bundled",
    +            "shadowed",
    +            {
    +                "name": "shadowed",
    +                "label": "Bundled Shadowed",
    +                "api": "api.py",
    +                "entry": "dist/index.js",
    +            },
    +        )
    +        (bundled_dashboard / "api.py").write_text("router = None\n")
    +        _write_plugin_manifest(
    +            hermes_home / "plugins",
    +            "shadowed",
    +            {
    +                "name": "shadowed",
    +                "label": "User Shadowed",
    +                "api": "api.py",
    +                "entry": "dist/index.js",
    +            },
    +        )
    +
    +        plugins = web_server._get_dashboard_plugins(force_rescan=True)
    +        entry = next(p for p in plugins if p["name"] == "shadowed")
    +        assert entry["source"] == "bundled"
             assert entry["_api_file"] == "api.py"
             assert entry["has_api"] is True
     
    ```

    ```diff
    @@ -276,6 +356,16 @@ def test_project_source_api_is_not_imported(self, tmp_path):
                 "GHSA-5qr3-c538-wm9j defence-in-depth regression"
             )
     
    +    def test_user_source_api_is_not_imported(self, tmp_path):
    +        plugin = self._payload_plugin(tmp_path, source="user")
    +        web_server._dashboard_plugins_cache = [plugin]
    +        with patch("importlib.util.spec_from_file_location") as spec:
    +            web_server._mount_plugin_api_routes()
    +        assert spec.call_count == 0, (
    +            "user-installed plugin api file was imported — "
    +            "third-party dashboard plugin backend code must stay inert"
    +        )
    +
         def test_bundled_source_api_imports_normally(self, tmp_path):
             plugin = self._payload_plugin(tmp_path, source="bundled")
             web_server._dashboard_plugins_cache = [plugin]
    ```
  - `tests/hermes_cli/test_web_server.py`

    ```diff
    @@ -5070,14 +5070,8 @@ class TestPluginAPIAuth:
         """Tests that plugin API routes require the session token (issue #19533)."""
     
         @pytest.fixture(autouse=True)
    -    def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin):
    -        """Create a TestClient without the session token header.
    -
    -        Pulls in ``_install_example_plugin`` so ``test_plugin_route_allows_auth``
    -        has the ``/api/plugins/example/hello`` endpoint available — the
    -        example plugin is no longer a bundled plugin, so the fixture
    -        installs it into the per-test ``HERMES_HOME``.
    -        """
    +    def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
    +        """Create TestClients with and without the session token header."""
             try:
                 from starlette.testclient import TestClient
             except ImportError:
    ```

    ```diff
    @@ -5102,19 +5096,15 @@ def test_plugin_route_requires_auth(self):
         def test_plugin_route_allows_auth(self):
             """Plugin API routes should work with a valid session token.
     
    -        Uses ``/api/plugins/example/hello`` from the example-dashboard
    -        test fixture (installed into HERMES_HOME by the class-level
    -        ``_install_example_plugin`` fixture) — a stable, side-effect-free
    -        GET that's only loaded for tests. With a valid token the handler
    -        should run (200); without one the middleware should 401 before
    -        the handler is reached.
    +        Uses a bundled plugin route so the test covers authenticated plugin
    +        API access without relying on user-installed plugin backend imports.
             """
             # Without auth: middleware blocks before reaching the handler.
    -        resp = self.client.get("/api/plugins/example/hello")
    +        resp = self.client.get("/api/plugins/kanban/board")
             assert resp.status_code == 401
     
             # With auth: handler runs.
    -        resp = self.auth_client.get("/api/plugins/example/hello")
    +        resp = self.auth_client.get("/api/plugins/kanban/board")
             assert resp.status_code == 200
     
         def test_plugin_post_requires_auth(self):
    ```
  - `website/docs/reference/environment-variables.md`

    ```diff
    @@ -625,7 +625,7 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us
     | `HERMES_AGENT_NOTIFY_INTERVAL` | Gateway: interval in seconds between progress notifications on long-running agent turns. |
     | `HERMES_CHECKPOINT_TIMEOUT` | Timeout for filesystem checkpoint creation in seconds (default: `30`). |
     | `HERMES_EXEC_ASK` | Enable execution approval prompts in gateway mode (`true`/`false`) |
    -| `HERMES_ENABLE_PROJECT_PLUGINS` | Enable auto-discovery of repo-local plugins from `./.hermes/plugins/` for both the agent loader and the dashboard web server. Accepts the standard truthy set: `1` / `true` / `yes` / `on` (case-insensitive). Everything else — including `0`, `false`, `no`, `off`, and the empty string — is treated as **disabled** (default). Note: as of GHSA-5qr3-c538-wm9j (#29156) the dashboard web server refuses to auto-import a project plugin's Python `api` file even when this var is enabled — project plugins may extend the UI via static JS/CSS but their backend routes are only loaded when moved under `~/.hermes/plugins/`. |
    +| `HERMES_ENABLE_PROJECT_PLUGINS` | Enable auto-discovery of repo-local plugins from `./.hermes/plugins/` for both the agent loader and the dashboard web server. Accepts the standard truthy set: `1` / `true` / `yes` / `on` (case-insensitive). Everything else — including `0`, `false`, `no`, `off`, and the empty string — is treated as **disabled** (default). Note: as of GHSA-5qr3-c538-wm9j (#29156) and #43719, the dashboard web server refuses to auto-import Python `api` files from project or user-installed plugins — they may extend the UI via static JS/CSS, while backend routes are reserved for bundled plugins. |
     | `HERMES_PLUGINS_DEBUG` | `1`/`true` to surface verbose plugin-discovery logs on stderr — directories scanned, manifests parsed, skip reasons, and full tracebacks on parse or `register()` failure. Aimed at plugin authors. |
     | `HERMES_BACKGROUND_NOTIFICATIONS` | Background process notification mode in gateway: `all` (default), `result`, `error`, `off` |
     | `HERMES_EPHEMERAL_SYSTEM_PROMPT` | Ephemeral system prompt injected at API-call time (never persisted to sessions) |
    ```
  - `website/docs/user-guide/features/extending-the-dashboard.md`

    ```diff
    @@ -431,14 +431,14 @@ If you prefer JSX, use any bundler (esbuild, Vite, rollup) with React as an exte
         ├── dist/
         │   ├── index.js         # required — pre-built JS bundle (IIFE)
         │   └── style.css        # optional — custom CSS
    -    └── plugin_api.py        # optional — backend API routes (FastAPI)
    +    └── plugin_api.py        # bundled plugins only — backend API routes (FastAPI)
     ```
     
     A single plugin directory can carry three orthogonal extensions:
     
     - `plugin.yaml` + `__init__.py` — CLI/gateway plugin ([see plugins page](./plugins)).
     - `dashboard/manifest.json` + `dashboard/dist/index.js` — dashboard UI plugin.
    -- `dashboard/plugin_api.py` — dashboard backend routes.
    +- `dashboard/plugin_api.py` — bundled plugins only; backend API routes.
     
     None of them are required; include only the layers you need.
     
    ```

    ```diff
    @@ -743,7 +743,10 @@ Routes are mounted under `/api/plugins/<name>/`, so the above becomes:
     - `GET  /api/plugins/my-plugin/data`
     - `POST /api/plugins/my-plugin/action`
     
    -Plugin API routes bypass session-token authentication since the dashboard server binds to localhost by default. **Don't expose the dashboard on a public interface with `--host 0.0.0.0` if you run untrusted plugins** — their routes become reachable too.
    +Security notes:
    +
    +- Bundled plugin API routes bypass session-token authentication. The dashboard server binds to localhost by default, which mitigates the risks of this bypass.
    +- User-installed and project dashboard plugins may still extend the UI with static JS/CSS, but their Python `api` files are not auto-imported by the dashboard server. Backend routes are reserved for bundled plugins.
     
     #### Accessing Hermes internals
     
    ```

    ```diff
    @@ -804,11 +807,14 @@ The dashboard scans three directories for `dashboard/manifest.json`:
     
     | Priority | Directory | Source label |
     |----------|-----------|--------------|
    -| 1 (wins on conflict) | `~/.hermes/plugins/<name>/dashboard/` | `user` |
    -| 2 | `<repo>/plugins/memory/<name>/dashboard/` | `bundled` |
    -| 2 | `<repo>/plugins/<name>/dashboard/` | `bundled` |
    +| 1 (wins on conflict) | `<repo>/plugins/memory/<name>/dashboard/` | `bundled` |
    +| 1 (wins on conflict) | `<repo>/plugins/<name>/dashboard/` | `bundled` |
    +| 2 | `~/.hermes/plugins/<name>/dashboard/` | `user` |
     | 3 | `./.hermes/plugins/<name>/dashboard/` | `project` — only when `HERMES_ENABLE_PROJECT_PLUGINS` is set |
     
    +Bundled dashboard plugins win name conflicts because only bundled plugins may
    +register backend routes. Give user and project dashboard plugins unique names.
    +
     Discovery results are cached per dashboard process. After adding a new plugin, either:
     
     ```bash
    ```

    ```diff
    @@ -908,10 +914,11 @@ Check that the file is in `~/.hermes/dashboard-themes/` and ends in `.yaml` or `
     The `sidebar` slot only renders when the active theme has `layoutVariant: cockpit`. Other slots always render. If you're registering into a slot with no hits, add `console.log` inside `registerSlot` to confirm the plugin bundle ran at all.
     
     **Plugin backend routes return 404.**
    -1. Confirm the manifest has `"api": "plugin_api.py"` pointing to an existing file inside `dashboard/`.
    -2. Restart `hermes dashboard` — plugin API routes are mounted once at startup, **not** on rescan.
    -3. Check that `plugin_api.py` exports a module-level `router = APIRouter()`. Other export names are not picked up.
    -4. Tail `~/.hermes/logs/errors.log` for `Failed to load plugin <name> API routes` — import errors are logged there.
    +1. Confirm the plugin is bundled with Hermes. User-installed and project dashboard plugins can extend the UI, but their Python backend routes are not auto-imported.
    +2. Confirm the manifest has `"api": "plugin_api.py"` pointing to an existing file inside `dashboard/`.
    +3. Restart `hermes dashboard` — plugin API routes are mounted once at startup, **not** on rescan.
    +4. Check that `plugin_api.py` exports a module-level `router = APIRouter()`. Other export names are not picked up.
    +5. Tail `~/.hermes/logs/errors.log` for `Failed to load plugin <name> API routes` — import errors are logged there.
     
     **Theme change drops my color overrides.**
     `colorOverrides` are scoped to the active theme and cleared on theme switch — that's by design. If you want overrides that persist, put them in your theme's YAML, not in the live switcher.
    ```
- **#43786: fix(security): suppress user dashboard plugin APIs on public insecure dashboards (#43719)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#44472`, `#50773`
- **#43794: fix(dashboard): block public insecure plugin installs**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#43719`, `#50773`

## Fix status

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