From 233ef98afe2f156dd916c8f4e7f98d16676de4ee Mon Sep 17 00:00:00 2001 From: Harjoth Khara Date: Thu, 25 Jun 2026 19:09:52 -0700 Subject: [PATCH] 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 --- docker/stage2-hook.sh | 102 +++++++++--- tests/test_docker_home_override_scripts.py | 6 +- ...est_stage2_hook_gateway_bootstrap_state.py | 106 +++++++++++-- .../test_stage2_hook_seed_one_symlinks.py | 110 +++++++++++++ tests/tools/test_stage2_hook_symlink_chown.py | 145 ++++++++++++++++++ .../tools/test_stage2_hook_toplevel_chown.py | 73 +++++++++ 6 files changed, 511 insertions(+), 31 deletions(-) create mode 100644 tests/tools/test_stage2_hook_seed_one_symlinks.py create mode 100644 tests/tools/test_stage2_hook_symlink_chown.py diff --git a/docker/stage2-hook.sh b/docker/stage2-hook.sh index 8d7258991e1..ee71fee5326 100755 --- a/docker/stage2-hook.sh +++ b/docker/stage2-hook.sh @@ -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 --- diff --git a/tests/test_docker_home_override_scripts.py b/tests/test_docker_home_override_scripts.py index b5759785392..bea80e532d2 100644 --- a/tests/test_docker_home_override_scripts.py +++ b/tests/test_docker_home_override_scripts.py @@ -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 diff --git a/tests/tools/test_stage2_hook_gateway_bootstrap_state.py b/tests/tools/test_stage2_hook_gateway_bootstrap_state.py index 813d18d8990..1a539267891 100644 --- a/tests/tools/test_stage2_hook_gateway_bootstrap_state.py +++ b/tests/tools/test_stage2_hook_gateway_bootstrap_state.py @@ -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).""" diff --git a/tests/tools/test_stage2_hook_seed_one_symlinks.py b/tests/tools/test_stage2_hook_seed_one_symlinks.py new file mode 100644 index 00000000000..17774f9866b --- /dev/null +++ b/tests/tools/test_stage2_hook_seed_one_symlinks.py @@ -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 == "" diff --git a/tests/tools/test_stage2_hook_symlink_chown.py b/tests/tools/test_stage2_hook_symlink_chown.py new file mode 100644 index 00000000000..accb76bd07f --- /dev/null +++ b/tests/tools/test_stage2_hook_symlink_chown.py @@ -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 diff --git a/tests/tools/test_stage2_hook_toplevel_chown.py b/tests/tools/test_stage2_hook_toplevel_chown.py index 1ad2eea12e0..b1968e33fea 100644 --- a/tests/tools/test_stage2_hook_toplevel_chown.py +++ b/tests/tools/test_stage2_hook_toplevel_chown.py @@ -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"])