diff --git a/dockerfiles/Dockerfile.hermes-webui b/dockerfiles/Dockerfile.hermes-webui
index a9f22721..52803505 100644
--- a/dockerfiles/Dockerfile.hermes-webui
+++ b/dockerfiles/Dockerfile.hermes-webui
@@ -111,7 +111,7 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
&& grep -Fq "capability.provider!=='local_command'" /opt/hermes-webui/static/atlas-voice.js \
&& grep -Fq 'data-priority="maximum"' /opt/hermes-webui/static/index.html \
&& grep -Fq 'routing_priority:priority' /opt/hermes-webui/static/atlas-router.js \
- && grep -Fq "'atlas/auto/fast':'AUTO · Fast'" /opt/hermes-webui/static/atlas-router.js \
+ && grep -Fq "'atlas/auto/fast':'Automatic · Fast'" /opt/hermes-webui/static/atlas-router.js \
&& grep -Fq 'explicit_reasoning_effort' /opt/hermes-webui/api/gateway_chat.py \
&& /opt/hermes/.venv/bin/python -m py_compile \
/opt/hermes-webui/api/routes.py \
diff --git a/dockerfiles/hermes-webui-router-patch.py b/dockerfiles/hermes-webui-router-patch.py
index 779a9c5d..8bae3eb1 100644
--- a/dockerfiles/hermes-webui-router-patch.py
+++ b/dockerfiles/hermes-webui-router-patch.py
@@ -23,7 +23,7 @@ replace_once(
'''
\n''',
'''
diff --git a/dockerfiles/hermes-webui-router.js b/dockerfiles/hermes-webui-router.js
index 471c049c..28c2d33c 100644
--- a/dockerfiles/hermes-webui-router.js
+++ b/dockerfiles/hermes-webui-router.js
@@ -2,14 +2,14 @@
'use strict';
const PRIORITIES=new Set(['auto','fast','balanced','deep','maximum']);
- const LABELS={auto:'AUTO',fast:'FAST',balanced:'BALANCED',deep:'DEEP',maximum:'MAXIMUM'};
+ const LABELS={auto:'Automatic',fast:'Fast',balanced:'Balanced',deep:'Deep',maximum:'Maximum'};
const STORAGE_KEY='atlas.hermes.routing-priority';
const EXPLICIT_EFFORT_KEY='atlas.hermes.explicit-reasoning';
const ROUTE_LABELS={
- 'atlas/auto/fast':'AUTO · Fast',
- 'atlas/auto/balanced':'AUTO · Balanced',
- 'atlas/auto/deep':'AUTO · Deep',
- 'atlas/auto/maximum':'AUTO · Maximum',
+ 'atlas/auto/fast':'Automatic · Fast',
+ 'atlas/auto/balanced':'Automatic · Balanced',
+ 'atlas/auto/deep':'Automatic · Deep',
+ 'atlas/auto/maximum':'Automatic · Maximum',
'atlas/manual/codex/luna':'Codex · Luna',
'atlas/manual/codex/terra':'Codex · Terra',
'atlas/manual/codex/sol':'Codex · SOL',
diff --git a/docs/hermes_full_handoff_acceptance.md b/docs/hermes_full_handoff_acceptance.md
new file mode 100644
index 00000000..23efe79f
--- /dev/null
+++ b/docs/hermes_full_handoff_acceptance.md
@@ -0,0 +1,341 @@
+# Hermes full-handoff acceptance and release runbook
+
+`scripts/ops/hermes_handoff_acceptance.py` is a fail-closed release gate. It
+contains 63 checks in 15 groups. An empty catalog, an unknown result status, a
+mandatory `FAIL` or `NOT_RUN`, a harness error, or incomplete evidence makes the
+decision `NO_GO`. The four ephemeral checks are non-mandatory `NOT_RUN` in the
+default mode and are replaced with mandatory results only in armed mode.
+
+This runbook describes evidence collection. It does not authorize a merge,
+publish, deployment, Flux reconcile, credential read, or live cluster mutation.
+Human review of PR #19 and of the resulting report is required.
+
+## Required release inputs
+
+Every value that can go stale must be supplied explicitly. Resolve `main`, PR
+heads, the image digest, build source SHA, deployment revision, and chat config
+revision from their authoritative read-only sources immediately before the run.
+Do not copy the snapshot in the dependency section into a later run.
+
+```sh
+scripts/ops/hermes_handoff_acceptance.py \
+ --context atlas-operator \
+ --remote-main-sha "$REMOTE_MAIN_SHA" \
+ --reviewed-head-sha "$REVIEWED_PR19_HEAD" \
+ --agent-image "$AGENT_GIT_SHA_BUILD_TAG_AT_SHA256" \
+ --build-sha "$IMAGE_BUILD_SOURCE_SHA" \
+ --deployment-revision "$RUNNING_DEPLOYMENT_REVISION" \
+ --chat-config-revision "$RUNNING_CHAT_CONFIG_REVISION" \
+ --dependency-head "12=$PR12_HEAD" \
+ --dependency-head "14=$PR14_HEAD" \
+ --dependency-head "15=$PR15_HEAD" \
+ --dependency-head "16=$PR16_HEAD" \
+ --dependency-head "17=$PR17_HEAD" \
+ --dependency-head "18=$PR18_HEAD" \
+ --dependency-head "20=$PR20_HEAD" \
+ --output build/hermes-handoff-acceptance.json
+```
+
+The fixed topology is three pool workers on three distinct nodes. `--node-count`
+and `--pool-replicas` cannot lower that requirement. Pool node/ordinal assignment,
+Telegram session continuity, and the chat revision are mandatory. Deliberate Flux
+suspensions must be named with repeatable `--expected-suspension` arguments.
+
+Exit status is `0` only for `GO`, `1` for `NO_GO`, and `2` for invalid execution
+bounds. JSON is written to `--output` or stdout; the human summary goes to stderr
+unless `--json-only` is passed.
+
+## Structural safety boundary
+
+The catalog is validated before a `Runner` exists. Invalid inputs, an empty or
+unsafe catalog, and an invalid armed preflight therefore create no subprocess.
+The command policy is checked both before and after executable resolution.
+
+Default mode permits a narrow read grammar:
+
+- Kubernetes reads use `name` or reviewed JSONPath projections. Secret resources,
+ raw API reads, full JSON/YAML, `describe`, `cluster-info dump`, credential-bearing
+ projections, `auth reconcile`, config operations, all mutations, and every
+ dry-run mutation trick are rejected before spawn.
+- Git is read-only. `fetch`, configuration/helper overrides, alternate worktrees,
+ external diff helpers, and push are rejected.
+- Gitea paths are pinned to `atlas/titan-iac` on an exact segment boundary, so a
+ look-alike repository such as `atlas/titan-iac-evil` is refused, and any
+ relative segment — including a percent-encoded `%2e%2e` — is rejected before
+ the request is built.
+- Shell commands must be rendered from frozen templates, use fixed executable
+ paths, and cannot name service-account, Vault, runtime-access, SSH, or other
+ credential roots.
+- Impersonation is structurally impossible. `--as`, `--as-group`, and `--as-uid`
+ are refused for every argv, in every mode, from every vantage, and the inner
+ command of a `kubectl exec` is re-checked under the same rule rather than being
+ granted an exemption. `validate_catalog` refuses an impersonating step from any
+ vantage, not only the operator. A future audited self-probe that genuinely needs
+ impersonation is a reviewed code change, not a configuration flag.
+- The binary allowlist is exactly the set with pinned release digests:
+ `kubectl`, `git`, `sh`, `hermes`, and the Gitea client. `flux` and `helm` are
+ not on it; Flux and Helm status is read through `kubectl` instead, so no
+ allowlisted binary can be admitted that the executor would refuse to attest.
+- `PATH` is replaced with a fixed path. Every outer executable is resolved to an
+ allowed real path, checked for unsafe write mode, and compared with a pinned
+ SHA-256 digest. A caller-authored `kubectl`, `git`, or helper cannot fabricate
+ evidence.
+
+The executor uses a new process group, drains stdout and stderr concurrently,
+and retains at most the configured bytes while the child is running. A monotonic
+absolute timeout terminates and reaps the group, including descendants that keep
+pipes open. Timeout, deadline, and output-byte values reject booleans,
+zero/negative values, non-finite numbers, and values outside fixed bounds. There
+is no concurrency knob: `run_catalog` is strictly sequential and says so rather
+than accepting a bound it would ignore.
+Truncation discards the affected stream and makes every evaluator `NOT_RUN`.
+Bulk-evidence steps have their bytes replaced before they are recorded, but they
+keep the resolved executable path and its attested SHA-256, so withholding output
+never withholds the provenance of the binary that produced it.
+
+Credential screening runs before truncation. Command, output, error, identity,
+reason, evidence, JSON, and human-summary fields are screened recursively,
+including mapping keys and short/numeric sensitive values. The complete rendered
+report is screened again; a remaining credential shape is replaced by a bounded
+`NO_GO` stub rather than being written. No credential prefix is retained at a
+truncation boundary.
+
+## Vantages and evidence
+
+The operator must use an external read-only kubeconfig. The self probe runs in
+the actual Hermes agent pod. Both identities are obtained with
+`kubectl auth whoami`; equal or missing principals make the run `NO_GO`. Required
+Switchyard and chat/Telegram pod vantages must also resolve. Node coverage is
+derived from all daemonset pod node names, not one selected pod.
+
+Dangerous Kubernetes permissions are checked only with side-effect-free
+`kubectl auth can-i` reviews. The default catalog never constructs a Secret,
+TokenRequest, mutation, attach, port-forward, or arbitrary exec request. The one
+forge administrative denial check performs a read-only GET and accepts only an
+explicit authorization refusal; generic `404`, `not found`, and `no route` text
+are not denial evidence.
+
+An absence is only evidence when the probe observed something. Both name rules
+resolve their step through one guard, so a step that exits `0` with no lines — or
+with only separators — is `NOT_RUN`, never `PASS`. This is reachable in practice
+two ways, and both are pinned by regressions: a POSIX pipeline reports the status
+of its *last* stage, so a missing upstream binary in the frozen `env_names`
+template exits `0` with no bytes; and `kubectl -o jsonpath` exits `0` when the
+structure it walks has drifted, whether a parent field, a leaf field, or a
+container filter. Because all five absence checks are mandatory, silence costs
+the release its `GO`. The pool claim projection emits one `
=` line
+per template volume so a volume without a PVC still counts as an observation.
+
+The strict evaluators reject duplicate-key JSON, raw control characters,
+`NaN`/infinity, boolean/number equality coercion, future timestamps, stale
+provider evidence, and every malformed routing or Flux row. Routing freshness is
+required separately for each provider, lane, and effort. Flux objects must be
+non-suspended, current at `observedGeneration`, and explicitly `Ready=True`;
+unknown or reconciling state is not healthy.
+
+`release.exact-lineage-is-running` atomically binds:
+
+- fixed remote `main` SHA and local `origin/main`;
+- open draft PR #19, base `main`, existing feature ref, and exact reviewed head;
+- the exact `:git--build-@sha256:` image identity,
+ with the source SHA equal to the release `main` SHA;
+- current Deployment generation and revision;
+- the one active ReplicaSet revision/hash; every Ready pod image and imageID;
+- the Flux-applied `main` revision.
+
+The pool evaluator requires exactly ordinals 0, 1, and 2, all Ready, on three
+distinct nodes and claims, with service-account token automount disabled. The
+chat checks always require durable Telegram sessions and the exact running chat
+configuration revision.
+
+## Armed ephemeral mode
+
+Armed mode is the only write path and runs only after the complete default
+catalog reports `GO`.
+
+```sh
+scripts/ops/hermes_handoff_acceptance.py \
+ \
+ --arm-ephemeral-push \
+ --confirm "ARM EPHEMERAL HERMES HANDOFF PUSH" \
+ --ephemeral-token acceptance-20260817a \
+ --output build/hermes-handoff-acceptance-armed.json
+```
+
+Before any runner, network request, or cluster vantage is created, preflight
+binds the working directory to the existing linked PR #19 worktree, the exact
+feature branch and reviewed local HEAD, the fixed HTTPS Atlas origin, base
+`main`, confirmation phrase, and one unique ephemeral ref. The command policy is
+then pinned to that exact ref. It allows only its push, one exact draft-create
+payload, cleanup of the exactly discovered PR number, and deletion of that ref.
+
+The run reserves at least 300 seconds (up to 6000) for cleanup. It verifies the
+pushed SHA, then identifies the pull request from two independent sources: the
+create response itself, and the pull index read page by page to its last page
+(50 per page, 20 pages maximum) rather than a single truncated first page. Either
+source alone names a pull request this run definitely made, because preflight
+proved the ephemeral ref did not exist. Every number found from either source is
+registered and closed, so a failed or unusable index can no longer leave an
+orphan open. A `GO` still requires exactly one open draft with the expected base,
+head ref, base SHA, and head SHA, and the two sources must agree.
+
+Cleanup requires successful PR close, successful branch deletion, a successful
+empty `ls-remote`, and a strict re-read of the same closed PR. Malformed or
+partial cleanup is `FAIL`; it is never inferred from an error. When anything is
+uncertain the `draft-pull-request` evidence carries `residue_ref` and an exact
+`manual_cleanup` command list — the `PATCH .../pulls/ --field state=closed`
+calls for every number found, the `DELETE .../branches/[` call, and, when no
+number could be determined at all, the exact ref to sweep the index for by hand.
+
+Force, force-with-lease, fan-out, protected refs, other repositories, other PR
+numbers, arbitrary PATCH payloads, and repository administration remain denied.
+
+## Dependency, merge, and conflict graph
+
+Authoritative snapshot observed 2026-08-17 after refreshing Gitea and Git against
+remote `main` `d8f2d818b9a552ea6c2d7fe86554be829bd5ffff`:
+
+| PR | Head SHA | Draft | Base | Role |
+| --- | --- | --- | --- | --- |
+| #12 | `ed43dbaa7f094e1841aa760862e80015deb9d80c` | no | `main` | AI usage exporter repair |
+| #14 | `8c6e3acdacb223f68b1716f0a74bd543e18890e6` | yes | `main` | bounded forge client and least-privilege RBAC |
+| #15 | `73fefbb5d92ef07371a9a997f22d848acd2d06ed` | yes | `main` | result-decomposition reliability |
+| #16 | `e8c26ecf85a0c802d378fffffb98b7df43b78242` | no | `main` | unprivileged image builder |
+| #17 | `48cbe13ee50ce3fcb07cea3fe8d088cfed349e0c` | yes | `main` | three-lane placement |
+| #18 | `689bcb6e48414854447988ba174143a307d308b9` | yes | `main` | distributed three-worker pool |
+| #19 | `c808baff40a12a90f43c2e5e39b94d139f88ea29` | yes | `main` | head this repair replaces on the same branch |
+| #20 | `a242dcc786576ae1a18000cb4941836ff610dff2` | yes | `main` | CLI process-group reaping |
+
+The repaired #19 SHA cannot be embedded in its own commit without changing that
+SHA. Resolve it from Gitea after push and require it as `--reviewed-head-sha`;
+the PR handoff records the exact value. All eight heads in this table, including
+the repaired #19, merge cleanly with `main` individually. Pairwise
+`git merge-tree --write-tree` over all 28 combinations found conflicts for:
+#12+#17, #12+#18, #12+#19, #14+#15, #14+#16, #14+#17, #14+#19, #14+#20, #15+#16,
+#15+#17, #15+#19, #16+#19, #16+#20, #17+#18, and #17+#20. Gitea's
+`mergeable=false` on a draft is not treated as conflict evidence.
+
+This graph differs from the previous snapshot in exactly two ways. #12 and #18
+both advanced, which adds #12+#18, #17+#18, and #12+#19. And #19 no longer
+duplicates the repo-wide hygiene legacy-exception mechanism that #18 also
+carries, which makes #18+#19 clean. Recompute before every integration step;
+nothing here is durable.
+
+Use this dependency order, refreshing every head and re-running merge-tree at
+each step:
+
+1. #14 first so the release never restores or prolongs cluster-admin authority.
+2. #16, then #15, resolving their Dockerfile and quality-contract changes
+ additively. Never take one PR's quality contract wholesale.
+3. #20 after #15; it depends on the CLI lifecycle work and must be rebased across
+ the current #14/#16 contract and helper changes.
+4. #12 before #17, resolving their Deployment overlap once.
+5. #17, then #18, after the authority, image, reliability, and reaping work.
+6. #19 last as the integration/acceptance change, only after #12/#14-#18/#20 are
+ merged and their exact supplied heads are ancestors of refreshed `main`.
+
+An exact post-repair merge-tree check reports #19 clean with current `main`, #17,
+#18, and #20, and conflicting with #12, #14, #15, and #16 on these files:
+
+| Pair | Overlapping files |
+| --- | --- |
+| #12+#19 | `testing/tests/test_hermes_auto_router.py` |
+| #14+#19 | `testing/quality_contract.json`, `testing/quality_coverage.py`, `testing/tests/test_quality_coverage_helpers.py` |
+| #15+#19 | `testing/tests/conftest.py` |
+| #16+#19 | `testing/quality_contract.json` |
+
+Every one of those overlaps is additive on the #19 side. Resolve the contract by
+union — take every PR's managed modules, lint paths, tracked files, and branch
+tracked files, and never take one PR's quality contract wholesale. #19 adds two
+fixture-isolation fixtures to `conftest.py` and two lines to the auto-router test;
+keep both sides. Preserve branch coverage, every tracked module, fixture
+isolation, and all desired-state changes during conflict resolution.
+
+Recompute this graph from the Gitea API immediately before integration; do not
+infer conflict status from Gitea's draft `mergeable` field.
+
+The #16/#19 integration must keep both the immutable digest and the source tag
+(`newTag: git--build-` plus the digest) in rendered image identity.
+Digest-only rendering loses the attested build-source link and therefore
+correctly remains `NO_GO` under the exact-lineage evaluator.
+
+## Safe rollback guidance
+
+Every rollback is a reviewed forward fix or selective Git revert followed by
+the normal Flux workflow. Never restore `hermes-agent-cluster-admin`, even when
+reverting unrelated portions of #14. If a rollback would widen RBAC, stop and
+write a forward least-privilege fix instead.
+
+Reverting #18 removes the StatefulSet desired state, but its
+`volumeClaimTemplates` PVCs are retained by the Kubernetes default because no
+delete retention policy is set. Do not claim Flux pruning deletes those PVCs.
+Any PVC deletion is a separate, explicit, human-reviewed data-destruction task
+with exact targets and recovery implications.
+
+Image rollback means a reviewed commit restoring a previously attested digest;
+never retag an immutable image. Do not use direct `kubectl` edits as a durable
+rollback.
+
+## Quality and release evidence
+
+The canonical quality contract explicitly manages, lints, and tracks all 16
+`scripts/ops/hermes_handoff_*.py` modules. It enforces at least 95% line and
+branch coverage per tracked file and the 500-physical-line source cap. Contract
+tests derive the module set so a future file cannot silently escape the gate.
+This change is purely additive to the contract: it registers its own modules and
+changes no repo-wide hygiene semantics.
+
+The gate's `hygiene` step is red on `main` and stays red here. Exactly four
+pre-existing test files exceed the 500-line cap —
+`scripts/tests/test_dashboards_render_atlas.py` (516),
+`testing/tests/test_hermes_chat_quality.py` (2242),
+`testing/tests/test_hermes_cli_lanes.py` (1971), and
+`testing/tests/test_hermes_coordinator.py` (510). All four are byte-identical at
+merge-base `30259b52` and at current `origin/main`, none is touched by this
+change, and the same four are the only issues a pristine `main` reports. Splitting
+them, or grandfathering them, is a repo-wide contract decision that belongs to the
+canonical contract change carried by PR #14/#15, not to this acceptance harness.
+Every other gate step — docs, smell, unit, coverage — is green, and every file
+this change adds or edits is under the cap.
+
+```sh
+python3 -m testing.quality_gate --profile local --build-dir build
+python3 -m testing.quality_handoff_mutation
+python3 -m ruff check --select F,B,SIM,C4,UP --ignore B017,UP015,UP035 \
+ scripts/ops/hermes_handoff_*.py testing/quality_coverage.py \
+ testing/quality_handoff_mutation.py testing/tests/test_hermes_handoff_*.py
+python3 -m ruff format --check \
+ scripts/ops/hermes_handoff_*.py testing/quality_coverage.py \
+ testing/quality_handoff_mutation.py testing/tests/test_hermes_handoff_*.py
+python3 -m compileall -q scripts/ops testing
+kustomize build services/hermes | kubectl apply --dry-run=client --validate=false -f -
+git diff --check origin/main...HEAD
+```
+
+The format scope names the files this change owns. A wider `testing/quality_*.py`
+glob picks up `testing/quality_gate.py`, which is unmodified here and not
+format-clean, so the documented command would fail for a reason this change did
+not cause. `ruff format` is evidence, not a gate step: the canonical gate runs
+`ruff check` only.
+
+The deterministic mutation gate must kill every policy, redaction, verdict,
+numeric, truncation, provenance, and cleanup mutant, including one that restores
+the zero-evidence pass, one that restores the bare-prefix repository pin, one
+that stops re-checking a `kubectl exec` inner command, and one that drops binary
+attestation from an unrecorded step. The adversarial suite includes large
+concurrent stdout/stderr, malformed JSON and bytes, zero state, partial failure,
+absolute timeout, descendant-held pipes, malformed cleanup, ambiguous creates,
+and no-subprocess/no-mutation spies. Render/client dry-run, exact conflict checks,
+and bounded diff/full-tree secret scans must also pass.
+
+The provider-health and Gitea-token-sensitive tests use hermetic fixtures. Their
+result must be identical whether synthetic runtime files exist or are absent;
+tests must never read the mounted production token.
+
+## Release decision
+
+The handoff remains `NO_GO` until all dependency heads are merged, the exact
+lineage check passes from an external operator, both default and armed reports
+are complete, the branch and PR cleanup is verified, the canonical and
+adversarial gates are green, and a human has reviewed PR #19 plus the artifacts.
+The harness never merges, deploys, reconciles, publishes, or approves anything.
diff --git a/scripts/ops/hermes_handoff_acceptance.py b/scripts/ops/hermes_handoff_acceptance.py
new file mode 100755
index 00000000..6dbc69d2
--- /dev/null
+++ b/scripts/ops/hermes_handoff_acceptance.py
@@ -0,0 +1,354 @@
+#!/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--build-@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())
diff --git a/scripts/ops/hermes_handoff_arming.py b/scripts/ops/hermes_handoff_arming.py
new file mode 100644
index 00000000..5aa596c4
--- /dev/null
+++ b/scripts/ops/hermes_handoff_arming.py
@@ -0,0 +1,154 @@
+#!/usr/bin/env python3
+"""Arming, provenance, and push-target rules for the armed ephemeral mode.
+
+Nothing here touches the network or spawns a process. It is the half of armed
+mode that must be true before a single command exists: the confirmation phrase,
+the fixed repository and base, the one ephemeral ref grammar, and a local
+worktree whose HEAD and ``origin/main`` are byte-exact with the reviewed release.
+"""
+
+from __future__ import annotations
+
+import configparser
+import re
+from dataclasses import dataclass
+from pathlib import Path
+
+from hermes_handoff_policy import (
+ EXPECTED_BASE,
+ EXPECTED_REMOTE,
+ EXPECTED_REPO,
+ arm_ephemeral_policy,
+)
+
+CONFIRMATION = "ARM EPHEMERAL HERMES HANDOFF PUSH"
+REF_PREFIX = "ephemeral/hermes-handoff-acceptance"
+REF_RE = re.compile(rf"{REF_PREFIX}/[a-z0-9][a-z0-9-]{{7,63}}\Z")
+TOKEN_RE = re.compile(r"[a-z0-9][a-z0-9-]{7,63}\Z")
+SHA_RE = re.compile(r"[0-9a-f]{40}\Z")
+EXPECTED_HEAD_REF = "feature/hermes-full-handoff-acceptance"
+EXPECTED_REMOTE_URL = "https://scm.bstein.dev/atlas/titan-iac.git"
+PROTECTED_NAMES = frozenset(
+ {
+ "default",
+ "develop",
+ "head",
+ "main",
+ "master",
+ "prod",
+ "production",
+ "release",
+ "stable",
+ "trunk",
+ }
+)
+
+
+class ArmingError(ValueError):
+ """Raised when an ephemeral run is not safe to start."""
+
+
+@dataclass(frozen=True)
+class ArmRequest:
+ """Every fixed and caller-confirmed input to one armed run."""
+
+ repo: str
+ remote: str
+ token: str
+ confirmation: str
+ base: str = EXPECTED_BASE
+ expected_head: str = ""
+ expected_base_sha: str = ""
+
+ @property
+ def ref(self) -> str:
+ return f"{REF_PREFIX}/{self.token}"
+
+
+def normalise_branch(name: str) -> str:
+ """Return a branch name stripped of ref decoration, for comparison."""
+ return name.strip().removeprefix("refs/heads/").strip("/").lower()
+
+
+def assert_push_target_allowed(ref: str) -> None:
+ """Raise unless ``ref`` is the one ephemeral branch grammar."""
+ if normalise_branch(ref) in PROTECTED_NAMES:
+ raise ArmingError(f"refusing to push to protected branch {ref!r}")
+ if not REF_RE.fullmatch(ref):
+ raise ArmingError(f"push target {ref!r} is not an ephemeral acceptance ref")
+
+
+def bounded_read(path: Path, limit: int = 1024 * 1024) -> str:
+ data = path.read_bytes()
+ if not data or len(data) > limit:
+ raise ArmingError(f"local Git metadata is empty or exceeds {limit} bytes")
+ return data.decode("utf-8", "strict")
+
+
+def _attest_worktree(root: Path, expected_head: str, expected_base_sha: str) -> None:
+ """Bind cwd, common Git metadata, branch, remote, and exact local HEAD."""
+ resolved = root.resolve(strict=True)
+ marker = resolved / ".git"
+ if not marker.is_file():
+ raise ArmingError(
+ "armed mode must run at the root of the existing linked worktree"
+ )
+ line = bounded_read(marker, 4096).strip()
+ if not line.startswith("gitdir: "):
+ raise ArmingError("linked-worktree Git metadata is malformed")
+ gitdir = Path(line.removeprefix("gitdir: ")).resolve(strict=True)
+ common = (gitdir / bounded_read(gitdir / "commondir", 4096).strip()).resolve(
+ strict=True
+ )
+ head = bounded_read(gitdir / "HEAD", 4096).strip()
+ expected_ref = f"refs/heads/{EXPECTED_HEAD_REF}"
+ if head != f"ref: {expected_ref}":
+ raise ArmingError("armed mode must run from the existing PR #19 feature branch")
+ ref_path = common / expected_ref
+ actual_head = bounded_read(ref_path, 4096).strip() if ref_path.is_file() else ""
+ if actual_head != expected_head:
+ raise ArmingError(
+ "local worktree HEAD does not match the exact reviewed PR head"
+ )
+ remote_ref = common / "refs" / "remotes" / EXPECTED_REMOTE / EXPECTED_BASE
+ remote_main = bounded_read(remote_ref, 4096).strip() if remote_ref.is_file() else ""
+ if remote_main != expected_base_sha:
+ raise ArmingError("local origin/main does not match the exact release base SHA")
+ parser = configparser.RawConfigParser(interpolation=None)
+ try:
+ parser.read_string(bounded_read(common / "config"))
+ remote_url = parser.get(f'remote "{EXPECTED_REMOTE}"', "url")
+ except (configparser.Error, KeyError) as exc:
+ raise ArmingError("local Git remote metadata is malformed") from exc
+ if remote_url != EXPECTED_REMOTE_URL:
+ raise ArmingError("origin is not the fixed Atlas titan-iac HTTPS repository")
+
+
+def preflight(
+ request: ArmRequest, expected_repo: str, worktree: Path | None = None
+) -> None:
+ """Validate arming and local provenance before any subprocess/network call."""
+ if request.confirmation != CONFIRMATION:
+ raise ArmingError("confirmation phrase does not match")
+ if expected_repo != EXPECTED_REPO or request.repo != EXPECTED_REPO:
+ raise ArmingError(
+ "armed repository is not the fixed Atlas titan-iac repository"
+ )
+ if (
+ request.remote != EXPECTED_REMOTE
+ or normalise_branch(request.base) != EXPECTED_BASE
+ ):
+ raise ArmingError("armed remote and pull-request base are fixed to origin/main")
+ if not TOKEN_RE.fullmatch(request.token):
+ raise ArmingError(
+ "ephemeral token must be 8-64 lowercase alphanumeric or dash characters"
+ )
+ if not SHA_RE.fullmatch(request.expected_head) or not SHA_RE.fullmatch(
+ request.expected_base_sha
+ ):
+ raise ArmingError("exact reviewed head and release base SHAs are required")
+ assert_push_target_allowed(request.ref)
+ _attest_worktree(
+ worktree or Path.cwd(), request.expected_head, request.expected_base_sha
+ )
+ arm_ephemeral_policy(request.ref)
diff --git a/scripts/ops/hermes_handoff_catalog.py b/scripts/ops/hermes_handoff_catalog.py
new file mode 100644
index 00000000..59334dec
--- /dev/null
+++ b/scripts/ops/hermes_handoff_catalog.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python3
+"""Declarative acceptance catalog: baseline, identity, routing, scopes, access.
+
+The catalog is data on purpose. A reviewer should be able to read what the
+release is asserted to satisfy without reading any plumbing, and the harness
+should be able to prove structural properties over every entry — both vantages
+represented, no impersonation from outside a pod, and no mutation command of
+any kind — before it runs a single command.
+
+Catalog drift is a NO_GO, not a pass. If a manifest, a field name, or a workload
+is renamed, the affected checks report ``NOT_RUN`` because the evidence they
+name is not there, and the release stops until either the platform or the
+catalog is corrected.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass, field
+from typing import Any
+
+from hermes_handoff_model import ALWAYS, READ, CheckSpec, Step
+from hermes_handoff_policy import projection
+
+OPERATOR = "operator"
+SELF = "self"
+SWITCHYARD = "switchyard"
+NODE = "node"
+CHAT = "chat"
+VANTAGE_NAMES = (OPERATOR, SELF, SWITCHYARD, NODE, CHAT)
+
+# `namegenerationobservedGenerationsuspendconditions`.
+FLUX_PROJECTION = projection(
+ (
+ "jsonpath={range .items[*]}{.metadata.namespace}/{.metadata.name}{'\\t'}"
+ "{.metadata.generation}{'\\t'}{.status.observedGeneration}{'\\t'}{.spec.suspend}{'\\t'}"
+ "{range .status.conditions[*]}{.type}={.status},{end}{'\\n'}{end}"
+ ).replace("'", '"')
+)
+
+ENV_NAME_PROJECTION = projection(
+ (
+ "jsonpath={range .spec.template.spec.containers[*]}{range .env[*]}{.name}{'\\n'}{end}{end}"
+ ).replace("'", '"')
+)
+
+INIT_NAME_PROJECTION = projection(
+ (
+ "jsonpath={range .spec.template.spec.initContainers[*]}{.name}{'\\n'}{end}"
+ ).replace("'", '"')
+)
+
+NODE_NAME_PROJECTION = projection(
+ "jsonpath={range .items[*]}{.spec.nodeName}{'\\n'}{end}".replace("'", '"')
+)
+
+POOL_PROJECTION = projection(
+ (
+ "jsonpath={range .items[*]}{.metadata.name}{'\\t'}{.spec.nodeName}{'\\t'}"
+ "{.status.phase}{'\\t'}{range .status.conditions[?(@.type==\"Ready\")]}{.status}{end}"
+ "{'\\t'}{range .spec.volumes[*]}{.persistentVolumeClaim.claimName}{','}{end}{'\\n'}{end}"
+ ).replace("'", '"')
+)
+
+PROVIDER_API_KEY_NAMES = (
+ "ANTHROPIC_API_KEY",
+ "ANTHROPIC_AUTH_TOKEN",
+ "ANTHROPIC_BASE_URL",
+ "AZURE_OPENAI_API_KEY",
+ "CLAUDE_API_KEY",
+ "CLAUDE_CODE_OAUTH_TOKEN",
+ "GEMINI_API_KEY",
+ "GOOGLE_API_KEY",
+ "OPENAI_API_KEY",
+ "OPENAI_BASE_URL",
+)
+FORGE_CREDENTIAL_NAMES = (
+ "GITEA_PASSWORD",
+ "GITEA_TOKEN",
+ "GITEA_USERNAME",
+ "GIT_PASSWORD",
+)
+
+CODEX_HEALTH = "/opt/data/provider-health/codex.json"
+CLAUDE_HEALTH = "/opt/data/provider-health/claude.json"
+CODEX_HEALTH_FIELDS = "authenticated,transport,state,model,effort,latency_ms,checked_at"
+CLAUDE_HEALTH_FIELDS = (
+ "authenticated,api_provider,auth_method,transport,state,subscription_type,"
+ "model,effort,latency_ms,checked_at"
+)
+ROUTING_LOG = "/var/lib/switchyard/routing.jsonl"
+
+
+@dataclass(frozen=True)
+class Targets:
+ """Runtime names and expected values the catalog is written against.
+
+ Everything a site can legitimately rename lives here so the assertions stay
+ readable and a rename is a configuration change rather than a code change.
+ """
+
+ namespace: str = "hermes"
+ agent_deployment: str = "hermes-agent"
+ agent_container: str = "hermes"
+ chat_statefulset: str = "hermes-chat-tenant"
+ chat_container: str = "hermes"
+ chat_ordinal: int = 0
+ chat_router_deployment: str = "hermes-chat-router"
+ triage_deployment: str = "hermes"
+ triage_container: str = "hermes"
+ switchyard_deployment: str = "hermes-switchyard"
+ switchyard_container: str = "switchyard"
+ node_daemonset: str = "hermes-node-ssh-access"
+ node_container: str = "key-reconciler"
+ pool_statefulset: str = "hermes-execution-worker"
+ pool_replicas: int = 3
+ pool_selector: str = "app=hermes-execution-worker"
+ coordinator_claims: tuple[str, ...] = (
+ "hermes-agent-home",
+ "hermes-agent-workspace",
+ )
+ node_account: str = "hermes-agent"
+ node_account_identity: str = "1200:1200:/home/hermes-agent:/bin/bash"
+ node_count: int = 3
+ repo: str = "atlas/titan-iac"
+ remote: str = "origin"
+ baseline_commit: str = "ab346f55509d584e457fe26cf90be3078f7a375c"
+ dependency_pull_requests: tuple[int, ...] = (12, 14, 15, 16, 17, 18, 20)
+ dependency_heads: tuple[tuple[int, str], ...] = ()
+ remote_main_sha: str = ""
+ reviewed_pr_number: int = 19
+ reviewed_head_ref: str = "feature/hermes-full-handoff-acceptance"
+ reviewed_head_sha: str = ""
+ agent_image: str = ""
+ build_sha: str = ""
+ deployment_revision: str = ""
+ expected_suspensions: tuple[str, ...] = ()
+ harbor_namespace: str = "harbor"
+ harbor_immutability_job: str = "hermes-agent-immutability"
+ builder_namespace: str = "jenkins"
+ builder_serviceaccount: str = "hermes-image-builder"
+ builder_pipeline: str = "ci/Jenkinsfile.hermes-agent-image"
+ builder_capabilities: str = (
+ 'add: ["CHOWN", "FOWNER", "DAC_OVERRIDE", "SETGID", "SETUID"]'
+ )
+ agent_init_containers: tuple[str, ...] = ("patch-web-session-activity",)
+ chat_init_containers: tuple[str, ...] = ("patch-api-server-sessions",)
+ chat_config_revision: str = ""
+ pool_worker_env: tuple[str, ...] = ("HERMES_WORKER_NODE", "HERMES_WORKER_ORDINAL")
+ max_evidence_age_seconds: int = 86_400
+ routing_tail_lines: int = 600
+ routing_tail_bytes: int = 512 * 1024
+ listing_bytes: int = 512 * 1024
+ git_askpass: str = "/opt/coordinator/gitea_askpass.sh"
+ now: Any = None
+ extra: dict[str, Any] = field(default_factory=dict)
+
+
+def step(
+ key: str,
+ vantage: str,
+ *argv: str,
+ kind: str = READ,
+ optional: bool = False,
+ record: bool = True,
+ max_bytes: int | None = None,
+) -> Step:
+ """Return a probe step addressed to one vantage."""
+ return Step(
+ key=key,
+ vantage=vantage,
+ argv=tuple(argv),
+ kind=kind,
+ optional=optional,
+ record=record,
+ max_bytes=max_bytes,
+ )
+
+
+def check(
+ identifier: str,
+ title: str,
+ group: str,
+ rule: str,
+ steps: Sequence[Step],
+ expect: dict[str, Any] | None = None,
+ mandatory: bool = True,
+ scope: str = ALWAYS,
+ rationale: str = "",
+) -> CheckSpec:
+ """Return one catalog entry."""
+ return CheckSpec(
+ id=identifier,
+ title=title,
+ group=group,
+ rule=rule,
+ steps=tuple(steps),
+ expect=expect or {},
+ mandatory=mandatory,
+ scope=scope,
+ rationale=rationale,
+ )
diff --git a/scripts/ops/hermes_handoff_checks_access.py b/scripts/ops/hermes_handoff_checks_access.py
new file mode 100644
index 00000000..df5c8117
--- /dev/null
+++ b/scripts/ops/hermes_handoff_checks_access.py
@@ -0,0 +1,339 @@
+#!/usr/bin/env python3
+"""Acceptance checks for what Hermes may and may not do to the cluster.
+
+Denials use Kubernetes authorization reviews only. Live denied attempts were
+removed because a policy regression could turn a supposed probe into a real
+credential read or mutation; the default harness never constructs such argv.
+"""
+
+from __future__ import annotations
+
+from hermes_handoff_catalog import (
+ FLUX_PROJECTION,
+ OPERATOR,
+ SELF,
+ Targets,
+ check,
+ step,
+)
+from hermes_handoff_model import REVIEW, CheckSpec
+
+
+def _access(targets: Targets) -> list[CheckSpec]:
+ namespace = targets.namespace
+ return [
+ check(
+ "access.no-cluster-admin-binding-for-agent",
+ "No ClusterRoleBinding grants the agent service account cluster-admin",
+ "access-denied",
+ "names_absent",
+ [
+ step(
+ "bindings",
+ OPERATOR,
+ "kubectl",
+ "get",
+ "clusterrolebindings",
+ "-o",
+ "name",
+ )
+ ],
+ {"step": "bindings", "contains": ("hermes-agent-cluster-admin",)},
+ rationale="The binding inventory and in-pod authorization reviews are independent desired/enforced views.",
+ ),
+ check(
+ "access.secrets-are-denied",
+ "Hermes cannot read Secrets cluster-wide",
+ "access-denied",
+ "review_denied",
+ [
+ step(
+ "review",
+ SELF,
+ "kubectl",
+ "auth",
+ "can-i",
+ "get",
+ "secrets",
+ "--all-namespaces",
+ kind=REVIEW,
+ )
+ ],
+ rationale="A live Secret request is never constructed, even when an RBAC regression would allow it.",
+ ),
+ check(
+ "access.service-account-tokens-are-denied",
+ "Hermes cannot mint a service-account token",
+ "access-denied",
+ "review_denied",
+ [
+ step(
+ "review-token",
+ SELF,
+ "kubectl",
+ "auth",
+ "can-i",
+ "create",
+ "serviceaccounts/token",
+ "--namespace",
+ namespace,
+ kind=REVIEW,
+ ),
+ step(
+ "review-serviceaccount",
+ SELF,
+ "kubectl",
+ "auth",
+ "can-i",
+ "create",
+ "serviceaccounts",
+ "--namespace",
+ namespace,
+ kind=REVIEW,
+ ),
+ ],
+ rationale="Token creation has no credential-safe live probe, so both relevant grants must be denied by review.",
+ ),
+ check(
+ "access.impersonation-is-denied",
+ "Hermes cannot impersonate another identity",
+ "access-denied",
+ "review_denied",
+ [
+ step(
+ "review",
+ SELF,
+ "kubectl",
+ "auth",
+ "can-i",
+ "impersonate",
+ "users",
+ "--all-namespaces",
+ kind=REVIEW,
+ )
+ ],
+ rationale="The default harness never constructs an impersonated request.",
+ ),
+ check(
+ "access.workload-mutation-is-denied",
+ "Hermes cannot mutate its own workload",
+ "access-denied",
+ "review_denied",
+ [
+ step(
+ "review",
+ SELF,
+ "kubectl",
+ "auth",
+ "can-i",
+ "patch",
+ "deployments",
+ "--namespace",
+ namespace,
+ kind=REVIEW,
+ )
+ ],
+ rationale="Client-side and server-side dry-run mutation tricks are excluded structurally.",
+ ),
+ check(
+ "access.pod-exec-is-denied",
+ "Hermes cannot exec into an arbitrary pod",
+ "access-denied",
+ "review_denied",
+ [
+ step(
+ "review",
+ SELF,
+ "kubectl",
+ "auth",
+ "can-i",
+ "create",
+ "pods/exec",
+ "--all-namespaces",
+ kind=REVIEW,
+ )
+ ],
+ rationale="No cross-pod exec request is created by a read-only run.",
+ ),
+ check(
+ "access.attach-portforward-and-kube-system-writes-are-denied",
+ "Hermes holds no attach, port-forward, or control-plane write authority",
+ "access-denied",
+ "review_denied",
+ [
+ step(
+ "review-attach",
+ SELF,
+ "kubectl",
+ "auth",
+ "can-i",
+ "create",
+ "pods/attach",
+ "--all-namespaces",
+ kind=REVIEW,
+ ),
+ step(
+ "review-portforward",
+ SELF,
+ "kubectl",
+ "auth",
+ "can-i",
+ "create",
+ "pods/portforward",
+ "--all-namespaces",
+ kind=REVIEW,
+ ),
+ step(
+ "review-create",
+ SELF,
+ "kubectl",
+ "auth",
+ "can-i",
+ "create",
+ "configmaps",
+ "--namespace",
+ "kube-system",
+ kind=REVIEW,
+ ),
+ ],
+ rationale="Every authority is reviewed without issuing the request it would authorize.",
+ ),
+ check(
+ "access.required-reads-are-allowed",
+ "Hermes retains the reads its operator role depends on",
+ "access-allowed",
+ "allowed",
+ [
+ step(
+ "watch-review",
+ SELF,
+ "kubectl",
+ "auth",
+ "can-i",
+ "watch",
+ "pods",
+ "--namespace",
+ namespace,
+ kind=REVIEW,
+ ),
+ step(
+ "pods",
+ SELF,
+ "kubectl",
+ "--namespace",
+ namespace,
+ "get",
+ "pods",
+ "-o",
+ "name",
+ ),
+ step("namespaces", SELF, "kubectl", "get", "namespaces", "-o", "name"),
+ step(
+ "deployments",
+ SELF,
+ "kubectl",
+ "--namespace",
+ namespace,
+ "get",
+ "deployments",
+ "-o",
+ "name",
+ ),
+ step("nodes", SELF, "kubectl", "get", "nodes", "-o", "name"),
+ ],
+ ),
+ check(
+ "access.flux-and-helm-status-are-allowed",
+ "Hermes can read Flux and Helm reconciliation status",
+ "access-allowed",
+ "allowed",
+ [
+ step(
+ "kustomizations",
+ SELF,
+ "kubectl",
+ "get",
+ "kustomizations.kustomize.toolkit.fluxcd.io",
+ "--all-namespaces",
+ "-o",
+ "name",
+ record=False,
+ ),
+ step(
+ "helmreleases",
+ SELF,
+ "kubectl",
+ "get",
+ "helmreleases.helm.toolkit.fluxcd.io",
+ "--all-namespaces",
+ "-o",
+ "name",
+ record=False,
+ ),
+ ],
+ ),
+ check(
+ "access.namespace-view-agrees-across-vantages",
+ "The operator and in-pod vantages see the same namespace inventory",
+ "access-allowed",
+ "vantages_agree",
+ [
+ step(
+ "operator",
+ OPERATOR,
+ "kubectl",
+ "get",
+ "namespaces",
+ "-o",
+ "name",
+ record=False,
+ ),
+ step(
+ "self",
+ SELF,
+ "kubectl",
+ "get",
+ "namespaces",
+ "-o",
+ "name",
+ record=False,
+ ),
+ ],
+ {"steps": ("operator", "self")},
+ rationale="A disagreement here means one vantage is not seeing the cluster the other is certifying.",
+ ),
+ ]
+
+
+def _gitops(targets: Targets) -> list[CheckSpec]:
+ return [
+ check(
+ f"gitops.{kind.split('.')[0]}-reconcile-cleanly",
+ f"No unexpected {label} suspension or unhealthy reconciliation",
+ "gitops",
+ "flux_health",
+ [
+ step(
+ "objects",
+ OPERATOR,
+ "kubectl",
+ "get",
+ kind,
+ "--all-namespaces",
+ "-o",
+ FLUX_PROJECTION,
+ record=False,
+ )
+ ],
+ {"step": "objects", "expected_suspensions": targets.expected_suspensions},
+ )
+ for kind, label in (
+ ("kustomizations.kustomize.toolkit.fluxcd.io", "Kustomization"),
+ ("helmreleases.helm.toolkit.fluxcd.io", "HelmRelease"),
+ )
+ ]
+
+
+def access_checks(targets: Targets) -> list[CheckSpec]:
+ """Return the access-denied, access-allowed, and GitOps health checks."""
+ return [*_access(targets), *_gitops(targets)]
diff --git a/scripts/ops/hermes_handoff_checks_delivery.py b/scripts/ops/hermes_handoff_checks_delivery.py
new file mode 100644
index 00000000..102f93e4
--- /dev/null
+++ b/scripts/ops/hermes_handoff_checks_delivery.py
@@ -0,0 +1,374 @@
+#!/usr/bin/env python3
+"""Acceptance checks for forge authority, node accounts, images, and regressions.
+
+What the platform is allowed to do outside its own namespace: which forge
+authority a worker holds, what a node account is and is not, how an image is
+built and pinned, and whether the fixes the release depends on are actually
+running rather than merely merged.
+
+Where a property has no side-effect-free live probe — a merge that must never be
+attempted, a branch protection the harness identity cannot read — the entry says
+so in its rationale and asserts the nearest fact that *is* observable, rather
+than quietly asserting nothing.
+"""
+
+from __future__ import annotations
+
+from hermes_handoff_catalog import (
+ FORGE_CREDENTIAL_NAMES,
+ INIT_NAME_PROJECTION,
+ NODE_NAME_PROJECTION,
+ OPERATOR,
+ SELF,
+ Targets,
+ check,
+ step,
+)
+from hermes_handoff_model import ATTEMPT, CheckSpec
+from hermes_handoff_policy import GITEA_CLIENT, projection, shell
+
+
+def _forge(targets: Targets) -> list[CheckSpec]:
+ api = f"/api/v1/repos/{targets.repo}"
+ return [
+ check(
+ "forge.worker-holds-no-git-credential-variable",
+ "No forge credential is exported into the worker process",
+ "forge",
+ "names_absent",
+ [step("env", SELF, *shell("env_names"))],
+ {"step": "env", "names": FORGE_CREDENTIAL_NAMES},
+ rationale="Authentication reaches Git through an askpass helper that reads runtime state, never through the environment.",
+ ),
+ check(
+ "forge.identity-is-not-an-administrator",
+ "The forge identity the worker uses holds no administrative rights",
+ "forge",
+ "json_field",
+ [step("user", SELF, GITEA_CLIENT, "GET", "/api/v1/user")],
+ {"step": "user", "fields": {"is_admin": False}},
+ ),
+ check(
+ "forge.repository-authority-is-push-only",
+ "The worker can push a branch but cannot administer the repository",
+ "forge",
+ "json_field",
+ [step("repo", SELF, GITEA_CLIENT, "GET", api)],
+ {
+ "step": "repo",
+ "fields": {
+ "permissions.admin": False,
+ "permissions.push": True,
+ "permissions.pull": True,
+ },
+ },
+ rationale="Merge, approve, close, and protection changes all require authority this identity is proven not to hold.",
+ ),
+ check(
+ "forge.administrative-route-is-refused",
+ "An administrative forge route is refused for this identity",
+ "forge",
+ "denied",
+ [
+ step(
+ "attempt",
+ SELF,
+ GITEA_CLIENT,
+ "GET",
+ f"{api}/branch_protections",
+ kind=ATTEMPT,
+ )
+ ],
+ rationale="A live refusal, not a configuration reading. The same lack of authority is what blocks a merge route.",
+ ),
+ ]
+
+
+def _nodes(targets: Targets) -> list[CheckSpec]:
+ return [
+ check(
+ "nodes.hardening-covers-every-node",
+ "The node account audit runs on every node in the cluster",
+ "nodes",
+ "distinct_count",
+ [
+ step(
+ "coverage",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ targets.namespace,
+ "get",
+ "pods",
+ "--selector",
+ f"app={targets.node_daemonset}",
+ "-o",
+ NODE_NAME_PROJECTION,
+ )
+ ],
+ {
+ "step": "coverage",
+ "minimum": targets.node_count,
+ "total_equals": targets.node_count,
+ },
+ rationale="A hardened fleet with one unenrolled node is an unhardened fleet.",
+ ),
+ ]
+
+
+def _build(targets: Targets) -> list[CheckSpec]:
+ pipeline = f"{targets.remote}/main:{targets.builder_pipeline}"
+ return [
+ check(
+ "build.builder-service-account-is-tokenless",
+ "The image builder service account mounts no API token",
+ "build",
+ "stdout_matches",
+ [
+ step(
+ "serviceaccount",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ targets.builder_namespace,
+ "get",
+ "serviceaccount",
+ targets.builder_serviceaccount,
+ "-o",
+ projection("jsonpath={.automountServiceAccountToken}"),
+ )
+ ],
+ {"step": "serviceaccount", "equals": "false"},
+ ),
+ check(
+ "build.builder-runs-unprivileged-with-exact-capabilities",
+ "The build pod is unprivileged and adds only the expected capabilities",
+ "build",
+ "names_present",
+ [step("pipeline", OPERATOR, "git", "show", pipeline, record=False)],
+ {
+ "step": "pipeline",
+ "names": (
+ targets.builder_capabilities,
+ "privileged: false",
+ "allowPrivilegeEscalation: false",
+ "automountServiceAccountToken: false",
+ 'drop: ["ALL"]',
+ ),
+ },
+ rationale="Exact lines, not a substring sweep: a widened capability set has to change one of these lines to take effect.",
+ ),
+ check(
+ "build.harbor-immutability-rule-is-applied",
+ "The Harbor immutable-tag rule for the agent image has been applied",
+ "build",
+ "lines_match",
+ [
+ step(
+ "job",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ targets.harbor_namespace,
+ "get",
+ "job",
+ targets.harbor_immutability_job,
+ "-o",
+ projection("jsonpath={.status.succeeded}"),
+ )
+ ],
+ {"step": "job", "pattern": r"^[1-9][0-9]*$", "minimum": 1},
+ rationale="The job verifies the rule it creates, so a completed run is evidence the rule matched the expected contract.",
+ ),
+ _release_lineage(targets),
+ ]
+
+
+def _release_lineage(targets: Targets) -> CheckSpec:
+ """Bind the exact reviewed release from Git through the Ready pods."""
+ container = targets.agent_container
+ namespace = targets.namespace
+ deployment = f"deploy/{targets.agent_deployment}"
+ deployment_projection = projection(
+ (
+ "jsonpath={.metadata.generation}{'\\t'}{.status.observedGeneration}{'\\t'}"
+ "{.spec.replicas}{'\\t'}{.status.readyReplicas}{'\\t'}"
+ "{.metadata.annotations.deployment\\.kubernetes\\.io/revision}{'\\t'}"
+ f'{{.spec.template.spec.containers[?(@.name=="{container}")].image}}'
+ ).replace("'", '"')
+ )
+ pod_projection = projection(
+ (
+ "jsonpath={range .items[*]}{.metadata.name}{'\\t'}{.status.phase}{'\\t'}"
+ "{range .status.conditions[?(@.type==\"Ready\")]}{.status}{end}{'\\t'}"
+ f'{{.spec.containers[?(@.name=="{container}")].image}}{{"\\t"}}'
+ f'{{.status.containerStatuses[?(@.name=="{container}")].imageID}}{{"\\t"}}'
+ "{.metadata.labels.pod-template-hash}{'\\n'}{end}"
+ ).replace("'", '"')
+ )
+ replicaset_projection = projection(
+ (
+ "jsonpath={range .items[*]}{.metadata.name}{'\\t'}"
+ "{.metadata.annotations.deployment\\.kubernetes\\.io/revision}{'\\t'}"
+ "{.status.readyReplicas}{'\\t'}{.status.availableReplicas}{'\\t'}"
+ f'{{.spec.template.spec.containers[?(@.name=="{container}")].image}}{{"\\t"}}'
+ "{.metadata.labels.pod-template-hash}{'\\n'}{end}"
+ ).replace("'", '"')
+ )
+ flux_projection = projection(
+ (
+ "jsonpath={.metadata.name}{'\\t'}{.metadata.generation}{'\\t'}"
+ "{.status.observedGeneration}{'\\t'}{.spec.suspend}{'\\t'}"
+ "{range .status.conditions[?(@.type==\"Ready\")]}{.status}{end}{'\\t'}"
+ "{.status.lastAppliedRevision}"
+ ).replace("'", '"')
+ )
+ return check(
+ "release.exact-lineage-is-running",
+ "Remote main, reviewed PR, image, build, Flux, and running revision are identical",
+ "release",
+ "release_lineage",
+ [
+ step(
+ "remote-main",
+ OPERATOR,
+ "git",
+ "ls-remote",
+ targets.remote,
+ "refs/heads/main",
+ ),
+ step(
+ "reviewed-pr",
+ OPERATOR,
+ GITEA_CLIENT,
+ "GET",
+ f"/api/v1/repos/{targets.repo}/pulls/{targets.reviewed_pr_number}",
+ ),
+ step(
+ "build-source",
+ OPERATOR,
+ "git",
+ "rev-parse",
+ f"{targets.remote}/main",
+ ),
+ step(
+ "deployment",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ namespace,
+ "get",
+ deployment,
+ "-o",
+ deployment_projection,
+ ),
+ step(
+ "pods",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ namespace,
+ "get",
+ "pods",
+ "--selector",
+ f"app={targets.agent_deployment}",
+ "-o",
+ pod_projection,
+ ),
+ step(
+ "replicasets",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ namespace,
+ "get",
+ "replicasets",
+ "--selector",
+ f"app={targets.agent_deployment}",
+ "-o",
+ replicaset_projection,
+ ),
+ step(
+ "flux",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ "flux-system",
+ "get",
+ "kustomization/hermes",
+ "-o",
+ flux_projection,
+ ),
+ ],
+ {
+ "main_sha": targets.remote_main_sha,
+ "head_sha": targets.reviewed_head_sha,
+ "head_ref": targets.reviewed_head_ref,
+ "pr_number": targets.reviewed_pr_number,
+ "image": targets.agent_image,
+ "build_sha": targets.build_sha,
+ "deployment_revision": targets.deployment_revision,
+ },
+ rationale="A PASS requires one exact release identity across every source and running object.",
+ )
+
+
+def _reliability(targets: Targets) -> list[CheckSpec]:
+ return [
+ check(
+ "reliability.finalization-and-replay-patches-are-deployed",
+ "The agent workload runs the finalization, replay, and session patches",
+ "reliability",
+ "names_present",
+ [
+ step(
+ "init",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ targets.namespace,
+ "get",
+ f"deploy/{targets.agent_deployment}",
+ "-o",
+ INIT_NAME_PROJECTION,
+ )
+ ],
+ {"step": "init", "names": targets.agent_init_containers},
+ rationale="The regression suites live in the repository; this asserts the fixes they cover are actually running.",
+ ),
+ check(
+ "reliability.regression-suites-are-present-on-main",
+ "The decomposition and worker-recovery regression suites are on main",
+ "reliability",
+ "names_present",
+ [
+ step(
+ "suites",
+ OPERATOR,
+ "git",
+ "ls-tree",
+ "--name-only",
+ f"{targets.remote}/main",
+ "testing/tests/",
+ )
+ ],
+ {
+ "step": "suites",
+ "names": (
+ "testing/tests/test_hermes_cli_lanes.py",
+ "testing/tests/test_hermes_coordinator.py",
+ "testing/tests/test_hermes_worker_recovery.py",
+ ),
+ },
+ ),
+ ]
+
+
+def delivery_checks(targets: Targets) -> list[CheckSpec]:
+ """Return the forge, node, build, and reliability checks."""
+ return [
+ *_forge(targets),
+ *_nodes(targets),
+ *_build(targets),
+ *_reliability(targets),
+ ]
diff --git a/scripts/ops/hermes_handoff_checks_platform.py b/scripts/ops/hermes_handoff_checks_platform.py
new file mode 100644
index 00000000..7126036b
--- /dev/null
+++ b/scripts/ops/hermes_handoff_checks_platform.py
@@ -0,0 +1,387 @@
+#!/usr/bin/env python3
+"""Acceptance checks for the release baseline, provider identity, and routing.
+
+These are the facts a handoff rests on before any authority question is asked:
+that the tree under test is the merged one, that both providers authenticate
+through the subscriptions they are supposed to, that Switchyard actually decided
+the routes it claims to, and that chat, agent, and triage are still three
+distinct scopes rather than one surface wearing three hostnames.
+"""
+
+from __future__ import annotations
+
+from hermes_handoff_catalog import (
+ CLAUDE_HEALTH,
+ CLAUDE_HEALTH_FIELDS,
+ CODEX_HEALTH,
+ CODEX_HEALTH_FIELDS,
+ ENV_NAME_PROJECTION,
+ OPERATOR,
+ PROVIDER_API_KEY_NAMES,
+ ROUTING_LOG,
+ SELF,
+ SWITCHYARD,
+ Targets,
+ check,
+ step,
+)
+from hermes_handoff_model import CheckSpec
+from hermes_handoff_policy import GITEA_CLIENT, projection, shell
+
+
+def _baseline(targets: Targets) -> list[CheckSpec]:
+ api = f"/api/v1/repos/{targets.repo}"
+ dependency_heads = dict(targets.dependency_heads)
+ checks = [
+ check(
+ "baseline.local-origin-main-is-current",
+ "The local origin/main ref equals the fixed remote-main SHA",
+ "baseline",
+ "stdout_matches",
+ [step("main", OPERATOR, "git", "rev-parse", f"{targets.remote}/main")],
+ {"step": "main", "equals": targets.remote_main_sha},
+ rationale="The ancestry check is meaningful only after the local tracking ref is bound to the remote observation.",
+ ),
+ check(
+ "baseline.origin-main-descends-merged-work",
+ "origin/main descends from the merged Hermes isolation baseline",
+ "baseline",
+ "allowed",
+ [
+ step(
+ "ancestor",
+ OPERATOR,
+ "git",
+ "merge-base",
+ "--is-ancestor",
+ targets.baseline_commit,
+ f"{targets.remote}/main",
+ )
+ ],
+ rationale="Everything downstream assumes the merged worker-isolation baseline is present.",
+ ),
+ check(
+ "baseline.pending-pull-requests-recorded",
+ "Exact open pull-request heads are recorded, not assumed",
+ "baseline",
+ "json_record",
+ [
+ step(
+ "open",
+ OPERATOR,
+ GITEA_CLIENT,
+ "GET",
+ f"{api}/pulls?state=open&limit=50",
+ )
+ ],
+ {
+ "step": "open",
+ "record": {
+ "numbers": "[].number",
+ "head_refs": "[].head.ref",
+ "head_shas": "[].head.sha",
+ "base_shas": "[].base.sha",
+ "drafts": "[].draft",
+ "mergeable": "[].mergeable",
+ },
+ },
+ rationale="The runbook has to name the heads it was verified against; stale heads are how a release verifies the wrong tree.",
+ ),
+ check(
+ "baseline.operator-checkout-clean",
+ "The operator checkout has no uncommitted drift",
+ "baseline",
+ "stdout_matches",
+ [step("status", OPERATOR, "git", "status", "--porcelain")],
+ {"step": "status", "equals": ""},
+ mandatory=False,
+ rationale="Advisory: a dirty checkout does not invalidate cluster evidence, but it does muddy what was compared.",
+ ),
+ ]
+ checks += [
+ check(
+ f"baseline.dependency-pr-{number}-merged",
+ f"Pull request #{number} is merged into main",
+ "baseline",
+ "json_field",
+ [step("pr", OPERATOR, GITEA_CLIENT, "GET", f"{api}/pulls/{number}")],
+ {
+ "step": "pr",
+ "fields": {
+ "merged": True,
+ "base.ref": "main",
+ "head.sha": dependency_heads.get(number, ""),
+ },
+ },
+ rationale="This harness certifies the post-merge platform; an unmerged dependency means it is measuring something else.",
+ )
+ for number in targets.dependency_pull_requests
+ ]
+ return checks
+
+
+def _identity(targets: Targets) -> list[CheckSpec]:
+ codex = shell("json_fields", path=CODEX_HEALTH, fields=CODEX_HEALTH_FIELDS)
+ claude = shell("json_fields", path=CLAUDE_HEALTH, fields=CLAUDE_HEALTH_FIELDS)
+ agent = f"deploy/{targets.agent_deployment}"
+ return [
+ check(
+ "identity.codex-is-chatgpt-subscription",
+ "Codex authenticates through the ChatGPT subscription, not an API key",
+ "identity",
+ "json_field",
+ [step("health", SELF, *codex)],
+ {
+ "step": "health",
+ "fields": {
+ "authenticated": True,
+ "transport": "codex-chatgpt-subscription",
+ "state": "available",
+ },
+ },
+ rationale="Only allow-listed status fields are read; the credential itself is never opened.",
+ ),
+ check(
+ "identity.codex-evidence-is-fresh",
+ "Codex authentication evidence is current",
+ "identity",
+ "json_recent",
+ [step("health", SELF, *codex)],
+ {
+ "step": "health",
+ "now": targets.now,
+ "fields": {"checked_at": targets.max_evidence_age_seconds},
+ },
+ rationale="Stale provider evidence describes a state the release no longer has.",
+ ),
+ check(
+ "identity.claude-is-firstparty-subscription",
+ "Claude authenticates as a claude.ai first-party subscription",
+ "identity",
+ "json_field",
+ [step("health", SELF, *claude)],
+ {
+ "step": "health",
+ "fields": {
+ "authenticated": True,
+ "api_provider": "firstParty",
+ "auth_method": "claude.ai",
+ "transport": "claude-code-cli-subscription",
+ "state": "available",
+ },
+ },
+ ),
+ check(
+ "identity.claude-evidence-is-fresh",
+ "Claude authentication evidence is current",
+ "identity",
+ "json_recent",
+ [step("health", SELF, *claude)],
+ {
+ "step": "health",
+ "now": targets.now,
+ "fields": {"checked_at": targets.max_evidence_age_seconds},
+ },
+ ),
+ check(
+ "identity.no-provider-api-key-in-pod",
+ "No provider API-key variable is set in the running agent process",
+ "identity",
+ "names_absent",
+ [step("env", SELF, *shell("env_names"))],
+ {
+ "step": "env",
+ "names": PROVIDER_API_KEY_NAMES,
+ "contains": ("API_KEY",),
+ },
+ rationale="Names only. The probe lists variable names and never their values.",
+ ),
+ check(
+ "identity.no-provider-api-key-in-manifest",
+ "No provider API-key variable is declared on the agent workload",
+ "identity",
+ "names_absent",
+ [
+ step(
+ "env",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ targets.namespace,
+ "get",
+ agent,
+ "-o",
+ ENV_NAME_PROJECTION,
+ )
+ ],
+ {"step": "env", "names": PROVIDER_API_KEY_NAMES, "contains": ("API_KEY",)},
+ rationale="The operator counterpart to the in-pod probe: desired state and running state are asserted separately.",
+ ),
+ ]
+
+
+def _routing(targets: Targets) -> list[CheckSpec]:
+ switchyard = f"deploy/{targets.switchyard_deployment}"
+ tail = shell("tail_lines", path=ROUTING_LOG, limit=str(targets.routing_tail_lines))
+ routing_step = step(
+ "routing", SWITCHYARD, *tail, record=False, max_bytes=targets.routing_tail_bytes
+ )
+ return [
+ check(
+ "routing.switchyard-is-available",
+ "Switchyard has a ready replica serving every model-call boundary",
+ "routing",
+ "lines_match",
+ [
+ step(
+ "deployment",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ targets.namespace,
+ "get",
+ switchyard,
+ "-o",
+ projection("jsonpath={.status.readyReplicas}"),
+ )
+ ],
+ {"step": "deployment", "pattern": r"^[1-9][0-9]*$", "minimum": 1},
+ ),
+ check(
+ "routing.provider-effort-and-fallback-evidence",
+ "Routing evidence covers both providers, all graded efforts, and a real fallback",
+ "routing",
+ "routing_evidence",
+ [routing_step],
+ {
+ "step": "routing",
+ "providers": ("codex", "claude"),
+ "efforts": ("medium", "high", "xhigh"),
+ "lanes": ("route", "worker"),
+ "require_fallback_evidence": True,
+ "max_age_seconds": targets.max_evidence_age_seconds,
+ "now": targets.now,
+ },
+ rationale="The routing log records the decided route per boundary; the tail is parsed here and never pasted into the report.",
+ ),
+ check(
+ "routing.codex-latency-recorded",
+ "Codex boundary latency is measured, not assumed",
+ "routing",
+ "json_numeric",
+ [
+ step(
+ "health",
+ SELF,
+ *shell(
+ "json_fields", path=CODEX_HEALTH, fields=CODEX_HEALTH_FIELDS
+ ),
+ )
+ ],
+ {"step": "health", "fields": {"latency_ms": {"min": 1, "max": 600_000}}},
+ ),
+ check(
+ "routing.claude-latency-recorded",
+ "Claude boundary latency is measured, not assumed",
+ "routing",
+ "json_numeric",
+ [
+ step(
+ "health",
+ SELF,
+ *shell(
+ "json_fields", path=CLAUDE_HEALTH, fields=CLAUDE_HEALTH_FIELDS
+ ),
+ )
+ ],
+ {"step": "health", "fields": {"latency_ms": {"min": 1, "max": 600_000}}},
+ ),
+ ]
+
+
+def _scope_profile(
+ targets: Targets, name: str, workload: str, container: str, expected: str
+) -> CheckSpec:
+ profile_projection = projection(
+ f'jsonpath={{.spec.template.spec.containers[?(@.name=="{container}")]'
+ '.env[?(@.name=="HERMES_AUTO_ROUTER_PROFILE")].value}'
+ )
+ return check(
+ f"scopes.{name}-profile-is-distinct",
+ f"The {name} surface runs under its own routing profile",
+ "scopes",
+ "stdout_matches",
+ [
+ step(
+ "profile",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ targets.namespace,
+ "get",
+ workload,
+ "-o",
+ profile_projection,
+ )
+ ],
+ {"step": "profile", "equals": expected},
+ rationale="Chat, agent, and triage must not collapse into one scope; each states its own profile.",
+ )
+
+
+def _scopes(targets: Targets) -> list[CheckSpec]:
+ return [
+ _scope_profile(
+ targets,
+ "agent",
+ f"deploy/{targets.agent_deployment}",
+ targets.agent_container,
+ "agent",
+ ),
+ _scope_profile(
+ targets,
+ "chat",
+ f"statefulset/{targets.chat_statefulset}",
+ targets.chat_container,
+ "chat",
+ ),
+ _scope_profile(
+ targets,
+ "triage",
+ f"deploy/{targets.triage_deployment}",
+ targets.triage_container,
+ "triage",
+ ),
+ check(
+ "scopes.surfaces-have-distinct-hosts",
+ "Chat, agent, and triage are published on distinct hostnames",
+ "scopes",
+ "distinct_count",
+ [
+ step(
+ "hosts",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ targets.namespace,
+ "get",
+ "ingress",
+ "-o",
+ projection(
+ 'jsonpath={range .items[*]}{range .spec.rules[*]}{.host}{"\\n"}{end}{end}'
+ ),
+ )
+ ],
+ {"step": "hosts", "minimum": 3},
+ ),
+ ]
+
+
+def platform_checks(targets: Targets) -> list[CheckSpec]:
+ """Return the baseline, identity, routing, and scope checks."""
+ return [
+ *_baseline(targets),
+ *_identity(targets),
+ *_routing(targets),
+ *_scopes(targets),
+ ]
diff --git a/scripts/ops/hermes_handoff_checks_workers.py b/scripts/ops/hermes_handoff_checks_workers.py
new file mode 100644
index 00000000..d62c719c
--- /dev/null
+++ b/scripts/ops/hermes_handoff_checks_workers.py
@@ -0,0 +1,376 @@
+#!/usr/bin/env python3
+"""Acceptance checks for the distributed worker pool and the visible surfaces.
+
+A pool is only distributed if its workers occupy distinct nodes and distinct
+volumes and leave state ownership with the coordinator; a replica count alone
+satisfies none of that. A surface is only working if the work it did is visible
+in it, which is why the session and Kanban checks look at the same data the UI
+renders rather than at whether a pod is Running.
+
+The ephemeral entries are placeholders in a default run. They report NOT_RUN
+until the mutation mode is separately armed, and the armed mode replaces them
+with real, mandatory results.
+"""
+
+from __future__ import annotations
+
+from hermes_handoff_catalog import (
+ CHAT,
+ INIT_NAME_PROJECTION,
+ OPERATOR,
+ POOL_PROJECTION,
+ SELF,
+ Targets,
+ check,
+)
+from hermes_handoff_catalog import step as step
+from hermes_handoff_model import EPHEMERAL, CheckSpec
+from hermes_handoff_policy import projection
+
+KANBAN_STATUSES = [
+ "archived",
+ "blocked",
+ "done",
+ "ready",
+ "review",
+ "running",
+ "scheduled",
+ "todo",
+ "triage",
+]
+
+
+def _pool(targets: Targets) -> list[CheckSpec]:
+ selector = targets.pool_selector
+ return [
+ check(
+ "pool.three-workers-have-distinct-nodes-and-volumes",
+ "Exactly three Ready ordinal workers occupy distinct nodes and claims",
+ "pool",
+ "pool_topology",
+ [
+ step(
+ "statefulset",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ targets.namespace,
+ "get",
+ f"statefulset/{targets.pool_statefulset}",
+ "-o",
+ projection(
+ "jsonpath={.spec.replicas}{'\\t'}{.status.readyReplicas}{'\\t'}{.status.currentReplicas}{'\\t'}{.spec.template.spec.automountServiceAccountToken}".replace(
+ "'", '"'
+ )
+ ),
+ ),
+ step(
+ "workers",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ targets.namespace,
+ "get",
+ "pods",
+ "--selector",
+ selector,
+ "-o",
+ POOL_PROJECTION,
+ ),
+ ],
+ {
+ "state_step": "statefulset",
+ "worker_step": "workers",
+ "replicas": targets.pool_replicas,
+ "name": targets.pool_statefulset,
+ },
+ rationale="Replica count, Ready state, ordinal coverage, node placement, PVC isolation, and tokenlessness are one fail-closed assertion.",
+ ),
+ check(
+ "pool.coordinator-retains-sole-state-ownership",
+ "No pool worker mounts the coordinator's durable state",
+ "pool",
+ "names_absent",
+ [
+ step(
+ "claims",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ targets.namespace,
+ "get",
+ f"statefulset/{targets.pool_statefulset}",
+ "-o",
+ projection(
+ "jsonpath={range .spec.template.spec.volumes[*]}{.name}{'='}"
+ "{.persistentVolumeClaim.claimName}{'\\n'}{end}".replace(
+ "'", '"'
+ )
+ ),
+ )
+ ],
+ {"step": "claims", "contains": targets.coordinator_claims},
+ rationale="Two writers on one SQLite state file is the duplicate-work and lost-result failure this pool exists to avoid. One line per template volume keeps a drifted or empty projection NOT_RUN instead of a silent pass.",
+ ),
+ *_pool_env(targets),
+ ]
+
+
+def _pool_env(targets: Targets) -> list[CheckSpec]:
+ """Return the mandatory assignment-safety configuration check."""
+ return [
+ check(
+ "pool.assignment-safety-knobs-are-configured",
+ "Pool workers carry the lease and de-duplication configuration",
+ "pool",
+ "names_present",
+ [
+ step(
+ "env",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ targets.namespace,
+ "get",
+ f"statefulset/{targets.pool_statefulset}",
+ "-o",
+ projection(
+ 'jsonpath={range .spec.template.spec.containers[*]}{range .env[*]}{.name}{"\\n"}{end}{end}'
+ ),
+ )
+ ],
+ {"step": "env", "names": targets.pool_worker_env},
+ rationale="Deployed configuration only; the stale-claim and duplicate-dispatch behaviour itself is covered by the pool's own regression suite.",
+ ),
+ check(
+ "pool.assignment-fieldrefs-are-bound",
+ "Worker node and ordinal identities come from fixed downward-API fields",
+ "pool",
+ "names_present",
+ [
+ step(
+ "source",
+ OPERATOR,
+ "git",
+ "show",
+ f"{targets.remote}/main:services/hermes/execution-worker-statefulset.yaml",
+ record=False,
+ )
+ ],
+ {
+ "step": "source",
+ "names": (
+ "name: HERMES_WORKER_ORDINAL",
+ "fieldPath: metadata.labels['apps.kubernetes.io/pod-index']",
+ "name: HERMES_WORKER_NODE",
+ "fieldPath: spec.nodeName",
+ ),
+ },
+ rationale="This prevents caller-authored ordinal/node values from impersonating another worker assignment.",
+ ),
+ ]
+
+
+def _surfaces(targets: Targets) -> list[CheckSpec]:
+ checks = [
+ check(
+ "surfaces.kanban-activity-is-visible",
+ "Durable Kanban activity is visible to the agent surface",
+ "surfaces",
+ "all_of_field",
+ [
+ step(
+ "tasks",
+ SELF,
+ "hermes",
+ "kanban",
+ "list",
+ "--json",
+ record=False,
+ max_bytes=targets.listing_bytes,
+ )
+ ],
+ {"step": "tasks", "key_field": "id", "fields": {"status": KANBAN_STATUSES}},
+ rationale="Non-empty and well-formed. An empty board is NOT_RUN, because it cannot distinguish a quiet week from a broken read.",
+ ),
+ check(
+ "surfaces.durable-sessions-render-without-blank-rows",
+ "Durable sessions are listed with a preview, a source, and an id",
+ "surfaces",
+ "lines_match",
+ [
+ step(
+ "sessions",
+ SELF,
+ "hermes",
+ "sessions",
+ "list",
+ "--limit",
+ "20",
+ record=False,
+ )
+ ],
+ {"step": "sessions", "pattern": r"^\S.*\S$", "skip": 2, "minimum": 1},
+ rationale="A row whose leading column is blank is the session-render regression seen from the data side.",
+ ),
+ check(
+ "surfaces.agent-ui-session-patches-are-deployed",
+ "The agent surface runs the session-activity rendering patches",
+ "surfaces",
+ "names_present",
+ [
+ step(
+ "init",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ targets.namespace,
+ "get",
+ f"deploy/{targets.agent_deployment}",
+ "-o",
+ INIT_NAME_PROJECTION,
+ )
+ ],
+ {
+ "step": "init",
+ "names": ("patch-web-session-activity", "patch-api-server-sessions"),
+ },
+ rationale="Blank session rows were a rendering regression; this asserts the patches that fixed it are in the running pod.",
+ ),
+ check(
+ "surfaces.chat-session-patches-are-deployed",
+ "The chat surface runs the API-session continuity patches",
+ "surfaces",
+ "names_present",
+ [
+ step(
+ "init",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ targets.namespace,
+ "get",
+ f"statefulset/{targets.chat_statefulset}",
+ "-o",
+ INIT_NAME_PROJECTION,
+ )
+ ],
+ {"step": "init", "names": targets.chat_init_containers},
+ ),
+ check(
+ "surfaces.chat-telegram-topic-state-is-durable",
+ "Telegram topic and tenant state is held on durable storage",
+ "surfaces",
+ "names_present",
+ [
+ step(
+ "claims",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ targets.namespace,
+ "get",
+ f"deploy/{targets.chat_router_deployment}",
+ "-o",
+ projection(
+ 'jsonpath={range .spec.template.spec.volumes[*]}{.persistentVolumeClaim.claimName}{"\\n"}{end}'
+ ),
+ )
+ ],
+ {"step": "claims", "contains": ("state",)},
+ rationale="Topic selection survives a router restart only if the tenant state file is on a claim rather than in the pod.",
+ ),
+ ]
+ checks.append(
+ check(
+ "surfaces.chat-telegram-sessions-are-continuous",
+ "The selected chat tenant still holds durable Telegram-sourced sessions",
+ "surfaces",
+ "lines_match",
+ [
+ step(
+ "telegram",
+ CHAT,
+ "hermes",
+ "sessions",
+ "list",
+ "--source",
+ "telegram",
+ "--limit",
+ "20",
+ record=False,
+ )
+ ],
+ {"step": "telegram", "pattern": r"^\S.*\S$", "skip": 2, "minimum": 1},
+ rationale="Session continuity is per tenant and depends on there having been Telegram traffic, so it is asserted only when the operator names the tenant that carries the identity.",
+ )
+ )
+ checks.append(
+ check(
+ "surfaces.chat-runs-the-telegram-topic-revision",
+ "The chat surface runs the durable Telegram topic configuration",
+ "surfaces",
+ "stdout_matches",
+ [
+ step(
+ "revision",
+ OPERATOR,
+ "kubectl",
+ "--namespace",
+ targets.namespace,
+ "get",
+ f"statefulset/{targets.chat_statefulset}",
+ "-o",
+ projection(
+ "jsonpath={.spec.template.metadata.annotations.ai\\.bstein\\.dev/config-rev}"
+ ),
+ )
+ ],
+ {"step": "revision", "equals": targets.chat_config_revision},
+ rationale="Telegram topic continuity is carried by the config revision the pod template pins.",
+ )
+ )
+ return checks
+
+
+def _ephemeral() -> list[CheckSpec]:
+ return [
+ check(
+ f"ephemeral.{name}",
+ title,
+ "ephemeral",
+ "not_armed",
+ [],
+ {"reason": reason},
+ mandatory=False,
+ scope=EPHEMERAL,
+ rationale="Mutating checks are separately armed. A default run reports them NOT_RUN and never touches the forge.",
+ )
+ for name, title, reason in (
+ (
+ "feature-branch-push",
+ "A unique ephemeral feature branch can be pushed and removed",
+ "ephemeral mutation mode is not armed",
+ ),
+ (
+ "draft-pull-request",
+ "A draft pull request can be opened and closed against the ephemeral branch",
+ "ephemeral mutation mode is not armed",
+ ),
+ (
+ "protected-branch-refusal",
+ "Arming against a protected branch is refused before any network call",
+ "ephemeral mutation mode is not armed",
+ ),
+ (
+ "cleanup-verified",
+ "Every ephemeral ref and pull request is removed and the removal is verified",
+ "ephemeral mutation mode is not armed",
+ ),
+ )
+ ]
+
+
+def worker_checks(targets: Targets) -> list[CheckSpec]:
+ """Return the pool, surface, and ephemeral-placeholder checks."""
+ return [*_pool(targets), *_surfaces(targets), *_ephemeral()]
diff --git a/scripts/ops/hermes_handoff_ephemeral.py b/scripts/ops/hermes_handoff_ephemeral.py
new file mode 100644
index 00000000..ca1ea594
--- /dev/null
+++ b/scripts/ops/hermes_handoff_ephemeral.py
@@ -0,0 +1,439 @@
+#!/usr/bin/env python3
+"""Discoverable, residue-surfacing, self-cleaning armed ephemeral run.
+
+The create response — not the index — is the authoritative name of a pull
+request this harness made, and the index is read to its last page. When either
+source is uncertain the run fails closed and reports the exact residue plus the
+exact commands that remove it, so an operator is never left guessing what was
+created.
+
+Arming, provenance, and push-target rules live in :mod:`hermes_handoff_arming`
+and are re-exported here for callers that treat armed mode as one surface.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Any
+
+from hermes_handoff_arming import (
+ CONFIRMATION,
+ REF_PREFIX,
+ ArmingError,
+ ArmRequest,
+ assert_push_target_allowed,
+ bounded_read,
+ normalise_branch,
+ preflight,
+)
+from hermes_handoff_exec import Outcome, Runner, Vantage
+from hermes_handoff_model import EPHEMERAL, FAIL, NOT_RUN, PASS, CheckResult, CheckSpec
+from hermes_handoff_policy import (
+ EXPECTED_BASE,
+ GITEA_CLIENT,
+ register_ephemeral_pull,
+)
+from hermes_handoff_rules import strict_json
+
+__all__ = [
+ "CONFIRMATION",
+ "DISCOVERY_PAGE_BUDGET",
+ "DISCOVERY_PAGE_SIZE",
+ "REF_PREFIX",
+ "ArmRequest",
+ "ArmingError",
+ "assert_push_target_allowed",
+ "bounded_read",
+ "normalise_branch",
+ "preflight",
+ "run_armed",
+]
+
+DRAFT_TITLE_PREFIX = "WIP: "
+DISCOVERY_PAGE_SIZE = 50
+DISCOVERY_PAGE_BUDGET = 20
+
+
+def _spec(identifier: str, title: str) -> CheckSpec:
+ return CheckSpec(
+ id=f"ephemeral.{identifier}",
+ title=title,
+ group="ephemeral",
+ rule="armed",
+ steps=(),
+ mandatory=True,
+ scope=EPHEMERAL,
+ )
+
+
+def _result(
+ identifier: str,
+ title: str,
+ status: str,
+ reason: str,
+ outcomes: Sequence[Outcome] = (),
+ evidence: dict[str, Any] | None = None,
+) -> CheckResult:
+ return CheckResult(
+ spec=_spec(identifier, title),
+ status=status,
+ reason=reason,
+ evidence=evidence or {},
+ outcomes=list(outcomes),
+ )
+
+
+def _protected_refusal_result() -> CheckResult:
+ accepted: list[str] = []
+ for candidate in ("main", "master", "refs/heads/main", "HEAD", f"{REF_PREFIX}/x"):
+ try:
+ assert_push_target_allowed(candidate)
+ except ArmingError:
+ continue
+ accepted.append(candidate)
+ return _result(
+ "protected-branch-refusal",
+ "Arming against a protected branch is refused before any network call",
+ FAIL if accepted else PASS,
+ f"guard accepted {accepted}"
+ if accepted
+ else "every protected and malformed target was refused",
+ evidence={"accepted": accepted},
+ )
+
+
+def _detail(outcome: Outcome) -> str:
+ return (
+ outcome.error or outcome.combined[:200] or f"exit status {outcome.returncode}"
+ )
+
+
+def _pr_problems(payload: Any, request: ArmRequest) -> list[str]:
+ if not isinstance(payload, dict):
+ return ["pull request is not an object"]
+ wanted = {
+ "state": "open",
+ "draft": True,
+ "merged": False,
+ "base": {"ref": EXPECTED_BASE, "sha": request.expected_base_sha},
+ "head": {"ref": request.ref, "sha": request.expected_head},
+ }
+ problems: list[str] = []
+ if (
+ not isinstance(payload.get("number"), int)
+ or isinstance(payload.get("number"), bool)
+ or payload["number"] <= 0
+ ):
+ problems.append("number is malformed")
+ for field in ("state", "draft", "merged"):
+ if (
+ type(payload.get(field)) is not type(wanted[field])
+ or payload.get(field) != wanted[field]
+ ):
+ problems.append(f"{field} does not match")
+ for group, values in (("base", wanted["base"]), ("head", wanted["head"])):
+ observed = payload.get(group)
+ if not isinstance(observed, dict):
+ problems.append(f"{group} is malformed")
+ continue
+ for field, value in values.items():
+ if observed.get(field) != value:
+ problems.append(f"{group}.{field} does not match")
+ return problems
+
+
+def _created_number(outcome: Outcome, request: ArmRequest) -> tuple[int, str]:
+ """Return the number the create response itself reports.
+
+ The create response is the only source that names a pull request the harness
+ definitely made. Relying on the index alone means an index that fails or
+ answers ambiguously leaves an unnamed, unclosed pull request behind.
+ """
+ if not outcome.ok:
+ return (0, f"pull-request creation failed: {_detail(outcome)}")
+ try:
+ payload = strict_json(outcome.stdout)
+ except ValueError as exc:
+ return (0, f"pull-request creation returned malformed JSON: {exc}")
+ problems = _pr_problems(payload, request)
+ if problems:
+ return (0, f"created pull request is invalid: {', '.join(problems)}")
+ return (payload["number"], "")
+
+
+def _index_pages(
+ runner: Runner, vantage: Vantage, api: str, reserve: float
+) -> tuple[list[Outcome], list[Any], str]:
+ """Read every page of the pull index; a truncated page can hide a match."""
+ outcomes: list[Outcome] = []
+ rows: list[Any] = []
+ for page in range(1, DISCOVERY_PAGE_BUDGET + 1):
+ outcome = runner.run(
+ (
+ GITEA_CLIENT,
+ "GET",
+ f"{api}/pulls?state=all&limit={DISCOVERY_PAGE_SIZE}&page={page}",
+ ),
+ vantage,
+ reserve_seconds=reserve,
+ )
+ outcomes.append(outcome)
+ if not outcome.ok:
+ return (outcomes, [], f"discovery page {page} failed: {_detail(outcome)}")
+ try:
+ payload = strict_json(outcome.stdout)
+ except ValueError as exc:
+ return (outcomes, [], f"discovery page {page} is malformed JSON: {exc}")
+ if not isinstance(payload, list):
+ return (outcomes, [], f"discovery page {page} did not return an array")
+ rows.extend(payload)
+ if len(payload) < DISCOVERY_PAGE_SIZE:
+ return (outcomes, rows, "")
+ return (outcomes, [], "pull-request discovery exceeded its page budget")
+
+
+def _residue(request: ArmRequest, numbers: Sequence[int], api: str) -> dict[str, Any]:
+ """Name the exact residue and the exact commands that remove it by hand."""
+ manual = [
+ f"{GITEA_CLIENT} PATCH {api}/pulls/{number} --field state=closed"
+ for number in numbers
+ ]
+ if not numbers:
+ manual.append(
+ f"{GITEA_CLIENT} GET {api}/pulls?state=all&limit={DISCOVERY_PAGE_SIZE}"
+ f" then close by hand every pull whose head ref is {request.ref}"
+ )
+ manual.append(f"{GITEA_CLIENT} DELETE {api}/branches/{request.ref}")
+ return {"residue_ref": request.ref, "manual_cleanup": manual}
+
+
+def _match_pulls(rows: Sequence[Any], request: ArmRequest) -> tuple[list[int], str]:
+ matches: list[int] = []
+ for index, item in enumerate(rows):
+ if (
+ not isinstance(item, dict)
+ or not isinstance(item.get("head"), dict)
+ or not isinstance(item.get("base"), dict)
+ ):
+ return ([], f"pull-request discovery row {index + 1} is malformed")
+ if item["head"].get("ref") != request.ref:
+ continue
+ problems = _pr_problems(item, request)
+ if problems:
+ return ([], f"discovered pull request is invalid: {', '.join(problems)}")
+ matches.append(item["number"])
+ if len(matches) != 1:
+ return (
+ matches,
+ f"expected one exact draft pull request, discovered {len(matches)}",
+ )
+ return (matches, "")
+
+
+def _closed_pr_problems(payload: Any, request: ArmRequest) -> list[str]:
+ """Validate the same exact PR identity with state changed to closed."""
+ problems = _pr_problems(payload, request)
+ if isinstance(payload, dict) and payload.get("state") == "closed":
+ problems = [
+ problem for problem in problems if problem != "state does not match"
+ ]
+ elif "state does not match" not in problems:
+ problems.append("state is not closed")
+ return problems
+
+
+def _cleanup(
+ runner: Runner,
+ vantage: Vantage,
+ request: ArmRequest,
+ api: str,
+ numbers: Sequence[int],
+ discovery_error: str = "",
+) -> CheckResult:
+ outcomes: list[Outcome] = []
+ problems = [discovery_error] if discovery_error else []
+ for number in sorted(set(numbers)):
+ closed = runner.run(
+ (GITEA_CLIENT, "PATCH", f"{api}/pulls/{number}", "--field", "state=closed"),
+ vantage,
+ )
+ outcomes.append(closed)
+ if not closed.ok:
+ problems.append(f"pull request #{number} close failed")
+ continue
+ try:
+ payload = strict_json(closed.stdout)
+ except ValueError:
+ problems.append(f"pull request #{number} close response is malformed")
+ continue
+ expected = _closed_pr_problems(payload, request)
+ if expected:
+ problems.append(f"pull request #{number} close response does not match")
+ deleted = runner.run(
+ (GITEA_CLIENT, "DELETE", f"{api}/branches/{request.ref}"), vantage
+ )
+ outcomes.append(deleted)
+ if not deleted.ok:
+ problems.append("ephemeral branch deletion failed")
+ remaining = runner.run(
+ ("git", "ls-remote", request.remote, f"refs/heads/{request.ref}"), vantage
+ )
+ outcomes.append(remaining)
+ if not remaining.ok or remaining.stdout.strip():
+ problems.append("ephemeral branch absence could not be verified")
+ for number in sorted(set(numbers)):
+ observed = runner.run((GITEA_CLIENT, "GET", f"{api}/pulls/{number}"), vantage)
+ outcomes.append(observed)
+ if not observed.ok:
+ problems.append(f"pull request #{number} closure could not be verified")
+ continue
+ try:
+ payload = strict_json(observed.stdout)
+ except ValueError:
+ problems.append(f"pull request #{number} verification is malformed")
+ continue
+ expected = _closed_pr_problems(payload, request)
+ if expected:
+ problems.append(f"pull request #{number} is not exactly closed")
+ return _result(
+ "cleanup-verified",
+ "Every ephemeral ref and pull request is removed and the removal is verified",
+ FAIL if problems else PASS,
+ "; ".join(problems)
+ if problems
+ else "the exact branch and draft pull request are gone",
+ outcomes,
+ {"ref": request.ref, "pull_requests": sorted(set(numbers))},
+ )
+
+
+def _nothing_created(branch: CheckResult, draft_reason: str) -> list[CheckResult]:
+ """Report the two results that follow a run which created no artifact."""
+ return [
+ branch,
+ _result(
+ "draft-pull-request",
+ "A draft pull request can be opened and closed",
+ NOT_RUN,
+ draft_reason,
+ ),
+ _result(
+ "cleanup-verified",
+ "Every ephemeral artifact is removed",
+ NOT_RUN,
+ "nothing was created",
+ ),
+ ]
+
+
+def run_armed(
+ runner: Runner, vantage: Vantage, request: ArmRequest
+) -> list[CheckResult]:
+ """Create, discover, validate, and clean one exact ephemeral branch/PR."""
+ guard = _protected_refusal_result()
+ branch_title = "A unique ephemeral feature branch can be pushed and removed"
+ if guard.status != PASS:
+ unrun = _result("feature-branch-push", branch_title, NOT_RUN, "guard failed")
+ return [guard, *_nothing_created(unrun, "protected-ref guard failed")]
+ api = f"/api/v1/repos/{request.repo}"
+ reserve = min(6000.0, max(300.0, runner.command_timeout * 10))
+ existing = runner.run(
+ ("git", "ls-remote", request.remote, f"refs/heads/{request.ref}"),
+ vantage,
+ reserve_seconds=reserve,
+ )
+ if not existing.ok or existing.stdout.strip():
+ reason = (
+ _detail(existing) if not existing.ok else "the ephemeral ref already exists"
+ )
+ refused = _result("feature-branch-push", branch_title, FAIL, reason, [existing])
+ return [guard, *_nothing_created(refused, "branch preflight failed")]
+ local_head = runner.run(
+ ("git", "rev-parse", "HEAD"), vantage, reserve_seconds=reserve
+ )
+ pushed = (
+ runner.run(
+ ("git", "push", request.remote, f"HEAD:refs/heads/{request.ref}"),
+ vantage,
+ reserve_seconds=reserve,
+ )
+ if local_head.ok and local_head.stdout.strip() == request.expected_head
+ else Outcome(
+ argv=("git", "push"),
+ vantage=vantage.name,
+ error="local HEAD changed after static preflight",
+ )
+ )
+ verified = runner.run(
+ ("git", "ls-remote", request.remote, f"refs/heads/{request.ref}"),
+ vantage,
+ reserve_seconds=reserve,
+ )
+ branch_ok = verified.ok and verified.stdout.split() == [
+ request.expected_head,
+ f"refs/heads/{request.ref}",
+ ]
+ branch_result = _result(
+ "feature-branch-push",
+ branch_title,
+ PASS if branch_ok else FAIL,
+ "the exact reviewed HEAD was pushed"
+ if branch_ok
+ else "push could not be verified exactly",
+ [local_head, pushed, verified],
+ {"ref": request.ref, "head": request.expected_head},
+ )
+ numbers: list[int] = []
+ problems: list[str] = ["branch creation was not verified"]
+ create_outcomes: list[Outcome] = []
+ if branch_ok:
+ created = runner.run(
+ (
+ GITEA_CLIENT,
+ "POST",
+ f"{api}/pulls",
+ "--field",
+ f"title={DRAFT_TITLE_PREFIX}Hermes handoff acceptance ephemeral probe",
+ "--field",
+ f"head={request.ref}",
+ "--field",
+ f"base={EXPECTED_BASE}",
+ "--field",
+ "body=Ephemeral acceptance probe. Closed and deleted by the harness.",
+ ),
+ vantage,
+ reserve_seconds=reserve,
+ )
+ number, create_error = _created_number(created, request)
+ pages, rows, index_error = _index_pages(runner, vantage, api, reserve)
+ found, match_error = (
+ _match_pulls(rows, request) if not index_error else ([], "")
+ )
+ create_outcomes = [created, *pages]
+ # Either source alone names a pull request this run definitely made: the
+ # ephemeral ref was proven absent in preflight, so any pull on it is ours.
+ numbers = sorted({item for item in (number, *found) if item})
+ problems = [text for text in (index_error, match_error) if text]
+ if number and found and found != [number]:
+ problems.append(f"created #{number} is not the discovered {found}")
+ if not numbers:
+ problems.append(
+ create_error or "no exact draft pull request was identified"
+ )
+ for item in numbers:
+ register_ephemeral_pull(item)
+ exact = len(numbers) == 1 and not problems
+ draft_result = _result(
+ "draft-pull-request",
+ "A draft pull request can be opened and closed against the ephemeral branch",
+ PASS if exact else FAIL,
+ f"discovered exact draft pull request #{numbers[0]}"
+ if exact
+ else "; ".join(problems),
+ create_outcomes,
+ {"pull_requests": numbers}
+ if exact
+ else {"pull_requests": numbers, **_residue(request, numbers, api)},
+ )
+ cleanup = _cleanup(runner, vantage, request, api, numbers, "; ".join(problems))
+ return [guard, branch_result, draft_result, cleanup]
diff --git a/scripts/ops/hermes_handoff_evaluators.py b/scripts/ops/hermes_handoff_evaluators.py
new file mode 100644
index 00000000..ea0a21b8
--- /dev/null
+++ b/scripts/ops/hermes_handoff_evaluators.py
@@ -0,0 +1,497 @@
+#!/usr/bin/env python3
+"""Text-shaped evaluators for the handoff acceptance harness.
+
+These rules cover evidence that arrives as a refusal, an exit status, or a list
+of names. The ``denied`` rule is the load-bearing one: it refuses to classify on
+an authorization review alone — ``kubectl auth can-i`` reports what RBAC says,
+not what the API server does — so a real request must have been made and
+refused before it will pass.
+
+An absence is only evidence when the probe observed something, so a name step
+that exits 0 with no lines is ``NOT_RUN``: drift produces silence, not a pass.
+
+Importing this module also registers the JSON-shaped rules, so a caller only has
+to import one place to have the full catalog vocabulary available.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+from hermes_handoff_exec import Outcome
+from hermes_handoff_json_rules import evaluate_flux_health # noqa: F401 (registers rules)
+from hermes_handoff_model import ATTEMPT, FAIL, NOT_RUN, PASS, REVIEW, CheckSpec
+from hermes_handoff_rules import (
+ EVALUATORS,
+ Evaluation,
+ dotted,
+ evaluate,
+ evaluator,
+ exact_equal,
+ lines,
+ looks_denied,
+ missing_steps,
+ step_keys,
+ strict_json,
+)
+
+__all__ = ["EVALUATORS", "Evaluation", "evaluate", "evaluator"]
+
+
+@evaluator("denied")
+def evaluate_denied(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
+ """Pass only when the review says no and a real request was refused."""
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ attempts = step_keys(spec, ATTEMPT)
+ if not attempts:
+ return Evaluation(
+ NOT_RUN, "deny checks require a real attempt, not only a review"
+ )
+ evidence: dict[str, Any] = {}
+ for key in step_keys(spec, REVIEW):
+ verdict = outcomes[key].stdout.strip().lower()
+ evidence[key] = verdict
+ if verdict != "no":
+ return Evaluation(
+ FAIL,
+ f"authorization review {key} reported {verdict or 'nothing'}",
+ evidence,
+ )
+ for key in attempts:
+ outcome = outcomes[key]
+ if outcome.ok:
+ return Evaluation(
+ FAIL, f"attempt {key} succeeded but must be refused", evidence
+ )
+ if not looks_denied(outcome):
+ return Evaluation(
+ NOT_RUN,
+ f"attempt {key} failed without a recognisable refusal (rc={outcome.returncode})",
+ evidence,
+ )
+ evidence[key] = "refused"
+ return Evaluation(PASS, "review and live attempt were both refused", evidence)
+
+
+@evaluator("review_denied")
+def evaluate_review_denied(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
+ """Pass only when every side-effect-free authorization review says no."""
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ reviews = step_keys(spec, REVIEW)
+ if not reviews:
+ return Evaluation(NOT_RUN, "a denial check requires an authorization review")
+ evidence: dict[str, str] = {}
+ for key in reviews:
+ verdict = outcomes[key].stdout.strip().lower()
+ evidence[key] = verdict
+ if verdict != "no":
+ return Evaluation(
+ FAIL,
+ f"authorization review {key} reported {verdict or 'nothing'}",
+ evidence,
+ )
+ return Evaluation(PASS, "every authorization review was denied", evidence)
+
+
+@evaluator("allowed")
+def evaluate_allowed(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
+ """Pass when every required step succeeded and every review said yes."""
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ evidence: dict[str, Any] = {}
+ for step in spec.steps:
+ outcome = outcomes[step.key]
+ if step.kind == REVIEW:
+ verdict = outcome.stdout.strip().lower()
+ evidence[step.key] = verdict
+ if verdict != "yes":
+ return Evaluation(
+ FAIL,
+ f"authorization review {step.key} reported {verdict or 'nothing'}",
+ evidence,
+ )
+ continue
+ if not outcome.ok:
+ return Evaluation(
+ FAIL, f"step {step.key} failed (rc={outcome.returncode})", evidence
+ )
+ evidence[step.key] = "succeeded"
+ return Evaluation(PASS, "required access is present", evidence)
+
+
+def _matched(observed: set[str], patterns: tuple[str, ...]) -> set[str]:
+ return {name for pattern in patterns for name in observed if pattern in name}
+
+
+def _line_step(
+ spec: CheckSpec, outcomes: dict[str, Outcome]
+) -> tuple[set[str], str] | Evaluation:
+ """Return a step's distinct lines, or the NOT_RUN that silence must produce."""
+ key = spec.expect["step"]
+ outcome = outcomes[key]
+ if not outcome.ok:
+ return Evaluation(NOT_RUN, f"step {key} failed (rc={outcome.returncode})")
+ observed = set(lines(outcome))
+ if not observed:
+ return Evaluation(NOT_RUN, f"step {key} exited 0 with no lines to examine")
+ return (observed, key)
+
+
+@evaluator("names_absent")
+def evaluate_names_absent(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
+ """Pass when a step observed names and none of them is forbidden."""
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ resolved = _line_step(spec, outcomes)
+ if isinstance(resolved, Evaluation):
+ return resolved
+ observed, _ = resolved
+ offenders = sorted(
+ (observed & set(spec.expect.get("names", ())))
+ | _matched(observed, tuple(spec.expect.get("contains", ())))
+ )
+ evidence = {"observed_count": len(observed), "offenders": offenders}
+ if offenders:
+ return Evaluation(
+ FAIL, f"forbidden names present: {', '.join(offenders)}", evidence
+ )
+ return Evaluation(PASS, "no forbidden names are present", evidence)
+
+
+@evaluator("names_present")
+def evaluate_names_present(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
+ """Pass when every required name, and every required substring, appears."""
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ resolved = _line_step(spec, outcomes)
+ if isinstance(resolved, Evaluation):
+ return resolved
+ observed, _ = resolved
+ missing = sorted(set(spec.expect.get("names", ())) - observed)
+ missing += sorted(
+ pattern
+ for pattern in spec.expect.get("contains", ())
+ if not any(pattern in name for name in observed)
+ )
+ evidence = {"observed_count": len(observed), "missing": missing}
+ if missing:
+ return Evaluation(
+ FAIL, f"required names absent: {', '.join(missing)}", evidence
+ )
+ return Evaluation(PASS, "every required name is present", evidence)
+
+
+@evaluator("stdout_matches")
+def evaluate_stdout_matches(
+ spec: CheckSpec, outcomes: dict[str, Outcome]
+) -> Evaluation:
+ """Compare a step's trimmed stdout against an expected value or set."""
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ key = spec.expect["step"]
+ outcome = outcomes[key]
+ if not outcome.ok:
+ return Evaluation(NOT_RUN, f"step {key} failed (rc={outcome.returncode})")
+ actual = outcome.stdout.strip()
+ expected = spec.expect["equals"]
+ allowed = expected if isinstance(expected, list) else [expected]
+ evidence = {"observed": actual}
+ if actual not in allowed:
+ return Evaluation(
+ FAIL, f"observed {actual!r}, expected one of {allowed!r}", evidence
+ )
+ return Evaluation(PASS, "output matches the expected value", evidence)
+
+
+@evaluator("distinct_count")
+def evaluate_distinct_count(
+ spec: CheckSpec, outcomes: dict[str, Outcome]
+) -> Evaluation:
+ """Pass when a step yields enough distinct rows — and, optionally, exactly N.
+
+ Distinctness is the point for placement checks: three worker pods pinned to
+ one node satisfy a replica count and defeat the isolation the replica count
+ was standing in for.
+ """
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ key = spec.expect["step"]
+ outcome = outcomes[key]
+ if not outcome.ok:
+ return Evaluation(NOT_RUN, f"step {key} failed (rc={outcome.returncode})")
+ values = lines(outcome)
+ distinct = sorted(set(values))
+ minimum = spec.expect["minimum"]
+ evidence = {
+ "total": len(values),
+ "distinct": len(distinct),
+ "values": distinct[:16],
+ }
+ if not values:
+ return Evaluation(NOT_RUN, f"step {key} produced no rows to count", evidence)
+ if len(distinct) < minimum:
+ return Evaluation(
+ FAIL,
+ f"{len(distinct)} distinct values, expected at least {minimum}",
+ evidence,
+ )
+ total_equals = spec.expect.get("total_equals")
+ if total_equals is not None and len(values) != total_equals:
+ return Evaluation(
+ FAIL, f"{len(values)} rows, expected exactly {total_equals}", evidence
+ )
+ return Evaluation(PASS, f"{len(distinct)} distinct values observed", evidence)
+
+
+@evaluator("lines_match")
+def evaluate_lines_match(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
+ """Pass when every data row of a tabular listing matches a shape.
+
+ Some surfaces only speak tables. A blank leading column is exactly what the
+ session-render regression looked like, so asserting the shape of each row is
+ a real check rather than a proxy for one.
+ """
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ key = spec.expect["step"]
+ outcome = outcomes[key]
+ if not outcome.ok:
+ return Evaluation(NOT_RUN, f"step {key} failed (rc={outcome.returncode})")
+ pattern = re.compile(spec.expect["pattern"])
+ rows = outcome.stdout.splitlines()[spec.expect.get("skip", 0) :]
+ rows = [row for row in rows if row.strip()]
+ offenders = [row[:60] for row in rows if not pattern.search(row)]
+ evidence = {"rows": len(rows), "offenders": offenders[:8]}
+ if len(rows) < spec.expect.get("minimum", 1):
+ return Evaluation(
+ NOT_RUN,
+ f"step {key} produced {len(rows)} data rows, expected at least "
+ f"{spec.expect.get('minimum', 1)}",
+ evidence,
+ )
+ if offenders:
+ return Evaluation(
+ FAIL, f"{len(offenders)} row(s) do not match the expected shape", evidence
+ )
+ return Evaluation(PASS, f"all {len(rows)} rows match the expected shape", evidence)
+
+
+@evaluator("vantages_agree")
+def evaluate_vantages_agree(
+ spec: CheckSpec, outcomes: dict[str, Outcome]
+) -> Evaluation:
+ """Pass only when two independent observations report the same fact.
+
+ A disagreement is a finding in its own right: it usually means the manifest
+ on record and the workload actually running have drifted apart, and either
+ one alone would have looked clean.
+ """
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ left_key, right_key = spec.expect["steps"]
+ observations: dict[str, list[str]] = {}
+ for key in (left_key, right_key):
+ outcome = outcomes[key]
+ if not outcome.ok:
+ return Evaluation(NOT_RUN, f"step {key} failed (rc={outcome.returncode})")
+ observations[key] = sorted(lines(outcome))
+ evidence = {key: value[:16] for key, value in observations.items()}
+ if observations[left_key] != observations[right_key]:
+ return Evaluation(
+ FAIL, f"vantages disagree: {left_key} and {right_key} differ", evidence
+ )
+ if not observations[left_key]:
+ return Evaluation(
+ NOT_RUN, "both vantages returned nothing to compare", evidence
+ )
+ return Evaluation(PASS, "both vantages report the same state", evidence)
+
+
+@evaluator("not_armed")
+def evaluate_not_armed(spec: CheckSpec, _outcomes: dict[str, Outcome]) -> Evaluation:
+ """Report the fixed NOT_RUN a default, read-only run must produce."""
+ reason = spec.expect.get("reason", "ephemeral mutation mode is not armed")
+ return Evaluation(NOT_RUN, reason, {"armed": False})
+
+
+# This validator is one compact, linear state machine so every exit observes
+# only fully validated state.
+# fmt: off
+@evaluator("pool_topology")
+def evaluate_pool_topology(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
+ """Require exactly three Ready ordinal workers on distinct nodes and claims."""
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ state = outcomes[spec.expect["state_step"]].stdout.strip().split("\t")
+ if len(state) != 4:
+ return Evaluation(NOT_RUN, "StatefulSet projection is malformed")
+ expected = spec.expect.get("replicas", 3)
+ try:
+ replicas, ready, current = (int(value) for value in state[:3])
+ except ValueError:
+ return Evaluation(NOT_RUN, "StatefulSet replica fields are malformed")
+ evidence: dict[str, Any] = {"replicas": replicas, "ready": ready, "current": current,
+ "automountServiceAccountToken": state[3]}
+ if (replicas, ready, current, state[3].lower()) != (expected, expected, expected, "false"):
+ return Evaluation(FAIL, "worker replica or tokenless state does not match the contract", evidence)
+ workers: dict[int, tuple[str, str]] = {}
+ names: list[str] = []
+ for number, row in enumerate(lines(outcomes[spec.expect["worker_step"]]), 1):
+ columns = row.split("\t")
+ if len(columns) != 5:
+ return Evaluation(NOT_RUN, f"worker row {number} is malformed", evidence)
+ name, node, phase, ready_state, claims = columns
+ match = re.fullmatch(rf"{re.escape(spec.expect['name'])}-(\d+)", name)
+ claim_list = [claim for claim in claims.split(",") if claim]
+ if (not match or not node or phase != "Running" or ready_state != "True"
+ or len(claim_list) != 1):
+ return Evaluation(FAIL, f"worker row {number} is not a Ready isolated worker", evidence)
+ ordinal = int(match.group(1))
+ if ordinal in workers:
+ return Evaluation(FAIL, f"worker ordinal {ordinal} is duplicated", evidence)
+ workers[ordinal] = (node, claim_list[0])
+ names.append(name)
+ evidence.update({"workers": sorted(names), "nodes": sorted({v[0] for v in workers.values()}),
+ "claims": sorted({v[1] for v in workers.values()})})
+ if set(workers) != set(range(expected)):
+ return Evaluation(FAIL, f"worker ordinals are not exactly 0..{expected - 1}", evidence)
+ if (len({v[0] for v in workers.values()}) != expected
+ or len({v[1] for v in workers.values()}) != expected):
+ return Evaluation(FAIL, "workers do not occupy distinct nodes and claims", evidence)
+ return Evaluation(PASS, "three Ready workers have distinct nodes, ordinals, and claims", evidence)
+# fmt: on
+
+
+_SHA_RE = re.compile(r"[0-9a-f]{40}\Z")
+_DIGEST_RE = re.compile(r"sha256:[0-9a-f]{64}\Z")
+_IMAGE_RE = re.compile(
+ r"[^@\s]+:git-([0-9a-f]{40})-build-([1-9][0-9]*)@(sha256:[0-9a-f]{64})\Z"
+)
+
+
+def _strict_object(outcome: Outcome, label: str) -> dict[str, Any] | Evaluation:
+ try:
+ payload = strict_json(outcome.stdout)
+ except ValueError as exc:
+ return Evaluation(NOT_RUN, f"{label} JSON is malformed: {exc}")
+ if not isinstance(payload, dict):
+ return Evaluation(NOT_RUN, f"{label} JSON is not an object")
+ return payload
+
+
+# fmt: off
+@evaluator("release_lineage")
+def evaluate_release_lineage(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
+ """Atomically bind Git, PR, image, build, Flux, and running revision evidence."""
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ expected = spec.expect
+ main_sha = expected["main_sha"]
+ head_sha = expected["head_sha"]
+ image = expected["image"]
+ build_sha = expected["build_sha"]
+ revision = str(expected["deployment_revision"])
+ digest = image.rsplit("@", 1)[-1]
+ image_match = _IMAGE_RE.fullmatch(image)
+ if not (_SHA_RE.fullmatch(main_sha) and _SHA_RE.fullmatch(head_sha)
+ and _SHA_RE.fullmatch(build_sha) and _DIGEST_RE.fullmatch(digest)
+ and image_match and image_match.group(1) == build_sha and build_sha == main_sha):
+ return Evaluation(NOT_RUN, "release lineage expectations are malformed")
+ remote = outcomes["remote-main"].stdout.strip().split()
+ if remote != [main_sha, "refs/heads/main"]:
+ return Evaluation(FAIL, "remote main does not match the fixed expected SHA",
+ {"remote_main": remote[:2]})
+ pr = _strict_object(outcomes["reviewed-pr"], "reviewed PR")
+ if isinstance(pr, Evaluation):
+ return pr
+ fields = {
+ "number": expected["pr_number"],
+ "state": "open",
+ "draft": True,
+ "merged": False,
+ "base.ref": "main",
+ "base.sha": main_sha,
+ "head.ref": expected["head_ref"],
+ "head.sha": head_sha,
+ }
+ for path, wanted in fields.items():
+ try:
+ actual = dotted(pr, path)
+ except (KeyError, ValueError, IndexError):
+ return Evaluation(NOT_RUN, f"reviewed PR is missing {path}")
+ if not exact_equal(actual, wanted):
+ return Evaluation(FAIL, f"reviewed PR {path} does not match", {path: actual})
+ if outcomes["build-source"].stdout.strip() != build_sha:
+ return Evaluation(FAIL, "build source SHA does not match the fixed release build SHA")
+ deployment = outcomes["deployment"].stdout.strip().split("\t")
+ if len(deployment) != 6:
+ return Evaluation(NOT_RUN, "Deployment projection is malformed")
+ generation, observed, replicas, ready, deployed_revision, deployed_image = deployment
+ try:
+ current = (int(generation) > 0 and generation == observed
+ and int(replicas) > 0 and replicas == ready)
+ except ValueError:
+ return Evaluation(NOT_RUN, "Deployment numeric fields are malformed")
+ if not current or (deployed_revision, deployed_image) != (revision, image):
+ return Evaluation(FAIL, "Deployment lineage does not match the release contract")
+ replica_hash = ""
+ for number, row in enumerate(lines(outcomes["replicasets"]), 1):
+ columns = row.split("\t")
+ if len(columns) != 6:
+ return Evaluation(NOT_RUN, f"ReplicaSet row {number} is malformed")
+ _, rs_revision, rs_ready, rs_available, rs_image, rs_hash = columns
+ if rs_revision != revision:
+ continue
+ if (rs_ready != replicas or rs_available != replicas or rs_image != image or not rs_hash):
+ return Evaluation(FAIL, "active ReplicaSet does not match the Deployment lineage")
+ if replica_hash:
+ return Evaluation(FAIL, "more than one ReplicaSet claims the running revision")
+ replica_hash = rs_hash
+ if not replica_hash:
+ return Evaluation(FAIL, "running Deployment revision has no matching ReplicaSet")
+ pod_rows = lines(outcomes["pods"])
+ if len(pod_rows) != int(replicas):
+ return Evaluation(FAIL, "running pod count does not match the Deployment")
+ pods: list[str] = []
+ for number, row in enumerate(pod_rows, 1):
+ columns = row.split("\t")
+ if len(columns) != 6:
+ return Evaluation(NOT_RUN, f"pod row {number} is malformed")
+ name, phase, ready_state, pod_image, image_id, pod_hash = columns
+ if phase != "Running" or ready_state != "True":
+ return Evaluation(FAIL, f"pod {name or number} is not Ready")
+ if (pod_image, pod_hash) != (image, replica_hash) or not image_id.endswith(f"@{digest}"):
+ return Evaluation(FAIL, f"pod {name or number} lineage does not match")
+ pods.append(name)
+ flux = outcomes["flux"].stdout.strip().split("\t")
+ if len(flux) != 6:
+ return Evaluation(NOT_RUN, "Flux lineage projection is malformed")
+ name, generation, observed, suspended, ready_state, applied = flux
+ if (not name or generation != observed or suspended.lower() == "true" or ready_state != "True"):
+ return Evaluation(FAIL, "Flux source is not current and Ready")
+ match = re.search(r"sha1:([0-9a-f]{40})\Z", applied)
+ if not match or match.group(1) != main_sha:
+ return Evaluation(FAIL, "Flux applied revision is not the expected remote main")
+ return Evaluation(PASS, "Git, PR, image, build, Flux, and running revision are bound",
+ {"main_sha": main_sha, "head_sha": head_sha, "image_digest": digest,
+ "build_sha": build_sha, "deployment_revision": revision, "pods": pods})
+# fmt: on
+
+
+# Two observations of the same fact agree whether or not they came from
+# different vantages; the dual-vantage case is just the important one.
+EVALUATORS["steps_agree"] = evaluate_vantages_agree
diff --git a/scripts/ops/hermes_handoff_exec.py b/scripts/ops/hermes_handoff_exec.py
new file mode 100644
index 00000000..d3edbe09
--- /dev/null
+++ b/scripts/ops/hermes_handoff_exec.py
@@ -0,0 +1,482 @@
+#!/usr/bin/env python3
+"""Bounded, screened, provenance-attested command execution.
+
+It bounds pipes, times out and reaps the process group, and discards truncated output.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import math
+import os
+import selectors
+import shlex
+import shutil
+import signal
+import stat
+import subprocess
+import time
+from collections.abc import Callable, Mapping, Sequence
+from contextlib import suppress
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from hermes_handoff_policy import GITEA_CLIENT, READ_ONLY, PolicyError, check_argv
+from hermes_handoff_redaction import (
+ DEFAULT_MAX_BYTES,
+ TRUNCATION_NOTE,
+ redact,
+ safe_text,
+ scrub,
+)
+
+DEFAULT_COMMAND_TIMEOUT = 25.0
+DEFAULT_RUN_DEADLINE = 900.0
+DEFAULT_CLEANUP_RESERVE = 90.0
+MAX_COMMAND_TIMEOUT = 600.0
+MAX_RUN_DEADLINE = 7200.0
+MAX_OUTPUT_BYTES = 8 * 1024 * 1024
+READ_CHUNK = 64 * 1024
+PIPE_DRAIN_SECONDS = 0.25
+
+SAFE_PATH = "/opt/data/tools/bin:/opt/hermes/.venv/bin:/opt/coordinator:/usr/local/bin:/usr/bin:/bin"
+# These exact allowlists are intentionally dense: reviewers compare them as a
+# single provenance boundary rather than as extensible application config.
+# fmt: off
+ENV_PASSTHROUGH = ("KUBECONFIG", "LANG", "LC_ALL", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE")
+FIXED_ENVIRONMENT = {"GIT_ASKPASS": "/opt/coordinator/gitea_askpass.sh",
+ "GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_NOSYSTEM": "1",
+ "GIT_TERMINAL_PROMPT": "0", "GITEA_BASE_URL": "https://scm.bstein.dev",
+ "LC_ALL": "C", "PATH": SAFE_PATH}
+EXPECTED_PATHS = {
+ "kubectl": {"/opt/data/tools/bin/kubectl", "/usr/local/bin/kubectl", "/usr/bin/kubectl"},
+ "git": {"/usr/bin/git", "/bin/git"},
+ "hermes": {"/opt/hermes/.venv/bin/hermes"},
+ "sh": {"/usr/bin/sh", "/bin/sh"},
+ GITEA_CLIENT: {GITEA_CLIENT},
+}
+EXPECTED_SHA256 = {
+ "kubectl": {"3d514dbae5dc8c09f773df0ef0f5d449dfad05b3aca5c96b13565f886df345fd"},
+ "git": {"a0e562e4bd3c4c79379e91d8c07a10104b2cefe8fac966dc6bd4874a57a807f3"},
+ "sh": {"367967c823a0c391e5049b15a67c6a0a629c88b9b6dcdca75ef13ac9d65334b1"},
+ "hermes": {"c8419290ef7f1a95f59eadcea9f58f446b1e6535a7f4ffee9f1c0cb1172335e4"},
+ GITEA_CLIENT: {"f0943db4e81ce96908e94b0f0e78aa4b5ef95d6c4f7803f9f74a43e761576d85"},
+}
+
+DEADLINE_ERROR = "deadline-exceeded"
+TIMEOUT_ERROR = "timeout"
+OPERATOR = "operator"
+SELF = "self"
+CONTEXT_AWARE_BINARIES = ("kubectl",)
+POD_COMMAND_PATHS = {"kubectl": "/usr/local/bin/kubectl", "hermes": "/opt/hermes/.venv/bin/hermes",
+ "git": "/usr/bin/git", "sh": "/bin/sh", GITEA_CLIENT: GITEA_CLIENT}
+# fmt: on
+
+
+@dataclass(frozen=True)
+class Attestation:
+ """The exact outer executable used for a probe."""
+
+ path: str
+ sha256: str
+
+
+@dataclass(frozen=True)
+class Capture:
+ """Raw bounded process result returned by an executor implementation."""
+
+ returncode: int
+ stdout: bytes = b""
+ stderr: bytes = b""
+ stdout_truncated: bool = False
+ stderr_truncated: bool = False
+ timed_out: bool = False
+
+
+@dataclass(frozen=True)
+class Outcome:
+ """The screened, bounded result of one probe command."""
+
+ argv: tuple[str, ...]
+ vantage: str
+ returncode: int | None = None
+ stdout: str = ""
+ stderr: str = ""
+ truncated: bool = False
+ duration_ms: int = 0
+ error: str | None = None
+ executable_path: str = ""
+ executable_sha256: str = ""
+
+ @property
+ def ok(self) -> bool:
+ return self.error is None and self.returncode == 0 and not self.truncated
+
+ @property
+ def ran(self) -> bool:
+ return self.error is None
+
+ @property
+ def combined(self) -> str:
+ return "\n".join(part for part in (self.stdout, self.stderr) if part)
+
+ def as_dict(self) -> dict[str, object]:
+ return scrub(
+ {
+ "command": redact(shlex.join(self.argv)),
+ "vantage": self.vantage,
+ "returncode": self.returncode,
+ "stdout": self.stdout,
+ "stderr": self.stderr,
+ "truncated": self.truncated,
+ "duration_ms": self.duration_ms,
+ "error": self.error,
+ "executable_path": self.executable_path,
+ "executable_sha256": self.executable_sha256,
+ }
+ )
+
+
+@dataclass(frozen=True)
+class Vantage:
+ """A named identity and transport used to observe a probe."""
+
+ name: str
+ prefix: tuple[str, ...] = ()
+ inserts: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
+ env: Mapping[str, str] = field(default_factory=dict)
+ command_paths: Mapping[str, str] = field(default_factory=dict)
+ description: str = ""
+
+ def wrap(self, argv: Sequence[str]) -> tuple[str, ...]:
+ command = tuple(argv)
+ if command:
+ replacement = self.command_paths.get(command[0])
+ if replacement:
+ command = (replacement, *command[1:])
+ extra = self.inserts.get(command[0].rsplit("/", 1)[-1])
+ if extra:
+ command = (command[0], *extra, *command[1:])
+ return (*self.prefix, *command)
+
+
+def _finite_number(name: str, value: Any, minimum: float, maximum: float) -> float:
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ raise ValueError(f"{name} must be numeric")
+ converted = float(value)
+ if not math.isfinite(converted) or not minimum <= converted <= maximum:
+ raise ValueError(
+ f"{name} must be finite and between {minimum:g} and {maximum:g}"
+ )
+ return converted
+
+
+def _byte_budget(value: Any) -> int:
+ if (
+ isinstance(value, bool)
+ or not isinstance(value, int)
+ or not 1 <= value <= MAX_OUTPUT_BYTES
+ ):
+ raise ValueError(
+ f"max_bytes must be an integer between 1 and {MAX_OUTPUT_BYTES}"
+ )
+ return value
+
+
+def build_environment(
+ vantage: Vantage, source: Mapping[str, str] | None = None
+) -> dict[str, str]:
+ """Build a deterministic child environment without caller-controlled PATH."""
+ origin = os.environ if source is None else source
+ environment = {name: origin[name] for name in ENV_PASSTHROUGH if name in origin}
+ environment.update(FIXED_ENVIRONMENT)
+ environment.update(vantage.env)
+ environment["PATH"] = SAFE_PATH
+ environment["GITEA_BASE_URL"] = FIXED_ENVIRONMENT["GITEA_BASE_URL"]
+ environment["GIT_ASKPASS"] = FIXED_ENVIRONMENT["GIT_ASKPASS"]
+ return environment
+
+
+# fmt: off
+def operator_vantage(kubeconfig: str | None = None, context: str | None = None) -> Vantage:
+ inserts = dict.fromkeys(CONTEXT_AWARE_BINARIES, ("--context", context)) if context else {}
+ detail = " ".join(part for part in (f"context={context}" if context else "",
+ "kubeconfig-pinned" if kubeconfig else "") if part)
+ return Vantage(name=OPERATOR, inserts=inserts,
+ env={"KUBECONFIG": kubeconfig} if kubeconfig else {},
+ description=f"operator {detail}".strip())
+
+
+def pod_vantage(namespace: str, pod: str, container: str, operator: Vantage | None = None) -> Vantage:
+ reach = operator.inserts.get("kubectl", ()) if operator else ()
+ return Vantage(name=SELF, prefix=("kubectl", *reach, "exec", "--namespace", namespace,
+ pod, "--container", container, "--"),
+ env=dict(operator.env) if operator else {}, command_paths=POD_COMMAND_PATHS,
+ description=f"in-pod {namespace}/{pod}[{container}]")
+# fmt: on
+
+
+def attest_executable(command: str, environment: Mapping[str, str]) -> Attestation:
+ """Resolve and hash an executable from a fixed, trusted path set."""
+ name = command if command == GITEA_CLIENT else command.rsplit("/", 1)[-1]
+ candidate = (
+ command if "/" in command else shutil.which(command, path=environment["PATH"])
+ )
+ if not candidate:
+ raise OSError(f"trusted executable is unavailable: {name}")
+ resolved = str(Path(candidate).resolve(strict=True))
+ allowed = {
+ str(Path(item).resolve(strict=False))
+ for item in EXPECTED_PATHS.get(name, set())
+ }
+ if resolved not in allowed:
+ raise OSError(f"executable resolved outside its trusted paths: {name}")
+ details = os.stat(resolved)
+ if (
+ not stat.S_ISREG(details.st_mode)
+ or details.st_mode & 0o022
+ or not details.st_mode & 0o111
+ ):
+ raise OSError(f"trusted executable has unsafe ownership mode: {name}")
+ digest = hashlib.sha256()
+ with open(resolved, "rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ observed = digest.hexdigest()
+ expected = EXPECTED_SHA256.get(name)
+ if not expected or observed not in expected:
+ raise OSError(f"executable digest is not release-attested: {name}")
+ return Attestation(resolved, observed)
+
+
+def _append_bounded(buffer: bytearray, chunk: bytes, limit: int) -> bool:
+ remaining = limit + 1 - len(buffer)
+ if remaining > 0:
+ buffer.extend(chunk[:remaining])
+ return len(buffer) > limit or len(chunk) > remaining
+
+
+def _signal_group(process: subprocess.Popen[bytes], signum: int) -> None:
+ with suppress(ProcessLookupError):
+ os.killpg(process.pid, signum)
+
+
+def execute_bounded(
+ argv: Sequence[str],
+ environment: Mapping[str, str],
+ timeout: float,
+ max_bytes: int,
+ clock: Callable[[], float] = time.monotonic,
+ popen: Callable[..., subprocess.Popen[bytes]] = subprocess.Popen,
+) -> Capture:
+ """Execute one process with bounded live pipe capture and group cleanup."""
+ process = popen(
+ list(argv),
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=dict(environment),
+ text=False,
+ close_fds=True,
+ start_new_session=True,
+ )
+ streams = {"stdout": process.stdout, "stderr": process.stderr}
+ buffers = {"stdout": bytearray(), "stderr": bytearray()}
+ truncated = {"stdout": False, "stderr": False}
+ selector = selectors.DefaultSelector()
+ for name, stream in streams.items():
+ if (
+ stream is not None
+ ): # pragma: no branch - PIPE was requested for both streams
+ os.set_blocking(stream.fileno(), False)
+ selector.register(stream, selectors.EVENT_READ, name)
+ expires = clock() + timeout
+ timed_out = False
+ try:
+ while process.poll() is None:
+ remaining = expires - clock()
+ if remaining <= 0:
+ timed_out = True
+ break
+ for key, _ in selector.select(min(remaining, 0.1)):
+ try:
+ chunk = os.read(key.fileobj.fileno(), READ_CHUNK)
+ except BlockingIOError: # pragma: no cover - readiness race, retry only
+ continue
+ if not chunk:
+ selector.unregister(key.fileobj)
+ continue
+ name = key.data
+ truncated[name] |= _append_bounded(buffers[name], chunk, max_bytes)
+ _signal_group(process, signal.SIGKILL if timed_out else signal.SIGTERM)
+ drain_until = clock() + PIPE_DRAIN_SECONDS
+ while selector.get_map() and clock() < drain_until:
+ for key, _ in selector.select(max(0.0, drain_until - clock())):
+ try:
+ chunk = os.read(key.fileobj.fileno(), READ_CHUNK)
+ except BlockingIOError: # pragma: no cover - readiness race, retry only
+ continue
+ if not chunk:
+ selector.unregister(key.fileobj)
+ continue
+ name = key.data
+ truncated[name] |= _append_bounded(buffers[name], chunk, max_bytes)
+ _signal_group(process, signal.SIGKILL)
+ returncode = process.wait(timeout=PIPE_DRAIN_SECONDS)
+ finally:
+ selector.close()
+ for stream in streams.values():
+ if (
+ stream is not None
+ ): # pragma: no branch - PIPE was requested for both streams
+ stream.close()
+ if (
+ process.poll() is None
+ ): # pragma: no branch - fail-safe for wait/selector exceptions
+ _signal_group(process, signal.SIGKILL)
+ process.wait()
+ return Capture(
+ returncode=returncode,
+ stdout=bytes(buffers["stdout"][:max_bytes]),
+ stderr=bytes(buffers["stderr"][:max_bytes]),
+ stdout_truncated=truncated["stdout"],
+ stderr_truncated=truncated["stderr"],
+ timed_out=timed_out,
+ )
+
+
+def _injected_executor(
+ spawn: Callable[..., subprocess.CompletedProcess[Any]],
+) -> Callable[..., Capture]:
+ """Adapt deterministic test doubles without weakening the production path."""
+
+ def execute(
+ argv: Sequence[str],
+ environment: Mapping[str, str],
+ timeout: float,
+ max_bytes: int,
+ ) -> Capture:
+ completed = spawn(
+ list(argv),
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ check=False,
+ env=dict(environment),
+ )
+ stdout = str(completed.stdout or "").encode()
+ stderr = str(completed.stderr or "").encode()
+ return Capture(
+ completed.returncode,
+ stdout[:max_bytes],
+ stderr[:max_bytes],
+ len(stdout) > max_bytes,
+ len(stderr) > max_bytes,
+ )
+
+ return execute
+
+
+def _screen_capture(data: bytes, truncated: bool, budget: int) -> str:
+ if truncated:
+ return TRUNCATION_NOTE.encode()[:budget].decode()
+ return safe_text(data.decode("utf-8", "replace"), budget)[0]
+
+
+class Runner:
+ """Run probes under policy, provenance, timeout, deadline, and byte budgets."""
+
+ def __init__(
+ self,
+ mode: str = READ_ONLY,
+ command_timeout: float = DEFAULT_COMMAND_TIMEOUT,
+ deadline_seconds: float = DEFAULT_RUN_DEADLINE,
+ max_bytes: int = DEFAULT_MAX_BYTES,
+ clock: Callable[[], float] = time.monotonic,
+ executor: Callable[..., Capture] | None = None,
+ spawn: Callable[..., subprocess.CompletedProcess[Any]] | None = None,
+ environ: Mapping[str, str] | None = None,
+ attestor: Callable[[str, Mapping[str, str]], Attestation] = attest_executable,
+ ) -> None:
+ self.mode = mode
+ self.command_timeout = _finite_number(
+ "command_timeout", command_timeout, 0.05, MAX_COMMAND_TIMEOUT
+ )
+ deadline = _finite_number(
+ "deadline_seconds", deadline_seconds, 0.05, MAX_RUN_DEADLINE
+ )
+ self.max_bytes = _byte_budget(max_bytes)
+ self._clock = clock
+ self._executor = executor or (
+ _injected_executor(spawn) if spawn else execute_bounded
+ )
+ self._environ = environ
+ self._attestor = attestor
+ self._deadline_at = clock() + deadline
+ self.commands_run = 0
+
+ @property
+ def remaining_seconds(self) -> float:
+ return self._deadline_at - self._clock()
+
+ def run(
+ self,
+ argv: Sequence[str],
+ vantage: Vantage,
+ max_bytes: int | None = None,
+ reserve_seconds: float = 0.0,
+ ) -> Outcome:
+ budget_bytes = self.max_bytes if max_bytes is None else _byte_budget(max_bytes)
+ reserve = _finite_number(
+ "reserve_seconds", reserve_seconds, 0.0, MAX_RUN_DEADLINE
+ )
+ addressed = vantage.wrap(argv)
+ try:
+ check_argv(addressed, self.mode)
+ except PolicyError as exc:
+ return Outcome(argv=addressed, vantage=vantage.name, error=f"policy: {exc}")
+ budget = min(self.command_timeout, self.remaining_seconds - reserve)
+ if budget <= 0:
+ return Outcome(argv=addressed, vantage=vantage.name, error=DEADLINE_ERROR)
+ environment = build_environment(vantage, self._environ)
+ try:
+ attestation = self._attestor(addressed[0], environment)
+ except (OSError, ValueError) as exc:
+ return Outcome(
+ argv=addressed, vantage=vantage.name, error=f"attestation: {exc}"
+ )
+ executed = (attestation.path, *addressed[1:])
+ try:
+ check_argv(executed, self.mode)
+ except PolicyError as exc:
+ return Outcome(argv=addressed, vantage=vantage.name, error=f"policy: {exc}")
+ started = self._clock()
+ self.commands_run += 1
+ try:
+ capture = self._executor(executed, environment, budget, budget_bytes)
+ except subprocess.TimeoutExpired:
+ capture = Capture(-signal.SIGKILL, timed_out=True)
+ except OSError as exc:
+ return Outcome(
+ argv=addressed,
+ vantage=vantage.name,
+ error=f"spawn: {exc.strerror or type(exc).__name__}",
+ executable_path=attestation.path,
+ executable_sha256=attestation.sha256,
+ )
+ was_truncated = capture.stdout_truncated or capture.stderr_truncated
+ stdout = _screen_capture(capture.stdout, capture.stdout_truncated, budget_bytes)
+ stderr = _screen_capture(capture.stderr, capture.stderr_truncated, budget_bytes)
+ return Outcome(
+ argv=addressed,
+ vantage=vantage.name,
+ returncode=capture.returncode,
+ stdout=stdout,
+ stderr=stderr,
+ truncated=was_truncated,
+ duration_ms=int((self._clock() - started) * 1000),
+ error=TIMEOUT_ERROR if capture.timed_out else None,
+ executable_path=attestation.path,
+ executable_sha256=attestation.sha256,
+ )
diff --git a/scripts/ops/hermes_handoff_json_rules.py b/scripts/ops/hermes_handoff_json_rules.py
new file mode 100644
index 00000000..0f44c6a2
--- /dev/null
+++ b/scripts/ops/hermes_handoff_json_rules.py
@@ -0,0 +1,474 @@
+#!/usr/bin/env python3
+"""JSON-shaped evaluators for the handoff acceptance harness.
+
+These rules cover the evidence that arrives as structured output — Kubernetes
+objects, forge pull requests, provider health files, and the two append-only
+evidence logs the platform keeps. They share the fail-closed contract of every
+other rule: an absent path, an unparsable payload, or an empty result set is
+``NOT_RUN``, never a quiet pass.
+"""
+
+from __future__ import annotations
+
+import datetime as dt
+import math
+import re
+from typing import Any
+
+from hermes_handoff_exec import Outcome
+from hermes_handoff_model import FAIL, NOT_RUN, PASS, CheckSpec
+from hermes_handoff_rules import (
+ Evaluation,
+ dotted,
+ evaluator,
+ exact_equal,
+ is_list,
+ missing_steps,
+ parsed_step,
+ strict_json,
+)
+
+_ROUTE_PART_RE = re.compile(r"[a-z0-9][a-z0-9_.-]{0,63}\Z")
+_FLUX_NAME_RE = re.compile(
+ r"[a-z0-9]([-a-z0-9.]*[a-z0-9])?/[a-z0-9]([-a-z0-9.]*[a-z0-9])?\Z"
+)
+
+
+def _timestamp(value: Any) -> dt.datetime:
+ if not isinstance(value, str) or not value.strip():
+ raise ValueError("timestamp is not a non-empty string")
+ observed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
+ if observed.tzinfo is None:
+ raise ValueError("timestamp has no timezone")
+ return observed.astimezone(dt.timezone.utc)
+
+
+def _now(value: Any) -> dt.datetime:
+ if not isinstance(value, dt.datetime) or value.tzinfo is None:
+ raise ValueError("run clock is not timezone-aware")
+ return value.astimezone(dt.timezone.utc)
+
+
+@evaluator("json_field")
+def evaluate_json_field(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
+ """Compare dotted JSON paths of one step's output against expected values."""
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ parsed = parsed_step(spec, outcomes)
+ if isinstance(parsed, Evaluation):
+ return parsed
+ payload, key = parsed
+ evidence: dict[str, Any] = {}
+ mismatches: list[str] = []
+ for path, expected in spec.expect["fields"].items():
+ try:
+ actual = dotted(payload, path)
+ except (KeyError, ValueError, IndexError):
+ return Evaluation(
+ NOT_RUN, f"path {path} is absent from step {key}", evidence
+ )
+ evidence[path] = actual
+ allowed = expected if isinstance(expected, list) else [expected]
+ if not any(exact_equal(actual, item) for item in allowed):
+ mismatches.append(f"{path}={actual!r} expected one of {allowed!r}")
+ if mismatches:
+ return Evaluation(FAIL, "; ".join(mismatches), evidence)
+ return Evaluation(PASS, "observed fields match the release contract", evidence)
+
+
+@evaluator("json_numeric")
+def evaluate_json_numeric(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
+ """Compare numeric JSON paths against inclusive minimum/maximum bounds."""
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ parsed = parsed_step(spec, outcomes)
+ if isinstance(parsed, Evaluation):
+ return parsed
+ payload, key = parsed
+ evidence: dict[str, Any] = {}
+ problems: list[str] = []
+ for path, bounds in spec.expect["fields"].items():
+ try:
+ actual = dotted(payload, path)
+ except (KeyError, ValueError, IndexError):
+ return Evaluation(
+ NOT_RUN, f"path {path} is absent from step {key}", evidence
+ )
+ if (
+ not isinstance(actual, (int, float))
+ or isinstance(actual, bool)
+ or not math.isfinite(float(actual))
+ ):
+ return Evaluation(
+ NOT_RUN, f"path {path} is not numeric in step {key}", evidence
+ )
+ evidence[path] = actual
+ if "min" in bounds and actual < bounds["min"]:
+ problems.append(f"{path}={actual} below minimum {bounds['min']}")
+ if "max" in bounds and actual > bounds["max"]:
+ problems.append(f"{path}={actual} above maximum {bounds['max']}")
+ if problems:
+ return Evaluation(FAIL, "; ".join(problems), evidence)
+ return Evaluation(PASS, "numeric fields are within the expected bounds", evidence)
+
+
+@evaluator("json_recent")
+def evaluate_json_recent(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
+ """Pass when a timestamp field is fresh enough to be evidence about now.
+
+ Stale provider or routing evidence is worse than none: it describes a state
+ the release no longer has. A timestamp older than the allowed age is a FAIL,
+ and one the harness cannot parse is NOT_RUN.
+ """
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ parsed = parsed_step(spec, outcomes)
+ if isinstance(parsed, Evaluation):
+ return parsed
+ payload, key = parsed
+ try:
+ now = _now(spec.expect["now"])
+ except (TypeError, ValueError) as exc:
+ return Evaluation(NOT_RUN, f"invalid run clock: {exc}")
+ evidence: dict[str, Any] = {}
+ stale: list[str] = []
+ for path, max_age in spec.expect["fields"].items():
+ try:
+ raw = dotted(payload, path)
+ except (KeyError, ValueError, IndexError):
+ return Evaluation(
+ NOT_RUN, f"path {path} is absent from step {key}", evidence
+ )
+ try:
+ observed = _timestamp(raw)
+ except (TypeError, ValueError):
+ return Evaluation(
+ NOT_RUN, f"path {path} is not a timezone-aware ISO timestamp", evidence
+ )
+ age = (now - observed).total_seconds()
+ evidence[path] = {"age_seconds": int(age)}
+ if age < 0:
+ stale.append(f"{path} is {abs(int(age))}s in the future")
+ elif age > max_age:
+ stale.append(f"{path} is {int(age)}s old, limit {max_age}s")
+ if stale:
+ return Evaluation(FAIL, "; ".join(stale), evidence)
+ return Evaluation(PASS, "observed timestamps are fresh", evidence)
+
+
+@evaluator("all_of_field")
+def evaluate_all_of_field(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
+ """Pass when every selected element of a JSON array matches expected fields."""
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ parsed = parsed_step(spec, outcomes)
+ if isinstance(parsed, Evaluation):
+ return parsed
+ payload, key = parsed
+ try:
+ elements = dotted(payload, spec.expect.get("array", ""))
+ except (KeyError, ValueError, IndexError):
+ return Evaluation(NOT_RUN, f"array path is absent from step {key}")
+ if not is_list(elements):
+ return Evaluation(NOT_RUN, f"step {key} did not return an array")
+ key_field = spec.expect.get("key_field", "")
+ wanted = spec.expect.get("keys")
+ exempt = set(spec.expect.get("exempt_keys", ()))
+ seen: list[Any] = []
+ problems: list[str] = []
+ evidence: dict[str, Any] = {}
+ for element in elements:
+ identity = dotted(element, key_field) if key_field else None
+ if wanted is not None and identity not in wanted:
+ continue
+ if identity in exempt:
+ continue
+ seen.append(identity)
+ for path in spec.expect.get("non_empty", ()):
+ try:
+ value = dotted(element, path)
+ except (KeyError, ValueError, IndexError):
+ value = None
+ if value in (None, "", [], {}):
+ problems.append(f"{identity}: {path} is empty")
+ for path, expected in spec.expect.get("fields", {}).items():
+ try:
+ actual = dotted(element, path)
+ except (KeyError, ValueError, IndexError):
+ actual = None
+ allowed = expected if isinstance(expected, list) else [expected]
+ if not any(exact_equal(actual, item) for item in allowed):
+ problems.append(
+ f"{identity}: {path}={actual!r} expected one of {allowed!r}"
+ )
+ evidence["examined"] = seen[:32]
+ if wanted is not None:
+ absent = [item for item in wanted if item not in seen]
+ if absent:
+ return Evaluation(
+ NOT_RUN, f"expected keys absent from step {key}: {absent}", evidence
+ )
+ elif not seen:
+ return Evaluation(NOT_RUN, f"step {key} returned nothing to examine", evidence)
+ if problems:
+ return Evaluation(FAIL, "; ".join(problems[:8]), evidence)
+ return Evaluation(
+ PASS, f"{len(seen)} element(s) match the release contract", evidence
+ )
+
+
+@evaluator("json_record")
+def evaluate_json_record(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
+ """Capture named JSON paths as evidence; pass when all of them resolve.
+
+ Recording is a release requirement in its own right — the runbook has to
+ name the exact heads it was verified against — so an unreachable source is a
+ NOT_RUN rather than an empty section.
+ """
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ parsed = parsed_step(spec, outcomes)
+ if isinstance(parsed, Evaluation):
+ return parsed
+ payload, key = parsed
+ evidence: dict[str, Any] = {}
+ for label, path in spec.expect["record"].items():
+ try:
+ evidence[label] = dotted(payload, path)
+ except (KeyError, ValueError, IndexError):
+ return Evaluation(
+ NOT_RUN, f"path {path} is absent from step {key}", evidence
+ )
+ return Evaluation(PASS, "state recorded", evidence)
+
+
+@evaluator("routing_evidence")
+def evaluate_routing_evidence(
+ spec: CheckSpec, outcomes: dict[str, Outcome]
+) -> Evaluation:
+ """Summarise a Switchyard routing log tail into provider/effort coverage.
+
+ Route identifiers are ``///``, so provider,
+ model family, effort tier, and lane all come from the same field. Fallback
+ and failure evidence is a separate ``fallback_reason`` on the record that
+ was retried. Every row must be complete strict JSON; a clipped or malformed
+ line makes the evidence unusable rather than being silently discarded.
+ """
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ key = spec.expect["step"]
+ outcome = outcomes[key]
+ if not outcome.ok:
+ return Evaluation(NOT_RUN, f"step {key} failed (rc={outcome.returncode})")
+ records: list[dict[str, Any]] = []
+ routes: list[list[str]] = []
+ stamps_by_dimension: dict[tuple[str, str], dt.datetime] = {}
+ now_value = spec.expect.get("now")
+ try:
+ now = _now(now_value) if now_value is not None else None
+ except (TypeError, ValueError) as exc:
+ return Evaluation(NOT_RUN, f"invalid routing run clock: {exc}")
+ for row_number, line in enumerate(outcome.stdout.splitlines(), 1):
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ record = strict_json(line)
+ except ValueError as exc:
+ return Evaluation(
+ NOT_RUN, f"routing row {row_number} is malformed JSON: {exc}"
+ )
+ if not isinstance(record, dict):
+ return Evaluation(NOT_RUN, f"routing row {row_number} is not an object")
+ try:
+ observed = _timestamp(record.get("ts"))
+ except (TypeError, ValueError) as exc:
+ return Evaluation(
+ NOT_RUN, f"routing row {row_number} has invalid timestamp: {exc}"
+ )
+ if now is not None and observed > now:
+ return Evaluation(FAIL, f"routing row {row_number} has a future timestamp")
+ model = record.get("model")
+ if not isinstance(model, str) or not model:
+ return Evaluation(NOT_RUN, f"routing row {row_number} has no model")
+ route = model.split("/")
+ if len(route) == 4 and all(_ROUTE_PART_RE.fullmatch(part) for part in route):
+ routes.append(route)
+ for label, value in (
+ ("provider", route[1]),
+ ("effort", route[3]),
+ ("lane", route[0]),
+ ):
+ key_dimension = (label, value)
+ stamps_by_dimension[key_dimension] = max(
+ observed, stamps_by_dimension.get(key_dimension, observed)
+ )
+ elif record.get("tier") != "classifier":
+ return Evaluation(
+ NOT_RUN, f"routing row {row_number} has malformed route {model!r}"
+ )
+ fallback = record.get("fallback_reason")
+ if fallback is not None and (
+ not isinstance(fallback, str) or not fallback.strip()
+ ):
+ return Evaluation(
+ NOT_RUN, f"routing row {row_number} has malformed fallback_reason"
+ )
+ record["_observed"] = observed
+ records.append(record)
+ fallbacks = [record for record in records if record.get("fallback_reason")]
+ stamps = sorted(record["_observed"] for record in records)
+ evidence: dict[str, Any] = {
+ "records": len(records),
+ "routed": len(routes),
+ "lanes": sorted({route[0] for route in routes}),
+ "providers": sorted({route[1] for route in routes}),
+ "families": sorted({route[2] for route in routes}),
+ "efforts": sorted({route[3] for route in routes}),
+ "fallback_events": len(fallbacks),
+ "fallback_reasons": sorted(
+ {str(record["fallback_reason"]) for record in fallbacks}
+ ),
+ "latest_ts": stamps[-1].isoformat() if stamps else "",
+ }
+ if not records:
+ return Evaluation(
+ NOT_RUN, "routing log tail contained no parsable records", evidence
+ )
+ missing = [
+ f"{label}:{item}"
+ for label, wanted in (
+ ("provider", spec.expect.get("providers", ())),
+ ("effort", spec.expect.get("efforts", ())),
+ ("lane", spec.expect.get("lanes", ())),
+ )
+ for item in wanted
+ if item not in evidence[f"{label}s"]
+ ]
+ if spec.expect.get("require_fallback_evidence") and not fallbacks:
+ missing.append("fallback:none-observed")
+ max_age = spec.expect.get("max_age_seconds")
+ if max_age and now:
+ ages: dict[str, int] = {}
+ for label, wanted in (
+ ("provider", spec.expect.get("providers", ())),
+ ("effort", spec.expect.get("efforts", ())),
+ ("lane", spec.expect.get("lanes", ())),
+ ):
+ for item in wanted:
+ stamp = stamps_by_dimension.get((label, item))
+ if stamp is None:
+ continue
+ age = int((now - stamp).total_seconds())
+ ages[f"{label}:{item}"] = age
+ if age > max_age:
+ missing.append(
+ f"freshness:{label}:{item}:{age}s-exceeds-{max_age}s"
+ )
+ evidence["age_seconds"] = ages
+ if missing:
+ return Evaluation(
+ FAIL, f"routing evidence missing {', '.join(missing)}", evidence
+ )
+ return Evaluation(PASS, "routing evidence covers the required lanes", evidence)
+
+
+@evaluator("flux_health")
+def evaluate_flux_health(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
+ """Classify a compact Flux projection into suspensions and unhealthy objects.
+
+ Each row carries name, generation, observed generation, suspension, and all
+ condition states. A non-suspended object must be current, explicitly
+ ``Ready=True``, not ``Healthy=False/Unknown``, and not reconciling. Suspended
+ objects are judged against the exact expected-suspension list.
+ """
+ blocked = missing_steps(spec, outcomes)
+ if blocked:
+ return blocked
+ key = spec.expect["step"]
+ outcome = outcomes[key]
+ if not outcome.ok:
+ return Evaluation(NOT_RUN, f"step {key} failed (rc={outcome.returncode})")
+ expected = set(spec.expect.get("expected_suspensions", ()))
+ suspended: list[str] = []
+ unhealthy: list[str] = []
+ total = 0
+ for row_number, line in enumerate(outcome.stdout.splitlines(), 1):
+ if not line.strip():
+ continue
+ columns = line.split("\t")
+ if len(columns) != 5:
+ return Evaluation(
+ NOT_RUN, f"Flux row {row_number} has {len(columns)} columns"
+ )
+ name, generation, observed_generation, suspend, conditions = columns
+ name = name.strip()
+ if not _FLUX_NAME_RE.fullmatch(name):
+ return Evaluation(
+ NOT_RUN, f"Flux row {row_number} has malformed object name"
+ )
+ try:
+ current_generation = int(generation)
+ reconciled_generation = int(observed_generation)
+ except ValueError:
+ return Evaluation(
+ NOT_RUN, f"Flux row {row_number} has malformed generations"
+ )
+ if current_generation <= 0 or current_generation != reconciled_generation:
+ return Evaluation(
+ FAIL, f"Flux row {row_number} has stale observedGeneration"
+ )
+ if suspend.strip().lower() not in {"", "false", "true"}:
+ return Evaluation(
+ NOT_RUN, f"Flux row {row_number} has malformed suspension state"
+ )
+ total += 1
+ if suspend.strip().lower() == "true":
+ suspended.append(name)
+ continue
+ states: dict[str, str] = {}
+ for pair in filter(None, conditions.strip().strip(",").split(",")):
+ if pair.count("=") != 1:
+ return Evaluation(
+ NOT_RUN, f"Flux row {row_number} has malformed condition"
+ )
+ condition, state = pair.split("=", 1)
+ if (
+ not condition
+ or state not in {"True", "False", "Unknown"}
+ or condition in states
+ ):
+ return Evaluation(
+ NOT_RUN, f"Flux row {row_number} has malformed condition state"
+ )
+ states[condition] = state
+ healthy = states.get("Healthy")
+ if (
+ states.get("Ready") != "True"
+ or healthy in {"False", "Unknown"}
+ or states.get("Reconciling") == "True"
+ ):
+ unhealthy.append(name)
+ unexpected = sorted(set(suspended) - expected)
+ evidence = {
+ "total": total,
+ "suspended": sorted(suspended),
+ "unexpected_suspensions": unexpected,
+ "unhealthy": sorted(unhealthy),
+ }
+ if not total:
+ return Evaluation(NOT_RUN, f"step {key} returned no Flux objects", evidence)
+ problems = []
+ if unexpected:
+ problems.append(f"unexpected suspensions: {', '.join(unexpected)}")
+ if unhealthy:
+ problems.append(f"unhealthy: {', '.join(sorted(unhealthy)[:8])}")
+ if problems:
+ return Evaluation(FAIL, "; ".join(problems), evidence)
+ return Evaluation(PASS, f"{total} Flux objects reconciled as expected", evidence)
diff --git a/scripts/ops/hermes_handoff_model.py b/scripts/ops/hermes_handoff_model.py
new file mode 100644
index 00000000..d35d39f5
--- /dev/null
+++ b/scripts/ops/hermes_handoff_model.py
@@ -0,0 +1,290 @@
+#!/usr/bin/env python3
+"""Result model, aggregation, and rendering for the handoff acceptance harness.
+
+The classification is deliberately four-valued. ``NOT_APPLICABLE`` is the only
+way a check can be excused, and a check can only reach it by matching a scope
+rule that a reviewer wrote down; anything the harness merely failed to observe
+is ``NOT_RUN``. Because a mandatory ``NOT_RUN`` is a NO_GO exactly like a
+``FAIL``, an unreachable cluster, an exhausted deadline, or a probe nobody
+maintained can never round up to a release.
+"""
+
+from __future__ import annotations
+
+import datetime as dt
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass, field
+from typing import Any
+
+from hermes_handoff_exec import Outcome
+from hermes_handoff_redaction import contains_credential_shape, safe_text, scrub
+
+PASS = "PASS"
+FAIL = "FAIL"
+NOT_RUN = "NOT_RUN"
+NOT_APPLICABLE = "NOT_APPLICABLE"
+STATUSES = (PASS, FAIL, NOT_RUN, NOT_APPLICABLE)
+
+GO = "GO"
+NO_GO = "NO_GO"
+
+ALWAYS = "always"
+EPHEMERAL = "ephemeral"
+
+HARNESS = "hermes-handoff-acceptance"
+SCHEMA_VERSION = 1
+
+
+REVIEW = "review"
+ATTEMPT = "attempt"
+READ = "read"
+STEP_KINDS = (REVIEW, ATTEMPT, READ)
+
+
+@dataclass(frozen=True)
+class Step:
+ """One command a check needs, and the vantage it must be observed from.
+
+ ``kind`` separates a self access review (``review``) from a real request the
+ identity is expected to be refused (``attempt``). A deny check needs both:
+ an authorization review states policy, only an attempt proves the API server
+ enforces it.
+
+ ``record`` is cleared for bulk evidence — a routing log tail, a large object
+ listing — which the harness parses but must not paste into the artifact.
+ ``max_bytes`` raises the capture budget for that same bulk evidence, where a
+ default-sized truncation would silently narrow the window being examined.
+ """
+
+ key: str
+ vantage: str
+ argv: tuple[str, ...]
+ kind: str = READ
+ optional: bool = False
+ record: bool = True
+ max_bytes: int | None = None
+
+
+@dataclass(frozen=True)
+class CheckSpec:
+ """A declarative acceptance check.
+
+ ``rule`` names an evaluator; ``expect`` carries its parameters. Keeping the
+ catalog declarative means a reviewer reads what is asserted, not how it is
+ plumbed, and the harness can prove structural properties over every check
+ (no impersonation, no mutation, both vantages present) before running any.
+ """
+
+ id: str
+ title: str
+ group: str
+ rule: str
+ steps: tuple[Step, ...] = ()
+ expect: Mapping[str, Any] = field(default_factory=dict)
+ mandatory: bool = True
+ scope: str = ALWAYS
+ rationale: str = ""
+
+ @property
+ def vantages(self) -> tuple[str, ...]:
+ """Return the distinct vantages this check draws evidence from."""
+ seen: list[str] = []
+ for step in self.steps:
+ if step.vantage not in seen:
+ seen.append(step.vantage)
+ return tuple(seen)
+
+
+@dataclass
+class CheckResult:
+ """The classified outcome of one acceptance check."""
+
+ spec: CheckSpec
+ status: str
+ reason: str = ""
+ evidence: dict[str, Any] = field(default_factory=dict)
+ outcomes: list[Outcome] = field(default_factory=list)
+
+ @property
+ def blocking(self) -> bool:
+ """Report whether this result alone forces NO_GO."""
+ if self.status not in STATUSES:
+ return True
+ return self.spec.mandatory and self.status in {FAIL, NOT_RUN}
+
+ def as_dict(self, include_outcomes: bool = True) -> dict[str, Any]:
+ """Return a JSON-serialisable, screened view of the result."""
+ payload: dict[str, Any] = {
+ "id": self.spec.id,
+ "title": self.spec.title,
+ "group": self.spec.group,
+ "rule": self.spec.rule,
+ "scope": self.spec.scope,
+ "mandatory": self.spec.mandatory,
+ "vantages": list(self.spec.vantages),
+ "status": self.status,
+ "reason": self.reason,
+ "blocking": self.blocking,
+ "evidence": self.evidence,
+ }
+ if self.spec.rationale:
+ payload["rationale"] = self.spec.rationale
+ if include_outcomes:
+ payload["commands"] = [outcome.as_dict() for outcome in self.outcomes]
+ return scrub(payload)
+
+
+@dataclass
+class VantageRecord:
+ """What the harness proved about one vantage before trusting its evidence."""
+
+ name: str
+ description: str
+ identity: str = ""
+ available: bool = False
+ detail: str = ""
+
+ def as_dict(self) -> dict[str, Any]:
+ """Return a JSON-serialisable view of the vantage."""
+ return scrub(
+ {
+ "name": self.name,
+ "description": self.description,
+ "identity": self.identity,
+ "available": self.available,
+ "detail": self.detail,
+ }
+ )
+
+
+@dataclass
+class Report:
+ """The whole acceptance run: metadata, per-check results, and a verdict."""
+
+ mode: str
+ started_at: str
+ baseline: dict[str, Any] = field(default_factory=dict)
+ vantages: list[VantageRecord] = field(default_factory=list)
+ results: list[CheckResult] = field(default_factory=list)
+ harness_errors: list[str] = field(default_factory=list)
+ finished_at: str = ""
+
+ @property
+ def counts(self) -> dict[str, int]:
+ """Return the number of results in each status."""
+ tally = dict.fromkeys(STATUSES, 0)
+ for result in self.results:
+ if result.status in tally:
+ tally[result.status] += 1
+ return tally
+
+ @property
+ def invalid_statuses(self) -> list[str]:
+ """Return check ids whose status is outside the report schema."""
+ return [
+ result.spec.id for result in self.results if result.status not in STATUSES
+ ]
+
+ @property
+ def blocking(self) -> list[CheckResult]:
+ """Return every result that forces NO_GO, most severe first."""
+ order = {FAIL: 0, NOT_RUN: 1}
+ return sorted(
+ (result for result in self.results if result.blocking),
+ key=lambda result: (order.get(result.status, 2), result.spec.id),
+ )
+
+ @property
+ def decision(self) -> str:
+ """Return GO only when nothing mandatory failed, skipped, or errored."""
+ if (
+ not self.results
+ or self.invalid_statuses
+ or self.harness_errors
+ or self.blocking
+ ):
+ return NO_GO
+ return GO
+
+ def group_summary(self) -> dict[str, dict[str, int]]:
+ """Return per-group status counts, for the human summary."""
+ groups: dict[str, dict[str, int]] = {}
+ for result in self.results:
+ bucket = groups.setdefault(result.spec.group, dict.fromkeys(STATUSES, 0))
+ bucket[result.status] = bucket.get(result.status, 0) + 1
+ return groups
+
+ def as_dict(self, include_outcomes: bool = True) -> dict[str, Any]:
+ """Return the machine-readable report."""
+ return scrub(
+ {
+ "harness": HARNESS,
+ "schema_version": SCHEMA_VERSION,
+ "mode": self.mode,
+ "started_at": self.started_at,
+ "finished_at": self.finished_at,
+ "decision": self.decision,
+ "counts": self.counts,
+ "baseline": scrub(self.baseline),
+ "vantages": [vantage.as_dict() for vantage in self.vantages],
+ "harness_errors": [scrub(error) for error in self.harness_errors],
+ "invalid_statuses": self.invalid_statuses,
+ "blocking": [result.spec.id for result in self.blocking],
+ "checks": [result.as_dict(include_outcomes) for result in self.results],
+ }
+ )
+
+ def render_summary(self, width: int = 88) -> str:
+ """Return the concise human summary printed alongside the JSON."""
+ counts = self.counts
+ lines = [
+ f"{HARNESS}: {self.decision}",
+ f" mode={self.mode} started={self.started_at} checks={len(self.results)}",
+ " " + " ".join(f"{status}={counts[status]}" for status in STATUSES),
+ ]
+ for vantage in self.vantages:
+ state = "available" if vantage.available else "unavailable"
+ identity = vantage.identity or "unknown"
+ lines.append(f" vantage {vantage.name}: {state} identity={identity}")
+ for error in self.harness_errors:
+ lines.append(f" harness error: {error}")
+ if self.blocking:
+ lines.append(" blocking:")
+ for result in self.blocking:
+ lines.append(f" {result.status:<15}{result.spec.id}")
+ lines.append(f" {'':<15}{result.reason[:width]}")
+ groups = self.group_summary()
+ lines.append(" groups:")
+ for name in sorted(groups):
+ bucket = groups[name]
+ detail = " ".join(
+ f"{status}={bucket[status]}" for status in STATUSES if bucket[status]
+ )
+ lines.append(f" {name:<18}{detail}")
+ return safe_text("\n".join(lines), 32 * 1024)[0]
+
+
+def utc_now(clock: Any = None) -> str:
+ """Return an ISO-8601 UTC timestamp, injectable for deterministic tests."""
+ now = clock() if clock else dt.datetime.now(dt.timezone.utc)
+ return now.replace(microsecond=0).isoformat().replace("+00:00", "Z")
+
+
+def unscreened_fields(payload: Any, path: str = "$") -> list[str]:
+ """Return JSON pointers whose text still looks credential-shaped.
+
+ The report is already screened field by field; this is the invariant check
+ that runs before anything is written, so a screening gap fails the run
+ instead of publishing an artifact nobody re-read.
+ """
+ offenders: list[str] = []
+ if isinstance(payload, str):
+ if contains_credential_shape(payload):
+ offenders.append(path)
+ elif isinstance(payload, Mapping):
+ for key, value in payload.items():
+ offenders.extend(unscreened_fields(value, f"{path}.{key}"))
+ elif isinstance(payload, Sequence) and not isinstance(payload, (str, bytes)):
+ for index, value in enumerate(payload):
+ offenders.extend(unscreened_fields(value, f"{path}[{index}]"))
+ return offenders
diff --git a/scripts/ops/hermes_handoff_policy.py b/scripts/ops/hermes_handoff_policy.py
new file mode 100644
index 00000000..11c8bc1c
--- /dev/null
+++ b/scripts/ops/hermes_handoff_policy.py
@@ -0,0 +1,493 @@
+#!/usr/bin/env python3
+"""Fail-closed command policy for the Hermes handoff acceptance harness.
+
+Before spawn it admits only credential-safe, structurally read-only grammar;
+shell is limited to frozen templates with absolute tool paths.
+
+Impersonation is structurally impossible: ``--as`` and its siblings are refused
+for every argv, in every mode, from every vantage, and the inner command of a
+``kubectl exec`` is re-checked under the same rule rather than being trusted.
+Only binaries with a pinned release digest are on the allowlist, so nothing can
+be admitted here that the executor would then refuse to attest.
+"""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Iterator
+from urllib.parse import unquote
+
+READ_ONLY = "read-only"
+ARMED = "ephemeral-armed"
+MODES = (READ_ONLY, ARMED)
+
+GITEA_CLIENT = "/opt/coordinator/gitea_api.py"
+EXPECTED_REPO = "atlas/titan-iac"
+EXPECTED_REMOTE = "origin"
+EXPECTED_BASE = "main"
+
+# fmt: off
+FORBIDDEN_ARGS = ("--as", "--as-group", "--as-uid", "--client-certificate",
+ "--client-key", "--password", "--token", "--username")
+# fmt: on
+IMPERSONATION_ARGS = ("--as", "--as-group", "--as-uid")
+FORBIDDEN_BINARIES = frozenset(
+ {"age", "curl", "gpg", "openssl", "sops", "vault", "wget"}
+)
+ALLOWED_BINARIES = frozenset({"kubectl", "git", "hermes", "sh", GITEA_CLIENT})
+TRUSTED_EXECUTABLE_ROOTS = (
+ "/bin/",
+ "/usr/bin/",
+ "/usr/local/bin/",
+ "/opt/data/tools/bin/",
+ "/opt/hermes/.venv/bin/",
+ "/opt/coordinator/",
+)
+TRUSTED_INNER_PATHS = {
+ "/bin/sh": "sh",
+ "/usr/bin/git": "git",
+ "/usr/bin/sh": "sh",
+ "/usr/local/bin/kubectl": "kubectl",
+ "/opt/hermes/.venv/bin/hermes": "hermes",
+ GITEA_CLIENT: GITEA_CLIENT,
+}
+
+VALUE_FLAGS = frozenset(
+ {
+ "-C",
+ "-c",
+ "-l",
+ "-n",
+ "-o",
+ "--cluster",
+ "--container",
+ "--context",
+ "--field-selector",
+ "--kubeconfig",
+ "--namespace",
+ "--output",
+ "--request-timeout",
+ "--selector",
+ "--server",
+ "--since",
+ "--tail",
+ "--user",
+ }
+)
+
+KUBECTL_READ_SUBCOMMANDS = frozenset(
+ {
+ "api-resources",
+ "api-versions",
+ "explain",
+ "get",
+ "top",
+ "version",
+ }
+)
+KUBECTL_EXEC_SUBCOMMANDS = frozenset({"exec"})
+KUBECTL_SUBCOMMANDS = KUBECTL_READ_SUBCOMMANDS | KUBECTL_EXEC_SUBCOMMANDS | {"auth"}
+SECRET_RESOURCES = frozenset({"secret", "secrets"})
+UNSAFE_PROJECTION_PARTS = (
+ ".args",
+ ".command",
+ ".data",
+ ".envfrom",
+ ".secretkeyref",
+ ".status.token",
+ ".stringdata",
+ ".valuefrom",
+ "clientconfig",
+ "password",
+ "privatekey",
+)
+SAFE_ENV_VALUE = 'env[?(@.name=="hermes_auto_router_profile")].value'
+SAFE_JSON_FIELDS = frozenset(["authenticated", "transport", "state", "model", "effort", "latency_ms", "checked_at", "api_provider", "auth_method", "subscription_type"]) # fmt: skip
+
+GIT_READ_SUBCOMMANDS = frozenset(
+ {
+ "cat-file",
+ "diff",
+ "for-each-ref",
+ "log",
+ "ls-remote",
+ "ls-tree",
+ "merge-base",
+ "rev-list",
+ "rev-parse",
+ "show",
+ "status",
+ }
+)
+GIT_FORCE_FLAGS = frozenset(
+ {"-f", "--force", "--force-with-lease", "--mirror", "--all", "--tags"}
+)
+GIT_UNSAFE_FLAGS = (
+ "-c",
+ "--config-env",
+ "--exec-path",
+ "--upload-pack",
+ "--receive-pack",
+)
+GITEA_READ_METHODS = frozenset({"GET"})
+HERMES_READ_COMMANDS = frozenset(
+ {("kanban", "list"), ("kanban", "show"), ("sessions", "list"), ("status",)}
+)
+
+# These locations contain credential bytes even when a caller claims it will
+# only inspect metadata. They are rejected for every argv, including a frozen
+# shell rendering and the inner command of kubectl exec.
+SECRET_PATH_RE = re.compile(
+ r"(?:^|[\s'\"=])(?:/[^\s'\"]*)?(?:"
+ r"var/run/secrets|run/secrets|vault/secrets|runtime-access|scm-access|pool-access|"
+ r"\.credentials\.json|auth\.json|\.git-credentials|authorized_keys|"
+ r"id_[a-z0-9]+|\.netrc|shadow|gshadow|k3s\.yaml"
+ r")(?:$|[/\s'\"])",
+ re.IGNORECASE,
+)
+_PARAMETER_RE = re.compile(r"[A-Za-z0-9/][A-Za-z0-9._/@:,-]{0,255}\Z")
+_PLACEHOLDER_RE = re.compile(r"\{([a-z_]+)\}")
+_SAFE_API_PATH_RE = re.compile(r"/[A-Za-z0-9][A-Za-z0-9._~:/?&=%+-]{0,511}\Z")
+
+SHELL_TEMPLATES: dict[str, str] = {
+ "env_names": (
+ "/usr/bin/env | /usr/bin/sed -n "
+ "'s/^\\([A-Za-z_][A-Za-z0-9_]*\\)=.*/\\1/p' | /usr/bin/sort"
+ ),
+ "json_fields": (
+ '/opt/hermes/.venv/bin/python -c "import json,sys;d=json.load(open(sys.argv[1]));'
+ 'print(json.dumps({{k:d.get(k) for k in sys.argv[2].split(chr(44))}}))" '
+ "{path} {fields}"
+ ),
+ "tail_lines": "/usr/bin/tail -n {limit} {path}",
+}
+
+
+class PolicyError(ValueError):
+ """Raised when a requested command falls outside the safe boundary."""
+
+
+def template_parameters(template_name: str) -> set[str]:
+ """Return the parameter names a frozen template requires."""
+ template = SHELL_TEMPLATES.get(template_name)
+ if template is None:
+ raise PolicyError(f"unknown shell template: {template_name}")
+ return set(_PLACEHOLDER_RE.findall(template))
+
+
+def render_shell(template_name: str, **parameters: str) -> str:
+ """Return a frozen shell template with validated parameters substituted."""
+ required = template_parameters(template_name)
+ if set(parameters) != required:
+ raise PolicyError(
+ f"shell template {template_name} expects parameters {sorted(required)}"
+ )
+ for key, value in parameters.items():
+ if not _PARAMETER_RE.fullmatch(value):
+ raise PolicyError(f"unsafe shell parameter for {template_name}.{key}")
+ if SECRET_PATH_RE.search(value):
+ raise PolicyError("shell templates may not name credential paths")
+ if (
+ template_name == "json_fields"
+ and not set(parameters["fields"].split(",")) <= SAFE_JSON_FIELDS
+ ):
+ raise PolicyError("json_fields requested a credential-bearing or unknown field")
+ return SHELL_TEMPLATES[template_name].format(**parameters)
+
+
+_RENDERED: set[str] = set()
+_APPROVED_PROJECTIONS: set[str] = set()
+_ARMED_REF = ""
+_ARMED_PULLS: set[int] = set()
+
+
+# fmt: off
+def arm_ephemeral_policy(ref: str) -> None:
+ global _ARMED_REF
+ if not re.fullmatch(r"ephemeral/hermes-handoff-acceptance/[a-z0-9][a-z0-9-]{7,63}", ref):
+ raise PolicyError("armed policy requires one exact ephemeral ref")
+ _ARMED_REF = ref
+ _ARMED_PULLS.clear()
+
+
+def register_ephemeral_pull(number: int) -> None:
+ if not _ARMED_REF or isinstance(number, bool) or not isinstance(number, int) or number <= 0:
+ raise PolicyError("ephemeral pull registration is malformed")
+ _ARMED_PULLS.add(number)
+
+
+def shell(template_name: str, **parameters: str) -> tuple[str, ...]:
+ """Return a validated ``sh -c`` argv built from a frozen template."""
+ script = render_shell(template_name, **parameters)
+ _RENDERED.add(script)
+ return ("sh", "-c", script)
+
+
+def projection(value: str) -> str:
+ normalized = value.lower().replace(" ", "")
+ if not normalized.startswith("jsonpath=") or any(part in normalized for part in UNSAFE_PROJECTION_PARTS):
+ raise PolicyError("Kubernetes projection is not credential-safe")
+ if ".metadata.annotations" in normalized and not any(key in normalized for key in (
+ "deployment\\.kubernetes\\.io/revision", "ai\\.bstein\\.dev/config-rev")):
+ raise PolicyError("Kubernetes annotations are not on the reviewed allowlist")
+ if ".metadata.labels" in normalized and "pod-template-hash" not in normalized:
+ raise PolicyError("Kubernetes labels are not on the reviewed allowlist")
+ if ".env" in normalized and ".value" in normalized and SAFE_ENV_VALUE not in normalized:
+ raise PolicyError("Kubernetes projection may expose environment values")
+ _APPROVED_PROJECTIONS.add(normalized)
+ return value
+# fmt: on
+
+
+def positionals(argv: tuple[str, ...], start: int = 1) -> Iterator[str]:
+ """Yield positional arguments, skipping flags and their values."""
+ index = start
+ while index < len(argv):
+ item = argv[index]
+ if item == "--":
+ return
+ if item.startswith("-"):
+ index += 2 if item in VALUE_FLAGS else 1
+ continue
+ yield item
+ index += 1
+
+
+def _binary_name(value: str) -> str:
+ if value == GITEA_CLIENT or (
+ value.startswith("/opt/coordinator/") and value.endswith("/gitea_api.py")
+ ):
+ return GITEA_CLIENT
+ if "/" not in value:
+ return value
+ mapped = TRUSTED_INNER_PATHS.get(value)
+ if mapped:
+ return mapped
+ if "/../" in value or value.endswith("/..") or "/./" in value:
+ raise PolicyError(f"executable path is not normalized: {value}")
+ if not value.startswith(TRUSTED_EXECUTABLE_ROOTS):
+ raise PolicyError(f"executable path is not trusted: {value}")
+ return value.rsplit("/", 1)[-1]
+
+
+def _reject_forbidden_arguments(argv: tuple[str, ...]) -> None:
+ """Refuse credential and impersonation arguments up to the ``--`` boundary.
+
+ Everything after ``--`` is the inner command of a ``kubectl exec``, which
+ :func:`_check_kubectl` submits to :func:`check_argv` again, so it is held to
+ exactly this rule rather than being exempted from it.
+ """
+ boundary = argv.index("--") if "--" in argv else len(argv)
+ for argument in argv[:boundary]:
+ for forbidden in FORBIDDEN_ARGS:
+ if argument == forbidden or argument.startswith(f"{forbidden}="):
+ raise PolicyError(f"forbidden argument: {forbidden}")
+ if argument == "--raw" or argument.startswith("--raw="):
+ raise PolicyError("raw API reads are forbidden")
+ for argument in argv:
+ if argument == "--dry-run" or argument.startswith("--dry-run="):
+ raise PolicyError("dry-run mutation commands are forbidden")
+
+
+def _reject_secret_paths(argv: tuple[str, ...]) -> None:
+ if any(SECRET_PATH_RE.search(argument) for argument in argv):
+ raise PolicyError("probes may not name a credential path")
+
+
+def _resource_name(value: str) -> str:
+ return value.lower().split("/", 1)[0].split(".", 1)[0]
+
+
+def _output_value(argv: tuple[str, ...]) -> str:
+ """Return kubectl's output argument in a single canonical form."""
+ for index, item in enumerate(argv[1:], 1):
+ if item in {"-o", "--output"} and index + 1 < len(argv):
+ return argv[index + 1]
+ if item.startswith(("-o=", "--output=")):
+ return item.split("=", 1)[1]
+ return ""
+
+
+def _check_projection(output: str) -> None:
+ """Reject full or credential-bearing Kubernetes output projections."""
+ lowered = output.lower().replace(" ", "")
+ if lowered in {"json", "yaml", "wide"} or lowered.startswith("custom-columns"):
+ raise PolicyError(
+ "full Kubernetes object output is forbidden; use a safe projection"
+ )
+ if lowered and not (lowered == "name" or lowered.startswith("jsonpath=")):
+ raise PolicyError(
+ "Kubernetes output must be name or an approved JSONPath projection"
+ )
+ if lowered.startswith("jsonpath="):
+ if any(part in lowered for part in UNSAFE_PROJECTION_PARTS):
+ raise PolicyError("Kubernetes projection may expose runtime credentials")
+ if ".env" in lowered and SAFE_ENV_VALUE not in lowered:
+ # Environment names are safe; arbitrary `.value` reads are not.
+ without_names = lowered.replace("{.name}", "")
+ if ".value" in without_names:
+ raise PolicyError("Kubernetes projection may expose environment values")
+ if lowered not in _APPROVED_PROJECTIONS:
+ raise PolicyError(
+ "Kubernetes JSONPath projection is not on the reviewed allowlist"
+ )
+
+
+def _check_kubectl(argv: tuple[str, ...], mode: str) -> None:
+ words = list(positionals(argv))
+ if not words or words[0] not in KUBECTL_SUBCOMMANDS:
+ raise PolicyError("kubectl subcommand is outside the read-only boundary")
+ subcommand = words[0]
+ if subcommand == "auth":
+ if len(words) < 2 or words[1] not in {"can-i", "whoami"}:
+ raise PolicyError("kubectl auth is limited to can-i and whoami")
+ return
+ if subcommand == "exec":
+ if "--" not in argv:
+ raise PolicyError("kubectl exec requires an explicit -- command boundary")
+ inner = argv[argv.index("--") + 1 :]
+ if not inner:
+ raise PolicyError("kubectl exec requires an inner command")
+ if inner[0] not in TRUSTED_INNER_PATHS:
+ raise PolicyError(
+ "kubectl exec inner command must use a fixed trusted path"
+ )
+ check_argv(inner, mode)
+ return
+ resources = words[1].split(",") if len(words) > 1 else []
+ if any(_resource_name(resource) in SECRET_RESOURCES for resource in resources):
+ raise PolicyError("Secret objects may not be read by the harness")
+ output = _output_value(argv)
+ if subcommand == "get" and not output:
+ raise PolicyError("kubectl get requires a reviewed output projection")
+ _check_projection(output)
+
+
+def _check_git(argv: tuple[str, ...], mode: str) -> None:
+ unsafe = False
+ for item in argv[1:]:
+ if item == "-C" or item.startswith(("--git-dir", "--work-tree", "--namespace")):
+ unsafe = True
+ if item == "-c" or (item.startswith("-c") and len(item) > 2):
+ unsafe = True
+ if any(
+ item == flag or item.startswith(f"{flag}=") for flag in GIT_UNSAFE_FLAGS[1:]
+ ):
+ unsafe = True
+ if unsafe:
+ raise PolicyError("git configuration and helper overrides are forbidden")
+ subcommand = next(positionals(argv), None)
+ if subcommand is None:
+ raise PolicyError("git requires a subcommand")
+ if subcommand in GIT_READ_SUBCOMMANDS:
+ return
+ if subcommand == "push" and mode == ARMED:
+ if GIT_FORCE_FLAGS & set(argv):
+ raise PolicyError("git push may not force or fan out across refs")
+ words = list(positionals(argv))
+ if len(words) != 3 or words[1] != EXPECTED_REMOTE:
+ raise PolicyError("armed git push is pinned to origin and one refspec")
+ destination = words[2].partition(":")[2]
+ if not _ARMED_REF or destination != f"refs/heads/{_ARMED_REF}":
+ raise PolicyError("armed git push is not the preflighted ephemeral ref")
+ return
+ raise PolicyError(f"git {subcommand} is not permitted in {mode} mode")
+
+
+def _check_hermes(argv: tuple[str, ...]) -> None:
+ words = tuple(positionals(argv))
+ if not words:
+ raise PolicyError("hermes requires a subcommand")
+ if words[:2] not in HERMES_READ_COMMANDS and words[:1] not in HERMES_READ_COMMANDS:
+ raise PolicyError(
+ f"hermes {' '.join(words[:2])} is outside the read-only boundary"
+ )
+
+
+def _reject_dot_segments(path: str) -> None:
+ """Refuse any relative segment, including a percent-encoded one.
+
+ The repository pin below is a textual prefix test, so a single ``..`` would
+ let a pinned path address another repository or the token endpoints once a
+ router normalised it. Decode first: ``%2e%2e`` is the same segment.
+ """
+ decoded = unquote(unquote(path)).split("?", 1)[0]
+ if any(segment in {"", ".", ".."} for segment in decoded.split("/")[1:]):
+ raise PolicyError("gitea_api.py paths may not contain relative segments")
+
+
+def _check_gitea(argv: tuple[str, ...], mode: str) -> None:
+ if len(argv) < 3:
+ raise PolicyError("gitea_api.py requires a method and a path")
+ method, path = argv[1].upper(), argv[2]
+ if not path.startswith("/api/v1/") or not _SAFE_API_PATH_RE.fullmatch(path):
+ raise PolicyError("gitea_api.py requires a bounded API path")
+ _reject_dot_segments(path)
+ repo_root = f"/api/v1/repos/{EXPECTED_REPO}"
+ if (
+ path != "/api/v1/user"
+ and path != repo_root
+ and not path.startswith((f"{repo_root}/", f"{repo_root}?"))
+ ):
+ raise PolicyError("gitea_api.py is pinned to the Atlas titan-iac repository")
+ if method in GITEA_READ_METHODS:
+ return
+ if mode == ARMED:
+ fields = [
+ argv[index + 1] for index, item in enumerate(argv[:-1]) if item == "--field"
+ ]
+ if method == "POST" and path == f"{repo_root}/pulls" and _ARMED_REF:
+ required = {
+ "title=WIP: Hermes handoff acceptance ephemeral probe",
+ f"head={_ARMED_REF}",
+ f"base={EXPECTED_BASE}",
+ "body=Ephemeral acceptance probe. Closed and deleted by the harness.",
+ }
+ if len(fields) == len(required) and set(fields) == required:
+ return
+ match = re.fullmatch(rf"{re.escape(repo_root)}/pulls/([1-9][0-9]*)", path)
+ if (
+ method == "PATCH"
+ and match
+ and int(match.group(1)) in _ARMED_PULLS
+ and fields == ["state=closed"]
+ ):
+ return
+ if (
+ method == "DELETE"
+ and _ARMED_REF
+ and path == f"{repo_root}/branches/{_ARMED_REF}"
+ and len(argv) == 3
+ ):
+ return
+ raise PolicyError(f"gitea_api.py {method} is not permitted in {mode} mode")
+
+
+def _check_shell(argv: tuple[str, ...]) -> None:
+ if len(argv) != 3 or argv[1] != "-c" or argv[2] not in _RENDERED:
+ raise PolicyError("sh is only reachable as sh -c ")
+
+
+def check_argv(argv: tuple[str, ...], mode: str = READ_ONLY) -> None:
+ """Raise :class:`PolicyError` unless ``argv`` is safe in ``mode``."""
+ if mode not in MODES:
+ raise PolicyError(f"unknown mode: {mode}")
+ if not argv:
+ raise PolicyError("empty command")
+ binary = _binary_name(argv[0])
+ if binary in FORBIDDEN_BINARIES:
+ raise PolicyError(f"forbidden binary: {binary}")
+ if binary not in ALLOWED_BINARIES:
+ raise PolicyError(f"binary is not on the harness allowlist: {binary}")
+ _reject_forbidden_arguments(argv)
+ _reject_secret_paths(argv)
+ if binary == "sh":
+ _check_shell(argv)
+ elif binary == "kubectl":
+ _check_kubectl(argv, mode)
+ elif binary == "git":
+ _check_git(argv, mode)
+ elif binary == "hermes":
+ _check_hermes(argv)
+ else:
+ _check_gitea(argv, mode)
diff --git a/scripts/ops/hermes_handoff_redaction.py b/scripts/ops/hermes_handoff_redaction.py
new file mode 100644
index 00000000..f7b97f4c
--- /dev/null
+++ b/scripts/ops/hermes_handoff_redaction.py
@@ -0,0 +1,243 @@
+#!/usr/bin/env python3
+"""Credential redaction and output bounding for the handoff acceptance harness.
+
+Every string the harness records — command output, failure reasons, evidence
+values — passes through :func:`redact` before it reaches a report. Screening is
+a fail-closed accident barrier, not proof that text is secret-free: a short
+secret under an innocuous key is not reliably distinguishable from prose. The
+harness therefore also never asks for a secret in the first place. Probes read
+names, shapes, and status fields; they never read environment values, token
+files, private keys, cookies, or Vault responses.
+
+Bounding is part of the same barrier. Screening always precedes truncation so a
+credential that crosses the byte boundary cannot leave a useful prefix behind.
+The process runner additionally discards all captured bytes when a live stream
+exceeds its cap; the fixed truncation marker is the only recorded output.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+REDACTED = "[redacted]"
+DEFAULT_MAX_BYTES = 64 * 1024
+TRUNCATION_NOTE = "[truncated]"
+
+# Keys whose value is never safe to record, matched against a normalised
+# (lowercased, separator-stripped) form of the key.
+SENSITIVE_KEY_SUFFIXES = (
+ "accesskey",
+ "accesskeyid",
+ "apikey",
+ "authtoken",
+ "clientsecret",
+ "connectionstring",
+ "cookie",
+ "credential",
+ "credentials",
+ "passphrase",
+ "passwd",
+ "password",
+ "privatekey",
+ "secret",
+ "secretkey",
+ "sessionkey",
+ "signature",
+ "signingkey",
+ "token",
+ "webhook",
+)
+SENSITIVE_KEY_EXACT = frozenset(
+ {
+ "auth",
+ "authorization",
+ "bearer",
+ "pat",
+ "pw",
+ "sas",
+ "setcookie",
+ }
+)
+# A bare `key:` is a structural field name across Kubernetes and TOML far more
+# often than it is a credential, so it is screened by value shape and by the
+# token patterns rather than by name. The compound forms above still match.
+
+# Ordinary prose values shorter than this threshold are not independently
+# treated as secret-shaped. Values under sensitive keys are screened at every
+# length, including numeric values.
+MIN_SECRET_LENGTH = 8
+NON_SECRET_LITERALS = frozenset(
+ {"true", "false", "null", "none", "yes", "no", "0", "1", ""}
+)
+_KEY_NOISE_RE = re.compile(r"[^a-z0-9]+")
+
+# Ordered: structural matches first so a nested token is not partially rewritten
+# by a looser rule.
+_PATTERNS: tuple[re.Pattern[str], ...] = (
+ re.compile(
+ r"-----BEGIN[ A-Z0-9-]{0,64}-----.*?-----END[ A-Z0-9-]{0,64}-----",
+ re.DOTALL,
+ ),
+ re.compile(r"-----BEGIN[ A-Z0-9-]{0,64}-----.*", re.DOTALL),
+ re.compile(r"(?i)\b(?:authorization|proxy-authorization)\s*:\s*\S.*"),
+ re.compile(r"(?i)\b(?:set-)?cookie\s*:\s*\S.*"),
+ re.compile(r"(?i)\b(?:bearer|basic|token)\s+[A-Za-z0-9+/_=.:~-]{8,}"),
+ re.compile(r"(?i)\b[a-z][a-z0-9+.-]{1,20}://[^\s/@:]{1,128}:[^\s/@]{1,256}@"),
+ re.compile(
+ r"\bssh-(?:rsa|ed25519|dss|ecdsa-[A-Za-z0-9-]+)\s+[A-Za-z0-9+/]{16,}={0,3}"
+ ),
+ re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}"),
+ re.compile(r"(?["']?)
+ (?P[A-Za-z][A-Za-z0-9_.\ -]{0,63})
+ (?P=quote)
+ (?P\s{0,8}[:=]\s{0,8})
+ (?P"[^"\n]{0,4096}"|'[^'\n]{0,4096}'|[^\s,;}\n][^,;}\n]{0,4096})
+ """
+)
+
+# Long, mixed-class runs. Deliberately conservative: prose and hex digests of
+# fewer than 44 characters are left alone so commit SHAs stay readable.
+_ENTROPY_RE = re.compile(
+ r"(? str:
+ """Return a key lowercased with separators removed, for suffix matching."""
+ return _KEY_NOISE_RE.sub("", key.lower())
+
+
+def is_sensitive_key(key: str) -> bool:
+ """Report whether a key name means its value must never be recorded."""
+ compact = normalise_key(key)
+ if not compact:
+ return False
+ if compact in SENSITIVE_KEY_EXACT:
+ return True
+ return compact.endswith(SENSITIVE_KEY_SUFFIXES)
+
+
+def _is_high_entropy(candidate: str) -> bool:
+ """Report whether a long run mixes enough character classes to be a token."""
+ classes = sum(
+ (
+ any(character.islower() for character in candidate),
+ any(character.isupper() for character in candidate),
+ any(character.isdigit() for character in candidate),
+ any(character in "+/_=-" for character in candidate),
+ )
+ )
+ return classes >= 4
+
+
+def could_hold_secret(value: str) -> bool:
+ """Report whether a raw value is long and opaque enough to be a credential."""
+ stripped = value.strip().strip("\"'")
+ if stripped.lower() in NON_SECRET_LITERALS or len(stripped) < MIN_SECRET_LENGTH:
+ return False
+ try:
+ float(stripped)
+ except ValueError:
+ return True
+ return False
+
+
+def _redact_assignment(match: re.Match[str]) -> str:
+ value = match.group("value")
+ stripped = value.strip().strip("\"'").lower()
+ if not is_sensitive_key(match.group("key")) or stripped in NON_SECRET_LITERALS:
+ return match.group(0)
+ # Preserve the original quoting so screened JSON stays parsable JSON.
+ quoted = value.strip()[:1] in {'"', "'"}
+ replacement = f'"{REDACTED}"' if quoted else REDACTED
+ key_quote = match.group("quote")
+ return (
+ f"{key_quote}{match.group('key')}{key_quote}{match.group('gap')}{replacement}"
+ )
+
+
+def _redact_entropy(match: re.Match[str]) -> str:
+ return REDACTED if _is_high_entropy(match.group(0)) else match.group(0)
+
+
+def redact(text: str) -> str:
+ """Return ``text`` with credential-shaped substrings replaced."""
+ if not text:
+ return text
+ for pattern in _PATTERNS:
+ text = pattern.sub(REDACTED, text)
+ text = _ASSIGNMENT_RE.sub(_redact_assignment, text)
+ return _ENTROPY_RE.sub(_redact_entropy, text)
+
+
+def bound(text: str, max_bytes: int = DEFAULT_MAX_BYTES) -> tuple[str, bool]:
+ """Truncate ``text`` to a UTF-8 byte budget, reporting whether it was cut."""
+ if max_bytes <= 0:
+ return ("", bool(text))
+ encoded = text.encode("utf-8", "replace")
+ if len(encoded) <= max_bytes:
+ return (text, False)
+ marker = TRUNCATION_NOTE.encode()[:max_bytes]
+ content_budget = max(
+ 0, max_bytes - len(marker) - (1 if len(marker) < max_bytes else 0)
+ )
+ kept = encoded[:content_budget].decode("utf-8", "ignore")
+ separator = "\n" if kept and len(marker) < max_bytes else ""
+ return (f"{kept}{separator}{marker.decode()}", True)
+
+
+def safe_text(text: str, max_bytes: int = DEFAULT_MAX_BYTES) -> tuple[str, bool]:
+ """Redact then bound, so truncation never exposes a credential prefix."""
+ return bound(redact(text), max_bytes)
+
+
+def scrub(value: Any, max_bytes: int = DEFAULT_MAX_BYTES) -> Any:
+ """Recursively bound and redact a JSON-shaped value.
+
+ Mapping keys are themselves screened; values under a sensitive key are
+ dropped outright rather than pattern-screened, because a short secret
+ would survive screening.
+ """
+ if isinstance(value, str):
+ return safe_text(value, max_bytes)[0]
+ if isinstance(value, dict):
+ screened: dict[str, Any] = {}
+ for key, item in value.items():
+ raw_key = str(key)
+ safe_key = safe_text(raw_key, max_bytes)[0]
+ sensitive_value = (
+ is_sensitive_key(raw_key)
+ and item is not None
+ and not isinstance(item, bool)
+ and item != ""
+ )
+ screened[safe_key] = REDACTED if sensitive_value else scrub(item, max_bytes)
+ return screened
+ if isinstance(value, (list, tuple)):
+ return [scrub(item, max_bytes) for item in value]
+ return value
+
+
+def contains_credential_shape(text: str) -> bool:
+ """Report whether screening would rewrite ``text``.
+
+ Used by the harness self-test to prove a rendered report is clean before it
+ is written, and by the unit suite as an assertion helper.
+ """
+ return redact(text) != text
diff --git a/scripts/ops/hermes_handoff_rules.py b/scripts/ops/hermes_handoff_rules.py
new file mode 100644
index 00000000..5cca2c5a
--- /dev/null
+++ b/scripts/ops/hermes_handoff_rules.py
@@ -0,0 +1,199 @@
+#!/usr/bin/env python3
+"""Shared primitives and dispatch for handoff acceptance evaluators.
+
+Evaluators are registered by name so the check catalog stays declarative: an
+entry says *what* it asserts, not how to plumb it. The dispatch here is the one
+place that turns an unexpected exception into ``NOT_RUN`` — a harness bug must
+never be indistinguishable from a passing release.
+"""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Callable, Mapping, Sequence
+from dataclasses import dataclass, field
+from typing import Any
+
+from hermes_handoff_exec import Outcome
+from hermes_handoff_model import NOT_RUN, STATUSES, CheckSpec
+
+DENIAL_MARKERS = (
+ "forbidden",
+ "is not allowed",
+ "unauthorized",
+ "permission denied",
+ "cannot get",
+ "cannot list",
+ "cannot create",
+ "cannot delete",
+ "cannot patch",
+ "not permitted",
+ "gitea api returned http 403",
+)
+
+
+@dataclass
+class Evaluation:
+ """A classification plus the small facts that justify it."""
+
+ status: str
+ reason: str
+ evidence: dict[str, Any] = field(default_factory=dict)
+
+
+Evaluator = Callable[[CheckSpec, dict[str, Outcome]], Evaluation]
+EVALUATORS: dict[str, Evaluator] = {}
+
+
+def evaluator(name: str) -> Callable[[Evaluator], Evaluator]:
+ """Register an evaluator under the rule name catalog entries use."""
+
+ def register(function: Evaluator) -> Evaluator:
+ EVALUATORS[name] = function
+ return function
+
+ return register
+
+
+def missing_steps(
+ spec: CheckSpec, outcomes: Mapping[str, Outcome]
+) -> Evaluation | None:
+ """Return NOT_RUN when a required step never produced usable evidence."""
+ for step in spec.steps:
+ if step.optional:
+ continue
+ outcome = outcomes.get(step.key)
+ if outcome is None:
+ return Evaluation(NOT_RUN, f"step {step.key} was never executed")
+ if outcome.truncated:
+ return Evaluation(NOT_RUN, f"step {step.key} output was truncated")
+ if not outcome.ran:
+ return Evaluation(NOT_RUN, f"step {step.key} did not run: {outcome.error}")
+ return None
+
+
+def looks_denied(outcome: Outcome) -> bool:
+ """Report whether a real request was refused by policy rather than failing."""
+ if outcome.ok:
+ return False
+ haystack = outcome.combined.lower()
+ return any(marker in haystack for marker in DENIAL_MARKERS)
+
+
+def step_keys(spec: CheckSpec, kind: str) -> tuple[str, ...]:
+ """Return the keys of every step of one kind."""
+ return tuple(step.key for step in spec.steps if step.kind == kind)
+
+
+def is_list(value: Any) -> bool:
+ """Report whether a value is a JSON array rather than a string."""
+ return isinstance(value, Sequence) and not isinstance(value, (str, bytes))
+
+
+def exact_equal(actual: Any, expected: Any) -> bool:
+ """Compare JSON values without Python's bool/int type confusion."""
+ return type(actual) is type(expected) and actual == expected
+
+
+def dotted(payload: Any, path: str) -> Any:
+ """Return a value addressed by a dotted path.
+
+ ``a.b[0].c`` indexes; ``a[].b`` maps the remainder of the path over a list,
+ which is how the catalog records one field from every pull request or every
+ Flux object without a bespoke parser per check.
+ """
+ current = payload
+ segments = [segment for segment in path.split(".") if segment]
+ for position, raw in enumerate(segments):
+ name, bracket, indexes = raw.partition("[")
+ if name:
+ if not isinstance(current, Mapping) or name not in current:
+ raise KeyError(path)
+ current = current[name]
+ if not bracket:
+ continue
+ for chunk in indexes.rstrip("]").split("]["):
+ if not is_list(current):
+ raise KeyError(path)
+ if chunk == "":
+ remainder = ".".join(segments[position + 1 :])
+ return [
+ dotted(item, remainder) if remainder else item for item in current
+ ]
+ offset = int(chunk)
+ if offset >= len(current):
+ raise KeyError(path)
+ current = current[offset]
+ return current
+
+
+def strict_json(text: str) -> Any:
+ """Decode strict JSON, rejecting constants and duplicate object keys."""
+ text = text.strip()
+ if not text:
+ raise ValueError("empty output")
+
+ def reject_constant(value: str) -> None:
+ raise ValueError(f"non-finite JSON constant {value}")
+
+ def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
+ result: dict[str, Any] = {}
+ for key, value in pairs:
+ if key in result:
+ raise ValueError(f"duplicate JSON key {key!r}")
+ result[key] = value
+ return result
+
+ return json.loads(
+ text,
+ strict=True,
+ parse_constant=reject_constant,
+ object_pairs_hook=unique_object,
+ )
+
+
+def parse_json(outcome: Outcome) -> Any:
+ """Parse one outcome as strict JSON."""
+ return strict_json(outcome.stdout)
+
+
+def lines(outcome: Outcome) -> list[str]:
+ """Return non-empty, stripped stdout lines."""
+ return [line.strip() for line in outcome.stdout.splitlines() if line.strip()]
+
+
+def parsed_step(
+ spec: CheckSpec, outcomes: dict[str, Outcome]
+) -> tuple[Any, str] | Evaluation:
+ """Return the parsed payload of ``expect['step']`` or the NOT_RUN to report."""
+ key = spec.expect["step"]
+ outcome = outcomes[key]
+ if not outcome.ok:
+ return Evaluation(NOT_RUN, f"step {key} failed (rc={outcome.returncode})")
+ try:
+ return (parse_json(outcome), key)
+ except ValueError as exc:
+ return Evaluation(NOT_RUN, f"step {key} did not return JSON: {exc}")
+
+
+def evaluate(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
+ """Dispatch to the named evaluator, converting any surprise into NOT_RUN."""
+ rule = EVALUATORS.get(spec.rule)
+ if rule is None:
+ return Evaluation(NOT_RUN, f"unknown evaluator: {spec.rule}")
+ truncated = sorted(key for key, outcome in outcomes.items() if outcome.truncated)
+ if truncated:
+ return Evaluation(
+ NOT_RUN, f"truncated evidence from step(s): {', '.join(truncated)}"
+ )
+ try:
+ result = rule(spec, outcomes)
+ except Exception as exc: # noqa: BLE001 - a harness bug must never read as PASS
+ return Evaluation(
+ NOT_RUN, f"evaluator {spec.rule} raised {type(exc).__name__}: {exc}"
+ )
+ if result.status not in STATUSES:
+ return Evaluation(
+ NOT_RUN, f"evaluator {spec.rule} returned unknown status {result.status!r}"
+ )
+ return result
diff --git a/scripts/ops/hermes_handoff_run.py b/scripts/ops/hermes_handoff_run.py
new file mode 100644
index 00000000..45459dc8
--- /dev/null
+++ b/scripts/ops/hermes_handoff_run.py
@@ -0,0 +1,484 @@
+#!/usr/bin/env python3
+"""Orchestration for the Hermes full-handoff acceptance harness.
+
+Three things happen before any check runs, and each of them can stop the run on
+its own:
+
+* the catalog is validated structurally — every rule exists, every live-denial
+ evaluator carries a read-only attempt, no operator-side step can impersonate;
+* each vantage is resolved and asked who it is; and
+* the operator and in-pod identities are compared. If they are the same
+ principal there is only one vantage, the dual-vantage evidence would be a
+ restatement rather than a corroboration, and the run is a NO_GO.
+
+Everything after that is uniform: run a check's steps against its vantage, hand
+the outcomes to the named evaluator, and record the classification. A vantage
+that could not be resolved yields ``NOT_RUN`` for every check that needs it,
+which is exactly the fail-closed answer.
+"""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Iterable, Mapping, Sequence
+
+from hermes_handoff_catalog import (
+ CHAT,
+ NODE,
+ OPERATOR,
+ SELF,
+ SWITCHYARD,
+ VANTAGE_NAMES,
+ Targets,
+)
+from hermes_handoff_checks_access import access_checks
+from hermes_handoff_checks_delivery import delivery_checks
+from hermes_handoff_checks_platform import platform_checks
+from hermes_handoff_checks_workers import worker_checks
+from hermes_handoff_evaluators import EVALUATORS, evaluate
+from hermes_handoff_exec import Outcome, Runner, Vantage, operator_vantage, pod_vantage
+from hermes_handoff_model import (
+ ATTEMPT,
+ EPHEMERAL,
+ NOT_RUN,
+ STEP_KINDS,
+ CheckResult,
+ CheckSpec,
+ Report,
+ VantageRecord,
+ utc_now,
+)
+from hermes_handoff_policy import (
+ EXPECTED_REMOTE,
+ EXPECTED_REPO,
+ IMPERSONATION_ARGS,
+ PolicyError,
+ check_argv,
+ projection,
+)
+
+UNRECORDED = "[output not recorded]"
+
+
+def build_catalog(targets: Targets) -> list[CheckSpec]:
+ """Return the whole acceptance catalog for a set of targets."""
+ return [
+ *platform_checks(targets),
+ *access_checks(targets),
+ *delivery_checks(targets),
+ *worker_checks(targets),
+ ]
+
+
+def validate_catalog(specs: Sequence[CheckSpec]) -> list[str]:
+ """Return the structural problems that make a catalog unsafe to run."""
+ problems: list[str] = []
+ if not specs:
+ return ["catalog is empty"]
+ seen: set[str] = set()
+ fake_operator = operator_vantage()
+ fake_pod = pod_vantage("hermes", "acceptance-probe", "probe", fake_operator)
+ for spec in specs:
+ if spec.id in seen:
+ problems.append(f"{spec.id}: duplicate check id")
+ seen.add(spec.id)
+ if spec.rule not in EVALUATORS and spec.scope != EPHEMERAL:
+ problems.append(f"{spec.id}: unknown rule {spec.rule!r}")
+ step_names: set[str] = set()
+ for step in spec.steps:
+ if step.key in step_names:
+ problems.append(f"{spec.id}: duplicate step key {step.key!r}")
+ step_names.add(step.key)
+ if step.vantage not in VANTAGE_NAMES:
+ problems.append(
+ f"{spec.id}: step {step.key} names unknown vantage {step.vantage!r}"
+ )
+ if step.kind not in STEP_KINDS:
+ problems.append(
+ f"{spec.id}: step {step.key} has unknown kind {step.kind!r}"
+ )
+ if set(step.argv) & set(IMPERSONATION_ARGS):
+ problems.append(
+ f"{spec.id}: step {step.key} impersonates from the "
+ f"{step.vantage} vantage"
+ )
+ try:
+ addressed = (
+ fake_operator if step.vantage == OPERATOR else fake_pod
+ ).wrap(step.argv)
+ check_argv(addressed)
+ except PolicyError as exc:
+ problems.append(f"{spec.id}: step {step.key} violates policy: {exc}")
+ if spec.rule == "denied" and not any(
+ step.kind == ATTEMPT for step in spec.steps
+ ):
+ problems.append(
+ f"{spec.id}: a deny check needs a real attempt, not only a review"
+ )
+ return problems
+
+
+def validate_targets(targets: Targets) -> list[str]:
+ """Return release-input problems before a runner or network call exists."""
+ problems: list[str] = []
+ sha = re.compile(r"[0-9a-f]{40}\Z")
+ image = re.compile(
+ r"[^@\s]+:git-([0-9a-f]{40})-build-[1-9][0-9]*@sha256:[0-9a-f]{64}\Z"
+ )
+ if targets.repo != EXPECTED_REPO or targets.remote != EXPECTED_REMOTE:
+ problems.append(
+ "repository and remote must be the fixed Atlas titan-iac origin"
+ )
+ if (
+ targets.reviewed_pr_number != 19
+ or targets.reviewed_head_ref != "feature/hermes-full-handoff-acceptance"
+ ):
+ problems.append(
+ "reviewed PR identity must remain fixed to draft PR #19 and its existing branch"
+ )
+ for label, value in (
+ ("baseline commit", targets.baseline_commit),
+ ("remote main SHA", targets.remote_main_sha),
+ ("reviewed head SHA", targets.reviewed_head_sha),
+ ("build SHA", targets.build_sha),
+ ):
+ if not sha.fullmatch(value):
+ problems.append(f"{label} must be an exact lowercase 40-character SHA")
+ image_match = image.fullmatch(targets.agent_image)
+ if not image_match or image_match.group(1) != targets.build_sha:
+ problems.append("agent image tag/digest must bind the exact build SHA")
+ if targets.build_sha != targets.remote_main_sha:
+ problems.append("build SHA must equal the exact release main SHA")
+ if not re.fullmatch(r"[1-9][0-9]{0,8}", str(targets.deployment_revision)):
+ problems.append("deployment revision must be a positive integer string")
+ if targets.node_count != 3 or targets.pool_replicas != 3:
+ problems.append("node and pool counts are fixed at exactly three")
+ if not targets.chat_config_revision.strip():
+ problems.append(
+ "Telegram continuity and its chat config revision are mandatory"
+ )
+ if set(targets.pool_worker_env) != {"HERMES_WORKER_NODE", "HERMES_WORKER_ORDINAL"}:
+ problems.append("pool assignment evidence must include worker node and ordinal")
+ heads = dict(targets.dependency_heads)
+ if len(heads) != len(targets.dependency_heads) or set(heads) != set(
+ targets.dependency_pull_requests
+ ):
+ problems.append("every dependency PR must have one exact current head")
+ elif any(not sha.fullmatch(value) for value in heads.values()):
+ problems.append("dependency heads must be exact lowercase 40-character SHAs")
+ if (
+ not isinstance(targets.max_evidence_age_seconds, int)
+ or targets.max_evidence_age_seconds <= 0
+ ):
+ problems.append("maximum evidence age must be a positive integer")
+ return problems
+
+
+def resolve_identity(runner: Runner, vantage: Vantage) -> tuple[str, str]:
+ """Return the authenticated username of a vantage and any failure detail."""
+ outcome = runner.run(
+ (
+ "kubectl",
+ "auth",
+ "whoami",
+ "-o",
+ projection("jsonpath={.status.userInfo.username}"),
+ ),
+ vantage,
+ )
+ if not outcome.ok:
+ return ("", outcome.error or outcome.combined[:200])
+ username = outcome.stdout.strip()
+ if not username or any(character.isspace() for character in username):
+ return ("", "identity response carried no username")
+ return (username, "")
+
+
+def _first_pod(
+ runner: Runner, operator: Vantage, namespace: str, selector: str
+) -> tuple[str, str]:
+ outcome = runner.run(
+ (
+ "kubectl",
+ "--namespace",
+ namespace,
+ "get",
+ "pods",
+ "--selector",
+ selector,
+ "--field-selector",
+ "status.phase=Running",
+ "-o",
+ "name",
+ ),
+ operator,
+ )
+ if not outcome.ok:
+ return ("", outcome.error or outcome.combined[:200])
+ names = [
+ line.strip().removeprefix("pod/")
+ for line in outcome.stdout.splitlines()
+ if line.strip()
+ ]
+ if not names:
+ return ("", f"no running pod matches {selector}")
+ return (sorted(names)[0], "")
+
+
+def resolve_vantages(
+ runner: Runner,
+ targets: Targets,
+ kubeconfig: str | None = None,
+ context: str | None = None,
+) -> tuple[dict[str, Vantage], list[VantageRecord]]:
+ """Resolve every vantage the catalog names, recording what each one is."""
+ operator = operator_vantage(kubeconfig, context)
+ username, detail = resolve_identity(runner, operator)
+ records = [
+ VantageRecord(
+ name=OPERATOR,
+ description=operator.description or "external read-only operator",
+ identity=username,
+ available=bool(username),
+ detail=detail,
+ )
+ ]
+ vantages: dict[str, Vantage] = {OPERATOR: operator}
+ if not username:
+ return (vantages, records)
+
+ for name, selector, container, description in (
+ (
+ SELF,
+ f"app={targets.agent_deployment}",
+ targets.agent_container,
+ "the Hermes agent itself",
+ ),
+ (
+ SWITCHYARD,
+ f"app={targets.switchyard_deployment}",
+ targets.switchyard_container,
+ "the Switchyard routing evidence log",
+ ),
+ (
+ NODE,
+ f"app={targets.node_daemonset}",
+ targets.node_container,
+ "a node hardening probe",
+ ),
+ ):
+ pod, failure = _first_pod(runner, operator, targets.namespace, selector)
+ if not pod:
+ records.append(
+ VantageRecord(
+ name=name, description=description, available=False, detail=failure
+ )
+ )
+ continue
+ vantage = pod_vantage(targets.namespace, pod, container, operator)
+ identity, identity_detail = (
+ resolve_identity(runner, vantage) if name == SELF else ("", "")
+ )
+ records.append(
+ VantageRecord(
+ name=name,
+ description=f"{description} ({targets.namespace}/{pod})",
+ identity=identity,
+ available=True,
+ detail=identity_detail,
+ )
+ )
+ vantages[name] = vantage
+
+ chat_pod = f"{targets.chat_statefulset}-{targets.chat_ordinal}"
+ present = runner.run(
+ (
+ "kubectl",
+ "--namespace",
+ targets.namespace,
+ "get",
+ f"pod/{chat_pod}",
+ "-o",
+ "name",
+ ),
+ operator,
+ )
+ if present.ok and present.stdout.strip():
+ vantages[CHAT] = pod_vantage(
+ targets.namespace, chat_pod, targets.chat_container, operator
+ )
+ records.append(
+ VantageRecord(
+ name=CHAT,
+ description=f"the chat surface ({targets.namespace}/{chat_pod})",
+ available=True,
+ )
+ )
+ else:
+ records.append(
+ VantageRecord(
+ name=CHAT,
+ description=f"the chat surface ({targets.namespace}/{chat_pod})",
+ available=False,
+ detail=present.error
+ or present.combined[:200]
+ or "chat tenant pod not found",
+ )
+ )
+ return (vantages, records)
+
+
+def vantage_problems(records: Iterable[VantageRecord]) -> list[str]:
+ """Return the identity problems that invalidate dual-vantage evidence."""
+ by_name = {record.name: record for record in records}
+ operator = by_name.get(OPERATOR)
+ hermes = by_name.get(SELF)
+ problems: list[str] = []
+ if operator is None or not operator.available:
+ problems.append(
+ "the operator vantage is unavailable; no evidence can be collected"
+ )
+ return problems
+ if hermes is None or not hermes.available:
+ problems.append(
+ "the in-pod Hermes vantage is unavailable; self-probe evidence is missing"
+ )
+ return problems
+ if not hermes.identity:
+ problems.append("the in-pod vantage did not report an identity")
+ elif operator.identity == hermes.identity:
+ problems.append(
+ "the operator and in-pod vantages authenticate as the same principal "
+ f"({operator.identity}); run the harness with a separate operator kubeconfig"
+ )
+ for name, label in ((SWITCHYARD, "Switchyard"), (CHAT, "chat/Telegram")):
+ record = by_name.get(name)
+ if record is None or not record.available:
+ problems.append(f"the required {label} vantage is unavailable")
+ return problems
+
+
+def run_check(
+ runner: Runner, spec: CheckSpec, vantages: Mapping[str, Vantage]
+) -> CheckResult:
+ """Run one check's steps and classify the result."""
+ outcomes: dict[str, Outcome] = {}
+ recorded: list[Outcome] = []
+ for step in spec.steps:
+ vantage = vantages.get(step.vantage)
+ if vantage is None:
+ outcome = Outcome(
+ argv=step.argv,
+ vantage=step.vantage,
+ error=f"vantage {step.vantage} is unavailable",
+ )
+ else:
+ outcome = runner.run(step.argv, vantage, step.max_bytes)
+ outcomes[step.key] = outcome
+ recorded.append(
+ outcome
+ if step.record
+ else Outcome(
+ argv=outcome.argv,
+ vantage=outcome.vantage,
+ returncode=outcome.returncode,
+ stdout=UNRECORDED if outcome.stdout else "",
+ stderr=UNRECORDED if outcome.stderr else "",
+ truncated=outcome.truncated,
+ duration_ms=outcome.duration_ms,
+ error=outcome.error,
+ executable_path=outcome.executable_path,
+ executable_sha256=outcome.executable_sha256,
+ )
+ )
+ evaluation = evaluate(spec, outcomes)
+ return CheckResult(
+ spec=spec,
+ status=evaluation.status,
+ reason=evaluation.reason,
+ evidence=evaluation.evidence,
+ outcomes=recorded,
+ )
+
+
+def run_catalog(
+ runner: Runner, specs: Sequence[CheckSpec], vantages: Mapping[str, Vantage]
+) -> list[CheckResult]:
+ """Run every check in the catalog.
+
+ Nothing is skipped once the deadline passes: the runner returns a
+ deadline outcome for each remaining command, which classifies as
+ ``NOT_RUN``, so a truncated sweep is visible in the report rather than
+ silently shorter.
+ """
+ return [run_check(runner, spec, vantages) for spec in specs]
+
+
+def build_report(
+ runner: Runner,
+ targets: Targets,
+ specs: Sequence[CheckSpec],
+ vantages: Mapping[str, Vantage],
+ records: Sequence[VantageRecord],
+ mode: str,
+ started_at: str,
+ extra_results: Sequence[CheckResult] = (),
+) -> Report:
+ """Run the catalog and assemble the report, including structural problems."""
+ problems = validate_catalog(specs) + vantage_problems(records)
+ report = Report(
+ mode=mode,
+ started_at=started_at,
+ baseline={
+ "namespace": targets.namespace,
+ "baseline_commit": targets.baseline_commit,
+ "dependency_pull_requests": list(targets.dependency_pull_requests),
+ "expected_suspensions": list(targets.expected_suspensions),
+ "node_count": targets.node_count,
+ "pool_replicas": targets.pool_replicas,
+ },
+ vantages=list(records),
+ harness_errors=problems,
+ )
+ armed_ids = {result.spec.id for result in extra_results}
+ runnable = [spec for spec in specs if spec.id not in armed_ids]
+ if problems:
+ report.results = [
+ CheckResult(spec=spec, status=NOT_RUN, reason="harness preflight failed")
+ for spec in runnable
+ ]
+ report.results.extend(extra_results)
+ else:
+ report.results = [*run_catalog(runner, runnable, vantages), *extra_results]
+ report.results.sort(key=lambda result: result.spec.id)
+ if runner.remaining_seconds <= 0:
+ report.harness_errors.append(
+ "the run deadline expired before every check completed"
+ )
+ report.finished_at = utc_now()
+ return report
+
+
+def unavailable_report(
+ mode: str, started_at: str, reason: str, targets: Targets
+) -> Report:
+ """Return a NO_GO report for a run that could not start at all."""
+ return Report(
+ mode=mode,
+ started_at=started_at,
+ finished_at=utc_now(),
+ baseline={"namespace": targets.namespace},
+ harness_errors=[reason],
+ results=[
+ CheckResult(
+ spec=CheckSpec(
+ id="harness.startup",
+ title="The harness could start and resolve its vantages",
+ group="harness",
+ rule="allowed",
+ ),
+ status=NOT_RUN,
+ reason=reason,
+ )
+ ],
+ )
diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml
index 7ae26434..c36a792e 100644
--- a/services/hermes/agent-deployment.yaml
+++ b/services/hermes/agent-deployment.yaml
@@ -25,7 +25,7 @@ spec:
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
- ai.bstein.dev/config-rev: "20260817-cli-auto-quota-routing-v1"
+ ai.bstein.dev/config-rev: "20260818-ai-usage-exporter-split-v3"
prometheus.io/scrape: "true"
prometheus.io/path: /metrics
prometheus.io/port: "9010"
diff --git a/services/hermes/chat-statefulset.yaml b/services/hermes/chat-statefulset.yaml
index dda35cd7..3d509116 100644
--- a/services/hermes/chat-statefulset.yaml
+++ b/services/hermes/chat-statefulset.yaml
@@ -204,6 +204,9 @@ spec:
- |
/opt/hermes/.venv/bin/python /opt/coordinator/patch_api_server_sessions.py \
/opt/hermes/gateway/platforms/api_server.py /patched/api_server.py
+ grep -Fq 'conversation_history = compact_telegram_history(conversation_history)' /patched/api_server.py
+ grep -Fq 'full_history = compact_telegram_history(full_history)' /patched/api_server.py
+ grep -Fq 'conversation_history_snapshot = compact_telegram_history(' /patched/api_server.py
/opt/hermes/.venv/bin/python /opt/coordinator/migrate_telegram_api_sessions.py \
/opt/data/state.db /opt/data/response_store.db
securityContext:
@@ -284,6 +287,7 @@ spec:
- {name: runtime-access, mountPath: /runtime-access}
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
- {name: api-server-patch, mountPath: /opt/hermes/gateway/platforms/api_server.py, subPath: api_server.py}
+ - {name: coordinator, mountPath: /opt/hermes/gateway/platforms/telegram_continuity.py, subPath: migrate_telegram_api_sessions.py, readOnly: true}
- {name: stream-recovery-patch, mountPath: /opt/hermes/agent/conversation_loop.py, subPath: conversation_loop.py}
- {name: subprocess-secret-patch, mountPath: /opt/hermes/tools/environments/local.py, subPath: local.py}
- {name: subprocess-secret-patch, mountPath: /opt/hermes/tools/process_registry.py, subPath: process_registry.py}
diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml
index 7abeb694..4636e301 100644
--- a/services/hermes/kustomization.yaml
+++ b/services/hermes/kustomization.yaml
@@ -97,7 +97,9 @@ configMapGenerator:
- name: hermes-coordinator
namespace: hermes
files:
+ - ai_usage_codex.py=scripts/ai_usage_codex.py
- ai_usage_exporter.py=scripts/ai_usage_exporter.py
+ - ai_usage_http.py=scripts/ai_usage_http.py
- claude=scripts/claude
- claude_command_policy.py=scripts/claude_command_policy.py
- cli_lane_board.py=scripts/cli_lane_board.py
diff --git a/services/hermes/router/main.go b/services/hermes/router/main.go
index 16d7656c..db115242 100644
--- a/services/hermes/router/main.go
+++ b/services/hermes/router/main.go
@@ -27,8 +27,9 @@ type linkRecord struct {
}
type telegramTopic struct {
- Label string `json:"label"`
- LastUsed int64 `json:"last_used"`
+ Label string `json:"label"`
+ LastUsed int64 `json:"last_used"`
+ Generation int64 `json:"generation,omitempty"`
}
type telegramTopicState struct {
@@ -372,7 +373,12 @@ func (router *tenantRouter) ServeHTTP(writer http.ResponseWriter, request *http.
return
}
proxy := httputil.NewSingleHostReverseProxy(target)
- proxy.ModifyResponse = injectChatBridge
+ proxy.ModifyResponse = func(response *http.Response) error {
+ if err := boundSessionSnapshot(response); err != nil {
+ return err
+ }
+ return injectChatBridge(response)
+ }
proxy.ErrorHandler = func(writer http.ResponseWriter, _ *http.Request, proxyErr error) {
log.Printf("tenant slot %d unavailable", slot)
http.Error(writer, "private chat runtime is starting", http.StatusBadGateway)
diff --git a/services/hermes/router/main_test.go b/services/hermes/router/main_test.go
index a17b020a..152ebeca 100644
--- a/services/hermes/router/main_test.go
+++ b/services/hermes/router/main_test.go
@@ -1,6 +1,7 @@
package main
import (
+ "encoding/json"
"io"
"net/http"
"net/http/httptest"
@@ -126,6 +127,9 @@ func TestRouterProxiesWebUIAndAddsTelegramShortcut(t *testing.T) {
if !strings.Contains(response.Body.String(), "hermes-chat-bridge.js") || !strings.Contains(response.Body.String(), "hermes-chat-bridge.css") {
t.Fatal("Telegram shortcut assets were not injected")
}
+ if !strings.Contains(response.Body.String(), "hermes-session-continuity.js") {
+ t.Fatal("session continuity fallback was not injected")
+ }
assetRequest := httptest.NewRequest(http.MethodGet, "/hermes-chat-bridge.js", nil)
assetRequest.Header.Set("X-Forwarded-User", "subject")
assetResponse := httptest.NewRecorder()
@@ -145,6 +149,75 @@ func TestRouterProxiesWebUIAndAddsTelegramShortcut(t *testing.T) {
}
}
+func TestSessionContinuityAssetHasAccessiblePollFallback(t *testing.T) {
+ router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(int) string { return "" })
+ if err != nil {
+ t.Fatal(err)
+ }
+ request := httptest.NewRequest(http.MethodGet, "/hermes-session-continuity.js", nil)
+ request.Header.Set("X-Forwarded-User", "subject")
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != http.StatusOK {
+ t.Fatalf("got status %d", response.Code)
+ }
+ asset := response.Body.String()
+ for _, expected := range []string{
+ "aria-live", "aria-busy", "/api/sessions/", "/messages?limit=24&hermes_fallback=1",
+ "Latest stored activity", "no renderable messages",
+ "Session updates disconnected", "oauth2/start?rd=", "payload.session_id",
+ "session.ended_at == null", "latest.observed", "addEventListener('online'",
+ "addEventListener('pageshow'", "schedule(document.hidden", "fetch(",
+ } {
+ if !strings.Contains(asset, expected) {
+ t.Fatalf("session fallback omitted %q", expected)
+ }
+ }
+ for _, forbidden := range []string{
+ "location.replace", "location.reload", "history.replaceState", "/api/session?", "WebSocket",
+ } {
+ if strings.Contains(asset, forbidden) {
+ t.Fatalf("session fallback interferes with native continuity via %q", forbidden)
+ }
+ }
+}
+
+func TestSessionContinuityPollIsBoundedByRouter(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
+ messages := make([]map[string]int, 30)
+ for index := range messages {
+ messages[index] = map[string]int{"index": index}
+ }
+ writer.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(writer).Encode(map[string]any{
+ "session_id": "resolved", "messages": messages,
+ })
+ }))
+ defer backend.Close()
+ router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(int) string { return backend.URL })
+ if err != nil {
+ t.Fatal(err)
+ }
+ request := httptest.NewRequest(
+ http.MethodGet,
+ "/api/sessions/root/messages?limit=24&hermes_fallback=1",
+ nil,
+ )
+ request.Header.Set("X-Forwarded-User", "subject")
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != http.StatusOK {
+ t.Fatalf("got status %d", response.Code)
+ }
+ var payload sessionSnapshot
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatal(err)
+ }
+ if len(payload.Messages) != sessionSnapshotItems || payload.TotalMessages != 30 {
+ t.Fatalf("router returned unbounded fallback: %#v", payload)
+ }
+}
+
func TestTelegramPageExplainsOneTimeOperatorSetup(t *testing.T) {
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
if err != nil {
@@ -294,11 +367,52 @@ func TestPrivateFileBrowserIsTenantAuthenticatedAndHidesKanban(t *testing.T) {
assetRequest.Header.Set("X-Forwarded-User", "subject")
assetResponse := httptest.NewRecorder()
router.ServeHTTP(assetResponse, assetRequest)
- if !strings.Contains(assetResponse.Body.String(), "endpoint('list'") || !strings.Contains(assetResponse.Body.String(), `'[data-panel="kanban"]'`) {
+ if !strings.Contains(assetResponse.Body.String(), "endpoint('list'") || !strings.Contains(assetResponse.Body.String(), `'[data-panel="' + panel + '"]'`) {
t.Fatal("file browsing or chat-only navigation policy is missing")
}
}
+func TestChatBridgeHidesOnlyNonChatCockpitChrome(t *testing.T) {
+ router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
+ if err != nil {
+ t.Fatal(err)
+ }
+ asset := func(path string) string {
+ request := httptest.NewRequest(http.MethodGet, path, nil)
+ request.Header.Set("X-Forwarded-User", "subject")
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != http.StatusOK {
+ t.Fatalf("%s got status %d", path, response.Code)
+ }
+ return response.Body.String()
+ }
+ js := asset("/hermes-chat-bridge.js")
+ css := asset("/hermes-chat-bridge.css")
+
+ // The whole cockpit (Kanban plus every non-chat rail panel) is declutter-hidden
+ // both pre-JS (CSS, no flash) and by the runtime .hidden reinforcement.
+ for _, panel := range []string{"kanban", "logs", "insights", "memory", "skills", "workspaces", "todos", "tasks", "profiles"} {
+ if !strings.Contains(css, `[data-panel="`+panel+`"]`) {
+ t.Fatalf("bridge CSS does not hide cockpit panel %q", panel)
+ }
+ if !strings.Contains(js, `'`+panel+`'`) {
+ t.Fatalf("bridge JS does not mark cockpit panel %q hidden", panel)
+ }
+ }
+ if !strings.Contains(css, "display:none") || !strings.Contains(css, "#settingsModel") {
+ t.Fatal("bridge CSS must hide the cockpit panels and the redundant settings model selector")
+ }
+
+ // The clean chat experience (chat surface, history, composer, model chip)
+ // must never be part of the hide set.
+ for _, keep := range []string{`[data-panel="chat"]`, `[data-panel="history"]`, `[data-panel="sessions"]`, `[data-panel="composer"]`, "composerRoutingChip", "composerRoutingLabel"} {
+ if strings.Contains(css, keep) || strings.Contains(js, keep) {
+ t.Fatalf("declutter wrongly targets a core chat control: %q", keep)
+ }
+ }
+}
+
func TestWebUIModelAndReasoningOverridesAreProxied(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost {
diff --git a/services/hermes/router/session_continuity.go b/services/hermes/router/session_continuity.go
new file mode 100644
index 00000000..af42b35d
--- /dev/null
+++ b/services/hermes/router/session_continuity.go
@@ -0,0 +1,97 @@
+package main
+
+const sessionContinuityJS = `(() => {
+ const match = location.pathname.match(/^\/session\/([^/]+)\/?$/);
+ if (!match) return;
+ let sessionId;
+ try { sessionId = decodeURIComponent(match[1]); } catch (_) { sessionId = match[1]; }
+ const started = Date.now();
+ let timer = 0;
+ let request = null;
+ const card = document.createElement('aside');
+ card.id = 'hermes-session-continuity';
+ card.setAttribute('role', 'status');
+ card.setAttribute('aria-live', 'polite');
+ card.setAttribute('aria-atomic', 'true');
+ card.hidden = true;
+ card.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:1000;max-width:min(420px,calc(100vw - 32px));box-sizing:border-box;padding:10px 12px;border:1px solid #475569;border-radius:10px;background:#111827;color:#e5e7eb;font:13px/1.4 system-ui,sans-serif;box-shadow:0 8px 28px #0008';
+ const message = document.createElement('span');
+ const newChat = document.createElement('a');
+ newChat.href = '/';
+ newChat.textContent = ' Start a new chat.';
+ newChat.style.color = '#7dd3fc';
+ card.append(message, newChat);
+ document.body.appendChild(card);
+
+ const show = (text, busy, link) => {
+ message.textContent = text;
+ card.hidden = false;
+ card.setAttribute('aria-busy', busy ? 'true' : 'false');
+ newChat.hidden = !link;
+ };
+ const hide = () => { card.hidden = true; card.setAttribute('aria-busy', 'false'); };
+ const activityLabel = (messages) => {
+ const latest = messages[messages.length - 1] || {};
+ if (latest.tool_name) return 'tool ' + String(latest.tool_name).slice(0, 80);
+ if (Array.isArray(latest.tool_calls) && latest.tool_calls.length) {
+ const call = latest.tool_calls[latest.tool_calls.length - 1] || {};
+ return 'tool ' + String((call.function || {}).name || 'activity').slice(0, 80);
+ }
+ if (latest.activity_event) return String(latest.activity_event).replace(/[._-]+/g, ' ').slice(0, 80);
+ if (latest.observed && typeof latest.content === 'string') return latest.content.slice(0, 120);
+ return latest.role === 'assistant' ? 'assistant update' : latest.role === 'user' ? 'request stored' : 'working';
+ };
+ const authOrMissing = (response) => {
+ if (response.status === 401 || response.status === 403) {
+ const rd = location.pathname + location.search + location.hash;
+ location.assign('/oauth2/start?rd=' + encodeURIComponent(rd));
+ return 'auth';
+ }
+ if (response.status === 404) {
+ show('This session is unavailable to this account.', false, true);
+ schedule(10000);
+ return 'missing';
+ }
+ return '';
+ };
+ const schedule = (delay) => {
+ clearTimeout(timer);
+ timer = window.setTimeout(poll, delay);
+ };
+ async function poll() {
+ if (request) return;
+ request = new AbortController();
+ const timeout = window.setTimeout(() => request && request.abort(), 8000);
+ try {
+ const root = '/api/sessions/' + encodeURIComponent(sessionId);
+ const messageResponse = await fetch(root + '/messages?limit=24&hermes_fallback=1', {cache:'no-store', credentials:'same-origin', signal:request.signal});
+ if (authOrMissing(messageResponse)) return;
+ if (!messageResponse.ok) throw new Error('session messages poll failed');
+ const payload = await messageResponse.json();
+ const messages = Array.isArray(payload.messages) ? payload.messages : [];
+ const resolvedId = typeof payload.session_id === 'string' && payload.session_id ? payload.session_id : sessionId;
+ const detailResponse = await fetch('/api/sessions/' + encodeURIComponent(resolvedId), {cache:'no-store', credentials:'same-origin', signal:request.signal});
+ if (authOrMissing(detailResponse)) return;
+ if (!detailResponse.ok) throw new Error('session detail poll failed');
+ const session = await detailResponse.json();
+ const lastActive = Number(session.last_active || session.started_at || 0);
+ const durableWorker = session.source === 'api_server' && Boolean(session.parent_session_id);
+ const recentlyInteractive = Number.isFinite(lastActive) && Date.now() / 1000 - lastActive < 300;
+ const active = session.ended_at == null && messages.length > 0 && (durableWorker || recentlyInteractive);
+ if (active) show('Hermes is working. Latest stored activity: ' + activityLabel(messages) + '.', true, false);
+ else if (!messages.length && Date.now() - started >= 4000) show('This session has no renderable messages yet. It may be new or no longer available.', false, true);
+ else hide();
+ schedule(document.hidden ? 10000 : 2500);
+ } catch (_) {
+ show('Session updates disconnected. Retrying without changing this session…', true, false);
+ schedule(document.hidden ? 10000 : 3000);
+ } finally {
+ clearTimeout(timeout);
+ request = null;
+ }
+ }
+ addEventListener('online', () => schedule(0));
+ addEventListener('pageshow', () => schedule(0));
+ document.addEventListener('visibilitychange', () => schedule(0));
+ schedule(1200);
+})();`
diff --git a/services/hermes/router/session_snapshot.go b/services/hermes/router/session_snapshot.go
new file mode 100644
index 00000000..237dd19b
--- /dev/null
+++ b/services/hermes/router/session_snapshot.go
@@ -0,0 +1,65 @@
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+)
+
+const (
+ sessionSnapshotItems = 24
+ sessionSnapshotBytes = 8 << 20
+)
+
+type sessionSnapshot struct {
+ SessionID string `json:"session_id"`
+ Messages []json.RawMessage `json:"messages"`
+ TotalMessages int `json:"total_messages"`
+}
+
+// boundSessionSnapshot caps only the continuity fallback response. Native
+// WebUI requests remain untouched, including when an older backend ignores
+// its optional `limit` query parameter.
+func boundSessionSnapshot(response *http.Response) error {
+ request := response.Request
+ if request == nil || response.StatusCode != http.StatusOK ||
+ request.URL.Query().Get("hermes_fallback") != "1" ||
+ !strings.HasPrefix(request.URL.Path, "/api/sessions/") ||
+ !strings.HasSuffix(request.URL.Path, "/messages") {
+ return nil
+ }
+ body, err := io.ReadAll(io.LimitReader(response.Body, sessionSnapshotBytes+1))
+ if err != nil {
+ return err
+ }
+ _ = response.Body.Close()
+ if len(body) > sessionSnapshotBytes {
+ return errors.New("session snapshot exceeds safe response limit")
+ }
+ var payload sessionSnapshot
+ if err := json.Unmarshal(body, &payload); err != nil || payload.Messages == nil {
+ return errors.New("session snapshot is malformed")
+ }
+ total := len(payload.Messages)
+ if payload.TotalMessages > total {
+ total = payload.TotalMessages
+ }
+ if len(payload.Messages) > sessionSnapshotItems {
+ payload.Messages = payload.Messages[len(payload.Messages)-sessionSnapshotItems:]
+ }
+ payload.TotalMessages = total
+ body, err = json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+ response.Body = io.NopCloser(strings.NewReader(string(body)))
+ response.ContentLength = int64(len(body))
+ response.Header.Set("Content-Length", strconv.Itoa(len(body)))
+ response.Header.Set("Content-Type", "application/json; charset=utf-8")
+ response.Header.Set("Cache-Control", "no-store")
+ response.Header.Del("ETag")
+ return nil
+}
diff --git a/services/hermes/router/session_snapshot_test.go b/services/hermes/router/session_snapshot_test.go
new file mode 100644
index 00000000..64aee010
--- /dev/null
+++ b/services/hermes/router/session_snapshot_test.go
@@ -0,0 +1,113 @@
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+ "testing/iotest"
+)
+
+func snapshotResponse(target, body string) *http.Response {
+ request, _ := http.NewRequest(http.MethodGet, target, nil)
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(body)),
+ Request: request,
+ }
+}
+
+func TestBoundSessionSnapshotKeepsOnlyRecentMessages(t *testing.T) {
+ messages := make([]map[string]int, 30)
+ for index := range messages {
+ messages[index] = map[string]int{"index": index}
+ }
+ body, _ := json.Marshal(map[string]any{
+ "session_id": "resolved", "messages": messages,
+ })
+ response := snapshotResponse(
+ "http://tenant/api/sessions/root/messages?limit=24&hermes_fallback=1",
+ string(body),
+ )
+ response.Header.Set("ETag", "stale")
+ if err := boundSessionSnapshot(response); err != nil {
+ t.Fatal(err)
+ }
+ var bounded sessionSnapshot
+ if err := json.NewDecoder(response.Body).Decode(&bounded); err != nil {
+ t.Fatal(err)
+ }
+ if len(bounded.Messages) != 24 || bounded.TotalMessages != 30 {
+ t.Fatalf("snapshot was not bounded: %#v", bounded)
+ }
+ var first map[string]int
+ if err := json.Unmarshal(bounded.Messages[0], &first); err != nil || first["index"] != 6 {
+ t.Fatalf("snapshot did not retain the recent tail: %#v, %v", first, err)
+ }
+ if response.Header.Get("Cache-Control") != "no-store" || response.Header.Get("ETag") != "" {
+ t.Fatal("bounded snapshot retained cache metadata")
+ }
+}
+
+func TestBoundSessionSnapshotPreservesLargerReportedTotal(t *testing.T) {
+ response := snapshotResponse(
+ "http://tenant/api/sessions/root/messages?hermes_fallback=1",
+ `{"session_id":"leaf","messages":[],"total_messages":100}`,
+ )
+ if err := boundSessionSnapshot(response); err != nil {
+ t.Fatal(err)
+ }
+ var bounded sessionSnapshot
+ if err := json.NewDecoder(response.Body).Decode(&bounded); err != nil {
+ t.Fatal(err)
+ }
+ if bounded.SessionID != "leaf" || bounded.TotalMessages != 100 {
+ t.Fatalf("reported total was lost: %#v", bounded)
+ }
+}
+
+func TestBoundSessionSnapshotFailsClosedOnMalformedOrOversizedBody(t *testing.T) {
+ for name, body := range map[string]string{
+ "malformed": `{`,
+ "oversized": strings.Repeat("x", sessionSnapshotBytes+1),
+ } {
+ t.Run(name, func(t *testing.T) {
+ response := snapshotResponse(
+ "http://tenant/api/sessions/root/messages?hermes_fallback=1",
+ body,
+ )
+ if err := boundSessionSnapshot(response); err == nil {
+ t.Fatal("unsafe snapshot was accepted")
+ }
+ })
+ }
+ response := snapshotResponse(
+ "http://tenant/api/sessions/root/messages?hermes_fallback=1", `{}`,
+ )
+ response.Body = io.NopCloser(iotest.ErrReader(errors.New("read failed")))
+ if err := boundSessionSnapshot(response); err == nil {
+ t.Fatal("snapshot body read failure was ignored")
+ }
+}
+
+func TestBoundSessionSnapshotLeavesNativeAndErrorResponsesUntouched(t *testing.T) {
+ for _, response := range []*http.Response{
+ {StatusCode: http.StatusOK},
+ snapshotResponse("http://tenant/api/sessions/root/messages", `{}`),
+ snapshotResponse("http://tenant/api/sessions/root", `{}`),
+ } {
+ if err := boundSessionSnapshot(response); err != nil {
+ t.Fatal(err)
+ }
+ }
+ errorResponse := snapshotResponse(
+ "http://tenant/api/sessions/root/messages?hermes_fallback=1", `{}`,
+ )
+ errorResponse.StatusCode = http.StatusNotFound
+ if err := boundSessionSnapshot(errorResponse); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/services/hermes/router/telegram.go b/services/hermes/router/telegram.go
index c4cc3b66..a2d591df 100644
--- a/services/hermes/router/telegram.go
+++ b/services/hermes/router/telegram.go
@@ -24,20 +24,21 @@ type telegramConfig struct {
}
type telegramBot struct {
- config telegramConfig
- router *tenantRouter
- apiBase string
- fileBase string
- client *http.Client
- mediaClient *http.Client
- agentClient *http.Client
- mu sync.RWMutex
- botUsername string
- ready bool
- lastError string
- workLimit chan struct{}
- slotLocks map[int]*sync.Mutex
- slotLocksMux sync.Mutex
+ config telegramConfig
+ router *tenantRouter
+ apiBase string
+ fileBase string
+ client *http.Client
+ mediaClient *http.Client
+ agentClient *http.Client
+ mu sync.RWMutex
+ botUsername string
+ ready bool
+ lastError string
+ workLimit chan struct{}
+ activityEvery time.Duration
+ slotLocks map[int]*sync.Mutex
+ slotLocksMux sync.Mutex
}
type telegramUpdate struct {
@@ -96,15 +97,16 @@ func readTelegramConfig(path string) (telegramConfig, error) {
func newTelegramBot(config telegramConfig, router *tenantRouter) *telegramBot {
return &telegramBot{
- config: config,
- router: router,
- apiBase: "https://api.telegram.org/bot" + config.BotToken,
- fileBase: "https://api.telegram.org/file/bot" + config.BotToken,
- client: &http.Client{Timeout: 70 * time.Second},
- mediaClient: newTenantMediaClient(),
- agentClient: &http.Client{Timeout: 15 * time.Minute},
- workLimit: make(chan struct{}, 4),
- slotLocks: map[int]*sync.Mutex{},
+ config: config,
+ router: router,
+ apiBase: "https://api.telegram.org/bot" + config.BotToken,
+ fileBase: "https://api.telegram.org/file/bot" + config.BotToken,
+ client: &http.Client{Timeout: 70 * time.Second},
+ mediaClient: newTenantMediaClient(),
+ agentClient: &http.Client{Timeout: 15 * time.Minute},
+ workLimit: make(chan struct{}, 4),
+ activityEvery: 4 * time.Second,
+ slotLocks: map[int]*sync.Mutex{},
}
}
@@ -238,16 +240,6 @@ func (bot *telegramBot) run() {
}
}
-func commandParts(text string) (string, []string) {
- fields := strings.Fields(strings.TrimSpace(text))
- if len(fields) == 0 || !strings.HasPrefix(fields[0], "/") {
- return "", nil
- }
- command := strings.TrimPrefix(strings.ToLower(fields[0]), "/")
- command, _, _ = strings.Cut(command, "@")
- return command, fields[1:]
-}
-
func (bot *telegramBot) handleUpdate(update telegramUpdate) {
message := update.Message
if message == nil || message.From == nil || message.Chat.Type != "private" || message.Chat.ID != message.From.ID {
@@ -276,7 +268,7 @@ func (bot *telegramBot) handleUpdate(update telegramUpdate) {
return
}
if command == "help" {
- _ = bot.sendText(message.Chat.ID, "Send text or a photo to chat with Hermes. Use /topic to create or switch a durable topic, /topics to list topics, or /unlink to disconnect this Telegram account. Model and intensity controls are available in Hermes WebUI.")
+ _ = bot.sendText(message.Chat.ID, "Send text or a photo to chat with Hermes. Photos are available for analysis on the message that carries them. Image edits can reuse the latest image Hermes generated; inbound Telegram photos are not stored as editable artifacts. Use /topic to create or switch a durable topic, /topics to list topics, or /unlink to disconnect this Telegram account. Model and intensity controls are available in Hermes WebUI.")
return
}
slot, linked := bot.router.telegramSlot(userID)
@@ -332,7 +324,9 @@ func (bot *telegramBot) handleUpdate(update telegramUpdate) {
_ = bot.sendText(message.Chat.ID, "Hermes could not save the active topic right now. Please try again shortly.")
return
}
- _ = bot.sendAction(message.Chat.ID, "typing")
+ text = telegramInputPrompt(text, hasPhoto)
+ stopActivity := bot.startTelegramActivity(message.Chat.ID)
+ defer stopActivity()
var reply string
if hasPhoto {
media, fetchErr := bot.fetchTelegramPhoto(message.Photo)
@@ -429,6 +423,10 @@ func (bot *telegramBot) askTenantRequest(slot int, bodyReader io.Reader, updateI
func (bot *telegramBot) sendAction(chatID int64, action string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
+ return bot.sendActionContext(ctx, chatID, action)
+}
+
+func (bot *telegramBot) sendActionContext(ctx context.Context, chatID int64, action string) error {
return bot.call(ctx, "sendChatAction", url.Values{
"chat_id": {strconv.FormatInt(chatID, 10)},
"action": {action},
diff --git a/services/hermes/router/telegram_input.go b/services/hermes/router/telegram_input.go
index 40be7f96..5e2c5956 100644
--- a/services/hermes/router/telegram_input.go
+++ b/services/hermes/router/telegram_input.go
@@ -12,6 +12,7 @@ import (
"os"
"path"
"strings"
+ "sync"
"time"
)
@@ -22,6 +23,93 @@ type telegramInputMedia struct {
Path string
}
+func commandParts(text string) (string, []string) {
+ fields := strings.Fields(strings.TrimSpace(text))
+ if len(fields) == 0 || !strings.HasPrefix(fields[0], "/") {
+ return "", nil
+ }
+ command := strings.TrimPrefix(strings.ToLower(fields[0]), "/")
+ command, _, _ = strings.Cut(command, "@")
+ return command, fields[1:]
+}
+
+const inboundTelegramEditNotice = "Transport note: this Telegram photo is available for visual analysis on this turn, but it is not stored as a generated-image artifact. The image_edit_latest tools can reuse only an image Hermes generated. Do not claim an exact edit of this inbound photo; explain the limitation and offer either a new image inspired by it or an edit of the latest Hermes-generated image."
+
+func looksLikeImageEdit(text string) bool {
+ lower := strings.ToLower(strings.Join(strings.Fields(text), " "))
+ for _, phrase := range []string{
+ "edit this", "edit the image", "edit the photo", "change this image",
+ "change this photo", "modify this", "retouch", "turn this into",
+ "turn this cat", "turn this dog", "remove from the image",
+ "replace in the image", "edit it", "change it", "modify it", "crop it",
+ "resize it", "upscale it",
+ } {
+ if strings.Contains(lower, phrase) {
+ return true
+ }
+ }
+ hasTarget := false
+ for _, target := range []string{"image", "photo", "picture", "portrait", "selfie", "screenshot"} {
+ if strings.Contains(lower, target) {
+ hasTarget = true
+ break
+ }
+ }
+ if hasTarget {
+ for _, action := range []string{
+ "add ", "blur", "brighten", "change", "color", "convert", "crop",
+ "edit", "make ", "modify", "remove", "replace", "resize", "sharpen",
+ "transform", "turn ", "upscale",
+ } {
+ if strings.Contains(lower, action) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func telegramInputPrompt(text string, hasPhoto bool) string {
+ if hasPhoto {
+ return inboundTelegramEditNotice + "\n\nUser request: " + text
+ }
+ if !looksLikeImageEdit(text) {
+ return text
+ }
+ return "Transport note: no image is attached to this Telegram message. Use image_edit_latest only if Hermes previously generated an image in this private tenant. Otherwise do not claim access to an earlier inbound Telegram photo; offer a new image or ask the user to use Hermes WebUI with the source attachment.\n\nUser request: " + text
+}
+
+func (bot *telegramBot) startTelegramActivity(chatID int64) func() {
+ done := make(chan struct{})
+ stopped := make(chan struct{})
+ ctx, cancel := context.WithCancel(context.Background())
+ every := bot.activityEvery
+ if every <= 0 {
+ every = 4 * time.Second
+ }
+ go func() {
+ defer close(stopped)
+ ticker := time.NewTicker(every)
+ defer ticker.Stop()
+ for {
+ _ = bot.sendActionContext(ctx, chatID, "typing")
+ select {
+ case <-done:
+ return
+ case <-ticker.C:
+ }
+ }
+ }()
+ var once sync.Once
+ return func() {
+ once.Do(func() {
+ close(done)
+ cancel()
+ })
+ <-stopped
+ }
+}
+
func largestTelegramPhoto(photos []telegramPhotoSize) (telegramPhotoSize, error) {
var selected telegramPhotoSize
for _, photo := range photos {
diff --git a/services/hermes/router/telegram_media.go b/services/hermes/router/telegram_media.go
index 90be976f..d4dacca9 100644
--- a/services/hermes/router/telegram_media.go
+++ b/services/hermes/router/telegram_media.go
@@ -24,7 +24,11 @@ const (
tenantMediaLimit = 50 << 20
)
-var telegramMediaMarker = regexp.MustCompile(`MEDIA:([^\s\)\]]+)`)
+var (
+ telegramMediaMarker = regexp.MustCompile(`(?i)MEDIA:\s*([^\s\)\]\}"']+)`)
+ telegramMediaLabel = regexp.MustCompile(`(?i)MEDIA:`)
+ telegramPrivatePath = regexp.MustCompile(`(?i)(?:/opt/data/(?:cache/images|workspace)|/workspace)/[^\s\)\]\}"']+`)
+)
type telegramReply struct {
Text string
@@ -50,6 +54,8 @@ func parseTelegramReply(text string) telegramReply {
}
}
cleaned := telegramMediaMarker.ReplaceAllString(text, "")
+ cleaned = telegramPrivatePath.ReplaceAllString(cleaned, "[private attachment]")
+ cleaned = telegramMediaLabel.ReplaceAllString(cleaned, "")
lines := strings.Split(cleaned, "\n")
compact := make([]string, 0, len(lines))
blank := false
diff --git a/services/hermes/router/telegram_media_test.go b/services/hermes/router/telegram_media_test.go
index ee36f3d0..ff59bb2c 100644
--- a/services/hermes/router/telegram_media_test.go
+++ b/services/hermes/router/telegram_media_test.go
@@ -124,6 +124,20 @@ func TestParseTelegramReplyRemovesOneOrMoreInternalMarkers(t *testing.T) {
}
}
+func TestParseTelegramReplyScrubsMalformedMarkersAndBarePrivatePaths(t *testing.T) {
+ reply := parseTelegramReply(
+ "Delivered. MEDIA: /opt/data/cache/images/one.png and /workspace/private/two.png; MEDIA:",
+ )
+ if len(reply.MediaPaths) != 1 || reply.MediaPaths[0] != "/opt/data/cache/images/one.png" {
+ t.Fatalf("valid spaced marker was not retained for delivery: %#v", reply)
+ }
+ for _, unsafe := range []string{"MEDIA:", "/opt/data/", "/workspace/"} {
+ if strings.Contains(reply.Text, unsafe) {
+ t.Fatalf("unsafe transport detail %q leaked in %q", unsafe, reply.Text)
+ }
+ }
+}
+
func TestNormalizeTenantMediaPath(t *testing.T) {
allowed := map[string]string{
"/workspace/./renders//portrait.png": "/opt/data/workspace/renders/portrait.png",
diff --git a/services/hermes/router/telegram_test.go b/services/hermes/router/telegram_test.go
index 317dcadc..b192ad14 100644
--- a/services/hermes/router/telegram_test.go
+++ b/services/hermes/router/telegram_test.go
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strings"
+ "sync/atomic"
"testing"
"time"
)
@@ -273,6 +274,144 @@ func TestTelegramTopicValidationAndLegacyGeneralConversation(t *testing.T) {
}
}
+func TestTelegramTopicContinuityExpiresOnlyAfterIdleWeek(t *testing.T) {
+ statePath := filepath.Join(t.TempDir(), "state.json")
+ base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
+ now := base
+ router, err := newTenantRouter(statePath, 1, func(int) string { return "" })
+ if err != nil {
+ t.Fatal(err)
+ }
+ router.now = func() time.Time { return now }
+ first, err := router.selectTelegramTopic("123", "Week plan")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, elapsed := range []time.Duration{time.Hour, 24 * time.Hour} {
+ now = base.Add(elapsed)
+ resumed, touchErr := router.touchTelegramTopic("123")
+ if touchErr != nil || resumed.Conversation != first.Conversation {
+ t.Fatalf("topic changed after %s: %#v, %v", elapsed, resumed, touchErr)
+ }
+ }
+ // Exactly seven idle days remains the same durable conversation.
+ now = base.Add(8 * 24 * time.Hour)
+ exactWeek, err := router.touchTelegramTopic("123")
+ if err != nil || exactWeek.Conversation != first.Conversation {
+ t.Fatalf("topic changed at exact idle week: %#v, %v", exactWeek, err)
+ }
+ reloaded, err := newTenantRouter(statePath, 1, func(int) string { return "" })
+ if err != nil {
+ t.Fatal(err)
+ }
+ reloaded.now = func() time.Time { return now }
+ if active := reloaded.activeTelegramTopic("123"); active != exactWeek {
+ t.Fatalf("router restart changed topic: %#v != %#v", active, exactWeek)
+ }
+ now = now.Add(7*24*time.Hour + time.Second)
+ rotated, err := reloaded.touchTelegramTopic("123")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rotated.Conversation == first.Conversation || !strings.HasSuffix(rotated.Conversation, "-g1") {
+ t.Fatalf("expired topic did not rotate deterministically: %#v", rotated)
+ }
+ again, err := newTenantRouter(statePath, 1, func(int) string { return "" })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if active := again.activeTelegramTopic("123"); active != rotated {
+ t.Fatalf("rotated identity changed after restart: %#v != %#v", active, rotated)
+ }
+}
+
+func TestTelegramActivityRepeatsAndStops(t *testing.T) {
+ var actions atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ if request.URL.Path != "/sendChatAction" {
+ t.Fatalf("unexpected Telegram method %s", request.URL.Path)
+ }
+ actions.Add(1)
+ writer.Header().Set("Content-Type", "application/json")
+ _, _ = writer.Write([]byte(`{"ok":true,"result":true}`))
+ }))
+ defer server.Close()
+ bot := &telegramBot{
+ apiBase: server.URL,
+ client: &http.Client{Timeout: time.Second},
+ activityEvery: 5 * time.Millisecond,
+ }
+ stop := bot.startTelegramActivity(42)
+ deadline := time.Now().Add(time.Second)
+ for actions.Load() < 2 && time.Now().Before(deadline) {
+ time.Sleep(time.Millisecond)
+ }
+ stop()
+ stoppedAt := actions.Load()
+ time.Sleep(20 * time.Millisecond)
+ if stoppedAt < 2 || actions.Load() != stoppedAt {
+ t.Fatalf("activity did not progress and stop: before=%d after=%d", stoppedAt, actions.Load())
+ }
+}
+
+func TestTelegramActivityStopCancelsInflightRequest(t *testing.T) {
+ started := make(chan struct{})
+ release := make(chan struct{})
+ server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) {
+ close(started)
+ select {
+ case <-request.Context().Done():
+ case <-release:
+ }
+ }))
+ defer server.Close()
+ bot := &telegramBot{
+ apiBase: server.URL,
+ client: &http.Client{Timeout: time.Minute},
+ activityEvery: time.Hour,
+ }
+ stop := bot.startTelegramActivity(42)
+ select {
+ case <-started:
+ case <-time.After(time.Second):
+ t.Fatal("activity request did not start")
+ }
+ stopped := make(chan struct{})
+ go func() {
+ stop()
+ close(stopped)
+ }()
+ select {
+ case <-stopped:
+ case <-time.After(time.Second):
+ t.Fatal("activity stop did not cancel the in-flight request")
+ }
+ close(release)
+}
+
+func TestTelegramImageEditPromptStatesTransportBoundary(t *testing.T) {
+ withPhoto := telegramInputPrompt("Edit this photo into a watercolor", true)
+ if !strings.Contains(withPhoto, "available for visual analysis") ||
+ !strings.Contains(withPhoto, "not stored as a generated-image artifact") {
+ t.Fatalf("inbound attachment boundary missing: %q", withPhoto)
+ }
+ withoutPhoto := telegramInputPrompt("Edit this photo into a watercolor", false)
+ if !strings.Contains(withoutPhoto, "no image is attached") ||
+ !strings.Contains(withoutPhoto, "image_edit_latest only if Hermes previously generated") {
+ t.Fatalf("prior generated image boundary missing: %q", withoutPhoto)
+ }
+ for _, request := range []string{"Make the photo brighter", "Crop it closer"} {
+ if got := telegramInputPrompt(request, false); !strings.Contains(got, "no image is attached") {
+ t.Fatalf("edit variant omitted attachment boundary: %q", got)
+ }
+ }
+ plain := telegramInputPrompt("Explain this photograph", true)
+ if !strings.Contains(plain, "available for visual analysis") ||
+ !strings.Contains(plain, "User request: Explain this photograph") {
+ t.Fatalf("photo analysis boundary missing: %q", plain)
+ }
+}
+
func TestTelegramInputRejectsUnsafePathsAndNonImages(t *testing.T) {
for _, value := range []string{"", "../secret", "/absolute/image.jpg", `photos\image.jpg`, "photos/../../secret"} {
if _, err := safeTelegramFilePath(value); err == nil {
diff --git a/services/hermes/router/telegram_topics.go b/services/hermes/router/telegram_topics.go
index d5e9bed6..59c1447a 100644
--- a/services/hermes/router/telegram_topics.go
+++ b/services/hermes/router/telegram_topics.go
@@ -5,12 +5,16 @@ import (
"encoding/hex"
"errors"
"sort"
+ "strconv"
"strings"
"unicode"
"unicode/utf8"
)
-const telegramDefaultTopic = "General"
+const (
+ telegramDefaultTopic = "General"
+ telegramTopicIdleTTL = 7 * 24 * 60 * 60
+)
type activeTelegramTopic struct {
Conversation string
@@ -42,12 +46,35 @@ func telegramTopicID(label string) string {
}
func telegramTopicConversation(topicID string) string {
+ return telegramTopicConversationGeneration(topicID, 0)
+}
+
+func telegramTopicConversationGeneration(topicID string, generation int64) string {
if topicID == "" || topicID == "general" {
// Preserve the original named conversation so existing Telegram context
// survives rollout into topic-aware routing.
- return "telegram"
+ if generation == 0 {
+ return "telegram"
+ }
+ topicID = "general"
}
- return "telegram-topic-" + topicID
+ conversation := "telegram-topic-" + topicID
+ if generation > 0 {
+ conversation += "-g" + strconv.FormatInt(generation, 10)
+ }
+ return conversation
+}
+
+func topicExpired(topic telegramTopic, now int64) bool {
+ return topic.LastUsed > 0 && now > topic.LastUsed && now-topic.LastUsed > telegramTopicIdleTTL
+}
+
+func touchTopic(topic telegramTopic, now int64) telegramTopic {
+ if topicExpired(topic, now) {
+ topic.Generation++
+ }
+ topic.LastUsed = now
+ return topic
}
func (router *tenantRouter) activeTelegramTopic(userID string) activeTelegramTopic {
@@ -63,7 +90,10 @@ func (router *tenantRouter) activeTelegramTopic(userID string) activeTelegramTop
if topic, found := state.Topics[topicID]; found && strings.TrimSpace(topic.Label) != "" {
label = topic.Label
}
- return activeTelegramTopic{Conversation: telegramTopicConversation(topicID), Label: label}
+ return activeTelegramTopic{
+ Conversation: telegramTopicConversationGeneration(topicID, state.Topics[topicID].Generation),
+ Label: label,
+ }
}
func (router *tenantRouter) selectTelegramTopic(userID, rawLabel string) (activeTelegramTopic, error) {
@@ -83,12 +113,18 @@ func (router *tenantRouter) selectTelegramTopic(userID, rawLabel string) (active
state.Topics = map[string]telegramTopic{}
}
state.Active = topicID
- state.Topics[topicID] = telegramTopic{Label: label, LastUsed: router.now().Unix()}
+ topic := state.Topics[topicID]
+ topic.Label = label
+ topic = touchTopic(topic, router.now().Unix())
+ state.Topics[topicID] = topic
router.state.TelegramTopics[identity] = state
if err := router.saveLocked(); err != nil {
return activeTelegramTopic{}, err
}
- return activeTelegramTopic{Conversation: telegramTopicConversation(topicID), Label: label}, nil
+ return activeTelegramTopic{
+ Conversation: telegramTopicConversationGeneration(topicID, topic.Generation),
+ Label: label,
+ }, nil
}
func (router *tenantRouter) touchTelegramTopic(userID string) (activeTelegramTopic, error) {
@@ -108,13 +144,16 @@ func (router *tenantRouter) touchTelegramTopic(userID string) (activeTelegramTop
if strings.TrimSpace(topic.Label) == "" {
topic.Label = telegramDefaultTopic
}
- topic.LastUsed = router.now().Unix()
+ topic = touchTopic(topic, router.now().Unix())
state.Topics[topicID] = topic
router.state.TelegramTopics[identity] = state
if err := router.saveLocked(); err != nil {
return activeTelegramTopic{}, err
}
- return activeTelegramTopic{Conversation: telegramTopicConversation(topicID), Label: topic.Label}, nil
+ return activeTelegramTopic{
+ Conversation: telegramTopicConversationGeneration(topicID, topic.Generation),
+ Label: topic.Label,
+ }, nil
}
func (router *tenantRouter) telegramTopics(userID string) []telegramTopic {
diff --git a/services/hermes/router/web.go b/services/hermes/router/web.go
index 0ea02a20..adb596a6 100644
--- a/services/hermes/router/web.go
+++ b/services/hermes/router/web.go
@@ -93,6 +93,8 @@ const privateFilesPage = `
]