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:

Design Principles

  1. Security by default - untrusted plugins are rejected unless a host has implemented real isolation and capability enforcement
  2. Lifecycle consistency - All plugins follow the same state machine regardless of trust level
  3. Zero-overhead for built-ins - BuiltIn plugins run in-process with direct method calls; no serialization or IPC cost
  4. 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/IProcessSpawnPolicyFirst-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 profileVetted 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 attachCommunity 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:

  1. Process creation: The sandbox spawns a new process with restricted permissions
  2. gRPC channel: A bidirectional gRPC channel is established for host-plugin communication
  3. Capability enforcement: The host proxy only forwards calls matching declared capabilities
  4. Resource limits: CPU and memory limits are enforced at the process level
  5. 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.

TablePurpose
platform.pluginsRegistered 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_capabilitiesDetailed 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_instancesTenant-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_historyHistorical health check results (plugin_id FK, checked_at, status, response_time_ms, details JSONB, error_message).
platform.plugin_migrationsMigration 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

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

Performance Characteristics

References