diff --git a/Dockerfile b/Dockerfile index 844922f07a..657827377e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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. diff --git a/scripts/bundles/payload.py b/scripts/bundles/payload.py index cfc3ccdb5b..fcfee8c208 100644 --- a/scripts/bundles/payload.py +++ b/scripts/bundles/payload.py @@ -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")) diff --git a/tests/docker/test_immutable_install.py b/tests/docker/test_immutable_install.py index 824eab3ce9..c92f9d4860 100644 --- a/tests/docker/test_immutable_install.py +++ b/tests/docker/test_immutable_install.py @@ -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 diff --git a/tests/pm/test_runtime_wheelhouse.py b/tests/pm/test_runtime_wheelhouse.py index 81b6769848..97106e9aa4 100644 --- a/tests/pm/test_runtime_wheelhouse.py +++ b/tests/pm/test_runtime_wheelhouse.py @@ -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(), diff --git a/tests/scripts/test_payload_digests.py b/tests/scripts/test_payload_digests.py index 6e4b1fe103..dc2039dcc2 100644 --- a/tests/scripts/test_payload_digests.py +++ b/tests/scripts/test_payload_digests.py @@ -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()