diff --git a/.github/scripts/verify-provenance.py b/.github/scripts/verify-provenance.py new file mode 100755 index 0000000..dd21ea4 --- /dev/null +++ b/.github/scripts/verify-provenance.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Verify that plugins in a PR originated from the internal claude-marketplace repo. + +For each plugin name provided, performs three checks: + 1. Existence: plugin directory exists in carta/claude-marketplace main branch. + 2. Security manifest: plugin appears with "status": "passed" in the manifest. + 3. Content integrity: local content hash matches the manifest's content_hash. + +Usage: + python verify-provenance.py "plugin-a,plugin-b,plugin-c" + +Requires: + GH_TOKEN environment variable for GitHub API authentication. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +PLUGINS_DIR = REPO_ROOT / "plugins" + +GITHUB_API = "https://api.github.com" +MARKETPLACE_REPO = os.environ.get("PROVENANCE_REPO", "carta/claude-marketplace") +MARKETPLACE_REF = "main" + +# Possible plugin directory prefixes in the marketplace repo. +# Plugins may live at plugins/ or nested under plugins//. +MARKETPLACE_SEARCH_PREFIXES = [ + "plugins", + "plugins/commands", + "plugins/mcps", +] + + +def _gh_token() -> str: + """Return the GitHub token or exit with an error.""" + token = os.environ.get("GH_TOKEN", "") + if not token: + print("Error: GH_TOKEN environment variable is not set.") + print("Set it to a GitHub personal access token with repo read access.") + sys.exit(1) + return token + + +def _github_get(path: str) -> dict | None: + """Make a GET request to the GitHub API and return parsed JSON. + + Returns None on 404. Raises SystemExit on auth or rate-limit errors. + """ + url = f"{GITHUB_API}{path}" + req = urllib.request.Request(url, headers={ + "Authorization": f"Bearer {_gh_token()}", + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28", + }) + + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return None + if exc.code == 403: + remaining = exc.headers.get("X-RateLimit-Remaining", "unknown") + if remaining == "0": + print(f"Error: GitHub API rate limit exceeded. Reset at " + f"{exc.headers.get('X-RateLimit-Reset', 'unknown')}.") + print("Wait a few minutes or use a token with higher limits.") + sys.exit(1) + print(f"Error: GitHub API returned 403 Forbidden for {url}.") + print("Ensure GH_TOKEN has read access to carta/claude-marketplace.") + sys.exit(1) + if exc.code == 401: + print("Error: GitHub API returned 401 Unauthorized.") + print("Ensure GH_TOKEN is a valid token with repo read access.") + sys.exit(1) + raise + + +# --------------------------------------------------------------------------- +# Content hash — must exactly match generate-security-manifest.py +# --------------------------------------------------------------------------- + +# Exclusions must stay in sync with generate-security-manifest.py so that +# hashes computed here match hashes recorded in the security manifest. +HASH_EXCLUDE_DIRS = {"__pycache__", "node_modules"} + + +def _should_exclude(path: Path) -> bool: + """Return True if *path* should be excluded from content hashing.""" + return ( + any(part in HASH_EXCLUDE_DIRS for part in path.parts) + or path.suffix == ".pyc" + or path.name == ".DS_Store" + ) + + +def compute_content_hash(plugin_dir: Path) -> str: + """Compute a deterministic SHA-256 hash of all files in a plugin directory. + + Algorithm (must match generate-security-manifest.py exactly): + 1. Collect all files recursively, excluding build artifacts and OS + metadata (__pycache__, .pyc, .DS_Store, node_modules). + 2. Sort by relative POSIX path. + 3. For each file: concatenate relative_path + newline + file bytes. + 4. Hash the full concatenation with SHA-256. + 5. Return "sha256:". + """ + hasher = hashlib.sha256() + + all_files: list[Path] = sorted( + ( + f + for f in plugin_dir.rglob("*") + if f.is_file() and not _should_exclude(f.relative_to(plugin_dir)) + ), + key=lambda f: f.relative_to(plugin_dir).as_posix(), + ) + + for filepath in all_files: + rel_path = filepath.relative_to(plugin_dir).as_posix() + hasher.update(rel_path.encode("utf-8")) + hasher.update(b"\n") + hasher.update(filepath.read_bytes()) + + return f"sha256:{hasher.hexdigest()}" + + +# --------------------------------------------------------------------------- +# Check 1: Existence in marketplace +# --------------------------------------------------------------------------- + +def check_existence(plugin_name: str) -> tuple[bool, str, str | None]: + """Verify the plugin exists in claude-marketplace main branch. + + Returns (passed, message, marketplace_path_prefix_or_none). + """ + for prefix in MARKETPLACE_SEARCH_PREFIXES: + api_path = ( + f"/repos/{MARKETPLACE_REPO}/contents/" + f"{prefix}/{plugin_name}/.claude-plugin/plugin.json" + f"?ref={MARKETPLACE_REF}" + ) + result = _github_get(api_path) + if result is not None: + return ( + True, + f"Found at {prefix}/{plugin_name} in {MARKETPLACE_REPO}", + prefix, + ) + + searched = ", ".join(f"{p}/{plugin_name}" for p in MARKETPLACE_SEARCH_PREFIXES) + return ( + False, + f"Plugin '{plugin_name}' not found in {MARKETPLACE_REPO}. " + f"Searched: {searched}. " + f"Publish it to claude-marketplace first before adding to the public repo.", + None, + ) + + +# --------------------------------------------------------------------------- +# Check 2 & 3: Security manifest +# --------------------------------------------------------------------------- + +def fetch_security_manifest() -> dict | None: + """Fetch and decode security-manifest.json from marketplace main branch.""" + api_path = ( + f"/repos/{MARKETPLACE_REPO}/contents/security-manifest.json" + f"?ref={MARKETPLACE_REF}" + ) + result = _github_get(api_path) + if result is None: + return None + + content_b64 = result.get("content", "") + try: + raw = base64.b64decode(content_b64) + return json.loads(raw) + except (json.JSONDecodeError, ValueError) as exc: + print(f"Error: Failed to parse security-manifest.json: {exc}") + return None + + +def check_security_manifest( + plugin_name: str, + manifest: dict, +) -> tuple[bool, str]: + """Verify the plugin appears in the manifest with status 'passed'.""" + plugins = manifest.get("plugins", {}) + entry = plugins.get(plugin_name) + + if entry is None: + return ( + False, + f"Plugin '{plugin_name}' not found in security-manifest.json. " + f"Run the security scan in claude-marketplace CI before publishing.", + ) + + status = entry.get("status", "unknown") + if status != "passed": + return ( + False, + f"Plugin '{plugin_name}' has status '{status}' in security manifest " + f"(expected 'passed'). Resolve security findings before publishing.", + ) + + return True, f"Security manifest status: passed" + + +def check_content_integrity( + plugin_name: str, + manifest: dict, +) -> tuple[bool, str]: + """Compare local content hash with the manifest's recorded hash.""" + plugins = manifest.get("plugins", {}) + entry = plugins.get(plugin_name) + + if entry is None: + return ( + False, + f"Cannot verify content integrity — plugin '{plugin_name}' " + f"missing from security manifest.", + ) + + expected_hash = entry.get("content_hash", "") + if not expected_hash: + return ( + False, + f"No content_hash recorded in manifest for '{plugin_name}'.", + ) + + # The local plugin directory is always at plugins/ in this repo, + # regardless of where it lives in the marketplace repo. + local_plugin_dir = PLUGINS_DIR / plugin_name + if not local_plugin_dir.is_dir(): + return ( + False, + f"Local plugin directory not found: {local_plugin_dir.relative_to(REPO_ROOT)}. " + f"Ensure the plugin has been copied into the plugins/ directory.", + ) + + local_hash = compute_content_hash(local_plugin_dir) + + if local_hash != expected_hash: + return ( + False, + f"Content hash mismatch for '{plugin_name}'.\n" + f" Local: {local_hash}\n" + f" Expected: {expected_hash}\n" + f" The local plugin content differs from what was scanned in " + f"claude-marketplace. Ensure you are syncing the exact same files.", + ) + + return True, f"Content hash verified: {local_hash}" + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def verify_plugin( + plugin_name: str, + manifest: dict | None, +) -> bool: + """Run all checks for a single plugin. Returns True if all pass.""" + print(f"\n--- Plugin: {plugin_name} ---") + + all_passed = True + + # Check 1: Existence + passed, msg, _ = check_existence(plugin_name) + status = "PASS" if passed else "FAIL" + print(f" [{status}] Existence check: {msg}") + if not passed: + all_passed = False + + # Check 2 & 3 require the manifest + if manifest is None: + print(" [FAIL] Security manifest check: security-manifest.json not found " + f"in {MARKETPLACE_REPO} main branch. Ensure the security scan has run.") + print(" [FAIL] Content integrity check: skipped (no manifest)") + return False + + # Check 2: Security manifest status + passed, msg = check_security_manifest(plugin_name, manifest) + status = "PASS" if passed else "FAIL" + print(f" [{status}] Security manifest check: {msg}") + if not passed: + all_passed = False + + # Check 3: Content integrity + passed, msg = check_content_integrity(plugin_name, manifest) + status = "PASS" if passed else "FAIL" + print(f" [{status}] Content integrity check: {msg}") + if not passed: + all_passed = False + + return all_passed + + +def main() -> int: + if len(sys.argv) < 2 or not sys.argv[1].strip(): + print("No plugins to verify. Exiting.") + return 0 + + plugin_names = [n.strip() for n in sys.argv[1].split(",") if n.strip()] + + if not plugin_names: + print("No plugins to verify. Exiting.") + return 0 + + print("=" * 60) + print("Plugin Provenance Verification") + print("=" * 60) + print(f"Repo: {MARKETPLACE_REPO} (ref: {MARKETPLACE_REF})") + print(f"Plugins: {', '.join(plugin_names)}") + + # Fetch the security manifest once (used by checks 2 & 3) + manifest = fetch_security_manifest() + if manifest is None: + print(f"\nWarning: Could not fetch security-manifest.json from " + f"{MARKETPLACE_REPO} main branch.") + + results: dict[str, bool] = {} + for name in plugin_names: + results[name] = verify_plugin(name, manifest) + + # Summary + passed = sum(1 for v in results.values() if v) + failed = len(results) - passed + + print("\n" + "=" * 60) + print(f"Summary: {passed} passed, {failed} failed out of {len(results)} plugin(s)") + + if failed: + print("\nFailed plugins:") + for name, ok in results.items(): + if not ok: + print(f" ✗ {name}") + print("\nFAILED") + return 1 + + print("\nPASSED") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/provenance-check.yml b/.github/workflows/provenance-check.yml new file mode 100644 index 0000000..6ec6068 --- /dev/null +++ b/.github/workflows/provenance-check.yml @@ -0,0 +1,56 @@ +name: Provenance Check +on: + pull_request: + paths: + - 'plugins/**' + +permissions: + contents: read + pull-requests: write + +jobs: + provenance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Identify changed plugins + id: changes + run: | + PLUGINS=$(gh pr diff "$PR_NUMBER" --name-only \ + | grep '^plugins/' \ + | cut -d'/' -f2 \ + | sort -u \ + | tr '\n' ',') + echo "changed_plugins=${PLUGINS%,}" >> "$GITHUB_OUTPUT" + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ github.token }} + - uses: actions/setup-python@v5 + if: steps.changes.outputs.changed_plugins != '' + with: + python-version: '3.12' + - name: Verify provenance + if: steps.changes.outputs.changed_plugins != '' + run: python .github/scripts/verify-provenance.py "$CHANGED_PLUGINS" + env: + GH_TOKEN: ${{ github.token }} + PROVENANCE_REPO: ${{ vars.PROVENANCE_REPO }} + CHANGED_PLUGINS: ${{ steps.changes.outputs.changed_plugins }} + - name: Post failure comment + if: failure() + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ github.token }} + run: | + gh pr comment "$PR_NUMBER" --body "## Provenance Check Failed + + One or more plugins in this PR could not be verified against the internal marketplace. + + **What this means:** Plugins must first be merged to the internal marketplace repo and pass security scanning before they can be published here. + + **How to fix:** + 1. Ensure your plugin is merged to \`main\` in the internal marketplace repo + 2. Wait for the security scan workflow to pass and generate the manifest + 3. Ensure the plugin content here is an exact copy (no modifications) + + See the workflow logs above for specific details on which checks failed." diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..92ca6cf --- /dev/null +++ b/LICENSE @@ -0,0 +1,199 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. Please do not edit this text + to alter its meaning. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing + permissions and limitations under the License. + + Copyright 2026 Carta, Inc.