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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
205 changes: 205 additions & 0 deletions build/prepare-plugin-submission.py
Original file line number Diff line number Diff line change
@@ -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 = "<!-- external-plugin-submission -->\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())
67 changes: 46 additions & 21 deletions plugins/tmforge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
"type": "json",
"path": "plugins/tmforge/plugin.json",
"jsonpath": "$.version"
},
{
"type": "json",
"path": ".github/plugin/marketplace.json",
"jsonpath": "$.plugins[0].version"
}
]
}
Expand Down
Loading
Loading