atlas-iac/scripts/ops/hermes_handoff_acceptance.py
Hermes Agent 8f00545828 hermes: add a fail-closed full-handoff acceptance harness
Decides whether the Hermes platform handoff is fit to release, and refuses
to round an absence of evidence up to a pass.

The harness is read-only by default and classifies 71 checks PASS / FAIL /
NOT_RUN / NOT_APPLICABLE. Any mandatory FAIL or NOT_RUN is NO_GO, and so is a
harness-level problem: an unreachable vantage, a catalog entry whose evidence
no longer exists, an expired deadline, or an evaluator that raised.

Evidence comes from two vantages that cannot cover for each other: an external
read-only operator kubeconfig, and the Hermes agent probing itself from inside
its own pod. Before any check runs, the harness asks each vantage who it is and
stops if they are the same principal, because dual-vantage evidence from one
identity is a restatement rather than a corroboration. `--as` is rejected for
every operator-side command and reachable only as the inner command of a
`kubectl exec`, so impersonation can never stand in for a real self-probe. A
deny check needs a live refused request, not only an authorization review.

Two safety properties are structural rather than conventional, enforced where
an argv becomes a subprocess: the default mode mutates nothing (mutating verbs
require a server dry run; there is deliberately no live TokenRequest probe,
because a successful one would mint a real credential), and no probe can pull a
credential value into a report (no vault/sops/curl, secrets readable only with
-o name, environment probes list names, shell only through frozen reviewed
templates). Captures are bounded before they are screened, and the rendered
report is re-screened before it is written.

Mutation lives behind a separate arming flag with an exact confirmation phrase,
a caller-supplied unique ref, a preflight that refuses a protected push target
before any network call, and a cleanup whose verification is itself mandatory.
A default run reports those four checks NOT_RUN.

The catalog is declarative so a reviewer reads what is asserted rather than how
it is plumbed, and so structural properties can be proven over every entry
before a run. Catalog drift surfaces as NOT_RUN, which stops the release.

docs/hermes_full_handoff_acceptance.md carries the merge order for PRs #14-#18
on top of the merged #13 baseline, the image build and Flux rollout, the
rollback point for each step, the go/no-go checklist, and the limits that are
asserted rather than exercised.

Validation: 295 handoff tests pass with 100% line coverage on all 15 new
modules; the full unit suite is 647 passed with two failures that reproduce
unchanged on origin/main; Ruff, py_compile, kustomize render, and a diff
credential screen are clean; a live read-only run against Atlas returns NO_GO
for the pre-merge cluster with no unscreened fields in the report.
2026-08-17 10:14:17 +00:00

237 lines
9.0 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 --context atlas-operator
scripts/ops/hermes_handoff_acceptance.py --output build/handoff.json
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,
SELF,
Runner,
)
from hermes_handoff_model import GO, Report, unscreened_fields, utc_now # noqa: E402
from hermes_handoff_policy import ARMED, READ_ONLY # noqa: E402
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,
)
EXIT_GO = 0
EXIT_NO_GO = 1
EXIT_USAGE = 2
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(
"--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=1, help="Nodes the fleet must cover.")
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(
"--expect-telegram-sessions",
action="store_true",
help="Require the selected chat tenant to hold durable Telegram-sourced sessions.",
)
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.")
armed.add_argument("--ephemeral-base", default="main", help="Pull-request base branch.")
armed.add_argument("--remote", default="origin", help="Git remote for the ephemeral push.")
armed.add_argument("--repo", default=Targets.repo, help="owner/name of the forge repository.")
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),
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,
expect_telegram_sessions=arguments.expect_telegram_sessions,
pool_worker_env=tuple(arguments.pool_worker_env or ()),
max_evidence_age_seconds=arguments.max_evidence_age,
repo=arguments.repo,
remote=arguments.remote,
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=arguments.repo,
remote=arguments.remote,
token=arguments.ephemeral_token,
confirmation=arguments.confirm,
base=arguments.ephemeral_base,
)
preflight(request, arguments.repo)
vantage = vantages.get(SELF) or 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
runner = Runner(
mode=mode,
command_timeout=arguments.command_timeout,
deadline_seconds=arguments.deadline,
max_bytes=arguments.max_output_bytes,
)
vantages, records = resolve_vantages(runner, targets, arguments.kubeconfig, arguments.context)
armed_results: list = []
if arguments.arm_ephemeral_push:
try:
armed_results = _armed_results(arguments, runner, vantages)
except ArmingError as exc:
report = unavailable_report(mode, started_at, f"arming refused: {exc}", targets)
emit(report, arguments)
return EXIT_NO_GO
specs = build_catalog(targets)
report = build_report(
runner, targets, specs, vantages, records, mode, started_at, armed_results
)
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())