From 66fd07d3c9d3c3ac1c13affcc48b872f980f334e Mon Sep 17 00:00:00 2001 From: Steve Wilson Date: Fri, 31 Jul 2026 20:57:48 -0700 Subject: [PATCH 1/3] security: fix org-scope bypass in the validator, close the self-grading gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the adversarial review in #1 (H1, H2, M1-M3, L1-L5). H1 (the serious one): the org-scope check was a bare startswith on an unnormalized URL, so https://github.com/open-agent-ai-security/../octocat/Hello-World.git passed both this validator and 'claude plugin validate', and git normalizes the '..' at clone time — the reviewer proved end-to-end that a foreign repo installs as 'praxen'. Sources are now PARSED (urlsplit) and matched in full: scheme https, netloc exactly github.com (no userinfo/port), path exactly /open-agent-ai-security/.git, no query/fragment. Also enforced, each closing a documented gap: - ref must be exactly 'main' (L3) — 'dev' or a tag would have shipped unreleased code under a green check - entry name must equal the target repository name (L3) — an entry can't publish another repo under its key (the H1 PoC's shape) - names match [a-z0-9][a-z0-9._-]* and descriptions are non-empty strings (L1) — a name with a space passed here and failed the client schema, breaking 'marketplace add' for everyone - owner/url type checks (L2) — these raised AttributeError tracebacks instead of reporting findings H2: the workflow ran the PR's own copy of the gate, so one PR could weaken the check and repoint a source together. The job now runs main's validator against the PR's manifest (with the branch's own validator re-run informationally), and .github/CODEOWNERS requires an owner review on /.claude-plugin/, /scripts/, and /.github/. Docs (M1-M3, L5): the README claimed praxen mirrors this index and that CI syncs it — false in both halves; it now describes praxen's separate praxen-only legacy marketplace and the one-way check that actually exists. The migration recipes claimed a marketplace-name conflict that doesn't occur: a re-add re-points the same-named marketplace losslessly, so the destructive 'marketplace remove' is gone from the praxen recipe, and migration is correctly described as optional for praxen but required for socxen. Codex install lines added (verified working) and the Claude-Code-only framing dropped. L4: actions/checkout pinned to a full SHA. Verified: 14 attack fixtures (traversal, userinfo, http downgrade, wrong host, bare-org url, ref=dev, ref=tag, name/repo mismatch, spaced name, non-string description, owner-as-string, null url, version metadata, github source type) all exit 1; unmodified manifest exits 0; 'claude plugin validate .' still passes. Co-Authored-By: Claude Fable 5 Signed-off-by: Steve Wilson --- .claude-plugin/marketplace.json | 2 +- .github/CODEOWNERS | 12 +++ .github/workflows/validate.yml | 24 +++++- README.md | 53 +++++++----- scripts/validate_catalog.py | 138 ++++++++++++++++++++++++-------- 5 files changed, 175 insertions(+), 54 deletions(-) create mode 100644 .github/CODEOWNERS 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..ec04c40 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,25 @@ jobs: catalog: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 - name: Sanity-check the catalog manifest + run: | + # Run main's copy of the validator against the PR's manifest, so a PR + # cannot weaken the gate 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.) + mkdir -p "$RUNNER_TEMP/gate" + if git show origin/main:scripts/validate_catalog.py > "$RUNNER_TEMP/gate/validate_catalog.py" 2>/dev/null; then + echo "::notice::running the validator from origin/main (PRs cannot grade their own homework)" + mkdir -p "$RUNNER_TEMP/gate/../.claude-plugin" + cp .claude-plugin/marketplace.json "$RUNNER_TEMP/.claude-plugin/marketplace.json" + python3 "$RUNNER_TEMP/gate/validate_catalog.py" + else + echo "::warning::could not read the validator from origin/main — falling back to this checkout's copy" + python3 scripts/validate_catalog.py + fi + - name: Re-run this branch's validator (informational) + if: always() run: python3 scripts/validate_catalog.py diff --git a/README.md b/README.md index ab95f74..527b8a8 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,33 +34,40 @@ 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`, that marketplace was +retired at the source, so this one does need an uninstall: ```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 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. +## 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 @@ -68,11 +76,16 @@ shared marketplace name until the old one is removed. 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`). +- 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. praxen's `marketplace-sync.yml` compares its + entry against this index one-way; there is no sync check in this repo. +- `main` is protected: changes land by PR with a required approval, CI sanity-checks the + manifest (`scripts/validate_catalog.py`, run from `main` so a PR can't weaken its own + gate), and `.github/CODEOWNERS` requires an owner's review on the manifest, the + validator, and workflows. ## License diff --git a/scripts/validate_catalog.py b/scripts/validate_catalog.py index b537b21..f3cb6a0 100755 --- a/scripts/validate_catalog.py +++ b/scripts/validate_catalog.py @@ -3,32 +3,114 @@ # 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._-]*$") + + +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 + 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}") + 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) != entry_name: + 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 +125,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 pinned to {EXPECTED_REF}, with no version metadata." + ) return 0 From c3419d3014c7af68cdff055ae9dc45adb56390cb Mon Sep 17 00:00:00 2001 From: Steve Wilson Date: Fri, 31 Jul 2026 21:07:28 -0700 Subject: [PATCH 2/3] README: correct the socxen migration recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same measured correction as socxen's own docs: the explicit plugin uninstall is redundant (marketplace remove takes its plugins with it), but the marketplace removal is required — the legacy socxen marketplace has a different NAME, so adding this catalog alongside it leaves two enabled copies of socxen registering the same skill. Verified in an isolated config. Co-Authored-By: Claude Fable 5 Signed-off-by: Steve Wilson --- README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 527b8a8..182f090 100644 --- a/README.md +++ b/README.md @@ -49,16 +49,20 @@ uninstalls the plugins that came from it, and it isn't necessary. Migrating is o 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`, that marketplace was -retired at the source, so this one does need an uninstall: +**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 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 ``` +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: From addc3b9c8f77be54d30bf03608b3daedb20b1aca Mon Sep 17 00:00:00 2001 From: Steve Wilson Date: Fri, 31 Jul 2026 21:24:09 -0700 Subject: [PATCH 3/3] review round 2: fail closed on gate-fetch failure, allowlist source keys, honest README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the fresh review on PR #3: - M1: the 'rules from main' step fell back to the PR's own validator with a ::warning:: when 'git show origin/main:...' failed — a green check that graded its own homework. It now fails CLOSED (::error::, exit 1): if main's rules can't be read, nothing has been validated. - L: the second step was labelled '(informational)' but gated the job; it is now continue-on-error and labelled 'advisory only'. - L: source keys are allowlisted to {source, url, ref}. The client schema understands fields this validator doesn't (e.g. 'path' for a subdirectory) and CI never runs the CLI, so an unreviewed source field was an install-behaviour change that could pass green. - L: URLs containing whitespace/control characters are rejected — urlsplit silently strips tabs and newlines, so such a URL parses as something other than what a reader sees (the reviewer's only non-canonical passing fixtures; none could clone). - L: entry-name/repo comparison is case-insensitive, so a legitimate uppercase repo name isn't foreclosed. - README: the H2 gap is now stated plainly rather than implied closed — on pull_request the workflow file also comes from the PR, and CODEOWNERS only auto-requests reviewers until 'Require review from Code Owners' is enabled on the branch rule. Also corrected the praxen drift-check bullet (it is on praxen's dev, reaching main with 1.2 — the previous wording asserted it as live) and softened 'pinned', since ref: main follows a branch, not a commit. H1 re-verified still blocked; 6-case fixture check plus the full prior gauntlet pass; 'claude plugin validate .' passes. Co-Authored-By: Claude Fable 5 Signed-off-by: Steve Wilson --- .github/workflows/validate.yml | 33 +++++++++++++++++++++------------ README.md | 25 +++++++++++++++++-------- scripts/validate_catalog.py | 20 ++++++++++++++++++-- 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ec04c40..16204fa 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -21,22 +21,31 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 - - name: Sanity-check the catalog manifest + - 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 weaken the gate and repoint a source in the same change. + # 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.) - mkdir -p "$RUNNER_TEMP/gate" - if git show origin/main:scripts/validate_catalog.py > "$RUNNER_TEMP/gate/validate_catalog.py" 2>/dev/null; then - echo "::notice::running the validator from origin/main (PRs cannot grade their own homework)" - mkdir -p "$RUNNER_TEMP/gate/../.claude-plugin" - cp .claude-plugin/marketplace.json "$RUNNER_TEMP/.claude-plugin/marketplace.json" - python3 "$RUNNER_TEMP/gate/validate_catalog.py" - else - echo "::warning::could not read the validator from origin/main — falling back to this checkout's copy" - python3 scripts/validate_catalog.py + # + # 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 - - name: Re-run this branch's validator (informational) + 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 182f090..94f2f4f 100644 --- a/README.md +++ b/README.md @@ -78,18 +78,27 @@ codex plugin list - 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. +- 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. praxen's `marketplace-sync.yml` compares its - entry against this index one-way; there is no sync check in this repo. -- `main` is protected: changes land by PR with a required approval, CI sanity-checks the - manifest (`scripts/validate_catalog.py`, run from `main` so a PR can't weaken its own - gate), and `.github/CODEOWNERS` requires an owner's review on the manifest, the - validator, and workflows. + 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 f3cb6a0..e335a4e 100755 --- a/scripts/validate_catalog.py +++ b/scripts/validate_catalog.py @@ -41,11 +41,23 @@ 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 " @@ -54,6 +66,10 @@ def check_source(label, src, entry_name, problems): 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' @@ -73,7 +89,7 @@ def check_source(label, src, entry_name, problems): f"{label}: source.url path must be exactly " f"'/{ORG}/.git', got {parts.path!r}" ) - elif entry_name and m.group(1) != entry_name: + 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" @@ -152,7 +168,7 @@ def main() -> int: return 1 print( f"catalog manifest OK — {len(plugins)} plugin(s); every source is an https " - f"github.com/{ORG}/.git URL pinned to {EXPECTED_REF}, with no version metadata." + f"github.com/{ORG}/.git URL on the {EXPECTED_REF} branch, with no version metadata." ) return 0