Skip to content

Wire the compiled policy into the eBPF map the LSM program enforces - #270

Merged
seanwevans merged 1 commit into
mainfrom
claude/ebpf-policy-wiring
Jul 20, 2026
Merged

seanwevans merged 1 commit into
mainfrom
claude/ebpf-policy-wiring

Conversation

@seanwevans

Copy link
Copy Markdown
Owner

The bug

The security audit that kicked off this series found a silent contract disconnect in the eBPF path:

  • Userspace (to_bpf_map_entries → _update_bpf_map) wrote maps named policy_fs_allow / policy_net_allow / … — to /sys/fs/bpf/{name} (the wrong path; maps are pinned under /sys/fs/bpf/pyisolate/), with hashed-string keys.
  • The kernel LSM program (syscall_filter.bpf.c) reads a different map, sandbox_policy, keyed by bpf_get_current_cgroup_id().

The two never met. The compiled policy was written to maps no kernel program reads, so "hardened" eBPF FS/net enforcement did nothing — while the threat model claimed kernel-enforced FS/network policy.

The fix

This wires the enforced path end-to-end, keyed the way the kernel actually looks policy up.

  • bpf/contract.py — single source of truth for:
    • the deny-mask bits, kept in lockstep with the PYI_DENY_* defines in the .bpf.c;
    • the little-endian key/value encoding matching struct pyisolate_policy { u32 deny_mask; u32 audit_only; };
    • compile_deny_mask(policy).
    • cgroup_id_for_path() — the cgroup id is the cgroup directory inode, which is exactly what bpf_get_current_cgroup_id() returns, so os.stat(cg_path).st_ino is the correct key.
  • BPFManager.set_sandbox_policy() — writes sandbox_policy at the correct pinned location, keyed by the cgroup id.
  • Supervisor — programs the map on spawn using the sandbox's real cgroup inode (best-effort in dev; fail-closed in hardened).

Granularity — deliberately coarse, and honest about it

deny_mask is a per-cgroup, per-capability-class switch: it can deny a whole class but not express "allow only /srv/data". So:

  • process creation and ptrace/mount/bpf are always denied inside a sandbox cgroup (the sandbox never execs; the broker runs privileged work from a different cgroup);
  • FS / NET are denied as whole classes only when the policy grants nothing in that class.

Per-path filesystem enforcement is delegated to Landlock (#269) and per-destination network to the broker. This PR makes the coarse kernel layer actually connected, rather than claiming per-path eBPF matching it doesn't do. (A real per-path eBPF matcher is a much larger effort and out of scope.)

Tests

tests/test_ebpf_contract.py — all run in userspace (no kernel needed):

  • Contract guards (the important ones): the enforcement map name (sandbox_policy) is defined in the .bpf.c, and the Python deny-mask bits equal the PYI_DENY_* defines parsed from the C source. These would have failed on the pre-fix code and prevent the disconnect from silently returning.
  • Deny-mask derivation (no policy → all classes denied; granting fs/net clears the class bit; process/risky always denied).
  • Key/value byte layout matches the kernel struct; cgroup_id_for_path returns the inode.
  • A spawn programs sandbox_policy with the derived mask.

Kernel enforcement itself runs on a real host as before; the wiring and encoding are what's tested here.

Full suite: 441 passed, 2 environment-gated skips; all pre-commit hooks pass.

Next

B-5 reconciles the threat model / docs now that the boundary (process isolation + seccomp + Landlock + this eBPF wiring) is real, and downgrades the sub-interpreter backend's "DEFENDED against hostile Python" language.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DDSofWX5HwawsrwbN2nSXR


Generated by Claude Code

My audit found a silent contract disconnect: userspace wrote maps named
policy_fs_allow/policy_net_allow/... (to the wrong pinned path, with
hashed-string keys), while the kernel LSM program reads sandbox_policy
keyed by cgroup id. The compiled policy never reached the map the kernel
consults, so 'hardened' eBPF FS/net enforcement did nothing.

This wires the enforced path end to end:

- bpf/contract.py: single source of truth for the deny-mask bits (kept in
  lockstep with the PYI_DENY_* defines in syscall_filter.bpf.c), the
  little-endian key/value encoding matching struct pyisolate_policy, and
  compile_deny_mask(policy). The mask is coarse by construction -- it can
  deny a whole capability class but not express per-path allow-lists, so
  process creation and ptrace/mount/bpf are always denied, and FS/NET are
  denied only when the policy grants nothing in that class. Per-path FS is
  enforced by Landlock and per-destination net by the broker.
- BPFManager.set_sandbox_policy writes sandbox_policy at the correct
  pinned location (/sys/fs/bpf/pyisolate/sandbox_policy), keyed by the
  cgroup id, which equals the cgroup directory inode that
  bpf_get_current_cgroup_id() returns.
- Supervisor programs the map on spawn (best-effort in dev; fail-closed in
  hardened) using the sandbox's real cgroup inode.

Tests assert the enforcement map name and deny-mask bits match the .bpf.c
(guarding against exactly the disconnect that existed), the key/value byte
layout matches the kernel struct, cgroup_id resolves to the inode, and a
spawn programs sandbox_policy with the derived mask. Kernel enforcement
itself is exercised on a real host as before; the wiring/encoding is
tested in userspace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDSofWX5HwawsrwbN2nSXR
@seanwevans
seanwevans merged commit ed2d201 into main Jul 20, 2026
18 of 19 checks passed
@seanwevans
seanwevans deleted the claude/ebpf-policy-wiring branch July 20, 2026 22:40
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