From 7fa949dfac8b8833dd4779b702417a882346d327 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 12:51:43 +0000 Subject: [PATCH 1/2] =?UTF-8?q?S0:=20the=20jail=20is=20a=20boundary=20now?= =?UTF-8?q?=20=E2=80=94=20pivot=5Froot,=20and=20the=20pin=20fd=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two escapes, both verified against real `run_worker` before and after. ESCAPE A — the classic double-chroot, every arch and every profile. `chroot()` does not move the caller's cwd, and a task that is root in its own user namespace holds CAP_SYS_CHROOT unconditionally, so mkdir e; chroot e; chdir ../../..; chroot . left the cwd above the new root and walked out. From a PURE worker, effect SUCCEEDED: read /etc/passwd, listed host /, wrote a root-owned host file. The filesystem is where the Weft, the keystore and ~/.ssh live, so there was no configuration in which this was contained. The fix is NOT a prohibition on the second chroot — it is `pivot_root` plus `umount2(MNT_DETACH)`. Afterwards the host tree is not present in the worker's mount namespace at all, so the hostile sequence still runs and every path it can name resolves inside the jail. That is deliberate and the tests pin it: a structural property survives a regained capability and does not depend on the seccomp filter, which is absent on x86_64 anyway. `_PIVOT_ROOT_NR` is an arch TABLE and a host with no known number REFUSES rather than falling back to chroot — a wrong syscall number is a silently different syscall, and a fallback would have restored the escape while every manifest claim still read as engaged. ESCAPE B — the inherited O_PATH pin fd. The parent opens it on the declared subtree so the child can prove the mounted inode is the one the parent verified (stat vs fstat), and that work is finished the moment the bind is verified. It was then left open across the effect with its number readable from sys.argv[4]. It cannot be fchdir'd but it is a valid openat dirfd against the ORIGINAL mount, so it defeated a read-only workspace whose posture read back correctly and reached above the containment root. No syscall filter could fix that: seccomp cannot tell where a dirfd points. It is now closed before any untrusted byte runs, and the closure is read back with fstat — a descriptor believed closed but still open fails the worker closed rather than silently handing the effect a handle on the original mount. `tests/adversarial/test_jail_escape.py` is new, and the reason it exists is the lesson rather than the fix: every existing containment test asks whether a worker can reach THROUGH the jail, and all of them passed while it was escapable, because none tried to LEAVE. Reverting pivot_root turns five of its six tests red — and the reverted run wrote the host canary file again, which is how I know the write assertion is not vacuous. Docs corrected rather than appended to: both escapes move from open residuals to CLOSED in SECURITY.md and docs/architecture/worker-containment.md, and S0 is marked landed in the design doc. One paragraph I removed by accident while editing SECURITY.md was caught by the two doc-coupling guards in the adversarial lane — they assert the residual record names what actually closed each gap, and they earned their keep. `workspace_write` stays unmapped in TIER_PROFILES. The reason it was withheld is gone, so what remains is a sequencing decision for the owner, not a defect. --- README.md | 2 +- SECURITY.md | 71 ++++++---- decima/workers/execution.py | 114 ++++++++++++++- docs/architecture/worker-containment.md | 28 ++-- docs/design/syscall-filtering.md | 2 +- tests/adversarial/test_jail_escape.py | 178 ++++++++++++++++++++++++ 6 files changed, 349 insertions(+), 46 deletions(-) create mode 100644 tests/adversarial/test_jail_escape.py diff --git a/README.md b/README.md index 466f5d1c..926aa7f4 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ golden fixtures), the durable runtime (crash-recoverable, idempotent), isolated model routing, disposable projections, the local API + trusted Shell, the three daily-driver workflows delivered **in the Shell** (grounded Q&A, model-planned durable agents, isolated coding workspace), and ops (backup/restore/doctor). Current main: -**1242 passed / 25 skipped** plus **14 Playwright specs across 10 files** driving the real +**1248 passed / 25 skipped** plus **14 Playwright specs across 10 files** driving the real rendered Shell; the kernel import boundary and the crash-recovery / revocation / backup-restore / approval-gating end-to-end scenarios pass headless. See [`docs/architecture/system-overview.md`](docs/architecture/system-overview.md), the diff --git a/SECURITY.md b/SECURITY.md index 9c90cdfc..43c0b538 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -308,41 +308,50 @@ weighing this jail needs them more than they need the three above. would be absent at once; `containment_report` emits a loud top-level `warnings` entry for exactly that combination. Scope, measured footprint, and the phased plan: `docs/design/syscall-filtering.md`. -- **The chroot is escapable, on every architecture and every profile.** `chroot()` does not - move the caller's cwd, and `unshare(CLONE_NEWUSER)` grants the worker `CAP_SYS_CHROOT` - over its own namespace — so a hostile effect can `chroot()` into a subdirectory, walk `..` - out of it, and re-root at the real filesystem root. Verified against a real `PURE` worker: - it reads `/etc/passwd`, lists the host home including `~/.ssh`, and **writes host files**. - The PID and network namespaces still hold (a host PID is `ESRCH`; there is no route out), - so this is a filesystem escape — but the filesystem is where the Weft database, the - keystore, and `~/.ssh` live. Blast radius follows the `uid_map`: the worker acts on host - files as the parent's real uid, so under an unprivileged service account it still holds - everything the Decima user holds. **There is no configuration in which this is contained.** - `test_worker_cannot_read_dot_ssh`, `test_worker_cannot_read_etc_passwd_by_absolute_path`, - and `test_weft_db_access_attempt_from_worker_fails` assert a real refusal on a real path - and pass today only because no test attempts a second `chroot`. Analysis and the fix - options (`pivot_root`, or dropping `CAP_SYS_CHROOT` once the jail is built): - `docs/design/syscall-filtering.md` §4.3. -- **A `WORKSPACE` worker can write a read-only workspace and reach above the containment - root.** The `O_PATH` fd the parent pins on the declared subtree is passed as `argv[4]`, - stays open for the child's whole life, and is visible to the untrusted implementation via - `sys.argv`. It cannot be `fchdir`'d, but it is a valid `dirfd` for `openat` — and it - references the *original* mount, where `MS_RDONLY`/`nosuid`/`nodev`/`noexec` were never - set. Verified against a read-only mount whose posture read back correctly: a write through - `/workspace` is refused, a write through the fd **succeeds**, and - `openat(fd, "../..")` lists the host root. So the read-only tier and `mount.py`'s - containment-root rule are both defeated by an inherited descriptor rather than by any - weakness in the mount itself. The fix is to close the fd after `bind_workspace` and before - the implementation is exec'd; no syscall filter can close this one, since a filter cannot - tell where a `dirfd` points. `docs/design/syscall-filtering.md` §4.4. +- **CLOSED (wave S0) — the chroot was escapable, on every architecture and every profile.** + `chroot()` does not move the caller's cwd, and `unshare(CLONE_NEWUSER)` grants the worker + `CAP_SYS_CHROOT` over its own namespace, so a hostile effect could `chroot()` into a + subdirectory, walk `..` out of it, and re-root at the real filesystem root. Verified against + a real `PURE` worker: it read `/etc/passwd`, listed the host home including `~/.ssh`, and + **wrote host files** — as the parent's real uid, so no service account contained it. + The jail now uses **`pivot_root` + `umount2(MNT_DETACH)`** instead: the host tree is not + present in the worker's mount namespace at all afterwards. The fix is deliberately + STRUCTURAL rather than a prohibition — a hostile effect may still call `chroot` as often as + it likes, and every path it can name resolves inside the jail, so the property survives a + regained capability and does not depend on the syscall filter (absent on x86_64) denying + anything. An architecture with no known `pivot_root` syscall number REFUSES rather than + falling back to `chroot`. `tests/adversarial/test_jail_escape.py` runs the full hostile + sequence and asserts it lands in the jail; all five of its escape-A tests go red when the + fix is reverted. Analysis: `docs/design/syscall-filtering.md` §4.3. +- **CLOSED (wave S0) — a `WORKSPACE` worker could write a read-only workspace and reach above + the containment root.** The `O_PATH` fd the parent pins on the declared subtree was passed + as `argv[4]`, stayed open for the child's whole life, and was visible to the untrusted + implementation via `sys.argv`. It cannot be `fchdir`'d, but it is a valid `dirfd` for + `openat` — and it referenced the *original* mount, where `MS_RDONLY`/`nosuid`/`nodev`/ + `noexec` were never set, so the read-only tier and the containment-root rule were both + defeated by an inherited descriptor rather than by any weakness in the mount. No syscall + filter could have fixed it: seccomp cannot tell where a `dirfd` points. The fd is now closed + after `bind_workspace` and **before any untrusted byte runs**, the closure is read back with + `fstat` (a descriptor believed closed but still open fails the worker closed), and + `allowed_fds` no longer lists it. +- A `WORKSPACE` dispatch handed **no** subtree is **refused** (`IsolationError`), never + downgraded to the write-less `PURE` jail under a profile name that promises otherwise. +- The **grant does not choose the blast radius.** For a `workspace_write` organ the root is + an operator-supplied deployment fact (`executor.generated_code_effect(workspace_root=…)`, + default `None` = concede nothing); a capability caveat may only name a subtree *beneath* + it, and an absolute path, a `..` component, or a symlink leaving the root are refused. +- Having an executor is **not** being auto-promotable. `promotion.SIGNER_POLICY` still + requires a human attestation for `workspace_write`, and `anchors.SIGNABLE_TIERS` still + excludes it, so the Reckoner cannot promote one on its own. **What changed, and what an operator may now rely on.** The workspace bind-mount (`workspace_bind_mount`) used to be on this list. It is now REAL and enforced for the `WORKSPACE` profile: one caller-declared host subtree is `MS_BIND`-mounted at `/workspace` -inside the worker's mount namespace before the `chroot`, always `nosuid`+`nodev`+`noexec` +inside the worker's mount namespace before the root pivot, always `nosuid`+`nodev`+`noexec` (plus `MS_RDONLY` for a read-only mount), with the mounted inode re-verified against an `O_PATH` fd the parent pinned — so the source cannot be swapped between the containment -check and the mount — and the posture read back from `statvfs`. Three things bound it: +check and the mount — the posture read back from `statvfs`, and that pin fd **closed before +any untrusted byte runs** (wave S0). Three things bound it: - A `WORKSPACE` dispatch handed **no** subtree is **refused** (`IsolationError`), never downgraded to the write-less `PURE` jail under a profile name that promises otherwise. @@ -354,6 +363,10 @@ check and the mount — and the posture read back from `statvfs`. Three things b requires a human attestation for `workspace_write`, and `anchors.SIGNABLE_TIERS` still excludes it, so the Reckoner cannot promote one on its own. +And the `workspace_write` TIER itself is still not mapped in `executor.TIER_PROFILES`. It was +withheld because the jail was escapable; S0 closed that, so what remains is a deliberate +sequencing decision rather than a defect — see `executor.py`'s table comment. + ## Automated guardrails - `tests/architecture/test_import_boundaries.py` fails the build if the trusted diff --git a/decima/workers/execution.py b/decima/workers/execution.py index a6cbae00..cbb2a5f9 100644 --- a/decima/workers/execution.py +++ b/decima/workers/execution.py @@ -516,7 +516,7 @@ def _merge_limits(limits: dict[str, int] | None) -> dict[str, int]: # A mandatory failure → {"fatal": ...} on the manifest pipe and exit 97. Pure stdlib. # --------------------------------------------------------------------------- _BOOTSTRAP = r""" -import ctypes, fcntl, json, os, resource, stat, sys +import ctypes, fcntl, json, os, platform, resource, stat, sys cfg_fd, manifest_fd, result_fd = (int(a) for a in sys.argv[1:4]) # -1 unless the parent pinned a workspace subtree open for us (see bind_workspace). @@ -773,9 +773,83 @@ def apply_namespaces(): if cfg.get("workspace") is not None and not ws.get("engaged"): report["detail"] = "workspace bind refused: %s" % ws.get("detail") return report - if libc.chroot(scratch.encode()) != 0: - report["detail"] = "chroot failed (errno %d)" % ctypes.get_errno() + # -- PIVOT_ROOT, NOT CHROOT (wave S0) ------------------------------------------- + # `chroot` was an escapable jail and this is the whole reason S0 exists. chroot() + # does not move the caller's cwd, and a task that is root in its own user namespace + # holds CAP_SYS_CHROOT unconditionally — so `mkdir e; chroot e; chdir ../../..; + # chroot .` re-rooted on the HOST filesystem. Verified from a PURE worker before this + # change: read /etc/passwd, listed host /, wrote a host file, effect SUCCEEDED. + # + # The fix is not to forbid the second chroot; it is to make it POINT NOWHERE. + # pivot_root moves the process's root to the scratch mount and puts the old root at a + # known path, which we then detach: after `umount2(MNT_DETACH)` the host tree is no + # longer reachable from this mount namespace AT ALL. A hostile effect may still call + # chroot as often as it likes — every path it can name resolves inside the jail, + # because the host mounts are gone rather than merely stepped over. That is a + # structural containment property rather than a capability check, which is what makes + # it worth the extra syscalls: it survives a regained capability and it does not + # depend on the seccomp filter (absent on x86_64) denying anything. + # + # Three preconditions, all already satisfied here, which is why the call sits exactly + # at this point: we are in our own mount namespace (CLONE_NEWNS above), the tree is + # MS_REC|MS_PRIVATE (so nothing propagates to the host mount table), and the workspace + # bind has already happened while the host-side source path was still resolvable. + # pivot_root additionally requires new_root to BE a mount point and to differ from the + # current root's mount, hence the bind of scratch onto itself first. + MS_BIND = 0x1000 + MNT_DETACH = 2 + # No glibc wrapper for pivot_root on every libc we support, so it goes through + # syscall(2). The number is per-arch: an ARCH TABLE, never a bare constant — a wrong + # number is a silently different syscall. + _PIVOT_ROOT_NR = {"x86_64": 155, "aarch64": 41, "armv7l": 218, "s390x": 217} + pivot_nr = _PIVOT_ROOT_NR.get(platform.machine()) + if pivot_nr is None: + report["detail"] = ( + "no pivot_root syscall number known for arch %r, and chroot alone is an " + "escapable jail (S0) — refusing rather than running with weaker containment " + "than the profile promises" % platform.machine() + ) + return report + if libc.mount(scratch.encode(), scratch.encode(), None, MS_BIND | MS_REC, None) != 0: + report["detail"] = "bind of scratch onto itself failed (errno %d)" % ( + ctypes.get_errno() + ) + return report + old_root = os.path.join(scratch, ".decima-oldroot") + try: + os.mkdir(old_root, 0o700) + except FileExistsError: + pass + except OSError as e: + report["detail"] = "oldroot mkdir failed: %s" % e + return report + if libc.syscall(pivot_nr, scratch.encode(), old_root.encode()) != 0: + report["detail"] = "pivot_root failed (errno %d)" % ctypes.get_errno() + return report + os.chdir("/") + # DETACH, then verify. An undetached old root would leave the whole host tree one + # `chdir` away, so this is not cleanup — it is the containment step, and a failure + # here fails the worker closed rather than logging a warning. + if libc.umount2(b"/.decima-oldroot", MNT_DETACH) != 0: + report["detail"] = "old-root detach failed (errno %d)" % ctypes.get_errno() + return report + try: + os.rmdir("/.decima-oldroot") + except OSError: + # Cosmetic only: the mount is already detached, so an empty directory is all + # that can remain. Never fatal — it would fail a contained worker for tidiness. + pass + # Read the property back rather than trusting two return codes: after the pivot the + # jail root and the process root must be the SAME inode, which is false for a chroot + # that was stepped out of and false if the pivot silently did not take. + try: + if os.stat("/").st_ino != os.stat(".").st_ino and not ws.get("engaged"): + report["detail"] = "post-pivot root read-back mismatch" + return report + except OSError as e: + report["detail"] = "post-pivot root read-back failed: %s" % e return report + report["pivoted"] = True # A bound worker's cwd IS its workspace: that is what makes the bind load-bearing # rather than decorative — ordinary relative-path writes land on the host subtree. os.chdir(ws["jail_path"] if ws.get("engaged") else "/") @@ -925,6 +999,40 @@ class sock_fprog(ctypes.Structure): # -- BEST-EFFORT seccomp filter, installed LAST so the manifest reports the truth --- manifest["seccomp"] = install_seccomp() +# -- CLOSE THE PINNING FD BEFORE THE UNTRUSTED CODE RUNS (wave S0) ----------- +# `ws_fd` is the O_PATH handle the PARENT opened on the declared subtree, and it is what made +# the swap detector above a real identity check (stat(target) vs fstat(ws_fd)). Its work is +# finished the moment the bind is verified — and leaving it open was escape B: +# +# * it is inherited by everything below, including the untrusted implementation; +# * its NUMBER is visible, because `sys.argv[4]` is readable from inside the effect; +# * it cannot be `fchdir`'d (O_PATH), but it IS a valid `openat` dirfd against the +# ORIGINAL mount — so `openat(ws_fd, "../..")` reached above the operator's containment +# root, and writes through it succeeded against a subtree mounted MS_RDONLY, whose +# read-only posture had read back correctly. +# +# No syscall filter can fix that: seccomp cannot tell where a dirfd points. The fix is that +# the descriptor stops existing before any untrusted byte runs, and `allowed_fds` stops +# listing it so the fd-closure audit below re-verifies exactly that. +if ws_fd >= 0: + try: + os.close(ws_fd) + except OSError as e: + fatal("workspace pin fd close failed: %s" % e) + allowed_fds.discard(ws_fd) + manifest["workspace_bind"]["pin_fd_closed"] = True + # Read it back: an fd we believe is closed but is not would be the whole escape again, + # so this is verified rather than assumed — the same discipline as every other layer. + try: + os.fstat(ws_fd) + fatal("workspace pin fd still open after close") + except OSError: + pass +# Note there is deliberately no second /proc/self/fd sweep here: after the pivot there is no +# /proc in the jail (nothing mounts one), which is itself part of the containment. The fd +# audit ran earlier, against `allowed_fds`, while /proc was still readable — and the fstat +# read-back above is what proves this particular descriptor is gone. + # -- hand off the honest manifest BEFORE running the effect ------------------ os.write(manifest_fd, json.dumps(manifest).encode()) os.close(manifest_fd) diff --git a/docs/architecture/worker-containment.md b/docs/architecture/worker-containment.md index 06a4c1a2..be2c07e3 100644 --- a/docs/architecture/worker-containment.md +++ b/docs/architecture/worker-containment.md @@ -122,19 +122,23 @@ from inside, and two of the rows above can be. Both are reproduced against a rea language. They are recorded here so the table is not read as a containment guarantee it does not deliver. -- **Filesystem isolation — the `chroot` is escapable, on every arch and every profile.** - `chroot()` does not move the cwd, and the worker holds `CAP_SYS_CHROOT` over the user - namespace it was placed in, so a second `chroot()` into a subdirectory plus a `..` walk - re-roots it at the real filesystem root: host files readable *and writable*, as the - parent's real uid. The three adversarial tests in this row assert a genuine refusal on a - genuine path — but none of them attempts to *leave*, so the property a reader infers is - not the property proven. The seccomp filter does not help: `chroot` is not on its denylist. -- **Workspace bind-mount — the pinned `O_PATH` fd outlives the mount setup.** It is passed as +- **CLOSED in wave S0 — filesystem isolation used `chroot`, which was escapable on every arch + and every profile.** `chroot()` does not move the cwd, and the worker holds `CAP_SYS_CHROOT` + over the user namespace it was placed in, so a second `chroot()` into a subdirectory plus a + `..` walk re-rooted it at the real filesystem root: host files readable *and writable*, as + the parent's real uid. The jail now performs `pivot_root` and detaches the old root + (`umount2(MNT_DETACH)`), so the host tree is absent from the worker's mount namespace rather + than merely stepped over — the hostile sequence still runs and resolves inside the jail. The + three adversarial tests in this row assert a genuine refusal on a genuine path but none of + them attempts to *leave*; `tests/adversarial/test_jail_escape.py` is the suite that does, + and it exists because that gap is what let this ship. +- **CLOSED in wave S0 — the pinned `O_PATH` fd outlived the mount setup.** It was passed as `argv[4]`, reachable from the untrusted implementation via `sys.argv`, and usable as an - `openat` `dirfd` against the *original* mount — so a read-only workspace is writable - through the fd, and `openat(fd, "../..")` reaches above the operator's containment root. - The `statvfs` posture read-back in this row is honest and correct; it is simply not the - only path to those bytes. + `openat` `dirfd` against the *original* mount — so a read-only workspace was writable + through the fd, and `openat(fd, "../..")` reached above the operator's containment root. The + `statvfs` posture read-back in this row was honest and correct; it was simply not the only + path to those bytes. The fd is now closed before any untrusted byte runs, with the closure + read back via `fstat`. ### Authority-seam gates (before any child spawns) diff --git a/docs/design/syscall-filtering.md b/docs/design/syscall-filtering.md index 474903e7..0fa9ee1c 100644 --- a/docs/design/syscall-filtering.md +++ b/docs/design/syscall-filtering.md @@ -557,7 +557,7 @@ lane** but is sequenced first because the rest is not worth doing before it. | Phase | Scope | Exit criterion | |---|---|---| -| **S0 — close the escapes** *(prerequisite, separate wave)* | Replace `chroot` with `pivot_root` into the scratch mount, or drop `CAP_SYS_CHROOT` after the jail is built. Close `ws_fd` before the implementation is exec'd and remove it from `allowed_fds` at that point. | A hostile effect that attempts §4.3 and §4.4 fails, asserted by new adversarial tests **that go red when the fix is reverted**. The three tests in §4.3 keep passing, now for the right reason. | +| **S0 — close the escapes** ✅ **LANDED** *(prerequisite, separate wave)* | Replace `chroot` with `pivot_root` into the scratch mount, or drop `CAP_SYS_CHROOT` after the jail is built. Close `ws_fd` before the implementation is exec'd and remove it from `allowed_fds` at that point. | A hostile effect that attempts §4.3 and §4.4 fails, asserted by new adversarial tests **that go red when the fix is reverted**. The three tests in §4.3 keep passing, now for the right reason. | | **S1 — shadow mode** | Move from `PR_SET_SECCOMP` to `seccomp(2)` via `ctypes`. Add a log-only filter (`SECCOMP_RET_LOG` / `SECCOMP_FILTER_FLAG_LOG`) that denies nothing and records the syscalls a worker actually makes. Run it over the whole adversarial + Nona suite and over generated organs. | An empirically derived allowlist per arch, checked in as generated data, with the derivation script and the corpus it was derived from. Zero behavior change to any worker. | | **S2 — x86_64 tables** | Generate per-arch tables from kernel headers (`AUDIT_ARCH_X86_64`, x86_64 numbers, legacy duplicates, x32 handling). Extend the BPF arch guard to accept a *set* of arches, keeping `KILL` for anything unlisted. | The existing 32-entry denylist engages on x86_64 with `seccomp.engaged=True`; `containment_report` claims the row on both arches; the matrix test asserts the live manifest on whichever arch it runs. | | **S3 — flip to default-deny** | Replace the deny chain with the S1 allowlist plus a deny-tail. Keep `ERRNO(EPERM)` initially, not `KILL`, so a miss is diagnosable. Report `denied_default: True` and the table digest in the manifest. | The full suite is green on both arches with default-deny; a deliberately removed allowlist entry makes a specific test go red; a hostile effect calling `chroot`/`clone`/`socket` gets `EPERM`. | diff --git a/tests/adversarial/test_jail_escape.py b/tests/adversarial/test_jail_escape.py new file mode 100644 index 00000000..72a8c6fa --- /dev/null +++ b/tests/adversarial/test_jail_escape.py @@ -0,0 +1,178 @@ +"""Wave S0: the two escapes that were real, as permanent regression tests. + +Every other test in this directory asks whether a worker can reach THROUGH the jail — read an +absolute host path, resolve a symlink out, open the Weft. All of them passed while the jail was +escapable, because none of them tried to LEAVE. That gap is the lesson this file exists to +keep: a containment test that only probes forbidden paths measures the path list, not the +boundary. + +ESCAPE A — the classic double-`chroot`. `chroot()` does not move the caller's cwd, and a task +that is root in its own user namespace holds `CAP_SYS_CHROOT` unconditionally. So +`mkdir e; chroot e; chdir ../../..; chroot .` left the cwd above the new root and walked out. +Measured before the fix, from a PURE worker, effect reporting SUCCEEDED: + + listdir('/') ['bin','boot','dev','etc','home','lib', ...] + open('/etc/passwd') 'root:x:0:0:root:/root:/bin/bash...' + +The fix is NOT a prohibition on the second chroot — it is `pivot_root` plus +`umount2(MNT_DETACH)`, after which the host tree is not present in the worker's mount +namespace at all. So the hostile sequence still RUNS, and every path it can name resolves +inside the jail. That distinction is the point of `test_the_escape_sequence_still_runs_and_ +lands_nowhere`: a test asserting the syscall fails would pin the wrong mechanism and would go +green for the wrong reason if someone swapped the fix for a seccomp deny (which is absent on +x86_64 anyway). + +ESCAPE B — the inherited `O_PATH` pin fd. The parent opens an `O_PATH|O_DIRECTORY` fd on the +declared subtree so the child can prove the mounted inode is the one the parent verified +(`stat` vs `fstat`). It was then left open across the effect, and its number is readable from +`sys.argv[4]`. It cannot be `fchdir`'d, but it is a valid `openat` dirfd against the ORIGINAL +mount — so it defeated a read-only workspace and reached above the containment root. No +syscall filter can fix that: seccomp cannot tell where a dirfd points. The fd is now closed +before any untrusted byte runs. +""" + +from __future__ import annotations + +import os +import pathlib + +from tests.adversarial.test_worker_isolation import SUCCEEDED, _run + +# ── escape A ───────────────────────────────────────────────────────────────── +_DOUBLE_CHROOT = ( + "def go(x):\n" + " import os\n" + " out = {}\n" + " try:\n" + " os.mkdir('esc')\n" + " os.chroot('esc')\n" + " except OSError as e:\n" + " out['chroot1'] = type(e).__name__\n" + " return out\n" + " out['chroot1'] = 'ok'\n" + " try:\n" + " for _ in range(60):\n" + " os.chdir('..')\n" + " os.chroot('.')\n" + " out['chroot2'] = 'ok'\n" + " except OSError as e:\n" + " out['chroot2'] = type(e).__name__\n" + " try:\n" + " with open('/etc/passwd') as f:\n" + " out['READ_ETC_PASSWD'] = f.read(24)\n" + " except OSError as e:\n" + " out['passwd'] = type(e).__name__\n" + " try:\n" + " out['root_listing'] = sorted(os.listdir('/'))\n" + " except OSError as e:\n" + " out['listdir'] = type(e).__name__\n" + " return out\n" +) + + +def _out(source: str, **kw) -> dict: + resp = _run(source, args={"x": 1}, **kw) + assert resp.status == SUCCEEDED, f"worker did not complete: {resp.receipt_data}" + return resp.receipt_data["output"] + + +def test_the_double_chroot_escape_reaches_no_host_file() -> None: + """The headline property. Before S0 this returned the first 24 bytes of the host + `/etc/passwd`.""" + out = _out(_DOUBLE_CHROOT) + + assert "READ_ETC_PASSWD" not in out, f"ESCAPED — read a host file: {out}" + assert out.get("passwd") == "FileNotFoundError" + + +def test_the_escape_sequence_still_runs_and_lands_nowhere() -> None: + """Pins the RIGHT mechanism. `pivot_root` + detach does not forbid the syscalls; it makes + them point nowhere. Both chroots still succeed and the walk still happens — the worker + simply arrives back inside its own jail, because the host mounts are gone rather than + stepped over. + + If someone replaced this fix with a seccomp deny on `chroot`, this test would fail — which + is the intent. A capability check can be regained and the filter is absent on x86_64; + "the host tree is not in this mount namespace" cannot be undone from inside.""" + out = _out(_DOUBLE_CHROOT) + + assert out["chroot1"] == "ok", "the fix must not depend on forbidding chroot" + assert out["chroot2"] == "ok" + listing = out.get("root_listing") + assert listing is not None + # What it sees after re-rooting is its OWN jail — the directory it just made. + assert listing == ["esc"], f"root listing is not the jail: {listing}" + + +def test_a_host_path_is_not_writable_after_the_escape_attempt() -> None: + """The escape's worst outcome was a WRITE. The Weft, the keystore and `~/.ssh` all live on + the filesystem the worker was reaching, so the write path gets its own assertion rather + than being inferred from the read one.""" + target = str(pathlib.Path.home() / ".decima-escape-canary") + src = ( + "def go(target):\n" + " import os\n" + " try:\n" + " os.mkdir('esc'); os.chroot('esc')\n" + " for _ in range(60):\n" + " os.chdir('..')\n" + " os.chroot('.')\n" + " except OSError as e:\n" + " return {'setup': type(e).__name__}\n" + " try:\n" + " with open(target, 'w') as f:\n" + " f.write('escaped')\n" + " return {'WROTE': target}\n" + " except OSError as e:\n" + " return {'blocked': type(e).__name__}\n" + ) + resp = _run(src, args={"target": target}) + + assert resp.status == SUCCEEDED + out = resp.receipt_data["output"] + assert "WROTE" not in out, f"ESCAPED — wrote a host file: {out}" + assert not os.path.exists(target), "a host file appeared — escape!" + + +def test_the_manifest_records_the_pivot_rather_than_claiming_a_chroot() -> None: + """The containment claim has to be derived from what happened, not from what the profile + asked for — the same discipline as every other read-back in the bootstrap.""" + src = "def go(x):\n return {'ok': True}\n" + resp = _run(src, args={"x": 1}) + + iso = resp.diagnostics["isolation"]["namespaces"] + assert iso["engaged"] is True + assert iso["fs_jail"] is True + assert iso.get("pivoted") is True, "the jail must report the pivot it actually performed" + + +def test_an_unknown_architecture_refuses_rather_than_falling_back_to_chroot() -> None: + """The arch table is the one place this fix could silently degrade. `pivot_root` goes + through `syscall(2)` with a per-arch number, and a host we have no number for must REFUSE + — falling back to `chroot` would restore the escape while every containment claim in the + manifest still read as engaged. + + Asserted against the source rather than by faking an arch, because the branch is inside + the bootstrap string that runs in the child: what matters is that the fallback does not + exist to be reached.""" + from decima.workers import execution + + src = execution._BOOTSTRAP + assert "_PIVOT_ROOT_NR" in src + assert "no pivot_root syscall number known for arch" in src + # There is exactly one chroot call left in the bootstrap — inside the hostile-sequence + # comment is fine, but no `libc.chroot(` may remain as the jail mechanism. + assert "libc.chroot(" not in src, "chroot must not remain as a jail mechanism" + + +# ── escape B ───────────────────────────────────────────────────────────────── +def test_the_workspace_pin_fd_is_closed_before_untrusted_code_runs() -> None: + """Escape B, asserted where it is observable without a workspace: the fd is closed and the + closure is read back with `fstat`, so a descriptor we believe is gone but is not fails the + worker closed instead of silently handing the effect a dirfd on the original mount.""" + from decima.workers import execution + + src = execution._BOOTSTRAP + assert "os.close(ws_fd)" in src + assert "workspace pin fd still open after close" in src, "the closure must be read back" + assert "allowed_fds.discard(ws_fd)" in src From af911d0753e2fcda6df67b2370f0a89f3694ad95 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 17:33:28 +0000 Subject: [PATCH 2/2] Review fixes: a read-back that actually reads something back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from re-reading my own diff before merge. THE POST-PIVOT READ-BACK WAS VACUOUS — the exact trap this branch's tests exist to catch, in the code doing the catching. It compared `stat("/")` with `stat(".")` immediately after `os.chdir("/")`, so the two were the same by construction whether or not the pivot took, and a trailing `and not ws.get("engaged")` disabled it entirely for bound workers. It now compares `stat("/")` against the scratch inode captured BEFORE the pivot, which is a fact the pivot has to have changed. Verified the chain fails closed by skipping the pivot syscall while leaving its success path: the old-root detach then fails ENOENT and the worker refuses to run at all, so a silently absent pivot cannot produce a running worker in a weaker jail. Also dropped the `platform` import I added to the bootstrap in favour of `os.uname().machine`, which `install_seccomp` a few lines below already uses for the same purpose. One arch accessor, not two. --- decima/workers/execution.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/decima/workers/execution.py b/decima/workers/execution.py index cbb2a5f9..08fd0998 100644 --- a/decima/workers/execution.py +++ b/decima/workers/execution.py @@ -516,7 +516,7 @@ def _merge_limits(limits: dict[str, int] | None) -> dict[str, int]: # A mandatory failure → {"fatal": ...} on the manifest pipe and exit 97. Pure stdlib. # --------------------------------------------------------------------------- _BOOTSTRAP = r""" -import ctypes, fcntl, json, os, platform, resource, stat, sys +import ctypes, fcntl, json, os, resource, stat, sys cfg_fd, manifest_fd, result_fd = (int(a) for a in sys.argv[1:4]) # -1 unless the parent pinned a workspace subtree open for us (see bind_workspace). @@ -802,14 +802,19 @@ def apply_namespaces(): # syscall(2). The number is per-arch: an ARCH TABLE, never a bare constant — a wrong # number is a silently different syscall. _PIVOT_ROOT_NR = {"x86_64": 155, "aarch64": 41, "armv7l": 218, "s390x": 217} - pivot_nr = _PIVOT_ROOT_NR.get(platform.machine()) + pivot_nr = _PIVOT_ROOT_NR.get(os.uname().machine) if pivot_nr is None: report["detail"] = ( "no pivot_root syscall number known for arch %r, and chroot alone is an " "escapable jail (S0) — refusing rather than running with weaker containment " - "than the profile promises" % platform.machine() + "than the profile promises" % os.uname().machine ) return report + try: + scratch_ino = os.stat(scratch).st_ino + except OSError as e: + report["detail"] = "scratch stat failed before pivot: %s" % e + return report if libc.mount(scratch.encode(), scratch.encode(), None, MS_BIND | MS_REC, None) != 0: report["detail"] = "bind of scratch onto itself failed (errno %d)" % ( ctypes.get_errno() @@ -839,12 +844,13 @@ def apply_namespaces(): # Cosmetic only: the mount is already detached, so an empty directory is all # that can remain. Never fatal — it would fail a contained worker for tidiness. pass - # Read the property back rather than trusting two return codes: after the pivot the - # jail root and the process root must be the SAME inode, which is false for a chroot - # that was stepped out of and false if the pivot silently did not take. + # Read the property back rather than trusting two return codes: `/` must now BE the + # scratch directory, compared against the inode captured before the pivot. Note what + # this is not — comparing `stat("/")` with `stat(".")` would be vacuous, because the + # chdir above makes them the same by construction whether or not the pivot took. try: - if os.stat("/").st_ino != os.stat(".").st_ino and not ws.get("engaged"): - report["detail"] = "post-pivot root read-back mismatch" + if os.stat("/").st_ino != scratch_ino: + report["detail"] = "post-pivot root is not the scratch jail (read-back)" return report except OSError as e: report["detail"] = "post-pivot root read-back failed: %s" % e