NousResearch/hermes-agent

Prevent gateway from running as root in Docker

Cluster 18936. Data as of

Nothing open on GitHub. Fix merged in #21250 on 7 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #21250. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #21250. That is separate from the fix event.
Still open
Nothing open on GitHub.

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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #21250 fix(docker): refuse root gateway runs in official image (salvage #19215)

    merged to main

    files: docker-compose.yml, hermes_cli/gateway.py, tests/hermes_cli/test_gateway.py, website/docs/user-guide/docker.md

    Show the diff
    • docker-compose.yml
      @@ -14,6 +14,9 @@
       #     keys; exposing it on LAN without auth is unsafe. If you want remote
       #     access, use an SSH tunnel or put it behind a reverse proxy that
       #     adds authentication — do NOT pass --insecure --host 0.0.0.0.
      +#   - If you override entrypoint, keep /opt/hermes/docker/entrypoint.sh in
      +#     the command chain. It drops root to the hermes user before gateway
      +#     files such as gateway.lock are created.
       #   - The gateway's API server is off unless you uncomment API_SERVER_KEY
       #     and API_SERVER_HOST. See docs/user-guide/api-server.md before doing
       #     this on an internet-facing host.
    • hermes_cli/gateway.py
      @@ -2770,6 +2770,42 @@ def launchd_status(deep: bool = False):
       # Gateway Runner
       # =============================================================================
       
      +def _truthy_env(value: str | None) -> bool:
      +    return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
      +
      +
      +def _is_official_docker_checkout() -> bool:
      +    return (
      +        str(PROJECT_ROOT) == "/opt/hermes"
      +        and (PROJECT_ROOT / "docker" / "entrypoint.sh").is_file()
      +    )
      +
      +
      +def _guard_official_docker_root_gateway() -> None:
      +    """Refuse gateway startup when the official Docker privilege drop was bypassed."""
      +    if not hasattr(os, "geteuid") or os.geteuid() != 0:
      +        return
      +    if _truthy_env(os.getenv("HERMES_ALLOW_ROOT_GATEWAY")):
      +        return
      +    if not _is_official_docker_checkout():
      +        return
      +
      +    print_error(
      +        "Refusing to run the Hermes gateway as root inside the official Docker image."
      +    )
      +    print(
      +        "  The image entrypoint normally drops privileges to the 'hermes' user. "
      +        "If you override entrypoint in Docker Compose, include "
      +        "/opt/hermes/docker/entrypoint.sh before the Hermes command."
      +    )
      +    print(
      +        "  Running the gateway as root can leave root-owned files in "
      +        "$HERMES_HOME and break later non-root dashboard/gateway runs."
      +    )
      +    print("  Set HERMES_ALLOW_ROOT_GATEWAY=1 only if you intentionally accept this risk.")
      +    sys.exit(1)
      +
      +
       def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
           """Run the gateway in foreground.
           
      @@ -2780,6 +2816,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
                        This prevents systemd restart loops when the old process
                        hasn't fully exited yet.
           """
      +    _guard_official_docker_root_gateway()
           sys.path.insert(0, str(PROJECT_ROOT))
       
           # Refresh the systemd unit definition on every boot so that restart
    • tests/hermes_cli/test_gateway.py
      @@ -53,6 +53,43 @@ def fake_start_gateway(*, replace, verbosity):
           assert calls == [(True, None)]
       
       
      +def test_run_gateway_refuses_root_in_official_docker(monkeypatch, tmp_path, capsys):
      +    project_root = tmp_path / "opt" / "hermes"
      +    (project_root / "docker").mkdir(parents=True)
      +    (project_root / "docker" / "entrypoint.sh").write_text("#!/bin/sh\n")
      +
      +    monkeypatch.setattr(gateway, "PROJECT_ROOT", project_root)
      +    monkeypatch.setattr(gateway.os, "geteuid", lambda: 0)
      +    monkeypatch.delenv("HERMES_ALLOW_ROOT_GATEWAY", raising=False)
      +    monkeypatch.setattr(gateway, "_is_official_docker_checkout", lambda: True)
      +
      +    with pytest.raises(SystemExit) as exc_info:
      +        gateway.run_gateway()
      +
      +    assert exc_info.value.code == 1
      +    out = capsys.readouterr().out
      +    assert "Refusing to run the Hermes gateway as root" in out
      +    assert "/opt/hermes/docker/entrypoint.sh" in out
      +
      +
      +def test_run_gateway_root_guard_has_escape_hatch(monkeypatch):
      +    calls = []
      +
      +    def fake_start_gateway(*, replace, verbosity):
      +        calls.append((replace, verbosity))
      +        return object()
      +
      +    _install_fake_gateway_run(monkeypatch, fake_start_gateway)
      +    monkeypatch.setattr(gateway.asyncio, "run", lambda coro: True)
      +    monkeypatch.setattr(gateway.os, "geteuid", lambda: 0)
      +    monkeypatch.setattr(gateway, "_is_official_docker_checkout", lambda: True)
      +    monkeypatch.setenv("HERMES_ALLOW_ROOT_GATEWAY", "1")
      +
      +    gateway.run_gateway(verbose=2, replace=True)
      +
      +    assert calls == [(True, 2)]
      +
      +
       class TestSystemdLingerStatus:
           def test_reports_enabled(self, monkeypatch):
               monkeypatch.setattr(gateway, "is_linux", lambda: True)
    • website/docs/user-guide/docker.md
      @@ -271,6 +271,10 @@ The entrypoint script (`docker/entrypoint.sh`) bootstraps the data volume on fir
       - Optionally launches `hermes dashboard` as a background side-process when `HERMES_DASHBOARD=1` (see [Running the dashboard](#running-the-dashboard))
       - Then runs `hermes` with whatever arguments you pass
       
      +:::warning
      +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.
      +:::
      +
       ## Upgrading
       
       Pull the latest image and recreate the container. Your data directory is untouched.

1 PR closed without merging

From the close comments: closed without comment 1

  • #19215 fix(docker): refuse root gateway runs in official image · closed without comment
Closed issues · 1
  • Closed#18936docker: entrypoint.sh privilege drop not enforced — bypassing it causes gateway processes to run as root
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

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 Lab

At a glance

Merged PRs
#21250
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
7 May 2026

For your agent

The same facts, typed.