Home
Why Sandlock
How It Works Use Cases Comparison Security Model
Docs
Documentation Home Getting Started CLI Reference Python SDK Sandbox Reference
Products
Overview Sandbox HTTP API Sandbox Scheduler
GitHub Schedule a Demo
Sandlock / Docs /Extension Handlers

Extension Handlers

Everything up to here is policy you declare. Handlers are the layer where the sandbox becomes programmable: your own code runs inside the supervisor, sees a syscall before the kernel acts on it, and decides what the workload observes.

Sandlock routes every intercepted syscall through a chain-of-responsibility table. Built-in handlers (chroot, COW, procfs, network, port remap, resource accounting) register for the syscall numbers they care about; within a chain, handlers run in registration order and the first one to return something other than continue wins.

Handler is the public extension trait that appends your handlers to that chain, after all the built-ins. With it you can observe any syscall Sandlock intercepts, deny it, fabricate its return value, hand the workload a file that does not exist on disk, or park the call while you go ask a remote service.

The mechanism is one feature with three bindings. This page covers the model and the Rust API first, since that is where the semantics are defined, then the Python and C surfaces, which differ only in shape.

Why this is a trait and not a fork

The kernel permits exactly one SECCOMP_FILTER_FLAG_NEW_LISTENER per process, which means one supervisor task. Code that wants to intercept extra syscalls in the same sandbox as the built-ins has to run inside the same dispatch loop. There is no second listener to attach to.

Without an extension point the only options would be forking sandlock-core or duplicating its supervisor. The handler trait exists so a downstream crate can depend on sandlock-core as an ordinary dependency, with no fork, no [patch.crates-io], and no copy of the notification loop to keep in sync.

Writing a handler in Rust

The trait has a single method returning a boxed future, kept dyn-compatible so the supervisor can store handlers as Vec<Arc<dyn Handler>>. State lives on the struct's fields, which avoids Arc::clone ladders and closure ceremony at the call site.

rust
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use sandlock_core::{Handler, HandlerCtx, Sandbox};
use sandlock_core::seccomp::notif::NotifAction;

struct OpenAudit { count: AtomicU64 }

impl Handler for OpenAudit {
    fn handle<'a>(
        &'a self,
        cx: &'a HandlerCtx,
    ) -> Pin<Box<dyn Future<Output = NotifAction> + Send + 'a>> {
        Box::pin(async move {
            let n = self.count.fetch_add(1, Ordering::SeqCst) + 1;
            eprintln!("[audit #{n}] pid={} openat", cx.notif.pid);
            NotifAction::Continue
        })
    }
}

HandlerCtx is passed by reference and exposes only the kernel notification (notif) and the supervisor's seccomp listener descriptor (notif_fd). Supervisor-internal state is deliberately outside this contract; handler state belongs on the implementor.

Closures

A blanket implementation covers closures, which is convenient for prototyping or trivial state. Switch to a struct as soon as the handler grows non-trivial captures.

rust
let audit = move |cx: &HandlerCtx| {
    let counter = Arc::clone(&counter_clone);
    let pid = cx.notif.pid;
    async move {
        let n = counter.fetch_add(1, Ordering::SeqCst) + 1;
        eprintln!("[audit #{n}] pid={pid} openat");
        NotifAction::Continue
    }
};

Registering handlers in Rust

Two entry points, both of which spawn the sandbox, wait for it to exit, and return the result.

Methodstdio
run_with_handlers(cmd, handlers)Captured, returned in the run result
run_interactive_with_handlers(cmd, handlers)Inherited from the parent terminal, for REPL-like workflows
rust
let policy = Sandbox::builder()
    .fs_read("/usr").fs_read("/lib").fs_read("/etc")
    .fs_write("/tmp")
    .build()?;

let result = policy.clone().with_name("openat-audit")
    .run_with_handlers(
        &cmd_ref,
        [(libc::SYS_openat, audit)],
    )
    .await?;

Several handlers at once

rust
sandbox.run_with_handlers(
    &cmd,
    [
        (libc::SYS_openat, openat_handler),
        (libc::SYS_close,  close_handler),
        (libc::SYS_mmap,   mmap_deny),
    ],
).await?;

When the iterator mixes handlers of different concrete types, which is the norm in a real downstream crate, erase them behind Box<dyn Handler> or Arc<dyn Handler>. Both implement Handler themselves, so the generic parameter resolves to one type.

rust
let openat_h: Box<dyn Handler> = Box::new(my_openat_handler);
let close_h:  Box<dyn Handler> = Box::new(MyCloseStruct { /* ... */ });

sandbox.run_with_handlers(
    &cmd,
    [(libc::SYS_openat, openat_h), (libc::SYS_close, close_h)],
).await?;

Registration is validated before fork

Two classes of error are caught up front, rather than becoming a handler that silently never fires:

  • SyscallError::Negative or SyscallError::UnknownForArch from Syscall::checked. Without validation, passing -5 as a syscall number would compile and simply never fire, because the cBPF filter cannot emit a comparison for a number the architecture does not know.
  • HandlerError::OnDenySyscall if a registered syscall is in the default blocklist or the policy's extra denials. See the blocklist bypass guard.

BPF coverage is merged for you

Registration collects the syscall numbers your handlers declare and merges them into the cBPF notification list installed in the child before execve. This step matters: without it the kernel would never raise SECCOMP_RET_USER_NOTIF for a syscall no built-in intercepts, and your handler would never run. The merge is dedup-aware, so an openat registered by both a built-in and a user handler produces a single comparison in the assembled program.

What a handler can return

VariantEffect
ContinueFall through to the next handler; if last, the kernel resumes the syscall.
Errno(e)Return -e to the guest. The kernel does not run the syscall.
ReturnValue(val)Return val to the guest without running the syscall. This is how you synthesize read, fstat, getdents64, and friends.
InjectFd { srcfd, targetfd }Inject a descriptor into the guest at a specific slot, then continue.
InjectFdSendTracked { srcfd, newfd_flags, on_success }Inject a descriptor; on_success runs synchronously when the kernel returns the slot, so bookkeeping cannot race the guest seeing the new fd.
Kill { sig, pgid }Signal the guest's process group.
Defer(Deferred)Run the carried future off the supervisor loop and send its terminal action later. See deferred handlers.

Everything except Continue ends the chain, so later handlers on the same syscall do not run.

Reading and writing the guest's memory

The kernel passes most syscall arguments by pointer: paths in openat, buffers in write, the struct stat slot in newfstatat. Three helpers in sandlock_core::seccomp::notif do this correctly.

HelperPurpose
read_child_cstrNUL-terminated string, typically a path. Page-aware, never crosses an unmapped boundary.
read_child_memFixed-length byte buffer.
write_child_memSynthesize return data into the guest, such as a fabricated getdents64 listing or stat buffer.

All three bracket the access with notification-id validity checks before and after the process_vm_readv or process_vm_writev call, so they cannot race with the kernel aborting or releasing the trapped syscall while the supervisor is reading.

rust: deny paths by suffix
use sandlock_core::seccomp::notif::{read_child_cstr, NotifAction};

struct ExtensionDenyHandler { denied_suffixes: Vec<String> }

impl Handler for ExtensionDenyHandler {
    fn handle<'a>(
        &'a self,
        cx: &'a HandlerCtx,
    ) -> Pin<Box<dyn Future<Output = NotifAction> + Send + 'a>> {
        Box::pin(async move {
            // openat(2): args[1] is `const char *pathname`. 4096 = PATH_MAX.
            let path = match read_child_cstr(cx.notif_fd, cx.notif.id, cx.notif.pid,
                                             cx.notif.data.args[1], 4096) {
                Some(p) => p,
                // Rare: NULL pointer, or the kernel released the notification
                // mid-read. Pass through; the kernel usually fails it with EFAULT.
                None => return NotifAction::Continue,
            };

            if self.denied_suffixes.iter().any(|s| path.ends_with(s)) {
                return NotifAction::Errno(libc::EACCES);
            }
            NotifAction::Continue
        })
    }
}

Reading a path safely is not the same as filtering on one safely. The id bracketing protects the read itself; it does not change the fact that the kernel re-reads user memory after a continue verdict. Keep Landlock as the authoritative layer for path policy, and treat handler-side path inspection as auditing or as a supplementary denial rather than as the boundary. See why policy events carry no path strings.

Injecting synthetic content

This is the capability that turns interception into virtualization. A handler can hand the guest a file that exists nowhere on disk: a generated config, a secret, an object fetched from remote storage.

rust
use sandlock_core::seccomp::notif::NotifAction;

// Inside a handler: serve these bytes as the openat result fd.
return NotifAction::inject_bytes(b"INJECTED CONTENT\n");

inject_bytes creates an in-memory file, populates it, rewinds it, seals it, and returns the inject action carrying that descriptor. It owns the descriptor end to end: the supervisor creates, populates, seals, and closes it, so the caller never touches a raw fd and there is no "when do I close this" question. On allocation failure it collapses to Errno(EIO), which is why it returns a NotifAction directly rather than a Result.

Two defaults suit the dominant case, which is synthetic and often sensitive read-only content:

  • Sealed read-only. The descriptor carries F_SEAL_SEAL | F_SEAL_WRITE | F_SEAL_GROW | F_SEAL_SHRINK, so the guest cannot modify or resize what it is handed. Sealing is best-effort: on a kernel without sealing support the fd is still injected but unsealed, bounded only by the rest of the policy.
  • O_CLOEXEC on the child-side descriptor, so the content does not leak into programs the guest later executes.

Why O_CLOEXEC is the default. Without it, a subprocess the guest execves inherits an open descriptor to the injected content and can read it without ever opening the file. For secret injection that is a silent leak, so inject_bytes closes the descriptor across exec.

When you are impersonating a real file and want byte-for-byte the semantics the guest asked for, such as a writable descriptor or the guest's own O_CLOEXEC choice, drop to content_memfd(content, seal), which returns an OwnedFd you pass to InjectFdSend yourself.

rust: mirror the guest's own request
use sandlock_core::seccomp::notif::{content_memfd, NotifAction};

let fd = match content_memfd(&bytes, /* seal */ false) {
    Ok(fd) => fd,
    Err(_) => return NotifAction::Errno(libc::EIO),
};
let cloexec = (cx.notif.data.args[2] as i32 & libc::O_CLOEXEC) != 0;
NotifAction::InjectFdSend {
    srcfd: fd,
    newfd_flags: if cloexec { libc::O_CLOEXEC as u32 } else { 0 },
}

Reach for InjectFdSendTracked only when you must know the exact descriptor number the kernel assigned in the child, for example to key per-fd bookkeeping. Its on_success callback delivers that number without racing the guest.

Deferred handlers

The supervisor processes notifications sequentially, so a handler that blocks on a network round trip or a slow lock stalls every other trapped syscall until it returns. NotifAction::Defer is the escape hatch.

A handler that returns Defer hands the supervisor an owned 'static future. The supervisor moves it onto a worker task, lets the notification loop proceed immediately, and sends the response, keyed by the notification id, when the future resolves. The trapped child stays parked in the syscall until then, so the id stays valid and the child-memory helpers keep working inside the deferred future.

rust
fn handle<'a>(
    &'a self,
    cx: &'a HandlerCtx,
) -> Pin<Box<dyn Future<Output = NotifAction> + Send + 'a>> {
    // Copy out what the deferred future needs: `notif` is Copy, `notif_fd`
    // is a RawFd, Arc state is cloned. Never borrow &self or cx, since the
    // deferred future is 'static and outlives this call.
    let (fd, id, pid) = (cx.notif_fd, cx.notif.id, cx.notif.pid);
    let key = read_child_cstr(fd, id, pid, cx.notif.data.args[1], 4096);
    let backend = self.backend.clone();
    Box::pin(async move {
        let Some(key) = key else { return NotifAction::Continue };
        NotifAction::defer(async move {
            // Runs on a worker, off the supervisor loop. The child is still
            // parked, so child-memory helpers and `id` are valid here.
            match backend.get(&key).await {
                Ok(data) => NotifAction::inject_bytes(&data),
                Err(_) => NotifAction::Errno(libc::EIO),
            }
        })
    })
}

The contract

  • Terminal decision. Defer is non-Continue, so it short-circuits the chain exactly like Errno. A deferring handler decides the outcome.
  • No deferral on freeze or fork syscalls. Refused with EPERM on execve, execveat, and fork-creating syscalls, because moving the response off-loop would skip the argv-safety freeze and the process creation-tracking those paths require before continuing.
  • Bounded fan-out. At most DEFER_MAX_INFLIGHT deferred futures run concurrently; beyond that, further deferrals fail fast with EAGAIN rather than queuing. The cap also bounds the resources workers hold.
  • No nesting. A deferred future that itself resolves to Defer is a bug; the supervisor collapses it to EIO so the child is never left wedged.
  • Stale id. If the child exits mid-defer, the eventual response is a no-op and the child-memory helpers fail safe.

Do not defer trivial fast handlers: the worker hop adds latency. Defer only when the work would otherwise block the loop.

Continue-site safety

Because the response sent for one notification gates the kernel's resumption of that trapped syscall, a handler must never leave the loop waiting on itself.

Concretely: never hold a tokio::sync::Mutex or RwLock guard across an .await inside a handler. If the guard is alive when control returns to the supervisor loop, the next notification that needs the same lock parks, the response for the current notification is never sent, and the child stays trapped in the syscall forever. Acquire, mutate, drop, and only then await.

For work that is genuinely slow rather than a short critical section, do not block the loop at all: return Defer.

Today's dispatch is largely serial, but treat that as an implementation detail rather than a contract. The trait already requires Send + Sync, and the C ABI requires the user-data pointer to be thread-safe, precisely so a future dispatcher can parallelize without an ABI break.

State patterns

handle takes &self, so anything mutated needs interior mutability. Pick by access pattern:

PatternUse whenExample
AtomicU64 / AtomicUsizeCounter or single value, lock-freeAudit call count
parking_lot::Mutex<T>Short critical section, never crosses .awaitAppend to a Vec<Event> log buffer
tokio::sync::RwLock<T>Read-heavy, rebuilt occasionallyA small virtual file table refreshed on change
dashmap::DashMap<K, V>High-fanout per-key concurrent accessPer-pid open-file table keyed by (pid, fd)

A synchronous parking_lot::Mutex is the safer default for short critical sections precisely because it cannot be held across an .await, and so cannot deadlock the supervisor loop.

The security boundary

User handlers run after built-ins. By the time one observes a notification, the built-ins have already normalized paths for chroot, applied the Landlock pre-checks, and short-circuited anything conflicting with the policy.

A handler can

  • Observe every syscall Sandlock intercepts, provided the built-ins for that syscall returned continue.
  • Fake results with ReturnValue or Errno, again only after the built-ins continued.
  • Inject descriptors to materialize virtual file content without touching the host filesystem.

A handler cannot

  • Remove a built-in handler.
  • Reorder itself to run before a built-in.
  • Skip a built-in's Errno, ReturnValue, or Kill response.

The ordering is enforced structurally, not by convention: the dispatch table registers built-ins into an empty table before iterating user handlers, and the chain evaluator short-circuits on the first non-continue result. It is covered by unit tests against the dispatch walker and by end-to-end tests driving a live Landlock and seccomp sandbox.

The blocklist bypass guard

This one is worth understanding, because it closes a hole that would otherwise be easy to open by accident.

The cBPF program emits notification comparisons before deny comparisons, so a syscall present in both lists reaches SECCOMP_RET_USER_NOTIF first. That means a handler registered on a syscall in the default blocklist would convert a kernel-level deny into a user-supervised path, and a handler returning continue would become SECCOMP_USER_NOTIF_FLAG_CONTINUE, so the kernel would actually run the syscall, silently bypassing the deny.

Sandlock rejects that configuration at registration time with HandlerError::OnDenySyscall, covering both the default blocklist and the policy's extra_deny_syscalls. Because Sandlock always installs its default blocklist, this guard is always active.

Panics

Handler calls are not wrapped in catch_unwind. A panic inside a handler propagates up the task driving the supervisor, and the child is killed by the watchdog. To tolerate bugs in a downstream handler, wrap it, using the futures crate's catch_unwind rather than the synchronous std::panic::catch_unwind, which does not apply to async futures.

rust
use std::panic::AssertUnwindSafe;
use futures::future::FutureExt as _;

struct PanicSafe<H: Handler>(H);

impl<H: Handler> Handler for PanicSafe<H> {
    fn handle<'a>(
        &'a self,
        cx: &'a HandlerCtx,
    ) -> Pin<Box<dyn Future<Output = NotifAction> + Send + 'a>> {
        Box::pin(async move {
            AssertUnwindSafe(self.0.handle(cx))
                .catch_unwind()
                .await
                .unwrap_or(NotifAction::Continue) // fail open on panic
        })
    }
}

What people build with this

A virtual filesystem that streams to object storage

Streaming guest-generated artifacts to object storage as the process runs, rather than collecting them after it exits, needs interceptors on openat(O_CREAT), write, and close that translate filesystem operations into multipart-upload calls.

Those uploads are slow network operations, so the handlers return Defer and the uploads run off the notification loop. Each handler observes the post-built-in view, so by the time it runs the openat arguments are already chroot-normalized and the path can be trusted against the configured policy.

A tamper-proof audit trail

Regulated environments need a guaranteed log of every file read and write the guest performs. Python-level wrappers such as import hooks are trivial for the guest to circumvent through ctypes or raw syscalls, and eBPF file tracing needs CAP_BPF, which is often unavailable in managed Kubernetes.

A handler on openat, write, and unlinkat captures the call before the kernel acts on it. The guest cannot bypass it without bypassing seccomp itself, which Sandlock blocks at the BPF level. A runnable example lives at crates/sandlock-core/examples/openat_audit.rs.

Virtual files with no host backing

A read-only virtual file, whether /etc/hostname or a configuration generated per call, is exposed by intercepting openat and injecting a sealed in-memory file. The guest reads the content normally and no host filesystem is touched.

Limitations

  • No built-in override. Security-critical handlers such as chroot and COW always run first. Changing their behaviour means modifying Sandlock itself.
  • No before-built-in priority. An audit handler that wants to observe calls the built-ins rejected is a coherent use case, but it needs a handler-priority concept that does not exist yet; the current API only appends to the chain.
  • Not part of the policy. Registering handlers is a runtime action, not a serializable part of a policy. Policy stays a pure data struct, which is why handlers never appear in a TOML profile.

Python

The Python wrapper in sandlock.handler exposes the same model. Everything above about ordering, the security boundary, and the deferral contract applies unchanged; what follows is the Python surface and the things that are Python-specific.

python
import sandlock
from sandlock.presets import AuditPathsHandler, COMMON_PATH_SYSCALLS

audit = AuditPathsHandler(callback=lambda path, _ctx: print(f"open {path}"))

sb = sandlock.Sandbox(fs_readable=["/usr", "/etc", "/lib", "/bin"])
sb.run_with_handlers(
    cmd=["/usr/bin/cat", "/etc/hostname"],
    handlers=[(s, audit) for s in COMMON_PATH_SYSCALLS],
)

Syscalls are named with strings here rather than libc::SYS_* constants, so registration reads as ("openat", handler).

Core types

  • Handler: subclass and override handle(ctx) -> NotifAction. Set the class attribute on_exception to choose what the supervisor does if your handler raises.
  • HandlerCtx: a frozen dataclass with the notification fields (id, pid, flags, syscall_nr, arch, instruction_pointer, args) plus the child-memory accessors below.
  • NotifAction: a frozen value object built through factories: continue_(), errno(v), returns(v), hold(), kill(sig, pgid), inject_fd_send(srcfd, newfd_flags), and inject_bytes(data, *, seal=True, cloexec=True).
  • ExceptionPolicy: KILL (the default), DENY_EPERM, CONTINUE, DENY_EIO.

The core types are re-exported from the root sandlock package; the presets deliberately are not, so the root surface stays minimal and you reach into sandlock.presets when you want them.

Reading the guest

AccessorReturns
read_cstr(addr, max_len)Decoded string, or None on failure
read(addr, length)Raw bytes, or None
write(addr, data)True on success
read_path(arg=None, max_len=4096)A path-bearing argument as a string, or None

read_path() infers which argument holds the path from the syscall number, using a name-keyed table:

SyscallPath argument
openat, unlinkat, mkdirat, newfstatat, statx, faccessat, readlinkat, execveat1
open, unlink, mkdir, rmdir, stat, lstat, access, readlink, execve0

Multi-path syscalls (renameat2, rename, linkat, link, symlinkat, symlink) and unknown syscalls raise ValueError; pass arg= explicitly there.

python
def handle(self, ctx):
    # renameat2(olddirfd, oldpath, newdirfd, newpath, flags)
    src = ctx.read_path(arg=1)
    dst = ctx.read_path(arg=3)
    return NotifAction.continue_()

That ValueError is a kill by default. Under on_exception=KILL, calling read_path() with no arg= on a syscall outside the table raises, and the raise becomes a kill signal to the child. Either register only against syscalls in COMMON_PATH_SYSCALLS, pass arg= explicitly, or set on_exception=ExceptionPolicy.CONTINUE.

Presets

COMMON_PATH_SYSCALLS is the set of modern path-bearing syscalls a generic file-operation handler is normally registered against: openat, unlinkat, newfstatat, statx, faccessat, readlinkat, mkdirat, execveat, execve.

PresetBehaviourOn handler error
AuditPathsHandler(callback, max_len=4096)Calls callback(path, ctx) on every intercepted syscall, including when the path is None so the caller sees "couldn't read", then continuesCONTINUE
PathDenyHandler(deny, errno=EPERM)Denies paths matching any fnmatch pattern; first match winsKILL
PathAllowHandler(allow, errno=EACCES)Allows only matching paths, denies the restKILL
LogSyscallsHandler(logger=None)Logs syscall=N pid=P args=(…) per call. Defaults to the sandlock.audit logger; any callable taking a string worksCONTINUE

The two path-matching presets treat an unreadable path differently, and the asymmetry is deliberate. A denylist that cannot classify the path continues, because a denylist only claims the listed patterns are denied and the decision defers to Landlock and the rest of the chain. An allowlist that cannot classify the path denies, because an allowlist claims everything unlisted is denied, so failing to verify means failing closed.

Both reject a bare string where a list is expected, with TypeError, so the API stays uniform.

Recipes

python
# Deny a directory tree
deny = PathDenyHandler(deny=["/etc/*", "/var/lib/*"])
sb.run_with_handlers(cmd, [(s, deny) for s in COMMON_PATH_SYSCALLS])

# Allowlist, fail-closed: anything else, and any unreadable path, is EACCES
allow = PathAllowHandler(allow=["/tmp/sandbox/*", "/usr/lib/*"])
sb.run_with_handlers(cmd, [(s, allow) for s in COMMON_PATH_SYSCALLS])

# Two handlers on one syscall: registration order, first non-continue wins
sb.run_with_handlers(cmd, [("openat", audit), ("openat", deny)])
python: synthesize a result, and a virtual file
from sandlock.handler import Handler, NotifAction, ExceptionPolicy

class FakePid(Handler):
    on_exception = ExceptionPolicy.KILL

    def handle(self, ctx):
        return NotifAction.returns(777)

class HostnameFile(Handler):
    on_exception = ExceptionPolicy.KILL

    def handle(self, ctx):
        if ctx.read_path() == "/etc/hostname":
            return NotifAction.inject_bytes(b"sandbox\n")
        return NotifAction.continue_()

sb.run_with_handlers(cmd, [("getpid", FakePid()), ("openat", HostnameFile())])

inject_bytes behaves as it does in Rust: it builds the in-memory file, rewinds it, seals it read-only, and transfers descriptor ownership to the supervisor, so the caller must not close it. Pass seal=False for a writable descriptor, or cloexec=False to mirror a guest that opened the file without O_CLOEXEC. A rare allocation failure raises OSError, which your on_exception policy then governs.

Slow work: async def handle

Python's deferral is simply defining handle as a coroutine. There is no flag to set. The coroutine is driven to completion on a worker thread, so it can await slow work without blocking the supervisor loop.

python
class FetchHandler(Handler):
    def __init__(self, backend):
        self.backend = backend

    async def handle(self, ctx):
        key = ctx.read_path()                # read the path before the slow work
        if key is None:
            return NotifAction.continue_()
        data = await self.backend.get(key)  # slow GET, awaited off-loop
        return NotifAction.inject_bytes(data)

This helps Python specifically: the coroutine runs on a worker thread and CPython releases the GIL while awaiting I/O, so several async handlers doing network work genuinely overlap while the supervisor loop stays free. It does not parallelize CPU-bound Python work, since the GIL still serializes that; for that, push the hot path into a C extension that releases the GIL.

ctx and its memory accessors stay valid for the whole coroutine, so paths can be read and results written across await points. The deferral contract is the same one described above: terminal decision, refused on execve and fork-creating syscalls, and bounded in-flight with EAGAIN beyond the cap.

Make handle async only when it must do slow work. For fast handlers such as audit counters and path checks, a synchronous handle runs inline at lower latency.

Threading and safety

  • GIL contention. Each dispatch holds the GIL for the duration of handle(), and the supervisor may dispatch callbacks concurrently across notifications. Design handle() to be fast, sub-millisecond, and protect mutable handler state with your own synchronization. High-frequency interception, such as per-openat auditing on a busy workload, will serialize on the GIL and can stall the supervisor.
  • Interpreter finalization. If Py_FinalizeEx runs while the sandbox is still alive, the trampoline detects it and routes the notification through on_exception. Do not rely on this for clean shutdown; wait for the run to finish before tearing down the interpreter.
  • Native crashes. A segfault inside a Python handler is not recoverable: the supervisor task hangs and the trapped child is held indefinitely. Write defensive handlers.
  • Tokio reentrancy. The C ABI builds and drives its own Tokio runtime, so run_with_handlers must not be called from a thread already running one. The FFI panics, surfacing as a Python exception. Pure-Python use, the common case, is unaffected.

Ownership

Handler instances must outlive the run; the sandbox holds a strong reference for its duration and releases it when the run completes, on success or failure. Descriptors passed via inject_fd_send(srcfd) transfer ownership to the supervisor on dispatch, so the caller must not close srcfd afterwards, whether or not the action was actually dispatched.

Testing security-critical handlers. User handlers only fire when every built-in for that syscall first returned continue. When testing something like a PathDenyHandler on openat, exercise it against the real built-in set for your syscall list rather than against an empty dispatch table, or you will be testing a path production never takes.

C and other languages

The same model is available to any language with a C FFI, through the sandlock-ffi shared library whose header declares the interface. The callback contract is strict and worth reading before writing one:

  1. Return zero exactly when you have called one, and only one, of the action setters on the output parameter.
  2. Return non-zero on any internal error. The supervisor then applies the handler's on_exception policy, which defaults to kill.
  3. Never retain the notification, memory-handle, or action-output pointers past the return statement. They are stack-scoped to a single callback.

Because the supervisor may invoke a C callback from multiple worker threads across different notifications, the caller must ensure their user-data pointer is thread-safe, either immutable or guarded by their own synchronization. Rust offers no synchronization for an opaque void*; that responsibility sits on the C side.

The C counterpart of inject_bytes takes a flags bitmask whose zero value is the safe default, sealed read-only with O_CLOEXEC. SANDLOCK_INJECT_WRITABLE leaves the in-memory file writable, and SANDLOCK_INJECT_NO_CLOEXEC clears the close-on-exec bit. The supervisor copies your data during the call and owns the resulting descriptor, so the caller passes no fd and frees nothing; data may be NULL when the length is zero, which injects an empty file.