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:
@@ -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)
|
||||
Reference in New Issue
Block a user