Plugin Framework Architecture
Audience: Module hosts that load plugins (Integrations, Scanner, Crypto, …) and plugin authors targeting the SDK. Technical architecture for the universal plugin lifecycle, sandboxing, and registry framework.
Overview
The Plugin Framework provides the core extensibility infrastructure for Stella Ops. It defines how plugins are discovered, loaded, initialized, monitored, and shut down. It is consumed as a library by other modules; it does not expose HTTP endpoints.
What ships today vs. target architecture. The current shared host provides deterministic discovery plus signature and version admission checks. Universal process-sandbox execution, universal runtime capability-proxy enforcement, and registry-backed lifecycle monitoring remain target architecture and must not be treated as shipped production controls.
Consumer-host hardening. Consumer hosts can add narrower operation guards. Integrations is the furthest-along consumer and:
- 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 is invoked — when capabilities are missing;
- validates a
stellaops.pluginhost.process.v1child-runner handshake for production external admission; - ships a narrow process-backed execution slice: a signed external connector can be represented by a WebService proxy while a child runner loads the connector assembly and executes the allowlisted
integration:test-connection,integration:health-check, andintegration:discoveryoperations. Dead-runner paths and configured CPU/private-memory budget breaches return deterministic per-invocation lifecycle evidence — but this is not a long-lived supervisor, restart policy, process-tree monitor, or kernel-enforced resource sandbox; - for authref-backed process operations, passes only opaque secret handles and broker-grant metadata in generic IPC, then materializes plaintext inside the child runner through a host-owned, one-use named-pipe broker after authorization.
Design Principles
- Security by default - untrusted plugins are rejected unless a host has implemented real isolation and capability enforcement
- 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/. (A separate, older library at src/__Libraries/StellaOps.Plugin/ provides the PluginHost/NullPluginVerifier/PluginHostOptions “shared host” referenced in the Overview and Consumer admission sections; it is a distinct assembly from the src/Plugin/ projects listed here.)
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.Sandbox/ # Process isolation, network/secret/resource policy, gRPC bridge (target architecture)
├── 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
Current implementation note (2026-04-28): accepted plugin assemblies load in-process after admission checks when a consumer uses the shared PluginHost directly. BuiltIn and signed trusted plugins are the only production-valid in-process modes. Untrusted plugin execution requires a real sandbox/process boundary and host-side capability proxy. Integrations has implemented a narrow child-runner provider named stellaops.pluginhost.process.v1 with a readiness handshake, deterministic JSON operation envelope, per-operation timeout/kill behavior, bounded stdout/stderr capture, deterministic dead-runner/lifecycle evidence, child-runner connector metadata validation, default integration:test-connection, integration:health-check, and integration:discovery execution, and one-use brokered secret materialization for authref-backed process operations. That Integrations slice is not universal shared-host sandboxing.
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).
| Level (value) | Intended sandbox mode (PSB-01) | Use Case |
|---|---|---|
| BuiltIn (0) | InProcessHardened — host default AssemblyLoadContext, full privileges, but filesystem/process-spawn calls interposed via IFilesystemPolicy/IProcessSpawnPolicy | First-party plugins shipped with the platform |
| Trusted (1) | OutOfProcessOverlay — process sandbox + overlay filesystem (writes redirected to per-sandbox scratch); higher resource ceiling than Untrusted, no OS confinement profile | Vetted third-party plugins with signed manifests |
| Untrusted (2) | OutOfProcessConfined — process sandbox + overlay filesystem + OS confinement profile (AppArmor/SELinux on Linux, Job Object on Windows); fail-closed if the profile cannot attach | Community or unverified plugins |
The trust-level → sandbox-mode mapping is defined by SandboxModeMatrix.FromTrust(PluginTrustLevel) (StellaOps.Plugin.Sandbox.SandboxMode), the exhaustive PSB-01 matrix. Trust level answers “who is the plugin?” (a cryptographic pre-load verdict); sandbox mode answers “what may it do at runtime?”. Out-of-process sandbox execution (OutOfProcessOverlay/OutOfProcessConfined) is target architecture in the shared PluginHost — the production-valid in-process modes today are BuiltIn and signed Trusted. The gRPC bridge (IGrpcPluginBridge), process manager, network/secret/resource policy seams exist as interfaces under StellaOps.Plugin.Sandbox but are not a shipped universal isolation control.
BinaryIndex signed analyzer bundles use the same manifest/trust-root vocabulary but are admitted by the module-local MountedDisassemblyRuntimePluginLoader, not by the shared PluginHost. The bundle manifest distinguishes executable analyzer plugins from non-executable data packs, requires signed payload/checksum material for Iced and B2R2 disassembly bundles, and rejects data-pack manifests that declare executable analyzers. Compose overlays mount analyzer bundle roots read-only under /app/plugins/binaryindex/<profile> and mount the BinaryIndex trust root separately under /app/trust-roots/plugins/binaryindex.
Release Orchestrator execution plugins use the shared PluginHost admission path. The first-party execution bundle producer writes signed bundles under devops/plugins/execution/default/ and optional build.script under devops/plugins/execution/optional-build-script/; compose overlays mount those roots read-only under /app/plugins/execution/<profile> and mount the execution trust root under /app/trust-roots/plugins/execution. Agent Host maps the pinned signer thumbprint in Plugins:BuiltInSignerThumbprints to PluginTrustLevel.BuiltIn, so default first-party execution plugins run in the host default AssemblyLoadContext after manifest, digest, and signature verification. The repo-local lab signer is development evidence only; production offline-kit publication must replace it with release-engineering signing material.
ExportCenter signed exporter bundles use the same runtime-bundle vocabulary but are admitted by the module-local MountedExportAdapterRuntimePluginLoader. The current signed profile is recommended and carries Trivy DB / Trivy Java DB export adapters outside the base service image. Declarative ExportCenter profiles and standards mappings stay under devops/etc/plugins/exportcenter as data/config and must not be executable. Built-in typed NIS2 routes are host-owned Core code; optional CRA/PDF/custom regulatory adapters require future signed bundle producers before they can be mounted.
ProcessSandbox (Target Architecture)
This section describes the required design before untrusted plugins can be enabled. It is not current production behavior in the shared PluginHost.
Untrusted plugins run in a child process managed by ProcessSandbox:
- Process creation: The sandbox spawns a new process with restricted permissions
- gRPC channel: A bidirectional gRPC channel is established for host-plugin communication
- Capability enforcement: The host proxy only forwards calls matching declared capabilities
- Resource limits: CPU and memory limits are enforced at the process level
- Crash isolation: If the plugin process crashes, the host logs the failure and marks the plugin as Failed; the host process is unaffected
Database Schema
Database: PostgreSQL (via PostgresPluginRegistry, the default IPluginRegistry registered by ServiceCollectionExtensions). Tables live in the platform schema (configurable via PluginRegistryOptions.SchemaName, default platform). Migrations are embedded SQL run by PluginRegistryMigrationRunner and tracked in a plugin_migrations ledger table (migration_name, applied_at); migration 001_CreatePluginTables creates the core tables. 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_health_history | Historical health check results (plugin_id FK, checked_at, status, response_time_ms, details JSONB, error_message). |
platform.plugin_migrations | Migration ledger created by the runner before applying embedded SQL. |
The InMemoryPluginRegistry provides an equivalent in-memory IPluginRegistry implementation for testing and offline scenarios.
Data Flow
[Module Host] ── discover ──> [Plugin.Host]
│
load plugins
│
┌───────────────┼───────────────┐
│ │ │
[BuiltIn] [Trusted] [Untrusted]
(in-process) (in-process) (ProcessSandbox)
│ │ │
└───────────────┼───────────────┘
│
[Plugin.Registry] ── persist ──> [PostgreSQL]
Security Considerations
- Trust level enforcement: Hosts must reject untrusted plugin code unless they provide a real process sandbox. The current shared
PluginHostdoes not provide that isolation by itself. - Capability restrictions: Runtime capability proxy enforcement is a host responsibility and is not yet a shipped shared-host control.
- Assembly hash verification: Plugin assemblies are hashed at registration; the host verifies the hash at load time to detect tampering
- No network access for untrusted plugins: The sandbox process has restricted network permissions; plugins that need network access must be at least Trusted
- Audit trail: Plugin lifecycle events (registration, activation, failure, shutdown) are logged with timestamps and actor identity
Consumer admission rules
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 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 current health status andplugin_health_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 plugins (
InProcessHardened): near-zero overhead (direct method dispatch; filesystem/process-spawn calls pass through policy seams). - Trusted plugins (
OutOfProcessOverlay, target architecture): out-of-process + overlay filesystem; IPC serialization cost per call once the process sandbox ships. - Untrusted plugins (
OutOfProcessConfined, target architecture): out-of-process + OS confinement; gRPC/IPC serialization cost per call (~1-5ms depending on payload size). - Plugin discovery: runs at host startup; cached until restart or explicit re-scan.
References
- Module README
- Integrations Architecture - Primary consumer
- Scanner Architecture - Plugin-based analysis
