ADR-0059 — Filesystem & Syscall Confinement (P28)
Status: Accepted
Date: 2026-06-13
Deciders: VayuPress Maintainers
Supersedes: ADR-0058 (extends it)
Context
P27 added cgroup v2 resource ceilings, PID/IPC/network namespace isolation, and symlink traversal hardening. The remaining attack surface after P27:
- Plugins can still see the full host filesystem mount tree.
- No syscall filtering — a compromised plugin can call any syscall the kernel exposes.
- Linux capabilities are fully inherited (if the daemon runs as root, so does every plugin).
- Stray parent file descriptors may leak to child processes.
- No private tmpfs scratch — plugins share
/tmpwith the host.
Decision
Implement P28 — Filesystem & Syscall Confinement — in internal/sandbox/:
1. Mount Namespace (CLONE_NEWNS)
Manifest.ConfineMounts = true sets CLONE_NEWNS in SysProcAttr.Cloneflags.
Inside the new namespace:
applyMountPropagation()marks the rootMS_PRIVATE|MS_RECso no mounts leak back to the host tree.applyProcMask()bind-mounts/dev/nullover sensitive/procentries (/proc/kcore,/proc/keys,/proc/sched_debug, etc.).
2. Private tmpfs Scratch Directory
setupMountConfinement() allocates a per-invocation os.MkdirTemp directory
and mounts a tmpfs (MS_NOEXEC|MS_NOSUID|MS_NODEV, 64 MiB cap) on it. The
path is passed to the plugin via PLUGIN_SCRATCH env var. Unmounted and removed
on PluginConfinement.Cleanup().
Failure to mount tmpfs is non-fatal (logs a warning, scratch dir still exists as a regular directory) — best-effort degradation.
3. Seccomp-BPF Syscall Allowlist
buildSeccompFilter() generates a minimal BPF program that:
- Validates
AUDIT_ARCH_X86_64(kills on wrong arch). - Allows a curated set of ~35 syscalls: exit/exit_group, read/write, mmap/brk, signal handling, Go runtime (futex/clone/gettid/tgkill), FD management, epoll, clock/time.
- Returns
SECCOMP_RET_ERRNO | EPERM(not KILL) for anything else — the plugin gets an error rather than a crash, enabling graceful degradation.
ApplySeccompFilter() installs via prctl(PR_SET_NO_NEW_PRIVS, 1) then
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog). This is called by the
plugin binary's own init path (plugin-side enforcement).
4. Linux Capability Dropping
DropCapabilities() calls capset(2) with all-zero effective/permitted/inheritable
sets, stripping every Linux capability (CAP_SYS_ADMIN, CAP_NET_ADMIN,
CAP_SYS_PTRACE, CAP_SETUID, CAP_SETGID, etc.). Called plugin-side before execve.
5. FD Inheritance Control
CloseExtraFDs() reads /proc/self/fd and sets FD_CLOEXEC on all FDs
except stdin (0), stdout (1), stderr (2), and any explicitly kept FDs.
Called in the parent before cmd.Start().
6. Secure Exec Environment
PrepareExecEnv() builds the minimal env {PATH, HOME=/tmp, PLUGIN_NAME, PLUGIN_SCRATCH} plus any Manifest-declared extras. Parent environment is
never inherited.
Consequences
Positive:
- Plugin syscall surface shrinks from ~450 to ~35.
- Mount tree leakage to host is impossible via
MS_PRIVATE|MS_REC. - Sensitive
/procfiles are masked before the plugin can read them. - Capability escalation eliminated by stripping all capabilities at exec.
- Stray FD leaks closed via CLOEXEC sweep.
- Writable scratch is isolated per-plugin, auto-cleaned, size-capped.
Negative / Trade-offs:
- Seccomp filter is x86-64 only; other architectures get no filtering
(non-Linux stubs are no-ops — see
confinement_other.go). CLONE_NEWNSmay requireCAP_SYS_ADMINon older kernels or restricted container runtimes; EPERM falls back to no isolation (P27 pattern).- Capability drop must be done in the child process before execve — current
implementation exposes
DropCapabilities()/ApplySeccompFilter()for plugin binaries to call in their own init; future work: seccomp-new-process viaSysProcAttr.Pdeathsig+ pre-exec helper.
Files Changed
internal/sandbox/confinement_linux.go— P28 implementation (Linux)internal/sandbox/confinement_other.go— no-op stubs (non-Linux)internal/sandbox/manifest.go—ConfineMounts,DropCapsfieldsinternal/sandbox/subprocess.go— wiredSetupConfinement,CloseExtraFDs,PrepareExecEnv,MountNamespaceFlagsintostart()/killSubprocess()