Plugin sandbox on Linux — operator runbook

Sprint 20260501-052 PLG-LINUX-OVL-06. Operator-facing companion to the architecture in docs/modules/release-orchestrator/modules/plugin-system.md“Workspace layout seam”. Architecture-level rationale lives there; this document covers what an operator needs to know to deploy, configure, diagnose, and recover the Linux plugin sandbox.

Audience

Site reliability engineers, platform operators, and security responders who own a Stella Ops host running Linux plugin workloads. Plugin authors should continue to follow the manifest/SDK guidance in the Plugin module dossier; this document is only about the host filesystem and process boundary.

Strategy selection

The Linux workspace seam (IPluginSandboxWorkspaceFactory, DefaultPluginSandboxWorkspaceFactory.Prepare) materializes exactly one strategy per sandbox start. The three Linux-relevant strategies are:

  1. Kernel overlayfs (PluginSandboxWorkspaceStrategy.KernelOverlay, strategy name kernel-overlay) — runs mount -t overlay overlay -o lowerdir=<lower>,upperdir=<upper>,workdir=<work> <merged>. Requires a kernel that advertises overlay in /proc/filesystems, CAP_SYS_ADMIN in the effective set of the Stella Ops host process, and a sandbox root on a filesystem the kernel accepts as overlay-able (avoid tmpfs upper layers on RHEL family hosts; ext4/xfs/btrfs upper is fine).
  2. fuse-overlayfs (PluginSandboxWorkspaceStrategy.FuseOverlay, strategy name fuse-overlayfs) — runs fuse-overlayfs -o lowerdir=<lower>,upperdir=<upper>,workdir=<work> <merged>. Requires the fuse-overlayfs binary in PATH and the fuse kernel module loaded. Useful inside rootless containers.
  3. Materialized copy (compatibility) (PluginSandboxWorkspaceStrategy.MaterializedCopy, strategy name materialized-copy) — the host copies the verified plugin install directory into merged and exposes the copy. Reads and writes both target the copy; lower is never mounted. This is flagged degraded (PluginSandboxWorkspace.IsDegraded == true) whenever the sandbox mode requires an overlay filesystem (OutOfProcessOverlay/OutOfProcessConfined).

Reality check (verified against DefaultPluginSandboxWorkspaceFactory.ResolveStrategy, 2026-05-29). There is no automatic kernel→fuse→copy priority fallback in the current code. ResolveStrategy returns the strategy named in SandboxWorkspaceOptions.ForcedStrategy when one is set, and otherwise always returns MaterializedCopy. The kernel-overlay and fuse-overlayfs paths are only reached when the caller explicitly sets ForcedStrategy. Materialized copy therefore remains the default even for OutOfProcessConfined/Untrusted workloads — it is marked degraded in that case but is not yet suppressed. An auto-selecting fallback chain (probe kernel overlay → fall back to fuse-overlayfs → fail closed) is roadmap, not shipped. Treat the numbered list above as the set of available strategies, not an automatic priority order.

The one safety guard that is enforced: when the mode requires an overlay filesystem and SandboxWorkspaceOptions.AllowMaterializedCopyFallback is set to false while no ForcedStrategy is supplied, ResolveStrategy fails closed with plugin.workspace.overlay_required rather than silently copying. AllowMaterializedCopyFallback defaults to true.

Selection is deterministic: the same options always choose the same strategy. When a forced kernel-overlay or fuse-overlayfs mount is rejected by the kernel, the sandbox fails closed with plugin.workspace.mount_failed rather than degrading to a copy.

Workspace layout

Every sandbox start materializes a fresh, per-invocation upper/work/merged triple under <sandbox-root>/<sandbox-id>/. The lower layer is not a subdirectory of the sandbox root — it is the verified plugin install directory itself, referenced in place as the read-only overlay lower layer:

lower   -> the verified plugin install directory (the directory containing the
           plugin assembly), referenced read-only — NOT copied or relocated
           under the sandbox root

<sandbox-root>/<sandbox-id>/
  upper/    -> writable scratch layer (per-invocation, deleted on cleanup)
  work/     -> overlayfs work directory (per-invocation, deleted on cleanup)
  merged/   -> mountpoint exposed to the plugin process as its working directory

(PluginSandboxWorkspaceLayout records Lower = the plugin install directory and Upper/Work/Merged = <root>/<sandbox-id>/{upper,work,merged}.)

The host process never mutates lower directly. Under the overlay strategies all writes from the plugin land in upper. After the sandbox stops, merged is unmounted and the per-sandbox root (upper/work/merged) is removed; the plugin install directory is untouched.

Materialized-copy exception. Under the materialized-copy strategy lower is not mounted at all: the install directory is recursively copied into merged and the plugin reads and writes the copy directly. There is no read-only lower layer protecting the original in that mode — the install directory stays untouched only because the copy is a separate tree.

The factory validates paths before any directory is created: it rejects non-absolute paths, parent-directory traversal (..), source/workspace overlap, and (when RejectReparsePoints is true, the default) symlinks or reparse points anywhere inside the plugin install directory.

Operator settings

Caveat — these are code-level configuration properties, not environment variables (verified 2026-05-29). The sandbox reads its workspace and AppArmor settings from SandboxConfiguration / SandboxWorkspaceOptions / AppArmorOptions objects constructed in process (see SandboxFactory). There is no environment-variable binding layer in the sandbox library: the STELLAOPS_PLUGIN_SANDBOX_* names (…_LINUX_STRATEGY, …_ROOT, …_ALLOW_COPY_FALLBACK, …_OS_CONFINEMENT) are not read by any sandbox code and do not configure the sandbox. (One CI test reads STELLAOPS_PLUGIN_SANDBOX_ROOT to locate a runner-provisioned sandbox root, but the production factory does not.) An env-var/host-config binding for these properties is roadmap.

Setting (code property)TypeDefaultPurpose
SandboxWorkspaceOptions.ForcedStrategyPluginSandboxWorkspaceStrategy?null (→ MaterializedCopy)Forces a strategy: KernelOverlay, FuseOverlay, MaterializedCopy, or WindowsJunction. When null, the factory always picks MaterializedCopy (no auto-detect). Forced Linux overlay strategies fail closed on non-Linux hosts; missing mount binaries surface plugin.workspace.mount_command_missing.
SandboxWorkspaceOptions.RootDirectorystring?null (→ <temp>/stellaops-sandbox)Parent directory under which per-sandbox <sandbox-id>/ workspaces are created. When unset, Path.GetTempPath() + stellaops-sandbox is used. The AppArmor profile and the manual triage commands below assume the conventional /var/lib/stellaops/plugin-sandbox; set RootDirectory (or the per-sandbox WorkingDirectory) to match if you rely on that path. Must support the chosen strategy.
SandboxWorkspaceOptions.AllowMaterializedCopyFallbackbooltrueWhen false and the mode requires an overlay and no ForcedStrategy is set, the factory fails closed with plugin.workspace.overlay_required instead of copying. With the default true, materialized copy is permitted (and flagged degraded for overlay modes).
SandboxWorkspaceOptions.RejectReparsePointsbooltrueRejects symlinks/reparse points anywhere in the plugin install directory before workspace creation (plugin.workspace.reparse_point_rejected).
SandboxConfiguration.AppArmor.RequireAppArmorboolfalse (flipped to OperatingSystem.IsLinux() for the Untrusted trust level)When true, sandbox start fails closed (SandboxStartupRefusedExceptionplugin.sandbox.os_confinement_failed) if the AppArmor profile cannot be attached.
SandboxConfiguration.AppArmor.ProfileNamestringstellaops-plugin-sandboxProfile attached via aa_change_onexec / aa-exec. Matches devops/linux/apparmor/stellaops-plugin-sandbox.profile.
SandboxConfiguration.AppArmor.ProfilePathstring?nullOptional absolute path. When set, EnsureProfileLoadedAsync tries to load the profile before attach; otherwise the operator must load it out-of-band (e.g. a systemd unit at host start).
SandboxConfiguration.AppArmor.EnableAuditLogScrapingboolfalseEnables ApparmorAuditLogScraper (sprint 20260504-002 KENF-AA-02). When true, the host tails the kernel audit log and emits plugin.sandbox.confinement.profile_denied events for AppArmor denials. Safe to enable on hosts without auditd: the scraper detects the missing log, emits a single startup warning, and enters a no-op posture. Not auto-wired — the scraper is a library type that the hosting layer must construct and StartAsync; toggling this property alone does not start it.
SandboxConfiguration.AppArmor.AuditLogPathstring/var/log/audit/audit.logFilesystem path the scraper tails. Override only when auditd is configured to dispatch to a custom path (e.g. a unified observability sidecar reading /dev/kmsg).

Persisted operator policy (whether Untrusted plugins may run on hosts without verified OS confinement) is not an environment variable and must not be set by env. It lives in the platform settings store; default production behavior is fail-closed.

The factory also exports workspace metadata to the spawned plugin process through these environment variables (set by ProcessSandbox, read by the plugin host, not operator-tunable): STELLAOPS_SANDBOX_WORKSPACE_STRATEGY, STELLAOPS_SANDBOX_WORKSPACE_DEGRADED, STELLAOPS_SANDBOX_WORKSPACE_LOWER, STELLAOPS_SANDBOX_WORKSPACE_UPPER, STELLAOPS_SANDBOX_WORKSPACE_WORK, and STELLAOPS_SANDBOX_WORKSPACE_MERGED.

Degraded-posture warning

ProcessSandbox.StartAsync emits a structured warning log line (not a structured event) when the prepared workspace is flagged degraded (PluginSandboxWorkspace.IsDegraded). In practice that is the materialized-copy strategy running under an overlay-requiring mode (OutOfProcessOverlay/OutOfProcessConfined); a materialized-copy workspace for a non-overlay mode is not flagged degraded and does not log this line. The log line includes:

Verified against ProcessSandbox.StartAsync (2026-05-29): the warning log carries no reason field (there is no opt-in vs prerequisite-missing distinction in the emitted line), and it fires off IsDegraded, not off “every materialized-copy start”. The previous wording on both points was incorrect.

The structured confinement-event subsystem from sprint 054 PLG-OS-CONF-05 has landed (IPluginSandboxEventEmitter, PluginSandboxEvent, and the event-name catalog including plugin.sandbox.workspace.degraded_fallback). However, the workspace factory does not yet emit through it — it has no event-emitter dependency, so the degraded-workspace signal is still only the ProcessSandbox warning log above. (The AppArmor attach path and the Windows Job Object path do emit structured events; the workspace-strategy path does not.) Operators should alert on this log line and treat any production occurrence as an incident-grade degradation, not a normal operating mode.

AppArmor kernel-denial events (auditd scraper)

Sprint 20260504-002 KENF-AA-02.

When SandboxConfiguration.AppArmor.EnableAuditLogScraping is set to true, the hosting layer is expected to construct an ApparmorAuditLogScraper and call StartAsync; the scraper tails SandboxConfiguration.AppArmor.AuditLogPath (default /var/log/audit/audit.log). For every line carrying the kernel signature apparmor="DENIED" that also has a parseable msg=audit(...) prefix and a non-empty profile= and operation=, the scraper emits one plugin.sandbox.confinement.profile_denied event via the standard IPluginSandboxEventEmitter surface.

No profile-name filtering (verified against ApparmorAuditLineParser + ApparmorAuditLogScraper.EmitDenied, 2026-05-29). The scraper does not match the profile= field against the configured Stella profile name — it emits for every parseable AppArmor denial in the tailed log, carrying the denying profile in Diagnostics["profile_name"]. On a host whose auditd also covers non-Stella AppArmor profiles, expect denials from those profiles to surface as events too. Per-sandbox attribution comes from the PID registry (below), not from the profile name.

Not auto-wired. The toggle is read from configuration, but neither AddPluginSandbox (DI) nor SandboxFactory constructs or starts the scraper today — it is a standalone library type. A host that wants kernel denials mirrored to events must instantiate ApparmorAuditLogScraper itself, supply the emitter and (for correlation) the shared ISandboxPidRegistry, and call StartAsync.

Operator notes:

Cleanup behavior

A normal sandbox stop performs, in order:

  1. Graceful plugin shutdown via the gRPC bridge.
  2. Process exit (graceful, with kill-after-timeout fallback).
  3. umount <merged> (kernel overlay or fuse-overlayfs strategy).
  4. rm -rf <upper> <work> <merged> constrained to the sandbox root.
  5. Removal of the per-sandbox directory under <sandbox-root>.

Materialized-copy strategy skips the unmount step (it has no mount); cleanup is a plain recursive delete of the per-sandbox root.

If step 3 fails on a Linux overlay strategy (busy mount, EBUSY because a child is still holding the view, kernel refuses to release), the unmount cleanup raises a PluginSandboxWorkspaceException with code plugin.workspace.unmount_failed carrying the merged path, the unmount exit code, and stderr. (The plugin.workspace.cleanup_failed code is raised by the Windows junction path’s bounded-retry root delete, not by the Linux unmount path — verified against PluginSandboxWorkspace.cs, 2026-05-29.) Operators must:

The sandbox must not force-unmount with umount -l (lazy) on busy mounts — that hides held file descriptors and lets a misbehaving plugin keep reading from a “deleted” workspace. Lazy unmount is reserved for emergency operator intervention and is documented separately below.

Diagnosing stale mounts

When the host crashes or a previous sandbox shut down uncleanly, mount points may be left behind under <sandbox-root>. Symptoms:

Note on workspace_in_use: the current workspace factory does not probe for stale mounts on start and does not raise a plugin.workspace.workspace_in_use error — that string exists only as a structured event name (plugin.sandbox.workspace.workspace_in_use) / reason code in the confinement-event catalog, reserved for the host-start stale-mount sweep once it is wired in. The earlier claim that a new start “fails with plugin.workspace.workspace_in_use” did not match the code.

Triage commands (run as the Stella Ops service account or root):

# 1. enumerate Stella-owned mounts under the sandbox root only.
#    Set SANDBOX_ROOT to the configured SandboxWorkspaceOptions.RootDirectory
#    for this host (the platform does NOT export it; the default when unset is
#    <temp>/stellaops-sandbox, i.e. typically /tmp/stellaops-sandbox).
SANDBOX_ROOT=${SANDBOX_ROOT:-/var/lib/stellaops/plugin-sandbox}
findmnt --raw --output TARGET,SOURCE,FSTYPE,OPTIONS \
  | awk -v root="$SANDBOX_ROOT" 'index($1, root) == 1'

# 2. find processes holding the mount or any path beneath it
sudo lsof +D "$SANDBOX_ROOT" 2>/dev/null

# 3. unmount one stale entry (only after lsof shows no live holder)
sudo umount /var/lib/stellaops/plugin-sandbox/<sandbox-id>/merged

# 4. emergency only — when umount refuses and no live holder exists
#    (e.g., kernel module bug). Use lazy unmount sparingly:
sudo umount -l /var/lib/stellaops/plugin-sandbox/<sandbox-id>/merged

Never unmount paths above <sandbox-root>. The future stale-mount sweep (PLG-LINUX-OVL-04) will enforce this constraint in code; operator muscle memory should match.

Troubleshooting matrix

SymptomLikely causeFirst checkResolution
Sandbox start fails with plugin.workspace.mount_command_missingmount or fuse-overlayfs binary not in PATHcommand -v mount fuse-overlayfsInstall missing binary; ensure host service has access to /bin//usr/sbin.
Sandbox start fails with plugin.workspace.mount_failed (kernel overlay)Missing CAP_SYS_ADMIN, sandbox root on tmpfs, or non-overlayable upper FSgrep CapEff /proc/$$/status and findmnt <sandbox-root>Move sandbox root to ext4/xfs/btrfs; grant CAP_SYS_ADMIN via systemd AmbientCapabilities; or switch to fuse-overlayfs.
Sandbox start fails with plugin.workspace.overlay_requiredOverlay-requiring mode (OutOfProcessOverlay/OutOfProcessConfined) with AllowMaterializedCopyFallback=false and no ForcedStrategyInspect the SandboxWorkspaceOptions passed by the hostSet a ForcedStrategy of KernelOverlay/FuseOverlay, or re-enable AllowMaterializedCopyFallback.
New start fails while a stale mount sits under <sandbox-root>A previous run left a mount that blocks the recursive delete of the reused per-sandbox root`mountgrep`
plugin.workspace.unmount_failed after stop (Linux overlay)Plugin held a file descriptor across shutdown, or the kernel rejects unmount while busylsof +D <merged>Terminate the holder; rerun cleanup. Do not lazy-unmount unless explicitly needed. (plugin.workspace.cleanup_failed is the Windows-junction equivalent.)
Kernel overlay strategy chosen but writes appear in lowerKernel overlay misconfigured (lowerdir==upperdir), bypassed mount, or operator manually wrote into lowerfindmnt <merged> to confirm overlay is active and inspect OPTIONSFile a bug — lower mutation is a contract violation. The pre-consensus guard cannot detect host-level mutation; operators must keep lower immutable.
Performance regression after switching from copy to overlayPlugin assumes writable lower (legacy copy semantics)Inspect plugin logs for write-to-lower errorsPlugin is non-conforming. Either upgrade the plugin to write to upper paths, or run it as Trusted with explicit copy-fallback opt-in.
aoc-lineage-broken quarantine event under Excititor with overlayUnrelated — VEX content lineage, not workspace lineagen/aSee docs/operations/excititor-quarantine.md.

Capability prerequisites

For kernel overlay strategy (recommended for production):

For fuse-overlayfs strategy (rootless / unprivileged):

Open follow-ups

Status reconciled against the archived sprint trackers and src/ on 2026-05-29. The overlay/fuse/sweeper code shipped and was validated; remaining gaps are about host-start wiring and an env/config binding layer.