{"repo":"NousResearch/hermes-agent","public_id":"18936","display_label":"Prevent gateway from running as root in Docker","summary":"These items address a security gap in the official Docker image where the entrypoint privilege-drop could be bypassed, allowing gateway processes to run as root. The work involves enforcing a refusal to start under root in that context.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":21250,"merged_at":"2026-05-07T12:59:26+00:00"},"resolution":{"state":"resolved","by":"pr-21250"},"freshness":{"state":"verified","last_verified_at":"2026-05-07T12:59:26+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":18936,"pr_number":21250,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-07T12:59:26+00:00"}],"members":[{"kind":"issue","number":18936,"title":"docker: entrypoint.sh privilege drop not enforced — bypassing it causes gateway processes to run as root","url":null,"state":"closed"},{"kind":"pr","number":19215,"title":"fix(docker): refuse root gateway runs in official image","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":21250,"title":"fix(docker): refuse root gateway runs in official image (salvage #19215)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["docker-compose.yml","hermes_cli/gateway.py","tests/hermes_cli/test_gateway.py","website/docs/user-guide/docker.md"],"fix_hunks":[{"file":"docker-compose.yml","hunks":["@@ -14,6 +14,9 @@\n #     keys; exposing it on LAN without auth is unsafe. If you want remote\n #     access, use an SSH tunnel or put it behind a reverse proxy that\n #     adds authentication — do NOT pass --insecure --host 0.0.0.0.\n+#   - If you override entrypoint, keep /opt/hermes/docker/entrypoint.sh in\n+#     the command chain. It drops root to the hermes user before gateway\n+#     files such as gateway.lock are created.\n #   - The gateway's API server is off unless you uncomment API_SERVER_KEY\n #     and API_SERVER_HOST. See docs/user-guide/api-server.md before doing\n #     this on an internet-facing host."]},{"file":"hermes_cli/gateway.py","hunks":["@@ -2770,6 +2770,42 @@ def launchd_status(deep: bool = False):\n # Gateway Runner\n # =============================================================================\n \n+def _truthy_env(value: str | None) -> bool:\n+    return str(value or \"\").strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n+\n+\n+def _is_official_docker_checkout() -> bool:\n+    return (\n+        str(PROJECT_ROOT) == \"/opt/hermes\"\n+        and (PROJECT_ROOT / \"docker\" / \"entrypoint.sh\").is_file()\n+    )\n+\n+\n+def _guard_official_docker_root_gateway() -> None:\n+    \"\"\"Refuse gateway startup when the official Docker privilege drop was bypassed.\"\"\"\n+    if not hasattr(os, \"geteuid\") or os.geteuid() != 0:\n+        return\n+    if _truthy_env(os.getenv(\"HERMES_ALLOW_ROOT_GATEWAY\")):\n+        return\n+    if not _is_official_docker_checkout():\n+        return\n+\n+    print_error(\n+        \"Refusing to run the Hermes gateway as root inside the official Docker image.\"\n+    )\n+    print(\n+        \"  The image entrypoint normally drops privileges to the 'hermes' user. \"\n+        \"If you override entrypoint in Docker Compose, include \"\n+        \"/opt/hermes/docker/entrypoint.sh before the Hermes command.\"\n+    )\n+    print(\n+        \"  Running the gateway as root can leave root-owned files in \"\n+        \"$HERMES_HOME and break later non-root dashboard/gateway runs.\"\n+    )\n+    print(\"  Set HERMES_ALLOW_ROOT_GATEWAY=1 only if you intentionally accept this risk.\")\n+    sys.exit(1)\n+\n+\n def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):\n     \"\"\"Run the gateway in foreground.\n     ","@@ -2780,6 +2816,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):\n                  This prevents systemd restart loops when the old process\n                  hasn't fully exited yet.\n     \"\"\"\n+    _guard_official_docker_root_gateway()\n     sys.path.insert(0, str(PROJECT_ROOT))\n \n     # Refresh the systemd unit definition on every boot so that restart"]},{"file":"tests/hermes_cli/test_gateway.py","hunks":["@@ -53,6 +53,43 @@ def fake_start_gateway(*, replace, verbosity):\n     assert calls == [(True, None)]\n \n \n+def test_run_gateway_refuses_root_in_official_docker(monkeypatch, tmp_path, capsys):\n+    project_root = tmp_path / \"opt\" / \"hermes\"\n+    (project_root / \"docker\").mkdir(parents=True)\n+    (project_root / \"docker\" / \"entrypoint.sh\").write_text(\"#!/bin/sh\\n\")\n+\n+    monkeypatch.setattr(gateway, \"PROJECT_ROOT\", project_root)\n+    monkeypatch.setattr(gateway.os, \"geteuid\", lambda: 0)\n+    monkeypatch.delenv(\"HERMES_ALLOW_ROOT_GATEWAY\", raising=False)\n+    monkeypatch.setattr(gateway, \"_is_official_docker_checkout\", lambda: True)\n+\n+    with pytest.raises(SystemExit) as exc_info:\n+        gateway.run_gateway()\n+\n+    assert exc_info.value.code == 1\n+    out = capsys.readouterr().out\n+    assert \"Refusing to run the Hermes gateway as root\" in out\n+    assert \"/opt/hermes/docker/entrypoint.sh\" in out\n+\n+\n+def test_run_gateway_root_guard_has_escape_hatch(monkeypatch):\n+    calls = []\n+\n+    def fake_start_gateway(*, replace, verbosity):\n+        calls.append((replace, verbosity))\n+        return object()\n+\n+    _install_fake_gateway_run(monkeypatch, fake_start_gateway)\n+    monkeypatch.setattr(gateway.asyncio, \"run\", lambda coro: True)\n+    monkeypatch.setattr(gateway.os, \"geteuid\", lambda: 0)\n+    monkeypatch.setattr(gateway, \"_is_official_docker_checkout\", lambda: True)\n+    monkeypatch.setenv(\"HERMES_ALLOW_ROOT_GATEWAY\", \"1\")\n+\n+    gateway.run_gateway(verbose=2, replace=True)\n+\n+    assert calls == [(True, 2)]\n+\n+\n class TestSystemdLingerStatus:\n     def test_reports_enabled(self, monkeypatch):\n         monkeypatch.setattr(gateway, \"is_linux\", lambda: True)"]},{"file":"website/docs/user-guide/docker.md","hunks":["@@ -271,6 +271,10 @@ The entrypoint script (`docker/entrypoint.sh`) bootstraps the data volume on fir\n - Optionally launches `hermes dashboard` as a background side-process when `HERMES_DASHBOARD=1` (see [Running the dashboard](#running-the-dashboard))\n - Then runs `hermes` with whatever arguments you pass\n \n+:::warning\n+Do not override the image entrypoint unless you keep `/opt/hermes/docker/entrypoint.sh` in the command chain. The entrypoint drops root privileges to the `hermes` user before gateway state files are created. Starting `hermes gateway run` as root inside the official image is refused by default because it can leave root-owned files in `/opt/data` and break later dashboard or gateway starts. Set `HERMES_ALLOW_ROOT_GATEWAY=1` only when you intentionally accept that risk.\n+:::\n+\n ## Upgrading\n \n Pull the latest image and recreate the container. Your data directory is untouched.\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}