ADR-013 Phase 2: enforcement + signed shelf + adopt (adversarial-review hardened)

This commit is contained in:
2026-07-18 21:06:38 -04:00
parent 0ff5edc553
commit 77ca2e0d2d
14 changed files with 1234 additions and 22 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()
@@ -140,7 +154,14 @@ class PluginManager:
available = []
for name in self.loader.discover_plugins():
plugin_class = self.loader.load_plugin_class(name)
# Under enforcement load_plugin_class refuses an unverified plugin
# (it will not import it). Skip such a plugin from the listing
# instead of failing the whole call.
try:
plugin_class = self.loader.load_plugin_class(name)
except PluginError as e:
logger.warning(f"Skipping plugin {name} in listing: {e}")
continue
if plugin_class:
try:
temp = plugin_class()
@@ -257,6 +278,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 +326,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 +409,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)