Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@ silently.
`process_vm_readv`/`writev`, and others. x86-64 Linux. This is a robust
deny-list, **not** a proof that only a fixed syscall allow-list is reachable.
3. **Filesystem policy** — Landlock confines the guest to the policy's read/write
paths, on kernels that support Landlock.
paths, on kernels that support Landlock. A policy that names **no** filesystem
paths is deny-by-default, not unrestricted: the guest is confined to the
interpreter's own runtime paths (so it can still import) and nothing else —
home directories, `/root`, `/var`, and all writes are denied by the kernel.
`/etc` remains readable, because the loader, TLS trust store, and locale data
live there. On a kernel without Landlock the layer is recorded as skipped, and
hardened rollout mode fails closed rather than running unconfined.
4. **Network-egress policy** — On Landlock ABI >= 4 (Linux 6.7+) the policy's TCP
allow-list is mapped to allowed `connect()` ports and the kernel denies egress
to every other port. Landlock keys network rules on port, not address, so this
Expand Down
15 changes: 13 additions & 2 deletions docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ answer below as conditional on the backend you select.
dangerous syscalls (`execve`, `ptrace`, mount/namespace ops, `bpf`, kernel
module load, `process_vm_*`, …) — x86-64 Linux;
- **Landlock** filesystem rules derived from policy, where the kernel supports
it, plus **Landlock TCP-egress** rules that allow-list the connect ports in
the policy (Landlock ABI >= 4 / Linux 6.7+; keyed on port, not address);
it — deny-by-default when the policy names no paths, confining the guest to
the interpreter's own runtime paths — plus **Landlock TCP-egress** rules that
allow-list the connect ports in the policy (Landlock ABI >= 4 / Linux 6.7+;
keyed on port, not address);
- a **coarse per-cgroup eBPF/LSM deny-mask** (deny whole capability classes),
where BPF-LSM is available;
- `rlimit` and cgroup resource caps.
Expand Down Expand Up @@ -210,6 +212,15 @@ Any semantic change to defended/not-defended status requires:

### History

- **2026-08-15** — Made the `backend="process"` filesystem layer deny-by-default.
A sandbox whose policy named no filesystem paths previously received no
Landlock confinement at all — the guest could read any host file and write
anywhere — and because that path returned before consulting `require_landlock`,
hardened rollout mode could not fail closed on it either. The filesystem access
class is now always handled: the interpreter's runtime paths are granted so the
guest still runs, everything else is denied, and a missing Landlock layer is
recorded (and fatal under hardened mode). Reported as
`landlock_default_deny_fs` in the confinement report.
- **2026-07-21** — Wired capability-gated broker mediation into
`backend="process"`: the guest's `request` cell op is denied unless the named
capability was granted, and a permitted request crosses the boundary as a
Expand Down
8 changes: 5 additions & 3 deletions pyisolate/nogil.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,11 @@ def imported_native_extensions() -> list[dict[str, Any]]:
),
"no_gil_safe": marked_safe,
"status": "declared-safe" if marked_safe else "unknown",
"reason": "declared in PYISOLATE_NOGIL_SAFE_MODULES"
if marked_safe
else "native extension has no PyIsolate no-GIL safety declaration",
"reason": (
"declared in PYISOLATE_NOGIL_SAFE_MODULES"
if marked_safe
else "native extension has no PyIsolate no-GIL safety declaration"
),
}
)
return records
Expand Down
2 changes: 2 additions & 0 deletions pyisolate/runtime/child.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ def _serve(sock: socket.socket) -> None:
net_connect_ports=_net_connect_ports(bootstrap.get("tcp")),
require_seccomp=bool(bootstrap.get("require_seccomp", False)),
require_landlock=bool(bootstrap.get("require_landlock", False)),
default_deny_fs=bool(bootstrap.get("default_deny_fs", True)),
)
_send_frame(
sock,
Expand All @@ -242,6 +243,7 @@ def _serve(sock: socket.socket) -> None:
"rlimits": report.rlimits,
"landlock": report.landlock,
"landlock_rules": report.landlock_rules,
"landlock_default_deny_fs": report.landlock_default_deny_fs,
"landlock_net": report.landlock_net,
"landlock_net_ports": report.landlock_net_ports,
"skipped": report.skipped,
Expand Down
24 changes: 19 additions & 5 deletions pyisolate/runtime/confine.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ class ConfinementReport:
rlimits: list[str] = field(default_factory=list)
landlock: bool = False
landlock_rules: int = 0
# True when the guest is confined to the interpreter's runtime paths only,
# because the policy named no filesystem paths of its own.
landlock_default_deny_fs: bool = False
landlock_net: bool = False
landlock_net_ports: int = 0
skipped: list[str] = field(default_factory=list)
Expand Down Expand Up @@ -224,22 +227,31 @@ def _apply_landlock(
fs_write: list[str] | None,
net_connect_ports: list[int] | None,
require_landlock: bool,
default_deny_fs: bool = True,
) -> None:
# Only handle an access class when the policy actually names an allow-list;
# without one a default-deny ruleset would break the interpreter (FS) or
# sever egress the policy meant to permit (network).
if not fs_read and not fs_write and not net_connect_ports:
"""Apply the Landlock filesystem and TCP-egress layers to this process.

The filesystem layer is applied even when the policy names no paths: the
guest is then confined to the interpreter's own runtime paths, which is the
deny-by-default posture the import allow-list already takes. The network
layer is only handled when the policy names ports, because Landlock's
network rules are default-deny per port and handling that class with no
allow-list would sever egress the policy never meant to restrict.
"""
if not fs_read and not fs_write and not default_deny_fs and not net_connect_ports:
return
landlock_report = _landlock.apply_landlock(
fs_read,
fs_write,
connect_ports=net_connect_ports,
require=require_landlock,
default_deny_fs=default_deny_fs,
)
if landlock_report.applied:
report.landlock = True
report.landlock_rules = landlock_report.rules
elif fs_read or fs_write:
report.landlock_default_deny_fs = landlock_report.default_deny_fs
elif fs_read or fs_write or default_deny_fs:
report.skipped.append(f"landlock:{landlock_report.skipped}")
if landlock_report.net_applied:
report.landlock_net = True
Expand All @@ -259,6 +271,7 @@ def apply_confinement(
seccomp: bool = True,
require_seccomp: bool = False,
require_landlock: bool = False,
default_deny_fs: bool = True,
) -> ConfinementReport:
"""Confine the *current* process before it runs guest code.

Expand All @@ -278,6 +291,7 @@ def apply_confinement(
fs_write=fs_write,
net_connect_ports=net_connect_ports,
require_landlock=require_landlock,
default_deny_fs=default_deny_fs,
)

if not seccomp:
Expand Down
27 changes: 21 additions & 6 deletions pyisolate/runtime/landlock.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ class LandlockReport:
applied: bool = False
abi: int = 0
rules: int = 0
# True when the filesystem layer was applied without any policy paths, i.e.
# the guest is confined to the interpreter's own runtime paths and nothing
# else. Distinguishes "confined to policy" from "confined to bare minimum".
default_deny_fs: bool = False
skipped: str | None = None
denied_paths: list[str] = field(default_factory=list)
net_applied: bool = False
Expand Down Expand Up @@ -343,16 +347,26 @@ def apply_landlock(
*,
connect_ports: list[int] | None = None,
require: bool = False,
default_deny_fs: bool = False,
) -> LandlockReport:
"""Restrict the current process's filesystem and TCP-egress access to policy.

``read_paths`` are granted read access, ``write_paths`` read+write, and the
interpreter's runtime paths are granted read+execute so it can keep running.
``connect_ports`` (Landlock ABI >= 4, Linux 6.7+) allow-lists the TCP ports
the guest may ``connect()`` to; every other port is denied by the kernel.
Both layers share a single ruleset. A layer whose allow-list is empty/None
is not handled at all, so a default-deny ruleset never breaks the
interpreter or blocks egress the policy did not mean to restrict.
Both layers share a single ruleset.

``default_deny_fs`` handles the filesystem access classes even when the
policy names no paths at all. The interpreter's own runtime paths are still
granted, so the guest keeps working, but everything else -- home
directories, ``/root``, ``/var``, and *all* writes -- is denied by the
kernel. Without it, a policy-free sandbox gets no filesystem confinement
whatsoever.

The network layer is never defaulted on: 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.

On a kernel without Landlock this is a no-op unless ``require`` is set, in
which case it raises. When network confinement is requested but the kernel's
Expand All @@ -368,7 +382,7 @@ def apply_landlock(
report.skipped = "unsupported"
return report

handle_fs = bool(read_paths or write_paths)
handle_fs = bool(read_paths or write_paths or default_deny_fs)
want_net = connect_ports is not None
handle_net = want_net and abi >= _NET_ABI
if want_net and not handle_net:
Expand All @@ -380,8 +394,8 @@ def apply_landlock(
report.net_skipped = f"net_unsupported_abi:{abi}"

if not handle_fs and not handle_net:
# Nothing to restrict. A ruleset that handled an access class with no
# allow-list rules would be default-deny and break the guest.
# Nothing to restrict. Reachable only when the caller opted out of the
# filesystem default-deny and named no network ports.
report.skipped = "no_rules"
return report

Expand Down Expand Up @@ -426,5 +440,6 @@ def apply_landlock(
os.close(ruleset_fd)

report.applied = handle_fs
report.default_deny_fs = handle_fs and not (read_paths or write_paths)
report.net_applied = handle_net
return report
2 changes: 2 additions & 0 deletions pyisolate/runtime/process_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ def __init__(
confine: bool = True,
require_seccomp: bool = False,
require_landlock: bool = False,
default_deny_fs: bool = True,
env: Optional[Mapping[str, str]] = None,
) -> None:
self.name = name
Expand Down Expand Up @@ -259,6 +260,7 @@ def __init__(
"cpu_seconds": cpu_seconds,
"require_seccomp": require_seccomp,
"require_landlock": require_landlock,
"default_deny_fs": default_deny_fs,
}
)

Expand Down
Loading
Loading