Plugin Framework Architecture
Verified against commit
942a18727c(2026-09-26). Re-verify with:rg -n "FromTrust|OutOfProcessOverlay|shouldIsolate" src/Plugin/StellaOps.Plugin.Sandbox/SandboxMode.cs src/Plugin/StellaOps.Plugin.Host/PluginHost.cs src/Plugin/StellaOps.Plugin.Host/Loading/AssemblyPluginLoader.cs && ls src/Plugin/StellaOps.Plugin.Registry.Persistence/Migrations
Audience: module hosts that load plugins and plugin authors targeting the SDK.
Overview
The Plugin Framework defines how plugins are discovered, admitted, loaded, initialized, health-checked and shut down. It is consumed as a library; it exposes no HTTP endpoints and owns no deployable.
Two plugin hosts exist in the tree, and they must not be confused:
| Host | Source | Isolation | Consumers |
|---|---|---|---|
Sandbox-capable host (StellaOps.Plugin.Host.PluginHost, registered by AddPluginHost) | src/Plugin/ (this dossier) | Trust-derived sandbox mode (see “Trust Levels and Sandbox Modes”) | Release Orchestrator (WebApi, Agent Host), StellaOps.Plugin.Host.Tool |
Shared lightweight host (StellaOps.Plugin.Hosting.PluginHost, PluginManifestLoader, NullPluginVerifier) | src/__Libraries/StellaOps.Plugin/ | None; admission checks only, then in-process load | Integrations WebService, Scanner WebService/Worker, JobEngine Scheduler plugin admission |
Both hosts share StellaOps.Plugin.Abstractions. Many modules reference only the abstractions (for example the *.Plugin.Unified packs of Router, AdvisoryAI, Authority and Cryptography).
Consumer-host hardening. Consumers of the shared lightweight host add their own guards. Integrations is the furthest along:
- it requires declared connector capabilities for directory-loaded external connector plugins, and wraps the connectors returned from its loader so test-connection, health and discovery calls fail closed, before plugin code runs, when a capability is missing;
- it validates a
stellaops.pluginhost.process.v1child-runner handshake for production external admission (src/Integrations/StellaOps.Integrations.PluginHost.ProcessRunner/); - it runs a narrow process-backed execution slice: a signed external connector is represented by a WebService proxy while a child runner loads the connector assembly and executes the allowlisted
integration:test-connection,integration:health-checkandintegration:discoveryoperations. Dead-runner paths and CPU/private-memory budget breaches return deterministic per-invocation evidence. This is not a long-lived supervisor, restart policy, process-tree monitor or kernel-enforced resource sandbox; - authref-backed process operations pass only opaque secret handles and one-use broker-grant metadata over generic IPC; the child runner materializes plaintext through a host-owned named-pipe broker after authorization.
Design Principles
- Security by default - a plugin whose signature does not verify is rejected; trust is granted only by pinned signer thumbprints
- Lifecycle consistency - All plugins follow the same state machine regardless of trust level
- Zero-overhead for built-ins - BuiltIn plugins run in-process with direct method calls; no serialization or IPC cost
- Testability - Every component has an in-memory or mock alternative for deterministic testing
Components
The component layout below reflects the projects under src/Plugin/. The shared lightweight host in src/__Libraries/StellaOps.Plugin/ is a distinct assembly (see the Overview).
src/Plugin/
├── StellaOps.Plugin.Abstractions/ # Core interfaces (IPlugin, PluginInfo, PluginCapabilities, manifest, trust, licensing)
├── StellaOps.Plugin.Host/ # Plugin host (IPluginHost), discovery, loading, lifecycle, health, dependency resolution
├── StellaOps.Plugin.Host.Tool/ # CLI tool host
├── StellaOps.Plugin.Registry/ # Plugin catalog (PostgreSQL via EF Core; SQL migration runner)
├── StellaOps.Plugin.Registry.Persistence/ # Embedded registry migrations
├── StellaOps.Plugin.Sandbox/ # ProcessSandbox, overlay filesystem, network/resource policy, AppArmor attach, gRPC bridge, confinement events
├── StellaOps.Plugin.Sdk/ # SDK for plugin authors (PluginBase, PluginInfoBuilder, options helpers)
├── StellaOps.Plugin.Testing/ # Test utilities (PluginTestHost, fake services/logger, deterministic time)
├── Samples/
│ └── StellaOps.Plugin.Samples.HelloWorld/ # Sample plugin demonstrating the SDK (+ .Tests project)
└── __Tests/
├── StellaOps.Plugin.Abstractions.Tests/
├── StellaOps.Plugin.Host.Tests/
├── StellaOps.Plugin.Registry.Tests/
├── StellaOps.Plugin.Sandbox.Tests/
└── StellaOps.Plugin.Sdk.Tests/
Core Interfaces
IPlugin
IPlugin (in StellaOps.Plugin.Abstractions) extends IAsyncDisposable. There is no StartAsync/StopAsync pair — lifecycle start/stop is driven by the host; the plugin exposes initialization, a periodic health check, and disposal-based shutdown.
public interface IPlugin : IAsyncDisposable
{
PluginInfo Info { get; }
PluginTrustLevel TrustLevel { get; }
PluginCapabilities Capabilities { get; }
PluginLifecycleState State { get; }
Task InitializeAsync(IPluginContext context, CancellationToken ct);
Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct);
}
PluginInfo
PluginInfo is an immutable positional record. The version is a SemVer string (not System.Version), the trust level is not part of PluginInfo (it lives on IPlugin.TrustLevel/LoadedPlugin.TrustLevel), and the identifier must be reverse-domain notation validated against ^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$.
public sealed partial record PluginInfo(
string Id, // reverse domain, e.g. "com.stellaops.crypto.gost"
string Name,
string Version, // SemVer string: Major.Minor.Patch[-PreRelease]
string Vendor,
string? Description = null,
string? LicenseId = null, // SPDX identifier
string? ProjectUrl = null,
string? IconUrl = null);
Validate() enforces the ID pattern, SemVer pattern, and non-empty Name/Vendor. Convenience members include ParsedVersion (pre-release suffix stripped) and QualifiedId ("{Id}@{Version}").
PluginCapabilities
PluginCapabilities is a [Flags] enum : long, not a class of boolean properties. The host checks capabilities before routing work. Defined flags (selected): Crypto, Auth, Llm, Secrets, Scm, Registry, Ci, Vault, Notification, IssueTracker, Analysis, Feed, Sbom, Transport, Network, FilesystemRead, FilesystemWrite, Process, StepProvider, GateProvider, TargetProvider, EvidenceProvider, Execution. Composite values are provided: AllConnectors, AllOrchestrator, AllInfrastructure. Extension helpers include Has, HasAny, ToStringArray/FromStringArray (database round-trip), and ToDisplayString.
The richer per-capability contracts (algorithm/provider-specific interfaces such as ICryptoCapability, IScmCapability, IConnectorCapability, IFeedCapability, IAnalysisCapability, IAuthCapability, ILlmCapability, ITransportCapability) live under StellaOps.Plugin.Abstractions/Capabilities/ and are resolved via LoadedPlugin.GetCapability<T>() / IPluginHost.GetCapability<T>(pluginId).
Plugin Lifecycle
The lifecycle is modeled by the PluginLifecycleState enum (StellaOps.Plugin.Abstractions.Lifecycle), which defines nine states (numeric values shown). There is no separate StartAsync; the host drives the plugin to Active after InitializeAsync, and shutdown flows through Stopping/Stopped/Unloading (disposal).
[Discovered] --> [Loading] --> [Initializing] --> [Active] --> [Stopping] --> [Stopped] --> [Unloading]
│ │ │
│ │ └── degrade ⇄ [Degraded]
└── failure ──> [Failed] <───────┘
| State (value) | Description |
|---|---|
| Discovered (0) | Plugin discovered and manifest parsed; not yet loaded |
| Loading (1) | Plugin assembly is being loaded into the application domain |
| Initializing (2) | InitializeAsync(IPluginContext, ct) is executing |
| Active (3) | Plugin is fully initialized and servicing requests (normal operating state) |
| Degraded (4) | Plugin is operational but with reduced capability/performance; may return to Active |
| Stopping (5) | Plugin is being stopped gracefully |
| Stopped (6) | Plugin has stopped and no longer services requests |
| Failed (7) | Initialization or operation failed; check status message |
| Unloading (8) | Plugin assembly is being unloaded from the application domain |
State helpers: IsOperational() (Active or Degraded), IsTerminal() (Stopped, Failed, or Unloading), and IsTransitioning() (Loading, Initializing, Stopping, Unloading). The host raises PluginStateChanged (with OldState/NewState/Reason) and PluginHealthChanged events; IPlugin.HealthCheckAsync returns a HealthCheckResult whose HealthStatus is Unknown (-1), Healthy (0), Degraded (1), or Unhealthy (2).
Trust Levels and Sandbox Modes
PluginTrustLevel (StellaOps.Plugin.Abstractions) has three values: BuiltIn (0), Trusted (1), Untrusted (2). Extension helpers: RequiresProcessIsolation() (Untrusted only), HasResourceLimits() (Trusted or higher), CanAccessInternals() (BuiltIn only), and storage round-trip (builtin/trusted/untrusted).
Trust decision. AddPluginHost registers PluginTrustDecisionMaker, which verifies plugin.signature.json with X509PinnedSignatureValidator against the PEM trust bundle (PluginHostOptions.TrustRootBundlePath, PemDirectoryPluginTrustRoot) and maps the leaf signer thumbprint: in BuiltInSignerThumbprints → BuiltIn; in TrustedSignerThumbprints → Trusted; valid but on neither list → Untrusted. A signature that does not verify is a hard reject (PluginSignatureRejectedException). AllowUnsignedPluginsInDevelopment loads an unsigned plugin as Untrusted only when the host environment is Development; it is ignored elsewhere. AllowMissingTrustRootBundle (default false) is the boot escape hatch for a missing bundle. When no trust decision maker is registered, the host falls back to Untrusted.
Sandbox mode. SandboxModeMatrix.FromTrust (src/Plugin/StellaOps.Plugin.Sandbox/SandboxMode.cs) maps the verdict to the intended runtime mode (PSB-01). What the host does today:
| Trust level | Intended mode | Load path in src/Plugin/ today |
|---|---|---|
BuiltIn | InProcessHardened | Loaded into the host’s default AssemblyLoadContext; filesystem and process-spawn calls from the SDK context pass through IFilesystemPolicy / IProcessSpawnPolicy. |
Trusted | OutOfProcessOverlay | PluginHost creates a ProcessSandbox through ISandboxFactory (registered by AddPluginHost via AddPluginSandbox) and returns a SandboxedPluginProxy; the plugin assembly is never loaded into the host. Without a sandbox factory the load fails. AssemblyPluginLoader refuses Trusted plugins. |
Untrusted | OutOfProcessConfined | The same ProcessSandbox route with the confined configuration (SandboxFactory.GetConfigurationForTrustLevel): lower resource limits, a deny-by-default network policy and, on Linux, a required AppArmor profile. The sandbox refuses to start when the profile cannot be attached. Without a sandbox factory the load fails. AssemblyPluginLoader refuses Untrusted plugins, so an Untrusted plugin never runs in the host process. |
ProcessSandbox (StellaOps.Plugin.Sandbox) provides process management (IPluginProcessManager), overlay filesystem write redirection (OverlayFilesystemPolicy, PluginSandboxWorkspace), NetworkPolicyEnforcer, resource limits (a Linux limiter and a Windows Job Object limiter), a Linux AppArmor attacher with preflight (LinuxAppArmorAttacher), an opt-in audit-log denial scraper (ApparmorAuditLogScraper), and a gRPC bridge (IGrpcPluginBridge, plugin_bridge.proto). SELinux has event vocabulary only; there is no SELinux attacher. Confinement outcomes are reported through the stable plugin.sandbox.* events in sandbox-confinement-events.md.
Module-local bundle loaders
Some modules admit signed runtime bundles with their own loaders, using the same manifest and trust-root vocabulary but not the src/Plugin/ PluginHost:
- BinaryIndex disassembly bundles.
MountedDisassemblyRuntimePluginLoader(src/BinaryIndex/__Libraries/StellaOps.BinaryIndex.Disassembly/) distinguishes executable analyzer plugins from non-executable data packs and requires signed payload and checksum material. Its live consumers are the CLI and the vulnerabilities worker; the BinaryIndex web service is retired. - Export adapters.
MountedExportAdapterRuntimePluginLoader(src/OfflineKit/__Libraries/StellaOps.ExportCenter.Core/Adapters/) is the registration seam for optional executable export adapters. None ships today: the Trivy DB adapters were retired on 2026-08-09. Declarative export profiles underdevops/etc/plugins/exportcenterare data only. - Scheduler job plugins. Admitted by JobEngine through the shared lightweight host; see JobEngine architecture §4.
Release Orchestrator execution plugins
Release Orchestrator hosts register the sandbox-capable host with AddPluginHost (src/ReleaseOrchestrator/__Agents/StellaOps.Agent.Host/Program.cs, src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Program.cs). The first-party execution bundle producer (devops/build/package-runtime-plugins.ps1) writes signed bundles under .runtime/plugins/execution/<profile>/ (generated, git-ignored); the overlays docker-compose.plugins.execution.yml and docker-compose.plugins.execution-optional.yml mount them read-only under /app/plugins/execution/<profile> with the trust root at /app/trust-roots/plugins/execution (Plugins__PluginPaths__*, Plugins__TrustRootBundlePath). The Agent Host maps its pinned signer in Plugins:BuiltInSignerThumbprints to BuiltIn, so first-party execution plugins run in-process after manifest, digest and signature verification. The repo-local lab signer is development evidence only; production offline-kit publication must use release-engineering signing material.
Database Schema
Database: PostgreSQL (via PostgresPluginRegistry, the default IPluginRegistry registered by ServiceCollectionExtensions), in the database of the host that registers it. Tables live in the schema named by PluginRegistryOptions.SchemaName (default platform); Release Orchestrator binds platform inside its own database. Migrations are embedded SQL run by PluginRegistryMigrationRunner and tracked in a plugin_migrations ledger table (migration_name, applied_at); the embedded migrations live in src/Plugin/StellaOps.Plugin.Registry.Persistence/Migrations/: 001_CreatePluginTables creates the core tables, 002_plugin_registry_retention_classes records their retention classes and 003_retire_plugin_health_history removes the unused health-history table. There is no plugin_versions table — plugin version is a column on platform.plugins, with UNIQUE(plugin_id, version) providing version history per plugin id.
| Table | Purpose |
|---|---|
platform.plugins | Registered plugins. Columns include id (UUID PK), plugin_id, name, version, vendor, description, license_id, trust_level (builtin/trusted/untrusted), signature (BYTEA), signing_key_id, capabilities (TEXT[]), capability_details (JSONB), source (bundled/installed/discovered), assembly_path, entry_point, status (the 9 lifecycle states), status_message, health_status (unknown/healthy/degraded/unhealthy), last_health_check, health_check_failures, manifest (JSONB), runtime_info (JSONB), created_at, updated_at, loaded_at. Unique on (plugin_id, version). |
platform.plugin_capabilities | Detailed capabilities per plugin (plugin_id FK, capability_type, capability_id, config_schema/input_schema/output_schema JSONB, display_name, description, documentation_url, metadata, is_enabled). Unique on (plugin_id, capability_type, capability_id). |
platform.plugin_instances | Tenant-specific plugin instances (plugin_id FK, tenant_id, instance_name, config JSONB, secrets_path, enabled, status, resource_limits JSONB, last_used_at, invocation_count, error_count). Unique on (plugin_id, tenant_id, COALESCE(instance_name, '')). |
platform.plugin_migrations | Migration ledger created by the runner before applying embedded SQL. |
Retention classes (ADR-039 P13)
Migration 002_plugin_registry_retention_classes records a retention class on three tables. The migration only adds comments and names no schema, so each comment lands in the schema the host bound.
| Table | Class | Reason |
|---|---|---|
plugins | source-of-truth | One row per admitted plugin version. Its id anchors tenant plugin_instances rows through ON DELETE CASCADE, so no row is reclaimed by age. Only UnregisterAsync removes a row. |
plugin_capabilities | derived-rebuildable | Projected from the plugin manifest on every registration by RegisterCapabilitiesAsync. The rows are removed with their plugin. |
plugin_instances | source-of-truth | Tenant instance configuration and secret references. Only DeleteInstanceAsync removes a row. |
Migration 003_retire_plugin_health_history removes plugin_health_history. Nothing wrote or read that table in production, and nothing enforced the 30-day / 1,000-row window that PluginRegistryOptions declared for it. The migration refuses a populated table with SQLSTATE 55000 and leaves the rows in place. The table, the registry’s RecordHealthCheckAsync and GetHealthHistoryAsync methods, the health-result argument of UpdateHealthAsync and the two options were retired together. UpdateHealthAsync still records the current health status on the plugin row.
No production path currently writes the remaining tables. The Release Orchestrator host registers PostgresPluginRegistry and reads it by capability, but it registers no plugins and records no health checks through it.
The InMemoryPluginRegistry provides an equivalent in-memory IPluginRegistry implementation for testing and offline scenarios.
Data Flow
[Module Host] -- discover --> [Plugin.Host] -- verify signature --> trust level
|
+--------------------+----------------------+
| | |
[BuiltIn] [Trusted] [Untrusted]
default ALC, in-process ProcessSandbox + ProcessSandbox (confined:
(policy-interposed) SandboxedPluginProxy AppArmor required on Linux)
+ SandboxedPluginProxy
| | |
+--------------------+----------------------+
|
[Plugin.Registry] -- persist --> [PostgreSQL]
Security Considerations
- Trust enforcement: a failed signature verification rejects the plugin. An
Untrustedplugin currently loads in-process in an isolated load context, which is not a security boundary; hosts that must not run unvetted code in-process should allowlist signers and refuseUntrustedloads themselves. - Capability restrictions: runtime capability-proxy enforcement is a host responsibility; the framework declares capabilities but does not intercept every call.
- Signed payloads: signature material pins the plugin’s assemblies; extraneous assemblies in a bundle are reported to the validator.
- Network access:
NetworkPolicyEnforcerapplies to plugins running inProcessSandbox(Trusted); in-process plugins are not network-restricted by the framework. - Audit trail: lifecycle transitions raise
PluginStateChanged/PluginHealthChangedand are logged; sandbox confinement decisions emitplugin.sandbox.*events.
Consumer admission rules
This section concerns the shared lightweight host (src/__Libraries/StellaOps.Plugin/). Module hosts must not load externally supplied plugin artifacts through the no-op signature verifier in production paths. NullPluginVerifier is limited to deterministic tests and explicit development harnesses.
The shared lightweight PluginHost rejects every discovered plugin before assembly load when EnforceSignatureVerification=true and no real verifier is configured, or when enforcement is paired with NullPluginVerifier. Hosts that want unsigned development loading must disable enforcement explicitly and must not treat that mode as production-valid evidence.
For the Integrations WebService, built-in connectors load from the compiled first-party catalog. External connector DLLs under Integrations:PluginsDirectory are disabled by default and, when enabled, require signature verification before any connector type is instantiated. The admission knobs named below are bound from IntegrationPluginAdmissionOptions under the Integrations:PluginAdmission configuration section (e.g., Integrations:PluginAdmission:EnableExternalDirectoryPlugins, :EnforceSignatureVerification (default true), :ProcessIsolationProvider, :ProcessIsolationRunnerPath, :ProcessIsolationMaxOutputBytes (default 65536), :ProcessIsolationOperationCpuTimeBudgetMilliseconds, :ProcessIsolationOperationPrivateMemoryBudgetBytes, :ProcessIsolationSecretBrokerTtlMilliseconds (default 5000)). Startup validation fails if external directory plugins are enabled without signature enforcement and local trust material. In Production, startup validation also requires ProcessIsolationProvider=stellaops.pluginhost.process.v1, a configured ProcessIsolationRunnerPath, and a successful runner handshake. The provider starts the runner as a child process and validates a deterministic JSON readiness payload. Handshake and operation captures are bounded by ProcessIsolationMaxOutputBytes; exceeding the limit kills the runner and returns deterministic fail-closed evidence before IPC output is trusted. Missing runners, early runner exit, malformed operation responses, timeout kills, and malformed runner-health state return deterministic lifecycle evidence instead of fake success. When configured, ProcessIsolationOperationCpuTimeBudgetMilliseconds and ProcessIsolationOperationPrivateMemoryBudgetBytes add a sampled host-side guard for one operation: the host reads the direct child process CPU time/private memory, kills on budget breach, and reports sandbox_operation_cpu_budget_exceeded or sandbox_operation_memory_budget_exceeded with pluginCodeExecuted=false. This is a supervised budget guard, not an OS hard limit. When the loader sees a signed external candidate under required isolation, it asks the child runner to load the candidate assembly, read connector metadata, and enforce baseline capability declarations; the WebService then registers a process-backed proxy instead of loading the DLL in-process. The default operation allowlist permits integration:test-connection, integration:health-check, and integration:discovery; the child runner returns pluginCodeExecuted=true and process evidence when the connector operation ran in the child. Authref-backed operations pass only opaque handles in secretReferences[] plus one-use broker grant metadata, never plaintext secrets or authref URIs in the generic payload, process args, process environment, or response evidence. The child runner connects to the host-owned named-pipe broker, presents request id, operation, grant id, and handle, and receives the resolved secret only after authorization. The grant has TTL semantics (ProcessIsolationSecretBrokerTtlMilliseconds) and is consumed once; unavailable, expired, replayed, malformed, or unauthorized grants fail closed with pluginCodeExecuted=false. Kernel hard resource limits, long-lived supervision, restart policy, process-tree accounting, platform-specific pipe/socket peer identity ACLs, durable broker audit routing, and multi-secret broker contracts remain follow-up work.
Directory-loaded Integrations connectors must also implement IIntegrationConnectorCapabilityDeclaration. The loader rejects external connectors that omit the declaration or fail to declare the required integration:test-connection and integration:health-check capabilities. Discovery-capable external connectors must declare integration:discovery. Declaring connectors retained by the Integrations loader are wrapped in a host-side proxy, and callers that obtain plugins through the loader receive the proxy, not the raw instance. The proxy refuses test-connection, health, and discovery operations the connector did not claim, without resolving secrets or invoking plugin operation code. This is a host-side operation guard, not process sandboxing.
Observability
- Metrics (proposed — NOT IMPLEMENTED): No
Meter/metric instruments namedplugin_active_count,plugin_load_duration_ms,plugin_failures_total, orsandbox_process_restarts_totalexist insrc/Plugin/. These are aspirational instrument names and should not be treated as emitted today. - Logs: The host and registry emit structured logs (e.g.,
ILogger<PluginRegistryMigrationRunner>migration logging, lifecycle/health logging inPluginLifecycleManager/PluginHealthMonitor). Plugins receive a scopedIPluginLogger(auto-tagged with plugin id) viaIPluginContext.Logger. - Health:
IPluginRegistrypersists the current health status on the plugin row (no history);LoadedPlugin.HealthCheckAsynccaches the last result, and the host raisesPluginHealthChangedevents on status transitions. Modules can query whether a required plugin is operational.
Performance Characteristics
BuiltIn(InProcessHardened): direct method dispatch; filesystem and process-spawn calls pass through policy seams.Trusted(OutOfProcessOverlay): out-of-process throughProcessSandbox; each call crosses the gRPC bridge.Untrusted: currently in-process in an isolated load context (see Trust Levels and Sandbox Modes).- Plugin discovery runs at host startup and is cached until restart or an explicit re-scan.
References
- Module README
- Sandbox confinement events
- Plugin system (Release Orchestrator) - signature verification and SPDX license enforcement
- Plugin sandbox threat model
- Integrations Architecture - consumer of the shared lightweight host
- Scanner Architecture - consumer of the shared lightweight host
