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 #
- The registry the subcommand goes into.
subcommand(name, fn, use)(src/hooks.mc:802,MAXSUBCMD 16), dispatched atsrc/cli.mc:163-166before any flag;fnisi64 f(i64 argc, uptr argv)and its result is the exit code.<mc/core_build>(src/core_build.mc) is the shape of a part that registers subcommands and owns its*_init();src/main.mcis the only file that names the parts. A<mc/core_sandbox>part is that shape again, andscripts/check-parts.shcase 1b already enforces that a part compiles on<mc/core_min>alone. - The host layer (M37,
src/host_linux.mc,src/host_linux_{aarch64,x86_64}.mc,src/host_macos.mc,src/host_windows.mc): "everything the COMPILER needs from the system it RUNS on is one file the entry point includes before the core". It answershost_os(),host_environ(),host_home(), and declaresposix_spawnp/waitpid/mkdir/unlink. Every question the sandbox asks the host ("can you issue syscall N?") belongs there. - Raw syscalls are already the project's own way.
lib/sys_linux.mcissuesopenat/close/read/write/fchmod/exit_groupas#opcode svc #0words with the number inx8(aarch64). On x86-64 the machine passes a rawemit()word through unchanged (src/machine_x86_64.mc:165, descriptor row 45,XF_SPEC), so a shim made of x86 instructions padded withnopto four-byte words is expressible today; only the three hand-written tests are// skip-x86_64:because their WORDS are AArch64. - Spawning, waiting and capturing stdout without a shell:
drv_spawn/drv_spawn_ok(src/driver.mc:220-246,128 + signalfor a killed child) and the file-actions capture indrv_sdk(:251-274). The playground's runner is that code. - Two Linux oracles exist and neither is the sandbox's kernel of record.
scripts/test-linux.shruns the suite indocker run --platform linux/arm64|amd64 alpine:3(Docker Desktop's LinuxKit kernel, 6.12);docs/build.md§ "A kernel-7 local oracle with Lima" adds a second Docker daemon inside a Lima VM running Ubuntu 26.04 (kernel7.0.0-30-genericmeasured). The owner's VPS is Ubuntu 26.04 x86_64, kernel 7.0.0-30, root over ssh, 4 CPU / 15 GiB. The baseline is the newest OS on a 7.x kernel; nothing below is targeted. - The attack surface is small and measurable. A program on
lib/sys_linux.mcissues 7 syscalls. A program built dynamically (M42) issues those plus whatld-musl-<arch>.so.1or glibc'sld.soneeds at load. The compiler itself, static musl (scripts/link-linux.sh), issues what musl's__libc_start_mainneeds plusopenat/read/write/close/fchmod/unlinkat/mmap/munmap/exit_group, and, formc build,posix_spawnp(clone/execve/wait4/pipe2/rt_sigprocmask). The exact lists are measured in this milestone (scripts/sandbox-trace.sh,strace -fc), not guessed; the hypotheses are in § 4. examples/api/lib/http.mcis macOS-only (sockaddr_inwithsin_lenat byte 0,SOL_SOCKET 0xFFFF,SO_REUSEADDR 4); the Linux layout (sa_familyu16 at 0,SOL_SOCKET 1,SO_REUSEADDR 2) is a ~30-line sibling. That is a Layer 2 cost, listed there.- M33's wasm backend is specified, not built (
docs/specs/M33.md:src/backend_wasm.mc~1100 lines,lib/sys_wasi.mc~190,lib/sys_browser.mc~110, ~90 lines of JS). Layer 2's client-side pricing stands on those numbers. - Ubuntu 26.04 fact that shapes the host story: since Ubuntu 23.10,
kernel.apparmor_restrict_unprivileged_userns=1makesunshare(CLONE_NEWUSER)fail withEPERMfor an unconfined unprivileged process unless the executable has an AppArmor profile grantinguserns. Root (CAP_SYS_ADMIN) is not affected. Docker's default seccomp profile putsunshare,mount,pivot_root,sethostnamebehindCAP_SYS_ADMIN, and its default AppArmor profile deniesmount; inside a container the sandbox needs--privileged.
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.
| need | primitive | syscall | since | unprivileged |
|---|---|---|---|---|
| the host filesystem does not exist | mount namespace, tmpfs root, bind mounts, pivot_root | unshare 97/272 (CLONE_NEWNS 0x20000), mount 40/165, pivot_root 41/155, umount2 39/166 | user ns 3.8; unprivileged tmpfs/bind inside a user ns 3.8; unprivileged overlayfs 5.11 | yes, inside a user namespace (Ubuntu AppArmor caveat above) |
| a second wall on the filesystem, independent of the mounts | Landlock | landlock_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.12 | yes, after prctl(PR_SET_NO_NEW_PRIVS) (prctl 167/157, option 38, 3.5) |
| no network | network namespace (an empty ns with a down loopback) | unshare with CLONE_NEWNET 0x40000000 | 2.6.24 (unprivileged via user ns 3.8) | yes |
| no other processes to see or signal | pid + ipc + uts namespaces | CLONE_NEWPID 0x20000000, CLONE_NEWIPC 0x8000000, CLONE_NEWUTS 0x4000000 | 3.8 | yes |
| a syscall allowlist | seccomp-BPF | seccomp 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 kill | SECCOMP_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 proceed | ioctl 29/16 | 5.0; CONTINUE 5.5 | yes |
| the supervisor reaches the listener fd | pidfd_open 434, pidfd_getfd 438 | 5.3 / 5.6 | yes (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 it | process_vm_readv 270/310 | 3.2 | yes, same rule | |
| CPU cap | RLIMIT_CPU (0): SIGXCPU at the soft limit, SIGKILL at the hard | prlimit64 261/302 (setrlimit 164/160) | 2.6.36 | yes |
| wall-clock cap | supervisor ppoll 73/271 with a timespec, then kill 129/62 of the pid-ns init (which tears the whole namespace down) | yes | ||
| memory cap | RLIMIT_AS (9), hard; mmap 222/9 under USER_NOTIF for the diagnostic | yes. A cgroup memory.max (OOM accounting) needs cgroup delegation (systemd-run --user); NOT used in Layer 1 | ||
| process cap | clone 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.14 | yes | |
| fd cap | RLIMIT_NOFILE (7) + close_range 436 (CLOSE_RANGE_UNSHARE 2) before exec | 5.9 | yes | |
| output cap | tmpfs size= on /src (overlay upper) + RLIMIT_FSIZE (1) | yes | ||
| the program's writable view of its own directory without copying it | overlayfs (lowerdir=DIR, upper and work on the box tmpfs) | mount("overlay", ...) | unprivileged in a user ns 5.11 | yes |
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.
| tool | mechanism | what it adds over the primitives | what it costs |
|---|---|---|---|
bubblewrap (bwrap) | user ns + mount ns + seccomp (a BPF blob passed on an fd) + pivot_root; ~3k lines of C | maturity of the mount sequence; nothing structural | an external binary (apt install bubblewrap); no CPU/wall/memory caps, no report, no Landlock; the same Ubuntu AppArmor rule applies to it |
| nsjail | namespaces + seccomp via the kafel policy language + rlimits + cgroups + time limits | cgroups (memory OOM, pids) when delegation exists; a policy language | external binary (C++, protobuf, kafel); same kernel surface as ours |
| Docker / runc | the same primitives behind a daemon and an image format | packaging, a curated default seccomp profile | a 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 KVM | the 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 |
| Firecracker | a microVM with its own guest kernel | hardware 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.
src/sysno_linux_aarch64.mc:sys6in thelib/sys_linux.mcstyle --mov x8, x0; mov x0, x1; ... mov x5, x6; svc #0(eight#opcodewords, the existingmovx/svc0templates) -- plus the number table.src/sysno_linux_x86_64.mc:sys6as sixemit()words holdingmov rax,rdi; mov rdi,rsi; mov rsi,rdx; mov rdx,rcx; mov r10,r8; mov r8,r9; mov r9,[rbp+16]; syscall(24 bytes exactly: the seventh argument is at[rbp+16]per M17 step B, the prologue leaves the argument registers intact perdocs/reference/objects.md§ 4, and the epilogue leavesrax). The bytes are checked againstllvm-mc -triple=x86_64-linux-musl --show-encodingbefore the file is written, the M17 sweep discipline, andcheck-surfaceasserts the six words in--dump-asm --machine=x86_64.- The table is an mc-side enum (
SN_UNSHARE,SN_MOUNT, ...) indexing au16[]per architecture, sosrc/sandbox*.mcnames no number and compiles unchanged for both.src/host_macos.mcandsrc/host_windows.mcanswer-38(ENOSYS) and an empty table (+8 lines each): the part compiles on every host, andhost_os()is what refuses.
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 box | source | how | mount flags |
|---|---|---|---|
/ | tmpfs size=<--out>m,mode=755 | mount("tmpfs", box, "tmpfs", ...) | MS_NOSUID|MS_NODEV |
/mc | readlink("/proc/self/exe") taken by P before any unshare | bind of a file onto an empty file, then remount MS_BIND|MS_REMOUNT|MS_RDONLY | ro, exec |
/src | the source's directory (or the project directory) | overlayfs: lowerdir=DIR, upperdir=/upper, workdir=/work on the box tmpfs | rw, exec, capped by the tmpfs size= |
/lib, /lib64, /usr/lib | the host's, when they exist | bind + remount ro | ro, 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:
- compile profile (
/mcand any taught compiler it spawns):execve brk mmap munmap mprotect set_tid_address rt_sigprocmask openat read write close fchmod unlinkat readlinkat newfstatat lseek exit_group clone clone3 wait4 pipe2 dup3 getpid-- the last six only becausemc buildspawns the compiler it just taught. - program profile (default, what a
lib/sys_linux.mcor M42 program gets):execve read write openat close fchmod exit_group exit mmap munmap brk mprotect set_tid_address--execveonce, for the step's own binary, which is what the refusal table below counts, plus the dynamic loader'snewfstatat pread64 readlinkatwhen the binary has aPT_INTERP; glibc'sld.soadditionally needsaccess prlimit64 getrandom rseq set_robust_list arch_prctl(x86_64) -- a--libc=gnuvariant of the same table. --allow=threadsaddsfutex set_robust_list rseq sched_yield madvise clock_nanosleep nanosleep tgkill membarrierand the flag-checkedclone.
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 P | P does |
|---|---|
| anything not below | prints refused: syscall N (name), answers -EPERM, kills C -- stop |
openat/open | reads 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=threads | counts; 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). |
execve | counts; the compile step allows 3 (mc, a taught compiler, its --entry-only child), the run step 1; beyond: refused: execve. |
socket, connect, bind | never 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.
| PATH | compile 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 #
- Linux, unprivileged (the CI runners as
runner, Lima's default user, the VPS as a normal user): needskernel.apparmor_restrict_unprivileged_userns=0or an AppArmor profile for themcbinary withuserns,(10 lines, shipped asops/apparmor-mcin Layer 2 and documented in Layer 1).mc sandbox checksays which. - Linux, root (the VPS over ssh,
sudoon the runners,docker run --privileged): the same code path; the box's uid 0 maps to host 65534. Root is not restricted by the AppArmor sysctl. - Docker: only with
--privileged;scripts/test-sandbox.shuses it for the macOS developer path against Docker Desktop (kernel 6.12) and the Lima daemon (kernel 7.0,DOCKER_CONTEXT=mc-k7), runningbuild/mc-linux-<arch>from the bind-mounted checkout. The Lima VM shell (limactl shell mc-k7, no Docker) is the unprivileged-path oracle on macOS. - macOS:
mc sandbox runrefuses with the M25 shape -- the exact command to run instead:mc: the sandbox is a Linux feature; on this Mac: limactl shell mc-k7 build/mc-linux-arm64 sandbox run PATH (docs/build.md § Lima). What is honestly possible on macOS and NOT done:sandbox-exec(1)/sandbox_init(3)still exist in macOS 26 but have been documented as deprecated since 10.8, have no CPU/wall/memory model beyondsetrlimit, and cannot name a refusal;setrlimitalone gives caps without isolation. Neither is worth a line that the Mac would have to keep true. - Windows: refuses (
the sandbox is a Linux feature). Job objects and AppContainers are a different milestone with no consumer.
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) #
- cgroups (memory OOM accounting,
pids.max, CPU weight): they need delegation the host may not grant;RLIMIT_*plus the notif accounting cover the caps, and the docs say what a cgroup would add. --allow=net: no consumer (examples/apiis macOS-only and a server); Landlock ABI 4 and the empty netns make it a one-line profile change later.- PTY allocation,
/dev,/procinside the box, signals INTO the program, interactive stdin. - A sandbox on macOS or Windows,
sandbox-exec, job objects. - gVisor or Firecracker integration (named in § 0 and in Layer 2 Risks; not code).
- Copying outputs back out of the box (
--keep): the playground needs only stdout/stderr/exit. - Any change to
stage0/.
Files and estimated deltas (Layer 1) #
| file | lines | what |
|---|---|---|
src/sandbox.mc | ~380 | new: 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 | ~260 | new: I -- unshare, waiting for the maps, the mount tree, overlay, pivot_root, hostname, rlimits, the step loop |
src/seccomp.mc | ~230 | new: 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 | ~120 | new, 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 | ~25 | new: 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 each | new: 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 | +6 | host_syscall6 = sys6; host_sandbox_supported() = 1 |
src/host_macos.mc, src/host_windows.mc | +8 each | host_syscall6 answering -ENOSYS, empty table, host_sandbox_supported() = 0 |
src/core.mc | +1 | the sixth part |
src/main.mc | +1 | mc_sandbox_init() |
tools/bundle.list | +7 | mc/core_sandbox, mc/sandbox, mc/sandbox_box, mc/seccomp, mc/sandbox_profiles, the two sysno files |
src/bundle_data.mc | regenerated | the five goldens move once |
tests/sandbox/{shadow,connect,forkbomb,eightgib,forever,clean}.mc + .expect | ~25 each | the six isolation cases |
scripts/test-sandbox.sh | ~200 | the suite under the sandbox, the six cases, Lima/Docker delegation from macOS |
scripts/sandbox-trace.sh | ~90 | strace -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 | +10 | the aarch64 shim's eight words |
Makefile | +15 | test-sandbox, sandbox-trace, inside check: |
.github/workflows/ci.yml | +70 | the sandbox job, four cells |
docs/reference/sandbox.md | ~260 | new: the box, the profiles, the report grammar, exit codes, the host table, what is NOT isolated (the kernel) |
docs/guide/99-sandbox.md | ~120 | new: "run something you do not trust" |
docs/reference/cli.md, docs/build.md, docs/ci.md, docs/reference/hooks.md, docs/reference/bundle.md | +110 | the subcommand, the hosts, the part |
stage0/, lib/, tests/*.mc | 0 | untouched |
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 #
- The shim is right before anything uses it. On both Linux hosts,
host_syscall6(SN_GETPID, ...)equalsgetpid()from musl, andhost_syscall6(SN_OPENAT, ...)of a missing file answers-2(-ENOENT). The x86-64 words re-assemble underllvm-mcbyte for byte;check-surface/check-partsassert both shims in--dump-asm. (The M39 discipline: the one thing that can sink the milestone is proved first.) -
Isolation is proved by six programs, each with a named diagnostic and the exact exit code, run by
scripts/test-sandbox.shon both architectures, as root and unprivileged:shadow.mcopens/etc/shadow:sandbox: refused: open /etc/shadow, exit 125; a control run OUTSIDE the sandbox as root reads it.connect.mccallssocketthenconnectto 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-lessbindattempt in the same test's second half.forkbomb.mccallsclone(SIGCHLD)in a loop:sandbox: refused: syscall 220 (clone), exit 125, and the host's process count before and after is equal; with--allow=threadsthe same source stops atrefused: process limit (64).eightgib.mcmaps 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.mcloops:sandbox: killed: cpu limit (2 s), exit 124;sleeper.mc(blockingreadon stdin with--stdinunset... which is EOF, so it usesclock_nanosleepunder--allow=threads):sandbox: killed: wall clock (5 s), exit 124; measured wall time within 5.0-5.5 s.clean.mc--tests/013-putnum.mcverbatim -- prints46368and exits 0 withsandbox: exit 0, and its stdout is byte-identical to the unsandboxed run.
- The whole suite runs under the sandbox with identical results:
scripts/test-sandbox.sh33/33 on linux/aarch64 and 30/30 on linux/x86_64 (thetest-linux.shcounts), throughmc sandbox run(compile inside) AND throughmc sandbox execon the M42--exebinaries, including at least one dynamic one that reacheslibc.sothroughPT_INTERP-- with/libbind-mounted and nothing else of the host. - A project builds inside the box.
mc sandbox run examples/langteaches the compiler and runsmain.lxwith the same output asexamples/lang/test.sh; the host'sexamples/lang/build/is untouched (git statusclean, mtimes unchanged); the compile step's execve count is 3. - The profiles are measured, not written.
scripts/sandbox-trace.sh --checkfinds no syscall in the trace that is missing fromsrc/sandbox_profiles.mc, and no profile entry the trace never used, on both architectures. A deliberate extra entry fails it. - 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 -newerempty);mcwith no arguments prints the previous usage text plus exactly onesandboxline. - Overhead is measured and printed:
mc sandbox exec /src/true(areturn 0program) 200 times vs plain, median box setup cost printed by the script and recorded indocs/reference/sandbox.md(hypothesis: 3-8 ms; the number the playground's capacity math uses). - The refusals are honest. On macOS
mc sandbox run x.mcprints the Lima command and exits 126; on a Linux host with the AppArmor restriction on,mc sandbox checksaysuserns: restricted (apparmor)andrunsayscannot unshare: EPERM (...), exit 126; insidedocker runWITHOUT--privileged, the same shape. - Inertness and the parts.
check-parts:<mc/core_min>+<mc/core_sandbox>compiles alone; a compiler that omits the part prints nosandboxusage line andmc sandboxthere isunknown option-class refusal as today.check-obj32/32 against the frozen seed;scripts/check-inert.shclean; the five goldens rewritten once, after the empty--dump-asmdiff andcmp build/mc2.o build/mc3.o. - CI: the four-cell
sandboxjob green and made a required check;make checkon macOS reportstest-sandboxeither green (Lima/Docker present) orSKIPPED (mc sandbox check: ...)with the reason. - Docs:
make check-docsgreen with every new symbol and flag documented;docs/reference/sandbox.mdhas a section titled "What is not isolated" that names the kernel. git diff --stat stage0/ lib/ tests/*.mcis empty.
Risks (Layer 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 checknames 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. SECCOMP_USER_NOTIF_FLAG_CONTINUEonopenatis 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.- Overlayfs over virtiofs (Lima's mount) or over unusual lower filesystems may refuse (
EINVAL). Fallback priced, not built: a ro bind of/srcplus a separate tmpfs/outand-o /out/prog;mc buildprojects would then need the copy. Measured on Lima and on the VPS in acceptance 4 before the fallback is decided. - 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.
RLIMIT_NPROCsemantics across kernels: per-user-namespace accounting is the 5.14 ucounts behaviour; the baseline is 7.x, and the default profile refusescloneoutright so the rlimit is the second wall, not the first.- Killing pid 1 of the pid namespace from outside is the wall-clock mechanism; it is documented kernel behaviour (
SIGKILLfrom the parent namespace is always delivered to init). Proved byforever.mc/sleeper.mcand by the process-count check inforkbomb.mc. - 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.
- 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).
- One shared enum file. The spec's file list has the two
sysno_linux_*files and nothing between them, but theSN_*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 aresrc/sysno.mcand all four host files include it. Three bundle entries, not two:mc/sysno,mc/sysno_linux_aarch64,mc/sysno_linux_x86_64.
host_sysno(sn)joined the host interface, besidehost_syscall6andhost_sandbox_supported. The number table is per architecture, so the lookup is too; a rawld16(sysno_tab + sn * 2)insrc/sandbox.mcwould also be the raw-offset accessCLAUDE.mdforbids. It answers-1for a call this architecture does not have (accessandarch_prctlon AArch64,SN_ABSENTin the table).
- 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*.mceither. Both sets are in the enum now, sosrc/sandbox_profiles.mccan be generated inSN_*terms.
-
unshare(CLONE_NEWUSER)does not fail under the AppArmor restriction. § Risks 1 says it does. Measured on Ubuntu 26.04 / kernel7.0.0-30-generic, unprivileged and unconfined, withkernel.apparmor_restrict_unprivileged_userns = 1, on both architectures (a Lima VM on aarch64 and the x86_64 VPS): theunsharesucceeds, and so does the combinedunshare(NEWUSER|NEWNS|NEWPID|NEWNET|NEWIPC|NEWUTS). Since Ubuntu 24.04 the kernel does not refuse the namespace; AppArmor transitions the process into theunprivileged_usernsprofile (/etc/apparmor.d/unprivileged_userns), which isaudit deny capabilityand carries nomountrule at all. The audit record isapparmor="AUDIT" operation="userns_create" info="Userns create - transitioning profile" profile="unconfined" target="unprivileged_userns", followed byapparmor="DENIED" operation="capable" profile="unprivileged_userns" capability=21 capname="sys_admin".What actually fails is the box's first mount:
call (unprivileged, sysctl = 1) aarch64 x86_64 unshare(NEWUSER|NEWNS|NEWPID|NEWNET|NEWIPC|NEWUTS)0 0 mount(0, "/", 0, MS_PRIVATE|MS_REC, 0)-13 EACCES-13 EACCESmount("tmpfs", "/tmp", "tmpfs", 0, "size=1m")-13 EACCES-13 EACCESsethostname("sandbox", 7)-1 EPERM-1 EPERMAs root all four return 0 on both.
So
mc sandbox check'susernsprobe 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 printokon a host where the box cannot be built. Consequences for step B: the supervisor's first diagnostic will be anEACCESfrommount, not anEPERMfromunshare, and § 6'scannot unshare: EPERM (...)line needs a sibling for the mount. The AppArmor way out is unchanged (the sysctl, or a profile grantinguserns,andmount,).
overlaydistinguishesnot loadedfromabsent./proc/filesystemslists REGISTERED filesystems. On the Lima oracle Docker had loaded the module and the line readsok; 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.zstis present. Root would autoload it on the firstmount -t overlay(request_moduleneedsCAP_SYS_ADMINin the init user namespace, which the box will not have), so the honest line isnot loaded (modprobe overlay)and the check fails with the fix printed. § 6's fixed vocabulary grows by that one value, andusernsgrows byEACCES.
checkon macOS and Windows exits 126, not 1. § 6 givescheckthe 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 testingif mc sandbox checkis unaffected.
- The probe of acceptance 1 is generated, not checked in.
tests/sandbox/is named in the file list with six specific programs andtests/*.mcis untouchable, soscripts/check-shim.shwrites its probe intobuild/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-argumentmmapat a non-zero offset, which is the only way to prove that the SEVENTH parameter ofsys6--x6on AArch64,[rbp+16]on x86-64 -- reaches the kernel.
- The seed's
MAXGLOBALSis the row to watch.mc limits src/mc.mcgoes from 420/512 (82%) to 437/512 (85%) with step A in.check-limitsfails 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.
- A root map of
0 65534 1makes 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 firstmkdiratin its own tmpfs answers EOVERFLOW (75). Adding a second line (1 0 1) and asetuid(0)fixed that and failed the next test (note 2). The map is now0 0 65536for root and0 <uid> 1for everyone else, and there is nosetuidat all: both maps put the caller at the inner uid 0.
- An overlay copy-up needs permission on the LOWER directory. With root mapped to inner 1,
mc --exeinside the box answeredcannot create: /src/tests/061-passon 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_OVERRIDEdoes 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.
RLIMIT_NPROCdoes not bind for root, becausecopy_processskips the check outright forINIT_USERand 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 plainforkand for theclone3(CLONE_VM|CLONE_VFORK)behindposix_spawnalike, andforkbomb.mcprintsforked 0. As root it printsforked 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 ofclonefor root.tests/sandbox/forkbomb.mccarries both expectations and the script picks byid -u.
- 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
clonewith 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.
- 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 thecompile:prefix, so J announces each step with aB <step>line before forking it.
- 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 theSline. 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.
RLIMIT_ASandRLIMIT_NPROCcannot be set by I.RLIMIT_ASapplies to the process that sets it and I is a fork ofmccarrying its own arena;RLIMIT_NPROCis checked byforkand I still has to fork the steps. C sets those two plusRLIMIT_STACKimmediately beforeexecve; I setsRLIMIT_CPU,RLIMIT_FSIZE,RLIMIT_COREandRLIMIT_NOFILE, which are inherited and whose failure I can still report. And the compile step getsRLIMIT_NPROC16, becausemc buildwrites a compiler and then runs it (§ 5).
- Two options the corpus forced, both beyond § 6.
--root DIRmakes/srca tree the source is inside: half oftests/*.mcincludes../lib/sys.mc, and with/srcat the source's own directory that resolves to/lib/sys.mcand fails.--config NAMEnames the project file forrun DIRand keeps[project].outrelative to that file's directory, which is what letsexamples/lang-- whosemc.tomltargets macOS and which includes../../lib/prelude.mc-- be built inside the box asmc sandbox run --config examples/lang/mc.linux-gnu.toml .. Two new files in that example,mc.linux.tomlandmc.linux-gnu.toml, are its Linux configs: no[target]section at all, so the pair is the host's, and the second addslibc = "gnu"because the executable writer's default loader is musl's and every host measured here is glibc.
--report FILEwrites 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.
- 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 settrusted.overlay.*. The priced fallback (a read-only/srcplus 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):
| cell | isolation | suite | project | overhead |
|---|---|---|---|---|
| linux/aarch64 root (Lima) | 8/8 | 31/31, 1 skipped | ok | 1.37 ms |
| linux/aarch64 unprivileged | 8/8 | 31/31, 1 skipped | ok | 1.43 ms |
| linux/x86_64 root (VPS) | 8/8 | 29/29, 3 skipped | ok | 4.12 ms |
| linux/x86_64 unprivileged | 8/8 | 29/29, 3 skipped | ok | 4.98 ms |
| linux/aarch64 alpine:3, docker --privileged, kernel 6.12.76 | 8/8 | 31/31 | ok | -- |
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.
- 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 ofseccomp_notifthat 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 firstpidfd_getfd, and P does the second one on J, whose pid it already knows. Both hops needPTRACE_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 Yamaptrace_scope= 1 allows. That is the value on both hosts, measured, and no relaxation was needed.
- 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_rangesweeps keep it exactly as they kept the per-step pipe before.
- J may not speak before I has announced its pid. J's
L <fd>line and I'sP <pid>line go down the same status pipe, and P cannot act on the first without the second. I now forks J, writesP <pid>, and only then lets J start, through a one-byte pipe of its own.
- A Landlock rule on a FILE may not carry a directory right.
/mcis a bind of the compiler binary, and granting itEXECUTE|READ_FILE|READ_DIRisEINVALfromlandlock_add_rule-- which is how the very first run failed, withcannot install the Landlock ruleset: EINVALand nothing to say which rule.
/etc/ld.so.cachehad 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), andrefused: 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 neededLANDLOCK_ACCESS_FS_READ_FILEas well, and the AArch64 box hid that for a while because its loader finds libc in the first default directory it tries.
- 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 toUSER_NOTIF, where P answersCONTINUE. Without that,tests/sandbox/shadow.mcopened/etc/shadowwith nobody watching and the milestone's flagship line never appeared.
- Two calls are in every profile whatever the trace said: the
readthat waits for the sync byte and thecloseof 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 withreadvand no program in the corpus ever issued a bareread-- glibc's did, so the AArch64 profile hid it and the x86-64 one did not.
- The glibc delta is per STEP, not one list.
readis in musl's compile trace and not in its program trace;clone3is in the compile trace only. A single delta over both kinds would have failed in both directions at once -- noreadfor a glibc program, and a way to fork for a glibc program that the profile exists to refuse.
- A profile measured from a corpus that never allocates is a profile that refuses one run in
twelve. Every
tests/*.mcwrites with a rawwrite; none of them mallocs.madviseappeared in 5 of 60 runs of a program that merely callsfopen-- glibc advisesMADV_HUGEPAGEwhen 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_sigprocmaskcame from the same gap: glibc blocks signals aroundfork, so without it a fork bomb was refused at its signal mask rather than at itsclone.
strace -ccannot be used to measure a profile. Its summary omitsexit_group, which never returns and is therefore never counted -- and a profile withoutexit_grouprefuses every program at its last instruction. Measured:-creported 15 calls fortests/013-putnum, the raw trace 16.
- musl on x86-64 forks with
fork, notclone. Syscall 57, which the generic table does not have at all, so on AArch64 every fork is aclone.SN_FORK/SN_VFORKjoined the enum, the supervisor counts all four process-creating calls against the same cap, andtests/sandbox/forkbomb.mcgrew 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.
- A refused call is not answered. § 4 has P answer
-EPERM/-EACCESand then kill; measured, that wakes the step inside its system call and it runs for as long as theSIGKILLtakes to travel --shadow.mcprintedshadow errno=13andconnect.mcprintedsocket refusedon 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.expectfile possible. The errno column of § 4 is what the program WOULD be told by a box that continued; this one stops.
- The process cap needs the rlimit to be looser than the counter. With
RLIMIT_NPROCand P's counter both at 64, the kernel'sEAGAINarrives first -- the box already holds I, J and C against the same user -- and the program sees a failed fork instead ofrefused: process limit (64). The rlimit backstop is 128 with--allow=threads; the named wall is the counter.
eightgib.mchad to ask the kernel directly. Throughmalloc(8 GiB)glibc adds a page for the chunk header and the report readsmmap 8589938688 bytes-- true, and impossible to write down in a.expect. The test now callsmmapitself, 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):
| cell | isolation | suite | exec | project | box cost |
|---|---|---|---|---|---|
| linux/aarch64 glibc, root (Lima) | 10/10 | 31/31 | 2/2 | ok | 1.74 ms |
| linux/aarch64 glibc, unprivileged | 10/10 | 31/31 | 2/2 | ok | 1.70 ms |
| linux/x86_64 musl, root (VPS) | 10/10 | 29/29 | 2/2 | ok | 3.0-4.5 ms |
| linux/x86_64 musl, unprivileged | 10/10 | 29/29 | 2/2 | ok | 3.7 ms |
linux/aarch64 musl (alpine:3, --privileged) | 10/10 | 31/31 | 2/2 | ok | -- |
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.
- 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
madviseandgetrandomat start-up andclone3to spawn, 2.39 uses neither and needsrt_sigactionandclone-- both MISSING from the table, which is a legitimate program refused at run time, not a bookkeeping difference. Measured inubuntu:24.04on this Mac (glibc 2.39-0ubuntu8.7, aarch64) and folded in with the new--union;--checknow fails only on the direction that matters (a call the table lacks) and reports the other as anote;--strictkeeps 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. lex_readablebelieved a file that was not there.openreturns a Cint; M42 recorded the hazard and M45 narrowed every call site withc_int()except this one. On the runners a failingopenhands back0x00000000ffffffff, sofd < 0was 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 wasmc buildon a tree with nobuild/yet:lim_read_usagewas told the usage file existed andread_file(which does narrow) died withmc: 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].pathssearch. One line insrc/lex.mc, objects unchanged (scripts/check-inert.shagainstorigin/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):
| cell | suite | box cost |
|---|---|---|
| linux/arm64 runner, unprivileged | 52 ok, 0 failed, 1 skipped | 2210 us (plain 152) |
| linux/arm64 runner, root | 52 ok, 0 failed, 1 skipped | 2111 us (plain 142) |
| linux/x86_64 runner, unprivileged | 50 ok, 0 failed, 3 skipped | 2167 us (plain 109) |
| linux/x86_64 runner, root | 50 ok, 0 failed, 3 skipped | 2109 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:
- does it ask for a namespace? Any
CLONE_NEW*bit ->refused: clone with namespace flags, whatever the step and whatever the limit. Forclonethe flags areargs[0]; forclone3they are the firstu64of thestruct clone_argsthe caller owns, read out of the step withprocess_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 isrefused: 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. - how many has this step made? A per-step counter: 16 for the compile step (what
mc buildneeds is two or three), 0 for a run step, 64 with--allow=threads(unchanged). One past it isrefused: 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:
| cell | result |
|---|---|
| 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 --check | green 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:
| option | maximum | why |
|---|---|---|
--time, --wall | 86400 | a day |
--mem | 1048576 MiB | 1 TiB |
--out | 65536 MiB | 64 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 #
mc sandbox run DIRfor a project (so a lesson that teaches the compiler is one call);- the report as a machine-readable file (
--report FILE) with the fixed vocabulary of § 6, and the three exit codes; --stdin FILE,--time/--wall/--mem/--out;- stdout and stderr as separate inherited fds (the service captures them to two files with
posix_spawn_file_actions_addopen, thedrv_sdkprecedent); mc sandbox checkfor deployment and for the service's own startup refusal;- 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)
mc-playisexamples/playground/, written in mc, theexamples/apiprecedent ported to Linux:main.mc(~400: routes, request parsing, response, the prefork -- four workers sharing oneSO_REUSEPORTlistening socket, each one connection at a time, which is the capacity model),lib/http_linux.mc(~120: the Linuxsockaddr_in/constants,SO_REUSEPORT 15),lib/runner.mc(~150: write the source into/tmp/play/<id>/prog.mc-- or a project tree for the teach-the-compiler lessons -- spawnmc sandbox run, read the two capture files and the report, delete the directory),lib/ratelimit.mc(~80: a flat table of (ip, tokens, last-refill), 10 runs/min/IP, burst 3; a global queue of 16, then503),lib/json_out.mc(~60: escaping only -- the request is the raw source in the body with the compiler choice in a header, so no JSON parser is written). Responses:{ "stdout": ..., "stderr": ..., "report": [...], "exit": N }.- The compiler menu is FIXED: stock
mc, and pre-built taught compilers from the repository (<float>,lib/mc_syntax_demo.mc,examples/lang,examples/conc) bind-mounted read-only into the box as/mc-<name>. A lesson that teaches the compiler builds ITS OWN taught compiler inside the box (three execs,--wall 10). - Ops:
ops/Caddyfile(~15),ops/mc-play.service(~20,User=play,NoNewPrivilegesis NOT set on the unit -- the sandbox needs to install its own filters -- butProtectSystem=strictwith/tmp/playread-write),ops/apparmor-mc(~15, theuserns,grant),ops/deploy.sh(~80: buildmcand the taught compilers from a tag,scp, restart). The service runs as the unprivilegedplayuser: the userns path, never root. - TLS is Caddy's. Writing TLS in mc is out of scope for good.
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):
| part | lessons |
|---|---|
| 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) #
| piece | lines |
|---|---|
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) #
- 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 (
503past it) and by Caddy's own connection limits. - The compiler runs untrusted input.
#embed "/etc/passwd",#include "../../..": the box has no/etcand Landlock refuses paths outside/src;p_push_sourceloops and#rulere-expansion: the CPU cap (the re-expansion is capped at 64 anyway); arena exhaustion:RLIMIT_ASmakesmmapfail and the compiler prints its ownarena exhaustedand 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. - 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 fromops/deploy.shin minutes. gVisor is the optional outer layer if the owner wants a second wall (runscneeds root or KVM; the VPS's nested-virtualization status is unverified). - Output floods are bounded by the two 64 KiB capture files and the tmpfs.
- Privacy: request logs keep a hash of the IP and nothing of the source.
- The site move:
minicompiler.devis 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, pointplay.minicompiler.devat 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 | |
|---|---|---|
| prerequisite | Layer 1 (this spec) | M33 (~1500 lines: backend, <sys/wasi>, <sys/browser>, JS glue), "last in the queue" today |
| additional work | ~3300 lines, mostly prose | src/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 run | all 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 caps | zero on the server; zero abuse surface; runs offline |
| isolation | Layer 1 | the browser's |
| determinism proof | the same binaries as CI | the 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 #
- (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.shonly delegates from macOS. No external tool in the mechanism. Recommend adopt. - Raw syscalls through one host-layer shim per architecture (
host_syscall6), never a variadic libc wrapper; the x86-64 shim is sixemit()words, verified withllvm-mcand asserted bycheck-parts. Recommend adopt. - 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.
- 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.
- The profiles are generated from a trace and diffed in CI, never written by hand. Recommend adopt.
- Overlayfs for
/srcso thatmc buildprojects work in the box with the host tree untouched; the ro-bind +/outfallback is priced in risk 3. Recommend adopt, with the fallback decided by acceptance 4's measurement on Lima and the VPS. - macOS refuses and prints the Lima command; no
sandbox-exec. Recommend adopt. - Exit codes 124/125/126 (timeout(1)'s convention), distinct from M23's 3 and M25's 2. Recommend adopt.
- No cgroups, no
--allow=netin Layer 1. Recommend adopt; both are one-line profile changes plus a delegation story when a consumer appears. - 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.
- Keep the static site on GitHub Pages; the playground at
play.minicompiler.devon the VPS; the service runs as an unprivileged user under an AppArmorusernsgrant, never as root. Recommend adopt (Layer 2 risk 6). - Server-side first, wasm client-side after M33 for the portable lessons. Recommend adopt.
- No 1.0.0 on the back of this milestone: the owner's rule stands (roadmap +
tekocoordination).
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.