feat(update): image/package-managed installs refuse in-place updates through one shared gate (#91277 Phase 3)

Every surface that can start an in-place mutation — hermes update
(apply), update --check, and the dashboard's update endpoint — now
routes through evaluate_update_admission(): the baked image-provenance
marker first (authoritative; a bind-mounted checkout inside a container
looks like git to the heuristics while the filesystem is an immutable
image), then the pre-existing docker/nix/apt heuristics verbatim.

A refusal prints the real update command for the deployment kind,
records a 'refused' receipt (fleet tooling sees 'not updatable in
place, use <cmd>' instead of a silent non-update), and exits 2 on CLI
surfaces — distinct from exit-1 errors. The dashboard response keeps
the per-kind error codes its UI already keys on. collect_runtime
inventory()'s updatable_in_place also honors the marker, so --plan and
receipts report image-managed truthfully even with a bind-mounted
checkout.

Live E2E (real hermes update subprocesses, real marker file): apply and
--check both refuse exit-2 with docker-pull guidance, receipts land as
refused/image-marker, an in-place corrupted marker still refuses
(fail-closed), removing the marker admits the git checkout.
This commit is contained in:
Teknium
2026-08-26 09:52:01 -07:00
parent 82a702bf67
commit 4860978115
6 changed files with 379 additions and 53 deletions
+16 -17
View File
@@ -10725,12 +10725,8 @@ def cmd_update(args):
``sys.exit`` or unhandled exceptions).
"""
from hermes_cli.config import (
detect_install_method,
format_docker_update_message,
is_managed,
is_nix_install_method,
managed_error,
recommended_update_command_for_method,
)
if is_managed():
@@ -10753,20 +10749,23 @@ def cmd_update(args):
print_update_plan(collect_runtime_inventory())
return
# Docker users can't ``git pull`` — the image excludes ``.git`` from
# the build context. Bail with a friendly explanation pointing at
# ``docker pull`` BEFORE any of the apply-path / check-path branches
# below get a chance to error out with misleading "Not a git
# repository" text. See format_docker_update_message() for the full
# rationale and tag-pinning / config-persistence notes.
install_method = detect_install_method(PROJECT_ROOT)
if install_method == "docker":
print(format_docker_update_message())
sys.exit(1)
# Image-managed / package-managed admission gate (#91277 Phase 3): one
# shared decision for every mutation surface. Consults the baked image
# provenance marker first (authoritative, fail-closed on malformed),
# then the pre-existing docker/nix/apt heuristics. Prints the real
# update command, records a `refused` receipt so fleet tooling sees the
# blocked attempt, and exits 2 (refused-by-contract, distinct from
# exit 1 errors).
from hermes_cli.update_contract import (
evaluate_update_admission,
record_refusal_receipt,
)
if is_nix_install_method(install_method) or install_method == "apt":
print(recommended_update_command_for_method(install_method))
sys.exit(1)
refusal = evaluate_update_admission(PROJECT_ROOT)
if refusal is not None:
print(refusal.message)
record_refusal_receipt(refusal)
sys.exit(2)
if getattr(args, "check", False):
# --check honors --branch so the "any new commits?" answer matches
+11 -16
View File
@@ -3371,24 +3371,19 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
Installs that can't honor non-default branches (e.g. Docker) surface a
one-line notice instead of silently dropping the flag.
"""
from hermes_cli.config import (
detect_install_method,
is_nix_install_method,
recommended_update_command_for_method,
# Shared admission gate (#91277 Phase 3): same marker-first decision as
# the apply path, so --check can never report git state for an install
# whose real update mechanism is an image pull.
from hermes_cli.update_contract import (
evaluate_update_admission,
record_refusal_receipt,
)
method = detect_install_method(_m().PROJECT_ROOT)
if method == "docker":
# Docker can't ``git fetch`` from within the container. Surface the
# same long-form ``docker pull`` guidance ``hermes update`` (apply
# path) uses — telling the user to "reinstall via curl" or that
# ".git is missing" would point them at the wrong remediation.
from hermes_cli.config import format_docker_update_message
print(format_docker_update_message())
sys.exit(1)
if is_nix_install_method(method) or method == "apt":
print(recommended_update_command_for_method(method))
sys.exit(1)
refusal = evaluate_update_admission(_m().PROJECT_ROOT)
if refusal is not None:
print(refusal.message)
record_refusal_receipt(refusal)
sys.exit(2)
git_dir = _m().PROJECT_ROOT / ".git"
if not git_dir.exists():
+138
View File
@@ -0,0 +1,138 @@
"""Image-managed install refusal contract (#91277 Phase 3).
One shared admission gate for every surface that can start an in-place
``hermes update`` mutation (CLI apply, CLI --check, dashboard update
endpoint). The decision layers:
1. **Baked provenance marker** (``/etc/hermes/image-provenance.json``,
written by the image build — see :mod:`hermes_cli.image_provenance`):
authoritative ground truth that this filesystem came from an immutable
image. Fail-closed: a present-but-malformed marker still refuses.
2. **Filesystem heuristics** (``detect_install_method()``): the pre-existing
docker/nix/apt detection, kept as the fallback for images built before
the marker existed and for package-managed installs that have no image
marker at all.
A refusal prints the real update command for the deployment kind, records a
``refused`` receipt (so fleet tooling sees "this install cannot self-update,
use <command>" instead of a silent non-update), and exits 2 on CLI surfaces.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class UpdateRefusal:
"""Why an in-place update is refused, and what to run instead."""
code: str # image-marker | image-marker-invalid | docker | nix | apt
message: str # full user-facing text (multi-line ok)
update_command: str # the one-line remediation command
def evaluate_update_admission(project_root: Path) -> Optional[UpdateRefusal]:
"""Return an :class:`UpdateRefusal` when in-place update must not run.
``None`` means the install is eligible for in-place update (git checkout
or unknown-but-mutable). Never raises; on any internal error it falls
back to the heuristic layer only.
"""
# Layer 1: baked provenance marker — authoritative when present.
try:
from hermes_cli.image_provenance import read_image_provenance
provenance = read_image_provenance()
if provenance is not None:
from hermes_cli.config import (
format_docker_update_message,
recommended_update_command_for_method,
)
if not provenance.valid:
# Present but malformed: still image-managed — an integrity
# defect is never permission to mutate the image in place.
command = recommended_update_command_for_method("docker")
return UpdateRefusal(
code="image-marker-invalid",
message=(
"✗ This install is image-managed, but its provenance "
f"marker is invalid ({provenance.error}).\n"
" In-place update is disabled. Update by pulling a "
f"new image:\n {command}"
),
update_command=command,
)
manager = provenance.manager
if manager == "docker":
return UpdateRefusal(
code="image-marker",
message=format_docker_update_message(),
update_command=recommended_update_command_for_method("docker"),
)
command = recommended_update_command_for_method(manager)
return UpdateRefusal(
code="image-marker",
message=command,
update_command=command,
)
except Exception as exc:
logger.debug("Image provenance check failed (using heuristics): %s", exc)
# Layer 2: pre-existing filesystem heuristics, verbatim semantics.
try:
from hermes_cli.config import (
detect_install_method,
format_docker_update_message,
is_nix_install_method,
recommended_update_command_for_method,
)
method = detect_install_method(project_root)
if method == "docker":
return UpdateRefusal(
code="docker",
message=format_docker_update_message(),
update_command=recommended_update_command_for_method("docker"),
)
if is_nix_install_method(method) or method == "apt":
command = recommended_update_command_for_method(method)
return UpdateRefusal(
code=method if method == "apt" else "nix",
message=command,
update_command=command,
)
except Exception as exc:
logger.debug("Install-method admission check failed: %s", exc)
return None
def record_refusal_receipt(refusal: UpdateRefusal) -> None:
"""Write a minimal ``refused`` receipt for a blocked update attempt.
Gives fleet tooling a durable record that an update was ATTEMPTED and
refused ("not updatable in place, use <command>") instead of a silent
nothing. Best-effort; never raises.
"""
try:
from hermes_cli.update_receipt import (
begin_update_receipt,
finalize_update_receipt,
record_step,
)
begin_update_receipt()
record_step(
"admission",
False,
f"not updatable in place ({refusal.code}); use: {refusal.update_command}",
)
finalize_update_receipt("refused", stop_reason=refusal.code)
except Exception as exc:
logger.debug("Could not record refusal receipt: %s", exc)
+15
View File
@@ -152,6 +152,21 @@ def collect_runtime_inventory() -> UpdatePlan:
if managed:
plan.install_method = managed
plan.updatable_in_place = method in ("git", "unknown") and not managed
# Baked image provenance (#91277 Phase 3): when the image marker is
# present it is authoritative — a bind-mounted checkout inside a
# container can look like `git` to the heuristics while the running
# filesystem is actually an immutable image. Fail-closed: an invalid
# marker still flips the plan to not-updatable.
try:
from hermes_cli.image_provenance import read_image_provenance
provenance = read_image_provenance()
if provenance is not None:
plan.updatable_in_place = False
if provenance.valid and provenance.manager:
plan.install_method = provenance.manager
except Exception as exc:
logger.debug("Image provenance probe failed: %s", exc)
plan.update_mechanism = recommended_update_command_for_method(method)
except Exception as exc:
logger.debug("Install-method probe failed: %s", exc)
+22 -20
View File
@@ -5064,31 +5064,33 @@ async def update_hermes():
"update_command": "managed outside dashboard",
}
install_method = detect_install_method(PROJECT_ROOT)
if install_method == "docker":
message = format_docker_update_message()
_record_completed_action("hermes-update", message, exit_code=1)
return {
"ok": False,
"pid": None,
"name": "hermes-update",
"error": "docker_update_unsupported",
"message": message,
"update_command": recommended_update_command_for_method(install_method),
}
# Shared admission gate (#91277 Phase 3): marker-first, then the
# docker/nix/apt heuristics — one decision with the CLI paths. The
# response keeps the pre-existing per-kind error codes the dashboard UI
# already keys on.
from hermes_cli.update_contract import (
evaluate_update_admission,
record_refusal_receipt,
)
if is_nix_install_method(install_method) or install_method == "apt":
message = recommended_update_command_for_method(install_method)
_record_completed_action("hermes-update", message, exit_code=1)
refusal = evaluate_update_admission(PROJECT_ROOT)
if refusal is not None:
_record_completed_action("hermes-update", refusal.message, exit_code=1)
record_refusal_receipt(refusal)
error_code = {
"docker": "docker_update_unsupported",
"image-marker": "docker_update_unsupported",
"image-marker-invalid": "docker_update_unsupported",
"apt": "apt_update_required",
"nix": "nix_update_unsupported",
}.get(refusal.code, "update_not_in_place")
return {
"ok": False,
"pid": None,
"name": "hermes-update",
"error": (
"apt_update_required" if install_method == "apt" else "nix_update_unsupported"
),
"message": message,
"update_command": message,
"error": error_code,
"message": refusal.message,
"update_command": refusal.update_command,
}
existing = _ACTION_PROCS.get("hermes-update")
+177
View File
@@ -0,0 +1,177 @@
"""Image-managed refusal contract tests (#91277 Phase 3).
Marker semantics (image_provenance.py, salvaged from #92545 @andrexibiza):
absent → None; present-and-valid → provenance; present-but-broken →
fail-closed invalid. Admission gate (update_contract.py): marker first,
docker/nix/apt heuristics second; refusals record a `refused` receipt.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from hermes_cli.image_provenance import read_image_provenance
from hermes_cli.update_contract import (
UpdateRefusal,
evaluate_update_admission,
record_refusal_receipt,
)
def _valid_marker(tmp_path: Path) -> Path:
marker = tmp_path / "image-provenance.json"
marker.write_text(json.dumps({
"schema": 1,
"deployment_kind": "image",
"manager": "docker",
"image": "nousresearch/hermes-agent",
"version": "1.0.0",
"revision": "a" * 40,
}))
return marker
# ---------------------------------------------------------------------------
# Marker reader
# ---------------------------------------------------------------------------
def test_reader_absent_marker_means_none(tmp_path):
assert read_image_provenance(tmp_path / "nope.json") is None
def test_reader_valid_marker(tmp_path):
provenance = read_image_provenance(_valid_marker(tmp_path))
assert provenance is not None and provenance.valid
assert provenance.manager == "docker"
assert provenance.version == "1.0.0"
@pytest.mark.parametrize(
"payload,reason_prefix",
[
("not json {", "marker_unreadable"),
(json.dumps([1, 2]), "marker_not_object"),
(json.dumps({"schema": True, "deployment_kind": "image", "manager": "docker"}), "unsupported_marker_schema"),
(json.dumps({"schema": 2, "deployment_kind": "image", "manager": "docker"}), "unsupported_marker_schema"),
(json.dumps({"schema": 1, "deployment_kind": "source", "manager": "docker"}), "invalid_deployment_kind"),
(json.dumps({"schema": 1, "deployment_kind": "image", "manager": " "}), "missing_manager"),
],
)
def test_reader_fails_closed_on_malformed(tmp_path, payload, reason_prefix):
marker = tmp_path / "image-provenance.json"
marker.write_text(payload)
provenance = read_image_provenance(marker)
assert provenance is not None and not provenance.valid
assert provenance.error.startswith(reason_prefix)
def test_reader_rejects_symlink_marker(tmp_path):
real = _valid_marker(tmp_path)
link = tmp_path / "link.json"
try:
link.symlink_to(real)
except (OSError, NotImplementedError):
pytest.skip("symlinks unavailable")
provenance = read_image_provenance(link)
assert provenance is not None and not provenance.valid
assert provenance.error == "marker_not_regular_file"
# ---------------------------------------------------------------------------
# Admission gate
# ---------------------------------------------------------------------------
def test_admission_marker_refuses_even_on_git_checkout(tmp_path, monkeypatch):
"""The bind-mounted-checkout case: heuristics say git, marker says image
— the marker wins."""
import hermes_cli.image_provenance as ip
monkeypatch.setattr(ip, "IMAGE_PROVENANCE_PATH", _valid_marker(tmp_path))
monkeypatch.setattr(
"hermes_cli.config.detect_install_method", lambda *a, **k: "git"
)
refusal = evaluate_update_admission(tmp_path)
assert refusal is not None
assert refusal.code == "image-marker"
assert "docker pull" in refusal.update_command
def test_admission_invalid_marker_fails_closed(tmp_path, monkeypatch):
import hermes_cli.image_provenance as ip
bad = tmp_path / "image-provenance.json"
bad.write_text("corrupted {{{")
monkeypatch.setattr(ip, "IMAGE_PROVENANCE_PATH", bad)
refusal = evaluate_update_admission(tmp_path)
assert refusal is not None
assert refusal.code == "image-marker-invalid"
assert "docker pull" in refusal.update_command
def test_admission_no_marker_falls_back_to_heuristics(tmp_path, monkeypatch):
import hermes_cli.image_provenance as ip
monkeypatch.setattr(ip, "IMAGE_PROVENANCE_PATH", tmp_path / "absent.json")
monkeypatch.setattr(
"hermes_cli.config.detect_install_method", lambda *a, **k: "docker"
)
refusal = evaluate_update_admission(tmp_path)
assert refusal is not None and refusal.code == "docker"
def test_admission_git_checkout_no_marker_is_admitted(tmp_path, monkeypatch):
import hermes_cli.image_provenance as ip
monkeypatch.setattr(ip, "IMAGE_PROVENANCE_PATH", tmp_path / "absent.json")
monkeypatch.setattr(
"hermes_cli.config.detect_install_method", lambda *a, **k: "git"
)
assert evaluate_update_admission(tmp_path) is None
def test_admission_apt_and_nix_refuse(tmp_path, monkeypatch):
import hermes_cli.image_provenance as ip
monkeypatch.setattr(ip, "IMAGE_PROVENANCE_PATH", tmp_path / "absent.json")
for method, code in (("apt", "apt"), ("nix", "nix")):
def _detect(*a, _m=method, **k):
return _m
monkeypatch.setattr("hermes_cli.config.detect_install_method", _detect)
refusal = evaluate_update_admission(tmp_path)
assert refusal is not None and refusal.code == code
# ---------------------------------------------------------------------------
# Refusal receipt
# ---------------------------------------------------------------------------
def test_refusal_receipt_written_as_refused(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
import hermes_cli.update_receipt as ur
monkeypatch.setattr(ur, "_receipt_dir", lambda: tmp_path / "receipts")
record_refusal_receipt(
UpdateRefusal(
code="image-marker",
message="msg",
update_command="docker pull nousresearch/hermes-agent:latest",
)
)
receipts = list((tmp_path / "receipts").glob("*.json"))
receipts = [p for p in receipts if p.name != "latest.json"]
assert receipts, "a refusal receipt must be written"
data = json.loads(receipts[0].read_text())
assert data["outcome"] == "refused"
assert data["stop_reason"] == "image-marker"
steps = {s["name"]: s for s in data["steps"]}
assert "admission" in steps and steps["admission"]["ok"] is False
assert "docker pull" in steps["admission"]["detail"]