Plugin sandbox confinement events — contract

Sprint 20260501-054 PLG-OS-CONF-05. Defines the structured-event contract for plugin sandbox confinement (filesystem workspace, OS confinement profile attach, resource limiter, and lifecycle denial paths). This document is the binding spec. Implementations live under src/Plugin/StellaOps.Plugin.Sandbox/Diagnostics/.

1. Purpose

Plugin sandbox confinement is operator-facing security infrastructure. Operators need to know:

Free-form ILogger lines are too soft for these questions: log formatters strip structure, log levels conflate “policy denial” with “unexpected exception”, and consumers cannot reliably assert event shape in tests. This contract gives every confinement event a stable name, severity, typed payload, and correlation id so SOC consumers, audit pipelines, and unit tests can reason about confinement deterministically.

2. Scope

In scope:

Out of scope:

3. Naming convention

Event names use dotted lowercase with a fixed prefix:

plugin.sandbox.<surface>.<verb>

Where <surface> is the boundary the event describes (workspace, confinement, resource_limiter, lifecycle) and <verb> is the past-tense action.

Names are stable strings. The constants in PluginSandboxEventName are the only authoritative values; do not construct names from interpolation. Name changes are backwards-incompatible and require a migration entry in §10.

4. Event taxonomy

4.1 Workspace events

NameSeverityWhen
plugin.sandbox.workspace.strategy_selectedInfoA workspace strategy is chosen for a sandbox start (kernel-overlay, fuse-overlayfs, materialized-copy, junction).
plugin.sandbox.workspace.degraded_fallbackWarnMaterialized-copy strategy is in use because operator opt-in or because primary strategy was unavailable.
plugin.sandbox.workspace.mount_failedErrorKernel overlay or fuse-overlayfs mount returned non-zero; sandbox start fails closed.
plugin.sandbox.workspace.cleanup_failedWarnUnmount or scratch removal failed; operator action documented in docs/ops/plugin-sandbox-linux.md.
plugin.sandbox.workspace.workspace_in_useErrorA previous sandbox left state under the sandbox root; new start refuses.

4.2 OS confinement events

NameSeverityWhen
plugin.sandbox.confinement.profile_attachedInfoLinux AppArmor or SELinux profile attached successfully via aa_change_onexec / equivalent.
plugin.sandbox.confinement.profile_missingErrorUntrusted plugin start; profile required but not loaded into the kernel.
plugin.sandbox.confinement.profile_deniedWarnProfile fired a denial during plugin runtime; sandbox continues. Production emitter on Linux is ApparmorAuditLogScraper (sprint 20260504-002 KENF-AA-02).
plugin.sandbox.confinement.job_object_attachedInfoWindows Job Object created, configured, and the child process verified as a member.
plugin.sandbox.confinement.job_object_limit_hitWarnMemory or active-process limit triggered; OS terminated the child.
plugin.sandbox.confinement.fail_closedErrorSandbox start was denied because OS confinement could not be verified. The child process is killed before bridge connection.

4.3 Resource limiter events

NameSeverityWhen
plugin.sandbox.resource_limiter.appliedInfoCPU/memory/process/file-handle limits applied to a child.
plugin.sandbox.resource_limiter.exceededWarnLimit check found usage above the configured cap.
plugin.sandbox.resource_limiter.releasedInfoLimits removed during normal shutdown.

4.4 Lifecycle denial events

NameSeverityWhen
plugin.sandbox.lifecycle.start_deniedErrorSandbox start was refused by the host (signature trust, manifest validation, or OS confinement gate). Always paired with one of the more specific events above as the proximate cause.

5. Severity levels

PluginSandboxEventSeverity is a four-value enum, not a stringly-typed field. The mapping to Microsoft.Extensions.Logging.LogLevel for the default LoggerPluginSandboxEventEmitter bridge:

SeverityLogLevelMeaning
InfoInformationNormal policy decision. Routine.
WarnWarningDegraded posture, expected limit hit, recoverable failure.
ErrorErrorFail-closed denial. The action this event describes did not succeed; the sandbox refused to start or terminated.
CriticalCriticalReserved for events that indicate a confinement bypass (e.g. OS reported “not in job” when it should be). Currently unemitted; reserved so future use does not need a contract change.

Error is deterministic policy denial, not an exceptional condition. A typed Reason field carries the canonical reason code; the Exception field is optional and only populated for unexpected failures.

6. Field schema

The wire shape of every event is the PluginSandboxEvent record:

public sealed record PluginSandboxEvent
{
    public required string Name { get; init; }
    public required PluginSandboxEventSeverity Severity { get; init; }
    public required DateTimeOffset Timestamp { get; init; }
    public required string SandboxId { get; init; }
    public string? PluginId { get; init; }
    public PluginTrustLevel? TrustLevel { get; init; }
    public string? Strategy { get; init; }
    public string? Reason { get; init; }
    public Exception? Exception { get; init; }
    public IReadOnlyDictionary<string, string?> Diagnostics { get; init; }
        = ImmutableDictionary<string, string?>.Empty;
}

Field rules:

The record is immutable and safe to log: implementations must not mutate it after construction. Diagnostics is stored as IReadOnlyDictionary<string, string?> for cheap consumption; the default emitter does not allocate per-call beyond the record itself and the dictionary backing storage.

7. Emitter contract

public interface IPluginSandboxEventEmitter
{
    void Emit(PluginSandboxEvent evt);
}

Rules:

Default registrations:

services.AddPluginSandboxEvents();
// or:
services.AddSingleton<IPluginSandboxEventEmitter, LoggerPluginSandboxEventEmitter>();

The Null emitter is provided for tests that do not assert on event emission; production wiring must use LoggerPluginSandboxEventEmitter or a richer adapter.

8. Correlation with existing telemetry

SurfaceHow it correlates
ILoggerThe default emitter writes the event as a structured log entry with EventId.Name = evt.Name, properties SandboxId, PluginId, TrustLevel, Strategy, Reason, plus every Diagnostics key as its own field. Existing ILogger consumers see no breakage and gain typed scope.
Activity.CurrentIf present, the emitter copies TraceId/SpanId into Diagnostics["trace_id"] / Diagnostics["span_id"] so distributed-trace consumers can stitch confinement events into existing traces.
IPluginObservability (plugin-author surface)Plugin business events use SandboxId as the correlation key; SOC consumers can join confinement events with plugin events by SandboxId without invasive changes to the plugin contract.
Platform audit (docs/security/audit-events.md)Out of scope for this sprint. A future bridge adapter can subscribe to a typed IObservable<PluginSandboxEvent> (not in the v1 interface) and emit audit-store rows. The contract is forward-compatible because event names and reasons are stable.

9. Suppression and rate-limiting

The contract is intentionally low-volume — confinement events fire on sandbox lifecycle transitions, not per-syscall. Operators should not need rate-limiting in normal operation.

Two exceptions where rate-limiting is permitted:

  1. plugin.sandbox.confinement.profile_denied — a misbehaving plugin could emit thousands of denial events per second. Implementations may aggregate by (sandboxId, reason) over a fixed window (default: 1 second) and emit a single Warn with Diagnostics["denial_count"] = aggregate.
  2. plugin.sandbox.resource_limiter.exceeded — same reasoning; aggregate per window.

Error and Critical events are never rate-limited.

10. Backwards compatibility

A migration log lives at the bottom of this document. Every incompatible change adds an entry.

11. Test contract

Every confinement call site that emits an event must have a unit test that:

  1. Constructs the call site with a test emitter (typically the provided RecordingPluginSandboxEventEmitter from the test helpers).
  2. Triggers the action.
  3. Asserts the event was emitted exactly once with the expected Name (string match against the constant), Severity, and non-null SandboxId.
  4. Asserts the Strategy and Reason fields where the contract requires them.

Tests must not match on free-text message bodies — those are formatter-controlled and rotate with logger configuration. Match on structured fields only.

12. Migration log

DateChangeReason
2026-05-02Contract authored.Sprint 054 PLG-OS-CONF-05 unblock.
2026-05-05confinement.profile_denied gains a production emitter (ApparmorAuditLogScraper). Diagnostics keys stabilised: profile_name, operation, requested_path, requested_mask, denied_mask, comm, pid, audit_serial, audit_timestamp. Backwards compatible — only new keys added.Sprint 20260504-002 KENF-AA-02.

13. Open follow-ups