fix(packaging): seal shared PM metadata without private-store modes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user