feat(plugin): trust-on-first-use manifest pinning
Plugins are now verified against ~/.config/gnoma/plugins.pins.toml at load time. Each plugin's plugin.json bytes are hashed (SHA-256) and: - recorded automatically on first load (TOFU) with a prominent warning - compared on subsequent loads - refused with a clear error if the hash drifted, without overwriting the pin so the user can review and re-enrol deliberately Pin-store I/O failures degrade to load-without-pinning rather than locking the user out of previously-trusted plugins. Closes audit finding C2. See ADR-003 for the decision rationale and docs/plugins-trust.md for the end-user trust model.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -12,9 +14,10 @@ import (
|
||||
|
||||
// Plugin is a discovered, parsed plugin.
|
||||
type Plugin struct {
|
||||
Manifest Manifest
|
||||
Dir string // absolute path to plugin directory
|
||||
Scope string // "user" or "project"
|
||||
Manifest Manifest
|
||||
Dir string // absolute path to plugin directory
|
||||
Scope string // "user" or "project"
|
||||
ManifestBytes []byte // raw plugin.json bytes; used for trust pinning
|
||||
}
|
||||
|
||||
// SkillSource is a directory + source tag for skill.Registry.LoadDir.
|
||||
@@ -59,7 +62,19 @@ func (l *Loader) Discover(globalDir, projectDir string) ([]Plugin, error) {
|
||||
}
|
||||
|
||||
// Load processes enabled plugins and extracts their capabilities.
|
||||
func (l *Loader) Load(plugins []Plugin, enabledSet map[string]bool) (LoadResult, error) {
|
||||
//
|
||||
// If pins is non-nil, each plugin's manifest is hashed (SHA-256 over the raw
|
||||
// plugin.json bytes) and checked against the pin store:
|
||||
//
|
||||
// - Pin missing: TOFU — record the new hash, log a warning, load the plugin.
|
||||
// - Pin matches: load silently.
|
||||
// - Pin mismatches: skip the plugin and log an error. The user can remove
|
||||
// the offending pin to re-enroll.
|
||||
//
|
||||
// Pinning failures (file I/O) downgrade to load-without-pin and log a warning;
|
||||
// they never block startup, since a broken pin file shouldn't lock out plugins
|
||||
// that were previously working.
|
||||
func (l *Loader) Load(plugins []Plugin, enabledSet map[string]bool, pins PinStore) (LoadResult, error) {
|
||||
var result LoadResult
|
||||
|
||||
for _, p := range plugins {
|
||||
@@ -68,6 +83,10 @@ func (l *Loader) Load(plugins []Plugin, enabledSet map[string]bool) (LoadResult,
|
||||
continue
|
||||
}
|
||||
|
||||
if pins != nil && !l.verifyPin(p, pins) {
|
||||
continue
|
||||
}
|
||||
|
||||
l.logger.Debug("loading plugin", "name", p.Manifest.Name, "scope", p.Scope)
|
||||
|
||||
// Skills: resolve glob directories.
|
||||
@@ -143,9 +162,10 @@ func (l *Loader) scanDir(dir, scope string, byName map[string]Plugin) {
|
||||
}
|
||||
|
||||
byName[manifest.Name] = Plugin{
|
||||
Manifest: *manifest,
|
||||
Dir: pluginDir,
|
||||
Scope: scope,
|
||||
Manifest: *manifest,
|
||||
Dir: pluginDir,
|
||||
Scope: scope,
|
||||
ManifestBytes: data,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,3 +174,46 @@ func (l *Loader) scanDir(dir, scope string, byName map[string]Plugin) {
|
||||
func marshalJSON(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
// hashManifest returns the hex-encoded SHA-256 of the manifest bytes.
|
||||
func hashManifest(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// verifyPin enforces the TOFU trust contract on a single plugin.
|
||||
// Returns true if the plugin is cleared to load.
|
||||
func (l *Loader) verifyPin(p Plugin, pins PinStore) bool {
|
||||
if len(p.ManifestBytes) == 0 {
|
||||
// Synthetic plugin (e.g. constructed in tests without going through scanDir).
|
||||
// Treat as trusted; the surface that matters has its own coverage.
|
||||
return true
|
||||
}
|
||||
actual := hashManifest(p.ManifestBytes)
|
||||
pinned, hasPin := pins.Get(p.Manifest.Name)
|
||||
if !hasPin {
|
||||
l.logger.Warn("enrolling new plugin (trust on first use)",
|
||||
"name", p.Manifest.Name,
|
||||
"scope", p.Scope,
|
||||
"sha256", actual,
|
||||
)
|
||||
if err := pins.Set(p.Manifest.Name, actual); err != nil {
|
||||
l.logger.Warn("failed to persist plugin pin; will re-enrol next run",
|
||||
"name", p.Manifest.Name,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if pinned != actual {
|
||||
l.logger.Error("refusing plugin — manifest changed since enrolment",
|
||||
"name", p.Manifest.Name,
|
||||
"scope", p.Scope,
|
||||
"pinned", pinned,
|
||||
"actual", actual,
|
||||
"hint", "remove the entry from plugins.pins.toml to re-enrol",
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ func TestLoader_Load_AllEnabled(t *testing.T) {
|
||||
plugins, _ := loader.Discover(globalDir, filepath.Join(dir, "project"))
|
||||
|
||||
enabledSet := map[string]bool{"test-plugin": true}
|
||||
result, err := loader.Load(plugins, enabledSet)
|
||||
result, err := loader.Load(plugins, enabledSet, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -170,7 +170,7 @@ func TestLoader_Load_DisabledPlugin(t *testing.T) {
|
||||
plugins, _ := loader.Discover(globalDir, filepath.Join(dir, "project"))
|
||||
|
||||
// Plugin not in enabled set.
|
||||
result, err := loader.Load(plugins, map[string]bool{})
|
||||
result, err := loader.Load(plugins, map[string]bool{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func TestLoader_Load_HooksConverted(t *testing.T) {
|
||||
loader := NewLoader(testLogger())
|
||||
plugins, _ := loader.Discover(globalDir, filepath.Join(dir, "project"))
|
||||
|
||||
result, err := loader.Load(plugins, map[string]bool{"hook-plugin": true})
|
||||
result, err := loader.Load(plugins, map[string]bool{"hook-plugin": true}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -241,7 +241,7 @@ func TestLoader_Load_MCPServersConverted(t *testing.T) {
|
||||
loader := NewLoader(testLogger())
|
||||
plugins, _ := loader.Discover(globalDir, filepath.Join(dir, "project"))
|
||||
|
||||
result, err := loader.Load(plugins, map[string]bool{"mcp-plugin": true})
|
||||
result, err := loader.Load(plugins, map[string]bool{"mcp-plugin": true}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -260,3 +260,79 @@ func TestLoader_Load_MCPServersConverted(t *testing.T) {
|
||||
t.Errorf("Command = %q, want %q", s.Command, wantCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_Load_TOFU_RecordsPinOnFirstLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
globalDir := filepath.Join(dir, "global")
|
||||
writePlugin(t, globalDir, "newbie", "1.0.0", nil)
|
||||
|
||||
loader := NewLoader(testLogger())
|
||||
plugins, _ := loader.Discover(globalDir, filepath.Join(dir, "project"))
|
||||
|
||||
pins, _ := NewFilePinStore(filepath.Join(dir, "pins.toml"))
|
||||
result, err := loader.Load(plugins, map[string]bool{"newbie": true}, pins)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(result.Skills)+len(result.Hooks)+len(result.MCPServers) != 0 {
|
||||
// No capabilities declared, but plugin should still have been processed.
|
||||
}
|
||||
if _, ok := pins.Get("newbie"); !ok {
|
||||
t.Error("TOFU did not record a pin for the new plugin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_Load_MatchingPin_LoadsSilently(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
globalDir := filepath.Join(dir, "global")
|
||||
|
||||
caps := &Capabilities{Hooks: []HookSpec{{Name: "h", Event: "pre_tool_use", Type: "command", Exec: "h.sh"}}}
|
||||
writePlugin(t, globalDir, "trusted", "1.0.0", caps)
|
||||
|
||||
loader := NewLoader(testLogger())
|
||||
plugins, _ := loader.Discover(globalDir, filepath.Join(dir, "project"))
|
||||
|
||||
// First load enrols.
|
||||
pins, _ := NewFilePinStore(filepath.Join(dir, "pins.toml"))
|
||||
if _, err := loader.Load(plugins, map[string]bool{"trusted": true}, pins); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Second load with the same manifest must produce the same capability set.
|
||||
result, err := loader.Load(plugins, map[string]bool{"trusted": true}, pins)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(result.Hooks) != 1 {
|
||||
t.Fatalf("expected hook to be loaded on matching pin, got %d", len(result.Hooks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_Load_PinMismatch_RefusesPlugin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
globalDir := filepath.Join(dir, "global")
|
||||
|
||||
caps := &Capabilities{Hooks: []HookSpec{{Name: "h", Event: "pre_tool_use", Type: "command", Exec: "h.sh"}}}
|
||||
writePlugin(t, globalDir, "drifted", "1.0.0", caps)
|
||||
|
||||
loader := NewLoader(testLogger())
|
||||
plugins, _ := loader.Discover(globalDir, filepath.Join(dir, "project"))
|
||||
|
||||
// Seed a pin store with the WRONG hash to simulate manifest drift.
|
||||
pins, _ := NewFilePinStore(filepath.Join(dir, "pins.toml"))
|
||||
if err := pins.Set("drifted", "deadbeef"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := loader.Load(plugins, map[string]bool{"drifted": true}, pins)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(result.Hooks) != 0 {
|
||||
t.Errorf("plugin with mismatched pin must not contribute hooks, got %d", len(result.Hooks))
|
||||
}
|
||||
// The bad pin must be left in place so the user can review and decide.
|
||||
if h, _ := pins.Get("drifted"); h != "deadbeef" {
|
||||
t.Errorf("loader silently overwrote a mismatched pin: got %q", h)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// PinStore records the trusted SHA-256 of each enrolled plugin's manifest.
|
||||
// Implementations must be safe for the single-process startup flow used by
|
||||
// the loader; no concurrent Set calls are issued.
|
||||
type PinStore interface {
|
||||
Get(name string) (hash string, ok bool)
|
||||
Set(name, hash string) error
|
||||
}
|
||||
|
||||
// FilePinStore persists pins to a TOML file with a single [pins] table.
|
||||
type FilePinStore struct {
|
||||
path string
|
||||
pins map[string]string
|
||||
}
|
||||
|
||||
type pinsFile struct {
|
||||
Pins map[string]string `toml:"pins"`
|
||||
}
|
||||
|
||||
func NewFilePinStore(path string) (*FilePinStore, error) {
|
||||
s := &FilePinStore{path: path, pins: make(map[string]string)}
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pin file %q: %w", path, err)
|
||||
}
|
||||
var pf pinsFile
|
||||
if _, err := toml.Decode(string(data), &pf); err != nil {
|
||||
return nil, fmt.Errorf("decode pin file %q: %w", path, err)
|
||||
}
|
||||
if pf.Pins != nil {
|
||||
s.pins = pf.Pins
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *FilePinStore) Get(name string) (string, bool) {
|
||||
h, ok := s.pins[name]
|
||||
return h, ok
|
||||
}
|
||||
|
||||
func (s *FilePinStore) Set(name, hash string) error {
|
||||
s.pins[name] = hash
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return fmt.Errorf("create pin dir: %w", err)
|
||||
}
|
||||
// Atomic write: temp + rename so a crash mid-write doesn't corrupt the file.
|
||||
tmp, err := os.CreateTemp(filepath.Dir(s.path), "pins-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp pin file: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
enc := toml.NewEncoder(tmp)
|
||||
encErr := enc.Encode(pinsFile{Pins: s.pins})
|
||||
closeErr := tmp.Close()
|
||||
if encErr != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("encode pin file: %w", encErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("close temp pin file: %w", closeErr)
|
||||
}
|
||||
if err := os.Rename(tmpPath, s.path); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("rename pin file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFilePinStore_MissingFileIsEmpty(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pins.toml")
|
||||
s, err := NewFilePinStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFilePinStore on missing file: %v", err)
|
||||
}
|
||||
if _, ok := s.Get("anything"); ok {
|
||||
t.Error("empty store should not return any pins")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilePinStore_RoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pins.toml")
|
||||
s, err := NewFilePinStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Set("git-tools", "abc123"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
|
||||
// Re-open from disk: pin must persist.
|
||||
s2, err := NewFilePinStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := s2.Get("git-tools")
|
||||
if !ok {
|
||||
t.Fatal("pin missing after reload")
|
||||
}
|
||||
if got != "abc123" {
|
||||
t.Errorf("Get = %q, want abc123", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilePinStore_OverwriteExisting(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pins.toml")
|
||||
s, _ := NewFilePinStore(path)
|
||||
if err := s.Set("p", "v1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Set("p", "v2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := s.Get("p")
|
||||
if got != "v2" {
|
||||
t.Errorf("Get = %q, want v2 after overwrite", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilePinStore_PreservesOtherPinsOnWrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pins.toml")
|
||||
s, _ := NewFilePinStore(path)
|
||||
if err := s.Set("a", "h1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Set("b", "h2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s2, _ := NewFilePinStore(path)
|
||||
if h, _ := s2.Get("a"); h != "h1" {
|
||||
t.Errorf("Get(a) = %q, want h1", h)
|
||||
}
|
||||
if h, _ := s2.Get("b"); h != "h2" {
|
||||
t.Errorf("Get(b) = %q, want h2", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilePinStore_CreatesParentDir(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nested", "deeper", "pins.toml")
|
||||
s, err := NewFilePinStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Set("x", "h"); err != nil {
|
||||
t.Fatalf("Set into nested dir: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Errorf("pin file missing: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user