Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 84 additions & 18 deletions docker/stage2-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -234,15 +276,15 @@ 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
# docker-exec/root-write reason as profiles/. The cron scheduler state
# (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.
Expand All @@ -268,16 +310,24 @@ 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

# --- config.yaml permissions ---
# 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 ---
Expand Down Expand Up @@ -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"
Expand All @@ -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 ---
Expand All @@ -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
Expand Down Expand Up @@ -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 ---
Expand Down
6 changes: 4 additions & 2 deletions tests/test_docker_home_override_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
106 changes: 95 additions & 11 deletions tests/tools/test_stage2_hook_gateway_bootstrap_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
assert m, (
"stage2-hook.sh must contain the gateway_state.json bootstrap-seed block "
"guarded on HERMES_GATEWAY_BOOTSTRAP_STATE"
"""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'
)
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:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)."""
Expand Down
Loading
Loading