Files
hermes-agent/tests/hermes_cli/test_update_contract.py
ethernet 09a49ab37e update: refuse source checkouts on Termux hosts with the APT hint
The admission gate only refused the sealed apt-termux tree; a legacy
git-checkout install on a phone was admitted like any other checkout.
That path ff-merges and then syncs the venv against a lock whose CPython
is the bundled bionic build with no Android wheels, so the update
degrades into on-device sdist builds with no hint that the APT package
exists.

A git checkout under Termux (TERMUX_VERSION or the com.termux PREFIX,
via hermes_constants.is_termux) now refuses at the steward rung with
`pkg install hermes-agent` as the remediation. The same checkout off
Termux stays updatable.
2026-09-21 18:39:03 -04:00

254 lines
9.6 KiB
Python

"""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_nix_refuses(tmp_path, monkeypatch):
import hermes_cli.image_provenance as ip
monkeypatch.setattr(ip, "IMAGE_PROVENANCE_PATH", tmp_path / "absent.json")
def _detect(*a, **k):
return "nix"
monkeypatch.setattr("hermes_cli.config.detect_install_method", _detect)
refusal = evaluate_update_admission(tmp_path)
assert refusal is not None and refusal.code == "nix"
# ---------------------------------------------------------------------------
# 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"]
# ---------------------------------------------------------------------------
# Sealed-steward admission gate: apt-termux
# ---------------------------------------------------------------------------
def _sealed_tree(tmp_path: Path, distribution: str) -> Path:
"""A sealed (no .git) tree stamped with ``distribution``."""
root = tmp_path / "sealed"
root.mkdir(parents=True, exist_ok=True)
(root / "install-stamp.json").write_text(
json.dumps({"distribution": distribution})
)
return root
def test_admission_source_checkout_on_termux_host_refuses_with_apt_hint(tmp_path, monkeypatch):
"""A git checkout is normally admitted, but not on a Termux host: the
lock has no Android wheels, so a source sync would build sdists on the
phone. The refusal must point at the APT package, never `hermes update`."""
import hermes_cli.image_provenance as ip
monkeypatch.setattr(ip, "IMAGE_PROVENANCE_PATH", tmp_path / "absent.json")
(tmp_path / ".git").mkdir()
monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
refusal = evaluate_update_admission(tmp_path)
assert refusal is not None and refusal.code == "apt-termux"
assert "pkg install hermes-agent" in refusal.message
assert refusal.update_command == "pkg install hermes-agent"
assert "hermes update" not in refusal.update_command
monkeypatch.setenv("PREFIX", "/usr")
assert evaluate_update_admission(tmp_path) is None, "the same checkout off Termux stays updatable"
def test_admission_apt_termux_refuses_with_pkg_upgrade(tmp_path, monkeypatch):
"""A sealed apt-termux tree (no .git) is refused by the steward gate:
the package manager owns the code tree, so remediation is pkg upgrade
— never `hermes update`."""
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"
)
root = _sealed_tree(tmp_path, "apt-termux")
refusal = evaluate_update_admission(root)
assert refusal is not None
assert refusal.code == "apt-termux"
assert "pkg upgrade hermes-agent" in refusal.message
assert "pkg upgrade hermes-agent" in refusal.update_command
def test_admission_apt_termux_command_comes_from_steward_table(tmp_path, monkeypatch):
"""The apt-termux remediation command is read from the config module's
``_UPDATE_COMMAND_BY_METHOD`` table (the same one every install method
reads) — not hardcoded inline in the steward refusal — so there is ONE
source of truth for the update command."""
import hermes_cli.config as config_mod
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"
)
# Prove the table is the source: a table edit flows into the refusal.
monkeypatch.setitem(
config_mod._UPDATE_COMMAND_BY_METHOD,
"apt",
"pkg upgrade hermes-agent --from-table",
)
root = _sealed_tree(tmp_path, "apt-termux")
refusal = evaluate_update_admission(root)
assert refusal is not None
assert refusal.code == "apt-termux"
assert refusal.update_command == "pkg upgrade hermes-agent --from-table"