Skip to content

S0: the jail is a boundary now — pivot_root, and the pin fd closed - #21

Merged
Judge-M merged 2 commits into
mainfrom
claude/s0-close-jail-escapes
Jul 27, 2026
Merged

Judge-M merged 2 commits into
mainfrom
claude/s0-close-jail-escapes

Conversation

@Judge-M

@Judge-M Judge-M commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Both escapes from #20 are closed. Verified against real run_worker before and after, not reasoned about.

Escape A — the 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:

mkdir('esc'); chroot('esc'); chdir('../'*60); chroot('.')

Before — from a PURE worker, effect reporting SUCCEEDED:

{'READ_ETC_PASSWD': 'root:x:0:0:root:/root:/b',
 'root_listing': ['bin', 'boot', 'dev', 'etc', 'home', 'lib', ...]}

After — same code, same profile:

{'passwd_blocked': 'FileNotFoundError', 'root_listing': ['esc']}

The walk still happens; it just lands back inside the jail.

The fix is structural, not a prohibition. pivot_root + umount2(MNT_DETACH) means the host tree is not present in the worker's mount namespace at all afterwards. A hostile effect may call chroot as often as it likes and every path it can name resolves inside the jail. That matters because the alternative — denying chroot — depends on a capability check that can be regained and on a seccomp filter that is absent on x86_64. test_the_escape_sequence_still_runs_and_lands_nowhere deliberately asserts both chroot calls still succeed, so a future change that swapped the mechanism for a syscall deny would fail the suite rather than quietly pass it.

_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 have fixed this — 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.

The test suite is the point, not just the fix

tests/adversarial/test_jail_escape.py is new, and why it is new is the lesson. Every existing containment test asks whether a worker can reach through the jail — an absolute host path, a symlink out, the Weft. All of them passed while the jail was escapable, because none of them tried to leave. A containment test that only probes forbidden paths measures the path list, not the boundary.

Reverting pivot_root turns five of its six tests red — and the reverted run wrote the host canary file again (~/.decima-escape-canary, root-owned), which is how I know the write assertion isn't vacuous. I removed it afterwards.

Docs corrected, not appended to

Both escapes move from open residuals to CLOSED in SECURITY.md and docs/architecture/worker-containment.md; S0 is marked landed in docs/design/syscall-filtering.md.

Worth noting: while editing SECURITY.md I deleted a paragraph by accident, and the two doc-coupling guards in the adversarial lane caught it — they assert the residual record names what actually closed each gap (MS_BIND, workspace_bind_mount). They earned their keep, and the restored paragraph now also says the pin fd is closed.

Gate

lane result
ruff format --check . / ruff check . clean
mypy (configured lane) no issues in 229 source files
pytest -q 1248 passed / 25 skipped (1273 collected)
tests/adversarial 75 passed, incl. the 6 new escape tests
Playwright 14/14, no evidence churn
release-metadata drift guard OK — matches README

heartbeat/ and protocol/fixtures/ untouched.

Still yours to decide

workspace_write stays unmapped in TIER_PROFILES. The reason it was withheld is now gone, so what remains is a sequencing decision rather than a defect. Flipping it is one line plus deleting test_the_workspace_seam_is_built_but_the_tier_is_still_withheld. I did not do it in this PR because withholding it was your call to reverse, and because a containment fix and a capability expansion are worth reviewing separately.

Two things S0 does not close, both already scoped: the syscall filter is still absent on x86_64 and denylist-only on aarch64 (docs/design/syscall-filtering.md phases S1+ — and note that porting the denylist would turn the matrix row green while changing containment by nothing), and there is still no egress mediation, so network remains NOT EXECUTABLE.


Generated by Claude Code

claude added 2 commits July 27, 2026 12:51
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.
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.
@Judge-M
Judge-M marked this pull request as ready for review July 27, 2026 17:38
@Judge-M
Judge-M merged commit 4cec85a into main Jul 27, 2026
6 checks passed
@Judge-M
Judge-M deleted the claude/s0-close-jail-escapes branch August 5, 2026 10:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants