From 6092be413d59f5e535cc9b1fd9bccd001067f7a0 Mon Sep 17 00:00:00 2001 From: shannonsands Date: Thu, 18 Jun 2026 09:09:21 +1000 Subject: [PATCH] Harden hosted Docker install tree against self-modification (#47490) * Harden hosted Docker install tree * Document hosted Docker immutable install tree --- Dockerfile | 50 +++---- docker/stage2-hook.sh | 72 +++-------- .../test_immutable_install_permissions.py | 67 ++++++++++ .../test_dockerfile_immutable_install.py | 61 +++++++++ .../test_dockerfile_node_modules_perms.py | 41 ++---- .../test_stage2_hook_build_tree_chown.py | 122 ------------------ .../test_stage2_hook_immutable_install.py | 48 +++++++ .../test_stage2_hook_install_dir_chown.py | 57 -------- .../docs/reference/environment-variables.md | 1 + website/docs/user-guide/docker.md | 10 ++ 10 files changed, 240 insertions(+), 289 deletions(-) create mode 100644 tests/docker/test_immutable_install_permissions.py create mode 100644 tests/tools/test_dockerfile_immutable_install.py delete mode 100644 tests/tools/test_stage2_hook_build_tree_chown.py create mode 100644 tests/tools/test_stage2_hook_immutable_install.py delete mode 100644 tests/tools/test_stage2_hook_install_dir_chown.py diff --git a/Dockerfile b/Dockerfile index be358ac5343..dba5af1d56e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,11 @@ FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df228 FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source FROM debian:13.4 -# Disable Python stdout buffering to ensure logs are printed immediately +# Disable Python stdout buffering to ensure logs are printed immediately. +# Do not write .pyc files at runtime: /opt/hermes is immutable in the +# published container and writable state belongs under /opt/data. ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 # Store Playwright browsers outside the volume mount so the build-time # install survives the /opt/data volume overlay at runtime. @@ -186,36 +189,31 @@ RUN cd web && npm run build && \ # ---------- Source code ---------- # .dockerignore excludes node_modules, so the installs above survive. -COPY --chown=hermes:hermes . . +COPY . . # ---------- Permissions ---------- -# Make install dir world-readable so any HERMES_UID can read it at runtime. -# The venv needs to be traversable too. -# node_modules trees additionally need to be writable by the hermes user -# so the runtime `npm install` triggered by _tui_need_npm_install() in -# hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time -# only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally -# not chowned here. -# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and -# gateway state artifacts beneath the package after services drop privileges, -# especially when the hermes UID is remapped at boot (#27221). -# The .venv MUST remain hermes-writable so lazy_deps.py can install -# remaining optional platform packages and future pin bumps at first use. -# Without this, `uv pip install` fails with EACCES and adapters silently -# fail to load. See tools/lazy_deps.py. +# Link hermes-agent itself (editable). Deps are already installed in the +# cached layer above; `--no-deps` makes this a fast egg-link creation with no +# resolution or downloads. +RUN uv pip install --no-cache-dir --no-deps -e "." + +# Keep /opt/hermes immutable for the runtime hermes user. Hosted/container +# instances must not be able to self-edit the installed source or venv; user +# data, skills, plugins, config, logs, and dashboard uploads live under +# /opt/data instead. Root can still repair the image during build/boot, but +# supervised Hermes processes drop to the non-root hermes user. USER root -RUN chmod -R a+rX /opt/hermes && \ - chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules +RUN mkdir -p /opt/hermes/bin && \ + cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \ + chmod 0755 /opt/hermes/bin/hermes && \ + chown -R root:root /opt/hermes && \ + chmod -R a+rX /opt/hermes && \ + chmod -R a-w /opt/hermes # Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown # the data volume. Each supervised service then drops to the hermes user via # `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services # run as the default hermes user (UID 10000). -# ---------- Link hermes-agent itself (editable) ---------- -# Deps are already installed in the cached layer above; `--no-deps` makes -# this a fast (~1s) egg-link creation with no resolution or downloads. -RUN uv pip install --no-cache-dir --no-deps -e "." - # ---------- Bake build-time git revision ---------- # .dockerignore excludes .git, so `git rev-parse HEAD` from inside the # container always returns nothing — meaning `hermes dump` reports @@ -235,8 +233,9 @@ RUN uv pip install --no-cache-dir --no-deps -e "." # every published image has it. ARG HERMES_GIT_SHA= RUN if [ -n "${HERMES_GIT_SHA}" ]; then \ + chmod u+w /opt/hermes && \ printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \ - chown hermes:hermes /opt/hermes/.hermes_build_sha; \ + chmod a-w /opt/hermes /opt/hermes/.hermes_build_sha; \ fi # ---------- s6-overlay service wiring ---------- @@ -282,6 +281,8 @@ ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist # check. (A separate launcher hardening is tracked independently.) ENV HERMES_TUI_DIR=/opt/hermes/ui-tui ENV HERMES_HOME=/opt/data +ENV HERMES_WRITE_SAFE_ROOT=/opt/data +ENV HERMES_DISABLE_LAZY_INSTALLS=1 # `docker exec` privilege-drop shim. When operators run # `docker exec hermes ...` they default to root, and any file the @@ -294,7 +295,6 @@ ENV HERMES_HOME=/opt/data # Recursion is impossible because the shim exec's the venv binary by # absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for # the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1). -COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes # Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported # the venv bin onto PATH; Architecture B's main-wrapper.sh does the diff --git a/docker/stage2-hook.sh b/docker/stage2-hook.sh index b00c15a7081..89fc914b337 100755 --- a/docker/stage2-hook.sh +++ b/docker/stage2-hook.sh @@ -28,14 +28,13 @@ as_hermes() { [ "$(id -u)" = 0 ] || { "$@"; return; }; s6-setuidgid hermes "$@"; # arbitrary host UID (the classic `--user $(id -u):$(id -g)` invocation people # used in the tini era to make container-written files match their host user). # -# Under s6-overlay this no longer works: the bootstrap (UID remap, volume + -# build-tree chown, config seeding) all require root, and they're skipped when -# the container starts non-root. The baked image trees (/opt/data, /opt/hermes/ -# .venv, ui-tui, node_modules) stay owned by the hermes build UID (10000), so an -# arbitrary `--user` UID can't write them — the runtime then fails with EACCES -# on a bind mount, or hard-crashes on a named volume (Docker initialises the -# volume from the image as UID 10000, and the non-root start can't even `cd` -# into $HERMES_HOME). See #34837 for the supervision-tree side of this. +# Under s6-overlay this no longer works: the bootstrap (UID remap, data-volume +# ownership, config seeding) requires root, and it is skipped when the container +# starts non-root. The baked install tree under /opt/hermes is intentionally +# root-owned and non-writable; mutable runtime state must live under +# $HERMES_HOME. An arbitrary `--user` UID therefore cannot repair or populate +# the data volume, and startup fails with EACCES. See #34837 for the +# supervision-tree side of this. # # The supported way to match host-side ownership is to start as root (the image # default) and pass HERMES_UID/HERMES_GID — or the PUID/PGID aliases — which the @@ -53,9 +52,10 @@ if [ "$cur_uid" != 0 ] && [ "$cur_uid" != "$(id -u hermes)" ]; then [stage2] ERROR: container started with --user $cur_uid (an arbitrary, non-hermes UID). This is not supported under the s6-overlay image. The container bootstrap -(UID remap, volume ownership, dependency installs) needs to start as root, -and the baked image directories are owned by the hermes user (UID $(id -u hermes)), -so a pinned --user UID cannot write them — startup will fail. +(UID remap, data-volume ownership, config seeding) needs to start as root, +and the baked /opt/hermes install tree is intentionally root-owned and +non-writable, so a pinned --user UID cannot repair startup state — startup +will fail. To make container-written files match your HOST user, DON'T use --user. Start the container as root (the default) and pass your host UID/GID instead: @@ -207,49 +207,13 @@ if [ "$needs_chown" = true ]; then done fi -# --- Fix ownership of build trees under $INSTALL_DIR --- -# Hermes-owned trees under $INSTALL_DIR must be re-chowned whenever the -# runtime hermes UID no longer owns them — otherwise: -# - .venv: lazy_deps.py cannot install platform packages (discord.py, -# telegram, slack, etc.) with EACCES (#15012, #21100) -# - ui-tui: esbuild rebuilds dist/entry.js on every TUI launch (when -# the source mtime is newer than dist/ or when HERMES_TUI_FORCE_BUILD -# is set) and writes to ui-tui/dist/. Without this chown the new -# hermes UID can't write the build output (#28851). -# - gateway: Python writes __pycache__ and runtime artifacts beneath the -# gateway package on first import. After a UID remap those source-owned -# paths still belong to the build-time UID (10000) unless repaired here, -# producing EACCES for the supervised gateway (#27221). -# - node_modules: root-level dependencies (puppeteer, web tooling) -# that runtime code may walk/update. -# The set mirrors the build-time `chown -R hermes:hermes` line in the -# Dockerfile — keep them in sync if the Dockerfile chown set changes. -# These are under $INSTALL_DIR (not $HERMES_HOME), so the bind-mount -# concern doesn't apply — recursive is fine. -# -# This MUST be gated independently of the $HERMES_HOME ownership check -# above. `usermod -u hermes` re-chowns the hermes home dir -# ($HERMES_HOME == /opt/data) to the new UID as a side effect, so after a -# HERMES_UID/PUID remap `stat $HERMES_HOME` always already matches the new -# UID and `needs_chown` is false — but the build trees under /opt/hermes -# are NOT touched by usermod and remain owned by the build-time UID -# (10000). Gating them on $HERMES_HOME ownership (as #35027 did) silently -# skipped this chown on the common PUID/NAS path, regressing lazy installs -# and TUI rebuilds. Probe the build trees directly instead: chown only -# when the venv is not already owned by the runtime hermes UID. Idempotent -# and skips the expensive recursive chown on every restart once ownership -# is settled. -venv_owner=$(stat -c %u "$INSTALL_DIR/.venv" 2>/dev/null || echo "") -if [ -n "$venv_owner" ] && [ "$venv_owner" != "$actual_hermes_uid" ]; then - echo "[stage2] Fixing ownership of build trees under $INSTALL_DIR to hermes ($actual_hermes_uid)" - chown -R hermes:hermes \ - "$INSTALL_DIR/.venv" \ - "$INSTALL_DIR/ui-tui" \ - "$INSTALL_DIR/gateway" \ - "$INSTALL_DIR/node_modules" \ - 2>/dev/null || \ - echo "[stage2] Warning: chown of build trees failed (rootless container?) — continuing" -fi +# --- Immutable install tree --- +# Do not chown runtime code or dependency trees under $INSTALL_DIR back to the +# hermes user. Hosted/container instances keep mutable state under +# $HERMES_HOME (/opt/data) and run with PYTHONDONTWRITEBYTECODE plus +# HERMES_DISABLE_LAZY_INSTALLS=1. Keeping /opt/hermes root-owned and +# non-writable prevents an agent session from self-modifying the installed +# source, venv, TUI bundle, or node_modules and bricking the gateway. # Always reset ownership of $HERMES_HOME/profiles to hermes on every # boot. Profile dirs and files can land owned by root when commands diff --git a/tests/docker/test_immutable_install_permissions.py b/tests/docker/test_immutable_install_permissions.py new file mode 100644 index 00000000000..e9a4466a61e --- /dev/null +++ b/tests/docker/test_immutable_install_permissions.py @@ -0,0 +1,67 @@ +"""Docker smoke tests for immutable install permissions.""" +from __future__ import annotations + +import subprocess +import textwrap + + +def test_container_sets_hosted_write_policy_env(built_image: str) -> None: + script = ( + 'test "$HERMES_HOME" = "/opt/data" && ' + 'test "$HERMES_WRITE_SAFE_ROOT" = "/opt/data" && ' + 'test "$HERMES_DISABLE_LAZY_INSTALLS" = "1" && ' + 'test "$PYTHONDONTWRITEBYTECODE" = "1"' + ) + result = subprocess.run( + ["docker", "run", "--rm", "--entrypoint", "sh", built_image, "-c", script], + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stderr[-2000:] + + +def test_hermes_user_cannot_modify_install_but_can_write_data(built_image: str) -> None: + script = textwrap.dedent( + r""" + set -eu + /opt/hermes/.venv/bin/python - <<'PY' + from pathlib import Path + + install_file = Path("/opt/hermes/agent/message_sanitization.py") + try: + with install_file.open("a", encoding="utf-8") as handle: + handle.write("\n# unexpected hosted mutation\n") + except PermissionError: + pass + else: + raise SystemExit("install source write unexpectedly succeeded") + + skill_dir = Path("/opt/data/skills/permission-smoke") + skill_dir.mkdir(parents=True, exist_ok=True) + skill_file = skill_dir / "SKILL.md" + skill_file.write_text("# Permission smoke\n", encoding="utf-8") + if skill_file.read_text(encoding="utf-8") != "# Permission smoke\n": + raise SystemExit("data write verification failed") + PY + """ + ).strip() + result = subprocess.run( + [ + "docker", + "run", + "--rm", + "--entrypoint", + "su", + built_image, + "hermes", + "-s", + "/bin/sh", + "-c", + script, + ], + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, result.stderr[-2000:] diff --git a/tests/tools/test_dockerfile_immutable_install.py b/tests/tools/test_dockerfile_immutable_install.py new file mode 100644 index 00000000000..a1ae5377b68 --- /dev/null +++ b/tests/tools/test_dockerfile_immutable_install.py @@ -0,0 +1,61 @@ +"""Contract tests for the Docker image's immutable /opt/hermes install tree.""" +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +DOCKERFILE = REPO_ROOT / "Dockerfile" + + +def _dockerfile_text() -> str: + return DOCKERFILE.read_text() + + +def test_dockerfile_makes_opt_hermes_root_owned_and_non_writable() -> None: + text = _dockerfile_text() + + assert "COPY --chown=hermes:hermes . ." not in text + assert "COPY . ." in text + assert "chown -R root:root /opt/hermes" in text + assert "chmod -R a+rX /opt/hermes" in text + assert "chmod -R a-w /opt/hermes" in text + + immutable_block = re.search( + r"RUN mkdir -p /opt/hermes/bin && \\\n" + r"(?:.*\\\n)+?" + r"\s+chmod -R a-w /opt/hermes", + text, + ) + assert immutable_block, "Dockerfile must lock /opt/hermes after installing code/deps" + + +def test_dockerfile_keeps_mutable_state_under_opt_data() -> None: + text = _dockerfile_text() + + assert "ENV HERMES_HOME=/opt/data" in text + assert "ENV HERMES_WRITE_SAFE_ROOT=/opt/data" in text + assert 'VOLUME [ "/opt/data" ]' in text + + +def test_dockerfile_disables_runtime_install_mutations() -> None: + text = _dockerfile_text() + + assert "ENV PYTHONDONTWRITEBYTECODE=1" in text + assert "ENV HERMES_DISABLE_LAZY_INSTALLS=1" in text + assert "HERMES_TUI_DIR=/opt/hermes/ui-tui" in text + + +def test_dockerfile_does_not_chown_install_trees_to_hermes() -> None: + text = _dockerfile_text() + forbidden_patterns = ( + r"chown\s+-R\s+hermes:hermes\s+/opt/hermes/\.venv", + r"chown\s+-R\s+hermes:hermes\s+/opt/hermes/ui-tui", + r"chown\s+-R\s+hermes:hermes\s+/opt/hermes/gateway", + r"chown\s+-R\s+hermes:hermes\s+/opt/hermes/node_modules", + ) + for pattern in forbidden_patterns: + assert not re.search(pattern, text), ( + "runtime install trees under /opt/hermes must stay immutable; " + f"found forbidden pattern {pattern!r}" + ) diff --git a/tests/tools/test_dockerfile_node_modules_perms.py b/tests/tools/test_dockerfile_node_modules_perms.py index 671a8d843a0..7329a5ce6bb 100644 --- a/tests/tools/test_dockerfile_node_modules_perms.py +++ b/tests/tools/test_dockerfile_node_modules_perms.py @@ -1,11 +1,9 @@ -"""contract test: dockerfile chowns runtime node_modules trees to hermes +"""Contract test: Docker TUI must not require writable node_modules. -regression guard for #18800. the container drops privileges to the hermes -user (uid 10000) in entrypoint.sh, then the TUI launcher's -_tui_need_npm_install() trips on every startup (see the -npm_config_install_links=false comment in the Dockerfile) and runs -`npm install` in /opt/hermes/ui-tui. that install fails with EACCES unless -the runtime node_modules trees are owned by hermes. +Older images made /opt/hermes/ui-tui and /opt/hermes/node_modules writable so a +runtime npm install could repair stale dependencies. The hosted install tree is +now immutable, so the Docker image must take the prebuilt TUI bundle path +instead of writing to node_modules at runtime. """ from __future__ import annotations @@ -15,29 +13,10 @@ REPO_ROOT = Path(__file__).resolve().parents[2] DOCKERFILE = REPO_ROOT / "Dockerfile" -def test_dockerfile_chowns_runtime_node_modules_to_hermes_user() -> None: +def test_dockerfile_uses_prebuilt_tui_instead_of_writable_node_modules() -> None: text = DOCKERFILE.read_text() - chown_lines = [ - line for line in text.splitlines() - if "chown" in line and "hermes:hermes" in line - ] - assert chown_lines, ( - "Dockerfile must contain a chown -R hermes:hermes for the runtime " - "node_modules trees; see #18800" - ) - - chown_block = "\n".join(chown_lines) - - # Runtime-mutable trees must be passed to the chown command. - # /opt/hermes/web is intentionally excluded: it is build-time only, - # because HERMES_WEB_DIST points at hermes_cli/web_dist for runtime. - for required_path in ( - "/opt/hermes/ui-tui", - "/opt/hermes/node_modules", - "/opt/hermes/gateway", - ): - assert required_path in chown_block, ( - f"{required_path} must be passed to a chown -R hermes:hermes " - f"command in the Dockerfile (see #18800, #27221)" - ) + assert "ENV HERMES_TUI_DIR=/opt/hermes/ui-tui" in text + assert "cd ../ui-tui && npm run build" in text + assert "chown -R hermes:hermes /opt/hermes/ui-tui" not in text + assert "chown -R hermes:hermes /opt/hermes/node_modules" not in text diff --git a/tests/tools/test_stage2_hook_build_tree_chown.py b/tests/tools/test_stage2_hook_build_tree_chown.py deleted file mode 100644 index 69a7a3108db..00000000000 --- a/tests/tools/test_stage2_hook_build_tree_chown.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Contract test: the s6-overlay stage2 hook re-chowns the build trees under -$INSTALL_DIR (/opt/hermes/.venv, ui-tui, node_modules) to the runtime hermes -UID whenever they are not already hermes-owned — INDEPENDENTLY of whether -$HERMES_HOME ownership already matches. - -Regression guard for the HERMES_UID/PUID remap path broken by #35027. - -`usermod -u hermes` re-chowns the hermes home dir ($HERMES_HOME == -/opt/data) to the new UID as a side effect. #35027 gated the build-tree chown -behind `stat $HERMES_HOME != hermes_uid`, so after any remap that stat is -already satisfied and the build-tree chown was silently skipped — leaving -.venv owned by the build-time UID (10000) and breaking: - - lazy_deps.py `uv pip install` of platform extras (#15012, #21100) - - the TUI esbuild rebuild into ui-tui/dist (#28851) - -The fix probes the build trees directly (stat .venv) rather than $HERMES_HOME. - -The extraction + stubbed-shell-run approach mirrors -tests/tools/test_stage2_hook_toplevel_chown.py. -""" -from __future__ import annotations - -import re -import shutil -import subprocess -import tempfile -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[2] -STAGE2_HOOK = REPO_ROOT / "docker" / "stage2-hook.sh" - - -@pytest.fixture(scope="module") -def stage2_text() -> str: - if not STAGE2_HOOK.exists(): - pytest.skip("docker/stage2-hook.sh not present in this checkout") - return STAGE2_HOOK.read_text() - - -def _build_tree_block(text: str) -> str: - """Extract the build-tree chown block: from the `venv_owner=` probe - through the closing `fi` of the chown.""" - m = re.search( - r"(venv_owner=\$\(stat[^\n]*\n(?:.*\n)*?fi)", - text, - ) - assert m, "stage2-hook.sh must contain the venv_owner-gated build-tree chown block" - return m.group(1) - - -def test_build_tree_chown_not_gated_on_hermes_home(stage2_text: str) -> None: - """The build-tree chown must NOT live inside the `if [ "$needs_chown" = true ]` - block keyed on $HERMES_HOME ownership — that is exactly the #35027 bug.""" - block = _build_tree_block(stage2_text) - # The block probes the venv owner, not $HERMES_HOME. - assert "venv_owner" in block - assert "$INSTALL_DIR/.venv" in block - # All three build trees are covered. - for tree in ("$INSTALL_DIR/.venv", "$INSTALL_DIR/ui-tui", "$INSTALL_DIR/node_modules"): - assert tree in block, f"build-tree chown must cover {tree}" - - -def _run_build_tree_block( - text: str, *, venv_owner: int, hermes_uid: int -) -> bool: - """Run the extracted build-tree block with `stat`, `id`, and `chown` - stubbed. Returns True iff the block attempted the recursive chown.""" - bash = shutil.which("bash") - if bash is None: - pytest.skip("bash not available") - block = _build_tree_block(text) - - with tempfile.TemporaryDirectory() as d: - dpath = Path(d) - log = dpath / "chown.log" - # Stubs: - # stat -c %u -> echo the simulated venv owner - # id -u hermes -> handled via actual_hermes_uid var below - # chown ... -> record that it fired - script = ( - "set -eu\n" - f'INSTALL_DIR="/opt/hermes"\n' - f'actual_hermes_uid={hermes_uid}\n' - f'stat() {{ echo {venv_owner}; }}\n' - f'chown() {{ echo fired >> "{log}"; }}\n' - + block - ) - script_path = dpath / "harness.sh" - script_path.write_text(script) - proc = subprocess.run([bash, str(script_path)], capture_output=True, text=True) - assert proc.returncode == 0, proc.stderr - return log.exists() and "fired" in log.read_text() - - -def test_chown_fires_when_venv_owner_differs(stage2_text: str) -> None: - """The #35027 regression scenario: after a remap $HERMES_HOME already - matches the new UID, but the venv is still owned by the build-time UID - (10000). The build-tree chown MUST still fire.""" - fired = _run_build_tree_block(stage2_text, venv_owner=10000, hermes_uid=4242) - assert fired, ( - "build-tree chown must fire when the venv is not owned by the runtime " - "hermes UID, regardless of $HERMES_HOME ownership (#35027 regression)" - ) - - -def test_chown_skipped_when_venv_already_owned(stage2_text: str) -> None: - """Idempotency: once the venv is hermes-owned, the recursive chown is - skipped on subsequent boots.""" - fired = _run_build_tree_block(stage2_text, venv_owner=4242, hermes_uid=4242) - assert not fired, ( - "build-tree chown must be skipped when the venv already matches the " - "runtime hermes UID (avoid expensive recursive chown on every restart)" - ) - - -def test_chown_skipped_for_default_uid(stage2_text: str) -> None: - """No remap: venv owned by the default build UID (10000) and hermes is - still 10000 — nothing to do.""" - fired = _run_build_tree_block(stage2_text, venv_owner=10000, hermes_uid=10000) - assert not fired diff --git a/tests/tools/test_stage2_hook_immutable_install.py b/tests/tools/test_stage2_hook_immutable_install.py new file mode 100644 index 00000000000..d7ae79c5354 --- /dev/null +++ b/tests/tools/test_stage2_hook_immutable_install.py @@ -0,0 +1,48 @@ +"""Contract tests for the Docker stage2 immutable install-tree policy. + +Hosted/container Hermes keeps user-writable state under HERMES_HOME +(/opt/data). The installed source, venv, TUI bundle, and node_modules under +/opt/hermes must remain root-owned/non-writable by the runtime hermes user so +an agent session cannot self-modify the installation and brick the gateway. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +STAGE2_HOOK = REPO_ROOT / "docker" / "stage2-hook.sh" + + +@pytest.fixture(scope="module") +def stage2_text() -> str: + if not STAGE2_HOOK.exists(): + pytest.skip("docker/stage2-hook.sh not present in this checkout") + return STAGE2_HOOK.read_text() + + +def test_stage2_does_not_chown_install_tree_to_hermes(stage2_text: str) -> None: + assert "Fixing ownership of build trees under $INSTALL_DIR" not in stage2_text + assert 'chown -R hermes:hermes \\\n "$INSTALL_DIR/.venv"' not in stage2_text + + assert "venv_owner=$(stat -c %u \"$INSTALL_DIR/.venv\"" not in stage2_text + assert "chown of build trees failed" not in stage2_text + for install_tree in ( + '"$INSTALL_DIR/.venv" \\', + '"$INSTALL_DIR/ui-tui" \\', + '"$INSTALL_DIR/gateway" \\', + '"$INSTALL_DIR/node_modules" \\', + ): + assert install_tree not in stage2_text, ( + f"stage2 must not chown {install_tree} back to hermes; " + "the Dockerfile keeps /opt/hermes immutable and writable state " + "belongs under HERMES_HOME" + ) + + +def test_stage2_documents_immutable_install_contract(stage2_text: str) -> None: + assert "Immutable install tree" in stage2_text + assert "PYTHONDONTWRITEBYTECODE" in stage2_text + assert "HERMES_DISABLE_LAZY_INSTALLS=1" in stage2_text + assert "/opt/hermes" in stage2_text diff --git a/tests/tools/test_stage2_hook_install_dir_chown.py b/tests/tools/test_stage2_hook_install_dir_chown.py deleted file mode 100644 index 3e68aac76a1..00000000000 --- a/tests/tools/test_stage2_hook_install_dir_chown.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Contract test: stage2-hook repairs ownership of the gateway install tree. - -When HERMES_UID is remapped at container boot, ``usermod -u`` only rewrites -files under the hermes user's home directory ($HERMES_HOME == /opt/data). -Runtime-writable trees under ``/opt/hermes`` must be explicitly chowned to the -new UID before services drop privileges. ``/opt/hermes/gateway`` is one such -tree: Python writes ``__pycache__`` beneath the package on first import, which -fails with EACCES if the tree still belongs to the build-time UID (10000) after -a remap (#27221). -""" -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[2] -STAGE2_HOOK = REPO_ROOT / "docker" / "stage2-hook.sh" - - -@pytest.fixture(scope="module") -def stage2_text() -> str: - if not STAGE2_HOOK.exists(): - pytest.skip("docker/stage2-hook.sh not present in this checkout") - return STAGE2_HOOK.read_text() - - -def _install_dir_chown_block(text: str) -> str: - match = re.search( - r"(chown -R hermes:hermes \\\n" - r"(?:\s+\"\$INSTALL_DIR/[^\"]+\" \\\n)+" - r"\s+2>/dev/null \|\| \\\n" - r"\s+echo \"\[stage2\] Warning: chown of build trees failed.*?\")", - text, - flags=re.DOTALL, - ) - assert match, "stage2-hook.sh must repair ownership of runtime-writable install trees" - return match.group(1) - - -def test_uid_remap_chowns_runtime_writable_gateway_tree(stage2_text: str) -> None: - block = _install_dir_chown_block(stage2_text) - assert '"$INSTALL_DIR/gateway"' in block, ( - "the build-tree ownership repair must chown $INSTALL_DIR/gateway so the " - "gateway runtime can write Python cache artifacts after a UID remap (#27221)" - ) - - -def test_install_dir_chown_keeps_existing_runtime_writable_trees(stage2_text: str) -> None: - block = _install_dir_chown_block(stage2_text) - for required in ( - '"$INSTALL_DIR/.venv"', - '"$INSTALL_DIR/ui-tui"', - '"$INSTALL_DIR/node_modules"', - ): - assert required in block diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 76ce863e661..9e8220dd037 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -623,6 +623,7 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us | `HERMES_ALLOW_PRIVATE_URLS` | `true`/`false` — allow tools to fetch localhost/private-network URLs. Off by default in gateway mode. | | `HERMES_REDACT_SECRETS` | `true`/`false` — control secret redaction in tool output, logs, and chat responses (default: `true`). | | `HERMES_WRITE_SAFE_ROOT` | Optional directory prefix that restricts `write_file`/`patch` writes; paths outside require approval. | +| `HERMES_DISABLE_LAZY_INSTALLS` | Internal bridge var set automatically in the official Docker image to prevent runtime dependency installs into the immutable `/opt/hermes` tree. The user-facing equivalent is `security.allow_lazy_installs: false` in `config.yaml`; do not set this in `.env`. | | `HERMES_DISABLE_FILE_STATE_GUARD` | Set to `1` to turn off the "file changed since you read it" guard on `patch`/`write_file`. | | `HERMES_CORE_TOOLS` | Comma-separated override for the canonical core tool list (advanced; rarely needed). | | `HERMES_BUNDLED_SKILLS` | Comma-separated override for the list of bundled skills loaded at startup. | diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md index c40938db393..7825d2a6742 100644 --- a/website/docs/user-guide/docker.md +++ b/website/docs/user-guide/docker.md @@ -168,6 +168,16 @@ The `/opt/data` volume is the single source of truth for all Hermes state. It ma | `logs/` | Runtime logs | | `skins/` | Custom CLI skins | +### Immutable install tree + +In hosted and published Docker images, `/opt/hermes` is the installed application tree. It is root-owned and read-only to the runtime `hermes` user, so agent turns, gateway sessions, dashboard actions, and normal `docker exec hermes hermes ...` commands cannot edit the core source, bundled `.venv`, `node_modules`, or TUI bundle in place. + +All mutable Hermes state belongs under `/opt/data`: config, `.env`, profiles, skills, memories, sessions, logs, dashboard uploads, plugins, and other user-managed files. The image also disables runtime `.pyc` writes and Hermes lazy dependency installs into `/opt/hermes`; optional platform dependencies needed by the published image should be baked into the image or installed through a new image build. + +On hosted/published images, agent self-improvement is scoped to skills, memory, plugins, and config under `/opt/data`. The installed core source under `/opt/hermes` is immutable; core changes are made via PRs to the repo and shipped by updating the image, not by live-editing the running install. + +If an operator needs to repair or inspect files outside `/opt/data`, use a root shell intentionally. The `hermes` shim normally drops `docker exec hermes hermes ...` back to the runtime user; set `HERMES_DOCKER_EXEC_AS_ROOT=1` for a one-off root invocation when you explicitly need root semantics. + Skill CLIs that store credentials under `~` must be initialized against the subprocess HOME, not just the data-volume root. For example, the [xurl skill](./skills/bundled/social-media/social-media-xurl.md) stores OAuth state in `~/.xurl`; in the official Docker layout, Hermes tool calls read that as `/opt/data/home/.xurl`, so run manual xurl auth with `HOME=/opt/data/home` and verify with `HOME=/opt/data/home xurl auth status`. :::warning