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
+5
View File
@@ -175,6 +175,11 @@ gnoma plugin install ./my-plugin # install from directory
gnoma plugin list # list installed plugins
```
Plugins are pinned by SHA-256 of their `plugin.json` on first load
(Trust-On-First-Use). A manifest that changes between runs is refused with a
clear error and a re-enrollment hint. See [docs/plugins-trust.md](docs/plugins-trust.md)
and [ADR-003](docs/essentials/decisions/003-plugin-trust.md).
---
## Session Persistence
+7 -1
View File
@@ -534,7 +534,13 @@ func main() {
logger.Warn("plugin discovery error", "error", err)
}
enabledSet := resolveEnabledPlugins(cfg.Plugins, discoveredPlugins)
pluginResult, err := pluginLoader.Load(discoveredPlugins, enabledSet)
pinStorePath := filepath.Join(gnomacfg.GlobalConfigDir(), "plugins.pins.toml")
pinStore, pinErr := plugin.NewFilePinStore(pinStorePath)
if pinErr != nil {
logger.Warn("plugin pin store unavailable; plugins will load without trust pinning", "error", pinErr)
pinStore = nil
}
pluginResult, err := pluginLoader.Load(discoveredPlugins, enabledSet, pinStore)
if err != nil {
logger.Warn("plugin load error", "error", err)
}
+6
View File
@@ -33,3 +33,9 @@ essentials:
| 10 | Milestones | complete | [milestones.md](milestones.md) | 2026-04-03 |
| 11 | Decision Log | complete | [decisions/001-initial-decisions.md](decisions/001-initial-decisions.md) | 2026-04-03 |
| 12 | Risk / Unknowns | complete | [risks.md](risks.md) | 2026-04-03 |
## Architecture Decision Records
- [ADR-001 — Initial Decisions](decisions/001-initial-decisions.md)
- [ADR-002 — SLM Routing](decisions/002-slm-routing.md)
- [ADR-003 — Plugin Trust via TOFU Manifest Pinning](decisions/003-plugin-trust.md)
@@ -0,0 +1,90 @@
# ADR-003: Plugin Trust via TOFU Manifest Pinning
**Status:** Accepted
**Date:** 2026-05-19
## Context
Plugins ship arbitrary code paths: they declare hook executables (run on every
matching tool call) and MCP server commands (long-lived subprocesses with
stdin/stdout protocol access). The plugin loader resolves these against the
plugin directory and exec's them with gnoma's full privileges.
Before this ADR, the loader had:
- Path traversal guards in the manifest (`checkSafePath`).
- An enabled/disabled allowlist by plugin name (`PluginsSection`).
- No integrity check on the manifest itself.
The gap: once a plugin sits in `~/.config/gnoma/plugins/` (placed there by the
user, a setup script, or any process with write access to the directory), its
manifest can be edited or replaced silently. A modified `plugin.json` can add
a new hook on a popular event, or swap the MCP command to point at a malicious
binary, and the next gnoma startup will load it with no signal to the user.
Audit finding C2 flagged this as the critical gap in the plugin trust model.
## Decision
The plugin loader records and verifies a SHA-256 of every enrolled plugin's
`plugin.json` bytes using a **Trust-On-First-Use** (TOFU) discipline. Pins are
persisted in `~/.config/gnoma/plugins.pins.toml`.
For every enabled plugin on every startup:
1. **No pin recorded** — compute the hash, write it to the pin store, log a
prominent warning naming the plugin and the hash. The plugin is loaded.
2. **Pin matches** — load silently.
3. **Pin mismatches** — refuse to load. Log an error with the pinned hash, the
actual hash, and a hint on re-enrollment. Other plugins continue loading.
The hash covers the entire file, not just selected fields. Any edit to the
manifest — including a version bump, a typo fix, or a new skill glob —
triggers re-enrollment. This is intentional: the user re-confirms trust on
every change to the trust surface.
A pin-store I/O failure (read or write) is logged and downgrades to
load-without-pinning so a corrupted file cannot lock the user out of plugins
they previously trusted.
## Alternatives Considered
### Alternative A: Cryptographic signatures
- **Pros:** Strong identity assertion; cross-machine portability of trust.
- **Cons:** Requires plugin authors to manage signing keys, a verification key
to be configured in gnoma, and a workflow to distribute fingerprints.
Overkill for a tool that targets developer workstations with a single user.
### Alternative B: Require explicit enrollment (no TOFU)
- **Pros:** No silent first-load; user explicitly opts into every plugin.
- **Cons:** Friction that punishes the common case (user puts a plugin in
their plugin dir on purpose, runs gnoma, expects it to work). The first-run
TOFU warning preserves the audit trail without blocking workflow.
### Alternative C: Hash only the executable bits
- **Pros:** Fewer benign re-enrollments on documentation tweaks.
- **Cons:** Skill globs, plugin name, and `gnoma_version` constraints all
influence what gnoma loads or how. Selecting a subset would create subtle
bypasses (e.g. moving an existing hook into a newly-added `Capabilities`
section the hash skipped). Whole-file hash has no such surface.
## Consequences
**Positive:**
- Tampering with an installed plugin's `plugin.json` between gnoma runs is
loud and refused, not silent.
- Every plugin's enrollment is auditable from a single TOML file.
- No external infrastructure (no PKI, no registry).
**Negative:**
- Legitimate plugin upgrades require a manual pin removal. For active plugin
development this is friction; the workaround is to keep development plugins
in a project directory (which still pins, but is throwaway per project).
- A user who ignores the TOFU warning gains nothing.
**Neutral:**
- Pin file lives next to the global config — same trust boundary as the
config itself. Compromising that directory already compromises gnoma.
+117
View File
@@ -0,0 +1,117 @@
# Plugin Trust
gnoma plugins ship arbitrary executables (hooks and MCP servers) that run with
your user privileges. To prevent silent tampering with an already-installed
plugin, gnoma pins each plugin's `plugin.json` by SHA-256 the first time it
loads, and refuses to load on subsequent runs if the manifest has changed.
This is the same Trust-On-First-Use (TOFU) discipline that SSH uses for host
keys, applied to plugin manifests.
## Pin file
```
~/.config/gnoma/plugins.pins.toml
```
Format:
```toml
[pins]
git-tools = "a3f1c5d8e9b2..."
docker-tools = "7c4b9f0e2a8d..."
```
The file is created the first time gnoma loads any plugin. It is owned by the
same trust boundary as `~/.config/gnoma/config.toml` — anyone who can write
to your config directory can also re-pin plugins.
## First load (TOFU warning)
When gnoma sees a plugin it has no pin for, it records the hash automatically
and logs a warning that names the plugin and the hash:
```
WARN enrolling new plugin (trust on first use) name=git-tools scope=user
sha256=a3f1c5d8e9b2c7f0...
```
The plugin loads normally. If this is the plugin you intended to install, you
can ignore the warning. If it appeared unexpectedly, inspect the plugin
directory and the matching `plugins.pins.toml` entry before running gnoma
again.
## Subsequent loads
- **Match:** silent. The plugin loads with no log line.
- **Mismatch:** the plugin is refused. gnoma logs an error and **does not**
overwrite the pin:
```
ERROR refusing plugin — manifest changed since enrolment name=git-tools
pinned=a3f1c5d8... actual=b9e4d2c1...
hint="remove the entry from plugins.pins.toml to re-enrol"
```
Other plugins are unaffected.
## Re-enrolling a plugin
When you legitimately update a plugin and want gnoma to accept the new
manifest:
1. Inspect the changes (`git diff`, manifest diff, or just review the new
`plugin.json`). Confirm the update is what you expected.
2. Delete the plugin's line from `plugins.pins.toml`.
3. Run gnoma. The new hash is recorded as a fresh TOFU enrollment and a
warning is logged.
If you trust the source and want to skip the inspect-and-delete step, you can
overwrite the pin directly: replace the hex value in the file with the hash
gnoma logged for the new manifest. (gnoma prints the actual hash in the
mismatch error.)
## Disabling pinning
There is no opt-out. If the pin file cannot be read or written (permissions,
disk full, etc.), gnoma logs a warning and loads plugins without pinning for
that run — but it does not silently disable the mechanism for subsequent
runs.
## What the hash covers
The full bytes of `plugin.json`. Any edit — version bump, comment, whitespace
— invalidates the pin. This is intentional: the trust surface includes every
field the loader reads, and selectively hashing fields would create bypass
opportunities.
The hash does **not** cover the rest of the plugin directory (skill files,
hook scripts, MCP server binaries). Those are referenced by paths declared
in the manifest; tampering with a hook script while leaving `plugin.json`
untouched is **not** detected. Treat the plugin directory itself as a trust
boundary at filesystem-permissions level.
## Threat model
Pinning protects against:
- Silent tampering of an installed plugin's manifest by another process or
user with write access to the plugin directory.
- Accidental drift (e.g. `git pull` in a plugin you forgot was a working copy)
pulling in capabilities you didn't intend to grant.
Pinning does **not** protect against:
- The first install — TOFU trusts whatever is on disk at first sight.
- Tampering with the pin file itself by a process that already has write
access to your config directory.
- Tampering with hook scripts or MCP binaries that live alongside the
manifest (see "What the hash covers").
For the gnoma threat model (single-user dev workstation), pinning closes the
largest realistic gap: post-install manifest drift on a machine the user
shares with other processes or where a plugin source is a live working copy.
## See also
- ADR-003: [Plugin Trust via TOFU Manifest Pinning](essentials/decisions/003-plugin-trust.md)
+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)
}
}