diff --git a/README.md b/README.md index 8afdfe3..239c375 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/gnoma/main.go b/cmd/gnoma/main.go index 5aa82e1..a0cdb44 100644 --- a/cmd/gnoma/main.go +++ b/cmd/gnoma/main.go @@ -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) } diff --git a/docs/essentials/INDEX.md b/docs/essentials/INDEX.md index a674c06..fa90972 100644 --- a/docs/essentials/INDEX.md +++ b/docs/essentials/INDEX.md @@ -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) diff --git a/docs/essentials/decisions/003-plugin-trust.md b/docs/essentials/decisions/003-plugin-trust.md new file mode 100644 index 0000000..5d7b811 --- /dev/null +++ b/docs/essentials/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. diff --git a/docs/plugins-trust.md b/docs/plugins-trust.md new file mode 100644 index 0000000..d71392f --- /dev/null +++ b/docs/plugins-trust.md @@ -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) diff --git a/internal/plugin/loader.go b/internal/plugin/loader.go index 5ef6d02..44ed633 100644 --- a/internal/plugin/loader.go +++ b/internal/plugin/loader.go @@ -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 +} diff --git a/internal/plugin/loader_test.go b/internal/plugin/loader_test.go index 8e7c027..e413cd1 100644 --- a/internal/plugin/loader_test.go +++ b/internal/plugin/loader_test.go @@ -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) + } +} diff --git a/internal/plugin/pins.go b/internal/plugin/pins.go new file mode 100644 index 0000000..a4e81df --- /dev/null +++ b/internal/plugin/pins.go @@ -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 +} diff --git a/internal/plugin/pins_test.go b/internal/plugin/pins_test.go new file mode 100644 index 0000000..51ddd60 --- /dev/null +++ b/internal/plugin/pins_test.go @@ -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) + } +}