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 Trusted plugins ran in a custom AssemblyLoadContext (escapable via reflection) and BuiltIn ran 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 levelSandbox modeProcess boundaryFilesystemOS confinementWhere defined
BuiltInInProcessHardenedHost 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)
TrustedOutOfProcessOverlayOut-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)noneSandboxModeMatrix.FromTrust(Trusted)
UntrustedOutOfProcessConfinedOut-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 QueryInformationJobObjectSandboxModeMatrix.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/):

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.

SettingTrusted defaultNotes
Process isolationtrueTrusted starts through ProcessSandbox and must not use the host ALC execution path.
Max memory2048 MBHigher ceiling than the untrusted default.
Max CPU50%Higher ceiling than the untrusted default.
Max disk1024 MBHigher ceiling than the untrusted default.
Max network bandwidth100 MbpsHigher ceiling than the untrusted default.
Startup timeout60 sProcess/bridge/plugin initialization budget.
Operation timeout5 minDefault host-side Trusted operation budget.
Shutdown timeout30 sGraceful stop budget before forced cleanup.
Health-check timeout10 sPer 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:

PathPurpose
lowerVerified plugin install directory, treated as read-only source.
upperPer-sandbox scratch/write target.
workReserved OS workspace state for overlayfs/fuse-overlayfs/junction machinery.
mergedWorking 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:

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 stateOutcome
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 missingUntrusted: SandboxStartupRefusedException(AppArmorRequiredButUnavailable).
LSM present, libapparmor present, profile not loadedUntrusted: SandboxStartupRefusedException(AppArmorProfileLoadFailed).
LSM present, libapparmor present, profile loadedaa_change_onexec("stellaops-plugin-sandbox") via P/Invoke.
LSM present, libapparmor missing, aa-exec present, profile loadedFallback: 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:

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:

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):

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):


Modules

Module: plugin-registry

AspectSpecification
ResponsibilityPlugin discovery; versioning; manifest management
Data EntitiesPlugin, PluginManifest, PluginVersion
Events Producedplugin.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

AspectSpecification
ResponsibilityPlugin lifecycle management
Dependenciesplugin-registry, plugin-sandbox
Events Producedplugin.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

AspectSpecification
ResponsibilityIsolation; resource limits; security
EnforcementProcess 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

AspectSpecification
ResponsibilitySDK for plugin development
LanguagesC#, 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.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:

  1. Network Access: Plugins can only access declared hosts
  2. Secret Access: Plugins receive secrets through controlled injection
  3. Database Access: No direct database access; API only
  4. 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.DetermineTrustLevel derived trust from hardcoded BuiltInPluginIds / TrustedPluginIds / TrustedVendors allowlists in PluginHostOptions. 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

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:

FieldRequiredNotes
schemayesMust be stellaops.plugin.signature/v1. Forward-compatible: producers may add unknown fields; hosts MUST ignore them.
manifestSha256yes64 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[].pathyesResolved relative to the manifest directory. Must not escape with ...
assemblies[].sha256yes64 lowercase hex chars.
signerCertificateyesBase64 DER of the signing leaf cert. Must chain to a root in the offline-kit trust bundle.
certificateChain[]optionalBase64 DER intermediates, leaf-to-root order. May be empty when the signer chains directly to a trust-bundle root.
signatureAlgorithmyesOne of RS256, PS256, ES256 (case-insensitive; EdDSA reserved).
signatureyesBase64 of the signature over the canonical TBS bytes (see below).
signedAtoptionalRFC 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)


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

ListDefault contents
AllowBUSL-1.1, Apache-2.0, MIT, BSD-2-Clause, BSD-3-Clause, ISC, LLVM-exception, Apache-2.0 WITH LLVM-exception
DenyGPL-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:

KeyTypeDefaultNotes
AllowlistedLicensesstring[](canonical default — see above)When supplied, replaces the default list.
DeniedLicensesstring[](canonical default — see above)Replaces the default list. Deny wins on conflict.
AllowMissingLicenseboolfalseProduction deployments must leave this false.
AllowLicenseRefWildcardboolfalseWhen 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, LicensePolicyOptions ships empty lists; the canonical defaults are applied at validator construction iff the bound list is empty. Tests under src/Plugin/__Tests/StellaOps.Plugin.Host.Tests/Licensing/LicenseValidationDIRegistrationTests.cs pin this behaviour.

Rejection reasons

LicenseValidationResult.Reason reports one of:

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


References