mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-26 11:12:03 +00:00
fix(docker): skip symlinked stage2 chown targets (#52789)
Prevents stage2-hook.sh recursive chown from following a symlinked $HERMES_HOME/home (or profiles/cron) and destroying the host user's home directory. Also guards top-level state-file chowns and refuses first-boot seeding through symlinks. Fixes #52781. Co-authored-by: harjoth <harjoth.khara@gmail.com>
This commit is contained in:
parent
1abfa66ba6
commit
233ef98afe
6 changed files with 511 additions and 31 deletions
|
|
@ -181,6 +181,45 @@ done
|
|||
# The canonical list of hermes-owned subdirs is the same one the s6-setuidgid
|
||||
# mkdir -p block below seeds. Keep them in sync if the seed list changes.
|
||||
actual_hermes_uid=$(id -u hermes)
|
||||
|
||||
path_has_symlink_component() {
|
||||
path="$1"
|
||||
root="${2:-$HERMES_HOME}"
|
||||
while [ -n "$path" ] && [ "$path" != "/" ]; do
|
||||
if [ -L "$path" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$path" = "$root" ]; then
|
||||
break
|
||||
fi
|
||||
parent="$(dirname "$path")"
|
||||
if [ "$parent" = "$path" ]; then
|
||||
break
|
||||
fi
|
||||
path="$parent"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
refuse_symlinked_path() {
|
||||
action="$1"
|
||||
target="$2"
|
||||
if path_has_symlink_component "$target"; then
|
||||
echo "[stage2] Warning: refusing $action through symlinked path $target — continuing"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
chown_hermes_tree() {
|
||||
target="$1"
|
||||
if refuse_symlinked_path "recursive chown" "$target"; then
|
||||
return 0
|
||||
fi
|
||||
chown -R hermes:hermes "$target" 2>/dev/null || \
|
||||
echo "[stage2] Warning: chown $target failed (rootless container?) — continuing"
|
||||
}
|
||||
|
||||
needs_chown=false
|
||||
if [ "$(stat -c %u "$HERMES_HOME" 2>/dev/null)" != "$actual_hermes_uid" ]; then
|
||||
needs_chown=true
|
||||
|
|
@ -194,15 +233,18 @@ if [ "$needs_chown" = true ]; then
|
|||
# Top-level $HERMES_HOME: chown the directory itself (not its contents)
|
||||
# so hermes can mkdir new subdirs but bind-mounted host files keep
|
||||
# their existing ownership.
|
||||
chown hermes:hermes "$HERMES_HOME" 2>/dev/null || \
|
||||
echo "[stage2] Warning: chown $HERMES_HOME failed (rootless container?) — continuing"
|
||||
if refuse_symlinked_path "chown" "$HERMES_HOME"; then
|
||||
:
|
||||
else
|
||||
chown hermes:hermes "$HERMES_HOME" 2>/dev/null || \
|
||||
echo "[stage2] Warning: chown $HERMES_HOME failed (rootless container?) — continuing"
|
||||
fi
|
||||
# Hermes-owned subdirs: recursive chown is safe here because these are
|
||||
# created and managed exclusively by hermes (see the s6-setuidgid mkdir
|
||||
# -p block below for the canonical list).
|
||||
for sub in cron sessions logs hooks memories skills skins plans workspace home profiles pairing platforms/pairing lazy-packages; do
|
||||
if [ -e "$HERMES_HOME/$sub" ]; then
|
||||
chown -R hermes:hermes "$HERMES_HOME/$sub" 2>/dev/null || \
|
||||
echo "[stage2] Warning: chown $HERMES_HOME/$sub failed (rootless container?) — continuing"
|
||||
chown_hermes_tree "$HERMES_HOME/$sub"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
|
@ -234,7 +276,7 @@ fi
|
|||
# the profiles dir. Idempotent; skipped on rootless containers where
|
||||
# chown would fail.
|
||||
if [ -d "$HERMES_HOME/profiles" ]; then
|
||||
chown -R hermes:hermes "$HERMES_HOME/profiles" 2>/dev/null || true
|
||||
chown_hermes_tree "$HERMES_HOME/profiles"
|
||||
fi
|
||||
|
||||
# Always reset ownership of $HERMES_HOME/cron on every boot for the same
|
||||
|
|
@ -242,7 +284,7 @@ fi
|
|||
# (jobs.json) must stay readable by the unprivileged hermes runtime even
|
||||
# after root-context maintenance commands or scheduler writes.
|
||||
if [ -d "$HERMES_HOME/cron" ]; then
|
||||
chown -R hermes:hermes "$HERMES_HOME/cron" 2>/dev/null || true
|
||||
chown_hermes_tree "$HERMES_HOME/cron"
|
||||
fi
|
||||
|
||||
# Reset ownership of hermes-owned top-level state files on every boot.
|
||||
|
|
@ -268,7 +310,11 @@ for f in \
|
|||
gateway.pid gateway.lock gateway_state.json processes.json \
|
||||
active_profile; do
|
||||
if [ -e "$HERMES_HOME/$f" ]; then
|
||||
chown hermes:hermes "$HERMES_HOME/$f" 2>/dev/null || true
|
||||
if refuse_symlinked_path "chown" "$HERMES_HOME/$f"; then
|
||||
:
|
||||
else
|
||||
chown hermes:hermes "$HERMES_HOME/$f" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
|
|
@ -276,8 +322,12 @@ done
|
|||
# Ensure config.yaml is readable by the hermes runtime user even if it
|
||||
# was edited on the host after initial ownership setup.
|
||||
if [ -f "$HERMES_HOME/config.yaml" ]; then
|
||||
chown hermes:hermes "$HERMES_HOME/config.yaml" 2>/dev/null || true
|
||||
chmod 640 "$HERMES_HOME/config.yaml" 2>/dev/null || true
|
||||
if refuse_symlinked_path "chown/chmod" "$HERMES_HOME/config.yaml"; then
|
||||
:
|
||||
else
|
||||
chown hermes:hermes "$HERMES_HOME/config.yaml" 2>/dev/null || true
|
||||
chmod 640 "$HERMES_HOME/config.yaml" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Seed directory structure as hermes user ---
|
||||
|
|
@ -328,7 +378,11 @@ seed_one() {
|
|||
dest=$1
|
||||
src=$2
|
||||
if [ ! -f "$HERMES_HOME/$dest" ] && [ -f "$INSTALL_DIR/$src" ]; then
|
||||
as_hermes cp "$INSTALL_DIR/$src" "$HERMES_HOME/$dest"
|
||||
if refuse_symlinked_path "seed" "$HERMES_HOME/$dest"; then
|
||||
:
|
||||
else
|
||||
as_hermes cp "$INSTALL_DIR/$src" "$HERMES_HOME/$dest"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
seed_one ".env" ".env.example"
|
||||
|
|
@ -339,8 +393,12 @@ seed_one "SOUL.md" "docker/SOUL.md"
|
|||
# unconditionally (not only on first-seed) so a host-mounted .env that was
|
||||
# created with a permissive umask gets tightened on every container start.
|
||||
if [ -f "$HERMES_HOME/.env" ]; then
|
||||
chown hermes:hermes "$HERMES_HOME/.env" 2>/dev/null || true
|
||||
chmod 600 "$HERMES_HOME/.env" 2>/dev/null || true
|
||||
if refuse_symlinked_path "chown/chmod" "$HERMES_HOME/.env"; then
|
||||
:
|
||||
else
|
||||
chown hermes:hermes "$HERMES_HOME/.env" 2>/dev/null || true
|
||||
chmod 600 "$HERMES_HOME/.env" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Migrate persisted config schema ---
|
||||
|
|
@ -358,9 +416,13 @@ fi
|
|||
# pre-s6 entrypoint — the [ ! -f ] guard is critical to avoid clobbering
|
||||
# rotated refresh tokens on container restart.
|
||||
if [ ! -f "$HERMES_HOME/auth.json" ] && [ -n "${HERMES_AUTH_JSON_BOOTSTRAP:-}" ]; then
|
||||
printf '%s' "$HERMES_AUTH_JSON_BOOTSTRAP" > "$HERMES_HOME/auth.json"
|
||||
chown hermes:hermes "$HERMES_HOME/auth.json" 2>/dev/null || true
|
||||
chmod 600 "$HERMES_HOME/auth.json"
|
||||
if refuse_symlinked_path "seed" "$HERMES_HOME/auth.json"; then
|
||||
:
|
||||
else
|
||||
printf '%s' "$HERMES_AUTH_JSON_BOOTSTRAP" > "$HERMES_HOME/auth.json"
|
||||
chown hermes:hermes "$HERMES_HOME/auth.json" 2>/dev/null || true
|
||||
chmod 600 "$HERMES_HOME/auth.json"
|
||||
fi
|
||||
fi
|
||||
|
||||
# gateway_state.json: declare the gateway's INITIAL supervised state on a
|
||||
|
|
@ -390,9 +452,13 @@ fi
|
|||
# bogus state the reconciler would treat as "no prior state" anyway.
|
||||
if [ ! -f "$HERMES_HOME/gateway_state.json" ] && \
|
||||
[ "${HERMES_GATEWAY_BOOTSTRAP_STATE:-}" = "running" ]; then
|
||||
printf '{"gateway_state":"running"}\n' > "$HERMES_HOME/gateway_state.json"
|
||||
chown hermes:hermes "$HERMES_HOME/gateway_state.json" 2>/dev/null || true
|
||||
chmod 644 "$HERMES_HOME/gateway_state.json"
|
||||
if refuse_symlinked_path "seed" "$HERMES_HOME/gateway_state.json"; then
|
||||
:
|
||||
else
|
||||
printf '{"gateway_state":"running"}\n' > "$HERMES_HOME/gateway_state.json"
|
||||
chown hermes:hermes "$HERMES_HOME/gateway_state.json" 2>/dev/null || true
|
||||
chmod 644 "$HERMES_HOME/gateway_state.json"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Sync bundled skills ---
|
||||
|
|
|
|||
|
|
@ -85,7 +85,9 @@ def test_stage2_hook_repairs_profiles_and_cron_ownership_on_every_boot() -> None
|
|||
text = STAGE2_HOOK.read_text(encoding="utf-8")
|
||||
|
||||
assert 'if [ -d "$HERMES_HOME/profiles" ]; then' in text
|
||||
assert 'chown -R hermes:hermes "$HERMES_HOME/profiles" 2>/dev/null || true' in text
|
||||
assert 'chown_hermes_tree "$HERMES_HOME/profiles"' in text
|
||||
assert 'chown -R hermes:hermes "$HERMES_HOME/profiles" 2>/dev/null || true' not in text
|
||||
|
||||
assert 'if [ -d "$HERMES_HOME/cron" ]; then' in text
|
||||
assert 'chown -R hermes:hermes "$HERMES_HOME/cron" 2>/dev/null || true' in text
|
||||
assert 'chown_hermes_tree "$HERMES_HOME/cron"' in text
|
||||
assert 'chown -R hermes:hermes "$HERMES_HOME/cron" 2>/dev/null || true' not in text
|
||||
|
|
|
|||
|
|
@ -43,18 +43,25 @@ def stage2_text() -> str:
|
|||
|
||||
|
||||
def _seed_block(text: str) -> str:
|
||||
"""Extract the ``if [ ! -f "$HERMES_HOME/gateway_state.json" ] && … fi``
|
||||
block that seeds the gateway state file from the bootstrap env var."""
|
||||
m = re.search(
|
||||
r'(if \[ ! -f "\$HERMES_HOME/gateway_state\.json" \] && \\\n'
|
||||
r"(?:.*\n)*?fi)",
|
||||
text,
|
||||
"""Extract the gateway_state.json bootstrap block."""
|
||||
start = text.index('if [ ! -f "$HERMES_HOME/gateway_state.json" ] && \\')
|
||||
end = text.index("\n\n# --- Sync bundled skills ---", start)
|
||||
return text[start:end]
|
||||
|
||||
|
||||
def _auth_seed_block(text: str) -> str:
|
||||
start = text.index(
|
||||
'if [ ! -f "$HERMES_HOME/auth.json" ] && '
|
||||
'[ -n "${HERMES_AUTH_JSON_BOOTSTRAP:-}" ]; then'
|
||||
)
|
||||
assert m, (
|
||||
"stage2-hook.sh must contain the gateway_state.json bootstrap-seed block "
|
||||
"guarded on HERMES_GATEWAY_BOOTSTRAP_STATE"
|
||||
)
|
||||
return m.group(1)
|
||||
end = text.index("\n\n# gateway_state.json:", start)
|
||||
return text[start:end]
|
||||
|
||||
|
||||
def _path_guard_functions(text: str) -> str:
|
||||
start = text.index("path_has_symlink_component() {")
|
||||
end = text.index("\n\nchown_hermes_tree() {", start)
|
||||
return text[start:end]
|
||||
|
||||
|
||||
def test_seed_block_present_and_guarded(stage2_text: str) -> None:
|
||||
|
|
@ -99,6 +106,7 @@ def _run_seed(
|
|||
script = (
|
||||
"set -e\n"
|
||||
f'HERMES_HOME="{home}"\n'
|
||||
f"{_path_guard_functions(text)}\n"
|
||||
# Stub privilege ops — the sandbox isn't root.
|
||||
"chown() { :; }\n"
|
||||
"chmod() { :; }\n"
|
||||
|
|
@ -134,6 +142,82 @@ def test_does_not_clobber_existing_state(stage2_text: str) -> None:
|
|||
assert out == existing, "seed must not clobber a persisted state file"
|
||||
|
||||
|
||||
def test_does_not_seed_gateway_state_through_symlink(
|
||||
stage2_text: str,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A dangling gateway_state.json symlink must not become a host write."""
|
||||
bash = shutil.which("bash")
|
||||
if bash is None:
|
||||
pytest.skip("bash not available")
|
||||
block = _seed_block(stage2_text)
|
||||
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
outside_state = tmp_path / "outside-gateway-state.json"
|
||||
state_file = home / "gateway_state.json"
|
||||
try:
|
||||
state_file.symlink_to(outside_state)
|
||||
except (NotImplementedError, OSError):
|
||||
pytest.skip("symlinks are not available on this platform")
|
||||
|
||||
script = (
|
||||
"set -e\n"
|
||||
f'HERMES_HOME="{home}"\n'
|
||||
f"{_path_guard_functions(stage2_text)}\n"
|
||||
"chown() { :; }\n"
|
||||
"chmod() { :; }\n"
|
||||
'export HERMES_GATEWAY_BOOTSTRAP_STATE="running"\n'
|
||||
+ block
|
||||
)
|
||||
script_path = tmp_path / "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
|
||||
assert not outside_state.exists()
|
||||
assert state_file.is_symlink()
|
||||
assert "refusing seed through symlinked path" in proc.stdout
|
||||
|
||||
|
||||
def test_does_not_seed_auth_json_through_symlink(
|
||||
stage2_text: str,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A dangling auth.json symlink must not become a host write."""
|
||||
bash = shutil.which("bash")
|
||||
if bash is None:
|
||||
pytest.skip("bash not available")
|
||||
block = _auth_seed_block(stage2_text)
|
||||
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
outside_auth = tmp_path / "outside-auth.json"
|
||||
auth_file = home / "auth.json"
|
||||
try:
|
||||
auth_file.symlink_to(outside_auth)
|
||||
except (NotImplementedError, OSError):
|
||||
pytest.skip("symlinks are not available on this platform")
|
||||
|
||||
script = (
|
||||
"set -e\n"
|
||||
f'HERMES_HOME="{home}"\n'
|
||||
f"{_path_guard_functions(stage2_text)}\n"
|
||||
"chown() { :; }\n"
|
||||
"chmod() { :; }\n"
|
||||
'export HERMES_AUTH_JSON_BOOTSTRAP="{\\"ok\\": true}"\n'
|
||||
+ block
|
||||
)
|
||||
script_path = tmp_path / "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
|
||||
assert not outside_auth.exists()
|
||||
assert auth_file.is_symlink()
|
||||
assert "refusing seed through symlinked path" in proc.stdout
|
||||
|
||||
|
||||
def test_no_seed_when_env_unset(stage2_text: str) -> None:
|
||||
"""No env var -> no file written (preserves the default down-on-first-boot
|
||||
behaviour for orchestrators that don't opt in)."""
|
||||
|
|
|
|||
110
tests/tools/test_stage2_hook_seed_one_symlinks.py
Normal file
110
tests/tools/test_stage2_hook_seed_one_symlinks.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""Regression tests for symlink-safe Docker stage2 first-boot seeds."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
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 _seed_one_function(text: str) -> str:
|
||||
m = re.search(
|
||||
r"(seed_one\(\) \{\n(?:.*\n)*?\})\nseed_one",
|
||||
text,
|
||||
)
|
||||
assert m, "stage2-hook.sh must define seed_one before first-boot seeds"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _path_guard_functions(text: str) -> str:
|
||||
start = text.index("path_has_symlink_component() {")
|
||||
end = text.index("\n\nchown_hermes_tree() {", start)
|
||||
return text[start:end]
|
||||
|
||||
|
||||
def test_seed_one_refuses_symlinked_destinations(
|
||||
stage2_text: str,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
bash = shutil.which("bash")
|
||||
if bash is None:
|
||||
pytest.skip("bash not available")
|
||||
|
||||
home = tmp_path / "home"
|
||||
install_dir = tmp_path / "install"
|
||||
home.mkdir()
|
||||
install_dir.mkdir()
|
||||
outside_env = tmp_path / "outside.env"
|
||||
try:
|
||||
(home / ".env").symlink_to(outside_env)
|
||||
except (NotImplementedError, OSError):
|
||||
pytest.skip("symlinks are not available on this platform")
|
||||
(install_dir / ".env.example").write_text("SECRET=1\n")
|
||||
|
||||
script = (
|
||||
"set -e\n"
|
||||
f'HERMES_HOME="{home}"\n'
|
||||
f'INSTALL_DIR="{install_dir}"\n'
|
||||
"as_hermes() { \"$@\"; }\n"
|
||||
f"{_path_guard_functions(stage2_text)}\n"
|
||||
f"{_seed_one_function(stage2_text)}\n"
|
||||
'seed_one ".env" ".env.example"\n'
|
||||
)
|
||||
script_path = tmp_path / "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
|
||||
assert not outside_env.exists()
|
||||
assert (home / ".env").is_symlink()
|
||||
assert "refusing seed through symlinked path" in proc.stdout
|
||||
|
||||
|
||||
def test_seed_one_is_quiet_for_existing_symlinked_files(
|
||||
stage2_text: str,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
bash = shutil.which("bash")
|
||||
if bash is None:
|
||||
pytest.skip("bash not available")
|
||||
|
||||
home = tmp_path / "home"
|
||||
install_dir = tmp_path / "install"
|
||||
home.mkdir()
|
||||
install_dir.mkdir()
|
||||
outside_env = tmp_path / "outside.env"
|
||||
outside_env.write_text("EXISTING=1\n")
|
||||
try:
|
||||
(home / ".env").symlink_to(outside_env)
|
||||
except (NotImplementedError, OSError):
|
||||
pytest.skip("symlinks are not available on this platform")
|
||||
(install_dir / ".env.example").write_text("SECRET=1\n")
|
||||
|
||||
script = (
|
||||
"set -e\n"
|
||||
f'HERMES_HOME="{home}"\n'
|
||||
f'INSTALL_DIR="{install_dir}"\n'
|
||||
"as_hermes() { \"$@\"; }\n"
|
||||
f"{_path_guard_functions(stage2_text)}\n"
|
||||
f"{_seed_one_function(stage2_text)}\n"
|
||||
'seed_one ".env" ".env.example"\n'
|
||||
)
|
||||
script_path = tmp_path / "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
|
||||
assert outside_env.read_text() == "EXISTING=1\n"
|
||||
assert proc.stdout == ""
|
||||
145
tests/tools/test_stage2_hook_symlink_chown.py
Normal file
145
tests/tools/test_stage2_hook_symlink_chown.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"""Regression tests for symlink-safe Docker stage2 ownership repair."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
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 _chown_hermes_tree_function(text: str) -> str:
|
||||
start = text.index("path_has_symlink_component() {")
|
||||
end = text.index("\n\nneeds_chown=false", start)
|
||||
return text[start:end]
|
||||
|
||||
|
||||
def _run_helper(
|
||||
text: str,
|
||||
target: Path,
|
||||
log_path: Path,
|
||||
*,
|
||||
hermes_home: Path | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
shell = shutil.which("sh")
|
||||
if shell is None:
|
||||
pytest.skip("sh not available")
|
||||
hermes_home = target if hermes_home is None else hermes_home
|
||||
script = (
|
||||
"set -eu\n"
|
||||
f'HERMES_HOME="{hermes_home}"\n'
|
||||
f"{_chown_hermes_tree_function(text)}\n"
|
||||
f'chown() {{ printf "%s\\n" "$*" >> "{log_path}"; }}\n'
|
||||
f'chown_hermes_tree "{target}"\n'
|
||||
)
|
||||
return subprocess.run([shell, "-c", script], capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_chown_helper_repairs_real_directories(stage2_text: str, tmp_path: Path) -> None:
|
||||
target = tmp_path / "home"
|
||||
target.mkdir()
|
||||
log_path = tmp_path / "chown.log"
|
||||
|
||||
proc = _run_helper(stage2_text, target, log_path)
|
||||
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert log_path.read_text().splitlines() == [
|
||||
f"-R hermes:hermes {target}",
|
||||
]
|
||||
|
||||
|
||||
def test_chown_helper_refuses_symlinked_directories(stage2_text: str, tmp_path: Path) -> None:
|
||||
real_home = tmp_path / "real-home"
|
||||
real_home.mkdir()
|
||||
symlinked_home = tmp_path / "hermes-home"
|
||||
try:
|
||||
symlinked_home.symlink_to(real_home, target_is_directory=True)
|
||||
except (NotImplementedError, OSError):
|
||||
pytest.skip("directory symlinks are not available on this platform")
|
||||
log_path = tmp_path / "chown.log"
|
||||
|
||||
proc = _run_helper(stage2_text, symlinked_home, log_path)
|
||||
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert not log_path.exists()
|
||||
assert "refusing recursive chown through symlinked path" in proc.stdout
|
||||
|
||||
|
||||
def test_chown_helper_refuses_target_under_symlinked_home(
|
||||
stage2_text: str,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
real_home = tmp_path / "real-home"
|
||||
(real_home / "cron").mkdir(parents=True)
|
||||
linked_home = tmp_path / "linked-home"
|
||||
try:
|
||||
linked_home.symlink_to(real_home, target_is_directory=True)
|
||||
except (NotImplementedError, OSError):
|
||||
pytest.skip("directory symlinks are not available on this platform")
|
||||
log_path = tmp_path / "chown.log"
|
||||
|
||||
proc = _run_helper(
|
||||
stage2_text,
|
||||
linked_home / "cron",
|
||||
log_path,
|
||||
hermes_home=linked_home,
|
||||
)
|
||||
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert not log_path.exists(), "must not chown through a symlinked HERMES_HOME"
|
||||
assert "refusing recursive chown through symlinked path" in proc.stdout
|
||||
|
||||
|
||||
def test_chown_helper_refuses_target_with_symlinked_ancestor(
|
||||
stage2_text: str,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
external_platforms = tmp_path / "external-platforms"
|
||||
(external_platforms / "pairing").mkdir(parents=True)
|
||||
try:
|
||||
(home / "platforms").symlink_to(
|
||||
external_platforms,
|
||||
target_is_directory=True,
|
||||
)
|
||||
except (NotImplementedError, OSError):
|
||||
pytest.skip("directory symlinks are not available on this platform")
|
||||
log_path = tmp_path / "chown.log"
|
||||
|
||||
proc = _run_helper(
|
||||
stage2_text,
|
||||
home / "platforms" / "pairing",
|
||||
log_path,
|
||||
hermes_home=home,
|
||||
)
|
||||
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert not log_path.exists(), "must not chown through symlinked ancestors"
|
||||
assert "refusing recursive chown through symlinked path" in proc.stdout
|
||||
|
||||
|
||||
def test_stage2_uses_symlink_safe_helper_for_hermes_home_trees(stage2_text: str) -> None:
|
||||
assert 'chown_hermes_tree "$HERMES_HOME/$sub"' in stage2_text
|
||||
assert 'chown_hermes_tree "$HERMES_HOME/profiles"' in stage2_text
|
||||
assert 'chown_hermes_tree "$HERMES_HOME/cron"' in stage2_text
|
||||
assert 'chown -R hermes:hermes "$HERMES_HOME/$sub"' not in stage2_text
|
||||
assert 'chown -R hermes:hermes "$HERMES_HOME/profiles"' not in stage2_text
|
||||
assert 'chown -R hermes:hermes "$HERMES_HOME/cron"' not in stage2_text
|
||||
|
||||
|
||||
def test_stage2_skips_top_level_chown_for_symlinked_hermes_home(
|
||||
stage2_text: str,
|
||||
) -> None:
|
||||
assert 'refuse_symlinked_path "chown" "$HERMES_HOME"' in stage2_text
|
||||
|
|
@ -54,6 +54,12 @@ def _toplevel_chown_loop(text: str) -> str:
|
|||
return block
|
||||
|
||||
|
||||
def _path_guard_functions(text: str) -> str:
|
||||
start = text.index("path_has_symlink_component() {")
|
||||
end = text.index("\n\nchown_hermes_tree() {", start)
|
||||
return text[start:end]
|
||||
|
||||
|
||||
def test_toplevel_chown_loop_present(stage2_text: str) -> None:
|
||||
block = _toplevel_chown_loop(stage2_text)
|
||||
# The reported-broken files must be covered.
|
||||
|
|
@ -100,6 +106,7 @@ def _run_loop(text: str, present_files: list[str]) -> list[str]:
|
|||
script = (
|
||||
"set -e\n"
|
||||
f'HERMES_HOME="{home}"\n'
|
||||
f"{_path_guard_functions(text)}\n"
|
||||
f'chown() {{ for a in "$@"; do :; done; echo "${{a##*/}}" >> "{dpath}/chown.log"; }}\n'
|
||||
+ block
|
||||
)
|
||||
|
|
@ -131,6 +138,72 @@ def test_loop_skips_nonallowlisted_host_file(stage2_text: str) -> None:
|
|||
)
|
||||
|
||||
|
||||
def test_loop_skips_symlinked_allowlisted_file(stage2_text: str, tmp_path: Path) -> None:
|
||||
"""Even allowlisted state files must not be chowned through symlinks."""
|
||||
bash = shutil.which("bash")
|
||||
if bash is None:
|
||||
pytest.skip("bash not available")
|
||||
block = _toplevel_chown_loop(stage2_text)
|
||||
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
outside_auth = tmp_path / "outside-auth.json"
|
||||
outside_auth.touch()
|
||||
(home / "auth.json").symlink_to(outside_auth)
|
||||
|
||||
log = tmp_path / "chown.log"
|
||||
script = (
|
||||
"set -e\n"
|
||||
f'HERMES_HOME="{home}"\n'
|
||||
f"{_path_guard_functions(stage2_text)}\n"
|
||||
f'chown() {{ for a in "$@"; do :; done; echo "${{a##*/}}" >> "{log}"; }}\n'
|
||||
+ block
|
||||
)
|
||||
script_path = tmp_path / "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
|
||||
assert not log.exists(), "symlinked auth.json must not be passed to chown"
|
||||
assert "refusing chown through symlinked path" in proc.stdout
|
||||
|
||||
|
||||
def test_loop_skips_allowlisted_file_under_symlinked_home(
|
||||
stage2_text: str,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A symlinked $HERMES_HOME must not let file chown reach its target."""
|
||||
bash = shutil.which("bash")
|
||||
if bash is None:
|
||||
pytest.skip("bash not available")
|
||||
block = _toplevel_chown_loop(stage2_text)
|
||||
|
||||
real_home = tmp_path / "real-home"
|
||||
real_home.mkdir()
|
||||
(real_home / "auth.json").touch()
|
||||
linked_home = tmp_path / "linked-home"
|
||||
try:
|
||||
linked_home.symlink_to(real_home, target_is_directory=True)
|
||||
except (NotImplementedError, OSError):
|
||||
pytest.skip("directory symlinks are not available on this platform")
|
||||
|
||||
log = tmp_path / "chown.log"
|
||||
script = (
|
||||
"set -e\n"
|
||||
f'HERMES_HOME="{linked_home}"\n'
|
||||
f"{_path_guard_functions(stage2_text)}\n"
|
||||
f'chown() {{ for a in "$@"; do :; done; echo "${{a##*/}}" >> "{log}"; }}\n'
|
||||
+ block
|
||||
)
|
||||
script_path = tmp_path / "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
|
||||
assert not log.exists(), "must not chown files through symlinked HERMES_HOME"
|
||||
assert "refusing chown through symlinked path" in proc.stdout
|
||||
|
||||
|
||||
def test_loop_skips_absent_files(stage2_text: str) -> None:
|
||||
"""Allowlisted files that don't exist are skipped (no spurious chown)."""
|
||||
touched = _run_loop(stage2_text, ["auth.json"])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue