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..08fd0998 100644 --- a/decima/workers/execution.py +++ b/decima/workers/execution.py @@ -773,9 +773,89 @@ 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(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" % 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() + ) + 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: `/` 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 != 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 + 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 +1005,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