atlas-iac/docs/hermes_full_handoff_acceptance.md

324 lines
19 KiB
Markdown
Raw Normal View History

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
# 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.