Skip to content

Confine the guest process with seccomp, no-new-privs, and rlimits#268

Merged
seanwevans merged 1 commit into
mainfrom
claude/process-confinement
Jul 20, 2026
Merged

Confine the guest process with seccomp, no-new-privs, and rlimits#268
seanwevans merged 1 commit into
mainfrom
claude/process-confinement

Conversation

@seanwevans

Copy link
Copy Markdown
Owner

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) calls apply_confinement() from the new runtime/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.
  • seccomp deny-list filter — kills the process with SIGSYS if 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 across fork/clone, so anything the guest spawns stays confined.
  • rlimitsRLIMIT_CORE=0 always (no memory-leaking core dumps); RLIMIT_AS from the sandbox's mem_bytes; RLIMIT_CPU available 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 a SandboxError to 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 real os (defeating the Python guard), and calls os.execv — the kernel kills the guest, and the handle reports returncode == -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 u8 field. The kernel silently rejected the filter with EINVAL — i.e. no confinement, while a naive smoke test looked like it passed. test_filter_program_has_only_valid_forward_jumps is a regression guard for exactly this.

Testing

  • New tests/test_process_confinement.py (7 tests): filter-jump validity, seccomp install + normal-syscall survival, SIGSYS on 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.
  • Full suite: 428 passed, 2 environment-gated skips; all pre-commit hooks pass.

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

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
@seanwevans
seanwevans merged commit b3127e3 into main Jul 20, 2026
19 checks passed
@seanwevans
seanwevans deleted the claude/process-confinement branch July 20, 2026 22:19
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