ADR-013 Phase 2: enforcement + signed shelf + adopt

Completes the marketplace security model. Verification stops being advisory:
a plugin only loads or migrates when its tree matches a trusted signature, and
plugins are pulled from a signed shelf with anti-rollback and revocation.

Enforcement (default OFF - existing deploys unchanged):
- verification.py PluginVerifier, shared by the loader (verify-at-load, before
  plugin.py is imported) and the migration manager (verify-at-migrate, before
  any DDL). Fail-closed: an unsigned/tampered/wrong-key plugin does not run.
- Gated by PLUGIN_REQUIRE_SIGNED. PLUGIN_DEV_TRUST_DIRS exempts named dirs but
  only under DEBUG/TESTING; production ignores it.
- flask plugin stamp-bundled writes provenance into in-tree plugins so
  verify-at-load applies to bundled plugins too (image build step).
- tier:core manifest guard: uninstall/disable refuse a core-tier plugin.

Shelf (shelf.py):
- Signed shelf-index.json (+ .sig): monotonic serial (a site refuses an older
  index - anti-rollback), revoked list carried across builds, per-entry
  version/tier/core_version for browse. Index is a browse layer only; adopt
  reads security-bearing fields from the verified artifact.
- flask plugin shelf-build / shelf-list / adopt / audit. adopt verifies index +
  artifact (signature + every file hash), unpacks to staging, re-verifies, then
  atomically moves into place and installs+enables the closure. Refuses a
  downgrade without --force-downgrade. Anti-rollback serial stored in
  instance/shelf-state.json.
- config PLUGIN_SHELF_DIR; the app only reads the folder, never speaks a
  network. .env.example + docs/PLUGIN-SIGNING.md document the flow.

22 tests: verifier policy (off / no-keys / signed / tampered / wrong-key /
dev-exempt), verify-at-load + verify-at-migrate integration, tier guard, index
sign/verify + tamper/wrong-key, serial state, revocation, version resolution,
verified atomic unpack + tamper refusal. Live-smoked keygen->pack->shelf-build
->list->adopt->audit + serial guard. 1050 pass, naming green.
This commit is contained in:
cproudlock
2026-07-18 20:44:54 -04:00
parent 86f5f1be68
commit 5b19f3b554
12 changed files with 1048 additions and 8 deletions

View File

@@ -39,6 +39,7 @@ class PluginManager:
self.registry: Optional[PluginRegistry] = None
self.loader: Optional[PluginLoader] = None
self.migration_manager: Optional[PluginMigrationManager] = None
self.verifier = None
self._app: Optional[Flask] = None
self._db = None
# API prefixes already claimed by a registered plugin blueprint, to
@@ -66,6 +67,19 @@ class PluginManager:
app.config.get('SQLALCHEMY_DATABASE_URI')
)
# One trust policy, shared by verify-at-load and verify-at-migrate.
from .verification import PluginVerifier
verifier = PluginVerifier(
plugins_dir,
require_signed=app.config.get('PLUGIN_REQUIRE_SIGNED', False),
trusted_key_paths=app.config.get('PLUGIN_TRUSTED_KEYS', []),
dev_trust_dirs=app.config.get('PLUGIN_DEV_TRUST_DIRS', []),
is_dev=bool(app.config.get('DEBUG') or app.config.get('TESTING')),
)
self.verifier = verifier
self.loader.verifier = verifier
self.migration_manager.verifier = verifier
# Load enabled plugins
self._load_enabled_plugins()
@@ -257,6 +271,18 @@ class PluginManager:
"Seeded %d permission(s) for plugin %s",
created, plugin.meta.name)
def _is_core_tier(self, name: str) -> bool:
"""True when the plugin's manifest marks it tier=core (mandatory).
A core-tier plugin refuses uninstall/disable so a site cannot remove a
plugin its deployment depends on. No plugin ships tier=core today; the
guard makes that a manifest edit, not a code change (ADR-013).
"""
try:
return self.loader.load_manifest(name).get('tier') == 'core'
except PluginError:
return False
def _installed_dependents(self, name: str,
enabled_only: bool = False) -> List[str]:
"""Installed plugins that declare `name` as a hard dependency.
@@ -293,6 +319,10 @@ class PluginManager:
logger.warning(f"Plugin {name} is not installed")
return False
if self._is_core_tier(name):
logger.error(f"Cannot uninstall {name}: it is a core-tier plugin")
return False
# Refuse if any INSTALLED plugin (enabled or not) depends on this one.
dependents = self._installed_dependents(name)
if dependents:
@@ -372,6 +402,10 @@ class PluginManager:
logger.info(f"Plugin {name} is already disabled")
return True
if self._is_core_tier(name):
logger.error(f"Cannot disable {name}: it is a core-tier plugin")
return False
# Refuse if any ENABLED plugin depends on this one (a disabled dependent
# is not running, so it does not block). Manifests read from disk.
dependents = self._installed_dependents(name, enabled_only=True)