"""Image-managed install refusal contract. 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 Callable, Optional from hermes_cli.steward import STEWARD_APT_TERMUX, STEWARD_DESKTOP, STEWARD_DOCKER, STEWARD_NIX logger = logging.getLogger(__name__) COMMIT_BUILD_UPDATE_MESSAGE = ( "This build doesn't get updates. Ask the developer who gave it to you for a new build." ) def is_commit_build(project_root: Path) -> bool: from hermes_cli.steward import read_install_stamp return read_install_stamp(project_root).get("source") == "commit-build" @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 | desktop-app | message: str # full user-facing text (multi-line ok) update_command: str # the one-line remediation command def _refusal(code: str, method: str, message: Optional[Callable[[str], str]] = None) -> UpdateRefusal: """Refusal for ``method``: ``message(command)`` if given, else docker's full message / the bare command.""" from hermes_cli.config import format_docker_update_message, recommended_update_command_for_method command = recommended_update_command_for_method(method) if message is not None: text = message(command) else: text = format_docker_update_message() if method == "docker" else command return UpdateRefusal(code=code, message=text, update_command=command) # Sealed-tree steward -> install method whose CLI command remediates it. The # refusal code is the steward name itself. APT owns the Termux code tree: # ``pkg upgrade`` replaces it wholesale, so it must never update in place. _STEWARD_UPDATE_METHODS: dict[str, str] = { STEWARD_DOCKER: "docker", STEWARD_NIX: "nix", STEWARD_APT_TERMUX: "apt", } def _steward_refusal(steward: str) -> UpdateRefusal: """Refusal for a tree sealed by ``steward``.""" from hermes_cli.steward import steward_update_message method = _STEWARD_UPDATE_METHODS.get(steward) if method == "docker": return _refusal(steward, method) if method is not None: return _refusal(steward, method, lambda _command: steward_update_message(steward)) # desktop-app and future package managers have no CLI remediation: the # steward's own instructions are the remediation, and # recommended_update_command_for_method would falsely answer "hermes # update" for methods it doesn't know. command = ( "Manage updates from within the desktop app" if steward == STEWARD_DESKTOP else f"update via {steward}" ) return UpdateRefusal(code=steward, message=steward_update_message(steward), update_command=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. """ if is_commit_build(project_root): return UpdateRefusal("commit-build", COMMIT_BUILD_UPDATE_MESSAGE, "") # 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: if not provenance.valid: # Present but malformed: still image-managed — an integrity defect is never # permission to mutate the image in place. return _refusal("image-marker-invalid", "docker", lambda command: ( "✗ 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}" )) return _refusal("image-marker", provenance.manager) except Exception as exc: logger.debug("Image provenance check failed (using heuristics): %s", exc) # Layer 2: install stamp / steward classification. A sealed tree (no # ``.git``) belongs to a steward — the desktop app bundle, a Docker # image, the Nix store — and only the steward updates it. This is the # rung that covers ``desktop-app``, which the heuristics below never # detect (the payload has no .install_method stamp and no .git). try: from hermes_cli.steward import sealed_steward steward = sealed_steward(project_root) if steward is None: from hermes_constants import is_termux if is_termux(): from hermes_cli.steward import SOURCE_ON_TERMUX_UPDATE_COMMAND, SOURCE_ON_TERMUX_UPDATE_MESSAGE return UpdateRefusal( code=STEWARD_APT_TERMUX, message=SOURCE_ON_TERMUX_UPDATE_MESSAGE, update_command=SOURCE_ON_TERMUX_UPDATE_COMMAND, ) elif steward != "unknown": return _steward_refusal(steward) except Exception as exc: logger.debug("Steward admission check failed: %s", exc) # Layer 3: pre-existing filesystem heuristics, verbatim semantics. try: from hermes_cli.config import detect_install_method, is_nix_install_method method = detect_install_method(project_root) if method == "docker": return _refusal("docker", method) if is_nix_install_method(method) or method == "apt": return _refusal(method if method == "apt" else "nix", method) 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() detail = f"not updatable in place ({refusal.code})" detail += f"; use: {refusal.update_command}" if refusal.update_command else f"; {refusal.message}" record_step("admission", False, detail) finalize_update_receipt("refused", stop_reason=refusal.code) except Exception as exc: logger.debug("Could not record refusal receipt: %s", exc)