Skip to content

feat(ffi): C ABI for the Handler trait (RFC #43)#44

Merged
congwang-mk merged 24 commits into
multikernel:mainfrom
dzerik:follow-up-b-c-abi
May 14, 2026
Merged

feat(ffi): C ABI for the Handler trait (RFC #43)#44
congwang-mk merged 24 commits into
multikernel:mainfrom
dzerik:follow-up-b-c-abi

Conversation

@dzerik
Copy link
Copy Markdown
Contributor

@dzerik dzerik commented May 13, 2026

Summary

Adds a C ABI surface for the Handler trait introduced in #36 so non-Rust callers (Python via ctypes, Go via cgo, future bindings) can register custom seccomp-notif handlers without writing Rust.

Implements the maintainer-approved options from RFC #43:

RFC Choice Realization
Q1 (async model) A — sync C callback signature; Rust adapter offloads to tokio::task::spawn_blocking
Q2 (HandlerCtx surface) C — hybrid repr(C) snapshot (sandlock_notif_data_t) for the cheap copy-safe fields; opaque handle (sandlock_mem_handle_t*) for child-memory access
Q3 (NotifAction surface) C — output setters Caller-allocated sandlock_action_out_t filled via setter calls — no tagged union returned by value
Q4 (handler ownership) B — opaque box sandlock_handler_t* allocated via sandlock_handler_new, freed via sandlock_handler_free (or the supervisor on registration)
Q5 (exception policy) D — Kill default + per-handler override sandlock_exception_policy_t defaulting to Kill (fail-closed); applied on rc != 0, Unset action, or callback panic

Public surface added

Rust modules (new files under crates/sandlock-ffi/src/):

  • handler.rs — opaque types, setters, accessor functions, FfiHandler adapter, run entry points
  • notif_repr.rssandlock_notif_data_t stable layout

C header (crates/sandlock-ffi/include/sandlock.h):

  • Types: sandlock_notif_data_t, sandlock_mem_handle_t, sandlock_action_out_t (+ tag enum + payload union + nested structs), sandlock_handler_t, sandlock_handler_registration_t
  • Function pointer types: sandlock_handler_fn_t, sandlock_handler_ud_drop_t (both extern "C-unwind" for Rust handlers exposed through the C ABI)
  • Functions: sandlock_mem_read_cstr / _read / _write, seven sandlock_action_set_* setters, sandlock_handler_new / _free, sandlock_run_with_handlers / _interactive_with_handlers

sandlock-core touch — a single pub use line at crates/sandlock-core/src/lib.rs:31:

pub use sys::structs::{SeccompData, SeccompNotif};

Required because the Handler trait contract already binds SeccompNotif through HandlerCtx.notif; the types must be name-reachable for any external implementer. Narrower than making mod sys public.

Test plan

  • 39 tests pass (38 Rust integration + 1 pure-C end-to-end), 0 ignored.
  • cargo build -p sandlock-ffi clean.
  • cargo clippy -p sandlock-ffi -- -A clippy::all clean.
  • Coverage includes: every NotifAction variant translation, every action setter (including null-safe and payload verification), exception policies (Kill/DenyEperm/Continue), panic recovery via catch_unwind (verified destructively), Unset fallback, handler allocation/free with ud_drop counter assertion, run_with_handlers failure paths (null policy / argv / registrations / handler-in-array, with transactional cleanup), multiple-handler dispatch, real-fd mem_read_cstr through an intercepted openat, c-unwind ABI correctness, layout assertions via offset_of-style probing, k8s defense-in-depth for child_pgid resolution.
  • All claims-of-behavior tests verified by destructive sanity (the corresponding production code was temporarily mutated to confirm the test actually fails when the behavior breaks). The audit caught two "test-passes-anyway" patterns during development and fixed them: a leak-sanitizer-only assertion on Drop (now uses an AtomicUsize counter) and a self-referencing assertion on pgid substitution (now spawns a real child so pid != pgid).

Notable design notes

extern "C-unwind" for handler function pointers. The exception-policy doc-comment advertises panic recovery via the configured policy. With plain extern "C" fn this was a lie — Rust 1.71+ ABI rules abort on panic across extern "C" boundaries. Using extern "C-unwind" makes the documented behavior real for Rust handlers plugged through the C ABI; pure-C callers cannot panic (C has no unwinding) so the choice is invisible to them. Stable since 1.71 (RFC 2945).

InjectFdSendTracked discriminant reserved (no setter shipped). The tracker-callback path requires a registry of pending injections that is out of scope for this PR. The enum discriminant (SANDLOCK_ACTION_INJECT_FD_SEND_TRACKED = 5), payload variant, and inner struct types are preserved for ABI stability; the setter is deliberately omitted. translate_action treats the discriminant as Unset and drains any pending srcfd before falling back to the exception policy. The setter will return in a follow-up PR that lands the tracker registry.

Run-entry-point ownership contract. sandlock_run_with_handlers always consumes the handler pointers in its registration array, regardless of return value. Early-return paths (null policy, invalid argv, invalid name, null handler in array) free the handlers via release_registrations, so the C caller never has to worry about a partial-consume window. Documented in sandlock.h.

child_pgid resolution for Kill { pgid: 0 } substitution. Three defense-in-depth guards in FfiHandler::handle:

  • notif.pid <= 0 — guards against getpgid(0) returning the supervisor's own pgid in nested PID namespaces (Kubernetes pod-in-pod, KubeVirt, DinD). A killpg(supervisor_pgid) would be a sandbox-escape vector reachable by any malicious child.
  • getpgid() ESRCH — child exited between notification and query.
  • Resolved pgid coincides with the supervisor's own — backstop for hypothetical scenarios where the child has not been moved into its own process group (see below).

In all three cases, the substitution falls back to the bare pid; the kernel rejects killpg(pid) if pid does not name a valid process group, but the supervisor survives.

Out of scope (deferred follow-ups)

1. set_inject_fd_send srcfd validation. A C handler can today pass srcfd = 2 (stderr) or any other supervisor-owned fd; OwnedFd::from_raw_fd consumes it and close(2) corrupts supervisor output. A simple srcfd >= 3 && srcfd != notif_fd check would close the most obvious foot-gun. Deferred because the right policy (deny-list vs allow-list, runtime check vs build-time) deserves your input.

2. Unchecked setpgid(0, 0) calls in sandlock-core. While auditing pgid resolution for the FFI defense-in-depth, I noticed two child-path call sites that don't check the syscall return:

  • crates/sandlock-core/src/sandbox.rs:1201 (main spawn path, right after fork())
  • crates/sandlock-core/src/fork.rs:80 (COW clone child)

context.rs:766 already checks and exits on failure. The unchecked sites are not bugs the present PR is trying to fix — sandlock correctly calls setpgid in all three paths — but if either failed (rare: EPERM if the child is already a session leader, or namespace-policy denials), the child would silently inherit the supervisor's pgid. The FFI's third guard catches this, but the underlying core would benefit from matching error handling. Happy to open a small follow-up PR adding error checks if you'd find it useful.

3. name parameter via builder. The new entry points accept name: *const c_char for parity with sandlock_run. Internally the value is applied via Sandbox::with_name on a clone (same pattern as sandlock_run in lib.rs). The core API Sandbox::run_with_extra_handlers does not currently accept a name parameter directly; happy to thread it through if you prefer that shape, but the builder-on-clone path is identical to the existing sandlock_run.

4. Python bindings (PR 2). This PR ships the C ABI only. The Python wrapper module (sandlock.handler, sandlock.NotifAction) on ctypes is the next PR per the RFC's 3-phase plan.

5. Ergonomic helpers (PR 3). Path-deny presets, policy_fn-style glue, higher-level Python idioms — third PR.

Test environment

  • Linux 6.17, x86_64
  • Rust 1.71+ (extern "C-unwind" is stable since 1.71; extern "C" ABI tests cover the unchanged convention)
  • python3 at /usr/bin/python3 (two end-to-end tests spawn it; CI matrix without Python would need either the binary installed or #[ignore] on those two tests).

RFC reference

Implements maintainer-approved options A1 / C2 / C3 / B4 / D5 (Kill default) from #43. Each commit explains the why of its scope; the audit trail across the 17 commits also shows where additional review passes caught real bugs (panic-recovery ABI, fd leaks on error paths, pgid resolution). Happy to squash on request.

dzerik added 17 commits May 12, 2026 20:22
Add empty modules that the upcoming Handler ABI tasks will fill in.
The placeholder integration test wires the module path so later tasks
can extend it without churning the test file's discovery.
Stable repr(C) projection of SeccompNotif/SeccompData so external
handler implementations can read the kernel notification without
binding to the unstable internal struct layout.

Also re-export SeccompNotif/SeccompData from sandlock_core's crate
root. They are already part of the public Handler-trait contract
through HandlerCtx.notif; re-exporting them makes the path stable
without widening the `sys` module visibility.
Opaque child-memory accessor that wraps the existing TOCTOU-safe
helpers in sandlock-core. The handle's lifetime is bounded by a single
callback invocation; the C side must not retain it past the return.
Output-parameter approach for the NotifAction surface — the C handler
writes its decision via setter calls instead of returning a tagged
union by value. Makes future variant additions backwards-compatible
without breaking C ABI.
Wraps a C handler function pointer, opaque user-data slot with an
optional destructor, and per-handler exception policy (defaulting to
Kill — fail-closed).
Translates a sandlock_handler_t into a Handler impl by snapshotting the
notification, allocating a mem handle and action out struct, then
offloading the synchronous C callback to spawn_blocking. Failures (rc,
unset action, or panic across FFI) trigger the configured exception
policy.
New C entry points that pair the existing policy/argv surface with the
handler ABI. Each handler registration's ownership transfers into the
run; the supervisor frees them when the run completes.
Canonical example for downstream bindings — compiles handler_smoke.c
against sandlock.h, links the cdylib, runs it from a cargo test.
Adds a section to extension-handlers.md describing the new C ABI,
ownership rules, callback contract, and a pointer to the canonical
smoke test.
Adds tests for previously uncovered paths in the handler ABI:
all NotifAction translation variants, exception policy fallbacks,
panic recovery via catch_unwind, Unset action handling, handler
construction edge cases, run-path failure modes, and a live-fd
mem_read_cstr smoke against an intercepted openat.
The exception-policy doc-comment and extension-handlers.md both
advertise that a panicking handler triggers the configured
exception policy. With `extern "C" fn` that promise was a lie —
per Rust ABI rules panics across `extern "C"` abort the process
before `catch_unwind` ever fires.

Switch the two C-callable function-pointer type aliases to
`extern "C-unwind"`. C callers see no change (C cannot panic);
Rust handlers plugged into the C ABI now actually exercise the
panic-recovery path. Un-ignore the regression test.
The previous round-trip test relied on leak-sanitizer to flag a
missed ud_drop call; default cargo test does not enable LSan, so
the assertion was a no-op. Replace with an AtomicUsize counter
incremented inside the dropper and asserted post-free.
- Strip internal task/PR references from production code comments.
- Drop sandlock_action_set_inject_fd_send_tracked setter; reserve
  enum discriminant 5 for the future tracker-aware variant.
- Add `name` parameter to sandlock_run_with_handlers and
  sandlock_run_interactive_with_handlers for parity with the
  existing sandlock_run entry point.
- Tighten Sync safety comment on sandlock_handler_t to reflect the
  serial dispatch contract from sandlock-core.
- Document that InjectFdSend ownership transfer is conditional on
  the action surviving until supervisor dispatch.
- Reject argc == 0 in sandlock_run_with_handlers* before reaching
  the sandbox.
- Remove SANDLOCK_HANDLER_MODULE_BUILT scaffolding and its
  placeholder integration test.
The previous implementation substituted the child's pid for pgid==0
in sandlock_action_set_kill — that targets the single process, not
its process group, contradicting the documented "Kill the child
process group" semantics on NotifAction::Kill.

Query getpgid(notif.pid) inside FfiHandler::handle to recover the
real pgid. Fall back to the pid only on ESRCH (child already exited).
Update the C-header doc to reflect the actual resolution mechanism.
Four findings from adversarial review:

A1: A C handler that calls sandlock_action_set_inject_fd_send and
    then panics/returns rc != 0 left srcfd open in the supervisor
    forever. Drain pending inject-fd payloads on every fallback
    path.

A2: A C handler can write the InjectFdSendTracked discriminant
    directly without a setter (the value is public in the C header).
    Drain srcfd in that translate_action branch.

A3: run_with_handlers_inner's early-return paths (null policy,
    invalid argv, invalid name) abandoned registered handler
    pointers. Restructure to always consume the registration array
    regardless of return value; update the C header contract.

A5: sandlock_handler_free was extern "C" but its ud_drop docstring
    claimed unwinding panics. Switch to extern "C-unwind" to match
    the documented behavior.
B1: handler_new_rejects_invalid_exception_policy probed only one
    out-of-range value (99u32). A mutation that rejects only that
    single value would have slipped through. Iterate over the
    boundary (3u32, one past Continue=2), a mid-range value, and
    u32::MAX.

B2: action_out_layout_is_stable verified only size and alignment.
    A field reorder that preserves the total size — e.g. swapping
    kind with the implicit padding — would have passed. Pin down
    each field's byte offset via addr_of! through MaybeUninit. Add
    the same offset-based test for sandlock_notif_data_t alongside
    the existing size assertion.
In nested PID namespaces (Kubernetes pod-in-pod, KubeVirt, DinD)
the kernel can deliver a seccomp notification with notif.pid == 0
when the trapped task is invisible from the supervisor's PID
namespace. `getpgid(0)` then returns the supervisor's own pgid;
substituting that into a Kill { pgid } action sent via killpg
kills the supervisor — a sandbox-escape vector reachable by any
malicious child.

Add three defensive guards before substitution:
  - reject notif.pid <= 0,
  - reject getpgid() failure (ESRCH and friends),
  - reject resolved pgid that matches the supervisor's own.

In all three cases, fall back to the bare pid; the kernel will
reject the malformed killpg with ESRCH, but the supervisor lives.

Regression tests cover each guard with destructive verification:
removing the corresponding arm produces test failures asserting
the unfixed action would target the supervisor.
@congwang-mk
Copy link
Copy Markdown
Contributor

Thanks for the PR.

3 high-level comments:

  1. Null-handler ownership leak. When a registration array contains a NULL handler,
    run_with_handlers_inner returns without calling release_registrations, so every non-null handler leaks
    (and its ud_drop never runs) — violating the C header's "array is consumed as a whole" contract.
    One-line fix: add release_registrations(...) on the collect_registrations → None branch.

  2. Sync contract is self-contradictory. The Sync impl's safety comment leans on "supervisor
    serializes per handler," but the public C-ABI docs separately tell callers ud must be "minimally
    thread-safe" — pick one. I'd prefer requiring thread-safe ud so the ABI survives a future dispatcher
    that parallelises.

  3. handler.rs is a bit too large, 935 lines mixing ABI types, the FfiHandler adapter, helpers, and
    run entry points blurs the trust boundaries; splitting into handler/abi.rs + handler/adapter.rs +
    handler/run.rs would make them legible. Not blocking.

dzerik added 7 commits May 14, 2026 10:47
Maintainer review caught a gap in the always-consume contract: when
the registration array contains a null handler, collect_registrations
returns None and run_with_handlers_inner returned null without
calling release_registrations. The non-null entries leaked — their
ud_drop never fired — violating the public C-ABI contract documented
in sandlock.h.

Add release_registrations on that branch.

The accompanying test was internally consistent with the old contract
(it manually called sandlock_handler_free on the registered handler
to verify the dropper fires), which masked the bug. Update the test
to assert the supervisor performs the release without any manual
free — matching the public contract and serving as a regression hook
for both the implementation and the test pattern.

Resolves comment 1 from PR multikernel#44 review.
Maintainer review flagged that the Sync safety comment claimed
"supervisor serializes per handler" while the public C-ABI docs
separately required `ud` to be thread-safe — a self-contradiction.

Adopt the conservative position: the supervisor MAY dispatch handler
callbacks concurrently across different notifications (the current
loop is largely serial but the public ABI makes no concurrency
guarantee). The C caller is responsible for ensuring their opaque
`ud` is thread-safe. This survives a future dispatcher that
parallelises without an ABI break.

Resolves comment 2 from PR multikernel#44 review.
Maintainer review noted that the monolithic handler.rs (~940 lines)
mixed ABI types, the FfiHandler adapter, helpers, and run entry
points, blurring trust boundaries. Split into:

  * handler/abi.rs    — public ABI types, setters, accessors
  * handler/adapter.rs — FfiHandler + translate_action + drain helpers
  * handler/run.rs    — sandlock_run_with_handlers entry points

handler/mod.rs re-exports the full public surface so external
consumers and integration tests continue to import from
`sandlock_ffi::handler::*` unchanged.

Resolves comment 3 from PR multikernel#44 review.
The K1 fix (5deb8de) substituted `pid` for `child_pgid` when
`notif.pid <= 0`. The substituted `pid` then flowed into the Kill
action as `pgid = 0`, which `killpg(0, sig)` interprets per POSIX as
"signal the caller's process group" — i.e., the supervisor. Same
suicide vector K1 was supposed to close, just one syscall later.

Introduce an UNSAFE_PGID sentinel (i32::MIN). When pid resolution
cannot produce a safe value (pid<=0, getpgid failure, or resolved
pgid matches the supervisor's), set child_pgid = UNSAFE_PGID. Both
translate_action's Kill arm and exception_action's Kill arm check
the sentinel and refuse to produce a Kill action — translate_action
returns None (routing to exception policy), exception_action's Kill
arm degrades to Errno(EPERM).

The k1 regression test was a "for show" test that asserted
`Kill { pgid: 0 }` as the expected outcome, locking in the
suicide-vector behavior. Update it to assert exception-policy
fallback. Add a second test that registers a SIGURG handler in
the test process and verifies it is not delivered after a
lethal-pgid kill action runs through dispatch.

k2 and k3 tests also updated: their previous "fall back to bare pid"
assertions baked in a behaviour the new resolution rejects via
UNSAFE_PGID. They now assert the exception-policy fallback.

Four other tests (a1/a2 fd-drain hooks, kill-policy-on-rc-nonzero,
recovers-from-panic) used `fake_ctx()` whose pid is the test
process's own — getpgid then matches supervisor_pgid and the Kill
exception policy correctly degrades to EPERM. The two non-pipe
tests switch to an isolated-child helper so the Kill assertion stays
observable; the two pipe-bearing tests assert EPERM (their
load-bearing check is the drain, not the action kind).
A5 (d1807c3) made sandlock_handler_free use extern "C-unwind" so a
panicking user-supplied ud_drop unwinds cleanly. But the much-more-
trafficked sandlock_run_with_handlers / _interactive entry points
were still extern "C", and release_registrations dropped multiple
boxes in a loop without catch_unwind. A mid-loop ud_drop panic:
(1) aborted the process at the extern "C" boundary, and
(2) skipped cleanup of the remaining handlers in the array,
which violated the "array consumed as a whole" contract.

Switch the run entry points to extern "C-unwind". Wrap each
per-element drop in release_registrations with catch_unwind, then
re-raise the first captured panic after the loop completes so the
outer caller's panic-recovery flow still observes one unwinding
panic — but only after all handlers have been freed.

Add release_registrations_continues_after_mid_loop_panic as a
regression hook.
The C header documents sandlock_handler_ud_drop_t as "invoked exactly
once when the container is freed" — but the Rust Drop impl gated the
call on a non-null ud check, contradicting the contract. A C caller
using ud_drop for lifecycle logging (or any side effect not keyed on
ud) silently lost the cleanup whenever the container had a null ud.

The previous regression test
(handler_new_with_null_ud_and_dropper_does_not_invoke_dropper) used a
panicking dropper and asserted the dropper did NOT fire — locking in
the buggy behavior as expected output. Rename and rewrite to assert
the dropper does fire exactly once.

Idiomatic C: free(NULL) is well-defined no-op; user droppers can
mirror that on their own side if they care.
     max_len=1 path, argc/nreg bounds, errno field rename

Five minor findings from the deep bug-pattern audit:

- B-new-2: end-to-end tests asserted `stdout.contains("777")` /
  `contains("111")` / `contains("222")` — a real child pid containing
  those substrings (~1.1% per run) would mask a regression. Switch to
  exact match.

- B-new-4: action setter tests verified the kind tag and the written
  payload field but not that tag-only setters (set_continue, set_hold)
  leave the union payload untouched. Plant a sentinel in payload.none
  and assert it survives.

- A-new-3: sandlock_mem_read_cstr rejected max_len==1 even though the
  header doc says max_len >= 1 is sufficient to fit a NUL. Add a
  fast-path that probes the target for readability and writes a NUL
  on success.

- D-new-2: argc and nregistrations had no upper bound; a malicious or
  buggy C caller passing argc = u32::MAX with a small argv backing
  was unbounded-deref UB. Cap both at 4096 and add regression tests.

- E-new-1: the Rust union field `errno` had the C-side counterpart
  named `errno_value` (to avoid the <errno.h> macro collision). The
  asymmetry was a documentation hazard. Rename the Rust field to
  `errno_value` to match.
@dzerik
Copy link
Copy Markdown
Contributor Author

dzerik commented May 14, 2026

Thanks for the careful review — your 3 comments are addressed across 3 commits, and a follow-up audit caught 8 more bugs in the same patterns. CI is green (8/8).

Your 3 comments

1. Null-handler ownership leak872c885 "fix: release handlers on collect_registrations validation failure"

You were right: collect_registrations → None was the one early-return path missing release_registrations. One-line fix as suggested. The pre-existing test was masking the bug — it manually called sandlock_handler_free(valid) after the failed run, which our updated C-header contract forbids ("MUST NOT call sandlock_handler_free on registered pointers"). Rewrote the test to assert the supervisor performs the release; destructively verified that removing the fix makes the test fail with counter == 0, and that restoring the old manual free causes a double-free SIGABRT.

2. Sync contract self-contradictionc2323a0 "docs: align Sync contract — require thread-safe ud"

Adopted your preference: the supervisor may dispatch concurrently across notifications, so the C caller is responsible for ensuring ud is thread-safe. Aligned handler.rs safety comment, sandlock.h sandlock_handler_t docstring, and docs/extension-handlers.md to the same wording. Removed the misleading "serial in practice" framing.

3. handler.rs too largefcf133a "refactor: split handler module into abi/adapter/run"

Split into handler/abi.rs (457 lines, public ABI types + setters + accessors), handler/adapter.rs (270 lines, FfiHandler + translate_action + drain helpers), handler/run.rs (236 lines, run entry points + helpers), with handler/mod.rs re-exporting the public surface so external tests and downstream consumers compile unchanged. Cross-module visibility narrowed from pub(crate) to pub(super) where possible.

Proactive audit caught 8 more

Your review prompted me to run an explicit bug-pattern audit on the rest of the branch, looking specifically for repeats of the patterns we'd already hit (doc/impl mismatches, "for show" tests, ownership gaps in error paths, defense omissions). 11 findings total — 8 fixed in this push:

2 critical

598cebb — supervisor-suicide vector via killpg(0). The earlier "k1" fix changed child_pgid to 0 when notif.pid <= 0 (nested PID namespaces, KubeVirt/k8s pod-in-pod). But killpg(0, sig) per POSIX kills the caller's process group — the supervisor. The fix-of-the-fix-of-the-fix was to introduce an UNSAFE_PGID = i32::MIN sentinel that propagates through translate_action and exception_action, refusing to produce a Kill action when no safe pgid is available (routes to exception policy → Errno(EPERM)). New regression test registers SIGURG on the test process and asserts it is not delivered after a lethal-pgid Kill flows through dispatch.

0d5b480 — same extern "C" panic-abort pattern as A5, but on the high-traffic run path. The original A5 fix (d1807c3) made sandlock_handler_free use extern "C-unwind". But sandlock_run_with_handlers / _interactive_with_handlers were still extern "C", and release_registrations looped through per-element Box::from_raw without catch_unwind. A panicking user-supplied ud_drop aborted the process and skipped cleanup of remaining handlers. Switched the run entry points to extern "C-unwind", wrapped each per-element drop in catch_unwind, accumulated the first panic, and re-raise after the loop completes — so all handlers are still freed even if one of them panics, and the caller still observes one unwinding panic at the boundary.

3 important

611aba4ud_drop was gated on a non-null ud check, contradicting the C header's "invoked exactly once when the container is freed." A C caller using ud_drop for lifecycle logging (or any side-effect not keyed on ud) silently lost the cleanup. The pre-existing regression test asserted the dropper does not fire — locking in the bug. Removed the gate, rewrote the test to assert it fires exactly once.

(plus the test-pattern fix for the K1 supervisor-suicide test, bundled with 598cebb)

5 minor

f312d90 bundles:

  • exact-match assertions on end-to-end test stdout (the previous .contains("777") was flaky on real pids — ~1% per run);
  • payload-stomp checks on set_continue / set_hold (verify they're truly tag-only);
  • sandlock_mem_read_cstr fast-path for max_len == 1 (header promised that was sufficient for the NUL; impl was rejecting it);
  • upper bounds on argc / nregistrations (both capped at 4096; without bounds, slice::from_raw_parts(regs, usize::MAX) against a small backing was unbounded-deref UB);
  • Rust union field rename errno → errno_value to match the C header.

Out of scope / deferred

Same as the original PR body, two items unchanged:

  • A4 (srcfd >= 3 validation in set_inject_fd_send to prevent supervisor fd close) — still deferred pending your policy preference on deny-list vs. allow-list.
  • Unchecked setpgid(0, 0) in sandbox.rs:1201 and fork.rs:80 — out-of-scope for this PR, happy to send a small follow-up if you'd like.

Status

  • 21 commits, +3500/-150 ish.
  • 44 tests (43 Rust integration + 1 pure-C end-to-end), 0 ignored, 0 failed.
  • 8/8 CI green (Rust tests and Python tests on ubuntu-latest + ubuntu-24.04-arm).
  • mergeable: MERGEABLE, mergeStateStatus: CLEAN.

Happy to squash the audit-driven commits into the original feature commits before merge if you'd find that cleaner.

@congwang-mk congwang-mk merged commit 631b417 into multikernel:main May 14, 2026
8 checks passed
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.

2 participants