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

@@ -58,18 +58,21 @@ def test_registry_migrates_equipment_to_machines(tmp_path):
# --- Manager enable/disable dependency guards + permission seeding -----------
def _write_plugin(plugins_dir, name, deps=None, permissions=None):
def _write_plugin(plugins_dir, name, deps=None, permissions=None, tier=None):
"""Write a minimal valid synthetic plugin (manifest.json + plugin.py)."""
pdir = plugins_dir / name
pdir.mkdir()
(pdir / 'manifest.json').write_text(json.dumps({
manifest = {
'name': name,
'version': '1.0.0',
'description': f'synthetic {name}',
'dependencies': deps or [],
'core_version': '>=0.1.0,<1.0.0',
'api_prefix': f'/api/{name}',
}))
}
if tier:
manifest['tier'] = tier
(pdir / 'manifest.json').write_text(json.dumps(manifest))
(pdir / '__init__.py').write_text('')
(pdir / 'plugin.py').write_text(
'from shopdb.plugins.base import BasePlugin, PluginMeta\n\n'
@@ -286,6 +289,28 @@ def test_validate_bundled_plugin_passes(app):
assert 'valid' in result.output
def test_core_tier_plugin_cannot_be_disabled_or_uninstalled(app, db, tmp_path):
"""A tier=core plugin refuses disable and uninstall (ADR-013)."""
plugins_dir = tmp_path / 'plugins'
plugins_dir.mkdir()
_write_plugin(plugins_dir, 'kernel', tier='core')
registry = PluginRegistry(tmp_path / 'plugins.json')
registry.register('kernel', '1.0.0', enabled=True)
pm = PluginManager()
pm._app = app
pm._db = db
pm.registry = registry
pm.loader = PluginLoader(plugins_dir, registry)
pm.migration_manager = None
assert pm.disable_plugin('kernel') is False
assert pm.registry.is_enabled('kernel')
assert pm.uninstall_plugin('kernel') is False
assert pm.registry.is_installed('kernel')
# --- Seed idempotency -------------------------------------------------------
def test_seed_settings_is_idempotent(app, db):