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:
2026-05-19 16:44:09 +02:00
parent c44db99b41
commit dc438ea181
9 changed files with 546 additions and 12 deletions
+70 -7
View File
@@ -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
}
+80 -4
View File
@@ -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)
}
}
+81
View File
@@ -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
}
+90
View File
@@ -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)
}
}