mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-13 03:52:00 +00:00
fix(docker): refuse root gateway runs in official image
This commit is contained in:
parent
afbcca0f06
commit
84287b0de8
4 changed files with 81 additions and 0 deletions
|
|
@ -14,6 +14,9 @@
|
||||||
# keys; exposing it on LAN without auth is unsafe. If you want remote
|
# 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
|
# access, use an SSH tunnel or put it behind a reverse proxy that
|
||||||
# adds authentication — do NOT pass --insecure --host 0.0.0.0.
|
# 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
|
# - 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
|
# and API_SERVER_HOST. See docs/user-guide/api-server.md before doing
|
||||||
# this on an internet-facing host.
|
# this on an internet-facing host.
|
||||||
|
|
|
||||||
|
|
@ -2770,6 +2770,42 @@ def launchd_status(deep: bool = False):
|
||||||
# Gateway Runner
|
# 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):
|
def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
||||||
"""Run the gateway in foreground.
|
"""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
|
This prevents systemd restart loops when the old process
|
||||||
hasn't fully exited yet.
|
hasn't fully exited yet.
|
||||||
"""
|
"""
|
||||||
|
_guard_official_docker_root_gateway()
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
# Refresh the systemd unit definition on every boot so that restart
|
# Refresh the systemd unit definition on every boot so that restart
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,43 @@ def test_run_gateway_exits_nonzero_when_start_gateway_reports_failure(monkeypatc
|
||||||
assert calls == [(True, None)]
|
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:
|
class TestSystemdLingerStatus:
|
||||||
def test_reports_enabled(self, monkeypatch):
|
def test_reports_enabled(self, monkeypatch):
|
||||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
- 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
|
- 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
|
## Upgrading
|
||||||
|
|
||||||
Pull the latest image and recreate the container. Your data directory is untouched.
|
Pull the latest image and recreate the container. Your data directory is untouched.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue