mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
182 lines
6.3 KiB
Python
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
|