fix(packaging): seal shared PM metadata without private-store modes

This commit is contained in:
ethernet
2026-09-13 18:18:23 -04:00
parent 5a89533e1c
commit 8115e08e39
5 changed files with 150 additions and 19 deletions

View File

@@ -444,7 +444,10 @@ COPY --chmod=0755 docker/entrypoint-dispatch.sh /opt/hermes/docker/entrypoint-di
ENV PATH="/opt/hermes/bin:/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}"
# PM's atomic writer creates private facts for source installs. In the
# image these are shared, non-secret package metadata, read by UID 10000.
RUN mkdir -p /opt/data && chmod 0644 /opt/hermes/tools/facts.json
# uv's environment locks are build-only and may be world-writable. Remove
# them after all builds; never relax permissions on mutable PM/home state.
RUN mkdir -p /opt/data && chmod 0644 /opt/hermes/tools/facts.json && \
rm -f /opt/hermes/.venv/.lock /opt/hermes/pm-runtime/.lock
VOLUME [ "/opt/data" ]
# The image ENTRYPOINT is a tiny dispatcher rather than `/init` directly.

View File

@@ -31,13 +31,32 @@ def snapshot(repo: Path, ref: str, destination: Path) -> None:
source.extractall(destination, filter="data")
def _payload_file(root: Path, relative: str) -> Path:
path = root / relative
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
raise ValueError(f"metadata path escapes payload or is symlinked: {path}")
return path
def _share_metadata(path: Path) -> None:
# Atomic PM publication stays private; only packaged, non-secret records
# cross this boundary. Windows installers own their ACL policy.
if os.name != "nt":
path.chmod(0o644)
def _discard_build_locks(root: Path) -> None:
for name in ("venv", "pm-runtime"):
_payload_file(root, f"{name}/.lock").unlink(missing_ok=True)
def record_tools(root: Path, lock_path: Path, target: str, entries: dict[str, str]) -> None:
from pm.lock import Facts, Lockfile
from pm.registry import get_package
from pm.store import tree_digest
store = root / "tools"
facts, lock = Facts(store / "facts.json"), Lockfile(lock_path)
facts, lock = Facts(_payload_file(root, "tools/facts.json")), Lockfile(lock_path)
for name, entry_name in entries.items():
entry = store / entry_name
version, artifacts = lock.version(name), lock.artifacts(name, target)
@@ -45,6 +64,8 @@ def record_tools(root: Path, lock_path: Path, target: str, entries: dict[str, st
raise ValueError(f"incomplete payload tool: {name}")
facts.record(name, version, entry_name, get_package(name).env(entry, target), store,
target=target, artifacts=[a["sha256"] for a in artifacts], digest=tree_digest(entry))
if entries:
_share_metadata(facts.path)
def rehash_tools(root: Path) -> int:
@@ -52,7 +73,10 @@ def rehash_tools(root: Path) -> int:
from pm.lock import Facts
store = root / "tools"
return Facts(store / "facts.json", strict=True).refresh_digests(store)
facts = Facts(_payload_file(root, "tools/facts.json"), strict=True)
count = facts.refresh_digests(store)
_share_metadata(facts.path)
return count
def seal_pm_runtime(root: Path, python: Path) -> dict:
@@ -72,8 +96,9 @@ def seal_pm_runtime(root: Path, python: Path) -> dict:
"python": Path(os.path.relpath(python, runtime)).as_posix(),
"sitePackages": sites[0].relative_to(runtime).as_posix(),
}
cfg = runtime / "pyvenv.cfg"
lines = cfg.read_text(encoding="utf-8").splitlines()
cfg = _payload_file(root, "pm-runtime/pyvenv.cfg")
marker_path = _payload_file(root, "pm-runtime/pm-runtime.json")
lines = cfg.read_text(encoding="utf-8-sig").splitlines()
lines = [line for line in lines if line.partition("=")[0].strip() not in
{"home", "executable", "base-executable", "base-prefix", "base-exec-prefix", "command"}]
lines.insert(0, f"home = {os.path.relpath(python.parent, runtime)}")
@@ -86,13 +111,16 @@ def seal_pm_runtime(root: Path, python: Path) -> dict:
if entry.is_file():
entry.unlink()
_relativize_bin_links(root, runtime / "bin")
(runtime / "pm-runtime.json").write_text(json.dumps(marker, indent=2) + "\n", encoding="utf-8")
marker_path.write_text(json.dumps(marker, indent=2) + "\n", encoding="utf-8")
_share_metadata(marker_path)
_discard_build_locks(root)
return marker
def relativize_links(root: Path) -> int:
"""Only dependency-venv links move; framework links belong to codesign."""
root = root.resolve()
_discard_build_locks(root)
return sum(_relativize_bin_links(root, root / name / "bin") for name in ("venv", "pm-runtime"))

View File

@@ -10,6 +10,8 @@ Build the real image and verify at runtime:
"""
from __future__ import annotations
import pytest
from tests.docker.conftest import (
docker_exec,
docker_exec_sh,
@@ -18,8 +20,9 @@ from tests.docker.conftest import (
)
@pytest.mark.parametrize("uid", [10000, 23456])
def test_install_tree_not_writable_by_hermes(
built_image: str, container_name: str,
built_image: str, container_name: str, uid: int,
) -> None:
"""The hermes user must not be able to modify /opt/hermes.
@@ -27,7 +30,9 @@ def test_install_tree_not_writable_by_hermes(
root-owned and non-writable so an agent session cannot self-modify
the installation and brick the gateway.
"""
start_container(built_image, container_name)
start_container(built_image, container_name, f"HERMES_UID={uid}", f"HERMES_GID={uid}")
identity = docker_exec(container_name, "id", "-u")
assert identity.returncode == 0 and identity.stdout.strip() == str(uid)
probe = docker_exec(container_name, "/opt/hermes/.venv/bin/python", "-c", """
import os
@@ -47,6 +52,15 @@ except PermissionError:
else:
raise AssertionError('runtime user can alter installation method')
assert code.read_text().strip() == 'docker'
for relative in ('pm-runtime/pm-runtime.json', 'tools/facts.json', 'manifest.json'):
path = Path('/opt/hermes') / relative
assert path.stat().st_uid == 0, path
assert path.read_bytes(), path
assert not os.access(path, os.W_OK), path
assert path.stat().st_mode & 0o022 == 0, path
for relative in ('.venv/.lock', 'pm-runtime/.lock'):
path = Path('/opt/hermes') / relative
assert not path.exists() or not os.access(path, os.W_OK), path
""")
assert probe.returncode == 0, probe.stdout + probe.stderr

View File

@@ -13,7 +13,6 @@ from packaging.utils import parse_wheel_filename
import pytest
from pm.runtime import runtime_environment
from pm.runtime_stage import stage_runtime
from scripts.bundles.payload import seal_pm_runtime
@@ -22,7 +21,7 @@ def locked_wheelhouse(tmp_path_factory):
"""Download host wheels first; only the subsequent stage runs offline."""
wheelhouse = tmp_path_factory.mktemp("pm-wheelhouse")
project = Path(__file__).resolve().parents[2] / "pm"
lock = tomllib.loads((project / "uv.lock").read_text(encoding="utf-8"))
lock = tomllib.loads((project / "uv.lock").read_text(encoding="utf-8-sig"))
tags = set(sys_tags())
versions = {}
for package in lock["package"]:
@@ -57,21 +56,33 @@ def isolated_builder(tmp_path, monkeypatch):
@pytest.mark.platforms("linux")
def test_offline_wheelhouse_runtime_survives_sealing_and_move(
tmp_path, isolated_builder, locked_wheelhouse,
tmp_path, isolated_builder, locked_wheelhouse, monkeypatch,
):
wheelhouse, versions = locked_wheelhouse
root = tmp_path / "payload"
python = root / "tools/python/bin/python"
python.parent.mkdir(parents=True)
shutil.copy2(Path(sys._base_executable).resolve(), python)
executable = stage_runtime(isolated_builder, python, root / "pm-runtime",
wheelhouse=wheelhouse, offline=True)
from pm import stage_manager_runtime
from pm.lock import _write
monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (isolated_builder, python))
executable = stage_manager_runtime(python=python, destination=root / "pm-runtime",
wheelhouse=wheelhouse, offline=True)
assert executable.is_file()
marker_path = root / "pm-runtime/pm-runtime.json"
assert marker_path.stat().st_mode & 0o777 == 0o600
assert (root / "pm-runtime/.lock").is_file()
seal_pm_runtime(root, python)
assert marker_path.stat().st_mode & 0o777 == 0o644
assert not (root / "pm-runtime/.lock").exists()
private = tmp_path / "mutable/selected.json"
_write(private, {"runtime": "private"})
assert private.stat().st_mode & 0o777 == 0o600
moved = tmp_path / "installed elsewhere"
root.rename(moved)
runtime = moved / "pm-runtime"
marker = json.loads((runtime / "pm-runtime.json").read_text(encoding="utf-8"))
marker = json.loads((runtime / "pm-runtime.json").read_text(encoding="utf-8-sig"))
probe = """
import importlib.metadata, importlib.util, json, sys
sys.path.insert(0, sys.argv[1])
@@ -95,9 +106,9 @@ print(json.dumps({canonicalize_name(d.metadata['Name']): d.version
from pm.runtime import runtime_command
repo = moved / "hermes-agent"
repo.mkdir()
(moved / "manifest.json").write_text('{"repo":"hermes-agent"}')
(moved / "manifest.json").write_text('{"repo":"hermes-agent"}', encoding="utf-8")
script = repo / "probe.py"
script.write_text("import sys,json; print(json.dumps(sys.path))")
script.write_text("import sys,json; print(json.dumps(sys.path))", encoding="utf-8")
with pytest.MonkeyPatch.context() as patcher:
patcher.setattr(paths, "repo_root", lambda: repo)
child = subprocess.run(runtime_command(script), cwd=tmp_path, env=runtime_environment(),

View File

@@ -5,6 +5,8 @@ from pathlib import Path
import subprocess
import sys
import pytest
from pm.lock import Facts, Lockfile
from pm.store import current_target, tree_digest
from scripts.bundles import payload
@@ -38,12 +40,12 @@ def _rehash(root, cwd):
def test_rehash_records_all_changed_tools_and_preserves_identity(tmp_path):
root = _payload(tmp_path)
path = root / "tools" / "facts.json"
before = json.loads(path.read_text(encoding="utf-8"))
before = json.loads(path.read_text(encoding="utf-8-sig"))
(root / "tools/python/tool").write_bytes(b"final python bytes")
(root / "tools/uv/tool").write_bytes(b"final uv bytes")
result = _rehash(root, tmp_path)
assert result.returncode == 0, result.stderr
after = json.loads(path.read_text(encoding="utf-8"))
after = json.loads(path.read_text(encoding="utf-8-sig"))
for name in ("python", "uv"):
expected = dict(before["packages"][name])
expected["digest"] = tree_digest(root / "tools" / expected["entry"])
@@ -55,10 +57,83 @@ def test_rehash_records_all_changed_tools_and_preserves_identity(tmp_path):
assert path.read_bytes() == saved
@pytest.mark.platforms("posix")
def test_packaged_metadata_is_shared_but_mutable_writes_stay_private(tmp_path):
root = _payload(tmp_path)
path = root / "tools/facts.json"
# The fixture's final mutable state write deliberately restores 0600.
assert path.stat().st_mode & 0o777 == 0o600
entries = {"python": "python", "uv": "uv"}
payload.record_tools(root, tmp_path / "lock.json", current_target(), entries)
assert path.stat().st_mode & 0o777 == 0o644
Facts(path).record_state("venv", "selection", ["web"])
assert path.stat().st_mode & 0o777 == 0o600
assert payload.rehash_tools(root) == 2
assert path.stat().st_mode & 0o777 == 0o644
# Final native relocation follows application environment creation.
venv = root / "venv"
venv.mkdir()
lock = venv / ".lock"
lock.write_bytes(b"")
lock.chmod(0o666)
payload.relativize_links(root)
assert not lock.exists()
private = tmp_path / "private/facts.json"
Facts(private).record_state("venv", "private", [])
assert private.stat().st_mode & 0o777 == 0o600
@pytest.mark.platforms("posix")
def test_packaging_never_changes_external_metadata_or_build_locks(tmp_path):
root = _payload(tmp_path)
outside = tmp_path / "external"
outside.mkdir()
private = outside / "facts.json"
Facts(private).record_state("venv", "private", [])
before = private.read_bytes()
facts = root / "tools/facts.json"
facts.unlink()
facts.symlink_to(private)
for operation in (
lambda: payload.record_tools(root, tmp_path / "lock.json", current_target(), {"uv": "uv"}),
lambda: payload.rehash_tools(root),
):
with pytest.raises(ValueError, match="symlinked"):
operation()
assert private.read_bytes() == before
assert private.stat().st_mode & 0o777 == 0o600
lock = outside / ".lock"
lock.write_bytes(b"external lock")
lock.chmod(0o600)
(root / "venv").symlink_to(outside, target_is_directory=True)
with pytest.raises(ValueError, match="escapes"):
payload.relativize_links(root)
assert lock.read_bytes() == b"external lock"
assert lock.stat().st_mode & 0o777 == 0o600
def test_sealing_strips_bom_and_absolute_build_paths(tmp_path):
root = tmp_path / "payload"
python = root / "tools/pythön/python"
python.parent.mkdir(parents=True)
python.write_bytes(b"interpreter")
runtime = root / "pm-runtime"
(runtime / "Lib/site-packages").mkdir(parents=True)
(runtime / "bin").mkdir()
(runtime / "Scripts").mkdir()
cfg = runtime / "pyvenv.cfg"
cfg.write_bytes("\ufeffhome = /private/builder\nexecutable = /private/python\nversion = 3.14\n".encode("utf-8"))
payload.seal_pm_runtime(root, python)
assert not cfg.read_bytes().startswith(b"\xef\xbb\xbf")
text = cfg.read_text(encoding="utf-8-sig")
assert text.splitlines() == [f"home = {Path('..') / 'tools' / 'pythön'}", "version = 3.14"]
assert json.loads((runtime / "pm-runtime.json").read_text(encoding="utf-8-sig"))["python"] == "../tools/pythön/python"
def test_invalid_tool_evidence_never_partially_rewrites_facts(tmp_path):
root = _payload(tmp_path)
path = root / "tools" / "facts.json"
before = json.loads(path.read_text(encoding="utf-8"))
before = json.loads(path.read_text(encoding="utf-8-sig"))
(root / "tools/python/tool").write_bytes(b"final python bytes")
outside = tmp_path / "outside"
outside.mkdir()