Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"url": "https://github.com/open-agent-ai-security"
},
"metadata": {
"description": "Claude Code plugin marketplace for the Open Agent AI Security community. Add once, install any community plugin."
"description": "Plugin marketplace for the Open Agent AI Security community, serving Claude Code and OpenAI Codex. Add once, install any community plugin."
},
"plugins": [
{
Expand Down
12 changes: 12 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Copyright 2026 Exabeam, Inc.
# SPDX-License-Identifier: Apache-2.0
#
# The catalog manifest is the single install path for every community plugin,
# and the validator is the gate that protects it. Both require an owner's
# review — this is what stops one PR from weakening the gate and repointing a
# plugin source together. Requires "Require review from Code Owners" on the
# main branch protection rule.

/.claude-plugin/ @virtualsteve-exa @mike-machnik-exa @scottcexab @SarahFishCO
/scripts/ @virtualsteve-exa @mike-machnik-exa @scottcexab @SarahFishCO
/.github/ @virtualsteve-exa @mike-machnik-exa @scottcexab @SarahFishCO
35 changes: 33 additions & 2 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Copyright 2026 Exabeam, Inc.
# SPDX-License-Identifier: Apache-2.0
#
# Gates the catalog manifest — the single install path for every community
# plugin on both Claude Code and Codex.
name: validate

on:
Expand All @@ -15,6 +18,34 @@ jobs:
catalog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Sanity-check the catalog manifest
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- name: Sanity-check the catalog manifest (rules from main)
run: |
# Run main's copy of the validator against the PR's manifest, so a PR
# cannot relax the RULES and repoint a source in the same change.
# (A PR that legitimately improves the validator shows this job
# passing under the OLD rules; the new rules take effect on merge.)
#
# NOTE: this does not make the job tamper-proof — on `pull_request`
# the workflow FILE also comes from the PR, so a PR can edit this
# step itself. The control that actually closes that is a required
# code-owner review on /.github/ and /scripts/ (see .github/CODEOWNERS
# and the README's maintainer notes). This step raises the cost and
# makes the tampering visible in the diff; it is not a substitute.
mkdir -p "$RUNNER_TEMP/gate" "$RUNNER_TEMP/.claude-plugin"
if ! git show origin/main:scripts/validate_catalog.py > "$RUNNER_TEMP/gate/validate_catalog.py"; then
# Fail CLOSED: if main's rules can't be read, we have not validated
# anything. Falling back to the PR's own copy would let a PR that
# breaks this lookup grade its own homework.
echo "::error::could not read scripts/validate_catalog.py from origin/main — refusing to validate with the PR's own copy"
exit 1
fi
echo "::notice::validating with the ruleset from origin/main"
cp .claude-plugin/marketplace.json "$RUNNER_TEMP/.claude-plugin/marketplace.json"
python3 "$RUNNER_TEMP/gate/validate_catalog.py"
- name: Re-run with this branch's validator (advisory only)
if: always()
continue-on-error: true
run: python3 scripts/validate_catalog.py
74 changes: 50 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Open Agent AI Security — Plugin Marketplace

The single [Claude Code](https://claude.com/claude-code) plugin marketplace for the
[Open Agent AI Security](https://open-agent-ai-security.github.io/) community.
The single plugin marketplace for the
[Open Agent AI Security](https://open-agent-ai-security.github.io/) community — serving both
[Claude Code](https://claude.com/claude-code) and [OpenAI Codex](https://openai.com/codex/).

> **This repository exists solely to serve the community's plugin catalog** — one marketplace
> manifest (`.claude-plugin/marketplace.json`) plus this README. There is no product code here:
Expand Down Expand Up @@ -33,46 +34,71 @@ thing; run `/reload-plugins` (or restart the session) after an in-session instal

Both plugins were previously distributed from marketplaces hosted in their own repos.
The marketplace name (`open-agent-ai-security`) and the plugin keys are unchanged, so
migration is quick and nothing about your installed plugins' configuration is lost.
migration is one command and nothing about your installed plugins is lost.

**Praxen users** — if you added the marketplace from `open-agent-ai-security/praxen`:
**Praxen users** — if you added the marketplace from `open-agent-ai-security/praxen`,
just add this one; the same-named marketplace is re-pointed in place and your installed
praxen keeps working:

```bash
claude plugin marketplace remove open-agent-ai-security # this also uninstalls the plugin
claude plugin marketplace add open-agent-ai-security/plugins
claude plugin install praxen@open-agent-ai-security
```

Removing a marketplace uninstalls the plugins that came from it, so the reinstall line is
required — your plugin key and settings are unchanged, and the plugin re-enables as
before. The old path remains a maintained mirror for now, so migration is recommended,
not required.
Do **not** run `claude plugin marketplace remove` first — removing a marketplace
uninstalls the plugins that came from it, and it isn't necessary. Migrating is optional
for praxen (the legacy repo still publishes a praxen-only marketplace) but **required to
install socxen**, which only this catalog publishes.

**socxen users** — if you installed the plugin as `socxen@socxen`:
**socxen users** — if you installed the plugin as `socxen@socxen`, remove that marketplace
first. It has a *different* name from this one, so simply adding this catalog would leave you
with two enabled copies of socxen (the current release and the retired one), both registering
the `soc-investigate` skill:

```bash
claude plugin uninstall socxen@socxen
claude plugin marketplace remove socxen
claude plugin marketplace add open-agent-ai-security/plugins # skip only if already added from …/plugins
claude plugin marketplace remove socxen # also uninstalls socxen@socxen
claude plugin marketplace add open-agent-ai-security/plugins # re-points in place if already present
claude plugin install socxen@open-agent-ai-security
```

If a marketplace named `open-agent-ai-security` already exists but was added from the old
praxen repo path, follow the praxen migration above first — the add commands conflict on the
shared marketplace name until the old one is removed.
A separate `claude plugin uninstall socxen@socxen` isn't needed — removing the marketplace
uninstalls its plugins, which is the point here.

## OpenAI Codex

The same catalog serves Codex, with the same plugin keys:

```bash
codex plugin marketplace add open-agent-ai-security/plugins
codex plugin add praxen@open-agent-ai-security
codex plugin list
```

## For maintainers

- Index entries are deliberately minimal — no per-release version metadata. Each plugin
repo's `plugin.json` is the version authority, so product releases never require a
change here. Touch this repo only to add a plugin or update a description.
- Entries pin each plugin repo's `main` branch (the release channel) via `url` + https
sources — anonymous-clone friendly; the `github` source type requires SSH keys.
- The praxen repo carries a mirror of this index in its own
`.claude-plugin/marketplace.json` (legacy install path); a CI check in that repo keeps
the mirror in sync with this file.
- `main` is protected: changes land by PR with a required approval, and CI sanity-checks
the manifest (`scripts/validate_catalog.py`).
- Entries target each plugin repo's `main` branch (the release channel) via `url` + https
sources — anonymous-clone friendly; the `github` *plugin-source* type requires SSH keys.
Note `ref: main` follows the branch; it is not a fixed commit, so what installs is
whatever `main` holds at clone time.
- The praxen repo still hosts a **separate, praxen-only** marketplace under the same
registered name, serving installs added from `open-agent-ai-security/praxen` before this
catalog existed. It follows praxen's own conventions (relative `./` source, version
fields) — it is *not* a copy of this file, and copying this file there would break
praxen's CI and the legacy install path. A one-way drift check
(`marketplace-sync.yml` + `check_marketplace_mirror.py`, currently on praxen's `dev` and
reaching `main` with the 1.2 release) compares praxen's entry against this index; there
is no check in this repo, and nothing checks the socxen entry.
- `main` is protected: changes land by PR with a required approval, and CI validates the
manifest with **main's** copy of `scripts/validate_catalog.py`, so a PR can't relax the
rules and repoint a source in one change.
**Known gap:** on `pull_request` the workflow file itself comes from the PR, so a PR can
still edit the gate step. `.github/CODEOWNERS` exists to close this, but it only
auto-requests reviewers until **"Require review from Code Owners"** is enabled on the
branch protection rule — until then, treat a green `catalog` check as evidence about the
manifest, not proof the gate ran as written, and review diffs to `/.github/` and
`/scripts/` accordingly.

## License

Expand Down
154 changes: 122 additions & 32 deletions scripts/validate_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,130 @@
# SPDX-License-Identifier: Apache-2.0
"""Sanity-check .claude-plugin/marketplace.json (the whole point of this repo).

Structural rules only — the authoritative schema is Claude Code's own
(`claude plugin validate .`), which CI runners don't have; this catches the
mistakes that would silently break `marketplace add` or violate the catalog's
own conventions (https-only pinned sources, no per-release version metadata).
Exit 0 clean, 1 with findings.
This catalog is the single install path for every community plugin on both
Claude Code and Codex, so a bad `source` here ships arbitrary code to every
user. The rules below are therefore a SECURITY gate, not a style check:

- sources must be https URLs whose host is exactly github.com and whose
path is exactly /open-agent-ai-security/<repo>.git — parsed and matched
in full, never prefix-matched (a bare `startswith` is defeated by
`https://github.com/open-agent-ai-security/../attacker/repo.git`, which
git silently normalizes to another org);
- every source pins `ref: main` (each product repo's release channel);
- the entry name matches the target repository name, so an entry can't
install one plugin under another's key;
- entries carry no per-release version metadata (each plugin repo's
plugin.json is the version authority);
- names/descriptions are well-formed strings the client schema accepts.

Structural checks are stdlib-only so CI needs no dependencies. Claude Code's
own `claude plugin validate .` remains the authoritative schema check; this
runs where that CLI isn't available and enforces conventions it doesn't know
about.

Exit 0 clean, 1 with findings (always a findings list, never a traceback).
"""
import json
import re
import sys
from pathlib import Path
from urllib.parse import urlsplit

MANIFEST = Path(__file__).resolve().parents[1] / ".claude-plugin" / "marketplace.json"

MARKETPLACE_NAME = "open-agent-ai-security"
ORG = "open-agent-ai-security"
EXPECTED_REF = "main"
REPO_PATH_RE = re.compile(r"^/" + re.escape(ORG) + r"/([A-Za-z0-9._-]+)\.git$")
PLUGIN_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")


ALLOWED_SOURCE_KEYS = {"source", "url", "ref"}


def check_source(label, src, entry_name, problems):
"""Fully validate a plugin source object. Adds to `problems`."""
if not isinstance(src, dict):
problems.append(f"{label}: source must be an object, got {type(src).__name__}")
return
# Allow-list the keys: the client schema understands fields this validator
# doesn't (e.g. `path` for a subdirectory), and an unreviewed field is an
# install-behaviour change slipping past a green check.
extra = sorted(set(src) - ALLOWED_SOURCE_KEYS)
if extra:
problems.append(
f"{label}: unexpected source key(s) {extra} — this catalog only uses "
f"{sorted(ALLOWED_SOURCE_KEYS)}; add support deliberately if a new field is needed"
)
if src.get("source") != "url":
problems.append(
f"{label}: source.source must be 'url' (the 'github' type clones over SSH "
f"and fails for users without GitHub SSH keys), got {src.get('source')!r}"
)
url = src.get("url")
if not isinstance(url, str) or not url:
problems.append(f"{label}: source.url must be a non-empty string, got {url!r}")
elif any(c.isspace() or ord(c) < 0x20 for c in url):
# urlsplit silently strips tabs/newlines, so a URL containing them parses
# as something other than what a reader (or another tool) sees.
problems.append(f"{label}: source.url contains whitespace or control characters: {url!r}")
else:
parts = urlsplit(url)
# Parse, don't prefix-match: '.../open-agent-ai-security/../other/x.git'
# would satisfy a startswith check but clones from another org.
if parts.scheme != "https":
problems.append(f"{label}: source.url must use https, got scheme {parts.scheme!r}")
if parts.netloc != "github.com":
problems.append(
f"{label}: source.url host must be exactly 'github.com' "
f"(no userinfo, port, or alternate host), got {parts.netloc!r}"
)
if parts.query or parts.fragment:
problems.append(f"{label}: source.url must have no query string or fragment")
m = REPO_PATH_RE.match(parts.path)
if not m:
problems.append(
f"{label}: source.url path must be exactly "
f"'/{ORG}/<repo>.git', got {parts.path!r}"
)
elif entry_name and m.group(1).lower() != entry_name.lower():
problems.append(
f"{label}: entry name {entry_name!r} does not match target repository "
f"{m.group(1)!r} — an entry must not publish another repo under its key"
)
ref = src.get("ref")
if ref != EXPECTED_REF:
problems.append(
f"{label}: source.ref must be {EXPECTED_REF!r} (each product repo's release "
f"channel; an unpinned or integration-branch ref ships unreleased code), got {ref!r}"
)


def main() -> int:
problems = []
try:
m = json.loads(MANIFEST.read_text())
except FileNotFoundError:
print(f"{MANIFEST} not found", file=sys.stderr)
return 1
except Exception as e:
print(f"marketplace.json does not parse: {e}", file=sys.stderr)
return 1

if m.get("name") != "open-agent-ai-security":
problems.append(f"marketplace name must be 'open-agent-ai-security', got {m.get('name')!r}")
owner = m.get("owner") or {}
if not (owner.get("name") and owner.get("url")):
problems.append("owner.name and owner.url are required")
if not isinstance(m, dict):
print("marketplace.json must be a JSON object", file=sys.stderr)
return 1

if m.get("name") != MARKETPLACE_NAME:
problems.append(f"marketplace name must be {MARKETPLACE_NAME!r}, got {m.get('name')!r}")

owner = m.get("owner")
if not isinstance(owner, dict):
problems.append(f"owner must be an object, got {type(owner).__name__}")
else:
for k in ("name", "url"):
if not isinstance(owner.get(k), str) or not owner[k].strip():
problems.append(f"owner.{k} must be a non-empty string, got {owner.get(k)!r}")

plugins = m.get("plugins")
if not isinstance(plugins, list) or not plugins:
Expand All @@ -43,43 +141,35 @@ def main() -> int:
continue
name = e.get("name")
label = f"plugins[{i}] ({name!r})"
if not name or not isinstance(name, str):
problems.append(f"{label}: missing name")
if not isinstance(name, str) or not PLUGIN_NAME_RE.match(name):
problems.append(
f"{label}: name must be a lowercase string matching "
f"{PLUGIN_NAME_RE.pattern} (no spaces — the client schema rejects them)"
)
name = None
elif name in seen:
problems.append(f"{label}: duplicate name — a duplicate silently shadows the other entry")
else:
seen.add(name)
if not e.get("description"):
problems.append(f"{label}: missing description")
desc = e.get("description")
if not isinstance(desc, str) or not desc.strip():
problems.append(f"{label}: description must be a non-empty string, got {desc!r}")
if "version" in e:
problems.append(
f"{label}: carries a version — catalog entries must not; "
"each plugin repo's plugin.json is the version authority"
)
src = e.get("source")
if not isinstance(src, dict):
problems.append(f"{label}: source must be an object")
else:
if src.get("source") != "url":
problems.append(
f"{label}: source.source must be 'url' (the 'github' type clones over SSH "
"and fails for users without GitHub SSH keys)"
)
url = src.get("url", "")
if not url.startswith("https://github.com/open-agent-ai-security/"):
problems.append(f"{label}: source.url must be an https URL under the org, got {url!r}")
if not src.get("ref"):
problems.append(
f"{label}: source.ref is required — unpinned sources track the default "
"branch, which may be an integration branch"
)
check_source(label, e.get("source"), name, problems)

if problems:
print("catalog manifest problems:")
for p in problems:
print(f" - {p}")
return 1
print(f"catalog manifest OK — {len(plugins)} plugin(s), all sources https-pinned, no version metadata.")
print(
f"catalog manifest OK — {len(plugins)} plugin(s); every source is an https "
f"github.com/{ORG}/<name>.git URL on the {EXPECTED_REF} branch, with no version metadata."
)
return 0


Expand Down
Loading