[sandbox audit] Fail closed when Landlock cannot confine a sandbox - #19
Draft
Wauplin wants to merge 1 commit into
Draft
[sandbox audit] Fail closed when Landlock cannot confine a sandbox#19Wauplin wants to merge 1 commit into
Wauplin wants to merge 1 commit into
Conversation
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>
This was referenced Sep 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
SBX_MIN_LANDLOCK_ABIdefaults to 6, which is what the documented isolation modelactually 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:
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.
-1meant "skiprestrict_self". The sandbox was created, listed and handed out like anyother, 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 aruleset could enforce something other than what we described with no indication.
Approach
landlock_fd: i32→ aConfinementenum, so "not confined" has to be named at every usesite 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_ABIgate in host mode only; dedicated mode's boundary is the VM, so it isnot gated. The refusal names what is missing.
failure on the sandbox's own home aborts the build.
/healthreports the ABI and feature list,and the create response and
GET /v1/sandboxesreport each sandbox'sconfinement. Aclient can refuse to run untrusted work on a weaker-than-expected host instead of finding
out by not finding out.
/devfrom the whole directory to the nodes a workload needs.The
/devnarrowing caught its own bugWorth calling out, because it is the argument for the positive controls. My first version
granted
FS_READ_DIRon each device node. A Landlock rule on a device node is a rule on afile, and the kernel rejects an
allowed_accesscarrying directory-only bits — so every/devrule failed with EINVAL and sandboxes were left with no/devat all: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
FULL_ABIis the lowest ABI offering every advertisedfeature, 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:/devstill servescat /dev/null, redirection,/dev/urandom,python3,import ssl,hashlib, andpip install --user;/tmp,/dev/shm,/etc, an ungranteddevice node, a sibling's home by name, and TCP bind (skipped below ABI 4, with a message
rather than a silent pass);
--allow-unconfinedworks, and
/healthplus the create/list responses report the confinement.SBX_MIN_LANDLOCK_ABI=1,since they test other properties and CI/dev kernels are often older than production.
Behaviour changes
/devaccess is limited tonull,zero,full,random,urandom,tty,ptmx,/dev/ptsand/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 eversupported,
/dev/nvidia*will need adding./health, the create response andGET /v1/sandboxesgained fields (additive).