From cee95c46b9ac524d4a2e8ff721f04514170b1151 Mon Sep 17 00:00:00 2001 From: Hacks4Snacks Date: Fri, 4 Sep 2026 18:07:30 -0500 Subject: [PATCH] marketplace publication --- .github/plugin/marketplace.json | 17 ++ README.md | 5 +- build/prepare-plugin-submission.py | 205 +++++++++++++++++++++ plugins/tmforge/README.md | 67 ++++--- release-please-config.json | 5 + test/plugin/test_marketplace_submission.py | 130 +++++++++++++ test/plugin/test_plugin.py | 17 ++ 7 files changed, 423 insertions(+), 23 deletions(-) create mode 100644 .github/plugin/marketplace.json create mode 100644 build/prepare-plugin-submission.py create mode 100644 test/plugin/test_marketplace_submission.py diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json new file mode 100644 index 0000000..b1147bb --- /dev/null +++ b/.github/plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "tmforge", + "owner": { + "name": "Mark Dalton Gray" + }, + "metadata": { + "description": "Copilot plugins maintained by the tmforge project." + }, + "plugins": [ + { + "name": "tmforge", + "description": "Evidence-backed STRIDE threat modeling with Strider, deterministic validation, and optional tmforge .tm7 authoring.", + "version": "0.11.0", + "source": "./plugins/tmforge" + } + ] +} diff --git a/README.md b/README.md index df00457..db4dbd1 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,9 @@ open as-is; see [Formats & interoperability](docs/formats.md). - **Analyze in CI** with the `tmforge` CLI (`tmforge analyze`), gating builds on SARIF-reported findings. - **Model with Copilot** using the [Strider plugin](plugins/tmforge/README.md): evidence-backed - STRIDE analysis, deterministic reports, and optional `.tm7` authoring. Install the nested plugin - directory; Markdown-only analysis does not require the CLI. + STRIDE analysis, deterministic reports, and optional `.tm7` authoring. Install through the + [tmforge marketplace](plugins/tmforge/README.md#install-from-a-marketplace); + Markdown-only analysis does not require the CLI. ## Documentation diff --git a/build/prepare-plugin-submission.py b/build/prepare-plugin-submission.py new file mode 100644 index 0000000..c5b137c --- /dev/null +++ b/build/prepare-plugin-submission.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import cast + +ROOT = Path(__file__).resolve().parents[1] +REPOSITORY = "Hacks4Snacks/tmforge" +PLUGIN_PATH = "plugins/tmforge" +# The external catalog permits at most ten tags. The plugin itself can retain more. +LISTING_KEYWORDS = [ + "data-flow-diagrams", + "risk-assessment", + "security-review", + "stride", + "strider", + "threat-modeling", + "threat-modeling-as-code", + "tm7", + "tmforge", + "trust-boundaries", +] +REVIEW_NOTES = ( + "Agent Plugins 1.0 package with Strider and two skills. The workflow adds an " + "evidence ledger, stable identities, complete STRIDE coverage checks, deterministic " + "report rendering, and candidate validation for tmforge models. Python scripts use " + "the standard library only. No hooks, bundled MCP servers, or embedded CLI binaries. " + "A launcher downloads a version-pinned, checksum-verified CLI only after explicit " + "user approval; normal use never downloads or changes global PATH. Markdown-only " + "analysis requires no CLI download. The reviewed source is the nested plugin directory." +) +JsonObject = dict[str, object] + + +def object_from_json(text: str) -> JsonObject: + value: object = json.loads(text) + if not isinstance(value, dict): + raise ValueError("Expected a JSON object") + return cast(JsonObject, value) + + +def run_read(command: list[str], root: Path) -> str: + result = subprocess.run( + command, cwd=root, capture_output=True, text=True, timeout=60, check=False, + ) + if result.returncode != 0: + raise ValueError((result.stderr or result.stdout or "Read command failed").strip()) + return result.stdout + + +def github_read(resource: str, root: Path) -> JsonObject: + # Every call is a GET. Do not add issue creation, release creation, or tag mutation here. + return object_from_json(run_read(["gh", "api", resource], root)) + + +def pinned_manifest(root: Path, tag: str) -> tuple[str, JsonObject]: + if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]*)?", tag): + raise ValueError("--tag must be an exact release tag such as v0.11.0, not a branch or floating tag") + try: + sha = run_read(["git", "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}"], root).strip() + except ValueError as exc: + raise ValueError(f"Release tag {tag} is unavailable locally; fetch that published tag first") from exc + if not re.fullmatch(r"[0-9a-f]{40}", sha): + raise ValueError("Release tag did not resolve to a full 40-character commit SHA") + try: + text = run_read(["git", "show", f"{sha}:{PLUGIN_PATH}/plugin.json"], root) + except ValueError as exc: + raise ValueError(f"Release {tag} does not contain the plugin; publish a new release containing it") from exc + return sha, object_from_json(text) + + +def build_entry(manifest: JsonObject, tag: str, sha: str) -> JsonObject: + if manifest.get("$schema") != "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json": + raise ValueError("Released plugin is not an Agent Plugins 1.0 manifest") + if manifest.get("name") != "tmforge" or manifest.get("version") != tag[1:]: + raise ValueError("Released plugin name/version does not match the selected tmforge release") + if manifest.get("repository") != f"https://github.com/{REPOSITORY}": + raise ValueError("Released plugin does not point to the expected public repository") + fields = ("name", "description", "version", "author", "homepage", "repository", "license") + if any(field not in manifest for field in fields): + raise ValueError("Released manifest is missing intake metadata") + author = manifest["author"] + if not isinstance(author, dict) or not cast(JsonObject, author).get("name"): + raise ValueError("Released manifest is missing author.name") + keywords = manifest.get("keywords") + if not isinstance(keywords, list) or not set(LISTING_KEYWORDS).issubset(cast(list[str], keywords)): + raise ValueError("Review the listing keyword selection against the released manifest") + entry = {field: manifest[field] for field in fields} + entry["keywords"] = LISTING_KEYWORDS.copy() + entry["source"] = { + "source": "github", "repo": REPOSITORY, "path": PLUGIN_PATH, + "ref": tag, "sha": sha, + } + return entry + + +def verify_published_release(release: JsonObject, tag: str) -> None: + if release.get("tag_name") != tag or release.get("draft") is not False or not release.get("published_at"): + raise ValueError("The selected release must be published, not a draft") + if release.get("immutable") is not True: + raise ValueError("The selected release must have immutable assets for the managed binary download") + assets = release.get("assets") + names: set[str] = set() + if isinstance(assets, list): + for value in cast(list[object], assets): + if isinstance(value, dict): + name = cast(JsonObject, value).get("name") + if isinstance(name, str): + names.add(name) + version = tag[1:] + required = {"release-metadata.json", "checksums.txt"} + for rid in ("linux-x64", "linux-arm64", "osx-x64", "osx-arm64", "win-x64", "win-arm64"): + extension = "zip" if rid.startswith("win-") else "tar.gz" + required.add(f"tmforge-{version}-{rid}.{extension}") + if missing := required - names: + raise ValueError("Release is missing managed-download assets: " + ", ".join(sorted(missing))) + + +def prepare(root: Path, tag: str) -> JsonObject: + sha, manifest = pinned_manifest(root, tag) + entry = build_entry(manifest, tag, sha) + repo = github_read(f"repos/{REPOSITORY}", root) + if repo.get("private") is not False: + raise ValueError("Public intake requires a public GitHub repository") + release = github_read(f"repos/{REPOSITORY}/releases/tags/{tag}", root) + verify_published_release(release, tag) + commit = github_read(f"repos/{REPOSITORY}/commits/{tag}", root) + if commit.get("sha") != sha: + raise ValueError("The public release tag and local tag resolve to different commits") + return entry + + +def render_issue(entry: JsonObject) -> str: + source = cast(JsonObject, entry["source"]) + author = cast(JsonObject, entry["author"]) + fields = [ + ("Plugin name", entry["name"]), + ("Short description", entry["description"]), + ("GitHub repository", source["repo"]), + ("Plugin path inside the repository", source["path"]), + ("Ref to review", source["ref"]), + ("Commit SHA to review", source["sha"]), + ("Version", entry["version"]), + ("License identifier", entry["license"]), + ("Author name", author["name"]), + ("Author URL", author.get("url", "")), + ("Homepage URL", entry["homepage"]), + ("Keywords", ", ".join(cast(list[str], entry["keywords"]))), + ("Additional notes for reviewers", REVIEW_NOTES), + ] + body = "\n\n" + body += "\n\n".join(f"### {label}\n\n{value}" for label, value in fields) + # These are human attestations. Never assert approval/rights on the submitter's behalf. + body += ( + "\n\n### Submission checklist\n\n" + "- [ ] The plugin lives in a public GitHub repository.\n" + "- [ ] The ref and/or sha I provided is immutable (release tag and/or full 40-character commit SHA), not a branch.\n" + "- [ ] This submission follows this repository's contribution, security, and responsible AI policies.\n" + "- [ ] This plugin is not already listed in the Awesome Copilot marketplace.\n" + ) + return body + + +def submission_files(entry: JsonObject) -> dict[str, str]: + marketplace: JsonObject = { + "name": "tmforge-review", + "owner": {"name": "tmforge maintainers"}, + "metadata": {"description": "Local smoke test of the pinned Awesome Copilot submission."}, + "plugins": [entry], + } + return { + "external-plugin.json": json.dumps(entry, indent=2) + "\n", + "marketplace.json": json.dumps(marketplace, indent=2) + "\n", + "issue.md": render_issue(entry), + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", required=True, help="Published exact release tag containing the plugin") + parser.add_argument("--output-dir", type=Path, default=ROOT / "artifacts" / "plugin-submission") + args = parser.parse_args(argv) + try: + entry = prepare(ROOT, args.tag) + output = args.output_dir.resolve() + output.mkdir(parents=True, exist_ok=True) + for name, text in submission_files(entry).items(): + (output / name).write_text(text, encoding="utf-8") + source = cast(JsonObject, entry["source"]) + print(f"Prepared {source['ref']} at {source['sha']} in {output}") + print("Review the issue draft and attestations. Nothing was published or submitted.") + return 0 + except (OSError, ValueError, subprocess.TimeoutExpired) as exc: + print(f"Plugin submission not ready: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/tmforge/README.md b/plugins/tmforge/README.md index e07294c..ca7987d 100644 --- a/plugins/tmforge/README.md +++ b/plugins/tmforge/README.md @@ -117,21 +117,54 @@ replace code signing or notarization; Developer ID signing and notarization of release artifacts are the long-term distribution fix. See [Apple's guidance on opening downloaded software](https://support.apple.com/en-us/102445). -## Try the local development copy +## Install from a marketplace -From the tmforge checkout, install this **nested directory**, not the repository root: +After the repository marketplace catalog is merged to `main`, register it once and +install through the supported `plugin@marketplace` route: ```bash -copilot plugin install ./plugins/tmforge +copilot plugin marketplace add Hacks4Snacks/tmforge +copilot plugin install tmforge@tmforge copilot plugin list ``` -Some CLI versions warn that direct installs are deprecated. This remains a local -development check; the intended public installation is the reviewed marketplace entry. -Noninteractive CLI inventories may report skills but omit custom agents. Confirm -Strider in a new chat's agent picker; the install summary alone does not prove agent discovery. +The first `tmforge` is the plugin name; the second is the marketplace name. Adding a +GitHub repository as a marketplace is supported; directly installing a plugin from +a repository, URL, or local path is the deprecated operation. + +The project catalog follows the repository's default branch and loads the nested +plugin from the same catalog checkout. It can therefore contain changes ahead of +the next release. Plugin and catalog versions move together through Release Please. +For the managed CLI, the release matching the manifest version must already exist. + +If you previously installed tmforge directly, inspect that installation and remove +the direct entry before installing the marketplace copy to avoid duplicate agents +and skills. Do not remove an unrelated marketplace or its plugins. -Alternatively, register the absolute path to this directory with VS Code's +```bash +copilot plugin list +copilot plugin uninstall tmforge +copilot plugin install tmforge@tmforge +``` + +Reload VS Code or start a fresh Copilot session after installation. VS Code discovers +CLI-installed plugins. Noninteractive inventories can omit custom agents, so check +**Strider** in the agent picker and both skills in the customization view. + +## Try the local development copy + +Use the local repository as a marketplace to test the same installation route before merge: + +```bash +copilot plugin marketplace add /absolute/path/to/tmforge +copilot plugin install tmforge@tmforge +``` + +Use an isolated `COPILOT_HOME` and `COPILOT_CACHE_HOME` when smoke-testing so the local +catalog does not replace your normal `tmforge` marketplace registration. Refresh or +reinstall after source changes; do not assume an installed cache is a live copy. + +Alternatively, register the absolute path to the **nested plugin directory** with VS Code's `chat.pluginLocations` setting: ```json @@ -148,14 +181,6 @@ with the same ID can take precedence over an installed plugin; test in a workspa without another Strider installation. Strider links directly to its bundled skills so their version and validator contract stay together. -After a release containing this directory is public, a direct source installation -can use `copilot plugin install Hacks4Snacks/tmforge:plugins/tmforge`. That form -tracks the source; use a marketplace entry pinned to a release and commit for a -reproducible reviewed installation. - -**This plugin is not yet listed in Awesome Copilot.** Do not advertise a marketplace -install command until the external-plugin review has been approved. - ## Example requests - “Analyze the checkout request path and produce one Markdown STRIDE report. @@ -225,11 +250,11 @@ binary delivery using offline fixtures. They require Git but do not require tmfo network access, or third-party Python packages. The dependency guard covers all bundled Python scripts. A real download and `.tm7` smoke test remain release checks. -The plugin version follows tmforge. Release Please updates this manifest together -with the product version. The development value currently matches the product; -**the existing `v0.10.0` release does not contain this plugin**. The first submission -must use a **new release tag containing the plugin** and the full 40-character -commit SHA to which that tag resolves. +The plugin version follows tmforge. Release Please updates the plugin manifest, +project marketplace catalog, and product version together. **`v0.11.0` is the first +release containing the plugin**; `v0.10.0` predates it. External submissions use an +immutable release tag containing the plugin and the full 40-character commit SHA +to which that tag resolves. See the [external submission checklist](https://github.com/Hacks4Snacks/tmforge/blob/main/docs/copilot-plugin.md) in the source checkout for release and Awesome Copilot intake steps. That document is maintainer diff --git a/release-please-config.json b/release-please-config.json index e7cda2f..8aeab3f 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -16,6 +16,11 @@ "type": "json", "path": "plugins/tmforge/plugin.json", "jsonpath": "$.version" + }, + { + "type": "json", + "path": ".github/plugin/marketplace.json", + "jsonpath": "$.plugins[0].version" } ] } diff --git a/test/plugin/test_marketplace_submission.py b/test/plugin/test_marketplace_submission.py new file mode 100644 index 0000000..5810e29 --- /dev/null +++ b/test/plugin/test_marketplace_submission.py @@ -0,0 +1,130 @@ +"""Offline guards for preparing, but never publishing, a pinned external plugin submission.""" + +import copy +import importlib.util +import io +import json +import unittest +from contextlib import redirect_stderr +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("prepare_plugin_submission", ROOT / "build/prepare-plugin-submission.py") +assert SPEC is not None and SPEC.loader is not None +submission = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(submission) + + +class MarketplaceSubmissionTests(unittest.TestCase): + def setUp(self): + self.tag = "v1.2.3" + self.sha = "a" * 40 + self.manifest = json.loads((ROOT / "plugins/tmforge/plugin.json").read_text(encoding="utf-8")) + self.manifest["version"] = self.tag[1:] + names = ["release-metadata.json", "checksums.txt"] + for rid in ("linux-x64", "linux-arm64", "osx-x64", "osx-arm64", "win-x64", "win-arm64"): + extension = "zip" if rid.startswith("win-") else "tar.gz" + names.append(f"tmforge-{self.tag[1:]}-{rid}.{extension}") + self.release: dict[str, object] = { + "tag_name": self.tag, "draft": False, "immutable": True, + "published_at": "2026-09-04T00:00:00Z", "assets": [{"name": name} for name in names], + } + + def test_rejects_branch_floating_and_unsafe_locators_before_any_command(self): + for tag in ("main", "refs/heads/main", "v0.11", "latest", "--help", "../v1.2.3"): + with self.subTest(tag=tag), patch.object(submission, "run_read") as command: + with self.assertRaisesRegex(ValueError, "exact release tag"): + submission.pinned_manifest(ROOT, tag) + command.assert_not_called() + + def test_reads_manifest_from_peeled_tag_commit_not_working_tree(self): + with patch.object(submission, "run_read", side_effect=[self.sha, json.dumps(self.manifest)]) as command: + sha, manifest = submission.pinned_manifest(ROOT, self.tag) + self.assertEqual(sha, self.sha) + self.assertEqual(manifest, self.manifest) + self.assertEqual(command.call_args_list[0].args[0], + ["git", "rev-parse", "--verify", f"refs/tags/{self.tag}^{{commit}}"]) + self.assertEqual(command.call_args_list[1].args[0], + ["git", "show", f"{self.sha}:plugins/tmforge/plugin.json"]) + + def test_release_without_plugin_is_not_replaced_by_working_copy(self): + with patch.object(submission, "run_read", side_effect=[self.sha, ValueError("missing plugin")]): + with self.assertRaisesRegex(ValueError, "does not contain the plugin"): + submission.pinned_manifest(ROOT, self.tag) + + def test_missing_tag_requires_fetch_not_branch_fallback(self): + with patch.object(submission, "run_read", side_effect=ValueError("missing tag")): + with self.assertRaisesRegex(ValueError, "fetch that published tag"): + submission.pinned_manifest(ROOT, self.tag) + + def test_manifest_version_must_match_tag(self): + self.manifest["version"] = "1.2.2" + with self.assertRaisesRegex(ValueError, "name/version"): + submission.build_entry(self.manifest, self.tag, self.sha) + + def test_listing_uses_ten_keywords_without_mutating_plugin(self): + original = copy.deepcopy(self.manifest) + entry = submission.build_entry(self.manifest, self.tag, self.sha) + self.assertEqual(self.manifest, original) + self.assertEqual(len(entry["keywords"]), 10) + self.assertEqual(len(set(entry["keywords"])), 10) + for keyword in entry["keywords"]: + self.assertIn(keyword, original["keywords"]) + self.assertLessEqual(len(keyword), 30) + self.assertRegex(keyword, r"^[a-z0-9-]+$") + self.assertNotIn("$schema", entry) + self.assertEqual(entry["source"], { + "source": "github", "repo": "Hacks4Snacks/tmforge", "path": "plugins/tmforge", + "ref": self.tag, "sha": self.sha, + }) + + def test_only_published_immutable_complete_releases_are_accepted(self): + submission.verify_published_release(self.release, self.tag) + changes: dict[str, object] = {"draft": True, "immutable": False, "published_at": None, + "tag_name": "v1.2.2", "assets": []} + for name, value in changes.items(): + invalid = dict(self.release, **{name: value}) + with self.subTest(field=name), self.assertRaises(ValueError): + submission.verify_published_release(invalid, self.tag) + + def test_public_and_local_tag_sha_must_agree(self): + replies = [{"private": False}, self.release, {"sha": "b" * 40}] + with patch.object(submission, "pinned_manifest", return_value=(self.sha, self.manifest)), \ + patch.object(submission, "github_read", side_effect=replies): + with self.assertRaisesRegex(ValueError, "different commits"): + submission.prepare(ROOT, self.tag) + + def test_private_repository_is_not_a_public_submission(self): + with patch.object(submission, "pinned_manifest", return_value=(self.sha, self.manifest)), \ + patch.object(submission, "github_read", return_value={"private": True}): + with self.assertRaisesRegex(ValueError, "public GitHub"): + submission.prepare(ROOT, self.tag) + + def test_intake_outputs_share_pins_and_leave_attestations_to_human(self): + with patch.object(submission, "pinned_manifest", return_value=(self.sha, self.manifest)), \ + patch.object(submission, "github_read", side_effect=[{"private": False}, self.release, {"sha": self.sha}]): + entry = submission.prepare(ROOT, self.tag) + files = submission.submission_files(entry) + external = json.loads(files["external-plugin.json"]) + marketplace = json.loads(files["marketplace.json"]) + self.assertEqual(marketplace["plugins"], [external]) + self.assertEqual(marketplace["name"], "tmforge-review") + body = files["issue.md"] + self.assertIn("", body) + self.assertIn(f"### Ref to review\n\n{self.tag}", body) + self.assertIn(f"### Commit SHA to review\n\n{self.sha}", body) + self.assertEqual(body.count("- [ ]"), 4) + self.assertNotIn("- [x]", body) + + def test_failed_preflight_writes_no_draft_artifacts(self): + with TemporaryDirectory() as directory: + output = Path(directory) / "submission" + with patch.object(submission, "prepare", side_effect=ValueError("release not ready")), redirect_stderr(io.StringIO()): + self.assertEqual(submission.main(["--tag", self.tag, "--output-dir", str(output)]), 1) + self.assertFalse(output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/plugin/test_plugin.py b/test/plugin/test_plugin.py index 163674e..5e47cce 100644 --- a/test/plugin/test_plugin.py +++ b/test/plugin/test_plugin.py @@ -42,6 +42,23 @@ def test_manifest_matches_agent_plugins_and_product_version(self): "type": "json", "path": "plugins/tmforge/plugin.json", "jsonpath": "$.version", }, release["packages"]["."]["extra-files"]) + def test_repository_marketplace_matches_plugin_and_release_updater(self): + catalog = json.loads((ROOT / ".github/plugin/marketplace.json").read_text(encoding="utf-8")) + manifest = json.loads((PLUGIN / "plugin.json").read_text(encoding="utf-8")) + self.assertEqual(catalog["name"], "tmforge") + self.assertEqual(catalog["owner"]["name"], manifest["author"]["name"]) + self.assertEqual(len(catalog["plugins"]), 1) + entry = catalog["plugins"][0] + for field in ("name", "description", "version"): + self.assertEqual(entry[field], manifest[field]) + self.assertEqual(entry["source"], "./plugins/tmforge") + self.assertEqual((ROOT / entry["source"]).resolve(), PLUGIN.resolve()) + release = json.loads((ROOT / "release-please-config.json").read_text(encoding="utf-8")) + self.assertIn({ + "type": "json", "path": ".github/plugin/marketplace.json", + "jsonpath": "$.plugins[0].version", + }, release["packages"]["."]["extra-files"]) + def test_plugin_license_matches_repository(self): self.assertEqual((PLUGIN / "LICENSE.md").read_bytes(), (ROOT / "LICENSE.md").read_bytes())