atlas-iac/docs/hermes_full_handoff_acceptance.md
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

19 KiB
Raw Blame History

Hermes full-handoff acceptance harness and release runbook

scripts/ops/hermes_handoff_acceptance.py decides whether the Hermes platform handoff is fit to release. It is read-only by default, it classifies every check PASS / FAIL / NOT_RUN / NOT_APPLICABLE, and any mandatory FAIL or NOT_RUN makes the whole run NO_GO. Absence of evidence is never rounded up to evidence of absence: a probe that could not run, a catalog entry whose evidence no longer exists, an expired deadline, or a harness bug all land in NOT_RUN and stop the release.

scripts/ops/hermes_handoff_acceptance.py \
  --context atlas-operator \
  --node-count 21 \
  --expected-suspension flux-system/bstein-dev-home-migrations \
  --output build/hermes-handoff-acceptance.json

Exit status is 0 for GO and 1 for NO_GO. The JSON report goes to --output (or stdout); a short human summary goes to stderr unless --json-only is passed.

Why two vantages

Every authority claim is checked from two places that cannot cover for each other:

Vantage What it is What it proves
operator An external, read-only kubeconfig Desired state: bindings, manifests, workloads, Flux health, forge state
self kubectl exec into the running hermes-agent container Enforced state: what the agent's own service-account token can actually do
switchyard, node, chat kubectl exec into the Switchyard, node-hardening, and chat pods Evidence that only exists inside those workloads

Two rules keep this honest:

  • The harness cannot impersonate from outside. --as, --as-group, and --as-uid are rejected by policy for any operator-side command. They are reachable only as the inner command of a kubectl exec, where the identity is testing its own refusal to impersonate rather than borrowing authority. kubectl auth can-i --as is therefore not available as a substitute for a real self-probe.
  • The two vantages must be different principals. Before any check runs, the harness asks each vantage kubectl auth whoami and compares. If the operator and the in-pod probe authenticate as the same principal, the dual-vantage evidence is a restatement rather than a corroboration, and the run is NO_GO with the operator and in-pod vantages authenticate as the same principal. Run the harness from a genuinely external kubeconfig — not from inside the Hermes owner pod.

Deny checks additionally require a real refused request, not only an authorization review. kubectl auth can-i reports what RBAC says; only an attempt proves the API server enforces it. Mutating attempts go through --dry-run=server: the API server authorises the request and then discards it, so a refusal is genuine and nothing is written either way.

Safety properties

These hold structurally, in hermes_handoff_policy.py, so a catalog mistake is a policy error before a run rather than a mutation or a leak during one.

  • No mutation in the default mode. Only read subcommands are reachable; apply, create, delete, patch, replace, and scale require a dry-run flag. git push and every non-GET forge method are refused outside the armed mode. There is deliberately no live TokenRequest probe — a successful one would mint a real credential.
  • No credential is ever read. vault, sops, age, gpg, openssl, curl, and wget are not on the binary allowlist. Secrets may only be read with -o name. Environment probes list variable names and never values. Provider health files are read through a frozen template that projects an allow-listed set of non-secret status fields. The node account's lock state is reported without reading its password hash; its authorized keys are counted, never printed.
  • Shell is frozen. sh -c is reachable only through the reviewed, parameter-validated templates in SHELL_TEMPLATES. Parameters may not contain shell metacharacters.
  • Output is bounded and screened. Each capture is truncated to a byte budget before it is screened, so a large dump cannot outrun the screen. Bulk evidence (the routing-log tail, the Kanban listing) is parsed but replaced with [output not recorded] in the artifact. The screen preserves JSON quoting, so screened structured output is still parsable.
  • The report is re-checked before it is published. unscreened_fields walks the rendered payload; if anything still looks credential-shaped the report is replaced with a NO_GO stub naming the offending pointers, and the file that would have leaked is never written.

What the catalog asserts

The catalog is declarative (hermes_handoff_catalog.py plus the four hermes_handoff_checks_*.py modules) so a reviewer reads what is asserted, not how it is plumbed. 71 checks in 14 groups:

Group Assertions
baseline origin/main descends from the merged worker-isolation baseline; every dependency pull request is merged into main; the exact open pull-request heads are recorded
identity Codex authenticates as codex-chatgpt-subscription; Claude as firstParty / claude.ai / claude-code-cli-subscription; both evidence files are fresh; no provider API-key variable exists in the running process or in the manifest
routing Switchyard has a ready replica; the routing log covers both providers, the route and worker lanes, medium/high/xhigh effort, and at least one real fallback; the newest record is fresh; both providers record a measured latency
scopes Chat, agent, and triage each declare their own HERMES_AUTO_ROUTER_PROFILE, on distinct hostnames
access-denied No cluster-admin binding for the agent; Secrets, service-account tokens, impersonation, workload mutation, arbitrary pod exec, attach, port-forward, and control-plane writes are all refused
access-allowed get/list/watch on core objects, pod logs, and Flux/Helm status still work; the operator and in-pod namespace inventories agree
gitops No unexpected Kustomization or HelmRelease suspension, and nothing unhealthy
forge No forge credential in the worker environment or home; the forge identity is not an administrator; repository authority is pull/push only; an administrative route is live-refused; clone/fetch still works
nodes The dedicated hermes-agent account is locked, has the expected uid/gid/home/shell, joins no supplementary group, has no sudoers fragment, holds exactly one authorized key; K3s credentials, K3s state, kubelet state, and the container runtime are root-only; the audit covers every node
build The image builder service account mounts no token; the build pod is unprivileged with exactly CHOWN, FOWNER, DAC_OVERRIDE, SETGID, SETUID; the Harbor immutable-tag job succeeded; the running image is digest-pinned
reliability The finalization/replay/session patches are running in the pod; the regression suites are present on main
pool Three ready workers, on distinct nodes, with distinct volumes, mounting no API token and none of the coordinator's claims
surfaces Kanban activity and durable sessions are visible and render without blank rows; the agent and chat session patches are deployed; Telegram topic state is on durable storage
ephemeral Feature-branch push, draft pull request, protected-branch refusal, and verified cleanup — NOT_RUN unless armed

Site configuration

A few checks are emitted only when the operator declares what to expect, because a names_present assertion over an empty expectation would pass on any workload at all — which reads as coverage without being any:

Flag Enables
--pool-worker-env NAME (repeatable) pool.assignment-safety-knobs-are-configured
--chat-config-revision REV surfaces.chat-runs-the-telegram-topic-revision
--expect-telegram-sessions with --chat-ordinal N surfaces.chat-telegram-sessions-are-continuous

--node-count must be the real node count; the default of 1 makes the fleet-coverage check vacuous.

Catalog drift is a NO_GO, not a pass

If a workload, field, or manifest is renamed, the affected checks report NOT_RUN because the evidence they name is no longer there. That is the intended behaviour: the release stops until either the platform or the catalog is corrected. Do not "fix" a NOT_RUN by deleting the check.

Ephemeral mutation mode

Proving that a worker can still open a draft pull request means actually opening one. That is the only part of the harness that writes anything, and it is a separate mode:

scripts/ops/hermes_handoff_acceptance.py \
  --context atlas-operator \
  --arm-ephemeral-push \
  --confirm "ARM EPHEMERAL HERMES HANDOFF PUSH" \
  --ephemeral-token acceptance-20260817a \
  --output build/hermes-handoff-acceptance-armed.json
  • The confirmation phrase must match exactly.
  • --ephemeral-token is 864 lowercase alphanumeric/dash characters and must be unique per run; the ref is always ephemeral/hermes-handoff-acceptance/<token>.
  • Preflight runs before any network call: wrong phrase, wrong repository, malformed token, or a protected push target all abort with arming refused and a NO_GO report. main, master, HEAD, develop, release, production, stable, trunk, prod, and default are refused by name (case- and refs/heads/-insensitive), and anything that is not an ephemeral acceptance ref is refused by pattern.
  • The guard is exercised on every armed run by ephemeral.protected-branch-refusal, rather than trusting that it was checked once.
  • The push target is guarded; the pull-request base is not. Opening a draft pull request against main writes nothing to main. The draft title always starts with WIP: .
  • Cleanup closes the pull request, deletes the ref, and then re-reads both to confirm they are gone. ephemeral.cleanup-verified is mandatory: a half-removed branch fails the run rather than passing it. Cleanup runs even when an earlier step failed.
  • git push --force, --force-with-lease, --mirror, --all, and --tags are refused even in armed mode.

A default run reports all four ephemeral checks NOT_RUN and non-mandatory, so a read-only sweep can still reach GO.

Release order

Baseline, already merged and deployed

PR #13 (hermes: harden worker isolation and blocked-task semantics) merged as ab346f55509d584e457fe26cf90be3078f7a375c. Every check below assumes it is present; baseline.origin-main-descends-merged-work asserts it. Override with --baseline-commit only when a later merge legitimately replaces it.

Pending work, observed 2026-08-17T10:07Z

These heads move. The harness re-fetches them at run time via baseline.pending-pull-requests-recorded; the table is the snapshot this document was written against, not an input.

PR Branch Head at observation State Delivers
#14 feature/hermes-safe-gitea-pr-client 3cd8a17e772978fb554d88dd1c59777786be3991 draft Bounded forge authority, observer RBAC (removes hermes-agent-cluster-admin), node account hardening
#15 fix/hermes-result-decomposition-reliability 7609cd77e87a64e332b2f0d670c1d7a4f62b3d9e draft Result preservation, duplicate-fanout prevention, execution-safety patch
#16 feature/hermes-unprivileged-image-builder 5ab0eba619779b3dfed2eb09d5c02f0a9c92dd0a ready Daemonless Kaniko build lane, tokenless builder, Harbor immutable-tag rule
#17 feature/hermes-three-lane-placement 48cbe13ee50ce3fcb07cea3fe8d088cfed349e0c draft Three concurrent CLI lanes on the larger ARM64 nodes
#18 feature/hermes-distributed-worker-pool 20002527512235b29054f3636b02418168373009 draft Three-node fenced execution pool with coordinator-only state ownership

Base at observation: 30259b526be5b2e226620aeebd9fb5f57dc88b7d.

Merge order and why

  1. #14 first. It is the only change that reduces authority: it deletes services/hermes/agent-rbac.yaml (the cluster-admin binding) and replaces it with the namespaced/cluster observer roles, and it hardens the node account. Merging it last would mean every earlier acceptance run measured a cluster-admin agent. Every access-denied and nodes check depends on it.
  2. #16 second. The image lane must exist before anything that needs a new agent image is rolled out. It also touches testing/quality_contract.json, so merging it early keeps later rebases small. build.* depends on it.
  3. #15 third. It changes dockerfiles/Dockerfile.hermes-agent, which #16 also touches — take #16's shape and rebase #15 onto it. reliability.* depends on it.
  4. #17 fourth. Placement only; it edits services/hermes/agent-deployment.yaml and testing/tests/test_hermes_cli_lanes.py, both of which #14, #15, and #18 also touch. Merging it after them avoids resolving the same conflict twice.
  5. #18 last. The pool depends on the bounded forge client from #14, the execution-safety patch from #15, and the agent image from #16. pool.* depends on it.

Overlapping files to expect (rebase, do not merge blind): services/hermes/agent-deployment.yaml (#12, #14, #16, #17, #18), services/hermes/kustomization.yaml (#14, #16, #18), services/hermes/scripts/stage_runtime_access.py (#14, #16, #18), services/vault/scripts/vault_k8s_auth_configure.sh (#14, #16, #18), dockerfiles/Dockerfile.hermes-agent (#15, #16), testing/tests/test_hermes_cli_lanes.py (#14, #15, #17, #18), testing/tests/test_hermes_runtime_access.py (#16, #18).

PR #12 (AI usage exporter) is independent and can merge at any point; it touches services/hermes/agent-deployment.yaml, so land it before #17 if convenient.

Image build and Flux rollout

  1. Merge in the order above; observe the default-branch Jenkins build to a terminal result after each merge (/opt/coordinator/jenkins_build_evidence.py titan-iac --branch main --wait).
  2. After #16 is merged, run the hermes-agent-image pipeline. It publishes an immutable git-<sha>-build-<n> tag and emits build/hermes-agent-release/hermes-image-update.patch.
  3. Apply that patch to services/hermes/kustomization.yaml on a branch, review, and merge. The digest is what pins the deployment; build.deployed-image-is-digest-pinned asserts it.
  4. Let Flux reconcile. Confirm with flux get kustomizations -A that hermes and hermes-chat are Ready, then wait for the rollout to settle.
  5. Re-run the harness. Do not run it mid-rollout: gitops.* tolerates an in-flight reconcile (Reconciling=True with Ready=False) but a half-rolled deployment will legitimately fail the identity, routing, and surfaces groups.

Rollback points

After Roll back by Restores
#14 merge Revert the merge commit; Flux restores hermes-agent-cluster-admin Cluster-admin agent, previous node account state
#16 merge Revert the merge; the previously pinned digest is unchanged in kustomization.yaml Previous build lane; published immutable tags are not deleted
Image digest bump Revert the kustomization.yaml commit to the prior digest Previous agent image; Harbor immutability means the old tag is still exactly what it was
#15 merge Revert the merge; the init-container patch disappears on the next rollout Previous finalization/replay behaviour
#17 merge Revert the merge Previous lane concurrency and node placement
#18 merge Revert the merge; the StatefulSet and its PVCs are removed by Flux pruning Single-coordinator execution

Every rollback is a Git revert reconciled by Flux. Do not roll back by direct kubectl edit; the next reconcile would undo it.

Go / no-go checklist

Run in order. Stop at the first item that does not hold.

  1. origin/main is ab346f55509d584e457fe26cf90be3078f7a375c or a descendant.
  2. PRs #14, #15, #16, #17, #18 are all merged into main, in that order, each with a green default-branch build.
  3. The agent image has been built by the #16 lane, is pinned by digest in services/hermes/kustomization.yaml, and Flux has finished reconciling.
  4. The harness is run from an external operator kubeconfig, not from inside the Hermes owner pod, with --node-count set to the real node count and every deliberate Flux suspension declared via --expected-suspension.
  5. The default read-only run reports GO — no mandatory FAIL, no mandatory NOT_RUN, no harness_errors.
  6. The armed run reports PASS for all four ephemeral.* checks, including ephemeral.cleanup-verified, and the ephemeral ref and pull request are confirmed gone.
  7. The JSON report is archived with the exact origin/main commit and the recorded pull-request heads from baseline.pending-pull-requests-recorded.

Only then is the handoff releasable.

Known limits

Stated so nobody mistakes a proxy for the thing itself.

  • Merge, approve, and close authority is asserted, not exercised. The harness never attempts a merge. It proves the forge identity holds permissions.admin: false and is live-refused on an administrative route; the route-level allowlist is covered by PR #14's own regression suite.
  • Attach and port-forward have no side-effect-free live attempt. Their authorization reviews ride alongside a real, refused control-plane write in the same check, and pod exec — the one subresource with a harmless real attempt — has its own check.
  • TokenRequest is asserted by review. A successful live attempt would mint a real credential, so the check pairs the serviceaccounts/token review with a refused serviceaccount create in the same RBAC family.
  • Branch protection is not read. The harness identity is not a repository administrator, so GET /branch_protections is refused — which is itself recorded as evidence. Protected-push refusal is guaranteed by the harness's own preflight, exercised on every armed run, not by reading the forge's config.
  • Stale/duplicate assignment safety is asserted as deployed configuration. The behaviour itself is covered by the pool's regression suite; the harness checks that the workers carry the configuration and that the coordinator keeps sole ownership of its claims.
  • Telegram session continuity is per tenant and depends on there having been Telegram traffic, so it is off by default. The always-on check is that topic and tenant state live on a persistent claim rather than in the pod.
  • The routing window is a tail, 600 records by default. A property that did not occur inside that window is reported as missing rather than absent; widen the window or wait for traffic rather than removing the assertion.

Tests

python3 -m pytest testing/tests/ -k handoff
python3 -m coverage run --source=scripts/ops --include="*/hermes_handoff_*.py" \
  -m pytest testing/tests/ -k handoff && python3 -m coverage report

The suite covers parsing, redaction, timeouts and deadlines, partial failure, zero state, dual-vantage mismatch, mandatory skips, catalog structure, and ephemeral arming and cleanup — including the cases where cleanup cannot be verified.