Skip to content

Deny filesystem access by default in the process backend - #287

Merged
seanwevans merged 2 commits into
mainfrom
claude/process-backend-fs-default-deny
Aug 15, 2026
Merged

Deny filesystem access by default in the process backend#287
seanwevans merged 2 commits into
mainfrom
claude/process-backend-fs-default-deny

Conversation

@seanwevans

Copy link
Copy Markdown
Owner

The problem

A process sandbox whose policy names no filesystem paths got no filesystem confinement at all. confine._apply_landlock returned before building a ruleset. Measured on main:

sb = iso.spawn("p", backend="process", allowed_imports=["os"])
sb.exec("post(open('/etc/passwd').read())")      # -> full contents
sb.exec("open('/tmp/owned','w').write('x')")     # -> succeeds

…while the same sandbox's confinement report said seccomp: True, so it looked confined. This contradicts the deny-by-default posture the import allow-list already takes — DEFAULT_ALLOWED_IMPORTS is deliberately empty "so missing import policy fails closed."

Second, less visible bug: that early return happened before require_landlock was consulted. So hardened rollout mode — the mode whose entire purpose is to refuse rather than degrade — silently produced an unconfined guest on a kernel without Landlock.

The change

Always handle the filesystem access class. Landlock is default-deny for every right its ruleset handles, and _runtime_read_paths() already grants the interpreter its stdlib and shared libraries. So the fix is to stop skipping the ruleset, not to build new machinery:

  • guest keeps stdlib, site-packages, shared libraries → still runs
  • home directories, /root, /var, /opt, /srv, and all writes → denied by the kernel
  • missing Landlock is now recorded in skipped, and fatal under hardened mode

default_deny_fs=False opts out.

The network layer is deliberately unchanged. Landlock keys network rules on port and is default-deny for ports it does not name, so handling that class with no allow-list would sever egress the policy never meant to restrict.

/etc stays readable — the loader cache, TLS trust store, and locale data live there. That is a real residual: /etc/passwd remains readable (though it is world-readable on every Unix, and /etc/shadow is root-only while the guest is not root). Narrowing /etc to specific entries is worth doing, but it is a riskier change and belongs in its own PR.

New landlock_default_deny_fs field in the confinement report distinguishes "confined to policy" from "confined to the bare minimum".

⚠️ On verification — please read

This container's kernel reports Landlock ABI 0, so I could not verify live enforcement. I did not want to claim a result I hadn't seen, so I proved the mechanism two ways instead:

  1. Fake-libc tests (run on any kernel) assert the ruleset is created with a non-zero handled_access_fs when no policy paths are given, that exactly the interpreter's runtime paths are added as rules, and that landlock_restrict_self is called. This is the actual mechanism that makes everything else denied.
  2. Live tests (@live_landlock) assert a policy-free guest cannot read or write outside the interpreter tree and can still import the stdlib. These skip here but run in the tests-cross-kernel CI job, which already sets PYISOLATE_LIVE_LANDLOCK_TESTS=1 on ubuntu-22.04/24.04.

The CI run on this PR is the real check on enforcement. The can_still_import_the_stdlib test is the one to watch — it is the canary for default-deny breaking a guest.

Tests

12 new. Seven run on any kernel, including the hardened-mode regression (require_landlock=True with no policy paths now raises instead of returning silently) and the report-records-the-gap case. Five require a Landlock kernel.

Full suite 512 passed / 11 skipped (was 505/6 — the 5 new skips are the live tests). flake8 clean.

Behavior change to be aware of

Hardened rollout mode with backend="process" on a kernel without Landlock now raises where it previously succeeded unconfined. That is the intended fail-closed semantics, but it will surface as a new failure for anyone running hardened mode on such a kernel.

SECURITY.md §2.3, docs/threat-model.md §boundary and its History section are updated.


Generated by Claude Code

claude and others added 2 commits August 15, 2026 20:49
A process sandbox whose policy named no filesystem paths received no
filesystem confinement at all. confine._apply_landlock returned early
before building a ruleset, so the guest kept full read access to the host
and could write anywhere:

    sb = iso.spawn("p", backend="process", allowed_imports=["os"])
    sb.exec("post(open('/etc/passwd').read())")        # -> contents
    sb.exec("open('/tmp/owned','w').write('x')")       # -> succeeds

while the same report said seccomp was active, so the sandbox looked
confined. This contradicted the deny-by-default posture the import
allow-list already takes (DEFAULT_ALLOWED_IMPORTS is deliberately empty
"so missing import policy fails closed").

The early return also meant require_landlock was never consulted on that
path, so hardened rollout mode silently produced an unconfined guest on a
kernel without Landlock -- the one mode whose whole purpose is to refuse.

Always handle the filesystem access class. Landlock is default-deny for
every right its ruleset handles, and _runtime_read_paths() already grants
the interpreter what it needs to keep running, so the fix is to stop
skipping the ruleset rather than to build new machinery: the guest keeps
its stdlib and shared libraries, and home directories, /root, /var and all
writes are denied by the kernel.

The network layer is deliberately left as it was. Landlock keys network
rules on port and is default-deny for ports it does not name, so handling
that class with no allow-list would sever egress the policy never meant to
restrict.

/etc stays readable: the loader cache, TLS trust store and locale data live
there. Narrowing it to specific entries is worth doing separately.

Reported as landlock_default_deny_fs in the confinement report, so
"confined to policy" and "confined to the bare minimum" are distinguishable
by callers and by CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ebvMQ3vLxdK3joymz6Feg
@seanwevans
seanwevans merged commit ea4ac02 into main Aug 15, 2026
9 of 18 checks passed
@seanwevans
seanwevans deleted the claude/process-backend-fs-default-deny branch August 15, 2026 21:00
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