Repository navigation
S0: the jail is a boundary now — pivot_root, and the pin fd closed - #21
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Both escapes from #20 are closed. Verified against real
run_workerbefore and after, not reasoned about.Escape A — the double
chrootchroot()does not move the caller's cwd, and a task that is root in its own user namespace holdsCAP_SYS_CHROOTunconditionally:Before — from a
PUREworker, effect reportingSUCCEEDED:After — same code, same profile:
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 callchrootas often as it likes and every path it can name resolves inside the jail. That matters because the alternative — denyingchroot— 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_nowheredeliberately asserts bothchrootcalls 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_NRis an arch table and a host with no known number refuses rather than falling back tochroot. 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_PATHpin fdThe parent opens it on the declared subtree so the child can prove the mounted inode is the one the parent verified (
statvsfstat) — and that work is finished the moment the bind is verified. It was then left open across the effect, with its number readable fromsys.argv[4]. It cannot befchdir'd, but it is a validopenatdirfd 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.pyis 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_rootturns 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.mdanddocs/architecture/worker-containment.md; S0 is marked landed indocs/design/syscall-filtering.md.Worth noting: while editing
SECURITY.mdI 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
ruff format --check ./ruff check .mypy(configured lane)pytest -qtests/adversarialheartbeat/andprotocol/fixtures/untouched.Still yours to decide
workspace_writestays unmapped inTIER_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 deletingtest_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.mdphases 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, sonetworkremains NOT EXECUTABLE.Generated by Claude Code