From 82a702bf67e608422eb577ef8cecfd86f6f58689 Mon Sep 17 00:00:00 2001 From: "Andrex Ibiza, MBA" Date: Wed, 26 Aug 2026 09:52:01 -0700 Subject: [PATCH] feat(update): bake authoritative image provenance into the Docker image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked core of #92545: the image build writes a versioned, non-secret marker (/etc/hermes/image-provenance.json) outside both the bind-mountable checkout and the HERMES_HOME volume, and hermes_cli/image_provenance.py reads it fail-closed — absence means 'not image-managed', any present-but-malformed marker still means image-managed (an integrity defect is never permission to mutate the image in place). (#91277 Phase 3; salvaged from #92545 by @andrexibiza — marker bake + reader only, the scoped carve-out.) --- Dockerfile | 18 +++-- hermes_cli/image_provenance.py | 137 +++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 hermes_cli/image_provenance.py diff --git a/Dockerfile b/Dockerfile index 37070bd991..5dd66c8f45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -311,7 +311,11 @@ RUN mkdir -p /opt/hermes/bin && \ # `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services # run as the default hermes user (UID 10000). -# ---------- Bake build-time git revision ---------- +# ---------- Bake image provenance + build-time git revision ---------- +# The versioned, non-secret provenance marker is the authoritative runtime +# signal that this filesystem came from an immutable image. It deliberately +# lives outside both /opt/hermes (which operators sometimes bind-mount as a +# checkout) and /opt/data (the mutable HERMES_HOME volume). # .dockerignore excludes .git, so `git rev-parse HEAD` from inside the # container always returns nothing — meaning `hermes dump` reports # "(unknown)" and the startup banner drops its `· upstream ` suffix. @@ -324,14 +328,18 @@ RUN mkdir -p /opt/hermes/bin && \ # banner.get_git_banner_state() try the baked SHA first, then fall back # to live `git rev-parse` for source installs (unchanged behaviour). # -# The arg is optional — local `docker build` without --build-arg simply -# omits the file, and the runtime falls back to live-git lookup. CI +# The arg is optional — local `docker build` without --build-arg omits the +# SHA file (and records a null provenance revision), so build-info falls back +# to live-git lookup. CI # (.github/workflows/docker.yml) passes ${{ github.sha }} so # every published image has it. ARG HERMES_GIT_SHA= -RUN if [ -n "${HERMES_GIT_SHA}" ]; then \ +RUN set -eu; \ + if [ -n "${HERMES_GIT_SHA}" ]; then \ printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha; \ - fi + fi; \ + mkdir -p /etc/hermes; \ + HERMES_GIT_SHA="${HERMES_GIT_SHA}" python3 -c 'import json, os, pathlib, tomllib; project = tomllib.loads(pathlib.Path("/opt/hermes/pyproject.toml").read_text(encoding="utf-8"))["project"]; marker = pathlib.Path("/etc/hermes/image-provenance.json"); marker.write_text(json.dumps({"schema": 1, "deployment_kind": "image", "manager": "docker", "image": "nousresearch/hermes-agent", "version": project["version"], "revision": os.environ.get("HERMES_GIT_SHA") or None}, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8"); marker.chmod(0o444)' # ---------- s6-overlay service wiring ---------- # Static services declared at build time: main-hermes + dashboard. diff --git a/hermes_cli/image_provenance.py b/hermes_cli/image_provenance.py new file mode 100644 index 0000000000..188182e9fb --- /dev/null +++ b/hermes_cli/image_provenance.py @@ -0,0 +1,137 @@ +"""Image-authored deployment provenance for immutable Hermes runtimes. + +The published image bakes ``/etc/hermes/image-provenance.json`` outside both +``$HERMES_HOME`` and the mutable checkout. A bind-mounted checkout (including +``.git``) therefore cannot hide the build fact, and environment or config +values cannot forge it. + +Absence preserves every pre-existing source/package install path. Presence +fails closed: an unreadable, non-regular, or malformed marker still means the +runtime is image-managed; it is an integrity defect, never permission to +mutate the image in place. +""" + +from __future__ import annotations + +import json +import stat +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Optional + +IMAGE_PROVENANCE_PATH = Path("/etc/hermes/image-provenance.json") +IMAGE_PROVENANCE_SCHEMA = 1 + + +@dataclass(frozen=True) +class ImageProvenance: + """Validated provenance, or a fail-closed description of an invalid one.""" + + schema: int + deployment_kind: str + manager: str + image: Optional[str] + version: Optional[str] + revision: Optional[str] + marker_path: str + valid: bool = True + error: Optional[str] = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def _invalid(path: Path, reason: str) -> ImageProvenance: + return ImageProvenance( + schema=IMAGE_PROVENANCE_SCHEMA, + deployment_kind="image", + manager="unknown", + image=None, + version=None, + revision=None, + marker_path=str(path), + valid=False, + error=reason, + ) + + +def read_image_provenance( + marker_path: Optional[Path] = None, +) -> Optional[ImageProvenance]: + """Read the baked marker without consulting environment or config. + + ``None`` has one precise meaning: ``lstat`` proved that no marker exists. + Every other filesystem or validation failure returns an invalid + :class:`ImageProvenance`, so callers refuse image mutation closed. In + particular, ``lstat`` makes a dangling symlink visibly *present* and the + regular-file check rejects symlinks, directories, and device nodes. + + ``marker_path`` is a dependency-injection seam for tests and alternate + image builders. Normal callers always use the image-owned absolute path. + This function never raises. + """ + + path = IMAGE_PROVENANCE_PATH + try: + path = Path(marker_path) if marker_path is not None else path + except BaseException as exc: + return _invalid(path, f"marker_presence_unreadable:{type(exc).__name__}") + + try: + marker_stat = path.lstat() + except FileNotFoundError: + return None + except BaseException as exc: + # Permission errors and other lookup failures do not prove absence. + return _invalid(path, f"marker_presence_unreadable:{type(exc).__name__}") + + if not stat.S_ISREG(marker_stat.st_mode): + return _invalid(path, "marker_not_regular_file") + + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + # The file may disappear between lstat/read; it was nevertheless + # observed present, so the decision remains fail-closed. + return _invalid(path, f"marker_unreadable:{type(exc).__name__}") + + if not isinstance(payload, dict): + return _invalid(path, "marker_not_object") + + schema = payload.get("schema") + # ``bool`` is an ``int`` subclass in Python. Schema ``true`` must not be + # accepted as schema 1, hence the exact type check. + if type(schema) is not int or schema != IMAGE_PROVENANCE_SCHEMA: + return _invalid(path, "unsupported_marker_schema") + if payload.get("deployment_kind") != "image": + return _invalid(path, "invalid_deployment_kind") + + manager = payload.get("manager") + if not isinstance(manager, str) or not manager.strip(): + return _invalid(path, "missing_manager") + + def _optional_string(name: str) -> Optional[str]: + value = payload.get(name) + if value is None: + return None + if not isinstance(value, str): + raise TypeError(name) + value = value.strip() + return value or None + + try: + image = _optional_string("image") + version = _optional_string("version") + revision = _optional_string("revision") + except TypeError as exc: + return _invalid(path, f"invalid_{exc.args[0]}") + + return ImageProvenance( + schema=IMAGE_PROVENANCE_SCHEMA, + deployment_kind="image", + manager=manager.strip(), + image=image, + version=version, + revision=revision, + marker_path=str(path), + )