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>
440 lines
16 KiB
Python
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]
|