test(supply-chain): TDD spec for plugin supply-chain hardening (#768)

Adds platform/internal/plugins/supply_chain_test.go with 8 tests (7 from
the spec + 1 end-to-end combo) specifying both security controls.

Control 1 — SHA256 content integrity (tests 1-3 + end-to-end):
  Tests call VerifyManifestIntegrity(stagedDir string) error, which does
  NOT exist yet → 5 compile errors / build failure until supply_chain.go
  is written. Once stubbed to nil, SHA256Mismatch test fails at runtime.

  VerifyManifestIntegrity contract:
    - manifest.json absent → nil (backward compat)
    - manifest.json present, no sha256 field → nil (backward compat)
    - sha256 matches computed stagedDirDigest → nil
    - sha256 mismatch → error mentioning "sha256"

  stagedDirDigest algorithm (canonical, test + impl must agree):
    Walk all files except manifest.json, sorted by rel path,
    format each as "<rel>\x00<content>", concatenate, SHA256, hex.

Control 2 — Pinned-ref enforcement (tests 4-7):
  Tests call GithubResolver.Fetch with/without "#ref" fragment.
  Currently returns nil for bare refs → TestPluginInstall_UnpinnedRef_Rejected
  fails (GitRunner IS called; no "pinned ref" in error message).
  PLUGIN_ALLOW_UNPINNED=true escape hatch tested by test 7.

RED state summary (current):
  go test ./internal/plugins/... -v -run TestPluginInstall
  → build failed: 5× undefined: VerifyManifestIntegrity
  → (with no-op stub) 2 runtime failures:
       FAIL TestPluginInstall_SHA256Mismatch_AbortsInstall
       FAIL TestPluginInstall_UnpinnedRef_Rejected

Backend Engineer implementation checklist:
  [ ] Add supply_chain.go in package plugins with VerifyManifestIntegrity
  [ ] Add pinned-ref gate to GithubResolver.Fetch in github.go
  [ ] PLUGIN_ALLOW_UNPINNED=true check skips the gate
  [ ] All 8 tests GREEN before merge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Molecule AI QA Engineer 2026-04-17 16:41:32 +00:00
parent 350288f186
commit c964210e88

View File

@ -0,0 +1,368 @@
package plugins
// TDD specification for plugin supply-chain hardening — issue #768.
//
// Two security controls are being added to github.go and a new
// supply_chain.go (or plugins_install_pipeline.go):
//
// 1. SHA256 content-integrity: after fetching a plugin, if the staged
// directory contains a manifest.json with a "sha256" field, that field
// must match the computed hash of the staged tree. A mismatch aborts
// install before any files reach a workspace.
//
// 2. Pinned-ref enforcement: GithubResolver.Fetch rejects bare
// "org/repo" specs that carry no "#tag" or "#sha" fragment. Only
// pinned refs ("org/repo#v1.2.3", "org/repo#abc1234") are accepted.
// PLUGIN_ALLOW_UNPINNED=true skips this check for local dev.
//
// All tests in this file are intentionally RED:
// - TestPluginInstall_SHA256* → compile error: VerifyManifestIntegrity
// is not yet defined in this package.
// - TestPluginInstall_Unpinned* → runtime assertion failure: GithubResolver
// currently accepts bare refs without error.
// - TestPluginInstall_Pinned* → runtime pass (already green before impl).
//
// Backend Engineer: implement VerifyManifestIntegrity in a new
// supply_chain.go (package plugins) and add the pinned-ref gate to
// GithubResolver.Fetch in github.go. All 7 tests must be GREEN before merge.
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
// ──────────────────────────────────────────────────────────────────────────────
// Test helpers — canonical hash shared by tests and the implementation
// ──────────────────────────────────────────────────────────────────────────────
// stagedDirDigest computes the canonical SHA256 that VerifyManifestIntegrity
// uses to validate staged plugin content. Algorithm:
//
// 1. Walk all regular files in dir, skipping "manifest.json" itself.
// 2. For each file, build the string "<rel-path>\x00<file-content>".
// 3. Sort the strings lexicographically by relative path.
// 4. Concatenate and SHA256-hash the result.
// 5. Return the lower-case hex digest.
//
// The implementation MUST use this same algorithm so tests are deterministic.
// The choice of a sorted walk over individual file hashes avoids sensitivity
// to filesystem entry ordering across operating systems.
func stagedDirDigest(t *testing.T, dir string) string {
t.Helper()
var entries []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if info.IsDir() {
return nil
}
rel, err := filepath.Rel(dir, path)
if err != nil {
return err
}
// Exclude the manifest itself — it is the verifier, not the verified.
if rel == "manifest.json" {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return err
}
entries = append(entries, rel+"\x00"+string(content))
return nil
})
if err != nil {
t.Fatalf("stagedDirDigest: walk error: %v", err)
}
sort.Strings(entries)
sum := sha256.Sum256([]byte(strings.Join(entries, "")))
return hex.EncodeToString(sum[:])
}
// writeManifestJSON writes {"sha256": digest} to dir/manifest.json.
func writeManifestJSON(t *testing.T, dir, digest string) {
t.Helper()
data, err := json.Marshal(map[string]string{"sha256": digest})
if err != nil {
t.Fatalf("writeManifestJSON: marshal: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "manifest.json"), data, 0o600); err != nil {
t.Fatalf("writeManifestJSON: write: %v", err)
}
}
// writeStagedPlugin writes a minimal but realistic plugin tree to dir.
func writeStagedPlugin(t *testing.T, dir string) {
t.Helper()
files := map[string]string{
"plugin.yaml": "name: test-plugin\nversion: 1.0.0\ndescription: supply chain test\n",
"rules/guidelines.md": "# Plugin Guidelines\nFollow the rules.\n",
"skills/helper/SKILL.md": "---\nid: helper\nname: Helper\ndescription: does stuff\n---\n",
}
for relPath, content := range files {
full := filepath.Join(dir, relPath)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("writeStagedPlugin: mkdir %s: %v", filepath.Dir(full), err)
}
if err := os.WriteFile(full, []byte(content), 0o600); err != nil {
t.Fatalf("writeStagedPlugin: write %s: %v", relPath, err)
}
}
}
// stubGitSuccess returns a GitRunner that creates the target directory and
// returns nil (simulating a successful shallow clone). Does NOT write any
// repo content — tests that need files should write them into dst separately.
func stubGitSuccess() func(ctx context.Context, dir string, args ...string) error {
return func(ctx context.Context, dir string, args ...string) error {
if len(args) == 0 {
return fmt.Errorf("stubGitSuccess: no args")
}
target := args[len(args)-1]
return os.MkdirAll(target, 0o755)
}
}
// ──────────────────────────────────────────────────────────────────────────────
// SHA256 content-integrity tests (#768 Control 1)
//
// These tests call VerifyManifestIntegrity, which does not yet exist in this
// package. They will cause a COMPILE ERROR (build failure) until the Backend
// Engineer adds supply_chain.go with the following exported signature:
//
// func VerifyManifestIntegrity(stagedDir string) error
//
// Behaviour contract:
// - manifest.json absent → nil (backward compat)
// - manifest.json present, no sha256 field → nil (backward compat)
// - sha256 field matches computed digest → nil
// - sha256 field doesn't match → non-nil error
// ──────────────────────────────────────────────────────────────────────────────
// TestPluginInstall_SHA256Match_Succeeds verifies that when manifest.json
// carries the correct sha256 of the staged tree, VerifyManifestIntegrity
// returns nil and install is allowed to proceed.
func TestPluginInstall_SHA256Match_Succeeds(t *testing.T) {
dir := t.TempDir()
writeStagedPlugin(t, dir)
// Compute the canonical digest of the staged files, then write a
// manifest.json that claims exactly that digest (correct attestation).
digest := stagedDirDigest(t, dir)
writeManifestJSON(t, dir, digest)
// VerifyManifestIntegrity is defined in the not-yet-written supply_chain.go.
// This line causes a compile error until the implementation exists.
if err := VerifyManifestIntegrity(dir); err != nil {
t.Errorf("expected nil error when SHA256 matches: got %v", err)
}
}
// TestPluginInstall_SHA256Mismatch_AbortsInstall verifies that when
// manifest.json carries the WRONG sha256, VerifyManifestIntegrity returns
// a non-nil error. No files should be staged (the pipeline must abort before
// deliverToContainer).
func TestPluginInstall_SHA256Mismatch_AbortsInstall(t *testing.T) {
dir := t.TempDir()
writeStagedPlugin(t, dir)
// Write a manifest.json with a deliberately wrong digest.
writeManifestJSON(t, dir, "0000000000000000000000000000000000000000000000000000000000000000")
err := VerifyManifestIntegrity(dir) // compile error until supply_chain.go exists
if err == nil {
t.Error("expected non-nil error when SHA256 mismatches, got nil — " +
"a tampered/corrupted plugin must not be staged")
}
// The error message must be informative enough for operators.
if err != nil && !strings.Contains(strings.ToLower(err.Error()), "sha256") {
t.Errorf("error must mention 'sha256', got: %v", err)
}
}
// TestPluginInstall_SHA256Missing_Skips_Check verifies backward compatibility:
// when manifest.json is absent (or present but has no sha256 field), the check
// is skipped and VerifyManifestIntegrity returns nil. This preserves install
// behaviour for plugins that pre-date the supply-chain hardening.
func TestPluginInstall_SHA256Missing_Skips_Check(t *testing.T) {
t.Run("no manifest.json", func(t *testing.T) {
dir := t.TempDir()
writeStagedPlugin(t, dir)
// No manifest.json at all — check must be skipped.
if err := VerifyManifestIntegrity(dir); err != nil { // compile error until impl
t.Errorf("no manifest.json → expected nil error, got %v", err)
}
})
t.Run("manifest.json without sha256 field", func(t *testing.T) {
dir := t.TempDir()
writeStagedPlugin(t, dir)
// Write a manifest.json that has other metadata but no sha256 key.
data, _ := json.Marshal(map[string]string{
"name": "test-plugin",
"version": "1.0.0",
})
if err := os.WriteFile(filepath.Join(dir, "manifest.json"), data, 0o600); err != nil {
t.Fatal(err)
}
if err := VerifyManifestIntegrity(dir); err != nil { // compile error until impl
t.Errorf("manifest.json without sha256 → expected nil error, got %v", err)
}
})
}
// ──────────────────────────────────────────────────────────────────────────────
// Pinned-ref enforcement tests (#768 Control 2)
//
// GithubResolver.Fetch currently accepts bare "org/repo" specs (no "#ref").
// After the implementation adds the pinned-ref gate to github.go, bare refs
// must be rejected with an error whose message contains "pinned ref".
//
// RED state: TestPluginInstall_UnpinnedRef_Rejected and
// TestPluginInstall_UnpinnedRef_AllowedByEnvVar will both fail at
// runtime because GithubResolver.Fetch currently returns nil for
// bare refs. TestPluginInstall_Pinned*_Accepted tests may already
// pass (positive case) but are included to pin the contract.
// ──────────────────────────────────────────────────────────────────────────────
// TestPluginInstall_UnpinnedRef_Rejected verifies that a bare GitHub spec
// without a "#ref" fragment ("org/repo") is rejected before any network
// activity. The error must mention "pinned ref" so operators understand the
// fix (add a tag or SHA to the install spec).
func TestPluginInstall_UnpinnedRef_Rejected(t *testing.T) {
// Ensure PLUGIN_ALLOW_UNPINNED is not set (the default production state).
t.Setenv("PLUGIN_ALLOW_UNPINNED", "")
r := &GithubResolver{
GitRunner: func(ctx context.Context, dir string, args ...string) error {
// If this is called, the pinned-ref gate did NOT fire — test failure.
t.Error("GitRunner must not be called for unpinned refs: " +
"the rejection must happen before any clone attempt")
return nil
},
BaseURL: "file:///dev/null",
}
_, err := r.Fetch(context.Background(), "org/repo", t.TempDir())
if err == nil {
t.Fatal("expected non-nil error for unpinned ref 'org/repo', got nil — " +
"bare GitHub refs must be rejected to prevent supply-chain drift")
}
if !strings.Contains(strings.ToLower(err.Error()), "pinned ref") {
t.Errorf("error must mention 'pinned ref' so operators know the fix; got: %v", err)
}
}
// TestPluginInstall_PinnedTagRef_Accepted verifies that a ref pinned to a
// semantic-version tag ("org/repo#v1.2.3") is accepted by the gate and
// passed through to git clone.
func TestPluginInstall_PinnedTagRef_Accepted(t *testing.T) {
t.Setenv("PLUGIN_ALLOW_UNPINNED", "")
r := &GithubResolver{
GitRunner: stubGit(map[string]string{
"plugin.yaml": "name: pinned-tag-plugin\nversion: 1.2.3\n",
}),
BaseURL: "file:///dev/null",
}
_, err := r.Fetch(context.Background(), "org/repo#v1.2.3", t.TempDir())
if err != nil {
t.Fatalf("pinned tag ref 'org/repo#v1.2.3' must be accepted: %v", err)
}
}
// TestPluginInstall_PinnedSHARef_Accepted verifies that a ref pinned to a
// full 40-char git SHA ("org/repo#abc1234...") is accepted by the gate.
// Partial SHAs (e.g. "abc1234") are also accepted — the gate only requires
// a non-empty fragment, not a canonical SHA length.
func TestPluginInstall_PinnedSHARef_Accepted(t *testing.T) {
t.Setenv("PLUGIN_ALLOW_UNPINNED", "")
fullSHA := "abc1234567890abcdef1234567890abcdef123456"
r := &GithubResolver{
GitRunner: stubGit(map[string]string{
"plugin.yaml": "name: pinned-sha-plugin\nversion: 0.0.1\n",
}),
BaseURL: "file:///dev/null",
}
_, err := r.Fetch(context.Background(), "org/repo#"+fullSHA, t.TempDir())
if err != nil {
t.Fatalf("pinned SHA ref must be accepted: %v", err)
}
}
// TestPluginInstall_UnpinnedRef_AllowedByEnvVar verifies that setting
// PLUGIN_ALLOW_UNPINNED=true bypasses the pinned-ref gate. This is the
// local-development escape hatch — it must never be set in production.
func TestPluginInstall_UnpinnedRef_AllowedByEnvVar(t *testing.T) {
t.Setenv("PLUGIN_ALLOW_UNPINNED", "true")
r := &GithubResolver{
GitRunner: stubGit(map[string]string{
"plugin.yaml": "name: dev-unpinned-plugin\nversion: 0.0.0-dev\n",
}),
BaseURL: "file:///dev/null",
}
// With the escape hatch enabled, the bare ref must be accepted.
_, err := r.Fetch(context.Background(), "org/repo", t.TempDir())
if err != nil {
t.Fatalf("unpinned ref must be accepted when PLUGIN_ALLOW_UNPINNED=true: %v", err)
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Contract pinning: SHA256 + pinned-ref together (#768 end-to-end)
// ──────────────────────────────────────────────────────────────────────────────
// TestPluginInstall_PinnedRef_And_ValidSHA256_Succeeds confirms that a
// correctly pinned ref combined with a matching sha256 is the fully
// hardened path that must succeed end-to-end.
func TestPluginInstall_PinnedRef_And_ValidSHA256_Succeeds(t *testing.T) {
t.Setenv("PLUGIN_ALLOW_UNPINNED", "")
dir := t.TempDir()
r := &GithubResolver{
GitRunner: func(ctx context.Context, cloneDir string, args ...string) error {
// Simulate clone: write plugin files to the clone target.
target := args[len(args)-1]
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
return os.WriteFile(
filepath.Join(target, "plugin.yaml"),
[]byte("name: hardened-plugin\nversion: 2.0.0\n"),
0o600,
)
},
BaseURL: "file:///dev/null",
}
// Fetch into dir with a pinned ref — pinned-ref gate must pass.
pluginName, err := r.Fetch(context.Background(), "org/repo#v2.0.0", dir)
if err != nil {
t.Fatalf("pinned-ref fetch failed: %v", err)
}
if pluginName == "" {
t.Error("expected non-empty plugin name")
}
// Now compute digest and verify SHA256 integrity — must also pass.
digest := stagedDirDigest(t, dir)
writeManifestJSON(t, dir, digest)
if err := VerifyManifestIntegrity(dir); err != nil { // compile error until impl
t.Errorf("expected nil for matching SHA256 on pinned-ref fetch: %v", err)
}
}