File size: 16,556 Bytes
d909bd8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 | # Lean REPL OS sandbox (H3)
Status: `align/verify/lean/sandbox.py` + `tests/test_lean_sandbox.py` exist and pass; **nothing
imports the sandbox yet**. Turning it on is the ~45-line patch at the end (not applied: the
P2 baseline run imports `repl_pool.py`, and its result must stay comparable to the post-SFT run).
## The gap this closes
`strict.py` and the `#print axioms` audit decide whether a proof is *accepted*. They do not limit
what Lean *does while checking it*. `#eval`, `run_cmd`, `initialize`, `elab`/`macro` run
arbitrary code as the RL job's user, on a box that holds checkpoints, `hf_cache/token`, `~/.ssh`
and a neighbouring training run (same uid: `kill(2)`, `ptrace(2)`, `/proc/<pid>/environ` all work).
Before this, containment was `prlimit` + a memory watchdog: resource limits, not capability limits.
The tests demonstrate each of these succeeding in the real REPL outside the sandbox and failing
inside (`test_real_lean_elaborator_io_is_contained`: read a secret, write outside scratch,
signal another process).
## Design (what is and is not a read-only `/`)
Probed on this host (kernel 6.1, `max_user_namespaces=764532`): `unshare -Urnm` works; remounting
the inherited `/` read-only works only per mount and the view would still *show* every secret.
Unprivileged `pivot_root` into a fresh tmpfs works, so the sandbox is an **allow-list root**
instead: the child sees only
| path | mode |
|---|---|
| `/usr` (+ `/bin /lib /lib64 /sbin` symlinks), `/etc/{ld.so.cache,ld.so.conf,nsswitch.conf,passwd,group,localtime}` | ro bind |
| `ro_paths` = `<lean>/elan`, `<lean>/repl`, `<lean>/workspace` | ro bind |
| `/dev/{null,zero,full,random,urandom}` | ro tmpfs + device binds |
| `/proc` | fresh procfs of the new pid ns |
| `/tmp` | private tmpfs, 64 MB, nosuid nodev **noexec** |
| `scratch_dir` (per worker) | **the only writable host directory** |
| root `/` | tmpfs remounted ro, old root detached (`umount2(MNT_DETACH)`) |
Everything else (`/home`, `/root`, `/apps/*` incl. checkpoints and `hf_cache`, `<lean>/tmp`,
`<lean>/logs`) does not exist. Paths leading to scratch exist as empty skeleton dirs.
Other properties: new **user** ns (uid/gid mapped 1:1, *not* fake root, so exec drops all caps:
`CapEff=0`; `setgroups` denied), **net** ns (loopback down: even 127.0.0.1 is unreachable;
abstract unix sockets of the host invisible), **pid** ns (neighbours invisible and unkillable;
when the REPL, pid 1, dies the kernel kills every descendant incl. double-fork daemons),
**ipc/uts** ns, `PR_SET_NO_NEW_PRIVS`, `RLIMIT_CORE=0`, own session/pgrp, minimal env
(`ENV_KEEP` allow-list; `HOME`=scratch, `TMPDIR`=/tmp, `PATH` pruned to dirs that exist).
`nice/prlimit/stdbuf` run *inside* the sandbox (`sandbox -- nice prlimit stdbuf repl`), so the pool's
rlimits are unchanged and `stdbuf`'s `LD_PRELOAD` still reaches the REPL (`/usr` is visible).
Fail closed: if any step of setup fails the command is **not** executed (exit 125/126, message in
the worker's stderr file -> `ReplCrash` with tail). `ReplPool.from_toolchain(sandbox=True)` raises
if namespaces are unavailable rather than falling back.
### Why a supervisor, and what `Popen.pid` means now
`ReplPool` spawns from `ThreadPoolExecutor` threads, so `preexec_fn` is unsafe and
`unshare(CLONE_NEWUSER)` needs a single-threaded caller. The launcher is a tiny `python -I -S`
process: unshare, fork the REPL into the new pid ns, stay outside and poll its parent *process*.
`Popen.pid` is the supervisor; the REPL's host pid is published in a pidfile **outside** the
sandbox (`inner_pid()`), which the memory watchdog uses (patch below; verified: identical `pss_anon`
readings). `killpg(Popen.pid)` still kills everything (tested). `PR_SET_PDEATHSIG` is not used:
prctl(2) ties it to the spawning *thread*; on this kernel 6.1 it was measured not to fire for a
non-leader thread, but it differs across kernels, so nothing depends on it.
## What is NOT claimed
* No seccomp filter. Namespaces are a capability boundary, not a proof; kernel bugs reachable from
an unprivileged userns (the price of using one) are out of scope. The adversarial false-accept hunt
is a separate task.
* The toolchain (`elan`, `repl`, `workspace`) is shared read-only between workers. A proof cannot
modify it, but can read all of it (Mathlib is public).
* GPUs are not exposed. `/proc` of the new pid ns still shows the *kernel-global* files (`/proc/meminfo`,
`/proc/cpuinfo`, `/proc/sys`, ...): host load and RAM are observable, not secret.
* A proof can still burn CPU/RAM up to the pool's limits and write `fsize_mb` into scratch (the
scratch dir is on `/apps`: size it/clean it per worker; `.sandbox-root` is an empty dir the setup leaves in it).
* Tiny race: if the supervisor is SIGKILLed between `fork` and the child's `prctl(PDEATHSIG)`, the REPL
survives until its stdin closes.
* Linux x86_64/aarch64 only (`pivot_root` syscall number); other arch -> setup fails closed.
## Evidence
All numbers from this host while the P2 baseline held the GPUs (1-2 Lean workers, one `import Mathlib`
header per worker, no heavy-job lock taken).
### Property tests (`tests/test_lean_sandbox.py`, 31 pass, ~13 s, plus 2 `slow` real-Lean tests)
Every property: probe run unsandboxed (control, must succeed) then inside (must fail).
| property | control outside | inside |
|---|---|---|
| connect to a live 127.0.0.1 listener | connects | `ENETUNREACH` (101) |
| connect to host abstract unix socket | connects | fails |
| write a host-writable file outside scratch / `/` / `/usr` / ro toolchain | OK | fails (`EROFS`/`ENOENT`), host file absent |
| write inside scratch | OK | OK, visible on host |
| read a host secret outside the allow-list; `/home`, `/root`, `~`, `/etc/shadow` | readable | does not exist |
| exec a binary dropped in `/tmp` | - | `EACCES` (noexec); `/tmp` not the host's |
| rename/modify an allow-listed file | - | fails; content unchanged |
| `mount()` remount-rw / bind (setuid `mount(8)` too) | - | `EPERM` / non-zero |
| `..`, `/proc/1/root`, `/proc/self/cwd/..` to reach old root; open fds | - | unreachable; <=4 fds |
| `NoNewPrivs`, `CapEff/CapPrm`, uid, core rlimit | 0, host caps, uid, unlimited | 1, 0, same non-zero uid, (0,0) |
| see/signal a live same-uid process, read its `environ` | visible, `kill 0` OK | pid 1 only, `ESRCH` |
| double-fork + setsid daemon | - | gone when the REPL exits |
| env: `HF_TOKEN`-style var, `LD_PRELOAD` | passes | dropped; `PATH` pruned |
| own session / pgrp (no `start_new_session`) | - | sid == pgid == supervisor |
| parent process SIGKILLed | - | REPL and supervisor gone <5 s |
| spawning *thread* exits (pool pattern) | - | REPL survives |
| `killpg(Popen.pid)` | - | REPL dies |
| setup failure (missing scratch) | - | command NOT run, exit 125/126 |
Real REPL (`slow`; run outside pytest because the instructions forbid `-m slow` while the baseline
runs): `#eval` that reads a host secret / writes outside scratch / `kill -0`s a neighbour
**succeeds without the sandbox and fails inside**; 10 known-good + `sorry_plain` + `eval_side_effect` +
`exit_after_theorem` verdicts are byte-identical inside and outside.
### Mutation record
Each line removed/flipped in `sandbox.py`, suite re-run (the file was restored; `diff` clean):
| mutant | killed by |
|---|---|
| no `CLONE_NEWNET` | `test_network_connect_fails_inside_succeeds_outside` |
| no `PR_SET_NO_NEW_PRIVS` | `test_no_new_privs_no_capabilities_same_uid` |
| no `CLONE_NEWPID` (host `/proc` bound) | `test_other_processes_are_invisible_and_unsignalable` |
| scratch bound read-only | `test_scratch_is_writable_and_shared_with_host` |
| toolchain bound read-write | `test_allow_listed_path_is_readable_but_read_only` |
| `/home` or a sibling dir bound in | `test_read_outside_allow_list_fails_inside_succeeds_outside` |
| old root not detached | `test_no_old_root_reachable` |
| parent-death poll disabled | `test_repl_dies_when_the_parent_process_dies` |
| uid mapped to fake root | `test_cannot_remount_or_mount_over_the_view` |
| no `setsid` | `test_own_session_and_process_group` |
| `/tmp` exec allowed | `test_tmp_is_private_noexec_and_not_the_host_tmp` |
| setup error falls through to exec | `test_setup_failure_never_runs_the_command` |
| scratch existence unchecked (host dirs created) | same test |
| env not filtered / PATH not pruned | `test_minimal_env_...` / `test_child_sees_only_the_spec_env_...` |
| `RLIMIT_CORE` left | `test_no_new_privs_no_capabilities_same_uid` |
| no pidfile | `test_pidfile_names_the_repl_host_pid` |
| root tmpfs left writable | `test_write_outside_scratch_...` |
| `/tmp` tmpfs without a size cap (defaults to 50% of RAM, box has no swap) | `test_tmp_tmpfs_is_size_capped` (review) |
| `closerange` removed (fd passed by a caller crosses the exec) | `test_inherited_fds_do_not_cross_the_exec` (review) |
| recursive bind (`MS_BIND\|MS_REC`) of a ro path | `test_nested_writable_mount_under_a_ro_path_is_not_carried` (review; before it, only caught by accident) |
| REPL's own `PDEATHSIG` removed (supervisor alone SIGKILLed) | `test_killing_only_the_supervisor_kills_the_repl` (review) |
| no alias for a symlinked `ro_paths` / `scratch_dir` | `test_toolchain_reached_through_a_symlink_is_usable` (review) |
| `inner_pid` trusts a stale pidfile | `test_inner_pid_ignores_a_stale_pidfile` (review) |
| launcher broken wholesale (3 variants) | `test_launcher_works_whenever_the_kernel_allows_namespaces` (before it existed the same mutants turned all 24 tests into SKIPS) |
Survivors, honestly: (a) `PR_SET_PDEATHSIG` on the supervisor: kernel 6.1 does not deliver it on
thread exit, so `test_repl_survives_the_spawning_thread_exiting` cannot fail here (it pins the
contract on kernels that do). (b) Deleting the supervisor's explicit "kill inner on parent death":
equivalent, the inner's own `PDEATHSIG` on the (single-threaded) supervisor covers it - kept as two layers.
Not mutation-tested: `MS_REC|MS_PRIVATE` propagation (new mount ns already does not propagate
upward here). Equivalent mutants noted by review: binding `/etc` files read-write (the host
forbids the write anyway).
Review notes: a mount nested under a `ro_paths` entry makes setup REFUSE (kernel EINVAL on a
non-recursive bind over a locked child mount; fail closed, exit 126) -- list such a mount as its
own `ro_paths` entry. `test_pidfile_names_the_repl_host_pid` was flaky (the pidfile is written
before the child's exec; the test now waits for it). The pidfile is published BEFORE the exec, so
for a few ms `inner_pid` names the launcher's own child still running python.
### Overhead (real Lean 4.9 / Mathlib d1d1e4b72c REPL)
Single worker, 10 known-good ceiling proofs x3 reps: cold `import Mathlib` header **3.18 s -> 3.33 s**
(+0.15 s, once per worker); per-proof median **20.8 -> 20.4 ms**, mean 80.9 -> 79.8 ms (no measurable
difference). Pool of 2 workers via `ReplPool.from_toolchain(sandbox=True)` using the patched module
(patch applied to a COPY of the tree, not the repo), 10 proofs x5 over `map`:
warm **3.33 -> 3.67 s** (+0.33 s, once), median **23.9 -> 22.1 ms**, mean 85.2 -> 81.5 ms;
verdicts identical; `memory_report()` identical `pss_anon` (256/252 MB) through `inner_pid`;
`kill()` + respawn works; no leftover `sandbox.py` processes. Existing `tests/test_lean_verifier.py`
passes (198 passed with the sandbox tests) on the patched copy. Cost: one extra ~10 MB python per worker.
## The patch (NOT applied)
Touches only `align/verify/lean/repl_pool.py`; imports the sandbox lazily, default `sandbox=False` is
behaviour-identical. `sandbox_supported()` actually launches the helper once (a sysctl is not proof).
```diff
--- a/align/verify/lean/repl_pool.py 2026-10-13 04:43:15.450960951 +0530
+++ b/align/verify/lean/repl_pool.py 2026-10-13 04:43:15.460961181 +0530
@@ -54,7 +54,10 @@
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from dataclasses import dataclass, field
-from typing import Any, Callable, Iterator, Optional, Sequence
+from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional, Sequence
+
+if TYPE_CHECKING: # the sandbox module is imported lazily, only when a pool opts in
+ from align.verify.lean.sandbox import SandboxSpec
# --------------------------------------------------------------------------- errors
@@ -199,7 +202,10 @@
limits: ReplLimits = ReplLimits(),
stderr_path: Optional[str] = None,
wrap: bool = True,
+ sandbox: Optional["SandboxSpec"] = None,
) -> None:
+ #: opt-in OS sandbox (align/verify/lean/sandbox.py); None = unchanged behaviour
+ self.sandbox = sandbox
self.argv = list(argv)
self.env = dict(env)
self.cwd = cwd
@@ -216,6 +222,14 @@
# -- lifecycle
def _wrapped_argv(self) -> list[str]:
+ argv = self._limited_argv()
+ if self.sandbox is not None: # sandbox OUTSIDE nice/prlimit/stdbuf: they run inside it
+ from align.verify.lean.sandbox import sandbox_argv
+
+ argv = sandbox_argv(self.sandbox, argv)
+ return argv
+
+ def _limited_argv(self) -> list[str]:
if not self.wrap:
return list(self.argv)
lim = self.limits
@@ -237,6 +251,13 @@
pre += [stdbuf, "-o0", "-e0"]
return pre + self.argv
+ def _spawn_env(self) -> dict[str, str]:
+ if self.sandbox is None:
+ return self.env
+ from align.verify.lean.sandbox import helper_env
+
+ return helper_env() # the launcher gets nothing; the REPL's env travels in sandbox.env
+
def start(self) -> None:
if self.alive:
return
@@ -252,7 +273,7 @@
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=err,
- env=self.env,
+ env=self._spawn_env(),
cwd=self.cwd,
start_new_session=True, # own process group => killpg reaps every descendant
close_fds=True,
@@ -316,7 +337,12 @@
return ""
def memory(self) -> Optional[dict[str, float]]:
- return process_memory(self.pid) if self.pid else None
+ pid = self.pid
+ if self.sandbox is not None: # Popen.pid is the supervisor; the REPL is its child
+ from align.verify.lean.sandbox import inner_pid
+
+ pid = inner_pid(self.sandbox, self.pid)
+ return process_memory(pid) if pid else None
# -- I/O
@@ -671,6 +697,7 @@
max_requests: int = 1000,
default_timeout_s: float = 120.0,
preflight: bool = False,
+ sandbox: bool = False,
) -> "ReplPool":
n = n_workers if n_workers is not None else default_workers()
if preflight:
@@ -679,12 +706,29 @@
os.makedirs(scratch, exist_ok=True)
def launcher(idx: int) -> ReplProcess:
+ spec = None
+ if sandbox:
+ from align.verify.lean.sandbox import SandboxSpec, minimal_env, sandbox_supported
+
+ ok, why = sandbox_supported()
+ if not ok: # fail closed: never silently fall back to no isolation
+ raise RuntimeError(f"sandbox=True but namespaces are unavailable: {why}")
+ wdir = os.path.join(scratch, f"sandbox{idx}")
+ os.makedirs(wdir, exist_ok=True)
+ spec = SandboxSpec(
+ scratch_dir=wdir,
+ ro_paths=tuple(str(toolchain.root / d) for d in ("elan", "repl", "workspace")),
+ cwd=toolchain.cwd,
+ pidfile=os.path.join(scratch, f"worker{idx}.pid"),
+ env=minimal_env(toolchain.child_env, wdir),
+ )
return ReplProcess(
toolchain.argv(),
toolchain.child_env,
cwd=toolchain.cwd,
limits=limits,
stderr_path=os.path.join(scratch, f"worker{idx}.stderr"),
+ sandbox=spec,
)
return cls(launcher, n, max_requests, default_timeout_s)
```
To use: `ReplPool.from_toolchain(tc, n_workers=..., scratch_dir=".../pool", sandbox=True)`; pass
`scratch_dir` OUTSIDE the Lean root (the default `<lean>/tmp/pool` is fine because only `elan`, `repl`
and `workspace` are mapped, not `<lean>/tmp`). Remember to enable it for BOTH the baseline-comparable
pre-SFT and post-SFT runs, or neither: verdicts were identical in every case tested, but the
comparability argument should not rest on that.
|