Spec M43 -- the sandbox: compile and run an arbitrary mc program in isolation (Layer 1, now), and the web playground it makes possible (Layer 2, post-1.0.0, priced only)

Owner's direction (2026-09-04, Portuguese, translated): "add the possibility of running something in a sandbox; it can be post-1.0.0. The idea is to offer a web sandbox for people to try an mc playground, and even move the site there with a Go-style tour that teaches the language tools. For now, running in a sandbox is something clean that allows arbitrary tests that need to run in isolation."

Two layers. Layer 1 is the deliverable: mc sandbox run|exec|check, a subcommand of mc written in mc that compiles and runs an arbitrary program on Linux with no view of the host, no network, and named, deterministic refusals -- what the project itself needs for fuzz inputs, the M42 dynamic --exe binaries, and examples nobody audited. Layer 2 is priced, not built: the playground service, the tour, and the client-side (wasm) alternative.

Sequencing: after M42 (--exe on Linux -- without it a Linux mc cannot produce an executable without ld.lld, and the box has no linker). Line references are to main at bc8076b.

What already exists #

Design #

0. Mechanisms, evaluated against mc's surface #

What each kernel primitive stops, the syscall that installs it, the kernel that introduced it, and whether an unprivileged process can use it. Numbers are aarch64 / x86_64.

needprimitivesyscallsinceunprivileged
the host filesystem does not existmount namespace, tmpfs root, bind mounts, pivot_rootunshare 97/272 (CLONE_NEWNS 0x20000), mount 40/165, pivot_root 41/155, umount2 39/166user ns 3.8; unprivileged tmpfs/bind inside a user ns 3.8; unprivileged overlayfs 5.11yes, inside a user namespace (Ubuntu AppArmor caveat above)
a second wall on the filesystem, independent of the mountsLandlocklandlock_create_ruleset 444, landlock_add_rule 445, landlock_restrict_self 446 (same numbers on both)ABI 1: 5.13; ABI 2 REFER 5.19; ABI 3 TRUNCATE 6.2; ABI 4 TCP bind/connect 6.7; ABI 5 IOCTL_DEV 6.10; ABI 6 scoped signals and abstract unix sockets 6.12yes, after prctl(PR_SET_NO_NEW_PRIVS) (prctl 167/157, option 38, 3.5)
no networknetwork namespace (an empty ns with a down loopback)unshare with CLONE_NEWNET 0x400000002.6.24 (unprivileged via user ns 3.8)yes
no other processes to see or signalpid + ipc + uts namespacesCLONE_NEWPID 0x20000000, CLONE_NEWIPC 0x8000000, CLONE_NEWUTS 0x40000003.8yes
a syscall allowlistseccomp-BPFseccomp 277/317 (SECCOMP_SET_MODE_FILTER 1)3.17 (prctl(PR_SET_SECCOMP) 3.5)yes, with no_new_privs
a NAMED refusal instead of a silent killSECCOMP_RET_USER_NOTIF + SECCOMP_FILTER_FLAG_NEW_LISTENER 8, answered with ioctl(SECCOMP_IOCTL_NOTIF_RECV 0xC0502100 / _SEND 0xC0182101); SECCOMP_USER_NOTIF_FLAG_CONTINUE 1 to let an inspected call proceedioctl 29/165.0; CONTINUE 5.5yes
the supervisor reaches the listener fdpidfd_open 434, pidfd_getfd 4385.3 / 5.6yes (ptrace-mode access: same user, descendant -- Yama ptrace_scope=1 allows it; the user-ns creator holds CAP_SYS_PTRACE in it)
the supervisor reads a path argument to NAME itprocess_vm_readv 270/3103.2yes, same rule
CPU capRLIMIT_CPU (0): SIGXCPU at the soft limit, SIGKILL at the hardprlimit64 261/302 (setrlimit 164/160)2.6.36yes
wall-clock capsupervisor ppoll 73/271 with a timespec, then kill 129/62 of the pid-ns init (which tears the whole namespace down)yes
memory capRLIMIT_AS (9), hard; mmap 222/9 under USER_NOTIF for the diagnosticyes. A cgroup memory.max (OOM accounting) needs cgroup delegation (systemd-run --user); NOT used in Layer 1
process capclone 220/56 and clone3 435 refused by default; --allow=threads counts them under USER_NOTIF, RLIMIT_NPROC (6) as the hard wall -- per user namespace since the ucounts rework (5.14)5.14yes
fd capRLIMIT_NOFILE (7) + close_range 436 (CLOSE_RANGE_UNSHARE 2) before exec5.9yes
output captmpfs size= on /src (overlay upper) + RLIMIT_FSIZE (1)yes
the program's writable view of its own directory without copying itoverlayfs (lowerdir=DIR, upper and work on the box tmpfs)mount("overlay", ...)unprivileged in a user ns 5.11yes

Everything above is in a 6.12 kernel; kernel 7.0 has all of it. The sandbox checks the Landlock ABI at runtime (landlock_create_ruleset(0, 0, LANDLOCK_CREATE_RULESET_VERSION 1)) and requires >= 4; on the baseline it expects >= 6 and uses the scoped restrictions when present.

What external tools buy that these do not, and at what cost.

toolmechanismwhat it adds over the primitiveswhat it costs
bubblewrap (bwrap)user ns + mount ns + seccomp (a BPF blob passed on an fd) + pivot_root; ~3k lines of Cmaturity of the mount sequence; nothing structuralan external binary (apt install bubblewrap); no CPU/wall/memory caps, no report, no Landlock; the same Ubuntu AppArmor rule applies to it
nsjailnamespaces + seccomp via the kafel policy language + rlimits + cgroups + time limitscgroups (memory OOM, pids) when delegation exists; a policy languageexternal binary (C++, protobuf, kafel); same kernel surface as ours
Docker / runcthe same primitives behind a daemon and an image formatpackaging, a curated default seccomp profilea root daemon; the default profile forbids unshare/mount INSIDE the container, so a sandbox-in-Docker needs --privileged; Docker Desktop's kernel is fixed at 6.12
gVisor (runsc)a user-space kernel (Sentry, Go) intercepting every syscall via systrap or KVMthe untrusted program never talks to the host kernel: a 0-day in an ALLOWED syscall (mmap, openat, write) is absorbed by the Sentry~50 MB, Go runtime, 2-5x syscall overhead, tens to hundreds of ms startup, root or KVM, daemon integration
Firecrackera microVM with its own guest kernelhardware isolation; the host kernel exposes only virtio/dev/kvm (the VPS is itself a VM: nested virtualization is UNVERIFIED), a guest kernel and rootfs to build and maintain, a jailer, ~125 ms boot, orchestration

Verdict, honestly stated: for the project's own tests, the primitives match bwrap/nsjail feature for feature at ~1500 lines of mc and add what none of them has (a report that names each refusal). What they do NOT buy is protection against a kernel bug reachable through the ~15 syscalls the program is allowed -- that is gVisor's and Firecracker's whole value. For Layer 1 that residual is accepted. For Layer 2 it is handled by policy, not by code: the VPS holds nothing but the playground, is snapshotted, and is disposable (§ Layer 2 Risks); gVisor is named as the optional outer layer if the owner wants one.

(a), (b) or (c). (c) with (a) as the deliverable: the sandbox is subcommand("sandbox", ...) registered by a new part <mc/core_sandbox>, written in mc against the kernel ABI exactly as lib/sys_linux.mc was written against 7 syscalls and lib/sys_windows.mc against kernel32. The reasons are the project's own: (1) the binary alone is the toolchain (M15) -- a sandbox that is an apt package breaks "download mc and use it", and the project's isolation claims would live in someone else's binary; (2) everything teachable from the surface (M39) -- a part a recreated compiler includes or omits, measured by check-parts; (3) the host layer already exists for exactly this class of question, and host_os() decides in one place what a Mac does. The only script in the design, scripts/test-sandbox.sh, delegates to a Linux host (Lima or Docker --privileged) from macOS; it contains no mechanism.

1. The shape: three processes #

P  mc (host root, host uid)          parses the command line, resolves /proc/self/exe,
                                     creates the pipes, forks I, writes I's uid/gid maps,
                                     supervises: ppoll over {status pipe, listener fds},
                                     answers notifications, enforces the wall clock,
                                     reaps, prints the report, exits with the code of § 6.
I  the box (child of P)              unshare(NEWUSER|NEWNS|NEWPID|NEWNET|NEWIPC|NEWUTS);
                                     waits for the maps; mounts the tree (§ 3); pivot_root;
                                     sethostname("sandbox"); rlimits (§ 4); then one child
                                     per STEP (compile, then run), each reported to P over
                                     the status pipe as "pid N" then "exit N"/"signal N".
C  the step (child of I, pid 1       no_new_privs; Landlock (§ 3); close_range(3);
   of the new pid namespace)         seccomp with NEW_LISTENER (§ 4); reads one byte from
                                     the sync pipe (P has fetched the listener by then);
                                     execve.

Why three and not two: the seccomp filter must be installed by the process that execs (it is inherited, cumulative, and irrevocable), so it cannot live in I, which still has to clone/wait4 for the second step; and the supervisor cannot be I, because the listener has to be read by a process that is NOT under the filter and that holds CAP_SYS_PTRACE in the user namespace -- P, its creator. C is pid 1 of the pid namespace, so kill(C, SIGKILL) from P (allowed from the parent namespace) takes every descendant with it: that is the wall-clock kill and the fork-bomb kill in one syscall.

Why P writes the maps: unprivileged, /proc/<I>/uid_map may map only P's own uid (0 <uid> 1, after setgroups = deny and gid_map); as root, P maps the box's uid 0 to host 65534 so that "root inside" is nobody outside. One code path for both hosts; the difference is one number.

fork is clone(SIGCHLD, 0, 0, 0, 0) -- aarch64 has no fork syscall -- and returns into the same mc frame, which is safe because nothing shares the stack (no CLONE_VM).

2. The syscall shim and the number tables #

Every syscall the sandbox issues goes through one host-layer function,

i64 host_syscall6(i64 n, i64 a, i64 b, i64 c, i64 d, i64 e, i64 f);

with the raw result (-errno on failure, as lib/sys_linux.mc documents). No libc wrapper is used on purpose: prctl, syscall and clone are variadic in musl and glibc, and the project refuses variadic externs (M5.6: creat instead of open); seccomp, landlock_*, pidfd_* and close_range have no wrapper at all. Once a shim exists, using it for everything keeps the chain identical on both architectures except for the numbers.

The seed compiles both shim files (check-asm/check-ast compile every lib/*.mc and src/*.mc with mc0): #opcode and emit() have been in stage0 since M5.

3. The box #

The tree I builds, in order, all in its private mount namespace (the first mount is MS_PRIVATE|MS_REC on /, so nothing propagates back):

path in the boxsourcehowmount flags
/tmpfs size=<--out>m,mode=755mount("tmpfs", box, "tmpfs", ...)MS_NOSUID|MS_NODEV
/mcreadlink("/proc/self/exe") taken by P before any unsharebind of a file onto an empty file, then remount MS_BIND|MS_REMOUNT|MS_RDONLYro, exec
/srcthe source's directory (or the project directory)overlayfs: lowerdir=DIR, upperdir=/upper, workdir=/work on the box tmpfsrw, exec, capped by the tmpfs size=
/lib, /lib64, /usr/libthe host's, when they existbind + remount roro, exec, MS_NOSUID|MS_NODEV -- present so an M42 DYNAMIC binary finds ld-musl-*.so.1/ld-linux-*.so and libc.so
every other path--does not exist--

Then pivot_root(box, box/.old), umount2(".old", MNT_DETACH), chdir("/src"). No /proc, no /dev, no /etc, no /tmp, no /home: nothing in lib/*.mc or in the compiler opens them, and mc build gets HOME=/src in its environment (host_home()), PATH=/, nothing else -- a fixed, deterministic environment.

Overlay instead of a copy because copying a tree needs opendir/readdir layouts that differ per libc (what site/gen/util.mc had to do for macOS) and because the host tree must stay untouched by construction: every write the program makes lands in the tmpfs upper and dies with the box. The M42 --exe output, a taught compiler's build/ directory and build/.mc-usage.toml all go there.

Landlock, installed by C before seccomp (ruleset handling every fs access bit the ABI reports, LANDLOCK_RULE_PATH_BENEATH 1, one 12-byte landlock_path_beneath_attr per rule): /src read+write+make+remove+truncate+execute; /lib, /lib64, /usr/lib read+execute; /mc read+execute; with ABI >= 4, no TCP bind or connect; with ABI >= 6, scoped signals and abstract unix sockets. It is the second wall: if a mount were ever wrong, a path outside the four roots still answers EACCES.

4. Caps and the two filters #

rlimits, set by I and inherited by every step: RLIMIT_CPU = --time (soft = hard), RLIMIT_AS = --mem MiB, RLIMIT_FSIZE = --out MiB, RLIMIT_NOFILE 32, RLIMIT_NPROC 0 by default (64 with --allow=threads), RLIMIT_CORE 0, RLIMIT_STACK 8 MiB. struct rlimit is two u64 written byte by byte.

The filter is a sock_fprog (u16 len at 0, pointer at 8) over 8-byte sock_filter records (u16 code, u8 jt, u8 jf, u32 k): load arch (offset 4) and kill the process unless it is AUDIT_ARCH_AARCH64 0xC00000B7 / AUDIT_ARCH_X86_64 0xC000003E; load nr (offset 0); one JEQ nr -> ALLOW per allowlisted number (BPF_LD|BPF_W|BPF_ABS 0x20, BPF_JMP|BPF_JEQ|BPF_K 0x15, BPF_RET|BPF_K 0x06, SECCOMP_RET_ALLOW 0x7FFF0000, SECCOMP_RET_KILL_PROCESS 0x80000000, SECCOMP_RET_USER_NOTIF 0x7FC00000); the clone row also tests args[0] (offset 16) and allows only when CLONE_THREAD is set and no CLONE_NEW* bit is; everything else falls to USER_NOTIF. Installed with seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, &prog), whose return value is the listener fd.

Two profiles, both generated from a measurement (scripts/sandbox-trace.sh: strace -fc over the suite and over mc build examples/lang on both architectures, writing src/sandbox_profiles.mc as data; the checked-in file is what the script printed, and the script is what make check reruns when strace is present). Working hypotheses, to be replaced by the trace:

The explain channel. Every syscall not in the profile reaches P as a notification with nr and six scalar args. P's answers are fixed by table:

syscall reaching PP does
anything not belowprints refused: syscall N (name), answers -EPERM, kills C -- stop
openat/openreads the path with process_vm_readv; under one of the four roots: CONTINUE; otherwise refused: open PATH, -EACCES, stop. (Diagnostic only: enforcement is the mount tree and Landlock. With --allow=threads the read is racy, and the report says so in the docs.)
mmap (in the profile, but also notified)adds len to a running total (munmap subtracts); over --mem: refused: mmap N bytes over the cap (M), -ENOMEM, stop; else CONTINUE. RLIMIT_AS is the hard wall the kernel enforces regardless.
clone/clone3 with --allow=threadscounts; over 64: refused: process limit (64); else CONTINUE. Without the flag they are not in the profile: a fork bomb stops at its FIRST fork with refused: syscall 220 (clone).
execvecounts; the compile step allows 3 (mc, a taught compiler, its --entry-only child), the run step 1; beyond: refused: execve.
socket, connect, bindnever in a profile: refused: syscall 198 (socket). The network namespace is empty anyway.

The listener is fetched by P with pidfd_open(C) + pidfd_getfd(pidfd, 3, 0) -- C put the listener at fd 3 after close_range; C then reads its sync byte and execs. Names for the report come from a small table of the ~60 syscalls anyone would ask about; an unknown number prints as a number.

Time. P's ppoll timeout is --wall; on expiry it prints killed: wall clock (S s) and kills C. RLIMIT_CPU is the kernel's: C dies of SIGKILL and P, holding wait4's rusage, prints killed: cpu limit (S s) when ru_utime + ru_stime >= --time. A program that ends by a signal for its own reasons prints killed: signal N (SIGSEGV).

5. Steps #

mc sandbox run PATH is two steps in one box; mc sandbox exec BIN is one.

PATHcompile step (compile profile)run step (program profile)
prog.mc/mc --exe /src/prog.mc -o /src/prog/src/prog ARGS
a directory with mc.toml/mc build /src (the driver spawns the taught compiler inside the same box)/src/<[project].out> ARGS when kind = "exe", else no run step
--dump-asm etc. passed after run/mc --dump-asm /src/prog.mc (the dump IS the output)none

The compile step's exit code and stderr pass through unchanged; a compile failure ends the box with that exit code and no run step, and the report says compile: exit 1.

6. Interface, report, exit codes #

mc sandbox run  [OPTS] PATH [--] [ARGS]      compile PATH inside the box, then run it
mc sandbox exec [OPTS] BIN  [--] [ARGS]      run an already-built Linux executable
mc sandbox check                              print what this host can do, exit 0/1
OPTS:  --time S (2)  --wall S (5)  --mem MiB (256)  --out MiB (64)
       --allow=threads   --libc=musl|gnu (auto from PT_INTERP)
       --stdin FILE (default: EOF)   --ro DIR (repeatable)   --cwd DIR (inside /src)
       --report FILE (the report to a file instead of stderr)   --verbose (adds rusage; not deterministic)

The program's stdout and stderr ARE P's fds 1 and 2 (inherited through I and C): passthrough, unbuffered, in order. The report is written by P after the box is gone, one line per event, each starting with sandbox:, fixed vocabulary, no timing, no pid, no host path -- the only variable text is what the program itself passed (open PATH, mmap N):

sandbox: compile: exit 0
sandbox: exit 42
sandbox: killed: cpu limit (2 s)
sandbox: killed: wall clock (5 s)
sandbox: killed: signal 11 (SIGSEGV)
sandbox: refused: syscall 203 (connect)
sandbox: refused: open /etc/shadow
sandbox: refused: mmap 8589934592 bytes over the cap (268435456)
sandbox: refused: process limit (0)
sandbox: cannot unshare: EPERM (unprivileged user namespaces are restricted by AppArmor; see docs/reference/sandbox.md)

Exit code of mc sandbox: the program's own when it terminated on its own (including a compile failure's 1); 124 when a cap stopped it (cpu, wall); 125 when a refusal stopped it (syscall, path, mmap, process, execve); 126 when the box could not be set up (cannot ...). 2 and 3 stay M25's and M23's.

mc sandbox check prints, one per line: kernel release, userns: ok | restricted (apparmor) | EPERM, landlock: abi N, seccomp: notif ok, overlay: ok, pidfd: ok, and exits 1 if any is missing -- the guard make check and the CI leg consult, in the test-linux: SKIPPED (...) style.

7. Hosts #

8. The suite under the sandbox #

scripts/test-sandbox.sh [--arch A] [MC]: for each tests/*.mc with its // expect-* headers, mc sandbox run tests/NNN.mc (the box's /src is tests/, --ro . for 025-linecount, which opens its own source by a relative path from the repository root, --cwd accordingly), comparing exit and stdout exactly as test-linux.sh does, then the six tests/sandbox/*.mc isolation cases (§ Acceptance 2) each with an .expect holding the exact report lines and exit code. On a Linux host it runs natively; on macOS it delegates to Lima (LIMA_INSTANCE=mc-k7) and, failing that, to docker run --privileged. make test-sandbox inside make check, self-skipping with a printed reason when mc sandbox check fails.

CI: job sandbox on ubuntu-24.04-arm and ubuntu-latest, twice each -- sudo (root path) and unprivileged after sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 -- plus strace -fc comparing the measured profile against the checked-in one. The plan's "Rule for every new target" applies by analogy: no isolation claim without a runner that proves it, and the job becomes a required check.

Out of scope (Layer 1) #

Files and estimated deltas (Layer 1) #

filelineswhat
src/sandbox.mc~380new: the subcommand (run/exec/check), option parsing, P's supervisor loop (ppoll, notifications, wall clock, reaping), the report and exit codes
src/sandbox_box.mc~260new: I -- unshare, waiting for the maps, the mount tree, overlay, pivot_root, hostname, rlimits, the step loop
src/seccomp.mc~230new: the BPF builder, seccomp() with the listener, the notif record layouts (80-byte seccomp_notif, 24-byte seccomp_notif_resp), Landlock ruleset and rules, close_range, no_new_privs
src/sandbox_profiles.mc~120new, generated by scripts/sandbox-trace.sh and checked in: the two profiles per architecture, the --allow=threads delta, the ~60 syscall names
src/core_sandbox.mc~25new: the part; mc_sandbox_init() = one subcommand("sandbox", &sandbox_cmd, " mc sandbox run|exec|check ...\n")
src/sysno_linux_aarch64.mc / src/sysno_linux_x86_64.mc~70 eachnew: sys6 (8 #opcode words / 6 emit() words) and the number table
src/host_linux_aarch64.mc, src/host_linux_x86_64.mc+1 each#include of the table
src/host_linux.mc+6host_syscall6 = sys6; host_sandbox_supported() = 1
src/host_macos.mc, src/host_windows.mc+8 eachhost_syscall6 answering -ENOSYS, empty table, host_sandbox_supported() = 0
src/core.mc+1the sixth part
src/main.mc+1mc_sandbox_init()
tools/bundle.list+7mc/core_sandbox, mc/sandbox, mc/sandbox_box, mc/seccomp, mc/sandbox_profiles, the two sysno files
src/bundle_data.mcregeneratedthe five goldens move once
tests/sandbox/{shadow,connect,forkbomb,eightgib,forever,clean}.mc + .expect~25 eachthe six isolation cases
scripts/test-sandbox.sh~200the suite under the sandbox, the six cases, Lima/Docker delegation from macOS
scripts/sandbox-trace.sh~90strace -fc -> src/sandbox_profiles.mc; diff mode for CI
scripts/check-parts.sh+12<mc/core_min> + <mc/core_sandbox> compiles on its own; the six sys6 words asserted on --dump-asm --machine=x86_64
scripts/check-surface.sh+10the aarch64 shim's eight words
Makefile+15test-sandbox, sandbox-trace, inside check:
.github/workflows/ci.yml+70the sandbox job, four cells
docs/reference/sandbox.md~260new: the box, the profiles, the report grammar, exit codes, the host table, what is NOT isolated (the kernel)
docs/guide/99-sandbox.md~120new: "run something you do not trust"
docs/reference/cli.md, docs/build.md, docs/ci.md, docs/reference/hooks.md, docs/reference/bundle.md+110the subcommand, the hosts, the part
stage0/, lib/, tests/*.mc0untouched

About 1200 lines of mc in src/, ~300 of scripts, ~500 of docs. mc grows by roughly 35 KB of __text; a recreated compiler that omits <mc/core_sandbox> pays none of it (check-parts measures).

Acceptance (Layer 1), in order #

  1. The shim is right before anything uses it. On both Linux hosts, host_syscall6(SN_GETPID, ...) equals getpid() from musl, and host_syscall6(SN_OPENAT, ...) of a missing file answers -2 (-ENOENT). The x86-64 words re-assemble under llvm-mc byte for byte; check-surface/check-parts assert both shims in --dump-asm. (The M39 discipline: the one thing that can sink the milestone is proved first.)
  2. Isolation is proved by six programs, each with a named diagnostic and the exact exit code, run by scripts/test-sandbox.sh on both architectures, as root and unprivileged:

    • shadow.mc opens /etc/shadow: sandbox: refused: open /etc/shadow, exit 125; a control run OUTSIDE the sandbox as root reads it.
    • connect.mc calls socket then connect to 1.1.1.1:80: sandbox: refused: syscall 198 (socket) (41 on x86_64), exit 125; the netns is additionally shown empty by a --allow-less bind attempt in the same test's second half.
    • forkbomb.mc calls clone(SIGCHLD) in a loop: sandbox: refused: syscall 220 (clone), exit 125, and the host's process count before and after is equal; with --allow=threads the same source stops at refused: process limit (64).
    • eightgib.mc maps 8 GiB and touches every page: sandbox: refused: mmap 8589934592 bytes over the cap (268435456), exit 125; the host's free memory is unchanged.
    • forever.mc loops: sandbox: killed: cpu limit (2 s), exit 124; sleeper.mc (blocking read on stdin with --stdin unset... which is EOF, so it uses clock_nanosleep under --allow=threads): sandbox: killed: wall clock (5 s), exit 124; measured wall time within 5.0-5.5 s.
    • clean.mc -- tests/013-putnum.mc verbatim -- prints 46368 and exits 0 with sandbox: exit 0, and its stdout is byte-identical to the unsandboxed run.
  3. The whole suite runs under the sandbox with identical results: scripts/test-sandbox.sh 33/33 on linux/aarch64 and 30/30 on linux/x86_64 (the test-linux.sh counts), through mc sandbox run (compile inside) AND through mc sandbox exec on the M42 --exe binaries, including at least one dynamic one that reaches libc.so through PT_INTERP -- with /lib bind-mounted and nothing else of the host.
  4. A project builds inside the box. mc sandbox run examples/lang teaches the compiler and runs main.lx with the same output as examples/lang/test.sh; the host's examples/lang/build/ is untouched (git status clean, mtimes unchanged); the compile step's execve count is 3.
  5. The profiles are measured, not written. scripts/sandbox-trace.sh --check finds no syscall in the trace that is missing from src/sandbox_profiles.mc, and no profile entry the trace never used, on both architectures. A deliberate extra entry fails it.
  6. Determinism. Two runs of every case produce byte-identical report files (--report); the report contains no digit sequence that changes between runs (no pid, no time); the host tree has no new file after any run (find -newer empty); mc with no arguments prints the previous usage text plus exactly one sandbox line.
  7. Overhead is measured and printed: mc sandbox exec /src/true (a return 0 program) 200 times vs plain, median box setup cost printed by the script and recorded in docs/reference/sandbox.md (hypothesis: 3-8 ms; the number the playground's capacity math uses).
  8. The refusals are honest. On macOS mc sandbox run x.mc prints the Lima command and exits 126; on a Linux host with the AppArmor restriction on, mc sandbox check says userns: restricted (apparmor) and run says cannot unshare: EPERM (...), exit 126; inside docker run WITHOUT --privileged, the same shape.
  9. Inertness and the parts. check-parts: <mc/core_min> + <mc/core_sandbox> compiles alone; a compiler that omits the part prints no sandbox usage line and mc sandbox there is unknown option-class refusal as today. check-obj 32/32 against the frozen seed; scripts/check-inert.sh clean; the five goldens rewritten once, after the empty --dump-asm diff and cmp build/mc2.o build/mc3.o.
  10. CI: the four-cell sandbox job green and made a required check; make check on macOS reports test-sandbox either green (Lima/Docker present) or SKIPPED (mc sandbox check: ...) with the reason.
  11. Docs: make check-docs green with every new symbol and flag documented; docs/reference/sandbox.md has a section titled "What is not isolated" that names the kernel.
  12. git diff --stat stage0/ lib/ tests/*.mc is empty.

Risks (Layer 1) #

  1. Ubuntu's AppArmor user-namespace restriction is the one thing that makes the unprivileged path fail on a stock 26.04 install. Mitigation: mc sandbox check names it; the docs give the sysctl and the 10-line profile; the CI job runs both configurations so neither silently rots. Do not "fix" it by requiring root.
  2. SECCOMP_USER_NOTIF_FLAG_CONTINUE on openat is a diagnostic read of a pointer argument, TOCTOU-racy with threads. Contained: enforcement never depends on it (mounts + Landlock), the docs say so, and the single-threaded default profile has no race at all.
  3. Overlayfs over virtiofs (Lima's mount) or over unusual lower filesystems may refuse (EINVAL). Fallback priced, not built: a ro bind of /src plus a separate tmpfs /out and -o /out/prog; mc build projects would then need the copy. Measured on Lima and on the VPS in acceptance 4 before the fallback is decided.
  4. The compile profile drifts whenever the compiler gains a syscall (a new libc call in a host file, a new driver step). Contained by acceptance 5 running in CI: the trace and the table must agree, in both directions.
  5. RLIMIT_NPROC semantics across kernels: per-user-namespace accounting is the 5.14 ucounts behaviour; the baseline is 7.x, and the default profile refuses clone outright so the rlimit is the second wall, not the first.
  6. Killing pid 1 of the pid namespace from outside is the wall-clock mechanism; it is documented kernel behaviour (SIGKILL from the parent namespace is always delivered to init). Proved by forever.mc/sleeper.mc and by the process-count check in forkbomb.mc.
  7. The residual is the kernel. ~15 allowed syscalls on a 7.0 kernel; a bug in one of them is not stopped by anything here. Written down in the docs; Layer 2 handles it by disposability, not by pretending.
  8. Two shims to keep true, one per architecture, both hand-encoded. Acceptance 1 and the two check-* assertions are what keep them from drifting; they are 14 words in total.

Implementation notes -- step A (measured) #

Step A is the M39/M42 probe discipline: the shim and the part, and nothing that uses them. What landed is src/sysno.mc, src/sysno_linux_aarch64.mc, src/sysno_linux_x86_64.mc, the three host answers, src/sandbox.mc (option parser, check, refusals) and src/core_sandbox.mc; run/exec print not in this step and exit 126. stage0/, lib/ and tests/*.mc are untouched (acceptance 12).

  1. One shared enum file. The spec's file list has the two sysno_linux_* files and nothing between them, but the SN_* names have to exist on macOS and Windows too (their tables are all-absent rows of the same length). Writing sixty #defines four times is not a design, so the names are src/sysno.mc and all four host files include it. Three bundle entries, not two: mc/sysno, mc/sysno_linux_aarch64, mc/sysno_linux_x86_64.
  1. host_sysno(sn) joined the host interface, beside host_syscall6 and host_sandbox_supported. The number table is per architecture, so the lookup is too; a raw ld16(sysno_tab + sn * 2) in src/sandbox.mc would also be the raw-offset access CLAUDE.md forbids. It answers -1 for a call this architecture does not have (access and arch_prctl on AArch64, SN_ABSENT in the table).
  1. Sixty names, not thirty-five. The spec's § 2 list is what the box ISSUES; § 4's profiles are lists of numbers the filter allows, and they cannot be written in src/sandbox*.mc either. Both sets are in the enum now, so src/sandbox_profiles.mc can be generated in SN_* terms.
  1. unshare(CLONE_NEWUSER) does not fail under the AppArmor restriction. § Risks 1 says it does. Measured on Ubuntu 26.04 / kernel 7.0.0-30-generic, unprivileged and unconfined, with kernel.apparmor_restrict_unprivileged_userns = 1, on both architectures (a Lima VM on aarch64 and the x86_64 VPS): the unshare succeeds, and so does the combined unshare(NEWUSER|NEWNS|NEWPID|NEWNET|NEWIPC|NEWUTS). Since Ubuntu 24.04 the kernel does not refuse the namespace; AppArmor transitions the process into the unprivileged_userns profile (/etc/apparmor.d/unprivileged_userns), which is audit deny capability and carries no mount rule at all. The audit record is apparmor="AUDIT" operation="userns_create" info="Userns create - transitioning profile" profile="unconfined" target="unprivileged_userns", followed by apparmor="DENIED" operation="capable" profile="unprivileged_userns" capability=21 capname="sys_admin".

    What actually fails is the box's first mount:

    call (unprivileged, sysctl = 1)aarch64x86_64
    unshare(NEWUSER|NEWNS|NEWPID|NEWNET|NEWIPC|NEWUTS)00
    mount(0, "/", 0, MS_PRIVATE|MS_REC, 0)-13 EACCES-13 EACCES
    mount("tmpfs", "/tmp", "tmpfs", 0, "size=1m")-13 EACCES-13 EACCES
    sethostname("sandbox", 7)-1 EPERM-1 EPERM

    As root all four return 0 on both.

    So mc sandbox check's userns probe is two stages -- the combined unshare, then that first mount, inside the child's own mount namespace where it changes nothing. A one-stage probe would print ok on a host where the box cannot be built. Consequences for step B: the supervisor's first diagnostic will be an EACCES from mount, not an EPERM from unshare, and § 6's cannot unshare: EPERM (...) line needs a sibling for the mount. The AppArmor way out is unchanged (the sysctl, or a profile granting userns, and mount,).

  1. overlay distinguishes not loaded from absent. /proc/filesystems lists REGISTERED filesystems. On the Lima oracle Docker had loaded the module and the line reads ok; on the x86_64 VPS, which runs no container engine, it does not, while /lib/modules/7.0.0-30-generic/kernel/fs/overlayfs/overlay.ko.zst is present. Root would autoload it on the first mount -t overlay (request_module needs CAP_SYS_ADMIN in the init user namespace, which the box will not have), so the honest line is not loaded (modprobe overlay) and the check fails with the fix printed. § 6's fixed vocabulary grows by that one value, and userns grows by EACCES.
  1. check on macOS and Windows exits 126, not 1. § 6 gives check the codes 0/1 and § 7 gives the refusal 126. A refusal is not a failed measurement, so all three verbs print the same sentence with the same code; a guard script testing if mc sandbox check is unaffected.
  1. The probe of acceptance 1 is generated, not checked in. tests/sandbox/ is named in the file list with six specific programs and tests/*.mc is untouchable, so scripts/check-shim.sh writes its probe into build/shim/ and builds it with the compiler under test. It adds a fourth assertion the spec did not ask for and that the two static gates cannot make: a six-argument mmap at a non-zero offset, which is the only way to prove that the SEVENTH parameter of sys6 -- x6 on AArch64, [rbp+16] on x86-64 -- reaches the kernel.
  1. The seed's MAXGLOBALS is the row to watch. mc limits src/mc.mc goes from 420/512 (82%) to 437/512 (85%) with step A in. check-limits fails above 90%, and step B adds the BPF builder, the notif records and the profiles. It will need either fewer globals or a seed change the owner authorises.

Implementation notes -- step B (measured) #

Step B is the box and the supervisor: § 1, § 3, § 4's rlimits and time, § 5 and § 6, with Landlock, seccomp and the notification channel left to step C. src/sandbox_box.mc (I, J and C), src/sandbox.mc (P, rewritten around one arena record), scripts/test-sandbox.sh, tests/sandbox/ (8 programs) and the Makefile target. stage0/, lib/ and tests/*.mc are untouched (acceptance 12).

Ten things the kernel said that the design did not, in the order they were hit. Every one of them was measured on Ubuntu 26.04 / kernel 7.0.0-30, on linux/aarch64 (a Lima VM) and linux/x86_64 (the VPS), as root and unprivileged.

  1. A root map of 0 65534 1 makes the box unable to create anything. § 1's "root maps the box's uid 0 to host 65534" leaves the CALLER unmapped -- outer uid 0 has no inner number -- so the box's fsuid is the overflow uid and the first mkdirat in its own tmpfs answers EOVERFLOW (75). Adding a second line (1 0 1) and a setuid(0) fixed that and failed the next test (note 2). The map is now 0 0 65536 for root and 0 <uid> 1 for everyone else, and there is no setuid at all: both maps put the caller at the inner uid 0.
  1. An overlay copy-up needs permission on the LOWER directory. With root mapped to inner 1, mc --exe inside the box answered cannot create: /src/tests/061-pass on the VPS, where the tree belongs to root: the merged directory carries the lower's owner and mode (inner 1, 755) and the box was inner 0. CAP_DAC_OVERRIDE does not help -- capable_wrt_inode_uidgid() requires the inode's owner to be mapped in the namespace asking. The identity range map is what fixes it, and it is why "root inside is nobody outside" is not in the shipped design. The cost is note 3.
  1. RLIMIT_NPROC does not bind for root, because copy_process skips the check outright for INIT_USER and the root map is now the identity. Unprivileged it is a real wall: at 0 the kernel refuses the FIRST clone with EAGAIN, for a plain fork and for the clone3(CLONE_VM|CLONE_VFORK) behind posix_spawn alike, and forkbomb.mc prints forked 0. As root it prints forked 200, its own ceiling. § Risks 5 expected the opposite (a capability bypass everywhere); the answer is to run the sandbox unprivileged, and step C's seccomp refusal of clone for root. tests/sandbox/forkbomb.mc carries both expectations and the script picks by id -u.
  1. A pid namespace cannot be reused, and cannot be re-unshared. § 1 has C as pid 1 and I running one child per step; the second step then dies at clone with ENOMEM, because a pid namespace whose init exited accepts no new process. unshare(CLONE_NEWPID) a second time is EINVAL: copy_pid_ns() refuses when the caller's active pid namespace is not the one its children would join, which is exactly what the first unshare leaves behind. So there is a fourth process, J: the first child of I, pid 1 for the whole box, running the steps underneath it. It is also what P kills for the wall clock.
  1. The wall-clock line is P's own. Killing J kills the process that reports, so P composes killed: wall clock (S s) itself. It needs to know which step was running for the compile: prefix, so J announces each step with a B <step> line before forking it.
  1. The cpu decision is J's, not P's. § 4 has P read wait4's rusage; P waits for I and would see the sum of every step, so a compile that took a second would make a segfaulting program look like a cpu cap. J holds the rusage of ONE step and sends the verdict as a third field on the S line. Two details there: with soft = hard the kernel sends SIGKILL, not SIGXCPU (9, not 24, measured), and the recorded time is a shade UNDER the limit -- a 2 s cap came back as 1.997 s -- so the comparison carries a tenth of a second of slack.
  1. RLIMIT_AS and RLIMIT_NPROC cannot be set by I. RLIMIT_AS applies to the process that sets it and I is a fork of mc carrying its own arena; RLIMIT_NPROC is checked by fork and I still has to fork the steps. C sets those two plus RLIMIT_STACK immediately before execve; I sets RLIMIT_CPU, RLIMIT_FSIZE, RLIMIT_CORE and RLIMIT_NOFILE, which are inherited and whose failure I can still report. And the compile step gets RLIMIT_NPROC 16, because mc build writes a compiler and then runs it (§ 5).
  1. Two options the corpus forced, both beyond § 6. --root DIR makes /src a tree the source is inside: half of tests/*.mc includes ../lib/sys.mc, and with /src at the source's own directory that resolves to /lib/sys.mc and fails. --config NAME names the project file for run DIR and keeps [project].out relative to that file's directory, which is what lets examples/lang -- whose mc.toml targets macOS and which includes ../../lib/prelude.mc -- be built inside the box as mc sandbox run --config examples/lang/mc.linux-gnu.toml .. Two new files in that example, mc.linux.toml and mc.linux-gnu.toml, are its Linux configs: no [target] section at all, so the pair is the host's, and the second adds libc = "gnu" because the executable writer's default loader is musl's and every host measured here is glibc.
  1. --report FILE writes to the file AND to stderr, where § 6 said "instead of". A script needs the file to compare two runs byte for byte and a person still needs the diagnostic.
  1. overlayfs mounts on virtiofs. § Risks 3 named Lima's mount as the suspect; measured, the overlay works on both hosts and both privileges, with userxattr -- which is required, because a user namespace cannot set trusted.overlay.*. The priced fallback (a read-only /src plus a writable /out) is built and has not been reached by any cell.

Two smaller ones: /etc/shadow is not refused but ABSENT (ENOENT, there is no /etc), and a connect answers ENETUNREACH from the empty network namespace -- the named refusals of § 4 are step C's, and tests/sandbox/shadow.mc and connect.mc say so in their headers. And the box's own directory is a fresh /tmp/.mc-box<pid> rather than a tmpfs over an existing path, because the tree being compiled may itself live under the path that would be shadowed; P removes it.

Measured (scripts/test-sandbox.sh, four cells plus the Docker one):

cellisolationsuiteprojectoverhead
linux/aarch64 root (Lima)8/831/31, 1 skippedok1.37 ms
linux/aarch64 unprivileged8/831/31, 1 skippedok1.43 ms
linux/x86_64 root (VPS)8/829/29, 3 skippedok4.12 ms
linux/x86_64 unprivileged8/829/29, 3 skippedok4.98 ms
linux/aarch64 alpine:3, docker --privileged, kernel 6.12.768/831/31ok--

with mc sandbox exec on a static and a dynamic binary in every cell, two byte-identical reports, find -newer empty and no /tmp/.mc-box* left behind. Unprivileged with the stock kernel.apparmor_restrict_unprivileged_userns=1, all four verbs print sandbox: cannot mount /: EACCES (apparmor restricts unprivileged user namespaces: ...) and exit 126 (acceptance 8).

Globals (§ B3): the whole milestone owns ONE, sb_state, an arena record with named offsets and accessors. mc limits src/mc.mc reports globals 422/512 with step B in -- step A's sixteen sandbox globals became the record, so the row went DOWN from 437.

Implementation notes -- step C (measured) #

Step C is the two walls and the explain channel: § 3's Landlock, § 4's filter and notification table, § 6's exit 125, and the profiles of § 4 as a measurement rather than a hypothesis. src/seccomp.mc (the BPF builder, the Landlock ruleset, the notification ABI and P's policy), src/sandbox_profiles.mc (generated), scripts/sandbox-trace.sh, and the edits to src/sandbox.mc, src/sandbox_box.mc, src/sysno*.mc and the four host files. stage0/, lib/ and tests/*.mc are untouched (acceptance 12).

Fourteen things the kernel, the two C libraries and the two architectures said that the design did not, in the order they were hit. Everything below was measured on Ubuntu 26.04 / kernel 7.0.0-30, on linux/aarch64 (a Lima VM, glibc, plus alpine:3 for musl) and linux/x86_64 (the VPS, which has both loaders), as root and unprivileged.

  1. The listener travels in two hops, not one. § 4 has P do pidfd_open(C) + pidfd_getfd. P cannot: C's pid is a number in the box's pid namespace, and P's own namespace numbers it differently -- and the field of seccomp_notif that would tell P the right number arrives only with the first notification, which is what the listener is for. So C reports the descriptor NUMBER to J over its error pipe, J (C's parent, in the same namespace) does the first pidfd_getfd, and P does the second one on J, whose pid it already knows. Both hops need PTRACE_MODE_ATTACH_REALCREDS, and both have it: same real uid -- a user namespace remaps credentials, it does not change them -- and a descendant, which is what Yama ptrace_scope = 1 allows. That is the value on both hosts, measured, and no relaxation was needed.
  1. The sync pipe had to move to P. The byte that releases C means "your listener is held", so the process that holds it is the one that must write it. It is now created by P before the box exists and inherited by C through I and J; C's three close_range sweeps keep it exactly as they kept the per-step pipe before.
  1. J may not speak before I has announced its pid. J's L <fd> line and I's P <pid> line go down the same status pipe, and P cannot act on the first without the second. I now forks J, writes P <pid>, and only then lets J start, through a one-byte pipe of its own.
  1. A Landlock rule on a FILE may not carry a directory right. /mc is a bind of the compiler binary, and granting it EXECUTE|READ_FILE|READ_DIR is EINVAL from landlock_add_rule -- which is how the very first run failed, with cannot install the Landlock ruleset: EINVAL and nothing to say which rule.
  1. /etc/ld.so.cache had to come INTO the box, one host file more than § 3's "no /etc", and with a Landlock rule of its own. glibc's loader opens it at every start; without it the loader takes its fallback path, which issues calls the same binary never issues on the host -- and the profiles are measured on the host. It cost two refusals to learn: refused: syscall 233 (madvise) on AArch64 (one run in twelve, see 9), and refused: syscall 262 (newfstatat) on x86-64 for EVERY dynamic program, while the loader probed /lib/x86_64-linux-gnu/glibc-hwcaps/x86-64-v4/. The mount alone was not enough: Landlock refuses what it does not grant, so the file needed LANDLOCK_ACCESS_FS_READ_FILE as well, and the AArch64 box hid that for a while because its loader finds libc in the first default directory it tries.
  1. Five calls are notified even though every profile contains them -- openat, open, mmap, munmap, execve. § 4 says "in the profile, but also notified", and a filter cannot say both: the entry is dropped from the allowlist and the call falls to USER_NOTIF, where P answers CONTINUE. Without that, tests/sandbox/shadow.mc opened /etc/shadow with nobody watching and the milestone's flagship line never appeared.
  1. Two calls are in every profile whatever the trace said: the read that waits for the sync byte and the close of C's own copy of the listener. They cannot be moved before the filter (the listener does not exist yet) and they are C's, not the program's. Measured, without them: refused: syscall 0 (read) for every musl run step, because musl's stdio reads with readv and no program in the corpus ever issued a bare read -- glibc's did, so the AArch64 profile hid it and the x86-64 one did not.
  1. The glibc delta is per STEP, not one list. read is in musl's compile trace and not in its program trace; clone3 is in the compile trace only. A single delta over both kinds would have failed in both directions at once -- no read for a glibc program, and a way to fork for a glibc program that the profile exists to refuse.
  1. A profile measured from a corpus that never allocates is a profile that refuses one run in twelve. Every tests/*.mc writes with a raw write; none of them mallocs. madvise appeared in 5 of 60 runs of a program that merely calls fopen -- glibc advises MADV_HUGEPAGE when the chunk's alignment happens to warrant it. The trace script now writes a probe that allocates four megabytes in one block, which makes it 20 of 20, and does stdio, a seek, a directory listing and a signal mask beside it. rt_sigprocmask came from the same gap: glibc blocks signals around fork, so without it a fork bomb was refused at its signal mask rather than at its clone.
  1. strace -c cannot be used to measure a profile. Its summary omits exit_group, which never returns and is therefore never counted -- and a profile without exit_group refuses every program at its last instruction. Measured: -c reported 15 calls for tests/013-putnum, the raw trace 16.
  1. musl on x86-64 forks with fork, not clone. Syscall 57, which the generic table does not have at all, so on AArch64 every fork is a clone. SN_FORK/SN_VFORK joined the enum, the supervisor counts all four process-creating calls against the same cap, and tests/sandbox/forkbomb.mc grew a third expectation header (sandbox-report-x86_64-musl: refused: syscall 57 (fork)), because the number in a refusal is a property of the architecture AND of the C library.
  1. A refused call is not answered. § 4 has P answer -EPERM/-EACCES and then kill; measured, that wakes the step inside its system call and it runs for as long as the SIGKILL takes to travel -- shadow.mc printed shadow errno=13 and connect.mc printed socket refused on some runs and not on others. Killed and left unanswered, the step's output ends exactly where the refusal happened, which is what makes an .expect file possible. The errno column of § 4 is what the program WOULD be told by a box that continued; this one stops.
  1. The process cap needs the rlimit to be looser than the counter. With RLIMIT_NPROC and P's counter both at 64, the kernel's EAGAIN arrives first -- the box already holds I, J and C against the same user -- and the program sees a failed fork instead of refused: process limit (64). The rlimit backstop is 128 with --allow=threads; the named wall is the counter.
  1. eightgib.mc had to ask the kernel directly. Through malloc(8 GiB) glibc adds a page for the chunk header and the report reads mmap 8589938688 bytes -- true, and impossible to write down in a .expect. The test now calls mmap itself, and the line is the number in the file.

Two smaller ones. The compile step's execve count is 2, not the 3 § 4 predicted: mc build execs the compiler it wrote, and that compiler compiles the entry in-process under --entry-only. Three is kept as the ceiling. And an unprivileged box can only copy up a file whose owner and group are mapped -- a tree owned user:root fails at the first write with cannot create, which is the same class of failure as step B's notes 1 and 2 and cost an afternoon on a test machine whose checkout had been chowned by hand.

Measured (scripts/test-sandbox.sh, five cells; the isolation set is ten cases now, eight programs with forkbomb run twice and libcuser added):

cellisolationsuiteexecprojectbox cost
linux/aarch64 glibc, root (Lima)10/1031/312/2ok1.74 ms
linux/aarch64 glibc, unprivileged10/1031/312/2ok1.70 ms
linux/x86_64 musl, root (VPS)10/1029/292/2ok3.0-4.5 ms
linux/x86_64 musl, unprivileged10/1029/292/2ok3.7 ms
linux/aarch64 musl (alpine:3, --privileged)10/1031/312/2ok--

plus the x86-64 glibc profile exercised by hand on the same VPS with --libc=gnu. Against step B on the same quiet machine: the box cost 1420 us -> 1717 us, so the two walls are +297 us, +21% -- one ruleset with eight rules, one filter, two pidfd_getfd hops and a dozen notification round trips. On the VPS the same difference is inside the machine's own noise.

sh scripts/sandbox-trace.sh --check is green on all four measured cells, in both directions, and fails (exit 1, one FAIL line) on a deliberate extra entry.

Implementation notes -- step D (measured) #

Step D is the CI job (acceptance 10), the guide, and the last acceptance items. The job is The sandbox (linux/arm64) on ubuntu-24.04-arm and The sandbox (linux/x86_64) on ubuntu-latest; each runs scripts/test-sandbox.sh unprivileged and then under sudo, plus the profile trace and the unprivileged-container refusal. It found two things the four local cells could not, and both were real.

  1. A profile is a union over C library VERSIONS, not one host's trace. The runners are Ubuntu 24.04 (glibc 2.39); the project's oracles are Ubuntu 26.04 (glibc 2.43). With one compiler and one corpus the two do not issue the same calls: 2.43 uses madvise and getrandom at start-up and clone3 to spawn, 2.39 uses neither and needs rt_sigaction and clone -- both MISSING from the table, which is a legitimate program refused at run time, not a bookkeeping difference. Measured in ubuntu:24.04 on this Mac (glibc 2.39-0ubuntu8.7, aarch64) and folded in with the new --union; --check now fails only on the direction that matters (a call the table lacks) and reports the other as a note; --strict keeps the two-way failure for a single-host audit. The x86-64 glibc 2.39 row is the runner's own measurement, in CI, on every pull request.
  2. lex_readable believed a file that was not there. open returns a C int; M42 recorded the hazard and M45 narrowed every call site with c_int() except this one. On the runners a failing open hands back 0x00000000ffffffff, so fd < 0 was false and the function answered "readable" for every missing path -- on the project's own machines the same call sign-extends and the bug is invisible. What it broke was mc build on a tree with no build/ yet: lim_read_usage was told the usage file existed and read_file (which does narrow) died with mc: cannot open: .../build/.mc-usage.toml. It is the only failure in 51 and 49 other cases, on both architectures, and it is not a sandbox bug at all -- the same false positive is in the [include].paths search. One line in src/lex.mc, objects unchanged (scripts/check-inert.sh against origin/main: 33 objects and the five taught examples identical).

Three smaller things. The artifact is four executables, not objects: since M42 mc build writes a dynamic ELF itself, so a runnable Linux compiler costs half a second on the macOS job with no linker and no sysroot (make mc-linux-gnu for the four cells, make mc-linux for the alpine:3 container cell). scripts/ci-sandbox-cell.sh holds a runner to more than a developer's machine -- the guard may not skip the run and no isolation case, exec, project or overhead measurement may be skipped, only a test's own // skip-linux: header. And Docker Desktop on macOS is no longer a usable delegate for a bind-mounted checkout: execve of a file on its fakeowner mount inside the box answers EACCES (sandbox: cannot execute the step, exit 126) while the same image, kernel and compiler run the whole suite when the tree is on the container's own filesystem. Lima is unaffected and is the first delegate the script tries.

What the runners answered, which is the whole point of the job:

kernel: 6.17.0-1022-azure     both runners
landlock: abi 7               (the oracles are abi 8; the floor is 4)
seccomp: notif ok
overlay: ok                   (Docker has loaded it)
pidfd: ok

kernel.apparmor_restrict_unprivileged_userns = 1   as the runner ships
  userns: restricted (apparmor)      exit 1
sysctl -w ...=0
  userns: ok                         exit 0

Measured, both jobs green (run 33962063476, the second push of the branch; the first found the two facts above and failed on nothing else -- 51/1 and 49/1, the other twelve jobs green):

cellsuitebox cost
linux/arm64 runner, unprivileged52 ok, 0 failed, 1 skipped2210 us (plain 152)
linux/arm64 runner, root52 ok, 0 failed, 1 skipped2111 us (plain 142)
linux/x86_64 runner, unprivileged50 ok, 0 failed, 3 skipped2167 us (plain 109)
linux/x86_64 runner, root50 ok, 0 failed, 3 skipped2109 us (plain 96)

sandbox-trace.sh --check: aarch64 compile 25 / program 22 / threads 7 covered, three note lines (clone getrandom rt_sigaction, madvise, clone -- the 2.43 entries this host does not need); x86_64 compile 25 measured against a 26-entry table, program 23, threads 6, one note (getrandom). The x86-64 glibc 2.39 row needed nothing added, which is what the union was for. docker run without --privileged, both architectures: userns: EPERM and sandbox: cannot unshare: EPERM, exit 126.

Implementation notes -- the review (measured) #

The security review of the branch raised three findings. All three are fixed here; the code change is src/seccomp.mc, src/sandbox.mc, src/sandbox_box.mc and scripts/sandbox-trace.sh, stage0/, lib/ and tests/*.mc untouched (acceptance 12).

1. HIGH -- a process-creating call was a plain ALLOW in the compile profile #

What it was. A profile entry is a call the supervisor never sees. mc build forks -- it writes a compiler and runs it -- so clone was measured into the compile profile (and clone3 into the glibc delta) and written there as a plain SECCOMP_RET_ALLOW. The arg-checked clone block existed but was emitted only under --allow=threads, and it is a filter, so it could not have named anything anyway.

mc build also runs [linker].cmd, and [linker].cmd comes out of the source tree's own mc.toml. So an untrusted tree chose which binary the COMPILE step executed.

Reproduced on the Lima oracle (Ubuntu 26.04, kernel 7.0.0-30, aarch64, glibc 2.43), with the compiler built from the branch's HEAD before the fix, on the project now checked in as tests/sandbox/linkbomb/ -- an mc.toml naming /src/bomb as its linker and a fork bomb beside it:

                       unprivileged                     root
pre-fix   forked 12  / sandbox: compile: exit 1 / rc 1   forked 200 / compile: exit 1 / rc 1
post-fix  sandbox: refused: process limit (16)  / rc 125          the same, both privileges

Twelve children unprivileged (RLIMIT_NPROC 16, the only backstop, and the box already holds three processes against that user), two hundred as root -- the bomb's own ceiling, because copy_process skips the RLIMIT_NPROC check outright for INIT_USER. No refused: line in either case: the report said compile: exit 1 and nothing else.

The same position with CLONE_NEWUSER was worse than unbounded, it was allowed: with the bomb replaced by tests/sandbox/nsclone.mc the pre-fix box printed cloned 4 -- a fresh user namespace created inside the box -- and stopped only later, at an unrelated exit from the child (refused: syscall 93 (exit)).

The rule now, and it is a rule about the class and not about a call: a call that makes a process is never a plain ALLOW in any profile. sb_notified() (src/seccomp.mc) drops clone, clone3, fork and vfork from every allowlist before the filter is built, exactly as it already dropped openat/open/mmap/munmap/execve, so all four always reach P. P then asks two questions in order:

  1. does it ask for a namespace? Any CLONE_NEW* bit -> refused: clone with namespace flags, whatever the step and whatever the limit. For clone the flags are args[0]; for clone3 they are the first u64 of the struct clone_args the caller owns, read out of the step with process_vm_readv (args[1] is the struct's size) -- BPF cannot follow a pointer, which is why clone3 can never be flag-tested by the filter and is always a notification. A struct that cannot be read is refused: clone3 with unreadable arguments: a process-creating call whose flags cannot be inspected is not one to let through. CLONE_NEWTIME (bit 7) is in the clone3 mask and not in the clone one, where bits 0..7 are the exit signal.
  2. how many has this step made? A per-step counter: 16 for the compile step (what mc build needs is two or three), 0 for a run step, 64 with --allow=threads (unchanged). One past it is refused: process limit (N).

RLIMIT_NPROC stays as the second wall and had to be loosened where the counter now sits: the compile step's is 32, because two walls race and the named one has to win (the same fact step C's note 13 recorded for --allow=threads).

With --allow=threads the filter still short-cuts a REAL thread by its flags, so a threaded program pays no round trip per thread; a new PROCESS always does.

What moved in the report. tests/sandbox/forkbomb.mc used to carry three expectation headers, one per (architecture, C library) pair, because the refusal named the system call NUMBER of the entry point the C library happened to use -- clone 220 on AArch64, 56 on x86-64 under glibc, fork 57 under musl. Counting instead of naming gives one line on every host: refused: process limit (0), which is the same line the compile step prints with 16.

The generated profile file says which. scripts/sandbox-trace.sh still measures the four calls into tools/sandbox/*.list -- the lists are what the trace SAW, and mc build really does fork -- but writes them into src/sandbox_profiles.mc as a comment, // SN_CLONE notified, never allowed (src/seccomp.mc), never as a row.

Measured after the fix, scripts/test-sandbox.sh in four cells plus two by hand:

cellresult
linux/aarch64 glibc, unprivileged (Lima)55 ok, 0 failed, 1 skipped; box 1833 us
linux/aarch64 glibc, root (Lima)55 ok, 0 failed, 1 skipped; box 1712 us
linux/x86_64 musl, root (the VPS)53 ok, 0 failed, 3 skipped; box 4002 us
linux/x86_64 musl, unprivileged (the VPS, a fresh user)53 ok, 0 failed, 3 skipped; box 4268 us
linux/x86_64 glibc, by hand (--libc=gnu)the four cases, each with its exact line
sh scripts/sandbox-trace.sh --checkgreen on both hosts, in both directions, and the generated file reproduces byte for byte

with the host's process count equal before and after every run.

Two new cases in tests/sandbox/, plus the project: nsclone.mc (a libc clone with CLONE_NEWUSER), nsclone3.mc (the same through raw clone3 -- syscall 435 on both architectures -- which is the only way to exercise the struct read), and linkbomb/ (mc.toml + app.mc + bomb.mc, whose binary the test script builds into build/ so that nothing binary is checked in). scripts/test-sandbox.sh gained part 2b for the project.

One defect the new case found in the script itself: lf (the --libc=gnu flag every --exe build in it needs) was assigned in part 4, so part 2b built its bomb for the wrong C library. The box then stopped at refused: syscall 95 (waitid) -- glibc's posix_spawn reaping a child that never exec'd -- and the case under test never happened. The assignment moved to the top, beside the libc detection.

2. LOW -- sb_num accumulated without a bound #

--mem 999999999999999999999999 wrapped. Measured pre-fix: the box ran with a negative cap and the first mapping was sandbox: refused: mmap 8192 bytes over the cap (-3541500564788477952); --mem 0 gave a compile step killed by SIGSEGV (exit 139) and --wall 0 a box killed before it started.

sb_num now stops at 10^12 and answers a distinct value for it (mc: --mem: number too large, exit 2), and each cap has a maximum and a minimum of one:

optionmaximumwhy
--time, --wall86400a day
--mem1048576 MiB1 TiB
--out65536 MiB64 GiB

Past it: mc: --mem: at most 1048576. Zero: mc: --time: must be at least 1. They are not the kernel's limits -- they are the largest values that still mean something here, so that a number past one of them is a typo or an attack and never a request. Measured on Lima, all nine cases, plus --mem 1048576 --wall 86400 --time 1 --out 1, which is accepted and runs.

3. INFO -- the scoped restrictions below Landlock ABI 6 were silently absent #

ABI 6 (kernel 6.12) added the scoped field -- abstract unix sockets and signals confined to the domain -- and the floor this sandbox accepts is 4. Between them the ruleset simply does not carry that word. mc sandbox check now says so and still exits 0:

landlock: abi 8 (no scoped signals below 6)

Both oracles report abi 8 and the GitHub runners abi 7, so the line was proved by building a compiler with the constant raised to 9 and running check on Lima, not by a host that shows it. docs/reference/sandbox.md § What is not isolated has it as its fourth bullet.




Layer 2 -- the web playground and the tour (post-1.0.0, priced only) #

What Layer 1 must already provide for Layer 2 to be cheap #

  1. mc sandbox run DIR for a project (so a lesson that teaches the compiler is one call);
  2. the report as a machine-readable file (--report FILE) with the fixed vocabulary of § 6, and the three exit codes;
  3. --stdin FILE, --time/--wall/--mem/--out;
  4. stdout and stderr as separate inherited fds (the service captures them to two files with posix_spawn_file_actions_addopen, the drv_sdk precedent);
  5. mc sandbox check for deployment and for the service's own startup refusal;
  6. the box-setup overhead number (acceptance 7).

None of these is Layer 2 work; all six are in Layer 1's acceptance list on purpose.

Architecture on the VPS #

browser --HTTPS--> Caddy (TLS, Let's Encrypt, static site/public, gzip)
                     |-- /api/run, /api/health --> mc-play (127.0.0.1:8080, N=4 prefork workers)
                                                     |-- posix_spawnp: mc sandbox run /tmp/play/<id>/ --report ...
                                                           (stdout/stderr captured to files, 64 KiB each)

The tour #

docs/tour/NN-*.md, rendered by mcsite like every other page, with a new fence form ```mc run that site/gen/hl.mc (+40) renders as a highlighted block with a Run button and an output pane; site/static/play.js (~200) posts the fence text to /api/run and prints stdout, stderr and the report lines. scripts/check-docs.sh (+20) compiles and runs a run fence like any other fence, so every lesson is a test. About 25 lessons, ~60 lines each (~1500 lines):

partlessons
the language (8)hello and write; types and ld*/st*; loop/break N/continue; functions and recursion (fib); globals, arrays, strings; &x, callp; #define and folding; #include <sys> and <prelude>
Tier 1 (5)#token/#infix/#prefix; #rule stmt:; #section; #opcode/emit(); reloc()
Tiers 2-4 (6)pass; backend; syntax_stmt/syntax_expr; type_alias and on_stmt; type_new/syntax_lit/<float>; intrinsic
the tools (6)--dump-tokens/--dump-ast; --dump-asm/--dump-syms; mc build and mc.toml; mc limits; cross-compiling ([target], --dump-syms of an ELF object -- no execution); --exe and mc sandbox itself

Sizing (Layer 2) #

piecelines
examples/playground/ (service, libs, mc.toml, test.sh, README)~1100 mc + ~200 sh/md
docs/tour/~1500 md
site/gen/hl.mc, site/gen/site.mc, site/templates/page.html, site/static/play.js, CSS~300
ops/~130
scripts/check-docs.sh, docs/ci.md, site.yml (deploy the static half; the service is deployed by ops/deploy.sh)~80

Roughly 3300 lines, two-thirds of them prose. Cost per run, from Layer 1's numbers: box setup ~5 ms + compile of a 50-line lesson ~10 ms (mc compiles ~80k lines/s on this class of machine) + the program's own time; a lesson round-trip is dominated by the network. Worst case is the caps: 2 s CPU, 5 s wall, 256 MiB, 64 MiB of overlay; with 4 workers the floor under abuse is 0.8 runs/s and the typical throughput is tens per second. The VPS costs what it costs today; Caddy is free.

Risks (Layer 2) #

  1. Abuse as compute: a 2-second CPU cap times 10 runs/min/IP bounds a miner to a fraction of one core per IP; the global queue bounds the sum to 4 cores; no network from the box means no reflection, no exfiltration, no C2. Residual: a botnet of IPs; mitigated by a daily global budget in the service (503 past it) and by Caddy's own connection limits.
  2. The compiler runs untrusted input. #embed "/etc/passwd", #include "../../..": the box has no /etc and Landlock refuses paths outside /src; p_push_source loops and #rule re-expansion: the CPU cap (the re-expansion is capped at 64 anyway); arena exhaustion: RLIMIT_AS makes mmap fail and the compiler prints its own arena exhausted and exits 1; a 64 KiB source cap is enforced by the service before anything runs. The compiler is under the compile profile, which is small because the compiler is small.
  3. The kernel is the residual (Layer 1 risk 7). Policy: the VPS runs nothing but Caddy and mc-play, holds no secret beyond the Let's Encrypt key, is snapshotted, and is rebuilt from ops/deploy.sh in minutes. gVisor is the optional outer layer if the owner wants a second wall (runsc needs root or KVM; the VPS's nested-virtualization status is unverified).
  4. Output floods are bounded by the two 64 KiB capture files and the tmpfs.
  5. Privacy: request logs keep a hash of the IP and nothing of the source.
  6. The site move: minicompiler.dev is GitHub Pages today (site.yml); moving it to the VPS adds an operational dependency (uptime, renewals) for a static site that needed none. Recommendation: keep Pages for the static site, point play.minicompiler.dev at the VPS, and have the tour pages call it cross-origin (CORS is one header in Caddy). The "move the site there" part of the owner's sentence is then a DNS decision, not a migration.

Client-side (wasm) against server-side, priced honestly #

A client-side playground means the COMPILER runs in the browser: mc itself compiled to wasm through M33's backend, hosted by a WASI shim, producing a .wasm for the user's program that a second instantiation runs.

server-side (above)client-side
prerequisiteLayer 1 (this spec)M33 (~1500 lines: backend, <sys/wasi>, <sys/browser>, JS glue), "last in the queue" today
additional work~3300 lines, mostly prosesrc/host_wasi.mc (~60: no spawn, so no mc build with [compiler] -- the driver's spawn is replaced by JS orchestration of two instantiations), site/static/mc.wasm (built in CI), a WASI shim for the compiler (~250 JS: fd_read/write, path_open over an in-memory tree, proc_exit), the program runtime glue (~150 JS), the same tour
lessons it can runall 25~18: every lesson whose OUTPUT is text or whose program is portable mc. NOT: #opcode/emit()/reloc() (AArch64 words), intrinsic and <float>'s machines, #dylib, --exe and mc sandbox -- anything that EXECUTES native code. The dumps still work: the arm64 and x86-64 machines run inside the wasm mc and --dump-asm prints the same text.
cost per run~15 ms of VPS CPU, bounded by capszero on the server; zero abuse surface; runs offline
isolationLayer 1the browser's
determinism proofthe same binaries as CIthe wasm mc must produce the same objects as the native one -- one more cross proof in the M37 style

Verdict: the server-side path first, because it runs everything the language can do and because the owner's sentence is about running in isolation; then, after M33 lands for its own reasons, move the ~18 portable lessons client-side (~600 lines on top of M33) and keep the server for the native seven. The playground's API does not change shape for that: the JS decides per fence whether to run locally or to post.

Decisions (architect) -- to ratify with the owner #

  1. (a) with (c)'s script: the sandbox is subcommand("sandbox", ...) in a new part <mc/core_sandbox>, written in mc against the kernel ABI; scripts/test-sandbox.sh only delegates from macOS. No external tool in the mechanism. Recommend adopt.
  2. Raw syscalls through one host-layer shim per architecture (host_syscall6), never a variadic libc wrapper; the x86-64 shim is six emit() words, verified with llvm-mc and asserted by check-parts. Recommend adopt.
  3. Three processes (P supervises, I is the box, C is the step and pid 1 of the pid namespace); P writes the uid/gid maps, so root and unprivileged share one code path. Recommend adopt.
  4. Seccomp USER_NOTIF is the explain channel; kernel limits are the walls. Every named diagnostic comes from a notification; every enforcement also exists without it (mounts, Landlock, netns, rlimits, the pid-namespace kill). Recommend adopt; it is what turns "killed" into a sentence.
  5. The profiles are generated from a trace and diffed in CI, never written by hand. Recommend adopt.
  6. Overlayfs for /src so that mc build projects work in the box with the host tree untouched; the ro-bind + /out fallback is priced in risk 3. Recommend adopt, with the fallback decided by acceptance 4's measurement on Lima and the VPS.
  7. macOS refuses and prints the Lima command; no sandbox-exec. Recommend adopt.
  8. Exit codes 124/125/126 (timeout(1)'s convention), distinct from M23's 3 and M25's 2. Recommend adopt.
  9. No cgroups, no --allow=net in Layer 1. Recommend adopt; both are one-line profile changes plus a delegation story when a consumer appears.
  10. Layer 2 is post-1.0.0, per the owner, and depends on nothing but Layer 1's acceptance list; the six "must provide" items are in that list now so that Layer 2 does not reopen Layer 1. Recommend adopt.
  11. Keep the static site on GitHub Pages; the playground at play.minicompiler.dev on the VPS; the service runs as an unprivileged user under an AppArmor userns grant, never as root. Recommend adopt (Layer 2 risk 6).
  12. Server-side first, wasm client-side after M33 for the portable lessons. Recommend adopt.
  13. No 1.0.0 on the back of this milestone: the owner's rule stands (roadmap + teko coordination).

Architect's additions: (a) acceptance 1 (the shim) is run before any other file exists, the M39/M42 probe discipline; (b) docs/reference/sandbox.md § "What is not isolated" is mandatory and names the kernel in its first sentence; (c) the sandbox CI job runs the unprivileged cell as well as the root cell, so the Ubuntu AppArmor fact can never be forgotten by a green build.

Edit this page