A Linux sandbox runtime built from first principles in C++20.
ABox is an experimental Linux process-isolation and workload-management project. It uses Linux system calls and kernel interfaces directly—fork, execvp, waitpid, namespaces, and cgroups v2—instead of delegating execution to Docker or another container runtime.
The project has two execution paths:
abox runlaunches a one-shot workload with optional CPU and memory limits, resource accounting, and namespace isolation.aboxdis a long-running daemon that accepts lifecycle commands over a Unix domain socket and manages named workloads.
Project status: Experimental, through the initial daemon/lifecycle milestone (Phase 4). ABox is intended for learning and systems experimentation, not for safely executing untrusted code. The two execution paths do not yet offer identical isolation: the daemon currently manages cgroup-backed host-namespace processes, while namespace and root-filesystem isolation are implemented in the one-shot
runpath.
- Why ABox?
- Features
- Requirements
- Build and test
- Installation and setup
- Quick start: one-shot execution
- Quick start: daemon-managed workloads
- Architecture
- How execution works
- Kernel mechanisms
- State, IPC, and lifecycle
- Testing
- Implementation status and limitations
- Roadmap
- References
A Linux process normally shares the host's view of process IDs, mounts, hostname, and other resources. Running a program through fork() and execvp() is easy; running it with controlled resources, separate system views, observable behavior, and a managed lifecycle takes considerably more work.
ABox explores those mechanisms as small, independently understandable components. The objective is to understand how a container-like runtime interacts with the Linux kernel: how a workload starts, what it can see, how the kernel accounts for its resources, and how the runtime eventually cleans it up.
| Capability | One-shot abox run |
Daemon-managed aboxd |
|---|---|---|
| Launch an arbitrary executable | Yes | Yes (start / exec) |
| cgroups v2 process placement | Yes | Yes |
| Configurable CPU quota / memory maximum | Yes | Not exposed by the current daemon CLI |
| Final CPU, memory, and OOM statistics | --stats |
Not exposed by the current daemon CLI |
| UTS, PID, mount, user, and IPC namespaces | Optional | Not yet integrated |
Root filesystem switch with pivot_root |
Optional / experimental | Not yet integrated |
| Named workloads and lifecycle commands | No | Yes |
| Unix domain socket IPC | No | Yes |
| On-disk workload configuration | No | Yes (basic metadata) |
ABox does not currently implement network namespace isolation, container images, storage quotas, seccomp policies, a terminal UI, or a production-grade security boundary.
- Linux with cgroup v2 (
cgroup2) mounted. - A C++20 compiler, CMake 3.16+, and standard Linux development headers.
- Access to a properly delegated cgroup subtree with the required controllers enabled. Creating workload cgroups and writing their control files is permission-sensitive.
- Kernel support and the appropriate permissions for any requested namespaces or mounts.
Development has been carried out on Ubuntu under WSL2. Some namespace and mount operations depend on WSL configuration; privileged integration testing is best performed in a disposable Linux VM or test environment.
The default cgroup parent in the current code is:
/sys/fs/cgroup/abox
ABox expects this parent to already exist and allow creation of child cgroups. You can override it for both the CLI and daemon:
export ABOX_CGROUP_BASE=/path/to/your/delegated/cgroupCheck the available and enabled controllers at the parent:
cat "$ABOX_CGROUP_BASE/cgroup.controllers"
cat "$ABOX_CGROUP_BASE/cgroup.subtree_control"
cat "$ABOX_CGROUP_BASE/cgroup.procs"For CPU and memory limits, cpu and memory must be available and enabled for children. In cgroups v2, enabling domain controllers on a non-root parent normally requires that no processes reside directly in that parent (the no-internal-process constraint).
Do not change systemd-owned cgroups arbitrarily. Use systemd delegation for routine development, or a dedicated, disposable environment for privileged experiments. Also avoid running untrusted workloads under sudo: doing so launches those programs with elevated host privileges.
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
ctest --test-dir build --output-on-failureThis builds two primary executables:
build/abox # Command-line client and one-shot runner
build/aboxd # Daemon for named workloads
Confirm that the CLI works:
./build/abox --help
./build/abox --versionABox is distributed as a Debian package. First, configure the public ABox Cloudsmith APT repository on your machine:
No Cloudsmith account or login is required to install ABox from a public repository. First, add the ABox APT repository and its signing key once. Review the setup script before running it with elevated privileges:
curl -fsSL \
'https://dl.cloudsmith.io/public/abox/abox/cfg/setup/bash.deb.sh' \
-o /tmp/abox-cloudsmith-setup.sh
less /tmp/abox-cloudsmith-setup.sh
sudo bash /tmp/abox-cloudsmith-setup.shThis uses Cloudsmith's documented public Debian repository setup URL pattern
for the abox/abox workspace and repository. The repository must be set to
public in Cloudsmith for anonymous installation to work. ABox v0.1.0 package
page.
After the repository has been added, install ABox v0.1.0:
sudo apt-get update
sudo apt-get install abox=0.1.0Verify the installation:
abox --version
command -v abox
command -v aboxdIf APT cannot find version 0.1.0, check whether the repository is configured
for your Ubuntu/Debian release and architecture, and inspect the available
package versions:
apt-cache policy aboxTo provision the default Ubuntu sandbox root filesystem, explicitly run:
sudo abox setup --dry-run
sudo abox setupPackage installation alone does not grant namespace permissions or configure cgroup delegation; see Requirements before running isolated workloads.
cmake -S . -B build-release \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr
cmake --build build-release -jTo inspect the installation layout without changing the host:
DESTDIR="$PWD/stage" cmake --install build-releaseTo install the binaries and packaged configuration onto the host instead:
sudo cmake --install build-releaseABox also supports CPack-generated Debian and TGZ packages. These packages
install the ABox binaries and supporting files, not a complete root
filesystem. Setup is a separate, explicit operation; it preserves an existing
valid rootfs and refuses to overwrite an existing non-empty incomplete one.
Provisioning requires supported Ubuntu tooling (including debootstrap),
network access if required, and appropriate permissions.
When --isolate is used, ABox resolves the rootfs in this order:
- Explicit
--rootfs DIR; ABOX_ROOTFSenvironment variable;/etc/abox/abox.conf;/var/lib/abox/images/default.
The selected path must exist and be a directory. Individual namespace flags,
such as --uts or --pid, do not implicitly select a rootfs.
Some integration tests require a delegated cgroup subtree and namespace capabilities. Tests lacking those prerequisites may report skipped, not passed; see Testing.
The run command executes a program and returns its exit status:
./build/abox run -- /bin/echo 'Hello from ABox'Limit CPU time and memory, then print a summary when the program finishes:
./build/abox run \
--cpus 0.5 \
--memory 256M \
--stats \
-- /bin/sleep 2For this to succeed, ABox must be able to create and configure cgroups beneath ABOX_CGROUP_BASE.
| Option | Meaning |
|---|---|
--cpus N |
CPU-time quota in CPU-equivalents (for example, 0.5 or 2) |
--memory SIZE |
Hard memory maximum; supported suffixes: M (MiB) or G (GiB) |
--stats |
Print resource usage after execution |
--uts |
Create a new hostname/UTS namespace |
--pid |
Launch a workload through a new PID namespace supervisor |
--mount |
Create a separate mount namespace and make mounts private |
--user |
Create a user namespace and configure a single UID/GID mapping |
--ipc |
Create a separate IPC namespace |
--isolate |
Enable PID, mount, UTS, user, and IPC namespace setup together |
--rootfs DIR |
Attempt a root filesystem switch using pivot_root (also enables PID/mount setup) |
-- |
End ABox options; everything afterward is the executable and its arguments |
For example, with the required namespace privileges:
./build/abox run --uts -- /bin/hostname
# Expected workload hostname: aboxTo request the full set of implemented namespaces:
./build/abox run --isolate -- /bin/hostnameTo attempt execution against a prepared root filesystem:
./build/abox run --rootfs /absolute/path/to/rootfs -- /bin/shRootfs prerequisites: The directory must exist and contain the program, its interpreter and shared libraries (if dynamically linked), and a usable /proc directory. pivot_root and mount operations require suitable permissions. The current /proc setup needs further hardening/validation for PID namespace correctness; do not treat this mode as a verified security boundary.
The daemon accepts simple text commands over a Unix domain socket. It keeps per-workload metadata in a state directory and launches independent workload processes.
Run this in one terminal:
export ABOX_CGROUP_BASE=/path/to/your/delegated/cgroup
export ABOX_SOCKET="/tmp/aboxd-$(id -u).sock"
export ABOX_STATE_DIR="$HOME/.local/state/abox"
./build/aboxd --state "$ABOX_STATE_DIR" --socket "$ABOX_SOCKET"aboxd currently stays in the foreground. Keep this process running while issuing commands in another terminal. The state directory and socket are configurable; if unspecified, the defaults are /tmp/abox-state-<uid> and /tmp/aboxd-<uid>.sock.
In a second terminal, point the CLI at the same socket:
export ABOX_SOCKET="/tmp/aboxd-$(id -u).sock"
./build/abox create demo
./build/abox list
./build/abox start demo -- /bin/sleep 120
./build/abox list
./build/abox exec demo -- /usr/bin/id
./build/abox stop demo
# Wait for the process to exit and for the daemon to observe it.
./build/abox list
./build/abox delete demostart and exec return an OK <pid> response when the daemon has launched the process. exec is currently a launch operation, not an interactive terminal attachment: workload stdout/stderr are not streamed back through the CLI socket.
The daemon's command parser currently splits on whitespace, so quoted arguments and paths containing spaces are not reliably preserved. Keep these examples to simple arguments.
| Command | What the current daemon does |
|---|---|
create <name> |
Creates a state directory with a basic config file |
list |
Scans state directories and reports created/running/stopped states |
start <name> -- <program> ... |
Creates a cgroup, starts a process, records its PID |
exec <name> -- <program> ... |
Starts an additional process in the named cgroup, without entering an existing namespace |
stop <name> |
Sends SIGTERM to the recorded process group |
delete <name> |
Removes state for a workload that is not reported running |
A created sandbox is currently metadata, not an already-running isolated container. The daemon does not yet save CPU/memory/namespace configuration or create a persistent namespace-init process during create. The daemon's start and exec path is separate from the namespace-aware one-shot runner.
+----------------------+
| abox CLI |
+-----------+----------+
|
+----------------+----------------+
| |
abox run lifecycle commands
| |
v v
+-----------------+ Unix domain socket
| Process runner | |
+--------+--------+ v
| +-----------------+
+---------+---------+ | aboxd |
| | | state + process |
v v | management |
CgroupManager Namespace setup +--------+--------+
| | |
v v v
cgroups v2 Linux namespaces cgroups v2
\ / |
\ / v
+-- workload ---+ host-namespace workload
src/
main.cpp CLI parsing and daemon client IPC
process.hpp/.cpp one-shot execution, synchronization, exit status, stats
cgroup.hpp/.cpp cgroup lifecycle, resource controls, metric parsing
namespace.hpp/.cpp namespace setup, UID/GID mapping, outer PID supervisor
config.hpp/.cpp rootfs configuration and precedence rules
setup.hpp/.cpp explicit rootfs provisioning and prerequisite checks
daemon.cpp Unix socket server, named state, workload lifecycle
tests/
stats_test.cpp cgroup-statistics parsing
namespace_test.cpp supervisor unit test
cli_acceptance.cpp CLI regression checks
namespace_acceptance.cpp namespace integration checks
daemon_acceptance.cpp daemon and lifecycle checks
ACCEPTANCE_TESTS.md expanded phase-by-phase test specification
CMakeLists.txt
version.hpp.in
The one-shot runner performs the following sequence:
Parse options and program arguments
|
Create a unique cgroup (run-<parent-pid>)
|
Write cpu.max / memory.max when requested
|
Create synchronization pipes, then fork()
|
+-- Parent: move child PID into cgroup.procs
| |
| +-- signal child to continue
| +-- if needed, write UID/GID mappings
| +-- waitpid(), collect final statistics, remove cgroup
|
+-- Child: block until parent authorizes execution
|
+-- unshare requested namespaces
+-- coordinate user namespace mapping
+-- configure mounts, rootfs, hostname
+-- if PID isolation: fork workload into the new PID namespace
+-- otherwise: execvp(program, argv)
Why the synchronization pipe? After fork(), Linux does not guarantee whether the parent or child runs first. Without the pipe, the child could execute before ABox attaches it to its cgroup. The child therefore blocks until the parent has attached its PID; an attachment failure prevents execution.
Why execvp()? It replaces the child process image with the requested program without creating another ordinary wrapper process. ABox uses conventional exit-status handling: a normal exit returns the process's code, and signal termination is represented as 128 + signal number.
Why the PID supervision step? unshare(CLONE_NEWPID) affects subsequently created children, not the calling process. The current supervise() function forks the workload itself into the new PID namespace, where that workload becomes PID 1; the outer parent, which remains outside the new PID namespace, waits and returns its exit status. There is not yet a dedicated init process inside the namespace to forward signals or reap arbitrary orphaned descendants. This distinction is important for PID-namespace correctness and reliability.
The daemon's current execution sequence is different:
CLI --> connect(AF_UNIX) --> send a newline-terminated command
|
v
aboxd
|
state lookup / create or update
|
fork() + setsid() + execvp()
|
attach spawned PID to named cgroup
|
send OK / ERR response to client
The Unix socket is created with filesystem permissions 0600 and the daemon state directory is set to 0700. The server uses a simple line-oriented protocol; it does not yet support authenticated multi-user access, structured argument framing, terminal I/O forwarding, or joining another workload's namespaces.
Unlike the one-shot path, the daemon currently attaches the child after fork() without holding it behind an execution gate. That leaves a race in which the process could begin executing before the cgroup attachment completes. This must be fixed before treating daemon-managed resource limits as strictly enforced from first instruction.
A cgroup is represented by a directory under the cgroup v2 filesystem. Linux exposes its controls and counters as virtual files:
<ABOX_CGROUP_BASE>/
run-<pid>/
cgroup.procs
cpu.max
cpu.stat
memory.max
memory.current
memory.peak
memory.events
sandbox-<name>/
cgroup.procs
...
ABox translates CPU-equivalents into CFS bandwidth quota with a 100,000 µs period:
--cpus 0.5 -> cpu.max = 50000 100000
--cpus 2 -> cpu.max = 200000 100000
Memory limits are expressed in bytes:
--memory 256M -> memory.max = 268435456
--memory 1G -> memory.max = 1073741824
These are limits, not reservations. CPU quotas constrain execution time, not CPU affinity. memory.max caps memory charges and may cause reclamation or cgroup OOM handling when exceeded.
With --stats, the runner reads the cgroup's counters before it removes the cgroup:
| Source | Data |
|---|---|
cpu.stat |
CPU usage in microseconds; quota throttling count and time, when available |
memory.current |
Current memory charges at sampling time |
memory.peak |
Peak accounted memory, when supported |
memory.events |
Memory limit, OOM, and OOM-kill events |
std::chrono::steady_clock |
Elapsed wall-clock duration |
Average CPU consumption is reported in CPU-equivalents:
average CPU cores = CPU usage seconds / elapsed wall seconds
This can exceed 1.0 when the workload uses multiple CPU cores. Missing optional kernel counters are represented as unavailable rather than silently being treated as zero.
| Namespace | Linux flag | Purpose |
|---|---|---|
| UTS | CLONE_NEWUTS |
Separate hostname |
| PID | CLONE_NEWPID |
Separate process ID numbering for subsequently created children |
| Mount | CLONE_NEWNS |
Separate mount table and private mount propagation |
| User | CLONE_NEWUSER |
Namespace-local user IDs and capabilities; mapped back to a host UID/GID |
| IPC | CLONE_NEWIPC |
Separate System V IPC and POSIX message-queue namespace |
The user namespace mapping is coordinated with the parent process using pipes and /proc/<pid>/uid_map, /proc/<pid>/gid_map, and setgroups. This allows the child to wait until its mappings are configured before continuing.
The rootfs path bind-mounts the selected directory, attempts pivot_root, detaches the old root, and mounts /proc. This is experimental. In particular, mount timing relative to entry into the new PID namespace needs verification; a fresh procfs mounted from the wrong PID namespace may expose an incorrect process view.
ABox uses waitpid() to observe direct children, converts process termination into shell-style status codes, and attempts to remove empty workload cgroups.
The daemon records each primary workload PID in a file and uses it to infer state. It also attempts child reaping during its request loop. This is a basic lifecycle model: it is not yet a robust durable process supervisor capable of complete recovery from daemon crashes, PID reuse, orphaned descendants, or partial transitions.
The daemon's state directory resembles:
<ABOX_STATE_DIR>/
demo/
config # currently version=1
pid # present while a primary workload is recorded as active
The Unix socket handles requests such as:
create demo
list
start demo -- /bin/sleep 120
stop demo
delete demo
ABox currently sends a single newline-terminated request and receives an OK, ERR, or listing response. The wire format is deliberately simple for this milestone and is not a stable public API.
By default the state is stored under /tmp, which may be cleared across reboots. Specify --state to use a persistent directory. Configuration persistence is not process persistence: workloads do not survive shutdown of the WSL/Linux instance.
Run all registered CTest tests:
ctest --test-dir build --output-on-failureInspect registered tests and labels:
ctest --test-dir build -N
ctest --test-dir build -L phase3 --output-on-failure
ctest --test-dir build -L phase4 --output-on-failure
ctest --test-dir build -L phaseA --output-on-failure
ctest --test-dir build -L phaseB --output-on-failure
ctest --test-dir build -L phaseC --output-on-failure
ctest --test-dir build -L phaseD --output-on-failureCurrent test programs cover statistics parsing, a namespace supervisor check, CLI behavior, rootfs configuration precedence, setup safety, staged installation, DEB metadata/content, namespace integration checks, and daemon lifecycle behavior. Namespace tests are labeled requires_privileges; the daemon integration test may also skip the workload-lifecycle portion if ABOX_CGROUP_BASE is not provided. CTest uses exit code 77 for supported skips.
The larger test inventory in tests/ACCEPTANCE_TESTS.md is an acceptance specification, not proof that every scenario has already been automated or passed. In particular, test results from one Linux/WSL environment do not establish security guarantees on every host.
ABox is a systems-learning project with working kernel-facing components, but several implementation details are intentionally unfinished:
- Two separate execution paths.
abox runsupports namespaces and rootfs setup;aboxdcurrently starts processes in the daemon's host namespaces and does not join a persistent sandbox's namespace set. - Basic daemon lifecycle. Daemon state is primarily a saved PID and metadata directory; restart recovery, PID reuse protection, graceful-stop timeouts, signal forwarding, and descendant cleanup need strengthening.
- Daemon startup race. Unlike the one-shot path, daemon workloads may execute before cgroup attachment succeeds.
- Minimal IPC framing. Socket commands are whitespace-delimited; quoting, spaces in arguments, interactive I/O, streaming, and structured responses are not implemented. The current CLI also treats non-
OKlist responses as nonzero exit status even when it prints the listing. - Rootfs/procfs limitations.
pivot_rootand/procsetup require privilege and correctness testing; mount/proc placement should not be taken as verified isolation. - Incomplete security boundary. There is no seccomp policy, capability drop policy, dedicated network namespace, or comprehensive host-filesystem escape testing. Do not use ABox for hostile workloads.
- PID 1 is currently the workload. The outer supervisor is not a namespace-local init; signal and orphan reaping semantics need explicit handling before calling PID isolation robust.
- No persistent storage backend. The daemon persists metadata, not per-sandbox filesystem images, storage quotas, or copy-on-write layers.
- No live metrics service or TUI. The implemented stats path is a post-execution summary for one-shot workloads.
- Setup is deliberately explicit. It does not create cgroup delegation, enable controllers, install packages, or provision a rootfs unless the user invokes the non-dry-run setup command.
- Packaging is not a security boundary. A shared default rootfs has no per-sandbox writable storage, copy-on-write layer, or storage quota.
These limitations are tracked deliberately: avoiding false isolation guarantees is part of building a reliable runtime.
- Phase 0–2: C++ CLI, process execution, cgroups v2 limits, and end-of-run statistics — implemented.
- Phase 3: UTS, PID, mount, user, and IPC namespace plumbing, plus experimental rootfs support — implemented as an initial one-shot execution path; further validation/hardening required.
- Phase 4: Unix-socket daemon, named workloads, basic state and lifecycle — initial implementation complete; lifecycle and namespace integration require follow-up.
- Installation/configuration: standard CMake installation, rootfs precedence, explicit setup diagnostics, and DEB/TGZ packaging — implemented; provisioning remains an explicit privileged operation.
- Phase 5: Persistent per-sandbox storage, filesystem capacity limits, and optionally OverlayFS/copy-on-write.
- Phase 6: Continuous CPU/memory/I/O telemetry and per-sandbox sampling.
- Phase 7: An interactive terminal dashboard for sandbox inspection.
- Phase 8: Daemon recovery, stronger supervision, capabilities, seccomp, authorization, and fault-injection testing.
- Linux kernel: Control Group v2
- systemd: Control Group Delegation
- Linux manual: namespaces(7)
- Linux manual: pid_namespaces(7)
- Linux manual: user_namespaces(7)
- Linux manual: pivot_root(2)
ABox is a learning-oriented systems project, not a replacement for a production container runtime. The emphasis is on understanding and validating the Linux mechanisms that make isolation, resource control, and workload supervision possible.