hermes-agent/tests/docker/test_toplevel_chown.py

182 lines
6.3 KiB
Python

"""Runtime smoke tests for Docker top-level state-file ownership repair.
Build the real image and verify the actual runtime behavior:
1. Root-owned top-level state files (auth.json, state.db, gateway.lock,
gateway_state.json) are chowned to hermes on boot
2. Non-allowlisted host-owned files are NOT touched (targeted, not
blanket find -user root sweep)
3. Symlinked allowlisted files are NOT chowned through the symlink
(path_has_symlink_component guard)
"""
from __future__ import annotations
import tempfile
from pathlib import Path
import subprocess
from tests.docker.conftest import (
docker_exec,
docker_exec_sh,
restart_container,
start_container,
wait_for_container_ready,
)
# The files the stage2 hook should repair (mirrors the allowlist in
# stage2-hook.sh). We test a representative subset.
ALLOWLISTED_FILES = ("auth.json", "state.db", "gateway.lock", "gateway_state.json")
def test_root_owned_state_files_repaired_on_boot(
built_image: str, container_name: str,
) -> None:
"""Root-owned top-level state files must be chowned to hermes on boot."""
start_container(built_image, container_name)
# Create root-owned state files to simulate docker exec (root) writes
for f in ALLOWLISTED_FILES:
docker_exec(
container_name, "touch", f"/opt/data/{f}",
user="root", timeout=5,
)
# Verify they're root-owned
r = docker_exec_sh(
container_name,
" ".join(f'stat -c %U /opt/data/{f}' for f in ALLOWLISTED_FILES),
timeout=5,
)
for line in r.stdout.split():
assert line == "root", f"expected root-owned, got: {line}"
# Restart - stage2 should repair ownership
restart_container(container_name)
# Verify files are now hermes-owned
r = docker_exec_sh(
container_name,
" ".join(f'stat -c %U /opt/data/{f}' for f in ALLOWLISTED_FILES),
timeout=5,
)
for line in r.stdout.split():
assert line == "hermes", (
f"expected hermes-owned after restart, got: {line}"
)
def test_non_allowlisted_host_file_not_touched(
built_image: str, container_name: str,
) -> None:
"""A non-allowlisted host-owned file must NOT be chowned, even if
root-owned. Regression guard for #19788 / #19795: a bind-mounted
$HERMES_HOME may contain host-owned files Hermes does not manage."""
start_container(built_image, container_name)
# Create a non-allowlisted file as root
docker_exec(
container_name, "touch", "/opt/data/host_secret.json",
user="root", timeout=5,
)
# Make it root-owned explicitly (it already is, but be sure)
docker_exec(
container_name, "chown", "root:root", "/opt/data/host_secret.json",
user="root", timeout=5,
)
# Restart
restart_container(container_name)
# The file must STILL be root-owned (not touched by stage2)
r = docker_exec_sh(
container_name,
"stat -c %U /opt/data/host_secret.json",
timeout=5,
)
assert r.stdout.strip() == "root", (
f"non-allowlisted host file was chowned by stage2 (should be "
f"preserved): {r.stdout.strip()}"
)
def test_symlinked_allowlisted_file_not_chowned(
built_image: str, container_name: str,
) -> None:
"""A symlinked allowlisted file (e.g. auth.json -> /tmp/outside.json)
must NOT be chowned through the symlink.
The path_has_symlink_component guard in stage2-hook.sh must detect
the symlink and refuse the chown, printing a warning instead. The
symlink target must remain untouched and the symlink itself must
still be a symlink after restart.
"""
tmp = tempfile.mkdtemp()
host_data: Path | None = None
tmp_path = Path(tmp)
try:
host_data = tmp_path / "data"
host_data.mkdir()
# Pre-create a symlink: auth.json -> /opt/data/.symlink-target
# The target must exist so [ -e ] on the symlink returns true and
# the chown loop enters the refuse_symlinked_path guard. We create
# the target inside the bind mount so it persists across containers.
subprocess.run(
["docker", "run", "--rm",
"-v", f"{host_data}:/opt/data",
"--entrypoint", "sh", built_image,
"-c", "touch /opt/data/.symlink-target && ln -s /opt/data/.symlink-target /opt/data/auth.json"],
check=True, capture_output=True, timeout=30,
)
# Boot the container with the bind mount
subprocess.run(
["docker", "run", "-d", "--name", container_name,
"-v", f"{host_data}:/opt/data",
built_image, "sleep", "infinity"],
check=True, capture_output=True, timeout=60,
)
# Wait for cont-init to finish (first boot runs stage2)
wait_for_container_ready(container_name)
# The symlink must still exist (not replaced by a regular file)
r = docker_exec_sh(
container_name,
"test -L /opt/data/auth.json && echo SYMLINK || echo NOT_SYMLINK",
timeout=5,
)
assert "SYMLINK" in r.stdout, (
f"auth.json symlink was replaced by a regular file: {r.stdout}"
)
# The refusal warning goes to stdout (docker logs), not
# container-boot.log (which is written by container_boot.py).
r = subprocess.run(
["docker", "logs", container_name],
capture_output=True, text=True, timeout=10,
)
combined = r.stdout + r.stderr
assert "refusing" in combined and "auth.json" in combined, (
f"expected symlink refusal warning for auth.json in docker logs: {combined}"
)
finally:
# Clean up root/hermes-owned files left by stage2 chown
if host_data is not None:
subprocess.run(
["docker", "rm", "-f", container_name],
capture_output=True, timeout=10,
)
subprocess.run(
["docker", "run", "--rm",
"-v", f"{host_data}:/clean",
"--entrypoint", "sh", built_image,
"-c", "chown -R 0:0 /clean 2>/dev/null; rm -rf /clean/* /clean/.* 2>/dev/null; chown 0:0 /clean; true"],
capture_output=True, timeout=15,
)
try:
host_data.rmdir()
tmp_path.rmdir()
except OSError:
pass