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:
+16
-17
@@ -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
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user