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:
- What policy decision did the host take just now? (strategy selected, profile attached, fail-closed denial)
- Which sandbox/plugin/process is involved? (correlation)
- Was an OS-level limit hit? (Job Object memory/process, AppArmor denial)
- Why did sandbox start fail? (deterministic reason code, not a free-text exception message)
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:
- Plugin sandbox lifecycle events around the OS confinement boundary (
OutOfProcessOverlayandOutOfProcessConfined). - Filesystem workspace strategy selection and degraded-posture events.
- Resource limiter attach/limit-hit/release events.
- Linux AppArmor / SELinux profile attach / denial events.
- Windows Job Object attach / verification / kill events.
Out of scope:
- Plugin-author observability (
IPluginObservability): plugin emits business events; sandbox emits confinement events. They share correlation but live on different surfaces. - Authority audit (
docs/security/audit-events.md): plugin sandbox events bridge into platform audit through a separate adapter; this contract is the source side, not the audit-store schema. - Plugin gRPC bridge protocol events: those are tracked under bridge diagnostics, not confinement.
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
| Name | Severity | When |
|---|---|---|
plugin.sandbox.workspace.strategy_selected | Info | A workspace strategy is chosen for a sandbox start (kernel-overlay, fuse-overlayfs, materialized-copy, junction). |
plugin.sandbox.workspace.degraded_fallback | Warn | Materialized-copy strategy is in use because operator opt-in or because primary strategy was unavailable. |
plugin.sandbox.workspace.mount_failed | Error | Kernel overlay or fuse-overlayfs mount returned non-zero; sandbox start fails closed. |
plugin.sandbox.workspace.cleanup_failed | Warn | Unmount or scratch removal failed; operator action documented in docs/ops/plugin-sandbox-linux.md. |
plugin.sandbox.workspace.workspace_in_use | Error | A previous sandbox left state under the sandbox root; new start refuses. |
4.2 OS confinement events
| Name | Severity | When |
|---|---|---|
plugin.sandbox.confinement.profile_attached | Info | Linux AppArmor or SELinux profile attached successfully via aa_change_onexec / equivalent. |
plugin.sandbox.confinement.profile_missing | Error | Untrusted plugin start; profile required but not loaded into the kernel. |
plugin.sandbox.confinement.profile_denied | Warn | Profile 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_attached | Info | Windows Job Object created, configured, and the child process verified as a member. |
plugin.sandbox.confinement.job_object_limit_hit | Warn | Memory or active-process limit triggered; OS terminated the child. |
plugin.sandbox.confinement.fail_closed | Error | Sandbox start was denied because OS confinement could not be verified. The child process is killed before bridge connection. |
4.3 Resource limiter events
| Name | Severity | When |
|---|---|---|
plugin.sandbox.resource_limiter.applied | Info | CPU/memory/process/file-handle limits applied to a child. |
plugin.sandbox.resource_limiter.exceeded | Warn | Limit check found usage above the configured cap. |
plugin.sandbox.resource_limiter.released | Info | Limits removed during normal shutdown. |
4.4 Lifecycle denial events
| Name | Severity | When |
|---|---|---|
plugin.sandbox.lifecycle.start_denied | Error | Sandbox 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:
| Severity | LogLevel | Meaning |
|---|---|---|
Info | Information | Normal policy decision. Routine. |
Warn | Warning | Degraded posture, expected limit hit, recoverable failure. |
Error | Error | Fail-closed denial. The action this event describes did not succeed; the sandbox refused to start or terminated. |
Critical | Critical | Reserved 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:
Name— one of the constants inPluginSandboxEventName. The emitter rejects unknown names inDEBUGbuilds and logs a diagnostic in release builds (a missing constant is almost always a bug; failing closed in test discovers it).SandboxId— required, non-empty, the same id used byIPluginSandboxWorkspaceFactoryandProcessSandbox. This is the single correlation key.PluginId/TrustLevel— null is permitted only at very early lifecycle stages where they are not yet known. Most events populate them.Strategy— strategy or profile name. Allowed values:kernel-overlay,fuse-overlayfs,materialized-copy,junction,apparmor:<profile>,selinux:<context>,windows:job-object.Reason— short, stable string forError/Warnpaths. Allowed reason codes are namespaced under the event surface, e.g.confinement.profile_missing.aa_status_unknown. Reasons are enumerated inPluginSandboxEventReasonconstants.Diagnostics— open-ended typed strings for extra context (paths, kernel versions, error codes). Use stable keys; avoid PII and credentials.Exception— only populated when the event was triggered by an unexpected exception. Deterministic policy denials do not need it.
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:
- Synchronous, non-throwing. The emitter is on hot paths (sandbox start, every limit check). It must never throw, and must return quickly. Implementations that need async work (durable audit storage) buffer internally and flush off the hot path.
- Thread-safe.
Emitis called from any thread including resource-limiter background tasks. - Best-effort delivery. Lossy delivery is acceptable for
Info;Error/Criticalevents should never be dropped silently. If an emitter cannot persist anErrororCritical, it bridges toILoggeratLogLevel.Criticalas a fallback.
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
| Surface | How it correlates |
|---|---|
ILogger | The 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.Current | If 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:
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 singleWarnwithDiagnostics["denial_count"]= aggregate.plugin.sandbox.resource_limiter.exceeded— same reasoning; aggregate per window.
Error and Critical events are never rate-limited.
10. Backwards compatibility
- Adding a new event name: backwards compatible. Subscribers unaware of the name see it as a string; structured-log consumers see a new event type.
- Adding a new field: backwards compatible if the field is nullable or has a default. Required fields cannot be added without a major version bump of the contract.
- Renaming a field: backwards incompatible. Add the new field alongside the old, deprecate the old in a release, remove in the next major version.
- Renaming an event: backwards incompatible. Emit both names for one release cycle.
- Removing a reason: backwards incompatible. Reasons are stable for SOC playbook stability.
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:
- Constructs the call site with a test emitter (typically the provided
RecordingPluginSandboxEventEmitterfrom the test helpers). - Triggers the action.
- Asserts the event was emitted exactly once with the expected
Name(string match against the constant),Severity, and non-nullSandboxId. - Asserts the
StrategyandReasonfields 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
| Date | Change | Reason |
|---|---|---|
| 2026-05-02 | Contract authored. | Sprint 054 PLG-OS-CONF-05 unblock. |
| 2026-05-05 | confinement.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
- Linux wiring. AppArmor profile attach lands in sprint 054 PLG-OS-CONF-02 (
profile_attached,profile_missing,fail_closed). The kernel-enforcedprofile_deniedemitter ships in sprint 20260504-002 KENF-AA-02 asApparmorAuditLogScraper— opt-in viaSandboxConfiguration.AppArmor.EnableAuditLogScraping(defaultfalse) and consumes/var/log/audit/audit.log(path overridable viaSandboxConfiguration.AppArmor.AuditLogPath). SELinux sample (PLG-OS-CONF-03) remains a sample-only carve-out perdocs/security/plugin-sandbox-threat-model.md§7. - Audit-store bridge. Out of scope for v1. Tracked as a future Plugin/Telemetry sprint that defines the durable persistence path and operator query surface.
IObservable<PluginSandboxEvent>source. A reactive surface for audit consumers. Not in the v1 emitter contract; will be added if the audit bridge requires it.
