diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5510804..cda8f54 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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": [ { diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..a355c27 --- /dev/null +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c1e78c5..16204fa 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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: @@ -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 diff --git a/README.md b/README.md index ab95f74..94f2f4f 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/scripts/validate_catalog.py b/scripts/validate_catalog.py index b537b21..e335a4e 100755 --- a/scripts/validate_catalog.py +++ b/scripts/validate_catalog.py @@ -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/.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}/.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: @@ -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}/.git URL on the {EXPECTED_REF} branch, with no version metadata." + ) return 0