Claude Code plugins and the trust nobody talks about
|
This post was written with the help of AI. |
I’ve been spending a lot of time with Claude Code skills, hooks and plugins lately. When I started building togi (a friction-capture plugin), I quickly realized the way plugins are published comes with real supply-chain risks. I wanted to understand them fully and mitigate them as much as possible before shipping anything. Then a second plugin idea came up, and I needed a way to publish both from a single marketplace while keeping each plugin in its own repo. The multi-repo layout and the GitHub Actions plumbing for that are easy to find, but what I couldn’t find anywhere was the supply-chain security reasoning behind the design, and a working pipeline built around it. The official docs describe SHA pinning as a feature but never explain why you should use it or what happens if you don’t.
This post is what I learned: the risks, the mitigations, and the pipeline that ties it all together.
A plugin is not a library
A Claude Code plugin is not a passive dependency you import.
Installing one lets the author run code on your machine, at every session start and end, with no per-update review.
Hooks can run shell scripts, but they can also execute pre-compiled binaries shipped in the plugin repo.
Git preserves the executable bit, so a binary committed with chmod +x is ready to run the moment Claude Code fetches the plugin.
No additional permission step on the user’s machine.
As of this writing, Claude Code shows no diff when plugin hooks change and asks for no re-approval.
There is no plugin signing, no checksum verification, no integrity check in the install path.
That is a lot of trust to hand someone.
So the security bar for publishing a plugin is closer to running a software-update service than shipping a package. Every design choice in this post follows from that.
What can go wrong
A plugin’s source field in marketplace.json tells Claude Code where to fetch the code.
The three options (branch, tag and SHA) carry very different risks.
Branch
A branch (e.g. main, or a relative "./." source) tracks the latest commit silently.
Every push is an implicit release.
One bad commit, whether from a compromised account, a force push, or a merged malicious PR, reaches every user on their next marketplace refresh.
No gate, no review, no rollback signal.
Tag
Most people assume a tag (v0.3.0) is immutable.
It’s not: git tag -f v0.3.0 <new-sha> silently rewrites what the tag points to.
GitHub repository rulesets can block force-moves and tag deletion, but that protection is a repo-owner setting, not a platform guarantee.
A compromised account can disable the ruleset, re-point a tag to a different commit, and users fetching the tag get different code with no way to tell.
SHA
A SHA is content-addressed and immutable. It cannot be re-pointed, force-moved, or overwritten. Anyone can verify what they run by comparing the pinned SHA against the repository history and inspecting the tree at that commit. Not exactly convenient, but in a system with no signing, this is the best you can get.
The official docs mention the sha field as "Optional. Full 40-character git commit SHA to pin to an exact version."
That undersells it.
SHA pinning is not a version-management convenience.
It is the only thing standing between your machine and silent code substitution in a system with no signing.
A marketplace plugin entry
Three fields in each marketplace plugin entry control version detection, code pinning, and release traceability:
{
"$schema": "https://json.schemastore.org/claude-code-marketplace.json",
"name": "claude-ichiba",
"plugins": [
{
"name": "togi",
"description": "Captures AI coding friction...",
"version": "0.3.0", (1)
"source": {
"source": "github",
"repo": "gwenneg/togi",
"ref": "v0.3.0", (2)
"sha": "744400ba1d80c69fb9fa078862974e94fc353753" (3)
}
}
]
}
| 1 | Claude Code’s update cache key. This is how it decides whether a newer version is available. |
| 2 | The branch or tag Claude Code fetches the plugin from. When sha is also set, Claude Code pins to the SHA directly. ref stays in the entry as a human-readable label: which tag this SHA came from. |
| 3 | The immutable security anchor. A SHA cannot be re-pointed, force-moved, or overwritten. |
Both version and sha must change together on every release: a version bump without a SHA bump ships the same code, and a SHA bump without a version bump is invisible to Claude Code’s update mechanism.
Why version stays out of plugin.json
Claude Code resolves a plugin’s version in this order: plugin.json first, then the marketplace entry, then the source SHA.
If plugin.json sets a version, it silently wins over the marketplace entry.
A version bump in the marketplace alone changes nothing: Claude Code still sees the old version from plugin.json and skips the update.
Keeping version only in the marketplace entry means the catalog is the single authority on what gets released.
Why split the marketplace from the plugin?
A Claude Code marketplace is a JSON catalog that lists plugins and tells Claude Code where to fetch them.
When the marketplace and the plugin share a repo, source is typically a relative path or a self-reference.
That ties the plugin’s development to the marketplace’s.
Every plugin commit is a marketplace commit.
Every collaborator who can touch a plugin can also touch the catalog.
Splitting gives you:
| Benefit | What it means |
|---|---|
Independent lifecycles |
A plugin repo has its own issues, PRs, and releases. The marketplace repo is a thin catalog that changes only when a plugin releases. |
Multiple plugins, one catalog |
Adding a second plugin means adding an entry to |
Separate access control |
Contributors to a plugin do not need write access to the catalog. |
Pipeline layout
I built a release pipeline for Claude Code plugins around two repos, both open source: claude-ichiba (Japanese for marketplace) is the catalog, and togi is the friction-capture plugin.
claude-ichiba/ # marketplace catalog
├── .claude-plugin/
│ └── marketplace.json # references plugins in other repos
└── .github/
└── workflows/
└── update-marketplace.yml # receives release dispatches
togi/ # plugin repo
├── .claude-plugin/
│ └── plugin.json # plugin manifest (no version field)
├── skills/
├── hooks/
└── .github/
└── workflows/
└── release.yml # tags → release → dispatch
From tag to release
Step 1: tag and release (plugin repo)
The release flow starts with a signed annotated tag push:
git tag -s v0.3.0 -m "v0.3.0"
git push origin v0.3.0
The tag triggers a release workflow, which dispatches to the marketplace repo:
name: Release
on:
push:
tags: ['v*']
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write (1)
steps:
- id: tag
name: Validate and extract tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
# Only annotated tags are supported — lightweight tags provide no signing guarantee.
TYPE=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --jq '.object.type')
if [ "$TYPE" != "tag" ]; then
echo "Error: $TAG is not an annotated tag (type='$TYPE')"
exit 1
fi
echo "value=$TAG" >> "$GITHUB_OUTPUT"
- id: create-release
name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.tag.outputs.value }}
run: |
URL=$(gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --title "$TAG" --generate-notes)
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Dispatch to marketplace
env:
ICHIBA_PAT: ${{ secrets.ICHIBA_PAT }} (2)
RELEASE: ${{ steps.create-release.outputs.url }}
run: |
# Sends a `repository_dispatch` event to the marketplace repo with the release URL as the only payload.
curl -fsSL -X POST \
-H "Authorization: token $ICHIBA_PAT" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/gwenneg/claude-ichiba/dispatches" \
-d "$(jq -n --arg release "$RELEASE" '{event_type: "plugin-release", client_payload: {release: $release}}')"
| 1 | Required by gh release create. |
| 2 | See The bridge: a fine-grained PAT. |
Step 2: update the catalog (marketplace repo)
The marketplace repo receives the dispatch and resolves the plugin, tag, and commit SHA from the release URL.
It then opens a pull request so every catalog update can be reviewed before it reaches main.
name: Update Marketplace
on:
repository_dispatch:
types: [plugin-release]
jobs:
update-marketplace:
runs-on: ubuntu-latest
permissions:
contents: write (1)
pull-requests: write (2)
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 (3)
- id: update-marketplace
name: Update marketplace.json
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE: ${{ github.event.client_payload.release }}
run: |
# Resolve the plugin name by matching the release URL against `source.repo` in marketplace.json.
PLUGIN=$(jq -r --arg u "$RELEASE" \
'.plugins[] | select(("https://github.com/" + .source.repo + "/") as $prefix | $u | startswith($prefix)) | .name' \
.claude-plugin/marketplace.json)
if [ -z "$PLUGIN" ]; then
echo "Error: no plugin matched $RELEASE"
exit 1
fi
# Extract tag and repo from the release URL (https://github.com/owner/repo/releases/tag/vX.Y.Z).
TAG=$(basename "$RELEASE")
REPO=$(echo "$RELEASE" | cut -d'/' -f4-5)
# Resolve the commit SHA from the annotated tag — two calls needed to dereference the tag object.
REF=$(gh api "repos/$REPO/git/ref/tags/$TAG")
if [ "$(echo "$REF" | jq -r '.object.type')" != "tag" ]; then
echo "Error: $TAG is not an annotated tag"
exit 1
fi
SHA=$(gh api "repos/$REPO/git/tags/$(echo "$REF" | jq -r '.object.sha')" --jq '.object.sha')
# Update version, ref, and sha for the matched plugin. Uses a temp file because jq cannot read and write the same file.
jq --arg p "$PLUGIN" --arg v "${TAG#v}" --arg r "$TAG" --arg s "$SHA" \
'(.plugins[] | select(.name == $p)) |= (.version = $v | .source.ref = $r | .source.sha = $s)' \
.claude-plugin/marketplace.json > /tmp/marketplace.json
mv /tmp/marketplace.json .claude-plugin/marketplace.json
echo "plugin=$PLUGIN" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Create branch and PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PLUGIN: ${{ steps.update-marketplace.outputs.plugin }}
RELEASE: ${{ github.event.client_payload.release }}
TAG: ${{ steps.update-marketplace.outputs.tag }}
run: |
BRANCH="release/$PLUGIN-$TAG"
# Create the release branch from the current HEAD of main.
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
--method POST \
-f "ref=refs/heads/$BRANCH" \
-f "sha=$(git rev-parse HEAD)"
# Commit the updated marketplace.json via the Contents API, which produces a signed commit.
gh api "repos/$GITHUB_REPOSITORY/contents/.claude-plugin/marketplace.json" \
--method PUT \
-f "message=release: $PLUGIN $TAG" \
-f "content=$(base64 -w 0 < .claude-plugin/marketplace.json)" \
-f "sha=$(git rev-parse HEAD:.claude-plugin/marketplace.json)" \
-f "branch=$BRANCH"
gh pr create \
--title "release: $PLUGIN $TAG" \
--body "[$TAG]($RELEASE)" \
--base main \
--head "$BRANCH"
| 1 | Required to create the release branch and commit marketplace.json. |
| 2 | Required to create the pull request. |
| 3 | Security tip: always pin GitHub Actions to a commit SHA, not a tag. The same tag-rewriting risk described earlier applies to actions too. |
The triple update (version, source.ref, source.sha) happens in one jq call and lands in one commit, so the catalog is never in a half-updated state.
The bridge: a fine-grained PAT
Step 1 dispatches from the plugin repo to the marketplace repo.
The default GITHUB_TOKEN in a GitHub Actions workflow is scoped to its own repo, so it cannot reach another repository.
To make the cross-repo dispatch work, you need a token with write access to the target.
Create a fine-grained Personal Access Token:
-
Repository access: only the marketplace repo
-
Permissions: Contents → Read and write
Store it as a repository secret (e.g. ICHIBA_PAT) in the plugin repo.
|
This PAT is the most sensitive piece in the pipeline.
Anyone who obtains it can trigger a dispatch with an arbitrary release URL, opening a rogue pull request against the marketplace repo.
They cannot inject an arbitrary SHA: the marketplace workflow resolves the commit SHA directly from the GitHub API.
And they cannot write to |
What users see
None of the pipeline complexity surfaces to users. Their side stays simple. Install with:
/plugin marketplace add gwenneg/claude-ichiba
/plugin install togi@claude-ichiba
/reload-plugins
And update with:
/plugin marketplace update claude-ichiba
/reload-plugins
|
Auto-update is off by default for third-party marketplaces. Users pull updates only when they choose to. If a user or org admin enables auto-update, a compromised marketplace entry propagates without any user action at all. The threat model changes significantly: the refresh becomes silent, not deliberate. |
Adding a second plugin
-
Create the plugin repo with its own
.claude-plugin/plugin.json, skills, hooks, etc. -
Copy
release.ymlverbatim: the marketplace resolves the plugin name from the release URL, so nothing is hardcoded per plugin. -
Add an entry to
marketplace.jsonin the marketplace repo.
Known gaps
This setup is the best I could do with what Claude Code and GitHub offer today. It’s not airtight.
-
The catalog lives on
main. Every release goes through a pull request before landing. But the branch is the final source of truth: a bad commit that reaches it ships a rewritten SHA on the next refresh. Branch protection, required reviews, and required signed commits reduce this exposure but do not eliminate it. -
No update notification. Claude Code does not tell users when a new version exists. They have to watch the plugin repo for releases and refresh manually.
-
No platform-level signing. Claude Code has no plugin signature verification. SHA pinning gives tamper-evidence (you can verify what you run), but not tamper-prevention (nothing stops a bad SHA from being written to the catalog if
mainis compromised). That gap closes only when Claude Code adds signing.
This pipeline won’t solve what Claude Code hasn’t built yet. But it closes every gap I could find, and it’s yours to fork.
Plugin consumers: verify what you run. Authors: make that easy.
Leave a comment