Connector-manifest signing custody path (GOC-16 / BUG-234 / GOC-84)¶
This page is the operator runbook for the ONE remaining step BUG-234 named: making the
already-built release-signing custody mechanism (docs/architecture/drift_proof_release.md
§4, D-DP-2/D-OC-1) reachable from a controlled release job, instead of nowhere. It is
written for the person who will actually run this, not as design narrative.
What already exists (do not rebuild)¶
- The real signing key already lives in OpenBao: mount
apps, secretagent-utilities, fieldONTOLOGY_RELEASE_SIGNING_PRIVATE_KEY, KV v2 version2— confirmed against the live cluster on 2026-07-31 (plans/au-eg-program/deferred/lane-openbao-custody.md, D-OC-1/D-OC-2), derived public keyQkigdPNpcUU7x7NcSkwCsUXIQpaFncMQbYSoiSgI2SY, matchingagent_utilities/knowledge_graph/ontology.lockexactly. The key is not missing. No environment that can reach OpenBao with the right identity has ever been wired up. agent_utilities/knowledge_graph/ontology/ontology_integrity.py(release_signer_for_publication,ReleaseSigner) is the ONLY signer any generator may use, refuses anything but a versionedvault:///secret://reference, and fails closed (ReleaseSigningError) rather than falling back to an ephemeral key. Proven against a fake OpenBao transport intests/unit/security/test_openbao_signing_custody.py.- All four manifest generators (
scripts/generate_connector_manifests.py,scripts/generate_native_connector_manifest.py,scripts/generate_connector_capability_bundles.py, and the fingerprint step inscripts/certify_connector_tool_schemas.py) already call this signer internally — this page's job is never to re-implement signing, only to give it a place with the real key. agent_utilities/knowledge_graph/ontology/connector_manifest_gate.pyalready fails closed on an unsigned manifest, a tampered manifest, or a wrong signer — this isprecheck_source, the gate everysync_sourcecall goes through before any tool is exposed.
What GOC-16 built (this lane)¶
| Piece | Path |
|---|---|
| The controlled-release orchestrator (freeze → regenerate → sign → check) | scripts/release/regenerate_and_sign_connector_manifests.py |
| The Kubernetes Job template that holds the real key via OpenBao workload identity | deploy/release/connector-manifest-signing-job.yaml |
The keyless diff/freeze report job (GitHub Actions, workflow_dispatch-only) |
.github/workflows/advisory.yml → connector-manifest-diff |
| Source-only input/output custody contract (synthetic fixture + static gate) | tests/fixtures/release/connector-manifest-signing-inputs.yml, tests/unit/release/test_connector_manifest_signing_job_contract.py |
| Known-bad proofs for the four named adversarial cases | tests/unit/knowledge_graph/ontology/test_connector_manifest_signing_known_bad.py |
(GOC-16's original dependency_lock_digest signed pin was removed: it made every
pyproject.toml dependency addition a release-Job-gated operation for no benefit in an
internal deployment. Provenance now records ProvenanceSpec.source_commit — the git
commit SHA a manifest was generated from — nothing more.)
Not built here, and deliberately not attempted: regenerating and signing the REAL
bundled fleet manifests. That is GOC-84's own work, hard-blocked on GOC-83's lock freeze
landing first (plans/graph-os-completion-program/lanes/GOC-84-connector-manifest-
implementation-parity.md) — this lane builds the mechanism GOC-84 will run, it does not
run GOC-84's regeneration itself, and it never requests, generates, or handles the actual
key.
Exactly what the operator must supply, and where¶
Nothing new needs to be typed into a .env file, a GitHub secret, or an agent session.
Everything below is infrastructure the operator applies directly against the live cluster.
0. The signing key itself — mint it and store it under a version¶
Check first; this step is not always needed. As of 2026-08-20 it is: a cluster-wide
sweep found ONTOLOGY_RELEASE_SIGNING_PRIVATE_KEY in no ExternalSecret and no Secret,
so the reference vault://agent-utilities#ONTOLOGY_RELEASE_SIGNING_PRIVATE_KEY@2 that
deploy/release/connector-manifest-signing-job.yaml resolves does not exist yet and the
Job will fail closed at startup. Re-check before assuming:
kubectl get externalsecrets,secrets -A -o json \
| grep -c ONTOLOGY_RELEASE_SIGNING_PRIVATE_KEY # 0 => this step is required
This step is called out separately, and deliberately left to a human, because it mints a
root of trust. Everything else in this document is recoverable by re-running it; a
signing key is not. ontology_integrity.py refuses an env:// reference by design
(D-OB-5), so the key must land in versioned custody — the @2 suffix pins the exact
version the Job reads, which is what makes a rotation observable instead of silent.
The private key is ed25519, stored as base64 of the 32-byte seed. Generate it off the cluster, on a host the operator trust, and never let it transit an agent session or a shell history file:
umask 077
python3 - <<'EOF' > /dev/shm/ontology-signing-key.json
import base64, json
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
k = Ed25519PrivateKey.generate()
priv = k.private_bytes(serialization.Encoding.Raw,
serialization.PrivateFormat.Raw,
serialization.NoEncryption())
pub = k.public_key().public_bytes(serialization.Encoding.Raw,
serialization.PublicFormat.Raw)
print(json.dumps({"private": base64.b64encode(priv).decode(),
"public": base64.b64encode(pub).decode()}))
EOF
Write it to the apps/agent-utilities secret without disturbing the other keys already
there (bao kv patch, never put — put replaces the whole secret and will silently
drop every co-resident value):
P=$(kubectl -n platform get pod -l app=openbao -o name | head -1)
jq -r .private /dev/shm/ontology-signing-key.json \
| kubectl -n platform exec -i "$P" -- env BAO_ADDR=http://127.0.0.1:8200 \
BAO_TOKEN="$BAO_ROOT_TOKEN" \
bao kv patch apps/agent-utilities ONTOLOGY_RELEASE_SIGNING_PRIVATE_KEY=-
kubectl -n platform exec "$P" -- env BAO_ADDR=http://127.0.0.1:8200 \
BAO_TOKEN="$BAO_ROOT_TOKEN" bao kv metadata get -format=json apps/agent-utilities \
| jq .data.current_version # must equal the @N pinned in the Job manifest
shred -u /dev/shm/ontology-signing-key.json
If current_version is not 2, update the @N in
deploy/release/connector-manifest-signing-job.yaml to the printed value rather than
writing the key again to force the number — a rewrite to reach a version number produces
two live keys and no way to tell which signed a given manifest.
Finally, publish the public half: add it to DEFAULT_TRUSTED_SIGNERS in
agent_utilities/knowledge_graph/ontology/ontology_integrity.py under the signer id the
Job passes. Until that lands, verify_release_signature() returns False for everything
this key signs — which is the intended fail-closed default, not a bug, and is exactly what
UNSIGNED-PREVIEW relies on to stay unverifiable.
1. An OpenBao read-only policy, scoped to exactly one secret¶
Add to services/openbao/k8s/bootstrap-policies.sh (or run once by hand, same
kubectl exec pattern that file already uses — never over the network, never with a
static token typed anywhere):
P=$(kubectl -n platform get pod -l app=openbao -o name | head -1)
run() { kubectl -n platform exec -i "$P" -- env BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN="$BAO_ROOT_TOKEN" "$@"; }
printf 'path "apps/data/agent-utilities" { capabilities = ["read"] }\npath "apps/metadata/agent-utilities" { capabilities = ["read","list"] }\n' \
| run bao policy write agent-utilities-connector-manifest-signer-ro -
This is deliberately narrower than the existing eso-read policy (apps/data/*) — the
signing job only ever needs apps/data/agent-utilities, nothing else in the apps mount.
2. A Vault Kubernetes-auth role bound to the Job's own ServiceAccount¶
Kubernetes auth is already enabled at auth/kubernetes/; the deployed openbao
ClusterSecretStore proves that path. Create the role
agent-utilities-connector-manifest-signer with a ten-minute TTL and only these
bindings: ServiceAccount connector-manifest-signer, namespace release-tooling, token
audience openbao, and policy agent-utilities-connector-manifest-signer-ro. The Job
manifest already projects that audience, so the role and workload contract agree.
3. Prepare the digest-attested input and public-output boundaries¶
The signing Job does not read a live checkout, a mutable host mount, or a floating
image. Prepare one operator-owned connector-manifest-signing-input PVC containing a
reviewed release input bundle. It contains release-inputs.sha256 with relative digest
entries, the exact built wheel under wheels/, the frozen provider fleet under
agents/, and the repository-manager workspace manifest at its normal package path.
The Job checks the attestation file digest, then checks every listed input before
copying the fleet into its bounded disposable /work staging volume. The operator
substitutes the attestation digest and wheel digest in the Job template; the image is
also required to be pinned as image@sha256:<digest>. Create a separate durable
connector-manifest-signing-public-output PVC for reviewed public output. The Job
writes only three run-scoped directories there: connector-bundles/ for public bundles,
manifests/ for fleet projections, and native/ for the native projection.
Neither PVC contains signing authority material. The input is read-only to the Job; all provider writes stay in disposable staging, and a unique run directory prevents a later attempt from overwriting an earlier output.
4. Apply the namespace/ServiceAccount and run the Job¶
Apply the reviewed namespace and ServiceAccount setup with
kubectl apply -f deploy/release/connector-manifest-signing-job.yaml.
Then, for an actual signing run: take the frozen_sha the keyless
connector-manifest-diff GitHub Actions job reports (workflow_dispatch it from the
Actions tab, read the job summary), substitute it plus the frozen commit's built
image digest, input attestation/wheel digests, release timestamp, and unique output run
ID into a COPY of the Job in deploy/release/connector-manifest-signing-job.yaml
(never re-apply the same Job name twice — give each run a unique name). Apply that copy,
wait up to 30 minutes for its complete condition in release-tooling, then read the
run-scoped Job logs. Those logs are the JSON report and never contain key material.
The Job's restartPolicy: Never / backoffLimit: 0 mean a failure never silently retries
with stale state — read the printed JSON report for the exact [freeze|regenerate|verify]
stage and reason.
5. Review before applying¶
The Job signs. It does not review. Before ever running the signing Job, review the diff
report the keyless connector-manifest-diff job (or a local
regenerate_and_sign_connector_manifests.py run without --sign) produced — GOC-84's own
acceptance gates require a field-by-field reviewed diff, not merely "the gate didn't
complain."
Why this shape (workload identity, not a materialized Secret)¶
release_signer_for_publication() explicitly refuses an env:// reference for release
signing (DURABLE_SECRET_SCHEMES = ("vault://", "secret://")) — so the design deliberately
does not follow the common ExternalSecret → k8s Secret → envFrom pattern this
workspace uses elsewhere (e.g. inventory/k8s-migration/cutover/apptier/github-runner.yaml).
Materializing the key into a k8s Secret object will (a) require a second custody surface
(the Secret) that this workspace's own documented trap warns is silently reverted if
hand-patched via kubectl patch rather than written through OpenBao, and (b) persist the
key at rest in etcd for the Secret's lifetime instead of only holding it in memory for the
duration of one sign() call. The Job instead authenticates to OpenBao directly with its
own Kubernetes ServiceAccount token (VAULT_AUTH_METHOD=kubernetes,
VaultBackend._try_kubernetes()), reads the key live via the KV v2 API at sign time, and
never writes it anywhere — matching this lane's own invariant ("KMS/HSM access is
workload-identity-only") and BUG-234's explicit instruction ("via the existing openbao
ClusterSecretStore" — same OpenBao instance and the same custody discipline, reached here
by direct API call under a scoped role rather than through the ESO CRD, because ESO's job is
materializing a k8s Secret, which is exactly the persistence this design avoids).
Fail-closed behavior this mechanism enforces (proven, not asserted)¶
tests/unit/knowledge_graph/ontology/test_connector_manifest_signing_known_bad.py proves,
against real signed manifests built with the same generator code this job runs, that each of
the following is refused with a bounded diagnostic — never a silent pass, never a bare crash:
| Adversarial case | Caught by | Diagnostic prefix |
|---|---|---|
| One-bit source change (native connector code diverges post-signing) | _native_provider_violations |
[tool-schema] ...differs from its signed code fingerprint |
| Schema change (a field mapping changes post-signing) | _check_manifest_bytes integrity/signature check |
[integrity] / [signature] |
Alias change (a sync preset's server changes post-signing) |
_signature_violations |
[signature] |
scripts/release/regenerate_and_sign_connector_manifests.py also refuses to
proceed (verify_freeze) on a dirty working tree, a commit SHA mismatch, or (with
--require-built-artifact) an editable/source-tree-only install — the "built-wheel vs
source-tree mismatch" case GOC-84 names explicitly.
What remains unverified by this lane¶
- The OpenBao policy and Vault Kubernetes-auth role above are documented, not applied — this lane never touches the live OpenBao instance. An operator must run the commands in §1–§2 before the Job in §3 can authenticate at all.
deploy/release/connector-manifest-signing-job.yamlhas not beenkubectl apply'd or run against the live cluster — it is reviewed infrastructure-as-code, not a proven deployment.- GOC-84's own regeneration of the REAL 11 bundled connector manifests has not happened — this page only removes the "no custody path exists" blocker; GOC-84's own W01–W05 work breakdown (frozen-commit confirmation, field-by-field diff review, the full ten-case adversarial matrix against the real bundle) is unstarted and remains that lane's own work, gated on GOC-83.