# NousResearch/hermes-agent · Prevent gateway from running as root in Docker

## Open now

- 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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #21250. Verify it addresses your case.
- **Recommended:** read PR #21250 (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: `resolved` · deciding PR(s): `pr-21250`. 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.67`: 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

- #18936: docker: entrypoint.sh privilege drop not enforced — bypassing it causes gateway processes to run as root

## Prior & competing PRs

- **#21250: fix(docker): refuse root gateway runs in official image (salvage #19215)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `docker-compose.yml`, `hermes_cli/gateway.py`, `tests/hermes_cli/test_gateway.py`, `website/docs/user-guide/docker.md`
  - fix hunks:
  - `docker-compose.yml`

    ```diff
    @@ -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`

    ```diff
    @@ -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.
         
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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.
    ```
- **#19215: fix(docker): refuse root gateway runs in official image**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`

## Fix status

- Merged to main in PR #21250 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-07`.
