Confine the guest process with seccomp, no-new-privs, and rlimits#268
Merged
Conversation
Layers kernel confinement onto the process boundary so escapes that defeat the Python guards are still stopped by the kernel. Before any guest code runs, the child process: - sets PR_SET_NO_NEW_PRIVS (also required to install seccomp unprivileged), - installs a seccomp deny-list filter that kills the process (SIGSYS) if it issues an unambiguously dangerous syscall -- execve/execveat, ptrace, mount/umount2/pivot_root/chroot, setns/unshare, kexec, module load, bpf, perf_event_open, process_vm_readv/writev, keyring, reboot, swap -- and refuses to run under a non-x86-64 arch so the ABI cannot be swapped to reach a denied call by a different number, - applies rlimits (core=0 always; address space from mem_bytes). no_new_privs makes the filter irremovable and seccomp is inherited across fork/clone, so children the guest spawns stay confined. The filter is a deny-list (a robust attack-surface reduction), not a complete allow-list; it is x86-64-Linux specific and skips elsewhere (hardened mode can require it via require_seccomp). A guest that defeats the import guard and reaches the *real* os.execv is now killed by the kernel (returncode -SIGSYS), covered by an end-to-end test. A unit test guards the BPF jump-offset arithmetic that, if wrong, silently produces an EINVAL-rejected filter (no confinement). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDSofWX5HwawsrwbN2nSXR
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.
Why
#267 made
backend="process"a real address-space boundary, but a guest that runs native code (ctypes, a hostile wheel) or defeats the Python import guard can still issue raw syscalls from inside the guest process. This PR adds the kernel half of the boundary so those syscalls are stopped by the kernel, not just by Python-level guards. (Builds on #267, now merged.)What
Before any guest code runs, the child process (
runtime/child.py) callsapply_confinement()from the newruntime/confine.py:PR_SET_NO_NEW_PRIVS— the guest can never gain privileges, and (unprivileged) can install a seccomp filter. It also makes the filter irremovable.SIGSYSif it issues an unambiguously dangerous syscall:execve/execveat,ptrace,mount/umount2/pivot_root/chroot,setns/unshare,kexec_*,init_module/finit_module/delete_module,bpf,perf_event_open,process_vm_readv/writev, keyring calls,reboot,swapon/off. The filter first checks the audit arch and refuses to run under a non-x86-64 ABI, so the syscall ABI can't be swapped to reach a denied call by a different number. seccomp filters are inherited acrossfork/clone, so anything the guest spawns stays confined.RLIMIT_CORE=0always (no memory-leaking core dumps);RLIMIT_ASfrom the sandbox'smem_bytes;RLIMIT_CPUavailable as a knob.This is a deny-list — a strong, robust reduction of the syscall attack surface that still lets a normal CPython interpreter run — not a proof that only a fixed allow-list of syscalls is reachable. It is x86-64-Linux specific and skips (recorded in the report) elsewhere. Hardened rollout mode passes
require_seccomp=True, turning an unsupported platform into a failure rather than a silent skip.The parent (
ProcessSandbox) passes confinement config in the bootstrap frame, records the child's confinement report (wait_confined()), and surfaces an unexpected guest death (e.g. a seccomp kill) as aSandboxErrorto any waiter instead of hanging.The boundary, demonstrated
test_real_execve_escape_is_killed_by_the_kernel: the guest walks the object graph to recover the real__import__, imports the realos(defeating the Python guard), and callsos.execv— the kernel kills the guest, and the handle reportsreturncode == -signal.SIGSYS. Compare with #267, where that escape merely ran in a separate process; here the syscall itself is denied.A real bug this caught
The seccomp BPF builder initially double-counted the instruction index, producing negative jump offsets that wrapped to 255 in the
u8field. The kernel silently rejected the filter withEINVAL— i.e. no confinement, while a naive smoke test looked like it passed.test_filter_program_has_only_valid_forward_jumpsis a regression guard for exactly this.Testing
tests/test_process_confinement.py(7 tests): filter-jump validity, seccomp install + normal-syscall survival,SIGSYSon a denied syscall, core-dump disable, confinement-by-default, the real-execve kill, and a confine-disabled path. Kill tests run in a forked child; seccomp-dependent tests skip off x86-64 Linux.Follow-ups (later in this series)
Landlock FS enforcement from policy, wiring the eBPF policy maps end-to-end, and reconciling the threat model once the boundary is enforced.
🤖 Generated with Claude Code
https://claude.ai/code/session_01DDSofWX5HwawsrwbN2nSXR
Generated by Claude Code