mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
Merge pull request #50773 from NousResearch/salvage/43719-dashboard-plugin-rce
fix(security): restrict dashboard plugin backend auto-import to bundled plugins — defense-in-depth (#43719)
This commit is contained in:
commit
5937b95192
6 changed files with 174 additions and 47 deletions
|
|
@ -12182,12 +12182,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 = []
|
||||
|
|
@ -12196,9 +12204,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``,
|
||||
|
|
@ -12257,10 +12265,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,
|
||||
|
|
@ -12667,23 +12685,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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ These tests pin each layer of the new defence:
|
|||
* ``_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``.
|
||||
"""
|
||||
|
|
@ -216,7 +216,7 @@ class TestDiscoveryScrubsApiField:
|
|||
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",
|
||||
|
|
@ -230,6 +230,86 @@ class TestDiscoveryScrubsApiField:
|
|||
)
|
||||
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
|
||||
|
||||
|
|
@ -276,6 +356,16 @@ class TestMountApiRoutesRefusesUntrusted:
|
|||
"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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -5102,19 +5096,15 @@ class TestPluginAPIAuth:
|
|||
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):
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue