ADR-0057 — Security Sandboxing & Capability Enforcement
Status: Accepted
Date: 2026-06-13
Deciders: VayuPress core team
Supersedes: —
Related: ADR-0056 (process isolation & runtime sandboxing)
Context
ADR-0056 established the process-isolation model: plugins run as child subprocesses communicating via a JSON-over-stdio IPC protocol. That foundation provided crash containment but left several attack surfaces open:
- Binary substitution — an attacker who can replace the plugin binary on disk gets arbitrary code execution inside the sandbox user.
- IPC flood — a malicious or buggy plugin could write an unbounded stdout stream, causing the host to allocate unbounded memory while scanning.
- Privilege escalation — without
PR_SET_NO_NEW_PRIVS, a plugin binary with a setuid bit could escalate privileges after exec. - Capability bypass — the host had no host-side enforcement of the declared
Manifestcapabilities before dispatching to the subprocess. - Orphan processes — if the host process died unexpectedly, child subprocesses could continue running under a zombie parent.
Decision
P26 adds five defence-in-depth layers to internal/sandbox/ with no new external dependencies.
1. Binary Hash Integrity (manifest.go)
Manifest.ExecutableHash (SHA-256 hex string) is verified by verifyExecutableHash() at the top of start() before exec.Command is called. A mismatch aborts the launch and returns an error — the plugin is not started. This prevents binary substitution attacks where an attacker writes a different executable to the declared path.
2. IPC Stdout Flood Protection (subprocess.go)
The stdout pipe is wrapped with io.LimitReader capped at Manifest.effectiveMaxMessageBytes() (default 1 MiB). The bufio.Scanner buffer is sized to the same limit. Any single JSON line exceeding the limit causes scanner EOF, which is treated as a crash and triggers the normal restart/quarantine path. This bounds host memory allocation to O(MaxMessageBytes) per plugin regardless of what the subprocess writes.
3. Process Hardening via SysProcAttr (subprocess_linux.go)
On Linux, applyProcAttr sets:
Pdeathsig: SIGKILL— the child receives SIGKILL when the parent thread dies, preventing orphaned processes.Setpgid: true— the child gets its own process group, enabling group-kill on timeout.NoNewPrivs: true— equivalent toPR_SET_NO_NEW_PRIVS; the subprocess and all its descendants cannot gain privileges via setuid/setgid/file capabilities, even if the binary has those bits set.
applyRunAs parses an optional Manifest.RunAs ("uid:gid") and sets SysProcAttr.Credential, allowing plugins to run as an unprivileged dedicated user.
On non-Linux platforms (subprocess_other.go), applyProcAttr and applyRunAs are no-ops.
4. Host-Side Capability Enforcement (capability_enforcer.go)
EnforceCapabilities(manifest, hook, payload) is called in SubprocessPlugin.Invoke after the quarantine check and before marshaling the request. It inspects the payload for keys that imply capabilities not declared in the manifest:
- If
AllowNetwork: false, any payload key namedurl,endpoint, orhost(case-insensitive) is rejected withErrCapabilityDenied. - If a
pathkey is present, it must fall underAllowedReadPathsorAllowedWritePaths.
This is a defence-in-depth layer — the subprocess self-enforces via the Capabilities field echoed in each Request, but host-side enforcement ensures a compromised plugin cannot even receive a request that exceeds its declared permissions.
5. Future BPF Seccomp Plumbing (Manifest.SeccompProfile)
Manifest.SeccompProfile is a string field reserved for a future BPF seccomp profile path. Writing real BPF bytecode requires kernel-version-specific care and was deferred to avoid introducing fragility. The field is present so operators can document intent and tooling can gate on it without a manifest schema change.
Implementation Files
| File | Change |
|---|---|
internal/sandbox/manifest.go |
Added ExecutableHash, MaxMessageBytes, SeccompProfile, RunAs fields; verifyExecutableHash(), effectiveMaxMessageBytes() |
internal/sandbox/subprocess.go |
Hash check at top of start(); LimitReader + Scanner buffer; EnforceCapabilities call in Invoke |
internal/sandbox/subprocess_linux.go |
applyProcAttr with Pdeathsig, Setpgid, NoNewPrivs; applyRunAs |
internal/sandbox/subprocess_other.go |
No-op stubs for non-Linux |
internal/sandbox/capability_enforcer.go |
EnforceCapabilities function |
internal/sandbox/sandbox_test.go |
Tests for all new behaviours |
Consequences
Positive:
- Binary substitution is caught before any code from the replacement binary runs.
- Host memory is bounded regardless of plugin behaviour.
- Privilege escalation via setuid is blocked on Linux.
- Capability violations are rejected before touching the subprocess, reducing attack surface.
RunAsallows operators to run plugins as dedicated low-privilege OS users.
Negative / Trade-offs:
ExecutableHashmust be updated whenever the plugin binary is redeployed. CI pipelines should compute and embed the hash automatically.LimitReadermeans a plugin that legitimately wants to stream large responses must chunk them or increaseMaxMessageBytes.NoNewPrivsis irreversible for the child's process tree; plugins that need to invoke setuid helpers will need a different approach.- Real seccomp BPF enforcement (syscall allow-listing) is deferred —
SeccompProfileis currently non-functional.