diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 65a63f0b99..be8a823b80 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 3a62699bc3..87c495c817 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -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(): diff --git a/hermes_cli/update_contract.py b/hermes_cli/update_contract.py new file mode 100644 index 0000000000..cf6f4e2f75 --- /dev/null +++ b/hermes_cli/update_contract.py @@ -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 " 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 ") 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) diff --git a/hermes_cli/update_inventory.py b/hermes_cli/update_inventory.py index 4b8f2234bc..eb0d17abcd 100644 --- a/hermes_cli/update_inventory.py +++ b/hermes_cli/update_inventory.py @@ -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) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 0751e8746f..8528ced952 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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") diff --git a/tests/hermes_cli/test_update_contract.py b/tests/hermes_cli/test_update_contract.py new file mode 100644 index 0000000000..9f6adce2b5 --- /dev/null +++ b/tests/hermes_cli/test_update_contract.py @@ -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"]