mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-18 09:51:59 +00:00
Harden hosted Docker install tree against self-modification (#47490)
* Harden hosted Docker install tree * Document hosted Docker immutable install tree
This commit is contained in:
parent
f8098c6b6f
commit
6092be413d
10 changed files with 240 additions and 289 deletions
50
Dockerfile
50
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 <c> 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
|
||||
|
|
|
|||
|
|
@ -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 <new> 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
|
||||
|
|
|
|||
67
tests/docker/test_immutable_install_permissions.py
Normal file
67
tests/docker/test_immutable_install_permissions.py
Normal file
|
|
@ -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:]
|
||||
61
tests/tools/test_dockerfile_immutable_install.py
Normal file
61
tests/tools/test_dockerfile_immutable_install.py
Normal file
|
|
@ -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}"
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <new> 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 <path> -> 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
|
||||
48
tests/tools/test_stage2_hook_immutable_install.py
Normal file
48
tests/tools/test_stage2_hook_immutable_install.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue