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.
19 KiB
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-uidare rejected by policy for any operator-side command. They are reachable only as the inner command of akubectl exec, where the identity is testing its own refusal to impersonate rather than borrowing authority.kubectl auth can-i --asis 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 whoamiand 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 isNO_GOwiththe 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, andscalerequire a dry-run flag.git pushand every non-GETforge 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, andwgetare 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 -cis reachable only through the reviewed, parameter-validated templates inSHELL_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_fieldswalks the rendered payload; if anything still looks credential-shaped the report is replaced with aNO_GOstub 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-tokenis 8–64 lowercase alphanumeric/dash characters and must be unique per run; the ref is alwaysephemeral/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 refusedand aNO_GOreport.main,master,HEAD,develop,release,production,stable,trunk,prod, anddefaultare refused by name (case- andrefs/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
mainwrites nothing tomain. The draft title always starts withWIP:. - Cleanup closes the pull request, deletes the ref, and then re-reads both to
confirm they are gone.
ephemeral.cleanup-verifiedis 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--tagsare 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
- #14 first. It is the only change that reduces authority: it deletes
services/hermes/agent-rbac.yaml(thecluster-adminbinding) 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. Everyaccess-deniedandnodescheck depends on it. - #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. - #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. - #17 fourth. Placement only; it edits
services/hermes/agent-deployment.yamlandtesting/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. - #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
- 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). - After #16 is merged, run the
hermes-agent-imagepipeline. It publishes an immutablegit-<sha>-build-<n>tag and emitsbuild/hermes-agent-release/hermes-image-update.patch. - Apply that patch to
services/hermes/kustomization.yamlon a branch, review, and merge. The digest is what pins the deployment;build.deployed-image-is-digest-pinnedasserts it. - Let Flux reconcile. Confirm with
flux get kustomizations -Athathermesandhermes-chatareReady, then wait for the rollout to settle. - Re-run the harness. Do not run it mid-rollout:
gitops.*tolerates an in-flight reconcile (Reconciling=TruewithReady=False) but a half-rolled deployment will legitimately fail theidentity,routing, andsurfacesgroups.
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.
origin/mainisab346f55509d584e457fe26cf90be3078f7a375cor a descendant.- PRs #14, #15, #16, #17, #18 are all merged into
main, in that order, each with a green default-branch build. - The agent image has been built by the #16 lane, is pinned by digest in
services/hermes/kustomization.yaml, and Flux has finished reconciling. - The harness is run from an external operator kubeconfig, not from inside
the Hermes owner pod, with
--node-countset to the real node count and every deliberate Flux suspension declared via--expected-suspension. - The default read-only run reports
GO— no mandatoryFAIL, no mandatoryNOT_RUN, noharness_errors. - The armed run reports
PASSfor all fourephemeral.*checks, includingephemeral.cleanup-verified, and the ephemeral ref and pull request are confirmed gone. - The JSON report is archived with the exact
origin/maincommit and the recorded pull-request heads frombaseline.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: falseand 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/tokenreview with a refusedserviceaccountcreate in the same RBAC family. - Branch protection is not read. The harness identity is not a repository
administrator, so
GET /branch_protectionsis 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.