Before you open a plugin-catalog PR, look at what its gate actually checks


You write a plugin, open the PR the catalog asks for, and CI comes back with one line: the pinned sha is not a commit object in the repo. You stare at your YAML — you copied that sha straight off the GitHub page. You copied the sha of a tag object, and the gate wants the sha of a commit object. In your PR the two look identical.

The plugin catalog is a place where other people execute code on their own machines, so its gate is not ceremony — it is a real supply-chain check. Here is what it verifies, why it cares about something as innocent as a rename, and what to confirm before you submit.

Where the gate lives and when it runs

Admission is a dedicated workflow that only fires on PRs touching plugin-catalog/**, so it can never go red on unrelated work. It has two jobs:

  • structural — a cheap schema check that needs no Hermes install: it validates the whole plugin-catalog/ directory, preserving the invariant that everything in it stays valid.
  • pinned-source-validate — the actual supply-chain gate: the sha you pinned must be reachable in your repo, and the plugin at that exact commit must pass hermes plugins validate.

In other words, the gate clones your repository for real, checks out the commit you declared, and runs validation there. It does not read your YAML and take your word for it — it reads the code at that commit.

Three fields get checked one by one

Every field in your entry that a PR can control is checked before it is used:

Field What the gate requires
repo an https URL with no whitespace
sha 40 hex characters that git cat-file -t reports as a commit object
subdir a plain relative path that still resolves inside the pinned clone after resolution

That sha type check is where the error at the top comes from: a tag object is a perfectly legal object id, but it peels to a commit the reviewer never declared as the pin, so the gate refuses it. The subdir check works the same way — not a literal comparison, but a real path resolution followed by a confinement test, which is what makes .. useless as an escape.

Why it cares about a rename

The changed-entry list used --diff-filter=AM — added and modified only. The catch: a renamed entry file never lands in that list, and the gate only vets entries on the list. Rename a dangerous entry and it slides under the gate entirely.

The filter now reads AMRT: added, modified, renamed, and typechanged (a file turning into a symlink, for example) all reach the list. Alongside it there is a blunter rule: every symlink anywhere in the clone must stay inside the clone. That check walks the whole checkout on purpose — find does not descend into a symlinked directory, so a directory-to-directory link could hide an escape that a plugin-directory-only scan would never see.

Making sure plugin code cannot lean on the gate

This is the most interesting part of the design, because the gate executes code you have not reviewed (hermes plugins validate runs plugin-declared install logic):

  • Entries are read from fd 3, not stdin. Plugin code that drains standard input would make the remaining entry list vanish and silently skip validation. Moving to a different file descriptor closes that door.
  • The parser runs under python3 -I, resets its variables per entry, and fails closed with a sentinel on any parse failure.
  • Entry PRs must be data-only: they may touch plugin-catalog/** and contributors/emails/** and nothing else. The reason is blunt — the gate’s own enforcement code (the workflow, the validator, the built Hermes) resolves from the PR checkout, so an entry PR that also edits tooling could neuter the very gate vetting it.

The over-strict rule that got narrowed

The first version of the fix was too broad: it demanded “data only” whenever anything under plugin-catalog/ changed. By that standard 29 of the last 134 merged catalog PRs would have failed — the ones carrying attribution email maps — and every README, policy or CI PR touching the directory would have gone red too.

The rule is now scoped: it runs once, only when an entry YAML actually changed, and it allows contributors/emails/** alongside plugin-catalog/**. The same PR also dropped the full-history clone: the diff is GitHub’s merge commit against its first parent with only two levels of depth, and rename detection still works.

A pre-submit checklist

  • Pin a commit sha, not a tag sha. Take the full 40-character id from the file page or the commit list, not from the Releases/Tags page.
  • Keep subdir a plain relative path — no ./, no .., no absolute paths, no variables.
  • One PR, one job. A catalog entry PR touches plugin-catalog/** (plus attribution maps) and nothing else — not tooling, not workflows, not docs.
  • Run the validator locally. It is the same script CI uses: python3 scripts/validate_plugin_catalog.py plugin-catalog/.
  • Make sure the plugin passes at the pinned commit. The gate clones your repo and checks out that commit, so “the latest version passes but the pinned one does not” is a very common red cross.

For how the catalog works as a user (browsing, installing, offline install), see the plugin catalog guide; the command-level entry points are in the hermes plugins reference. If you care about the trust boundary on the MCP side instead, the MCP catalog security guide is its companion piece.

The takeaway

The design philosophy fits in one sentence: anything a PR can control gets checked before use, and it gets checked after resolution rather than literally. Renames, .., tag objects, symlinks, even stdin being drained — none of these are “a bug in the code”; they are all “the checkpoint was in the wrong place”.

For plugin authors the upside is that the rules are stable. Pin a commit object, keep the subdir relative, do one thing per PR — and the gate will verify on your behalf that what the reviewer saw and what a user installs are the same artifact.