Skip to content

Enforce filesystem policy on guest processes with Landlock#269

Merged
seanwevans merged 1 commit into
mainfrom
claude/landlock-fs-enforcement-v2
Jul 20, 2026
Merged

Enforce filesystem policy on guest processes with Landlock#269
seanwevans merged 1 commit into
mainfrom
claude/landlock-fs-enforcement-v2

Conversation

@seanwevans

Copy link
Copy Markdown
Owner

Why

#268 stops the guest from making dangerous syscalls, but the sandbox's filesystem policy was still enforced only by the Python open/import guards — which a guest that reaches the real open/openat (native code, or the object-graph escape) bypasses. This PR turns the policy's filesystem allow-list into a real kernel boundary using Landlock. (Builds on #267 and #268, both merged.)

What

New runtime/landlock.py builds and applies a Landlock ruleset to the guest process, wired into confine.apply_confinement():

  • ABI-aware. Landlock's access-right set and struct layout depend on the kernel's ABI version. The module queries it (landlock_create_ruleset(NULL, 0, VERSION)) and masks the handled access rights to it — requesting an unsupported right (e.g. TRUNCATE on ABI < 3) makes ruleset creation fail. landlock_supported() is authoritative.
  • Keeps the interpreter alive. Landlock is default-deny for every right the ruleset handles, so the guest is granted read+execute on the interpreter's runtime paths (stdlib, shared libs, sys.path) in addition to the policy's paths; otherwise it couldn't import anything.
  • Policy translation. Read paths get read; write paths get read+write; everything else the ruleset handles is denied.
  • Ordering. no_new_privs first (required by landlock_restrict_self), then rlimits, then Landlock, then seccomp. Landlock is applied only when the policy actually names paths — a default-deny ruleset with no allow-list would just break the interpreter.

ProcessSandbox extracts read-vs-write paths from the policy and passes them in the bootstrap; the confinement report gains landlock / landlock_rules.

A latent bug this fixes

_extract_fs_tcp (from #267) treated getattr(policy, "allow_fs") as a rule collection — but the legacy Policy exposes allow_fs/allow_tcp as methods, so passing a legacy Policy to a process sandbox raised TypeError: 'method' object is not iterable. Extraction now discriminates RuntimePolicy (rule tuples) from Policy (methods + .fs/.tcp attributes) by type. Covered by test_extract_fs_tcp_handles_legacy_policy_methods.

Testing

  • New tests/test_landlock.py. Unit tests (always run): ABI access-mask correctness, packed path_beneath struct size, support/ABI consistency, and policy read/write extraction incl. the legacy-Policy regression. Kernel-gated: test_process_sandbox_reports_landlock_applied (needs Landlock) and test_landlock_blocks_disallowed_reads_but_allows_permitted (needs Landlock + PYISOLATE_LIVE_LANDLOCK_TESTS=1) — the enforcement test reads an allowed file via the real open and asserts a read outside the allow-list is denied.
  • CI cross-kernel job now runs the process-backend/confinement/Landlock tests, plus the live Landlock test with the env flag set (it runs for real on kernels that support Landlock, e.g. recent Ubuntu images).
  • Full suite: 438 passed, 4 environment-gated skips; all pre-commit hooks pass.

Honesty note

Landlock enforcement is unavailable in the dev container this was written in (landlock_create_rulesetENOSYS), so the live enforcement path is exercised by CI on capable kernels, not here — same pattern as the repo's existing live BPF test. Everything not requiring the kernel is unit-tested directly.

Next

B-4 wires the eBPF policy maps end-to-end (fixing the map-name/schema disconnect), then B-5 reconciles the threat model now that the boundary is real.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DDSofWX5HwawsrwbN2nSXR


Generated by Claude Code

Translates the sandbox's filesystem policy into a kernel Landlock ruleset
applied to the guest process, so a guest that defeats the Python open
guard and reaches the real open/openat can still only touch permitted
paths. Applied after PR_SET_NO_NEW_PRIVS (which landlock_restrict_self
requires) and before the seccomp lockdown.

- runtime/landlock.py: ABI-versioned ruleset construction. Queries the
  kernel Landlock ABI and masks the handled access-rights set to it
  (requesting an unsupported right fails ruleset creation); grants
  read+execute on the interpreter's runtime paths (stdlib, shared libs)
  so the guest can keep importing, read on policy read paths, and
  read+write on policy write paths; everything else the ruleset handles
  is denied. Packed path_beneath struct, O_PATH parent fds.
- confine.apply_confinement now sets no_new_privs first, then rlimits,
  then Landlock (only when the policy names paths -- a default-deny
  ruleset with no allow-list would just break the interpreter), then
  seccomp.
- process_backend extracts read vs write paths from the policy and passes
  them in the bootstrap; the confinement report gains landlock fields.
- Fixes a latent bug in _extract_fs_tcp: the legacy Policy exposes
  allow_fs/allow_tcp as *methods*, so it must be discriminated from
  RuntimePolicy (which exposes rule tuples) by type, not getattr.

Landlock is unavailable on many kernels; it is applied best-effort and
recorded in the report (hardened mode can require it). Enforcement tests
are gated on landlock_supported() plus PYISOLATE_LIVE_LANDLOCK_TESTS,
like the live BPF test; the ABI-masking, struct-packing, and
policy-extraction logic is unit-tested unconditionally. CI runs the live
Landlock test on the cross-kernel matrix.

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