Skip to content

feat(core): apply max_open_files as RLIMIT_NOFILE in the child - #181

Merged
congwang-mk merged 2 commits into
multikernel:mainfrom
dzerik:feat/max-open-files
Jul 30, 2026
Merged

feat(core): apply max_open_files as RLIMIT_NOFILE in the child#181
congwang-mk merged 2 commits into
multikernel:mainfrom
dzerik:feat/max-open-files

Conversation

@dzerik

@dzerik dzerik commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes #176.

max_open_files 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.

Where the call goes

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, 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.

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.

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 65536 handed 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=N sets 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, systemd LimitNOFILE=), 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) plus capset, 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 reach main, so it would die with EMFILE from the loader and exit 127 with nothing naming the setting. 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.

Tests

Three of them exist because the suite could not see the defects review found:

  • the cap is applied after Landlock and seccomp — moving setrlimit earlier makes the child die before the notify fd is read, which the original test tolerated;
  • the cap never raises an inherited soft limit — invisible on hosts where soft equals hard, as CI's are;
  • the enforcement test branches on 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_NOFILE while 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_RESOURCE for privileged runs, mapping a failed SECCOMP_ADDFD to EMFILE rather than EACCES/EIO, and a test for the in-process entrypoint under a cap.

dzerik added 2 commits July 30, 2026 14:35
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.
@congwang-mk
congwang-mk merged commit 66c1d19 into multikernel:main Jul 30, 2026
13 checks passed
@congwang-mk

Copy link
Copy Markdown
Contributor

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.

congwang-mk added a commit that referenced this pull request Jul 31, 2026
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>
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.

max_open_files is accepted everywhere but never applied

2 participants