atlas-iac/scripts/ops/hermes_handoff_acceptance.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

355 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Fail-closed acceptance harness for the Hermes full handoff.
Read-only by default. The harness collects evidence from two genuinely separate
places — an external operator vantage and the Hermes agent probing itself from
inside its own pod — classifies every check PASS / FAIL / NOT_RUN /
NOT_APPLICABLE, and emits machine-readable JSON alongside a short human summary.
Any mandatory FAIL or NOT_RUN is a NO_GO, and so is a harness-level problem: an
unreachable vantage, a catalog whose evidence no longer exists, an expired
deadline, or two vantages that turn out to be the same principal. Nothing here
rounds an absence of evidence up to a pass.
scripts/ops/hermes_handoff_acceptance.py --help
The release runbook lists every required freshness and lineage input.
Mutation lives behind a separate arming flag, needs an exact confirmation
phrase and a unique ref, refuses protected push targets before any network
call, and verifies its own cleanup. A default run reports those checks NOT_RUN.
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import sys
from collections.abc import Sequence
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from hermes_handoff_catalog import Targets # noqa: E402
from hermes_handoff_ephemeral import ( # noqa: E402
CONFIRMATION,
ArmingError,
ArmRequest,
preflight,
run_armed,
)
from hermes_handoff_exec import ( # noqa: E402
DEFAULT_COMMAND_TIMEOUT,
DEFAULT_RUN_DEADLINE,
Runner,
)
from hermes_handoff_model import GO, Report, unscreened_fields, utc_now # noqa: E402
from hermes_handoff_policy import ( # noqa: E402
ARMED,
EXPECTED_BASE,
EXPECTED_REMOTE,
EXPECTED_REPO,
READ_ONLY,
)
from hermes_handoff_redaction import DEFAULT_MAX_BYTES # noqa: E402
from hermes_handoff_run import ( # noqa: E402
build_catalog,
build_report,
resolve_vantages,
unavailable_report,
validate_catalog,
validate_targets,
)
EXIT_GO = 0
EXIT_NO_GO = 1
EXIT_USAGE = 2
def dependency_head(value: str) -> tuple[int, str]:
"""Parse one `PR=SHA` dependency-head binding."""
try:
number, sha = value.split("=", 1)
parsed = int(number)
except (TypeError, ValueError) as exc:
raise argparse.ArgumentTypeError(
"dependency head must be PR=40-character-SHA"
) from exc
if (
parsed <= 0
or len(sha) != 40
or any(character not in "0123456789abcdef" for character in sha)
):
raise argparse.ArgumentTypeError(
"dependency head must be PR=lowercase-40-character-SHA"
)
return (parsed, sha)
def build_parser() -> argparse.ArgumentParser:
"""Return the command-line interface for the harness."""
parser = argparse.ArgumentParser(
prog="hermes_handoff_acceptance",
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--namespace", default="hermes", help="Hermes namespace.")
parser.add_argument(
"--context", help="Kubernetes context for the operator vantage."
)
parser.add_argument("--kubeconfig", help="Kubeconfig for the operator vantage.")
parser.add_argument(
"--output", type=Path, help="Write the JSON report here instead of stdout."
)
parser.add_argument(
"--json-only", action="store_true", help="Suppress the human summary."
)
parser.add_argument(
"--command-timeout",
type=float,
default=DEFAULT_COMMAND_TIMEOUT,
help="Per-command seconds.",
)
parser.add_argument(
"--deadline",
type=float,
default=DEFAULT_RUN_DEADLINE,
help="Whole-run seconds.",
)
parser.add_argument(
"--max-output-bytes",
type=int,
default=DEFAULT_MAX_BYTES,
help="Per-capture byte budget.",
)
parser.add_argument(
"--baseline-commit",
default=Targets.baseline_commit,
help="Commit main must descend from.",
)
parser.add_argument(
"--dependency-pr",
type=int,
action="append",
default=None,
help="Pull request that must be merged before this release is certifiable (repeatable).",
)
parser.add_argument(
"--dependency-head",
type=dependency_head,
action="append",
default=None,
metavar="PR=SHA",
help="Exact current dependency PR head (repeat for every dependency).",
)
parser.add_argument(
"--remote-main-sha", default="", help="Exact observed origin/main SHA."
)
parser.add_argument(
"--reviewed-head-sha", default="", help="Exact reviewed PR #19 head SHA."
)
parser.add_argument(
"--agent-image",
default="",
help="Exact agent :git-<source>-build-<n>@sha256 identity.",
)
parser.add_argument(
"--build-sha", default="", help="Exact source SHA used for the image build."
)
parser.add_argument(
"--deployment-revision", default="", help="Exact running Deployment revision."
)
parser.add_argument(
"--expected-suspension",
action="append",
default=None,
metavar="NAMESPACE/NAME",
help="Flux object allowed to be suspended (repeatable).",
)
parser.add_argument(
"--node-count",
type=int,
default=3,
help="Nodes the fleet must cover (fixed at 3).",
)
parser.add_argument(
"--pool-replicas", type=int, default=3, help="Expected pool worker count."
)
parser.add_argument(
"--chat-config-revision",
default="",
help="Expected chat pod-template config revision.",
)
parser.add_argument(
"--chat-ordinal",
type=int,
default=0,
help="Chat tenant ordinal that carries the Telegram identity.",
)
parser.add_argument(
"--pool-worker-env",
action="append",
default=None,
help="Environment variable the pool workers must declare (repeatable).",
)
parser.add_argument(
"--max-evidence-age",
type=int,
default=86_400,
help="Seconds before provider and routing evidence is treated as stale.",
)
armed = parser.add_argument_group("ephemeral mutation mode")
armed.add_argument(
"--arm-ephemeral-push",
action="store_true",
help="Permit one ephemeral branch push and draft pull request, then remove both.",
)
armed.add_argument(
"--confirm", default="", help=f"Exact confirmation phrase: {CONFIRMATION!r}."
)
armed.add_argument(
"--ephemeral-token", default="", help="Unique 8-64 character ref suffix."
)
return parser
def targets_from_args(arguments: argparse.Namespace, now: dt.datetime) -> Targets:
"""Return the catalog targets a parsed command line describes."""
defaults = Targets()
return Targets(
namespace=arguments.namespace,
baseline_commit=arguments.baseline_commit,
dependency_pull_requests=tuple(
arguments.dependency_pr or defaults.dependency_pull_requests
),
dependency_heads=tuple(arguments.dependency_head or ()),
remote_main_sha=arguments.remote_main_sha,
reviewed_head_sha=arguments.reviewed_head_sha,
agent_image=arguments.agent_image,
build_sha=arguments.build_sha,
deployment_revision=arguments.deployment_revision,
expected_suspensions=tuple(arguments.expected_suspension or ()),
node_count=arguments.node_count,
pool_replicas=arguments.pool_replicas,
chat_config_revision=arguments.chat_config_revision,
chat_ordinal=arguments.chat_ordinal,
pool_worker_env=tuple(arguments.pool_worker_env or defaults.pool_worker_env),
max_evidence_age_seconds=arguments.max_evidence_age,
now=now,
)
def emit(report: Report, arguments: argparse.Namespace, stream=None) -> list[str]:
"""Write the report, refusing to publish one that still looks credential-shaped."""
payload = report.as_dict()
offenders = unscreened_fields(payload)
if offenders:
payload = {
"harness": payload["harness"],
"decision": "NO_GO",
"harness_errors": [
"the rendered report failed its own credential screening at "
+ ", ".join(offenders[:8])
],
}
rendered = json.dumps(payload, indent=2, sort_keys=True, default=str)
if arguments.output:
arguments.output.parent.mkdir(parents=True, exist_ok=True)
arguments.output.write_text(rendered + "\n", encoding="utf-8")
else:
print(rendered, file=stream or sys.stdout)
if not arguments.json_only:
print(report.render_summary(), file=sys.stderr)
return offenders
def _armed_results(arguments: argparse.Namespace, runner: Runner, vantages) -> list:
request = ArmRequest(
repo=EXPECTED_REPO,
remote=EXPECTED_REMOTE,
token=arguments.ephemeral_token,
confirmation=arguments.confirm,
base=EXPECTED_BASE,
expected_head=arguments.reviewed_head_sha,
expected_base_sha=arguments.remote_main_sha,
)
preflight(request, EXPECTED_REPO, Path.cwd())
vantage = vantages["operator"]
return run_armed(runner, vantage, request)
def main(argv: Sequence[str] | None = None) -> int:
"""Run the acceptance sweep and return the process exit status."""
arguments = build_parser().parse_args(argv)
now = dt.datetime.now(dt.timezone.utc)
started_at = utc_now(lambda: now)
targets = targets_from_args(arguments, now)
mode = ARMED if arguments.arm_ephemeral_push else READ_ONLY
specs = build_catalog(targets)
startup_problems = [*validate_targets(targets), *validate_catalog(specs)]
if arguments.arm_ephemeral_push:
try:
preflight(
ArmRequest(
repo=EXPECTED_REPO,
remote=EXPECTED_REMOTE,
token=arguments.ephemeral_token,
confirmation=arguments.confirm,
base=EXPECTED_BASE,
expected_head=arguments.reviewed_head_sha,
expected_base_sha=arguments.remote_main_sha,
),
EXPECTED_REPO,
Path.cwd(),
)
except ArmingError as exc:
startup_problems.append(f"arming refused: {exc}")
if startup_problems:
report = unavailable_report(
mode, started_at, "; ".join(startup_problems), targets
)
emit(report, arguments)
return EXIT_NO_GO
try:
runner = Runner(
mode=mode,
command_timeout=arguments.command_timeout,
deadline_seconds=arguments.deadline,
max_bytes=arguments.max_output_bytes,
)
except ValueError as exc:
report = unavailable_report(
mode, started_at, f"invalid execution bound: {exc}", targets
)
emit(report, arguments)
return EXIT_USAGE
vantages, records = resolve_vantages(
runner, targets, arguments.kubeconfig, arguments.context
)
report = build_report(runner, targets, specs, vantages, records, mode, started_at)
if arguments.arm_ephemeral_push:
if report.decision != GO:
report.harness_errors.append(
"armed mutation was not started because read-only acceptance did not GO"
)
else:
armed_results = _armed_results(arguments, runner, vantages)
armed_ids = {result.spec.id for result in armed_results}
report.results = [
result for result in report.results if result.spec.id not in armed_ids
]
report.results.extend(armed_results)
report.results.sort(key=lambda result: result.spec.id)
report.finished_at = utc_now()
offenders = emit(report, arguments)
if offenders:
return EXIT_NO_GO
return EXIT_GO if report.decision == GO else EXIT_NO_GO
if __name__ == "__main__": # pragma: no cover - exercised through main()
raise SystemExit(main())