Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
86 lines
2.4 KiB
Go
86 lines
2.4 KiB
Go
package plugins
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// VerifyManifestIntegrity checks the SHA256 content hash declared in
|
|
// manifest.json against the actual contents of stagedDir.
|
|
//
|
|
// Behaviour:
|
|
// - manifest.json absent → nil (backward compat with pre-#768 plugins)
|
|
// - manifest.json present, no sha256 → nil (same backward compat)
|
|
// - sha256 field matches digest → nil
|
|
// - sha256 field doesn't match → non-nil error
|
|
func VerifyManifestIntegrity(stagedDir string) error {
|
|
manifestPath := filepath.Join(stagedDir, "manifest.json")
|
|
|
|
data, err := os.ReadFile(manifestPath)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil // no manifest — backward compat, skip check
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("supply chain: read manifest.json: %w", err)
|
|
}
|
|
|
|
var manifest map[string]interface{}
|
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
|
return fmt.Errorf("supply chain: parse manifest.json: %w", err)
|
|
}
|
|
|
|
declaredRaw, ok := manifest["sha256"]
|
|
if !ok {
|
|
return nil // no sha256 field — backward compat
|
|
}
|
|
declared, ok := declaredRaw.(string)
|
|
if !ok {
|
|
return fmt.Errorf("supply chain: sha256 field must be a string")
|
|
}
|
|
|
|
computed := computeStagedDigest(stagedDir)
|
|
if !strings.EqualFold(declared, computed) {
|
|
return fmt.Errorf("supply chain: sha256 mismatch — declared %s, computed %s", declared, computed)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// computeStagedDigest computes the canonical SHA256 digest of a staged plugin
|
|
// directory. Algorithm:
|
|
// 1. Walk all regular files, skipping manifest.json itself.
|
|
// 2. For each file, build "<rel-path>\x00<content>".
|
|
// 3. Sort lexicographically by relative path.
|
|
// 4. Concatenate and SHA256-hash.
|
|
// 5. Return lower-case hex digest.
|
|
func computeStagedDigest(dir string) string {
|
|
var entries []string
|
|
_ = filepath.Walk(dir, func(path string, info os.FileInfo, walkErr error) error {
|
|
if walkErr != nil || info.IsDir() {
|
|
return walkErr
|
|
}
|
|
rel, err := filepath.Rel(dir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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
|
|
})
|
|
sort.Strings(entries)
|
|
sum := sha256.Sum256([]byte(strings.Join(entries, "")))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|