diff --git a/cmd/wfctl/plugin_registry_sync.go b/cmd/wfctl/plugin_registry_sync.go index 74009189..7fdb6f0b 100644 --- a/cmd/wfctl/plugin_registry_sync.go +++ b/cmd/wfctl/plugin_registry_sync.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "sort" "strings" ) @@ -27,9 +28,8 @@ import ( // 6. Fetches tagged plugin.json from upstream; syncs capabilities, // minEngineVersion, iacProvider into registry manifest. // 7. (--verify-capabilities) Downloads release tarball + spawns binary; -// diffs GetManifest's capabilities vs committed; with --fix rewrites. -// NOTE: deferred to a follow-up PR per plan I4 / I-P9 — initial impl -// stubs this with a clear "not implemented yet" message. +// reuses wfctl plugin verify-capabilities to diff runtime GetManifest +// against the registry manifest. func runPluginRegistrySync(args []string) error { if len(args) > 0 { switch args[0] { @@ -195,7 +195,16 @@ func syncDefault(registryDir string, fix bool, pluginFilter string, verifyCaps b } if verifyCaps { - fmt.Fprintf(os.Stderr, " NOTE %s — --verify-capabilities not yet implemented (workflow#762 follow-up)\n", pluginName) + verifyName, _ := raw["name"].(string) + if verifyName == "" { + verifyName = pluginName + } + if err := verifyRegistryPluginCapabilities(verifyName, manifestPath, ghRepo, targetTag); err != nil { + fmt.Fprintf(os.Stderr, " ERROR %s — verify capabilities: %v\n", pluginName, err) + mismatches++ + continue + } + fmt.Printf(" OK %s capabilities verified against %s (%s/%s)\n", pluginName, targetTag, runtime.GOOS, runtime.GOARCH) } } @@ -247,6 +256,7 @@ func releaseExists(ghRepo, tag string) bool { } type releaseAsset struct { + Name string `json:"name"` OS string `json:"os"` Arch string `json:"arch"` URL string `json:"url"` @@ -288,11 +298,143 @@ func releaseDownloads(ghRepo, tag string) ([]releaseAsset, error) { if !isKnownOS(os) || !isKnownArch(arch) { continue } - assets = append(assets, releaseAsset{OS: os, Arch: arch, URL: a.URL}) + assets = append(assets, releaseAsset{Name: a.Name, OS: os, Arch: arch, URL: a.URL}) } return assets, nil } +var ( + registrySyncReleaseDownloads = releaseDownloads + registrySyncDownloadReleaseAsset = downloadReleaseAsset + registrySyncVerifyManifest = verifyPluginManifestAgainstBinaryWithOptions +) + +func verifyRegistryPluginCapabilities(pluginName, manifestPath, ghRepo, tag string) error { + assets, err := registrySyncReleaseDownloads(ghRepo, tag) + if err != nil { + return fmt.Errorf("list release downloads for %s %s: %w", ghRepo, tag, err) + } + asset, ok := selectPlatformReleaseAsset(assets, runtime.GOOS, runtime.GOARCH) + if !ok { + return fmt.Errorf("no %s/%s release asset found for %s %s", runtime.GOOS, runtime.GOARCH, ghRepo, tag) + } + + tmpDir, err := os.MkdirTemp("", "wfctl-registry-sync-*") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + assetPath, err := registrySyncDownloadReleaseAsset(ghRepo, tag, asset.Name, tmpDir) + if err != nil { + return err + } + + searchDir := tmpDir + if isTarGz(assetPath) { + extractDir := filepath.Join(tmpDir, "extracted") + file, err := os.Open(assetPath) // #nosec G304 -- release asset downloaded to agent tempdir + if err != nil { + return err + } + if err := extractTarGzReader(file, extractDir); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + searchDir = extractDir + } + + binaryPath, err := locateRegistrySyncBinary(searchDir, pluginName, assetBinaryName(asset.Name)) + if err != nil { + return err + } + return registrySyncVerifyManifest(binaryPath, manifestPath, manifestCompareOptions{SkipName: true}) +} + +func selectPlatformReleaseAsset(assets []releaseAsset, goos, goarch string) (releaseAsset, bool) { + for _, asset := range assets { + if asset.OS == goos && asset.Arch == goarch { + return asset, true + } + } + return releaseAsset{}, false +} + +func downloadReleaseAsset(ghRepo, tag, assetName, dir string) (string, error) { + if assetName == "" { + return "", fmt.Errorf("release asset name is empty") + } + cmd := exec.Command("gh", "release", "download", tag, "--repo", ghRepo, "--pattern", assetName, "--dir", dir, "--clobber") // #nosec G204 -- ghRepo+tag+assetName from trusted registry manifest/release metadata + if out, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("gh release download %s %s %s: %w: %s", ghRepo, tag, assetName, err, strings.TrimSpace(string(out))) + } + return filepath.Join(dir, assetName), nil +} + +func isTarGz(path string) bool { + return strings.HasSuffix(path, ".tar.gz") || strings.HasSuffix(path, ".tgz") +} + +func assetBinaryName(assetName string) string { + name := strings.TrimSuffix(assetName, ".tar.gz") + name = strings.TrimSuffix(name, ".tgz") + for _, sep := range []string{"-", "_"} { + parts := strings.Split(name, sep) + if len(parts) >= 3 && isKnownOS(parts[len(parts)-2]) && isKnownArch(parts[len(parts)-1]) { + return strings.Join(parts[:len(parts)-2], sep) + } + } + return name +} + +func locateRegistrySyncBinary(root string, names ...string) (string, error) { + wanted := map[string]bool{} + for _, name := range names { + if name == "" { + continue + } + wanted[name] = true + wanted[name+".exe"] = true + } + var candidates []string + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + base := filepath.Base(path) + if !wanted[base] { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + if info.Mode().IsRegular() && info.Mode()&0111 != 0 { + candidates = append(candidates, path) + } + return nil + }) + if err != nil { + return "", err + } + sort.Strings(candidates) + if len(candidates) == 0 { + var requested []string + for name := range wanted { + requested = append(requested, name) + } + sort.Strings(requested) + return "", fmt.Errorf("no executable matching %v found under %s", requested, root) + } + return candidates[0], nil +} + func isKnownOS(s string) bool { switch s { case "linux", "darwin", "windows": diff --git a/cmd/wfctl/plugin_registry_sync_test.go b/cmd/wfctl/plugin_registry_sync_test.go index 4002caa1..0472580e 100644 --- a/cmd/wfctl/plugin_registry_sync_test.go +++ b/cmd/wfctl/plugin_registry_sync_test.go @@ -1,6 +1,13 @@ package main import ( + "archive/tar" + "bytes" + "compress/gzip" + "errors" + "os" + "path/filepath" + "runtime" "strings" "testing" ) @@ -140,3 +147,159 @@ func TestPluginRegistrySync_UsageHelp(t *testing.T) { t.Logf("non-help error from --help (may be OK): %v", err) } } + +func TestPluginRegistrySync_SelectPlatformReleaseAsset(t *testing.T) { + assets := []releaseAsset{ + {Name: "workflow-plugin-foo-linux-amd64.tar.gz", OS: "linux", Arch: "amd64", URL: "linux-amd64"}, + {Name: "workflow-plugin-foo-darwin-arm64.tar.gz", OS: "darwin", Arch: "arm64", URL: "darwin-arm64"}, + {Name: "workflow-plugin-foo-linux-arm64.tar.gz", OS: "linux", Arch: "arm64", URL: "linux-arm64"}, + } + + got, ok := selectPlatformReleaseAsset(assets, "linux", "arm64") + if !ok { + t.Fatal("expected linux/arm64 asset to be selected") + } + if got.Name != "workflow-plugin-foo-linux-arm64.tar.gz" { + t.Fatalf("selected asset = %q, want linux arm64 tarball", got.Name) + } + + if _, ok := selectPlatformReleaseAsset(assets, "windows", "amd64"); ok { + t.Fatal("unexpected asset for missing windows/amd64 platform") + } +} + +func TestPluginRegistrySync_LocateExtractedBinary(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "workflow-plugin-foo") + if err := os.WriteFile(bin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + if runtime.GOOS == "windows" { + t.Skip("executable-bit lookup is POSIX-specific") + } + + got, err := locateRegistrySyncBinary(dir, "foo", "workflow-plugin-foo") + if err != nil { + t.Fatalf("locateRegistrySyncBinary returned error: %v", err) + } + if got != bin { + t.Fatalf("binary path = %q, want %q", got, bin) + } + + if _, err := locateRegistrySyncBinary(dir, "missing-plugin"); err == nil { + t.Fatal("expected missing binary error") + } +} + +func TestPluginRegistrySync_AssetBinaryName(t *testing.T) { + cases := map[string]string{ + "workflow-plugin-github-darwin-arm64.tar.gz": "workflow-plugin-github", + "workflow-plugin-foo_linux_amd64.tgz": "workflow-plugin-foo", + "custom-plugin": "custom-plugin", + } + for in, want := range cases { + if got := assetBinaryName(in); got != want { + t.Fatalf("assetBinaryName(%q) = %q, want %q", in, got, want) + } + } +} + +func TestPluginRegistrySync_VerifyCapabilitiesDownloadsExtractsAndSkipsName(t *testing.T) { + restoreRegistrySyncTestHooks(t) + + dir := t.TempDir() + assetName := "workflow-plugin-foo-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" + assetPath := filepath.Join(dir, assetName) + if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"name":"foo"}`), 0o644); err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(dir, "plugin.json") + if err := writeTestTarGz(assetPath, "archive/workflow-plugin-foo", []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + + registrySyncReleaseDownloads = func(ghRepo, tag string) ([]releaseAsset, error) { + if ghRepo != "owner/repo" || tag != "v1.2.3" { + t.Fatalf("releaseDownloads args = %q %q, want owner/repo v1.2.3", ghRepo, tag) + } + return []releaseAsset{{Name: assetName, OS: runtime.GOOS, Arch: runtime.GOARCH}}, nil + } + registrySyncDownloadReleaseAsset = func(ghRepo, tag, name, targetDir string) (string, error) { + if name != assetName { + t.Fatalf("download asset name = %q, want %q", name, assetName) + } + if targetDir == "" { + t.Fatal("download target dir is empty") + } + return assetPath, nil + } + + var verifyCalled bool + registrySyncVerifyManifest = func(binary, manifest string, opts manifestCompareOptions) error { + verifyCalled = true + if filepath.Base(binary) != "workflow-plugin-foo" { + t.Fatalf("binary = %q, want extracted workflow-plugin-foo", binary) + } + if manifest != manifestPath { + t.Fatalf("manifest = %q, want %q", manifest, manifestPath) + } + if !opts.SkipName { + t.Fatal("registry verification must skip strict manifest name comparison") + } + return nil + } + + if err := verifyRegistryPluginCapabilities("foo", manifestPath, "owner/repo", "v1.2.3"); err != nil { + t.Fatalf("verifyRegistryPluginCapabilities returned error: %v", err) + } + if !verifyCalled { + t.Fatal("expected registrySyncVerifyManifest to be called") + } +} + +func TestPluginRegistrySync_VerifyCapabilitiesDownloadError(t *testing.T) { + restoreRegistrySyncTestHooks(t) + + registrySyncReleaseDownloads = func(string, string) ([]releaseAsset, error) { + return []releaseAsset{{Name: "workflow-plugin-foo-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz", OS: runtime.GOOS, Arch: runtime.GOARCH}}, nil + } + registrySyncDownloadReleaseAsset = func(string, string, string, string) (string, error) { + return "", errors.New("download failed") + } + + err := verifyRegistryPluginCapabilities("foo", filepath.Join(t.TempDir(), "plugin.json"), "owner/repo", "v1.2.3") + if err == nil || !strings.Contains(err.Error(), "download failed") { + t.Fatalf("error = %v, want download failure", err) + } +} + +func restoreRegistrySyncTestHooks(t *testing.T) { + t.Helper() + oldReleaseDownloads := registrySyncReleaseDownloads + oldDownloadReleaseAsset := registrySyncDownloadReleaseAsset + oldVerifyManifest := registrySyncVerifyManifest + t.Cleanup(func() { + registrySyncReleaseDownloads = oldReleaseDownloads + registrySyncDownloadReleaseAsset = oldDownloadReleaseAsset + registrySyncVerifyManifest = oldVerifyManifest + }) +} + +func writeTestTarGz(path, name string, data []byte, mode int64) error { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: mode, Size: int64(len(data))}); err != nil { + return err + } + if _, err := tw.Write(data); err != nil { + return err + } + if err := tw.Close(); err != nil { + return err + } + if err := gw.Close(); err != nil { + return err + } + return os.WriteFile(path, buf.Bytes(), 0o644) +} diff --git a/cmd/wfctl/plugin_verify_capabilities.go b/cmd/wfctl/plugin_verify_capabilities.go index ee743568..0ad6dd2e 100644 --- a/cmd/wfctl/plugin_verify_capabilities.go +++ b/cmd/wfctl/plugin_verify_capabilities.go @@ -72,33 +72,49 @@ Options: return fmt.Errorf("exactly one argument required") } pluginDir := fs.Arg(0) - if err := preflightBinary(*binary); err != nil { + abs, err := filepath.Abs(pluginDir) + if err != nil { + return fmt.Errorf("resolve %q: %w", pluginDir, err) + } + return verifyPluginManifestAgainstBinary(*binary, filepath.Join(abs, "plugin.json")) +} + +func verifyPluginManifestAgainstBinary(binary, manifestPath string) error { + return verifyPluginManifestAgainstBinaryWithOptions(binary, manifestPath, manifestCompareOptions{}) +} + +type manifestCompareOptions struct { + SkipName bool +} + +func verifyPluginManifestAgainstBinaryWithOptions(binary, manifestPath string, opts manifestCompareOptions) error { + if err := preflightBinary(binary); err != nil { return err } - abs, err := filepath.Abs(pluginDir) + absManifestPath, err := filepath.Abs(manifestPath) if err != nil { - return fmt.Errorf("resolve %q: %w", pluginDir, err) + return fmt.Errorf("resolve %q: %w", manifestPath, err) } - manifestPath := filepath.Join(abs, "plugin.json") + manifestPath = absManifestPath manifestBytes, err := os.ReadFile(manifestPath) //nolint:gosec // operator-supplied path. if err != nil { - return fmt.Errorf("plugin.json: %w", err) + return fmt.Errorf("%s: %w", filepath.Base(manifestPath), err) } var declared plugin.PluginManifest if err := json.Unmarshal(manifestBytes, &declared); err != nil { - return fmt.Errorf("plugin.json parse: %w", err) + return fmt.Errorf("%s parse: %w", filepath.Base(manifestPath), err) } if err := declared.Validate(); err != nil { - return fmt.Errorf("plugin.json validate: %w", err) + return fmt.Errorf("%s validate: %w", filepath.Base(manifestPath), err) } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - binAbs, err := filepath.Abs(*binary) + binAbs, err := filepath.Abs(binary) if err != nil { - return fmt.Errorf("resolve --binary %q: %w", *binary, err) + return fmt.Errorf("resolve --binary %q: %w", binary, err) } var stdout, stderr tailBuffer @@ -137,13 +153,7 @@ Options: return fmt.Errorf("GetManifest RPC: %w (stderr: %s)", err, stderr.String()) } - var failures []string - if runtime.GetName() != declared.Name { - failures = append(failures, fmt.Sprintf("name: plugin.json=%q; binary Manifest.Name=%q", declared.Name, runtime.GetName())) - } - if pass, reason := diffVersion(declared.Version, runtime.GetVersion()); !pass { - failures = append(failures, "version: "+reason) - } + failures := compareManifestWithRuntime(declared, runtime, opts) // Contract-diff (workflow#767). One new RPC after GetManifest. contractReg, regErr := pbClient.GetContractRegistry(ctx, &emptypb.Empty{}) @@ -185,6 +195,17 @@ Options: return nil } +func compareManifestWithRuntime(declared plugin.PluginManifest, runtime *pb.Manifest, opts manifestCompareOptions) []string { + var failures []string + if !opts.SkipName && runtime.GetName() != declared.Name { + failures = append(failures, fmt.Sprintf("name: declared manifest=%q; binary Manifest.Name=%q", declared.Name, runtime.GetName())) + } + if pass, reason := diffVersion(declared.Version, runtime.GetVersion()); !pass { + failures = append(failures, "version: "+reason) + } + return failures +} + // preflightBinary validates the --binary path before exec: // - non-empty + not literal "null" (guards against jq fallback returning empty) // - file exists and is a regular file (not directory) diff --git a/cmd/wfctl/plugin_verify_capabilities_test.go b/cmd/wfctl/plugin_verify_capabilities_test.go index c18fb294..d408fc7b 100644 --- a/cmd/wfctl/plugin_verify_capabilities_test.go +++ b/cmd/wfctl/plugin_verify_capabilities_test.go @@ -7,6 +7,9 @@ import ( "path/filepath" "strings" "testing" + + "github.com/GoCodeAlone/workflow/plugin" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" ) func TestVerifyCapabilitiesUsage(t *testing.T) { @@ -134,6 +137,21 @@ func TestDiffVersion(t *testing.T) { } } +func TestCompareManifestWithRuntimeCanSkipRegistryAliasName(t *testing.T) { + declared := plugin.PluginManifest{Name: "github", Version: "1.0.6"} + runtime := &pb.Manifest{Name: "workflow-plugin-github", Version: "v1.0.6"} + + strict := compareManifestWithRuntime(declared, runtime, manifestCompareOptions{}) + if len(strict) != 1 || !strings.Contains(strict[0], "name:") { + t.Fatalf("strict comparison failures = %v, want one name mismatch", strict) + } + + registry := compareManifestWithRuntime(declared, runtime, manifestCompareOptions{SkipName: true}) + if len(registry) != 0 { + t.Fatalf("registry comparison failures = %v, want none", registry) + } +} + // buildFixtureBinaryForVerify builds the fixture scenario in-place and emits // the binary to t.TempDir(). ldflag is the -X ...Version= value ("" = no flag, // which makes ResolveBuildVersion fall back to "(devel) [@ sha]" for fixtures diff --git a/docs/PLUGIN_RELEASE_GATES.md b/docs/PLUGIN_RELEASE_GATES.md index bd29e403..224c1d6b 100644 --- a/docs/PLUGIN_RELEASE_GATES.md +++ b/docs/PLUGIN_RELEASE_GATES.md @@ -156,6 +156,17 @@ particular, `type: "scaffold"` (used by `scaffold-workflow-plugin` + `scaffold-workflow-plugin-private`) is rejected to catch accidental re-registration of the scaffold repos as plugins. +**Defense in depth — runtime capability verification:** when +`--verify-capabilities` is set, registry-sync downloads the upstream release +asset for the current `GOOS/GOARCH`, extracts the plugin binary, and runs the +same runtime `GetManifest` check as `wfctl plugin verify-capabilities` against +the registry manifest. Registry aliases may use short names such as `github` +while the binary reports `workflow-plugin-github`, so this registry-side check +does not enforce strict name equality; the standalone +`wfctl plugin verify-capabilities` command remains strict for source-tree +`plugin.json` checks. This is intentionally slow and executes downloaded plugin +binaries; only use it in trusted registry maintenance environments. + ## Registry-side gate (defense in depth) `workflow-registry/scripts/sync-versions.sh` rejects ingest of any plugin whose upstream release tag is not strict-semver: diff --git a/docs/plans/2026-06-01-registry-sync-verify-capabilities.md b/docs/plans/2026-06-01-registry-sync-verify-capabilities.md new file mode 100644 index 00000000..a10140c7 --- /dev/null +++ b/docs/plans/2026-06-01-registry-sync-verify-capabilities.md @@ -0,0 +1,101 @@ +# Registry Sync Verify-Capabilities Implementation Plan + +> **For the implementing agent:** REQUIRED SUB-SKILL: Use autodev:executing-plans to implement this plan task-by-task. + +**Goal:** Implement the `wfctl plugin registry-sync --verify-capabilities` gap tracked by workflow#762. + +**Architecture:** Reuse `wfctl plugin verify-capabilities` by extracting a manifest-path helper, then have registry-sync download the current-platform release artifact, extract/locate the plugin binary, and verify it against the registry manifest. Keep the behavior additive and bounded to the existing registry-sync default mode. + +**Tech Stack:** Go 1.26, existing `gh` CLI release access, existing plugin gRPC spawn path, standard-library tar/gzip extraction. + +**Base branch:** main + +--- + +## Scope Manifest + +**PR Count:** 1 +**Tasks:** 3 +**Estimated Lines of Change:** ~250 + +**Out of scope:** +- Replacing workflow-registry's authoritative bash parity cycle. +- Layer 3b plugin-repo fanout from workflow#760. +- Full SemVer 2.0.0 prerelease support. +- Verifying non-current-platform release assets. + +**PR Grouping:** + +| PR # | Title | Tasks | Branch | +|------|-------|-------|--------| +| 1 | feat(wfctl): implement registry-sync capability verification | Task 1, Task 2, Task 3 | feat/762-registry-sync-verify-capabilities | + +**Status:** Draft + +## Global Design Guidance + +Source: `docs/AGENT_GUIDE.md`, `docs/PLUGIN_RELEASE_GATES.md`, `docs/plans/2026-05-23-wfctl-registry-sync.md`, `docs/plans/2026-05-24-verify-capabilities-design.md`. + +| guidance | design response | +|---|---| +| Prefer focused tests first | Add failing tests for the missing flag behavior and helper selection before production edits. | +| Update docs for CLI behavior | Update `docs/PLUGIN_RELEASE_GATES.md` registry-sync section. | +| Keep plugin contracts centralized in wfctl | Reuse `verify-capabilities`; do not duplicate manifest diff rules. | +| Runtime binary execution is security-sensitive | Keep explicit warning in docs and execute only release artifacts selected by current OS/arch. | + +## Security Review + +`--verify-capabilities` executes downloaded plugin binaries. This is already the posture of `wfctl plugin verify-capabilities`; registry-sync must document the same trust boundary and use `gh release download` against the manifest repository/tag rather than unauthenticated ad hoc URL execution. + +## Infrastructure Impact + +No cloud resources, migrations, or deployment changes. The command performs network access to GitHub releases and writes only when existing `--fix` is supplied for manifest drift. + +## Multi-Component Validation + +Focused unit tests cover command behavior and artifact selection. A smoke command against `workflow-registry` with a single plugin verifies the command path reaches GitHub release metadata without requiring a full registry sweep. + +## Assumptions + +- Registry manifests contain plugin-manifest-compatible fields plus extra registry metadata. +- Plugin release assets include a current-platform tarball named with parseable OS/arch suffix. +- Registry aliases may be shorter than runtime plugin names; registry-side verification checks runtime version/contract freshness and intentionally skips strict name equality. +- `gh` is available in registry-sync environments, as it already is for the existing subcommand. + +## Rollback + +Revert the PR. Existing registry-sync behavior without `--verify-capabilities` is unchanged; callers not using the flag are unaffected. + +### Task 1: Tests + +**Files:** +- Modify: `cmd/wfctl/plugin_registry_sync_test.go` + +**Steps:** +1. Add a test that `verifyCapabilitiesForRegistryPlugin` is invoked when `--verify-capabilities` is requested and returns errors instead of printing a stub note. +2. Add a test for selecting the current-platform asset from release assets. +3. Run `GOWORK=off go test ./cmd/wfctl -run 'TestPluginRegistrySync' -count=1`; expected RED because production helpers do not exist / flag is still stubbed. + +### Task 2: Implementation + +**Files:** +- Modify: `cmd/wfctl/plugin_verify_capabilities.go` +- Modify: `cmd/wfctl/plugin_registry_sync.go` + +**Steps:** +1. Extract `verifyPluginManifestAgainstBinary(binary, manifestPath string) error` from `runPluginVerifyCapabilities`. +2. Add current-platform release asset selection. +3. Add `gh release download` + tar extraction + executable discovery. +4. Replace the stub note with real verification. +5. Run focused tests; expected PASS. + +### Task 3: Docs and Verification + +**Files:** +- Modify: `docs/PLUGIN_RELEASE_GATES.md` + +**Steps:** +1. Document what `--verify-capabilities` now does and the execution trust boundary. +2. Run `GOWORK=off go test ./cmd/wfctl -run 'TestPluginRegistrySync|TestPluginVerifyCapabilities' -count=1`. +3. Run `GOWORK=off go test ./cmd/wfctl -count=1`. +4. Run `GOWORK=off golangci-lint run ./cmd/wfctl` if available.