M43: the sandbox -- compile and run an arbitrary mc program in isolation - #23
Conversation
The probe discipline first: nothing of the sandbox exists until the one thing that can sink it -- a hand-encoded system-call shim per architecture -- is proved on both Linux hosts. * src/sysno.mc: 60 SN_* names and SN_ABSENT. src/sandbox*.mc names no syscall number; the tables do, one per architecture. * src/sysno_linux_aarch64.mc: sys6 as eight #opcode words -- mov x8,x0 then mov x0..x5,x1..x6 in ASCENDING order (x8 must read x0 before x0 is overwritten), then svc #0 -- plus the asm-generic/unistd.h numbers. * src/sysno_linux_x86_64.mc: sys6 as six emit() words, 24 bytes of x86 cut into four-byte pieces with instructions straddling the boundaries. The seventh parameter is at [rbp+16] (M17 step B, docs/reference/objects.md § 4c) and becomes the sixth kernel argument in r9. Bytes from llvm-mc. * src/host_linux.mc: host_syscall6 = sys6, host_sandbox_supported() = 1; src/host_macos.mc and src/host_windows.mc answer -ENOSYS, an all-absent table of the same length, and 0 -- so <mc/core_sandbox> compiles on every host and what refuses is host_os(), not a missing symbol. * src/sandbox.mc + src/core_sandbox.mc: the sixth part. Step A carries the option parser, `mc sandbox check` and the refusals; run/exec say "not in this step" and exit 126. Two things the kernel corrected in the spec, both measured on Ubuntu 26.04 / kernel 7.0.0-30 (aarch64 and x86_64) and written into the code's comments: 1. § Risks 1 expects unshare(CLONE_NEWUSER) to fail with EPERM for an unprivileged unconfined process when kernel.apparmor_restrict_unprivileged_userns is 1. It SUCCEEDS. Since Ubuntu 24.04 the process is transitioned into the `unprivileged_userns` AppArmor profile, which denies every capability and grants no mount rule, so the first failure is the box's first mount: EACCES on mount(0,"/",0,MS_PRIVATE|MS_REC,0) and EPERM on sethostname. The check probe is therefore two stages, and a one-stage probe would print `ok` on a host where nothing works. 2. /proc/filesystems lists only LOADED filesystems, so `overlay: absent` was a lie on a machine that has the module and no container engine. The line is now `not loaded (modprobe overlay)`. Measured, both hosts, both privilege states -- see the PR description. The five goldens are rewritten once, each after its own criterion: mc2.sha256 after an empty `--dump-asm` diff between mc1 and mc2 and cmp mc2.o mc3.o; the Linux pair deleted and re-recorded by `make check-linux-host` (four cells, each after its own mc2l.o == mc3l.o and with the cross proof green); the Windows pair cross-computed per tests/golden/README.md and reproduced by build/mc2. stage0/, lib/ and tests/*.mc untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* scripts/check-surface.sh asserts the eight AArch64 words of sys6 against --dump-asm src/sysno_linux_aarch64.mc. * scripts/check-parts.sh asserts the six x86-64 words against --dump-asm --machine=x86_64, and adds core_sandbox to the per-part case (<mc/core_min> + <mc/core_sandbox> compiles alone) and to the spelled-out six-part core, which still cmp's byte for byte against <mc/core>. * scripts/check-shim.sh (make check-shim) is acceptance 1 itself: it GENERATES a probe -- tests/*.mc is untouchable for this milestone and the spec's file list names six other programs -- builds it with the compiler under test and RUNS it. Four assertions: getpid through the shim equals the libc's, openat of a missing path is -2, a write reaches fd 1, and a six-argument mmap at offset 4096 reads the byte that is there. The last is the one that matters: the sixth kernel argument is sys6's SEVENTH parameter, x6 on AArch64 and [rbp+16] on x86-64 -- the one place the two shims differ in kind. check-shim is in the Linux `check` list, prints one SKIPPED line on macOS, and is named in the Windows check-skipped report with its reason. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs/reference/sandbox.md (new). Its § "What is not isolated" names the kernel in its first sentence, as the architect's addition (b) requires, and says plainly what gVisor and Firecracker buy that this does not. The rest is the hosts table, `check` line by line with how each value is measured, the AppArmor section, the shim with both encodings and the three gates, and the part. Every claim that is a measurement says so and names the host. * docs/reference/cli.md § 3c: the three verbs, the eleven options, the six `check` lines, and exit codes 124/125/126 in the table. * docs/reference/hooks.md § 6: host_syscall6, host_sysno, host_sandbox_supported, with why a libc wrapper is not usable here. * docs/reference/bundle.md: the sixth part, the four new names, 83 entries. * docs/reference/diagnostics.md § 11b: every message `mc sandbox` can print. * docs/README.md: sandbox.md on the map. * docs/specs/M43.md § Implementation notes -- step A: eight notes, four of them corrections the kernel made to the spec, each with the numbers it was measured with. make check-docs: 191 symbols, 32 flags, 20 toml keys, 10 directives, 48 samples, 301 links. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…l answered Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ree, the caps, the report
`mc sandbox run|exec` builds a box and runs something in it. Landlock, the
seccomp filter and the notification channel that names a refusal are step C;
src/sandbox_box.mc carries the one marked place they go, and exit code 125 is
defined and not produced yet.
P src/sandbox.mc the option parser, the plan, the pipes, the uid/gid
maps, the wall clock, the report, the exit code
I src/sandbox_box.mc unshare, the mount tree of the spec's section 3,
pivot_root, the inheritable caps
J src/sandbox_box.mc pid 1 of the pid namespace, one child per step
C src/sandbox_box.mc close_range, no_new_privs, the per-step caps, execve
Five things the kernel corrected, each measured on Ubuntu 26.04 / kernel
7.0.0-30, on linux/aarch64 and linux/x86_64, as root and unprivileged:
* a root map of `0 65534 1` leaves the CALLER unmapped, and an unmapped fsuid
answers EOVERFLOW to every file it creates -- it was the box's first mkdir.
Adding `1 0 1` + setuid(0) then failed the overlay's copy-up, which needs
permission on the LOWER directory (CAP_DAC_OVERRIDE does not help: the
inode's owner must be mapped). The root map is now the identity range
`0 0 65536` and there is no setuid at all;
* RLIMIT_NPROC does not bind for root (copy_process exempts INIT_USER), and
does bind for everyone else -- at 0 the first clone is EAGAIN, for fork and
for the clone3 behind posix_spawn alike;
* a pid namespace dies with its init and cannot be re-unshared (EINVAL from
copy_pid_ns), so J exists: one pid 1 for the whole box, running the steps;
* the cpu verdict is J's, not P's: P's wait4 sees the sum of every step. With
soft = hard the kernel sends SIGKILL, not SIGXCPU, and the recorded rusage
is a shade under the cap;
* overlayfs mounts on virtiofs and on ext4, with `userxattr`. The priced
read-only fallback is built and no cell has reached it.
Two options beyond the spec's section 6, both forced by this repository's own
corpus: `--root DIR` (the tree that becomes /src, for a source that includes
`../lib/sys.mc`) and `--config NAME` (the project file, with [project].out
relative to its own directory).
Globals: the whole milestone owns one, sb_state, an arena record with named
offsets and accessors. `mc limits src/mc.mc` reports globals 422/512 -- step A's
sixteen sandbox globals became the record, so the row went DOWN from 437.
The five goldens are rewritten once, each after its own criterion: mc2 after an
empty `--dump-asm` diff between mc1 and mc2 and `cmp build/mc2.o build/mc3.o`,
the Linux pair by deleting them and running `make check-linux-host`, the Windows
pair cross-computed per tests/golden/README.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
scripts/test-sandbox.sh (make test-sandbox, inside make check) is seven parts: `mc sandbox check` as the guard, the eight isolation programs of tests/sandbox/, the whole tests/*.mc suite compiled AND run inside the box, `mc sandbox exec` on two binaries built outside it (one dynamic, which reaches libc through the bind of /lib and nothing else of the host), a project, the determinism of the report, and the overhead number. It contains no mechanism -- the box is `mc sandbox`, written in mc. On a Linux host it runs natively; from macOS it cross-builds a Linux mc and hands the run to Lima, else to `docker run --privileged`, else prints one SKIPPED line. The box's own guard is what skips it on a kernel that cannot build one, which is what happens inside the unprivileged containers of `make check-linux-host`: `userns: EPERM`, printed, and check stays green. Measured, all four cells green: linux/aarch64 root (Lima) 49 ok, 0 failed, 1 skipped, 1.37 ms per box linux/aarch64 unprivileged 49 ok, 0 failed, 1 skipped, 1.43 ms linux/x86_64 root (the VPS) 47 ok, 0 failed, 3 skipped, 4.12 ms linux/x86_64 unprivileged 47 ok, 0 failed, 3 skipped, 4.98 ms linux/aarch64 alpine:3 under docker --privileged (kernel 6.12.76) 48 ok plus, unprivileged with the stock kernel.apparmor_restrict_unprivileged_userns=1 on both hosts, the honest refusal: `sandbox: cannot mount /: EACCES (apparmor restricts unprivileged user namespaces: ...)`, exit 126. tests/sandbox/ is eight programs, each carrying its own expectations. Two of them record what step B can honestly claim and what it cannot: shadow.mc gets ENOENT because there is no /etc in the box, not a named refusal, and connect.mc gets ENETUNREACH from the empty network namespace. forkbomb.mc carries both answers, because RLIMIT_NPROC binds for an ordinary user and not for root, and it declares `fork` as i32: an i64 declaration read glibc's -1 as 4294967295 and the bomb looked unbounded because its own test could not see a failure (M45). examples/lang gains two Linux configs with no [target] section at all -- so the pair is the host's and one file serves both architectures -- the second adding `libc = "gnu"` because the executable writer's default loader is musl's and every Linux host measured here is glibc. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ach host answered docs/reference/sandbox.md goes from "status: step A" to the box: the four processes and why there are four, the mount tree, the maps and the two kernel answers that shaped them, the caps and the split between the ones I sets and the ones C sets, the report grammar with its six lines and nineteen `cannot` sites, the steps, and a table of what the five measured cells did. § What is not isolated gains the process cap for root, with the reason from copy_process and the two answers -- run it unprivileged, and wait for step C. docs/reference/cli.md § 3c: --root, --config, the corrected --libc and --verbose descriptions, an example of a run, and the note that 125 is defined and not produced yet. docs/build.md § M43: `make test-sandbox`, the three Linux kernels it looks for in order (Lima, docker --privileged, none), the two `-gnu` cross-build targets and why they exist, and the two host facts a new machine will need (the AppArmor sysctl, `modprobe overlay`). docs/specs/M43.md § Implementation notes -- step B: the ten kernel corrections in the order they were hit, with the measurement that forced each one, plus the measured table and the globals number. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cepts, the numbers Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Landlock and a seccomp filter with SECCOMP_RET_USER_NOTIF, installed by C in the place step B left marked for them -- after PR_SET_NO_NEW_PRIVS and before the byte that releases it. What the box could only make impossible, it can now also NAME: `refused: open /etc/shadow`, `refused: syscall 198 (socket)`, `refused: mmap 8589934592 bytes over the cap (268435456)`, each one line and exit 125. src/seccomp.mc (600 lines, 336 of them code) is the ABI and the policy: the BPF builder over 8-byte sock_filter records, the Landlock ruleset and its per-root rules, the three notification ioctls, and P's table. The filter's shape is fixed and every jump in it is forward -- architecture, then the x32 bit, then one JEQ per allowed number, then the clone flag test, then the three returns. The architecture comes from host_audit_arch(), a new host-layer answer, because a value that changes with the machine belongs there and not in a file that compiles for both. Five calls are notified even though every profile contains them -- openat, open, mmap, munmap, execve -- because they are what the table decides on; P answers CONTINUE. Two are in every profile whatever the trace said, and they are C's own: the read that waits for the sync byte and the close of its copy of the listener. The listener travels in two hops of pidfd_getfd, not the one the design drew: P cannot name C, whose pid is a number in the box's namespace, and the field that would tell it arrives only with the first notification. So C reports the descriptor number to J, J takes it (it is C's parent), P takes it from J. Both hops are between a process and its own descendant with the same real uid, which is what Yama ptrace_scope = 1 allows -- the value on both hosts. /etc/ld.so.cache comes into the box, bound read-only and granted read-only, which is one host file more than the design's "no /etc" and is measured: with it missing, glibc's loader takes a fallback path that issues calls the same binary never issues on the host, and the profiles are measured on the host. globals 422 -> 430 of the frozen seed's 512; everything new is in the one sb_state record. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
scripts/sandbox-trace.sh runs strace -f over every tests/*.mc compiled and then run, over mc build examples/lang, and over two probes it writes itself, and records one list per (architecture, C library, step) in tools/sandbox/. src/sandbox_profiles.mc is generated from the twelve of them; `make sandbox-trace-check` re-traces the host it runs on and fails in either direction, or when regenerating the file does not reproduce it byte for byte. It is green on all four measured cells and exits 1 on a deliberate extra entry. Four things it had to learn the hard way, each recorded in the script: the trace runs OUTSIDE the box, because tracing the box would record the box's own unshare/mount/pivot_root and, once a filter exists, a call missing from the profile is refused before it can be observed; strace -c cannot be used: its summary drops exit_group, which never returns and is therefore never counted -- and a profile without exit_group refuses every program at its last instruction; the corpus needs a program that uses the C library. Every tests/*.mc writes with a raw write and allocates nothing. The probe allocates four megabytes in ONE block on purpose: that makes glibc advise MADV_HUGEPAGE every time, where a smaller heap did it in 5 runs out of 60 -- and a profile measured from a corpus that hits a call one run in twelve is a box that refuses a legitimate program one run in twelve; the glibc delta is per STEP. `read` is in musl's compile trace and not in its program trace; clone3 is in the compile trace only. One delta over both would have denied a glibc program its read and handed it a way to fork. Two targets, neither in `make check`: the first writes generated source and the second is a full corpus run per libc, which belongs in the sandbox CI job. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
tests/sandbox/ carries the step-C expectations: shadow is `refused: open /etc/shadow` at exit 125, connect is `refused: syscall 198 (socket)` (41 on x86-64), forkbomb is `refused: syscall 220 (clone)` and, run a second time with --allow=threads, `refused: process limit (64)`. eightgib asks the kernel for its eight gibibytes directly instead of through malloc, so the number in the report is the number in the file -- glibc adds a page for the chunk header and `mmap 8589938688` cannot be written down. sleeper gained --allow=threads, because sleeping is a thing the default profile does not allow. libcuser.mc is new and is the case the rest of the directory did not cover: an ordinary C-library program, four megabytes of malloc and a file read through stdio, which is what the profile has to allow without a word. It replaced tests/linux/071-errno-malloc.mc in the `exec` check for a reason that is the milestone's own policy: that program opens /nonexistent-m42/nope on purpose, and a path under none of the box's roots is refused rather than answered ENOENT. The script grew the run_case helper, a second run per source when the header asks for one, expectations that can name the architecture AND the C library (musl on x86-64 forks with `fork` 57 where glibc uses `clone` 56), an assertion that a present-and-empty stdout header means "no output", and the two acceptance checks that look at the host: the process count and the available memory before and after the isolation set. Measured green on five cells: linux/aarch64 glibc root and unprivileged (Lima), linux/x86_64 musl root and unprivileged (the VPS), and linux/aarch64 musl under docker --privileged -- 52, 52, 50, 50 and 50 ok, 0 failed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs/reference/sandbox.md is no longer "step B, and 125 is defined but never produced". Three new sections: The two walls (the Landlock ABI ladder and what each root is granted, the filter's shape instruction by instruction, the clone flag test), The profiles (how they are measured, why outside the box, why not strace -c, why the corpus needed a program that allocates, why the glibc delta is per step) and The explain channel (the two pidfd hops, Yama, P's table, and why a refused call is never answered). The box's tree gained the /etc/ld.so.cache row and the reason it is there; What is not isolated now counts the calls a profile actually allows and says which fork the compile step can still make when the box was started by root. The measured tables are step C's: five cells, and the box cost against step B on the same quiet machine -- 1420 us -> 1717 us, +297 us, +21% for one ruleset, one filter, two pidfd hops and a dozen notification round trips. docs/reference/cli.md: 125 is produced now, --allow=threads says what it really permits, --verbose says what it adds. docs/reference/diagnostics.md gains the five refusal lines and the three new `cannot` sites. docs/reference/hooks.md gains host_audit_arch(). docs/build.md gains `make sandbox-trace`. docs/specs/M43.md gains fourteen numbered implementation notes -- everything the kernel, the two C libraries and the two architectures said that the design did not. make check-docs: 192 symbols, 33 flags, 20 TOML keys, 10 directives, 48 samples, 308 links. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
make bundle before make bootstrap, as always: 86 files, raw 1016670 -> lz 477278, blob 478358 bytes. tools/bundle.list gained mc/sandbox_profiles and mc/seccomp, so <mc/core_sandbox> is complete out of the binary alone. The five goldens, each rewritten only after its own criterion: mc2.sha256 675d62a4... -> bb48b0b2... after `diff <(mc1 --dump-asm src/mc.mc) <(mc2 --dump-asm src/mc.mc)` came out empty and cmp build/mc2.o build/mc3.o matched (1099928 bytes); the Linux pair deleted and re-recorded by `make check-linux-host`, which is green over all four cells (aarch64 and x86_64, musl and glibc), each after its own fixed point and with the cross proof against the macOS build/mc2.o: mc2-linux-arm64 394ce144..., mc2-linux-x86_64 8c9c7fab...; the Windows pair cross-computed per tests/golden/README.md -- the same source through --backend=coff-obj-arm64 and --backend=coff-obj-x86_64, which is by construction the object a Windows-hosted mc must write: f2d9b228... (1121582 B) and 3596c00b... (1150098 B), both produced byte for byte by build/mc2 as well. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…profiles, fourteen facts Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t's trace
`sh scripts/sandbox-trace.sh --check` failed on Ubuntu 24.04 -- the version both
GitHub runners carry -- with the same compiler and the same corpus that pass on
Ubuntu 26.04, and the difference is glibc: 2.39 issues `rt_sigaction` at start-up
and spawns with `clone`, where 2.43 issues `madvise` and `getrandom` and spawns
with `clone3`. Two of those were MISSING from the table, which is not a
bookkeeping difference: a call the profile does not have is a legitimate program
refused at run time.
So the two directions of the comparison stop being one:
* a call in the trace the list does not have FAILS, always;
* a list entry this host did not need is a `note` line, because the list is
the union over the C libraries and the C library VERSIONS the project
supports and no single host can exercise them all;
* `--union` adds what a host needs without erasing what another needs, and
`--strict` restores the two-way failure for a single-host audit.
Measured, not assumed: `ubuntu:24.04` (glibc 2.39-0ubuntu8.7, aarch64) folded in
with `--union`, which adds `clone` and `rt_sigaction` to the aarch64 glibc
compile list and `clone`/`madvise` to its threads list -- one line in
src/sandbox_profiles.mc, SN_RT_SIGACTION in sbp_gnu_compile_aarch64 (the others
were already in the musl base or in the shared threads delta). The relaxed
`--check` is green on Lima (glibc 2.43) with two `note` lines, and the suite is
52 ok / 0 failed there, root and unprivileged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ere can run Two jobs, `The sandbox (linux/arm64)` on ubuntu-24.04-arm and `The sandbox (linux/x86_64)` on ubuntu-latest, each running the whole of scripts/test-sandbox.sh twice: as an ordinary user and under sudo. That is the four-cell matrix of docs/specs/M43.md § 8, and the unprivileged cell with kernel.apparmor_restrict_unprivileged_userns=0 is the one state the project's own machines cannot show -- Ubuntu ships the sysctl at 1, so what Lima and the VPS can measure is only the refusal. The step prints `mc sandbox check` in both states and requires exit 0 in the second. What travels is a new artifact, `mc-linux-sandbox`: four EXECUTABLES, not objects. Since M42 `mc build` writes a dynamic ELF itself, so cross-building a runnable Linux compiler on macOS is half a second with no linker and no sysroot -- the reason the M37 jobs ship an object does not apply. Two are glibc-linked (the runners are glibc), two musl-linked for the alpine cell. scripts/ci-sandbox-cell.sh is what 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 is allowed. The summary must say `0 failed`. Two more steps per job: `sandbox-trace.sh --check` re-measures the seccomp profiles with strace on every pull request (acceptance 5 in CI), and `docker run` WITHOUT --privileged must answer with a `sandbox: cannot ...` line and exit 126 (acceptance 8's third refusal). docs/ci.md gains the job section and both names in § Branch protection -- the architect adds the two contexts after the merge. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs/guide/99-sandbox.md (205 lines) is the task-oriented half: ask the kernel first, a program that behaves, one that does not and the five refusals with their exit codes, the four dials, `exec` on a binary you already have, a whole project, the two hosts that have no box and the Ubuntu sysctl, what a box costs (1.7 ms) and what it does not buy (the kernel), and the two commands to run it in your own checks. Its two `mc` samples are compiled by `make check-docs` on the machine that renders these docs -- a Mac, which has no box -- and the page says so where a reader would otherwise assume they were run through one. scripts/check-parts.sh gains the second half of acceptance 9: a compiler assembled without <mc/core_sandbox> prints no `sandbox` usage line, and the word on its command line is an ordinary file name -- `mc: cannot open: sandbox`, and `mc sandbox check` is `mc: duplicate entry: check`, both exit 1. The contrast case asserts that the whole compiler DOES print the two usage lines, so a renamed subcommand cannot make the first assertion a tautology. check-docs: 192 symbols, 33 flags, 20 toml keys, 10 directives, 50 samples, 317 links. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
src/sandbox_profiles.mc gained one entry (SN_RT_SIGACTION, the glibc 2.39 row), and it is bundled as mc/sandbox_profiles, so the blob moves and every golden with it: bundle 86 files, raw 1016691 -> lz 477285, blob 478365 B. Rewritten only after `diff <(mc1 --dump-asm src/mc.mc) <(mc2 --dump-asm src/mc.mc)` came out EMPTY and `cmp build/mc2.o build/mc3.o` matched (1099928 B): mc2.sha256 f41c4eff3a2f85625534ea5afc4cc4923e228bff3142491bfa73a5801699919d mc2-linux-arm64.sha256 fe5f832e67b10a1e0ca3c651a36de3558b2a28f7034999d4eb8a34544a60402d mc2-linux-x86_64.sha256 eb9f949762d3f03d7da8a7aa716df75f5c7d9846054ff76451881551b5a5856d mc2-windows-arm64.sha256 82a46a1a3d85616f13ede3cace1c7b58c2a29ba1fc32debf592ead032da10d43 mc2-windows-x86_64.sha256 323a137439cad5efde8baee1495e0d4a8ae51fc880bf7405a31045e975cad3f1 The Linux pair was deleted and re-recorded by `make check-linux-host` (Docker, both architectures, both libcs, each after its own `mc2l.o == mc3l.o` and with the cross proof against the macOS build/mc2.o green); the Windows pair is cross-computed per tests/golden/README.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The sandbox CI job failed on both runners, in the same place and with the same line -- `mc: cannot open: /src/examples/lang/build/.mc-usage.toml`, the only failure in 51 and 49 other cases -- and the cause is not the sandbox. `open` returns a C `int`. The failure is -1 in the low 32 bits and the bits above it are UNSPECIFIED; M42 wrote that hazard down and M45 narrowed every call site with `c_int()` -- read_file, write_file, sysroot.mc, waitpid -- except this one. On the runners (Ubuntu 24.04, glibc 2.39, kernel 6.17.0-1022-azure) a failing `open` hands back 0x00000000ffffffff, so `fd < 0` was FALSE and lex_readable answered "readable" for every file that does not exist. On the project's own machines (glibc 2.43) the same call sign-extends and the bug is invisible -- which is why four measured cells and a green make check never saw it. What it broke, on those hosts: `mc build` on a tree with no `build/` directory yet. lim_read_usage asked lex_readable, was told the usage file was there, and read_file (which does narrow) disagreed and died. The same false positive is in the `[include].paths` search, where it makes a root that does not have the file claim it does. One line plus the reason. Objects do not move: scripts/check-inert.sh against a build/mc1 from origin/main (3966268) is identical for all 33 objects and for the five taught examples. `make check` RC 0, `make check-linux-host` RC 0 over four cells, and the five goldens are rewritten once more, each after its own criterion (empty --dump-asm diff, cmp mc2.o mc3.o at 1100464 B). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The two sandbox jobs are green (run 33962063476), so the numbers stop being a plan. docs/reference/sandbox.md § Hosts gains the four CI cells beside the four local ones -- kernel 6.17.0-1022-azure, glibc 2.39, Landlock abi 7, isolation 10/10 and the suite 31/31 and 29/29 in each, a box costing 2.1-2.2 ms -- plus what `mc sandbox check` says in both states of the AppArmor sysctl and what a container without --privileged says (`userns: EPERM`, then `sandbox: cannot unshare: EPERM`, exit 126). docs/specs/M43.md gains its step D implementation notes: the two things only a runner could find (a profile is a union over C library VERSIONS; lex_readable believed a file that was not there), the three smaller ones (the artifact is four executables, the cell wrapper's no-skip rule, and Docker Desktop's fakeowner bind mount refusing an execve inside the box), and the measured table. check-docs: 192 symbols, 33 flags, 20 toml keys, 10 directives, 50 samples, 318 links. `make site check-site` RC 0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…CI-only defects Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three findings from the security review of the branch. 1. HIGH. A profile entry is a call the supervisor never sees, and `clone` (with `clone3` in the glibc delta) was measured into the COMPILE profile as one, because `mc build` forks. `mc build` also runs `[linker].cmd`, and that name comes out of the source tree's own mc.toml -- so an untrusted tree chose which binary the compile step executed and forked in a loop with nothing in the report but `compile: exit 1`: twelve children unprivileged, two hundred as root, where RLIMIT_NPROC does not bind at all. A clone(CLONE_NEWUSER|SIGCHLD) in the same position simply succeeded (`cloned 4`). sb_notified() now drops clone, clone3, fork and vfork from every profile, as it already dropped openat/open/mmap/munmap/execve, so all four always reach P. P asks two questions: any CLONE_NEW* bit is `refused: clone with namespace flags` -- read from args[0] for clone and, for clone3, out of the caller's struct clone_args with process_vm_readv, since BPF cannot follow a pointer -- and everything else is counted against a per-step process limit: 16 for a compile, 0 for a run, 64 with --allow=threads. One past it is `refused: process limit (N)`. RLIMIT_NPROC stays as the second wall and is loosened to 32 for the compile step, because the two walls race and the named one has to win. With --allow=threads the filter still short-cuts a REAL thread by its flags, so a threaded program pays no round trip per thread. scripts/sandbox-trace.sh still MEASURES the four calls into tools/sandbox/*.list -- that is what the trace saw -- but writes them into src/sandbox_profiles.mc as a comment, never as a row. tests/sandbox/forkbomb.mc loses its three per-(arch, libc) expectations: counting instead of naming the entry point gives one line everywhere. New: nsclone.mc, nsclone3.mc (raw clone3, syscall 435 on both architectures, the only way to exercise the struct read) and linkbomb/, the hostile project, with part 2b of scripts/test-sandbox.sh building its bomb into build/ so nothing binary is checked in. That part also exposed a defect in the script: `lf` was assigned in part 4, so part 2b built its program for the wrong C library and the box stopped at `refused: syscall 95 (waitid)` instead of at the case under test. 2. LOW. sb_num accumulated without a bound, so `--mem 999999999999999999999999` wrapped and the box ran with a negative cap (measured: `refused: mmap 8192 bytes over the cap (-3541500564788477952)`). It now stops at 10^12 (`mc: --mem: number too large`), and each cap has a maximum -- 86400 s, 1048576 MiB, 65536 MiB -- and a minimum of one. 3. INFO. Below Landlock ABI 6 the scoped signal and abstract-socket restrictions do not exist. `mc sandbox check` says so: `landlock: abi N (no scoped signals below 6)`, still exit 0. Measured on Ubuntu 26.04 / kernel 7.0.0-30, four cells: linux/aarch64 glibc (Lima) unprivileged 55 ok / root 55 ok, linux/x86_64 musl (the VPS) root 53 ok / unprivileged 53 ok, 0 failed in each, with the host's process count equal before and after; the x86_64 glibc cases by hand; and `sandbox-trace.sh --check` green on both hosts in both directions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs/specs/M43.md gains a section "Implementation notes -- the review", with each finding, how it was reproduced (the pre-fix numbers, both privileges and both architectures) and the rule that replaced it. docs/reference/sandbox.md: the notification table splits clone/fork/vfork from clone3 (the struct read) and gains the per-step process limits and the reason every RLIMIT_NPROC above them is looser; the filter section says the clone block is a fast path for a thread and not a decision about processes; "What is not isolated" loses the paragraph about the compile step's unbounded forks -- it is bounded now -- and gains the Landlock-ABI-6 bullet. docs/reference/cli.md § 3c: the four maxima, why they are not the kernel's, and the pre-fix wrap. docs/reference/diagnostics.md: three option rows (`number too large`, `at most N`, `must be at least 1`) and three report rows (`process limit (N)` with its three values, `clone with namespace flags`, `clone3 with unreadable arguments`). docs/guide/99-sandbox.md: the refusal table, the caps paragraph, and the note that root now loses only the wall BEHIND the counter. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
make bundle re-run before bootstrapping (86 files, raw 1027114 -> lz 482081,
blob 483161 bytes): src/sandbox.mc, src/seccomp.mc, src/sandbox_box.mc and
src/sandbox_profiles.mc are all bundled.
The five goldens rewritten once, each only after its own criterion.
mc2.sha256 f8b05c08...a173e1 -> 9e7b803f...a2d431
after `diff <(mc1 --dump-asm src/mc.mc) <(mc2 --dump-asm src/mc.mc)` came
out empty and `cmp build/mc2.o build/mc3.o` matched (1107056 bytes).
mc2-linux-arm64.sha256 d8bdbeeb...da6b59 -> 182a4c6d...036679
mc2-linux-x86_64.sha256 ed7c7f61...fd08dd -> e63d09bc...d99ffa
deleted and re-recorded by `make check-linux-host` (RC 0, four cells: musl
and glibc on both architectures), each after its own fixed point
`mc2l.o == mc3l.o` and with the cross proof against the macOS build/mc2.o
green in all four.
mc2-windows-arm64.sha256 4de1c3c9...d48cf3 -> dcaac914...4256c3
mc2-windows-x86_64.sha256 7da0aa71...1c0d01 -> dfaf002c...a97b861
cross-computed per tests/golden/README.md, and build/mc2 produces both
byte for byte as build/mc1 does.
macOS `make check` RC 0, zero FAIL; scripts/check-inert.sh against a compiler
built from the pre-review sources: 33 objects identical (tests/*.mc and
src/mc.mc) and the five taught examples identical too.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…in ALLOW Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
It introduces a large, security-critical sandbox feature with deep kernel/ABI interactions that warrants final human review despite strong test/CI coverage.
Pull request overview
Adds Layer 1 of the M43 sandbox (mc sandbox run|exec|check) as a new composable core part (<mc/core_sandbox>), enabling isolated compilation/execution of arbitrary mc programs on Linux using namespaces + Landlock + seccomp user-notify, with measured syscall profiles, tests, and CI coverage.
Changes:
- Introduces
<mc/core_sandbox>and the sandbox implementation (supervisor, box setup, seccomp/Landlock walls, syscall shim + per-arch syscall-number tables). - Adds measured syscall profile lists and a dedicated sandbox test corpus (including hostile-project coverage).
- Wires the new part into bundling/docs/CI and fixes a Linux host issue in
lex_readableby narrowingopen()’s Cintresult viac_int().
File summaries
| File | Description |
|---|---|
| tools/sandbox/x86_64-musl-threads.list | Measured syscall allowlist delta for musl/x86_64 threads. |
| tools/sandbox/x86_64-musl-program.list | Measured syscall allowlist for musl/x86_64 program execution. |
| tools/sandbox/x86_64-musl-compile.list | Measured syscall allowlist for musl/x86_64 compile step. |
| tools/sandbox/x86_64-gnu-threads.list | Measured syscall allowlist delta for glibc/x86_64 threads. |
| tools/sandbox/x86_64-gnu-program.list | Measured syscall allowlist for glibc/x86_64 program execution. |
| tools/sandbox/x86_64-gnu-compile.list | Measured syscall allowlist for glibc/x86_64 compile step. |
| tools/sandbox/aarch64-musl-threads.list | Measured syscall allowlist delta for musl/aarch64 threads. |
| tools/sandbox/aarch64-musl-program.list | Measured syscall allowlist for musl/aarch64 program execution. |
| tools/sandbox/aarch64-musl-compile.list | Measured syscall allowlist for musl/aarch64 compile step. |
| tools/sandbox/aarch64-gnu-threads.list | Measured syscall allowlist delta for glibc/aarch64 threads. |
| tools/sandbox/aarch64-gnu-program.list | Measured syscall allowlist for glibc/aarch64 program execution. |
| tools/sandbox/aarch64-gnu-compile.list | Measured syscall allowlist for glibc/aarch64 compile step. |
| tools/bundle.list | Bundles the new core part and sandbox-related modules. |
| src/core_sandbox.mc | New core part wiring: registers sandbox subcommand and includes sandbox components. |
| src/sandbox.mc | Supervisor/subcommand implementation: parsing, planning, reporting, and orchestration. |
| src/sandbox_box.mc | Box implementation: namespace setup, mount tree, caps, step execution, and status reporting. |
| src/seccomp.mc | Landlock + seccomp user-notify “two walls” implementation and notification ABI helpers. |
| src/sandbox_profiles.mc | Generated profiles compiled into the binary (from scripts/sandbox-trace.sh). |
| src/sysno.mc | Defines the syscall-name enum (SN_*) used by the sandbox. |
| src/sysno_linux_aarch64.mc | AArch64 syscall shim (sys6) + syscall-number table + audit arch constant. |
| src/sysno_linux_x86_64.mc | x86_64 syscall shim (sys6) + syscall-number table + audit arch constant. |
| src/host_linux.mc | Provides Linux host syscall shim entry + host_sandbox_supported(). |
| src/host_linux_aarch64.mc | Includes the aarch64 syscall shim/table into the Linux host build. |
| src/host_linux_x86_64.mc | Includes the x86_64 syscall shim/table into the Linux host build. |
| src/host_macos.mc | Stubs sandbox host queries on macOS so <mc/core_sandbox> compiles everywhere. |
| src/host_windows.mc | Stubs sandbox host queries on Windows so <mc/core_sandbox> compiles everywhere. |
| src/lex.mc | Fixes lex_readable() by narrowing open()’s C int return value via c_int(). |
| src/core.mc | Includes the sixth core part (core_sandbox) in <mc/core>. |
| src/main.mc | Calls mc_sandbox_init() and updates entry-point documentation for the six-part core. |
| scripts/test-sandbox.sh | Runs sandbox isolation corpus + suite-through-sandbox; delegates from macOS when needed. |
| scripts/ci-sandbox-cell.sh | CI helper to run sandbox “cells” (unprivileged/root) without skipping. |
| scripts/sandbox-trace.sh | Generates/checks syscall profile lists from strace measurements. |
| scripts/check-surface.sh | Adds assertions for the AArch64 syscall shim words via --dump-asm. |
| tests/sandbox/clean.mc | Clean program used to validate sandboxed vs non-sandboxed equivalence. |
| tests/sandbox/shadow.mc | Isolation test asserting named refusal for opening /etc/shadow. |
| tests/sandbox/connect.mc | Isolation test asserting refusal for network-related syscalls. |
| tests/sandbox/eightgib.mc | Isolation test asserting named refusal for oversized mmap. |
| tests/sandbox/forever.mc | Isolation test asserting CPU-cap termination behavior. |
| tests/sandbox/sleeper.mc | Isolation test asserting wall-clock cap behavior via libc sleep. |
| tests/sandbox/rocwd.mc | Isolation test for --ro bind + --cwd behavior. |
| tests/sandbox/nsclone.mc | Isolation test asserting refusal of clone with namespace flags. |
| tests/sandbox/nsclone3.mc | Isolation test asserting refusal of clone3 with namespace flags. |
| tests/sandbox/forkbomb.mc | Isolation test asserting process-limit behavior. |
| tests/sandbox/libcuser.mc | Isolation test exercising dynamic loader / libc bindings inside box. |
| tests/sandbox/linkbomb/mc.toml | Hostile project config: [linker].cmd points to an in-tree fork bomb. |
| tests/sandbox/linkbomb/bomb.mc | The “linker” fork bomb program used by the hostile project test. |
| tests/sandbox/linkbomb/app.mc | The benign project entry used by the hostile project test. |
| docs/README.md | Adds sandbox guide/reference entries to the docs map. |
| docs/guide/99-sandbox.md | New end-user guide for `mc sandbox run |
| docs/reference/sandbox.md | New reference documentation for sandbox design/behavior/limits. |
| docs/specs/M43.md | The ratified spec and measured implementation notes for M43. |
| .github/workflows/ci.yml | Adds the two required sandbox CI jobs (arm64 + x86_64). |
| docs/ci.md | Documents the new sandbox CI jobs and branch-protection contexts. |
| tests/golden/mc2.sha256 | Updates golden hash after sandbox-related additions. |
| tests/golden/mc2-linux-arm64.sha256 | Updates Linux arm64 golden hash after sandbox-related additions. |
| tests/golden/mc2-linux-x86_64.sha256 | Updates Linux x86_64 golden hash after sandbox-related additions. |
| tests/golden/mc2-windows-arm64.sha256 | Updates Windows arm64 golden hash after sandbox-related additions. |
| tests/golden/mc2-windows-x86_64.sha256 | Updates Windows x86_64 golden hash after sandbox-related additions. |
Review details
- Files reviewed: 69/70 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| uptr p = mmap(0, 8589934592, 3, 0x22, -1, 0); | ||
| if (p == 0 || p + 1 == 0) { puts("mmap refused\n"); return 0; } | ||
| // If it ever succeeds, touch every page so the cap is measured and not |
There was a problem hiding this comment.
Fixed in 0a7842e: the test now checks MAP_FAILED alone (p + 1 == 0; the redundant p == 0 is gone, with the mmap_min_addr reason in a comment).
| // The CPU cap (§ 4): RLIMIT_CPU is soft = hard = --time, so the kernel sends | ||
| // SIGXCPU at the soft limit and the default action ends the process. It burns | ||
| // CPU rather than sleeping on purpose -- the wall clock is a different case | ||
| // and sleeper.mc is that one. |
There was a problem hiding this comment.
Fixed in 0a7842e: the comment now says SIGKILL with soft = hard, as measured in step B and recorded in the spec/reference.
…ts MAP_FAILED alone Copilot on #23: the cpu-cap comment described SIGXCPU where the kernel sends SIGKILL when soft == hard (measured in step B, recorded in the spec and the reference); and the mmap failure test carried a redundant p == 0 beside the MAP_FAILED test -- an anonymous mapping is placed above vm.mmap_min_addr, so only (uptr)-1 is a failure. Comments and one comparison; the reports and exit codes do not move. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
M43 -- the sandbox:
mc sandbox run|exec|check(Layer 1)Spec:
docs/specs/M43.md(Layer 1; the 13 ratified decisions; § Implementation notes A-D, where every place the kernel disagreed with the spec is recorded with the measurement).stage0/,lib/andtests/*.mcuntouched (acceptance 12).What lands
<mc/core_sandbox>, the sixth part:subcommand("sandbox", ...), written in mc against the kernel ABI through one host-layer shim per architecture (host_syscall6: eight#opcodewords on AArch64, sixemit()words on x86-64 verified withllvm-mcbefore the file existed) and a per-architecture number table (src/sysno*.mc;sandbox*.mcnames no number). No external tool in the mechanism (decision 1/2)./mcro,/srcas overlayfs over the source directory,/lib+/lib64+/usr/lib+/etc/ld.so.cachero when present,pivot_root), J is pid 1 of the pid namespace (an init that exits accepts no new process and a secondunshare(CLONE_NEWPID)is EINVAL, so the spec's three became four), C is the step. Maps0 <uid> 1unprivileged /0 0 65536root, one code path.SECCOMP_USER_NOTIF+NEW_LISTENER; the listener reaches P by twopidfd_getfdhops (C -> J -> P, Yama scope 1 allows both).refused: syscall N (name),refused: open PATH(before the kernel even answers ENOENT),refused: mmap N bytes over the cap (M),refused: process limit (64),refused: execve-- and kills the box, exit 125; caps arekilled: cpu limit (S s)/killed: wall clock (S s), exit 124; a box that cannot be set up iscannot ..., exit 126. A refused call is killed and not answered (answering woke the step and made the report non-deterministic).scripts/sandbox-trace.sh(strace -fcoutside the box, both architectures, musl and glibc, the--allow=threadsdelta) generatessrc/sandbox_profiles.mc;--checkdiffs a fresh trace against the checked-in table both ways and runs in CI.mc sandbox check: kernel,userns(a TWO-stage probe: with Ubuntu'sapparmor_restrict_unprivileged_userns=1the unshare SUCCEEDS and the denial comes at the first mount), landlock ABI, seccomp notif, overlay (loaded, not merely present), pidfd.--privileged.scripts/test-sandbox.shdelegates from macOS to Lima, then Docker, else one SKIPPED line;make test-sandboxis insidemake check.Measured (Ubuntu 26.04, kernel 7.0.0-30, Lima aarch64 + VPS x86_64, root and unprivileged, plus
alpine:3 --privilegedon 6.12): isolation cases 8/8 -> after the review 10 (shadow,connect,forkbomb,eightgib,forever,sleeper,clean,rocwd,nsclone,nsclone3, plus thelinkbomb/project with a hostilemc.toml), every one with the exact report line and exit code; the whole suite throughmc sandbox run(31/31 aarch64, 29/29 x86_64, identical exit/stdout),mc sandbox execon static AND dynamic (PT_INTERP) M42 binaries with only/libbound,examples/langtaught and run inside (compile: execve 2), host tree untouched;clean.mcbyte-identical to the unsandboxed run;--reportfilescmp-equal across runs; host process count and free memory unchanged across the isolation set. Final cells: Lima aarch64 root/unprivileged 55/55, VPS x86_64 root/unprivileged 53/53, CI four cells 55/55 x2 + 53/53 x2. Box cost: ~1.7-1.8 ms aarch64, ~4-4.3 ms x86_64, ~2.1 ms on the runners; the filter adds ~0.3 ms (+21%). What the kernel corrected in the spec is in § Implementation notes A-D: four processes not three, the maps root accepts,userxattr,/etc/ld.so.cachefor glibc's loader, a refused call killed and not answered,strace -cdroppingexit_group, profiles as a union over glibc versions,lex_readablewithoutc_int()(an M45 miss that broke[include].pathson glibc hosts -- fixed here).Globals: the whole milestone is ONE global (
sb_state, an arena record with accessors) --mc limits src/mc.mcglobals432/512 against the frozen seed'sMAXGLOBALS,check-limits17/17 under 90%.Gates:
make checkRC 0, zero FAIL on macOS (budget2848/3000,test32/32,check-lex/ast/asm134/134,check-obj32/32 identical to the frozen seed,bootstrapat a fixed point with an empty--dump-asmdiff,check-surface32/32 + the eight AArch64 shim words,check-parts+ the six x86-64 words + the part standing alone + a compiler WITHOUT the part refusingmc sandbox,check-shimrc 0 on both Linux hosts,check-limits17/17, the Linux/Windows suites and--exesets unchanged,test-sandbox55 ok / 1 skipped via Lima,check-docs192 symbols / 33 flags / 50 samples / 320 links,site87 pages);make check-linux-hostRC 0 over the four musl/gnu x aarch64/x86_64 cells with the cross proof;scripts/check-inert.shagainst amc1frommain: 33 objects and the five taught examples identical.Goldens (five, rewritten per step after their own criterion; final):
mc2.sha2569e7b803f127cb6f1e059c1e6572a629bfa909cfbecbfae18b01abd1fd7a2d431(empty--dump-asmdiff +cmp mc2.o mc3.o);mc2-linux-arm64182a4c6d…036679andmc2-linux-x86_64e63d09bc…d99ffa(deleted and re-recorded bymake check-linux-host);mc2-windows-arm64dcaac914…4256c3andmc2-windows-x86_64dfaf002c…a97b861(cross-computed pertests/golden/README.md, also written byte for byte bybuild/mc2).CI: two new jobs,
The sandbox (linux/arm64)(ubuntu-24.04-arm) andThe sandbox (linux/x86_64)(ubuntu-latest): the AppArmor sysctl flipped both ways withmc sandbox checkasserted in each state (thesysctl=0cell no local oracle could measure:userns: ok, exit 0), the unprivileged and root cells (a skip on a runner is a failure),sandbox-trace.sh --checkagainst a freshstracemeasurement, anddocker runwithout--privileged(cannot unshare: EPERM, exit 126). Both become required checks after the merge (docs/ci.md§ Branch protection). 14/14 green on the final head.Release: minor (0.14.0). Layer 2 (the playground) stays priced, post-1.0.0.
🤖 Generated with Claude Code