feat(update): bake authoritative image provenance into the Docker image
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.)
This commit is contained in:
committed by
Teknium
parent
2552579912
commit
82a702bf67
18
Dockerfile
18
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 <sha>` 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.
|
||||
|
||||
137
hermes_cli/image_provenance.py
Normal file
137
hermes_cli/image_provenance.py
Normal file
@@ -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),
|
||||
)
|
||||
Reference in New Issue
Block a user