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:
Harjoth Khara 2026-06-25 19:09:52 -07:00 committed by GitHub
parent 1abfa66ba6
commit 233ef98afe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 511 additions and 31 deletions

View file

@ -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 ---

View file

@ -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

View file

@ -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)."""

View 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 == ""

View 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

View file

@ -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"])