PLUGIN: Plugin Infrastructure
Purpose: Extensible plugin system for integrations, steps, and custom functionality.
Architecture Overview
┌─────────────────────────────────────────────────────────────────────────────┐
│ PLUGIN ARCHITECTURE │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ PLUGIN REGISTRY │ │
│ │ │ │
│ │ - Plugin discovery and versioning │ │
│ │ - Manifest validation │ │
│ │ - Dependency resolution │ │
│ └──────────────────────────────┬──────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ PLUGIN LOADER │ │
│ │ │ │
│ │ - Lifecycle management (load, start, stop, unload) │ │
│ │ - Health monitoring │ │
│ │ - Hot reload support │ │
│ └──────────────────────────────┬──────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ PLUGIN SANDBOX │ │
│ │ │ │
│ │ - Process isolation │ │
│ │ - Resource limits (CPU, memory, network) │ │
│ │ - Capability enforcement │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Plugin Types: │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Connector │ │ Step │ │ Gate │ │ Agent │ │
│ │ Plugins │ │ Providers │ │ Providers │ │ Plugins │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Trust level → sandbox mode matrix
Introduced in SPRINT_20260501_003_Plugin_sandbox_trusted_level (audit B3). Closes the gap where pre-PSB-01
Trustedplugins ran in a customAssemblyLoadContext(escapable via reflection) andBuiltInran in the host’s default ALC with full host privileges and zero policy interposition.
The pre-load trust level (PluginTrustLevel, derived cryptographically — see Plugin signature verification) is mapped at load time to a runtime sandbox mode (SandboxMode). The mapping is the single source of truth for the privilege blast radius of a loaded plugin.
| Trust level | Sandbox mode | Process boundary | Filesystem | OS confinement | Where defined |
|---|---|---|---|---|---|
BuiltIn | InProcessHardened | Host process (default ALC) | IFilesystemPolicy interposed at SDK seams; every read/write/exec audited via IFilesystemAccessAuditor; child-process spawn gated by IProcessSpawnPolicy (default DenyAllProcessSpawnPolicy) | none (in-process) | SandboxModeMatrix.FromTrust(BuiltIn) |
Trusted | OutOfProcessOverlay | Out-of-process (ProcessSandbox) | Overlay filesystem: pristine install dir is the read-only lower layer; per-sandbox scratch is the read-write upper layer; writes targeting the install dir are redirected to the upper layer (the install dir is never mutated) | none | SandboxModeMatrix.FromTrust(Trusted) |
Untrusted | OutOfProcessConfined | Out-of-process (ProcessSandbox) | Overlay filesystem (same as Trusted) | Linux: AppArmor profile (stellaops-plugin-sandbox) attached fail-closed via LinuxAppArmorAttacher (libapparmor aa_change_onexec preferred, aa-exec wrapper as fallback; SELinux ships sample-only/advisory). Windows: Job Object with LIMIT_PROCESS_MEMORY + LIMIT_KILL_ON_JOB_CLOSE + LIMIT_ACTIVE_PROCESS = 1, attachment verified via QueryInformationJobObject | SandboxModeMatrix.FromTrust(Untrusted) |
flowchart LR
Sig[plugin.signature.json verifies] -->|thumbprint pin| Trust{PluginTrustLevel}
Trust -->|BuiltIn| Mode1[SandboxMode.InProcessHardened]
Trust -->|Trusted| Mode2[SandboxMode.OutOfProcessOverlay]
Trust -->|Untrusted| Mode3[SandboxMode.OutOfProcessConfined]
Mode1 --> Inproc[Host process
+ FS / spawn policy
+ audit]
Mode2 --> ProcA[ProcessSandbox
+ overlay FS]
Mode3 --> ProcB[ProcessSandbox
+ overlay FS
+ AppArmor / Job Object]
Architectural rules (enforced by tests under src/Plugin/__Tests/StellaOps.Plugin.Sandbox.Tests/):
- The matrix is total: every defined
PluginTrustLevelmaps to exactly oneSandboxMode. New trust levels MUST extendSandboxModeMatrix.FromTrust; there is no implicit fallback (SandboxModeMatrixTests.FromTrust_UndefinedEnumValue_Throws). TrustedMUST NOT map toInProcessHardened. The regression-guardSandboxModeMatrixTests.Trusted_NoLongerRunsInProcess_RegressionGuardForAuditFindingB3fails loudly if a future change reverts the audit-finding-B3 fix.Untrustedrequires the full stack: process isolation + overlay FS + OS confinement. Pinned byUntrusted_RequiresFullConfinement_FailClosed.- For
InProcessHardened, write attempts targeting any path the inner policy reports as blocked (e.g./etc/passwd,C:\Windows\System32\...) emit aIsBypassAttempt = trueaudit event in addition to the deny event. Pinned byInProcessHardenedNegativePathTests.
Trusted resource defaults (SPRINT_20260501_051, PLG-TRUSTED-OOP-04):
SandboxConfiguration.Trusted is the single default configuration for SandboxMode.OutOfProcessOverlay. The defaults are pinned by SandboxConfigurationTests.Trusted_Defaults_PinOutOfProcessOverlayResourceCeilings and SandboxConfigurationTests.Trusted_Defaults_PinStartupAndOperationTimeouts.
| Setting | Trusted default | Notes |
|---|---|---|
| Process isolation | true | Trusted starts through ProcessSandbox and must not use the host ALC execution path. |
| Max memory | 2048 MB | Higher ceiling than the untrusted default. |
| Max CPU | 50% | Higher ceiling than the untrusted default. |
| Max disk | 1024 MB | Higher ceiling than the untrusted default. |
| Max network bandwidth | 100 Mbps | Higher ceiling than the untrusted default. |
| Startup timeout | 60 s | Process/bridge/plugin initialization budget. |
| Operation timeout | 5 min | Default host-side Trusted operation budget. |
| Shutdown timeout | 30 s | Graceful stop budget before forced cleanup. |
| Health-check timeout | 10 s | Per health-check call budget. |
Trusted uses the overlay filesystem intent of OutOfProcessOverlay: the plugin install directory is treated as the read-only lower layer and writes are redirected to per-sandbox scratch. Trusted does not attach AppArmor, SELinux, or Windows Job Object confinement by default; that OS-confinement requirement belongs to Untrusted / OutOfProcessConfined.
Workspace layout seam (SPRINT_20260501_052/053):
ProcessSandbox prepares process working directories through IPluginSandboxWorkspaceFactory. The stable layout is:
| Path | Purpose |
|---|---|
lower | Verified plugin install directory, treated as read-only source. |
upper | Per-sandbox scratch/write target. |
work | Reserved OS workspace state for overlayfs/fuse-overlayfs/junction machinery. |
merged | Working directory passed to the plugin host process. |
The workspace factory rejects non-absolute paths, parent traversal, source and workspace overlap, and symlink/reparse escapes before process start. MaterializedCopy remains compatibility/degraded behavior for overlay modes. Linux kernel overlayfs and fuse-overlayfs strategies now build typed process arguments instead of shell-concatenating paths:
- kernel overlayfs:
mount -t overlay overlay -o lowerdir=<lower>,upperdir=<upper>,workdir=<work> <merged>; fuse-overlayfs:fuse-overlayfs -o lowerdir=<lower>,upperdir=<upper>,workdir=<work> <merged>;- cleanup:
umount <merged>.
Forced Linux overlay strategies fail closed on non-Linux hosts before invoking any host command, and missing mount commands produce actionable plugin.workspace.mount_command_missing failures. Dedicated Linux runner evidence is still required before the Linux overlay tasks can be marked done: the runner must prove writes land in upper, lower remains immutable, and mount/unmount cleanup works under kernel overlayfs and fuse-overlayfs.
On Windows, WindowsJunction creates merged as a directory junction to the verified plugin install directory using Win32 reparse-point APIs, not cmd or mklink. The strategy fails closed on non-Windows and if junction creation or target verification fails. Cleanup removes the junction first, then deletes the sandbox root with bounded retry; persistent locked files surface plugin.workspace.cleanup_failed so operators can terminate the locking process and remove the leftover sandbox root. Existing install files are marked read-only while the workspace is active and restored during cleanup, which denies direct overwrite attempts through the junction-backed read view. This is not a full ACL sandbox: directory-level create/delete denial and transparent driver-level copy-on-write remain future hardening work.
OverlayFilesystemPolicy provides the SDK/policy copy-on-write layer. Writes targeting either lower or the optional merged read view resolve into upper; reads prefer the upper copy when present and fall back to the junction-backed install view. This differs from Linux kernel overlayfs: Windows currently relies on SDK/policy interposition plus read-only existing install files, not a filesystem driver such as WinFsp. When an operator explicitly allows the compatibility copy fallback, ProcessSandbox emits a warning log that names the sandbox id, degraded strategy, and lower/upper/work/merged paths. ProcessSandbox passes workspace metadata to child processes through STELLAOPS_SANDBOX_WORKSPACE_* environment variables for diagnostics and future OS confinement attachment.
Operator-facing Linux runbook: docs/ops/plugin-sandbox-linux.md covers fallback order, settings, degraded-posture events, cleanup behavior, stale-mount diagnosis, and the troubleshooting matrix that was previously implicit in the architecture text.
Structured confinement events: docs/modules/plugin/sandbox-confinement-events.mddefines the binding contract for plugin sandbox events (plugin.sandbox.workspace.*, plugin.sandbox.confinement.*, plugin.sandbox.resource_limiter.*, plugin.sandbox.lifecycle.*) with typed severities, field schemas, and persistence/correlation rules. Emitter implementations live under src/Plugin/StellaOps.Plugin.Sandbox/Diagnostics/; default registration is services.AddPluginSandboxEvents().
Threat model: docs/security/plugin-sandbox-threat-model.mdapplies STRIDE to ALC reflection, process boundary, overlay mutations, stale mounts, AppArmor false positives, SELinux sample-only limits, Windows junction reparse risks, COW residual, Job Object breakaway, BuiltIn residual, and the bridge surface; it also lists the residual items that depend on the BLOCKED runner-gated tasks.
Linux AppArmor confinement (SPRINT_20260501_054, PLG-OS-CONF-02):
LinuxAppArmorAttacher (src/Plugin/StellaOps.Plugin.Sandbox/Linux/) attaches the stellaops-plugin-sandbox profile to the next exec when SandboxConfiguration.AppArmor.RequireAppArmor=true (the default for PluginTrustLevel.Untrusted). Strategy selection is runtime-probed via IAppArmorEnvironmentProbe (default reads /sys/kernel/security/apparmor, loads libapparmor.so.1, looks up aa-exec on PATH, and checks /sys/kernel/security/apparmor/profiles for the named profile):
| Probe state | Outcome |
|---|---|
| LSM dir missing (e.g., WSL2) | Untrusted: SandboxStartupRefusedException(AppArmorRequiredButUnavailable) → mapped to plugin.sandbox.os_confinement_failed. Trusted opt-in: log warn + degrade. |
| LSM present, both libapparmor + aa-exec missing | Untrusted: SandboxStartupRefusedException(AppArmorRequiredButUnavailable). |
| LSM present, libapparmor present, profile not loaded | Untrusted: SandboxStartupRefusedException(AppArmorProfileLoadFailed). |
| LSM present, libapparmor present, profile loaded | aa_change_onexec("stellaops-plugin-sandbox") via P/Invoke. |
| LSM present, libapparmor missing, aa-exec present, profile loaded | Fallback: aa-exec wrapper (orchestration responsibility of IPluginProcessManager). |
The profile lives at devops/linux/apparmor/stellaops-plugin-sandbox.profile (AppArmor 3.x grammar, apparmor_parser -Q -N syntax-clean on WSL2). It allows reads from sandbox merged/lower, writes to upper/work, the gRPC bridge UDS, and /usr/bin/dotnet; it denies mount/umount/ pivot_root/ptrace, denies the sys_admin/sys_module/sys_rawio/ sys_ptrace/mknod/net_admin capability bag, and denies inet/inet6/raw/packet/netlink networking.
WSL2 vs Linux runner posture: profile syntax, libapparmor probing, fallback strategy, and the “AppArmor required but unavailable” fail-closed branch are all WSL2-validated. Kernel-enforced deny tests (showing the LSM blocks an Untrusted plugin’s read/write to a non-allowed path) require a Linux host whose kernel compiles in the AppArmor LSM; WSL2’s kernel does not. That assertion is a tracked residual under sprint 054 PLG-OS-CONF-02 “Residual” and will be re-validated when an apparmor-enabled runner is provisioned. SELinux ships sample-only at devops/linux/selinux/stellaops-plugin-sandbox.te; it is advisory in this release (checkmodule/semodule_package syntax-clean on WSL2; no runner-gated enforcement).
Windows Job Object confinement (SPRINT_20260501_054, PLG-OS-CONF-04):
On Windows, plugin host processes started through ProcessSandbox receive resource limits through WindowsResourceLimiter, which uses a Windows Job Object. For OutOfProcessConfined / Untrusted, this is the required OS process-confinement layer:
- the child process is assigned to a Job Object after process creation and before bridge connection or plugin initialization can succeed;
- the Job Object is configured with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - configured process and job memory limits are applied from
ResourceLimits.MaxMemoryMb; - active processes are limited by
ResourceLimits.MaxProcesses, with Untrusted defaults set to1unless an operator/fixture explicitly supplies a different limit; - breakaway flags are not enabled, and startup fails if a typed
QueryInformationJobObject/IsProcessInJobverification does not confirm assignment, memory limits, process-count limits, kill-on-close, and no-breakaway posture.
Job Object attachment failures are fail-closed for sandbox startup and surface as plugin.sandbox.os_confinement_failed. Local Windows integration coverage verifies typed attachment diagnostics and active-process-limit child-spawn denial. A dedicated Windows runner should still record host facts before release sign-off: Windows version, .NET SDK/runtime, account privilege level, Job Object support, and memory-limit pressure behavior.
There is currently no Plugin sandbox resource-limit configuration binder for operators. Existing binders cover PluginHostOptions, license policy, registry options, and PluginProcessManagerOptions; they do not bind SandboxConfiguration.Trusted or ResourceLimits. Until a later sprint adds that subsystem, operator override behavior is intentionally undocumented as a runtime capability and there are no override tests to run.
Trusted startup latency benchmark (SPRINT_20260501_051, PLG-TRUSTED-OOP-05):
TrustedStartupLatencyBenchmarkTests.Trusted_ProcessSandbox_StartupLatency_EmitsTrendableSummary is a local-only benchmark-category test for CI trend tracking. It starts the Trusted ProcessSandbox path with local fake IPC/resource/network seams and a local sleeping child process, then emits a JSON report with:
- sample count;
- median, p95, min, and max startup latency;
- OS description, OS/process architecture, processor count, and .NET runtime;
- warm/cold designation (
cold-first,warm-remaining) plus per-sample labels.
The harness does not require external services. Dedicated Windows and Linux runner baselines are still required before release sign-off for the original 2x ALC-vs-out-of-process budget.
Implementation surface (this sprint’s deliverables that are live):
src/Plugin/StellaOps.Plugin.Sandbox/SandboxMode.cs— enum + matrix.src/Plugin/StellaOps.Plugin.Host/Loading/AssemblyPluginLoader.cs— callsSandboxModeMatrix.FromTrustand threadsSandboxModethroughPluginAssemblyLoadResult.BuiltIn→ no ALC isolation;Trustedis rejected by the assembly loader and routed byPluginHostthroughProcessSandbox;Untrustedkeeps the compatibility isolated-ALC path until the confined process runner sprint takes ownership of that route.src/Plugin/StellaOps.Plugin.Host/Loading/SandboxedPluginProxy.cs— host-side lifecycle proxy forTrustedplugins. It preserves theIPluginlifecycle shape while starting the plugin throughProcessSandbox.StartAsyncrather than instantiating the plugin implementation type in the host ALC. It also exposesIPluginOperationInvokerso host callers can invoke unary and streaming operations throughISandbox.ExecuteAsync/ISandbox.ExecuteStreamingAsyncwithout type-loading the plugin implementation in-process.src/Plugin/StellaOps.Plugin.Abstractions/Execution/IPluginOperationInvoker.cs— stable host-side dispatch seam for out-of-process plugin operations. Operation failures surface asPluginOperationExceptionwith deterministic codes such asplugin.operation.timeout,plugin.sandbox.process_exited, andplugin.sandbox.not_started.src/Plugin/StellaOps.Plugin.Abstractions/Execution/PluginSandboxException.cs— deterministic sandbox lifecycle failure surface.ProcessSandbox.StartAsyncmaps process-start failure, bridge startup timeout, plugin initialization failure, and early process exit to stableplugin.sandbox.*codes while moving the sandbox state toFailedand cleaning up policy/process resources.src/Plugin/StellaOps.Plugin.Sandbox/Filesystem/OverlayFilesystemPolicy.cs— cross-platform write-redirection layer used by both PSB-04 (Linux overlay-fs / fuse-overlayfs) and PSB-05 (Windows junction + COW).src/Plugin/StellaOps.Plugin.Sandbox/Filesystem/IFilesystemAccessAuditor.csInMemoryFilesystemAccessAuditor.cs— structured audit seam plus bounded-ring default implementation.
src/Plugin/StellaOps.Plugin.Sandbox/Process/IProcessSpawnPolicy.cs—DenyAllProcessSpawnPolicy(default) andAllowListProcessSpawnPolicy.src/Plugin/StellaOps.Plugin.Sandbox/Resources/WindowsResourceLimiter.cs- Windows Job Object resource and Untrusted process-confinement enforcement, including kill-on-close, process/job memory limits, active-process limits, no-breakaway verification, and typedQueryInformationJobObjectdiagnostics.src/Plugin/StellaOps.Plugin.Abstractions/Security/— shared policy contracts exposed throughIPluginContext.FilesystemandIPluginContext.ProcessSpawnwithout creating a Plugin.Abstractions -> Plugin.Sandbox project cycle.src/Plugin/StellaOps.Plugin.Host/Context/PluginContext.cs— constructs the overlay filesystem policy and deny-all spawn policy from the sandbox matrix, then publishes them to plugin authors throughIPluginContext.src/Plugin/StellaOps.Plugin.Sdk/PluginExtensions.cs— file helpers (ReadAllTextAsync,WriteAllTextAsync) and process helper (StartProcess) route through the context policy seams before touchingSystem.IO.FileorProcess.Start.
Deferred to follow-up sprints (tracked under docs-archive/implplan/SPRINT_20260501_003_followups.md, see “Sandbox follow-ups” section of the Decisions & Risks of the parent sprint):
- PSB-03 — Trusted out-of-process operation, streaming, health, shutdown, and deterministic failure mapping are implemented in
SPRINT_20260501_051_Plugin_trusted_outofprocess_overlay.md. Resource defaults, local benchmark harness, and release-note handoff are documented there; dedicated external OS-runner benchmark evidence remains a release sign-off blocker until completed. - PSB-04 — Real Linux
mount -t overlaymachinery (current implementation is the policy-level redirect). - PSB-05 - Windows directory junctions, SDK copy-on-write, and direct overwrite denial are implemented in archived
SPRINT_20260501_053_Plugin_windows_filesystem_sandbox.md; transparent driver-level copy-on-write remains a future hardening item. - PSB-06 — AppArmor profile authoring +
aa_change_onexecP/Invoke. - PSB-07 - Windows Job Object attachment and typed
QueryInformationJobObjectverification are implemented inSPRINT_20260501_054_Plugin_os_process_confinement.md; dedicated runner evidence for memory-pressure termination remains blocked until a pinned Windows lane records host facts. - PSB-08 —
docs/security/threat-model/plugin-sandbox.mdand follow-up stub.
Modules
Module: plugin-registry
| Aspect | Specification |
|---|---|
| Responsibility | Plugin discovery; versioning; manifest management |
| Data Entities | Plugin, PluginManifest, PluginVersion |
| Events Produced | plugin.discovered, plugin.registered, plugin.unregistered |
Plugin Entity:
interface Plugin {
id: UUID;
pluginId: string; // "com.example.my-connector"
version: string; // "1.2.3"
vendor: string;
license: string;
manifest: PluginManifest;
status: PluginStatus;
entrypoint: string; // Path to plugin executable/module
lastHealthCheck: DateTime;
healthMessage: string | null;
installedAt: DateTime;
updatedAt: DateTime;
}
type PluginStatus =
| "discovered" // Found but not loaded
| "loaded" // Loaded but not active
| "active" // Running and healthy
| "stopped" // Manually stopped
| "failed" // Failed to load or crashed
| "degraded"; // Running but with issues
Module: plugin-loader
| Aspect | Specification |
|---|---|
| Responsibility | Plugin lifecycle management |
| Dependencies | plugin-registry, plugin-sandbox |
| Events Produced | plugin.loaded, plugin.started, plugin.stopped, plugin.failed |
Plugin Lifecycle:
┌──────────────┐
│ DISCOVERED │ ──── Plugin found in registry
└──────┬───────┘
│ load()
▼
┌──────────────┐
│ LOADED │ ──── Plugin validated and prepared
└──────┬───────┘
│ start()
▼
┌──────────────┐ ┌──────────────┐
│ ACTIVE │ ──── │ DEGRADED │ ◄── Health issues
└──────┬───────┘ └──────────────┘
│ stop() │
▼ │
┌──────────────┐ │
│ STOPPED │ ◄───────────┘ manual stop
└──────────────┘
│ unload()
▼
┌──────────────┐
│ UNLOADED │
└──────────────┘
Lifecycle Operations:
interface PluginLoader {
// Discovery
discover(): Promise<Plugin[]>;
refresh(): Promise<void>;
// Lifecycle
load(pluginId: string): Promise<Plugin>;
start(pluginId: string): Promise<void>;
stop(pluginId: string): Promise<void>;
unload(pluginId: string): Promise<void>;
restart(pluginId: string): Promise<void>;
// Health
checkHealth(pluginId: string): Promise<HealthStatus>;
getStatus(pluginId: string): Promise<PluginStatus>;
// Hot reload
reload(pluginId: string): Promise<void>;
}
Module: plugin-sandbox
| Aspect | Specification |
|---|---|
| Responsibility | Isolation; resource limits; security |
| Enforcement | Process isolation, capability-based security |
Sandbox Configuration:
interface SandboxConfig {
// Process isolation
processIsolation: boolean; // Run in separate process
containerIsolation: boolean; // Run in container
// Resource limits
resourceLimits: {
maxMemoryMb: number; // Memory limit
maxCpuPercent: number; // CPU limit
maxDiskMb: number; // Disk quota
maxNetworkBandwidth: number; // Network bandwidth limit
};
// Network restrictions
networkPolicy: {
allowedHosts: string[]; // Allowed outbound hosts
blockedHosts: string[]; // Blocked hosts
allowOutbound: boolean; // Allow any outbound
};
// Filesystem restrictions
filesystemPolicy: {
readOnlyPaths: string[];
writablePaths: string[];
blockedPaths: string[];
};
// Timeouts
timeouts: {
initializationMs: number;
operationMs: number;
shutdownMs: number;
};
}
Capability Enforcement:
interface PluginCapabilities {
// Integration capabilities
integrations: {
scm: boolean;
ci: boolean;
registry: boolean;
vault: boolean;
settingsStore: boolean;
router: boolean;
};
// Step capabilities
steps: {
deploy: boolean;
gate: boolean;
notify: boolean;
custom: boolean;
};
// System capabilities
system: {
network: boolean;
filesystem: boolean;
secrets: boolean;
database: boolean;
};
}
Module: plugin-sdk
| Aspect | Specification |
|---|---|
| Responsibility | SDK for plugin development |
| Languages | C#, TypeScript, Go |
Plugin SDK Interface:
// Base plugin interface
interface StellaPlugin {
// Lifecycle
initialize(config: PluginConfig): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
dispose(): Promise<void>;
// Health
getHealth(): Promise<HealthStatus>;
// Metadata
getManifest(): PluginManifest;
}
// Connector plugin interface
interface ConnectorPlugin extends StellaPlugin {
createConnector(config: ConnectorConfig): Promise<Connector>;
}
// Step provider plugin interface
interface StepProviderPlugin extends StellaPlugin {
getStepTypes(): StepType[];
executeStep(
stepType: string,
config: StepConfig,
inputs: StepInputs,
context: StepContext
): AsyncGenerator<StepEvent>;
}
// Gate provider plugin interface
interface GateProviderPlugin extends StellaPlugin {
getGateTypes(): GateType[];
evaluateGate(
gateType: string,
config: GateConfig,
context: GateContext
): Promise<GateResult>;
}
Three-Surface Plugin Model
Plugins contribute to the system through three distinct surfaces:
1. Manifest Surface (Static)
The plugin manifest declares:
- Plugin identity and version
- Required capabilities
- Provided integrations/steps/gates
- Configuration schema
- UI components (optional)
# plugin.stella.yaml
plugin:
id: "com.example.jenkins-connector"
version: "1.0.0"
vendor: "Example Corp"
license: "BUSL-1.1"
description: "Jenkins CI integration for Stella Ops"
capabilities:
required:
- network
optional:
- secrets
provides:
integrations:
- type: "ci.jenkins"
displayName: "Jenkins"
configSchema: "./schemas/jenkins-config.json"
capabilities:
- "pipelines"
- "builds"
- "artifacts"
steps:
- type: "jenkins-trigger"
displayName: "Trigger Jenkins Build"
category: "integration"
configSchema: "./schemas/jenkins-trigger-config.json"
inputSchema: "./schemas/jenkins-trigger-input.json"
outputSchema: "./schemas/jenkins-trigger-output.json"
ui:
configScreen: "./ui/config.html"
icon: "./assets/jenkins-icon.svg"
dependencies:
stellaCore: ">=1.0.0"
2. Connector Runtime Surface (Dynamic)
Plugins implement connector interfaces for runtime operations:
// Jenkins connector implementation
class JenkinsConnector implements CIConnector {
private client: JenkinsClient;
async initialize(config: ConnectorConfig, secrets: SecretHandle[]): Promise<void> {
const apiToken = await this.getSecret(secrets, "api_token");
this.client = new JenkinsClient({
baseUrl: config.endpoint,
username: config.username,
apiToken: apiToken,
});
}
async testConnection(): Promise<ConnectionTestResult> {
try {
const crumb = await this.client.getCrumb();
return { success: true, message: "Connected to Jenkins" };
} catch (error) {
return { success: false, message: error.message };
}
}
async listPipelines(): Promise<PipelineInfo[]> {
const jobs = await this.client.getJobs();
return jobs.map(job => ({
id: job.name,
name: job.displayName,
url: job.url,
lastBuild: job.lastBuild?.number,
}));
}
async triggerPipeline(pipelineId: string, params: object): Promise<PipelineRun> {
const queueItem = await this.client.build(pipelineId, params);
return {
id: queueItem.id.toString(),
pipelineId,
status: "queued",
startedAt: new Date(),
};
}
async getPipelineRun(runId: string): Promise<PipelineRun> {
const build = await this.client.getBuild(runId);
return {
id: build.number.toString(),
pipelineId: build.job,
status: this.mapStatus(build.result),
startedAt: new Date(build.timestamp),
completedAt: build.result ? new Date(build.timestamp + build.duration) : null,
};
}
}
3. Step Provider Surface (Execution)
Plugins implement step execution logic:
// Jenkins trigger step implementation
class JenkinsTriggerStep implements StepExecutor {
async *execute(
config: StepConfig,
inputs: StepInputs,
context: StepContext
): AsyncGenerator<StepEvent> {
const connector = await context.getConnector<JenkinsConnector>(config.integrationId);
yield { type: "log", line: `Triggering Jenkins job: ${config.jobName}` };
// Trigger build
const run = await connector.triggerPipeline(config.jobName, inputs.parameters);
yield { type: "output", name: "buildId", value: run.id };
yield { type: "log", line: `Build queued: ${run.id}` };
// Wait for completion if configured
if (config.waitForCompletion) {
yield { type: "log", line: "Waiting for build to complete..." };
while (true) {
const status = await connector.getPipelineRun(run.id);
if (status.status === "succeeded") {
yield { type: "output", name: "status", value: "succeeded" };
yield { type: "result", success: true };
return;
}
if (status.status === "failed") {
yield { type: "output", name: "status", value: "failed" };
yield { type: "result", success: false, message: "Build failed" };
return;
}
yield { type: "progress", progress: 50, message: `Build running: ${status.status}` };
await sleep(config.pollIntervalSeconds * 1000);
}
}
yield { type: "result", success: true };
}
}
Database Schema
-- Plugins
CREATE TABLE release.plugins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plugin_id VARCHAR(255) NOT NULL UNIQUE,
version VARCHAR(50) NOT NULL,
vendor VARCHAR(255) NOT NULL,
license VARCHAR(100),
manifest JSONB NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'discovered' CHECK (status IN (
'discovered', 'loaded', 'active', 'stopped', 'failed', 'degraded'
)),
entrypoint VARCHAR(500) NOT NULL,
last_health_check TIMESTAMPTZ,
health_message TEXT,
installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_plugins_status ON release.plugins(status);
-- Plugin Instances (per-tenant configuration)
CREATE TABLE release.plugin_instances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plugin_id UUID NOT NULL REFERENCES release.plugins(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
config JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_plugin_instances_tenant ON release.plugin_instances(tenant_id);
-- Integration types (populated by plugins)
CREATE TABLE release.integration_types (
id TEXT PRIMARY KEY, -- "scm.github", "ci.jenkins"
plugin_id UUID REFERENCES release.plugins(id),
display_name TEXT NOT NULL,
description TEXT,
icon_url TEXT,
config_schema JSONB NOT NULL, -- JSON Schema for config
capabilities TEXT[] NOT NULL, -- ["repos", "webhooks", "status"]
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
API Endpoints
# Plugin Registry
GET /api/v1/plugins
Query: ?status={status}&capability={type}
Response: Plugin[]
GET /api/v1/plugins/{id}
Response: Plugin (with manifest)
POST /api/v1/plugins/{id}/enable
Response: Plugin
POST /api/v1/plugins/{id}/disable
Response: Plugin
GET /api/v1/plugins/{id}/health
Response: { status, message, diagnostics[] }
# Plugin Instances (per-tenant config)
POST /api/v1/plugin-instances
Body: { pluginId: UUID, config: object }
Response: PluginInstance
GET /api/v1/plugin-instances
Response: PluginInstance[]
PUT /api/v1/plugin-instances/{id}
Body: { config: object, enabled: boolean }
Response: PluginInstance
DELETE /api/v1/plugin-instances/{id}
Response: { deleted: true }
Plugin Security
Capability Declaration
Plugins must declare all required capabilities in their manifest. The system enforces:
- Network Access: Plugins can only access declared hosts
- Secret Access: Plugins receive secrets through controlled injection
- Database Access: No direct database access; API only
- Filesystem Access: Limited to declared paths
Sandbox Enforcement
// Plugin execution is sandboxed
class PluginSandbox {
async execute<T>(
plugin: Plugin,
operation: () => Promise<T>
): Promise<T> {
// 1. Verify capabilities
this.verifyCapabilities(plugin);
// 2. Set resource limits
const limits = this.getResourceLimits(plugin);
await this.applyLimits(limits);
// 3. Create isolated context
const context = await this.createIsolatedContext(plugin);
try {
// 4. Execute with timeout
return await this.withTimeout(
operation(),
plugin.manifest.timeouts.operationMs
);
} catch (error) {
// 5. Log and handle errors
await this.handlePluginError(plugin, error);
throw error;
} finally {
// 6. Cleanup
await context.dispose();
}
}
}
Plugin Failures Cannot Crash Core
// Core orchestration is protected from plugin failures
public sealed class PromotionDecisionEngine
{
public async Task<DecisionResult> EvaluateAsync(
Promotion promotion,
IReadOnlyList<IGateProvider> gates,
CancellationToken ct)
{
var results = new List<GateResult>();
foreach (var gate in gates)
{
try
{
// Plugin provides evaluation logic
var result = await gate.EvaluateAsync(promotion, ct);
results.Add(result);
}
catch (Exception ex)
{
// Plugin failure is logged but doesn't crash core
_logger.LogError(ex, "Gate {GateType} failed", gate.Type);
results.Add(new GateResult
{
GateType = gate.Type,
Status = GateStatus.Failed,
Message = $"Gate evaluation failed: {ex.Message}",
IsBlocking = gate.IsBlocking,
});
}
// Core decides how to aggregate (plugins cannot override)
if (results.Last().IsBlocking && _policy.FailFast)
break;
}
// Core makes final decision
return _decisionAggregator.Aggregate(results);
}
}
Plugin signature verification
Introduced in SPRINT_20260501_001_Plugin_signature_verification (audit B1). Closes the gap where
PluginHost.DetermineTrustLevelderived trust from hardcodedBuiltInPluginIds/TrustedPluginIds/TrustedVendorsallowlists inPluginHostOptions. Trust is now derived cryptographically from a pinned X.509 signing certificate plus SHA-256 digests of the manifest and every plugin assembly.
Trust model: whitelisted-simple X.509
- Each plugin ships a detached signature manifest
plugin.signature.jsonnext to itsplugin.yaml. - The signature pins:
- SHA-256 of
plugin.yaml, - SHA-256 of every assembly the plugin loads,
- the signer’s X.509 leaf certificate (DER, base64),
- the certificate chain to a Stella-internal CA (the OS root store is deliberately ignored — only the offline-kit trust bundle counts),
- the signature itself (RS256, PS256, or ES256).
- SHA-256 of
- The host derives the trust level from the signer thumbprint:
BuiltIn⇔ thumbprint inPluginHostOptions.BuiltInSignerThumbprintsTrusted⇔ thumbprint inPluginHostOptions.TrustedSignerThumbprintsUntrusted⇔ signature verifies but the thumbprint is not pinned.
- A plugin without a valid signature is rejected (the plugin does not load) — except in
Developmenthost environments with the explicit escape hatchPluginHostOptions.AllowUnsignedPluginsInDevelopment = true, in which case the plugin loads asUntrustedwith a structured warning. This mirrors the SCANNERAllowAnonymousFallbackpattern.
Cosign / Fulcio / Rekor are not in scope for this sprint — they are deferred to a follow-up sprint (docs-archive/implplan/SPRINT_20260501_001_plugin_signature_verification_followups.md). The contract is IPluginSignatureValidator; a CosignSignatureValidator implementation in the follow-up sprint plugs in without touching the host.
plugin.signature.json schema (v1)
Schema discriminator: stellaops.plugin.signature/v1.
{
"schema": "stellaops.plugin.signature/v1",
"manifestSha256": "<lowercase hex SHA-256 of plugin.yaml>",
"assemblies": [
{ "path": "plugin.dll", "sha256": "<lowercase hex>" },
{ "path": "ext/extra.dll", "sha256": "<lowercase hex>" }
],
"signerCertificate": "<base64 DER of leaf certificate>",
"certificateChain": ["<base64 DER of intermediate>", "..."],
"signatureAlgorithm": "RS256",
"signature": "<base64 of signature bytes>",
"signedAt": "2026-04-29T14:32:00Z"
}
Field rules:
| Field | Required | Notes |
|---|---|---|
schema | yes | Must be stellaops.plugin.signature/v1. Forward-compatible: producers may add unknown fields; hosts MUST ignore them. |
manifestSha256 | yes | 64 lowercase hex chars. Validator computes SHA-256 of plugin.yaml and compares. |
assemblies[] | yes (≥1) | Every assembly pinned. Extra DLLs in the plugin directory cause ExtraneousAssembly rejection — there is no implicit-trust shadow file. |
assemblies[].path | yes | Resolved relative to the manifest directory. Must not escape with ... |
assemblies[].sha256 | yes | 64 lowercase hex chars. |
signerCertificate | yes | Base64 DER of the signing leaf cert. Must chain to a root in the offline-kit trust bundle. |
certificateChain[] | optional | Base64 DER intermediates, leaf-to-root order. May be empty when the signer chains directly to a trust-bundle root. |
signatureAlgorithm | yes | One of RS256, PS256, ES256 (case-insensitive; EdDSA reserved). |
signature | yes | Base64 of the signature over the canonical TBS bytes (see below). |
signedAt | optional | RFC 3339 timestamp; informational, logged but not enforced. |
Canonical to-be-signed (TBS) bytes
Implemented in PluginSignatureCanonicalBytes.ComputeFor (Plugin.Abstractions):
tbs = utf8(schema) || 0x0A ||
utf8(manifestSha256) || 0x0A ||
(for each entry in assemblies sorted ascending by path:
utf8(path) || 0x1F || utf8(sha256) || 0x0A)
0x0A (LF) terminates each line / entry; 0x1F (ASCII Unit Separator) delimits path from sha256 within an entry. Producers must sort the assemblies array ascending by path before computing the TBS so the signature is stable regardless of input order. The validator independently sorts the on-disk manifest entries by path before recomputing the TBS — re-ordering is not a tampering signal, but a wrong digest is.
Validation pipeline (decision flow)
1. plugin.signature.json present and parses ? → no → MissingSignatureFile / MalformedSignatureFile
│ yes
2. schema discriminator matches v1 ? → no → UnknownSchema
│ yes
3. SHA-256(plugin.yaml) == manifestSha256 ? → no → ManifestDigestMismatch
│ yes
4. every pinned assembly present + matches ? → no → AssemblyMissing / AssemblyDigestMismatch
│ yes
5. no extra DLLs next to plugin.yaml ? → no → ExtraneousAssembly
│ yes
6. signerCertificate parses as X.509 ? → no → CertificateParseFailed
│ yes
7. NotBefore <= now <= NotAfter ? → no → CertificateExpired
│ yes
8. X509Chain.Build against IPluginTrustRoot ? → no → CertificateChainInvalid
│ yes
9. signatureAlgorithm in {RS256,PS256,ES256} ? → no → UnknownAlgorithm
│ yes
10. IAsymmetricSignatureVerifier.Verify(tbs) ? → no → SignatureInvalid
│ yes
▼
PluginSignatureValidationResult.IsValid = true
Claims = { signerSubject, issuer, thumbprint, NotBefore, NotAfter, manifestSha256, assemblies }
The host then maps the resulting signer thumbprint to a PluginTrustLevel:
thumbprint ∈ BuiltInSignerThumbprints → BuiltIn
thumbprint ∈ TrustedSignerThumbprints → Trusted
otherwise → Untrusted
(or, if IsValid == false: reject — the plugin does not load)
Trust root (offline-kit handoff)
The validator never consults the OS root store. The only trust anchor is IPluginTrustRoot, populated at host startup from PluginHostOptions.TrustRootBundlePath (defaults to etc/stella/plugin-trust-roots/). The bundle ships as part of the offline kit — see docs/modules/airgap/architecture.md — and rotating it requires a kit refresh. A missing or empty bundle in Production is a fatal startup error (the host fails to start rather than silently trusting nothing or trusting everything).
Architectural rules (enforced by conformance tests)
- Asymmetric signature verification routes through
StellaOps.Cryptography.IAsymmetricSignatureVerifier. DirectRSA.VerifyData/ECDsa.VerifyDatacalls are forbidden outsidesrc/__Libraries/StellaOps.Cryptography/**andsrc/Cryptography/**. Enforced byAsymmetricVerifyBclCallSiteConformanceTests. X509Chain.Buildis restricted tosrc/Plugin/StellaOps.Plugin.Host/Trust/**(the only place that builds a chain for plugin signature validation). Other legacy uses (RFC 3161 timestamping, TLS pin validation, OCSP) are tracked in the conformance test’sKnownLegacyOffenderslist and migrated under follow-up sprints.- HMAC operations route through
IHmacAlgorithm(HmacBclCallSiteConformanceTests).
License declaration (SPDX)
Introduced in SPRINT_20260501_002_Plugin_license_enforcement (audit B2).
Every plugin manifest must declare its license under info.license (a.k.a. info.licenseId in JSON form). The string is parsed as an SPDX 2.3 license expression and evaluated against an operator-configured allow / deny list at discovery time, before the signature pipeline runs. Non-compliant plugins are filtered out of the discovery result and emitted as a PluginDiscoveryEvent of kind LicenseRejected.
The parser is hand-vendored under src/Plugin/StellaOps.Plugin.Abstractions/Licensing/SpdxExpression.cs (no NuGet SPDX dependency, per repo §2.6 dependency-license gate). It accepts the full grammar: licence ids, the + suffix, WITH exception, AND / OR, parentheses, and LicenseRef-* references.
Default policy
| List | Default contents |
|---|---|
| Allow | BUSL-1.1, Apache-2.0, MIT, BSD-2-Clause, BSD-3-Clause, ISC, LLVM-exception, Apache-2.0 WITH LLVM-exception |
| Deny | GPL-1.0/2.0/3.0 (-only / -or-later variants), AGPL-1.0/3.0, SSPL-1.0, Commons-Clause |
LGPL is not denied by default — operators that need it must opt in explicitly.
Configuration
Bound under Plugins:LicensePolicy:* (DI extension AddPluginLicenseValidation). Settings:
| Key | Type | Default | Notes |
|---|---|---|---|
AllowlistedLicenses | string[] | (canonical default — see above) | When supplied, replaces the default list. |
DeniedLicenses | string[] | (canonical default — see above) | Replaces the default list. Deny wins on conflict. |
AllowMissingLicense | bool | false | Production deployments must leave this false. |
AllowLicenseRefWildcard | bool | false | When true, any LicenseRef-* id is accepted. |
Note: the configuration binder appends to existing
IList<string>properties. To avoid silently mixing operator-supplied entries with the built-in defaults,LicensePolicyOptionsships empty lists; the canonical defaults are applied at validator construction iff the bound list is empty. Tests undersrc/Plugin/__Tests/StellaOps.Plugin.Host.Tests/Licensing/LicenseValidationDIRegistrationTests.cspin this behaviour.
Rejection reasons
LicenseValidationResult.Reason reports one of:
MissingLicenseDeclaration— manifest had nolicensefield andAllowMissingLicensewas false.MalformedLicenseExpression— the declared string failed SPDX 2.3 parsing. The first 256 characters of the input are echoed inRejectingTermsfor log triage; longer inputs are truncated with an ellipsis.DisallowedLicense— parsed correctly but the policy denied at least one required term.RejectingTermslists the SPDX identifiers responsible.
Migration: existing plugins
Existing plugins authored before SPRINT_20260501_002 may have shipped without a license declaration. With the default policy, those plugins are rejected at discovery in production.
Canonical fix — add the following to each plugin.yaml:
info:
id: ...
name: ...
version: ...
vendor: ...
license: BUSL-1.1 # or another SPDX id permitted by site policy
A sweep PR adding license: BUSL-1.1 to all in-tree plugin manifests is tracked as a follow-up to this sprint (see Decisions & Risks). Operators running non-stellaops plugins locally must pick the actual upstream SPDX id; do not blanket-apply BUSL.
Reference
- Sprint:
docs/implplan/SPRINT_20260501_002_Plugin_license_enforcement.md - Implementation:
src/Plugin/StellaOps.Plugin.Abstractions/Licensing/SpdxExpression.cssrc/Plugin/StellaOps.Plugin.Abstractions/Licensing/DefaultLicenseValidator.cssrc/Plugin/StellaOps.Plugin.Host/Discovery/FileSystemPluginDiscovery.cssrc/Plugin/StellaOps.Plugin.Host/Discovery/EmbeddedPluginDiscovery.cs
- Tests:
src/Plugin/__Tests/StellaOps.Plugin.{Abstractions,Host}.Tests/Licensing/andsrc/Plugin/__Tests/StellaOps.Plugin.Host.Tests/Discovery/LicenseFilteringTests.cs
References
- Module Overview
- Integration Hub
- Workflow Engine
- Connector Interface
- Cryptography architecture
- Air-gap architecture (trust-bundle distribution)
- Sprint:
docs/implplan/SPRINT_20260501_001_Plugin_signature_verification.md - Sprint:
docs/implplan/SPRINT_20260501_002_Plugin_license_enforcement.md - Sprint:
docs/implplan/SPRINT_20260501_054_Plugin_os_process_confinement.md
