feat(core): apply max_open_files as RLIMIT_NOFILE in the child - #181
Conversation
The field was accepted by the builder, the CLI, the C ABI, Python and Go,
carried through every structure — and never applied: `RLIMIT_NOFILE`
appeared nowhere in the crate. A caller who set it believed a descriptor
cap was in place while the guest ran with the host's limit.
Placed last in `confine_child`, deliberately. `RLIMIT_NOFILE` bounds the fd
*number* the kernel hands out, not the count of live descriptors, and every
earlier step still opens files: Landlock takes an `O_PATH` fd per rule path
plus the ruleset fd, seccomp allocates the notify listener, and
`close_fds_above` reads `/proc/self/fd`. Setting the cap before those turns
them into EMFILE and kills the child with a message blaming Landlock. After
that step the table is `{0,1,2}` plus kept fds, which is what a caller means
by "at most N open files". It sits before the `entry` match so the
in-process entrypoint gets it too, not only the exec path.
Both the soft and the hard limit are lowered. `setrlimit`/`prlimit64` are
not in the seccomp blocklist, so a soft-only cap would be advisory — the
guest could raise it straight back. Raising a hard limit needs
CAP_SYS_RESOURCE, which the child does not have, so this is one-way. The
value is clamped to the inherited hard limit: a larger request would fail
with EPERM and abort the child, and clamping only ever yields a lower cap,
so "no more than N" still holds.
Zero is rejected at `build()` rather than in each binding. It cannot be
honoured — the child needs descriptors to reach `main`, so it would die
with EMFILE from the loader and exit 127, with nothing naming the setting
responsible. The check sits next to the existing `max_cpu` one, which
already rejects zero the same way. Only the Go SDK filters zero itself, and
it has to: a `uint32` struct field cannot express "unset" any other way.
Documented the measured floors — about 4 descriptors for a plain
dynamically linked exec, about 17 under chroot — as the shape of the
constraint rather than a guarantee, and corrected the Go doc comment, which
promised RLIMIT_NOFILE while nothing enforced it.
Verified negatively: removing the setrlimit call leaves the guest seeing
the host limit and opening 200 descriptors; setting only the soft limit
lets the guest raise it back. Both turn the test red.
…aiming Review found three statements this branch had added to public documentation that do not hold. **"an upper bound, never a grant" was false.** The clamp consulted only the inherited hard limit, so a request between the inherited soft and hard limits *widened* the guest's budget: with the common soft 1024 / hard 524288 split, `--max-open-files 65536` handed the guest 64x the descriptors the same command gets unsandboxed, under a setting named "max". The clamp now takes both bounds, in a small pure function so it can be unit-tested. Callers who genuinely need more raise it on sandlock itself (`prlimit`, systemd `LimitNOFILE=`); the guest inherits the higher soft limit and the clamp stops constraining. Note this diverges from `docker --ulimit nofile=N`, which sets soft=hard=N regardless of the parent — say the word and I will match container convention instead and drop the "never a grant" wording. **"one-way" holds only unprivileged.** Nothing here drops CAP_SYS_RESOURCE, so a child under a root supervisor raises the cap straight back — verified: 64 becomes 4096 and 300 descriptors open. The comment, the rustdoc, the reference table and the Python docstring now say so, and the test branches on `geteuid()`. That fixes a real defect on the way: the previous test failed under root on a correct build, because it asserted the raise is always denied. **EMFILE is not what the guest always sees.** When an open is serviced by the supervisor — chroot, COW, /proc virtualisation — the failure surfaces as EACCES, and exec-fd injection reports EIO. Documented, with a chroot test pinning the EIO path. The measured floor under chroot is 13, not the 17 previously claimed. Two tests were added for defects the suite could not see: one pins that `setrlimit` runs after Landlock and seccomp (moving it earlier makes the child die before the notify fd is read, which the old test tolerated), and one pins that the cap never raises an inherited soft limit — invisible on hosts where soft equals hard, as this one does. Left for the maintainer, all out of scope here: dropping CAP_SYS_RESOURCE for privileged runs, mapping a failed SECCOMP_ADDFD to EMFILE rather than EACCES/EIO, and a test for the in-process entrypoint.
|
On the two decisions flagged for explicit attention: 1. The soft clamp: keep it as written. For a sandboxing tool the strict reading is the right one: a setting named "max" should only ever cap, never widen the guest's budget past what the same command gets unsandboxed. The docker convention makes sense for a general-purpose runtime, less so here. The escape hatch is legitimate (raise the limit on sandlock itself via prlimit or LimitNOFILE=), and the behavior is documented and pinned by a test. Keep the "upper bound, never a grant" wording too. 2. CAP_SYS_RESOURCE: agreed, leave it alone. Dropping it would change behavior for every privileged sandbox, and a root guest that legitimately relies on the capability (for other rlimits, quotas, etc.) would break silently. That is a bigger decision than this flag and I don't want to take it as a side effect here. Documenting the cap as a resource budget rather than confinement, and splitting the enforcement test on euid so it stays honest under sudo, is the right call. |
The max_open_files docs quoted 17 as the measured descriptor floor under chroot, while the PR discussion for #181 reported 13; neither matched a fresh measurement here, where 11 fails with EIO and 12 runs. Since the figure clearly varies by host, the rustdoc, the build() error message and the reference table now say about 12 and state the variance explicitly, keeping the existing not-a-guarantee hedge. The plain-exec floor of 4 re-measured exactly and is unchanged. The same two merges introduced em-dashes into comments, docstrings and user-facing messages; project prose avoids them, so they are rewritten with colons, commas or parentheses. Only lines added by #180 and #181 are touched, and sandlock.h is regenerated with the pinned cbindgen 0.29.2 (one line changed, matching the lib.rs doc edit). Signed-off-by: Cong Wang <cwang@multikernel.io>
Closes #176.
max_open_fileswas accepted by the builder, the CLI, the C ABI, Python and Go, carried through every structure, and never applied —RLIMIT_NOFILEappeared nowhere in the crate. A caller who set it believed a descriptor cap was in place while the guest ran with the host's limit.Where the call goes
Last in
confine_child, deliberately.RLIMIT_NOFILEbounds the fd number the kernel hands out, not the count of live descriptors, and every earlier step still opens files: Landlock takes anO_PATHfd per rule path plus the ruleset fd, seccomp allocates the notify listener,close_fds_abovereads/proc/self/fd. Setting the cap before those turns them into EMFILE and kills the child with a message blaming Landlock. After that step the table is{0,1,2}plus kept fds, which is what a caller means by "at most N open files". It sits before theentrymatch so the in-process entrypoint gets it too.Both the soft and the hard limit are lowered:
setrlimit/prlimit64are not in the seccomp blocklist, so a soft-only cap would be advisory.Two decisions worth your explicit attention
1. The clamp takes both inherited bounds, which diverges from container convention.
Clamping to the hard limit alone made the setting widen the budget: with the common soft 1024 / hard 524288 split,
--max-open-files 65536handed the guest 64x the descriptors the same command gets unsandboxed, under a setting named "max". That contradicted the "upper bound, never a grant" wording this branch itself introduced.docker --ulimit nofile=Nsets soft=hard=N regardless of the parent, so the current behaviour is the stricter reading, not the conventional one. Callers who genuinely need a bigger budget raise it on sandlock itself (prlimit, systemdLimitNOFILE=), after which the clamp stops constraining. If you would rather match container convention, say so and I will drop the soft clamp along with the "never a grant" sentence.2. The cap is not one-way under a privileged supervisor.
Nothing here drops
CAP_SYS_RESOURCE, so a child under a root sandlock raises the cap straight back — verified: 64 becomes 4096 and 300 descriptors open. Unlike Landlock and seccomp, which stay irreversible for root, this is a resource budget rather than confinement, and the comment, rustdoc, reference table and Python docstring now say so.Doing better would mean
PR_CAPBSET_DROP(CAP_SYS_RESOURCE)pluscapset, which is a new subsystem in a crate that touches no capabilities today, and it would change behaviour for every privileged sandbox rather than only this flag. Out of scope here; happy to take it separately if you want it.Zero
Rejected at
build()rather than per binding. It cannot be honoured — the child needs descriptors to reachmain, so it would die with EMFILE from the loader and exit 127 with nothing naming the setting. The check sits next to the existingmax_cpuone, which already rejects zero the same way. Only the Go SDK filters zero itself, and it has to: auint32struct field cannot express "unset" any other way.Tests
Three of them exist because the suite could not see the defects review found:
setrlimitearlier makes the child die before the notify fd is read, which the original test tolerated;geteuid(), which also fixes a real defect: the original failed under root on a correct build, because it asserted the raise is always denied.The chroot path gets its own test: a failure there surfaces as EIO from exec-fd injection, not the EMFILE the docs first claimed. The measured floor under chroot is 13, not 17 as first written.
Also corrected the Go doc comment, which promised
RLIMIT_NOFILEwhile nothing enforced it.Adjacent things I did not touch
While working through this I noticed a few similar shapes elsewhere — settings where zero means two different things depending on the surface, and places where an invalid argument is coerced into a valid one rather than refused. I would rather not assert any of it from a PR description without reproductions, so I am checking them one at a time and will open a separate issue with the analysis once they are verified.
Three specific follow-ups from this change, all deliberately out of scope: dropping
CAP_SYS_RESOURCEfor privileged runs, mapping a failedSECCOMP_ADDFDto EMFILE rather than EACCES/EIO, and a test for the in-process entrypoint under a cap.