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
+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)
}
}