Skip to content

AI-349: V1 catalog manifest schema + signed release pipeline#36

Merged
endor-matt merged 3 commits into
mainfrom
ai-349-v1-manifest-schema
Jul 2, 2026
Merged

AI-349: V1 catalog manifest schema + signed release pipeline#36
endor-matt merged 3 commits into
mainfrom
ai-349-v1-manifest-schema

Conversation

@dannykim-endor

Copy link
Copy Markdown
Contributor

tl;dr: producer side of the V1 agent surface (Spine RFC 6.8). Every recipe now carries the catalog field contract, and CI emits a signed catalog.json in the EndorAgent wire shape that apiserver (AI-351) consumes. This extends the existing validator + test suite + promote workflow -- no new schema system.

What's in here

  1. Schema + merge gate

    • 3 new recipe fields: audience (appsec|developer), short_description, authors (display names only, PII rejected) -- added to all 13 recipes.
    • requires_endorctl is now required and validated as a canonical-semver constraint (>=/>), no new dependency.
    • id-immutability gate blocks renaming or removing an id that is already on main (it is the telemetry join key).
    • CODEOWNERS over the trust set: recipes, validator, emitter, signing, release workflow, and the pinned key.
  2. Catalog emitter

    • catalog.json is generated, committed, and drift-checked like manifest.json, in the section-6.8 wire shape: snake_case keys, lowercase enums, claude-managed-agents -> claude-managed, install[] from the real published layout, endorctl_min_version operator-stripped, cadence/fetched_at/stale omitted. catalog_version is stamped at release.
  3. Release + signing

    • New tag-triggered release workflow (agents-v*): full gate -> build -> sign (Azure Key Vault, ES256, over OIDC) -> verify against the pinned public key -> GitHub Release with catalog.json + .sig.
    • Signing is gated behind CATALOG_SIGNING_ENABLED until AI-418 provisions the Key Vault key + federation. Releases ship unsigned-but-drift-gated until then.

Decisions worth a look

  • Went straight to AKV HSM signing over OIDC instead of a raw GitHub-secret key -- see the reasoning in AI-349. The verification model is a pinned public key (plain offline verify), which keeps apiserver simple and avoids a bootstrap loop.
  • The two MCP-only agents (vulnerability-explainer, repository-dependency-reviewer) now advertise endorctl_min_version: 1.0.0. That is the required proto field meeting the "always require requires_endorctl" rule -- there is no "none" value, and V1 does not gate installs on it. Flagging in case we want to handle it differently.

Testing

  • 384 tests pass (+16 new: validator, emitter, id-stability, signing round-trip). Publish is idempotent; catalog.json + manifest.json regenerate clean.
  • Ran a high-effort adversarial review pass and fixed the real findings (silent-drop of a no-audience agent, openssl-absent crash, ES256 width/format guards, a dead constant).

Depends on

  • AI-418 (Azure Key Vault signing key + OIDC federation) before the first signed release.

Lmk if the endorctl_min_version bit or the signing approach needs another look.

🤖 Generated with Claude Code

Producer side of the V1 agent surface (Spine RFC 6.8): make every recipe carry
the catalog field contract and emit a signed catalog.json in the EndorAgent wire
shape that apiserver (AI-351) consumes.

Schema + validator:
- Add audience (appsec|developer), short_description, and authors (display names
  only; PII rejected) to recipe.py + validator.py.
- Require requires_endorctl as a canonical-semver version constraint (>=/>),
  validated inline with no new dependency; convert all recipes to ">=1.0.0".

Catalog emitter:
- New publication/catalog_wire.py projects Catalog Manifest records into
  catalog.json {schema_version, agents:[EndorAgent]}: snake_case keys, lowercase
  enums, claude-managed-agents->claude-managed, install[] from the real layout
  (claude-code + claude-managed only), endorctl_min_version operator-stripped,
  cadence/fetched_at/stale omitted. Committed + drift-checked like manifest.json;
  catalog_version stamped at release. Carry catalog metadata on CatalogAgent.

Merge gate + release:
- id-immutability gate (id_stability.py + CLI, PR-only) blocks renaming/removing
  an id that exists on main (telemetry join key).
- catalog_signing.py: offline ES256 verify + raw-P1363->DER conversion; release
  workflow signs via Azure Key Vault over OIDC (gated behind CATALOG_SIGNING_
  ENABLED until AI-418 provisions the key), verifies against the pinned public
  key, and publishes catalog.json + .sig to a GitHub Release on agents-v* tags.
- CODEOWNERS gates the recipes, validator, emitter, signing, release workflow,
  and pinned key. RELEASES.md + docs/contributing-agents.md document the contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dannykim-endor dannykim-endor requested a review from endor-matt July 1, 2026 21:36
keys/README.md referenced ENDOR_ARTIFACT_SIGNING_ENABLED (the pre-existing
ai-plugins provenance flag); the catalog release signing gate is
CATALOG_SIGNING_ENABLED, matching the workflow and RELEASES.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
return editions[0]


def _install_command(repo_host: str, agent_id: str, bundle_path: str) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dannykim-endor make the Claude Code install command self-contained? generated catalog command currently assumes .claude/agents already exists. Existing Claude Code READMEs include mkdir -p .claude/agents, and a fresh repo will fail on the bare cp.

Suggested shape:

return (
    f"mkdir -p .claude/agents "
    f"&& cp {bundle_path}/{agent_id}.md .claude/agents/{agent_id}.md"
)

Then update tests/test_catalog_wire.py expectations and regenerate catalog.json.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 31e9cab. The Claude Code install command is now mkdir -p .claude/agents && cp <bundle>/<id>.md .claude/agents/<id>.md. Regenerated catalog.json and updated the test_catalog_wire.py expectations.

uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dannykim-endor Before install/test/signing, can we add a hard gate that proves the agents-v* tag target is already merged to main? Otherwise a tag pushed to an unmerged commit could run the signed release path without normal PR/CODEOWNERS review.

Suggested step after checkout:

- name: Verify release tag points at main
  run: |
    git fetch origin main --no-tags
    if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
      echo "::error title=Invalid release tag::agents-v* tags must point to commits already merged to main."
      exit 1
    fi

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 31e9cab. Added a "Verify tagged commit is on main" step right after checkout that runs git merge-base --is-ancestor "$GITHUB_SHA" origin/main and fails the release if the tag is not contained in main. (Fetching main as +refs/heads/main:refs/remotes/origin/main so origin/main resolves under the tag checkout.)

--signature catalog.json.sig \
--public-key keys/catalog-signing-public-key.pem

- name: Publish GitHub Release

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dannykim-endor Do we want agents-v* releases to publish unsigned catalogs when CATALOG_SIGNING_ENABLED is false? For the signed catalog release path, I’d prefer fail-closed unless we explicitly need a non-prod escape hatch. full fix to enforce signing:

      - name: Require catalog signing
        if: ${{ vars.CATALOG_SIGNING_ENABLED != 'true' }}
        run: |
          echo "::error title=Catalog signing disabled::CATALOG_SIGNING_ENABLED must be true before publishing agents-v* releases."
          exit 1

      - name: Publish GitHub Release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release create "$CATALOG_TAG" \
            catalog.json catalog.json.sig keys/catalog-signing-public-key.pem \
            --title "$CATALOG_TAG" \
            --notes "Endor agent catalog for ${CATALOG_TAG}. Wire shape: internal.endor.ai/endor/v1 EndorAgent."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 31e9cab, fail-closed as you suggested — no escape hatch. A "Refuse to publish an unsigned catalog" step fails the job when CATALOG_SIGNING_ENABLED is not true, and the publish step now always attaches catalog.json + .sig + public key. RELEASES.md updated: until AI-418 lands the Key Vault key, tagging a release fails rather than shipping unsigned; the catalog is still committed + drift-gated on every PR.

From Matt's review on PR #36:
- catalog_wire.py: Claude Code install command is now self-contained
  (mkdir -p .claude/agents && cp ...) so a fresh repo without .claude/agents
  doesn't fail on the bare cp. Regenerated catalog.json + updated tests.
- agent-kit-release.yml: add a hard gate that the agents-v* tag target is
  already merged to main (git merge-base --is-ancestor) before build/sign, so a
  tag on an unmerged commit can't run the signed release path around CODEOWNERS.
- agent-kit-release.yml: fail-closed on signing — refuse to publish when
  CATALOG_SIGNING_ENABLED is not 'true' instead of shipping an unsigned catalog.
  RELEASES.md updated to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@endor-matt endor-matt merged commit fb47ecf into main Jul 2, 2026
2 checks passed
@endor-matt endor-matt deleted the ai-349-v1-manifest-schema branch July 2, 2026 20:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants