Skip to content

[sandbox audit] Fail closed when Landlock cannot confine a sandbox - #19

Draft
Wauplin wants to merge 1 commit into
security/per-sandbox-capability-tokensfrom
security/fail-closed-landlock
Draft

[sandbox audit] Fail closed when Landlock cannot confine a sandbox#19
Wauplin wants to merge 1 commit into
security/per-sandbox-capability-tokensfrom
security/fail-closed-landlock

Conversation

@Wauplin

@Wauplin Wauplin commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

[sandbox audit] — PR 5 of 13 in this repo's stack; merge in order.
Previous: #18 · Next: #20
Review only the commits this PR adds on top of its base; bases collapse to main as the stack lands.

⚠️ One decision needs your input

SBX_MIN_LANDLOCK_ABI defaults to 6, which is what the documented isolation model
actually requires. On a kernel below that, host mode now refuses to start.

My dev kernel (6.8) reports ABI 4, so this is not hypothetical:

sbx-server: landlock ABI 4 on this kernel, but 6 is required for the documented isolation
guarantees (have: fs,refer,truncate,no_tcp_bind). Lower SBX_MIN_LANDLOCK_ABI to accept a
reduced set, or pass --allow-unconfined to run without confinement.

The audit notes production runs 6.12 (ABI 6), so the default should be fine — but if any
Jobs region runs an older kernel, this breaks pool mode there.
I chose the strict default
because that is what "fail closed" means and it matches what the docs promise; the alternative
is defaulting to 4 and warning loudly about the missing abstract-socket scoping. Happy to flip
it if you know the fleet's kernel range.

Why

Three ways the confinement could be silently weaker than advertised.

1. A failed ruleset build produced an unconfined sandbox.

let landlock_fd = crate::landlock::build_ruleset(&home).unwrap_or(-1);

-1 meant "skip restrict_self". The sandbox was created, listed and handed out like any
other, with nothing telling the client that /tmp, /dev/shm, TCP bind and other sandboxes'
homes were no longer denied.

2. ABI 1 was accepted, but the model needs more. TCP-bind denial needs ABI 4;
abstract-socket scoping (which uid isolation does not provide) needs ABI 6. Below those,
both documented guarantees were simply absent, silently.

3. Rule failures were discarded. landlock_add_rule's return value was ignored, so a
ruleset could enforce something other than what we described with no indication.

Approach

  • landlock_fd: i32 → a Confinement enum, so "not confined" has to be named at every use
    site instead of being a sentinel. Creation refuses unless the operator passed
    --allow-unconfined — an argv flag, not an env var, for the same reason as
    --allow-no-auth.
  • SBX_MIN_LANDLOCK_ABI gate in host mode only; dedicated mode's boundary is the VM, so it is
    not gated. The refusal names what is missing.
  • Rule failures: a system dir warns (losing one only costs access to that directory); a
    failure on the sandbox's own home aborts the build.
  • The confinement in force is now observable: /health reports the ABI and feature list,
    and the create response and GET /v1/sandboxes report each sandbox's confinement. A
    client can refuse to run untrusted work on a weaker-than-expected host instead of finding
    out by not finding out.
  • Narrows /dev from the whole directory to the nodes a workload needs.

The /dev narrowing caught its own bug

Worth calling out, because it is the argument for the positive controls. My first version
granted FS_READ_DIR on each device node. A Landlock rule on a device node is a rule on a
file, and the kernel rejects an allowed_access carrying directory-only bits — so every
/dev rule failed with EINVAL and sandboxes were left with no /dev at all:

FAIL  probe: cat /dev/null -> cat: /dev/null: Permission denied

Silent before this PR (the return value was discarded); caught here because the same PR both
checks the return value and asserts the positive controls.

Validation

  • 30 unit tests, including that FULL_ABI is the lowest ABI offering every advertised
    feature, that features and handled bits only ever widen with the ABI (so a future ABI bump
    cannot quietly drop one), and that a ruleset for a missing home is an error.
  • scripts/landlock-regression.sh — root container:
    • positive: the narrowed /dev still serves cat /dev/null, redirection, /dev/urandom,
      python3, import ssl,hashlib, and pip install --user;
    • negative: the documented denials still hold — /tmp, /dev/shm, /etc, an ungranted
      device node, a sibling's home by name, and TCP bind (skipped below ABI 4, with a message
      rather than a silent pass);
    • the ABI floor refuses in host mode, dedicated mode still starts, --allow-unconfined
      works, and /health plus the create/list responses report the confinement.
  • The three earlier regression suites still pass. They now pin SBX_MIN_LANDLOCK_ABI=1,
    since they test other properties and CI/dev kernels are often older than production.

Behaviour changes

  • Host mode refuses to start below ABI 6 by default (see the top of this description).
  • A sandbox whose ruleset cannot be built is a 500 rather than an unconfined sandbox.
  • /dev access is limited to null, zero, full, random, urandom, tty, ptmx,
    /dev/pts and /dev/fd. Anything else in /dev (loop devices, /dev/kmsg, a mounted
    /dev/fuse) is no longer reachable from a pooled sandbox. If GPU pools are ever
    supported, /dev/nvidia* will need adding.
  • /health, the create response and GET /v1/sandboxes gained fields (additive).

Three ways the confinement could silently be weaker than advertised.

**A failed ruleset build produced an unconfined sandbox.**
`build_ruleset(...).unwrap_or(-1)` stored -1, `pre_exec_isolation` skipped
`restrict_self` when the fd was negative, and the sandbox was created and
listed like any other. Under uid-only isolation none of /tmp, /dev/shm, TCP
bind or another sandbox's home is denied -- and nothing told the client.

`landlock_fd: i32` becomes a `Confinement` enum, so "not confined" has to be
named at every use site rather than being a sentinel value. Creation now
refuses unless the operator passed `--allow-unconfined` (an argv flag, not
an env var, for the same reason as `--allow-no-auth`: a Job's user-supplied
env must not be able to switch off confinement).

**ABI 1 was accepted, but the documented model needs more.** Refusing a TCP
bind needs ABI 4 and scoping abstract unix sockets needs ABI 6; below those,
both guarantees were silently absent. Host mode now requires
`SBX_MIN_LANDLOCK_ABI` (default 6, the lowest ABI that delivers everything
claimed) and names what is missing when it refuses. Dedicated mode is not
gated: its boundary is the VM, not Landlock.

**Rule failures were discarded.** `landlock_add_rule`'s return value was
ignored, so a ruleset could enforce something other than what we described
with no indication. System-dir rules now warn on failure (losing one only
costs the sandbox access to that directory), and a failure on the sandbox's
own home aborts the build.

The confinement in force is now observable: `/health` reports the ABI and
feature list, and both the create response and `GET /v1/sandboxes` report
each sandbox's `confinement`. A client can refuse to run untrusted work on a
host that is weaker than it expects instead of finding out by not finding
out.

Also narrows the `/dev` grant from the whole directory to the nodes a
workload actually needs. Writing the first version of this caught its own
bug: a Landlock rule on a device node is a rule on a *file*, and the kernel
rejects an `allowed_access` carrying directory-only bits, so granting
FS_READ_DIR on /dev/null made every rule fail with EINVAL and left sandboxes
with no /dev at all. The regression script's positive controls caught it,
which is the argument for having them.

Validation:
- 30 unit tests, including that FULL_ABI is the lowest ABI offering every
  advertised feature, that features and handled bits only ever widen with the
  ABI, and that a ruleset for a missing home is an error.
- `scripts/landlock-regression.sh`: reports the ABI and features, asserts a
  created sandbox says how it is confined, refuses to start below the floor
  while dedicated mode still starts, and accepts `--allow-unconfined`. Its
  positive controls check the narrowed /dev still serves cat/redirection/
  urandom/python/ssl/`pip install --user`, and its negative controls assert
  the documented denials still hold (/tmp, /dev/shm, /etc, an ungranted
  device node, a sibling's home by name, TCP bind).
- The three earlier regression suites still pass. They now pin
  SBX_MIN_LANDLOCK_ABI=1, since they test other properties and CI and dev
  kernels are often older than production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Wauplin Wauplin changed the title Fail closed when Landlock cannot confine a sandbox [sandbox audit] Fail closed when Landlock cannot confine a sandbox Sep 9, 2026
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.

1 participant