atlas-iac/scripts/ops/hermes_handoff_ephemeral.py
Hermes Agent d208a89d9d fix(hermes): close the zero-evidence fail-open in absence checks
evaluate_names_absent returned PASS when its step exited 0 with no output,
so five mandatory checks - the ones asserting that provider API keys, forge
credentials, a cluster-admin binding, and shared coordinator state are
absent - could report a pass on no evidence and turn a NO_GO into a GO.
Both name rules now resolve their step through one guard in _line_step, so
zero observations are NOT_RUN. Regressions pin all five real catalog specs
plus both reachable silence paths: a POSIX pipeline whose status comes from
its last stage, and a drifted kubectl -o jsonpath. The pool claim projection
emits one <volume>=<claim> line per template volume so a volume without a
PVC still counts as an observation rather than reading as drift.

Also closes the review's reachable hardening and evidence defects:

- pin Gitea paths to atlas/titan-iac on an exact segment boundary and
  reject relative segments, including percent-encoded ones
- forbid impersonation structurally in every mode and vantage; the inner
  command of kubectl exec is re-checked rather than exempted, and
  validate_catalog no longer guards only the operator vantage
- drop flux and helm from the binary allowlist; they had no pinned release
  digest, so no allowlisted binary can now be admitted that the executor
  would refuse to attest
- remove the inert --concurrency and --expect-telegram-sessions flags and
  the dead concurrency bound; Telegram continuity stays mandatory
- read the ephemeral pull index page by page, treat the create response as
  an authoritative source for the pull number, close every number either
  source names, and surface residue_ref plus exact manual_cleanup commands
  when creation is uncertain
- keep executable_path and executable_sha256 on unrecorded bulk-evidence
  steps so withholding bytes never withholds binary attestation
- revert the repo-wide hygiene legacy-exception mechanism; the contract
  change here is purely additive and the four pre-existing over-cap files
  are left to the canonical contract change in PR #14/#15
- correct the runbook ruff format scope so the documented command passes

Split hermes_handoff_arming.py out of hermes_handoff_ephemeral.py to keep
both modules under the 500-line cap. All 16 handoff modules hold at least
95% line and branch coverage; the mutation gate is 13/13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:24:37 +00:00

440 lines
16 KiB
Python

#!/usr/bin/env python3
"""Discoverable, residue-surfacing, self-cleaning armed ephemeral run.
The create response — not the index — is the authoritative name of a pull
request this harness made, and the index is read to its last page. When either
source is uncertain the run fails closed and reports the exact residue plus the
exact commands that remove it, so an operator is never left guessing what was
created.
Arming, provenance, and push-target rules live in :mod:`hermes_handoff_arming`
and are re-exported here for callers that treat armed mode as one surface.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from hermes_handoff_arming import (
CONFIRMATION,
REF_PREFIX,
ArmingError,
ArmRequest,
assert_push_target_allowed,
bounded_read,
normalise_branch,
preflight,
)
from hermes_handoff_exec import Outcome, Runner, Vantage
from hermes_handoff_model import EPHEMERAL, FAIL, NOT_RUN, PASS, CheckResult, CheckSpec
from hermes_handoff_policy import (
EXPECTED_BASE,
GITEA_CLIENT,
register_ephemeral_pull,
)
from hermes_handoff_rules import strict_json
__all__ = [
"CONFIRMATION",
"DISCOVERY_PAGE_BUDGET",
"DISCOVERY_PAGE_SIZE",
"REF_PREFIX",
"ArmRequest",
"ArmingError",
"assert_push_target_allowed",
"bounded_read",
"normalise_branch",
"preflight",
"run_armed",
]
DRAFT_TITLE_PREFIX = "WIP: "
DISCOVERY_PAGE_SIZE = 50
DISCOVERY_PAGE_BUDGET = 20
def _spec(identifier: str, title: str) -> CheckSpec:
return CheckSpec(
id=f"ephemeral.{identifier}",
title=title,
group="ephemeral",
rule="armed",
steps=(),
mandatory=True,
scope=EPHEMERAL,
)
def _result(
identifier: str,
title: str,
status: str,
reason: str,
outcomes: Sequence[Outcome] = (),
evidence: dict[str, Any] | None = None,
) -> CheckResult:
return CheckResult(
spec=_spec(identifier, title),
status=status,
reason=reason,
evidence=evidence or {},
outcomes=list(outcomes),
)
def _protected_refusal_result() -> CheckResult:
accepted: list[str] = []
for candidate in ("main", "master", "refs/heads/main", "HEAD", f"{REF_PREFIX}/x"):
try:
assert_push_target_allowed(candidate)
except ArmingError:
continue
accepted.append(candidate)
return _result(
"protected-branch-refusal",
"Arming against a protected branch is refused before any network call",
FAIL if accepted else PASS,
f"guard accepted {accepted}"
if accepted
else "every protected and malformed target was refused",
evidence={"accepted": accepted},
)
def _detail(outcome: Outcome) -> str:
return (
outcome.error or outcome.combined[:200] or f"exit status {outcome.returncode}"
)
def _pr_problems(payload: Any, request: ArmRequest) -> list[str]:
if not isinstance(payload, dict):
return ["pull request is not an object"]
wanted = {
"state": "open",
"draft": True,
"merged": False,
"base": {"ref": EXPECTED_BASE, "sha": request.expected_base_sha},
"head": {"ref": request.ref, "sha": request.expected_head},
}
problems: list[str] = []
if (
not isinstance(payload.get("number"), int)
or isinstance(payload.get("number"), bool)
or payload["number"] <= 0
):
problems.append("number is malformed")
for field in ("state", "draft", "merged"):
if (
type(payload.get(field)) is not type(wanted[field])
or payload.get(field) != wanted[field]
):
problems.append(f"{field} does not match")
for group, values in (("base", wanted["base"]), ("head", wanted["head"])):
observed = payload.get(group)
if not isinstance(observed, dict):
problems.append(f"{group} is malformed")
continue
for field, value in values.items():
if observed.get(field) != value:
problems.append(f"{group}.{field} does not match")
return problems
def _created_number(outcome: Outcome, request: ArmRequest) -> tuple[int, str]:
"""Return the number the create response itself reports.
The create response is the only source that names a pull request the harness
definitely made. Relying on the index alone means an index that fails or
answers ambiguously leaves an unnamed, unclosed pull request behind.
"""
if not outcome.ok:
return (0, f"pull-request creation failed: {_detail(outcome)}")
try:
payload = strict_json(outcome.stdout)
except ValueError as exc:
return (0, f"pull-request creation returned malformed JSON: {exc}")
problems = _pr_problems(payload, request)
if problems:
return (0, f"created pull request is invalid: {', '.join(problems)}")
return (payload["number"], "")
def _index_pages(
runner: Runner, vantage: Vantage, api: str, reserve: float
) -> tuple[list[Outcome], list[Any], str]:
"""Read every page of the pull index; a truncated page can hide a match."""
outcomes: list[Outcome] = []
rows: list[Any] = []
for page in range(1, DISCOVERY_PAGE_BUDGET + 1):
outcome = runner.run(
(
GITEA_CLIENT,
"GET",
f"{api}/pulls?state=all&limit={DISCOVERY_PAGE_SIZE}&page={page}",
),
vantage,
reserve_seconds=reserve,
)
outcomes.append(outcome)
if not outcome.ok:
return (outcomes, [], f"discovery page {page} failed: {_detail(outcome)}")
try:
payload = strict_json(outcome.stdout)
except ValueError as exc:
return (outcomes, [], f"discovery page {page} is malformed JSON: {exc}")
if not isinstance(payload, list):
return (outcomes, [], f"discovery page {page} did not return an array")
rows.extend(payload)
if len(payload) < DISCOVERY_PAGE_SIZE:
return (outcomes, rows, "")
return (outcomes, [], "pull-request discovery exceeded its page budget")
def _residue(request: ArmRequest, numbers: Sequence[int], api: str) -> dict[str, Any]:
"""Name the exact residue and the exact commands that remove it by hand."""
manual = [
f"{GITEA_CLIENT} PATCH {api}/pulls/{number} --field state=closed"
for number in numbers
]
if not numbers:
manual.append(
f"{GITEA_CLIENT} GET {api}/pulls?state=all&limit={DISCOVERY_PAGE_SIZE}"
f" then close by hand every pull whose head ref is {request.ref}"
)
manual.append(f"{GITEA_CLIENT} DELETE {api}/branches/{request.ref}")
return {"residue_ref": request.ref, "manual_cleanup": manual}
def _match_pulls(rows: Sequence[Any], request: ArmRequest) -> tuple[list[int], str]:
matches: list[int] = []
for index, item in enumerate(rows):
if (
not isinstance(item, dict)
or not isinstance(item.get("head"), dict)
or not isinstance(item.get("base"), dict)
):
return ([], f"pull-request discovery row {index + 1} is malformed")
if item["head"].get("ref") != request.ref:
continue
problems = _pr_problems(item, request)
if problems:
return ([], f"discovered pull request is invalid: {', '.join(problems)}")
matches.append(item["number"])
if len(matches) != 1:
return (
matches,
f"expected one exact draft pull request, discovered {len(matches)}",
)
return (matches, "")
def _closed_pr_problems(payload: Any, request: ArmRequest) -> list[str]:
"""Validate the same exact PR identity with state changed to closed."""
problems = _pr_problems(payload, request)
if isinstance(payload, dict) and payload.get("state") == "closed":
problems = [
problem for problem in problems if problem != "state does not match"
]
elif "state does not match" not in problems:
problems.append("state is not closed")
return problems
def _cleanup(
runner: Runner,
vantage: Vantage,
request: ArmRequest,
api: str,
numbers: Sequence[int],
discovery_error: str = "",
) -> CheckResult:
outcomes: list[Outcome] = []
problems = [discovery_error] if discovery_error else []
for number in sorted(set(numbers)):
closed = runner.run(
(GITEA_CLIENT, "PATCH", f"{api}/pulls/{number}", "--field", "state=closed"),
vantage,
)
outcomes.append(closed)
if not closed.ok:
problems.append(f"pull request #{number} close failed")
continue
try:
payload = strict_json(closed.stdout)
except ValueError:
problems.append(f"pull request #{number} close response is malformed")
continue
expected = _closed_pr_problems(payload, request)
if expected:
problems.append(f"pull request #{number} close response does not match")
deleted = runner.run(
(GITEA_CLIENT, "DELETE", f"{api}/branches/{request.ref}"), vantage
)
outcomes.append(deleted)
if not deleted.ok:
problems.append("ephemeral branch deletion failed")
remaining = runner.run(
("git", "ls-remote", request.remote, f"refs/heads/{request.ref}"), vantage
)
outcomes.append(remaining)
if not remaining.ok or remaining.stdout.strip():
problems.append("ephemeral branch absence could not be verified")
for number in sorted(set(numbers)):
observed = runner.run((GITEA_CLIENT, "GET", f"{api}/pulls/{number}"), vantage)
outcomes.append(observed)
if not observed.ok:
problems.append(f"pull request #{number} closure could not be verified")
continue
try:
payload = strict_json(observed.stdout)
except ValueError:
problems.append(f"pull request #{number} verification is malformed")
continue
expected = _closed_pr_problems(payload, request)
if expected:
problems.append(f"pull request #{number} is not exactly closed")
return _result(
"cleanup-verified",
"Every ephemeral ref and pull request is removed and the removal is verified",
FAIL if problems else PASS,
"; ".join(problems)
if problems
else "the exact branch and draft pull request are gone",
outcomes,
{"ref": request.ref, "pull_requests": sorted(set(numbers))},
)
def _nothing_created(branch: CheckResult, draft_reason: str) -> list[CheckResult]:
"""Report the two results that follow a run which created no artifact."""
return [
branch,
_result(
"draft-pull-request",
"A draft pull request can be opened and closed",
NOT_RUN,
draft_reason,
),
_result(
"cleanup-verified",
"Every ephemeral artifact is removed",
NOT_RUN,
"nothing was created",
),
]
def run_armed(
runner: Runner, vantage: Vantage, request: ArmRequest
) -> list[CheckResult]:
"""Create, discover, validate, and clean one exact ephemeral branch/PR."""
guard = _protected_refusal_result()
branch_title = "A unique ephemeral feature branch can be pushed and removed"
if guard.status != PASS:
unrun = _result("feature-branch-push", branch_title, NOT_RUN, "guard failed")
return [guard, *_nothing_created(unrun, "protected-ref guard failed")]
api = f"/api/v1/repos/{request.repo}"
reserve = min(6000.0, max(300.0, runner.command_timeout * 10))
existing = runner.run(
("git", "ls-remote", request.remote, f"refs/heads/{request.ref}"),
vantage,
reserve_seconds=reserve,
)
if not existing.ok or existing.stdout.strip():
reason = (
_detail(existing) if not existing.ok else "the ephemeral ref already exists"
)
refused = _result("feature-branch-push", branch_title, FAIL, reason, [existing])
return [guard, *_nothing_created(refused, "branch preflight failed")]
local_head = runner.run(
("git", "rev-parse", "HEAD"), vantage, reserve_seconds=reserve
)
pushed = (
runner.run(
("git", "push", request.remote, f"HEAD:refs/heads/{request.ref}"),
vantage,
reserve_seconds=reserve,
)
if local_head.ok and local_head.stdout.strip() == request.expected_head
else Outcome(
argv=("git", "push"),
vantage=vantage.name,
error="local HEAD changed after static preflight",
)
)
verified = runner.run(
("git", "ls-remote", request.remote, f"refs/heads/{request.ref}"),
vantage,
reserve_seconds=reserve,
)
branch_ok = verified.ok and verified.stdout.split() == [
request.expected_head,
f"refs/heads/{request.ref}",
]
branch_result = _result(
"feature-branch-push",
branch_title,
PASS if branch_ok else FAIL,
"the exact reviewed HEAD was pushed"
if branch_ok
else "push could not be verified exactly",
[local_head, pushed, verified],
{"ref": request.ref, "head": request.expected_head},
)
numbers: list[int] = []
problems: list[str] = ["branch creation was not verified"]
create_outcomes: list[Outcome] = []
if branch_ok:
created = runner.run(
(
GITEA_CLIENT,
"POST",
f"{api}/pulls",
"--field",
f"title={DRAFT_TITLE_PREFIX}Hermes handoff acceptance ephemeral probe",
"--field",
f"head={request.ref}",
"--field",
f"base={EXPECTED_BASE}",
"--field",
"body=Ephemeral acceptance probe. Closed and deleted by the harness.",
),
vantage,
reserve_seconds=reserve,
)
number, create_error = _created_number(created, request)
pages, rows, index_error = _index_pages(runner, vantage, api, reserve)
found, match_error = (
_match_pulls(rows, request) if not index_error else ([], "")
)
create_outcomes = [created, *pages]
# Either source alone names a pull request this run definitely made: the
# ephemeral ref was proven absent in preflight, so any pull on it is ours.
numbers = sorted({item for item in (number, *found) if item})
problems = [text for text in (index_error, match_error) if text]
if number and found and found != [number]:
problems.append(f"created #{number} is not the discovered {found}")
if not numbers:
problems.append(
create_error or "no exact draft pull request was identified"
)
for item in numbers:
register_ephemeral_pull(item)
exact = len(numbers) == 1 and not problems
draft_result = _result(
"draft-pull-request",
"A draft pull request can be opened and closed against the ephemeral branch",
PASS if exact else FAIL,
f"discovered exact draft pull request #{numbers[0]}"
if exact
else "; ".join(problems),
create_outcomes,
{"pull_requests": numbers}
if exact
else {"pull_requests": numbers, **_residue(request, numbers, api)},
)
cleanup = _cleanup(runner, vantage, request, api, numbers, "; ".join(problems))
return [guard, branch_result, draft_result, cleanup]