The sandlock.handler Python wrapper layers a pythonic API over the C ABI
exposed by sandlock-ffi. This page is the single source of truth for the
Python wrapper; for the underlying C/Rust contract see
extension-handlers.md.
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],
)Handler— base class. Subclass and overridehandle(ctx) -> NotifAction.handleruns on a worker thread off the supervisor loop and may beasync def; see Deferred handlers. Set the class attributeon_exception(defaultExceptionPolicy.KILL) to choose what the supervisor does when the handler errors.HandlerCtx— frozen dataclass with the notification fields (id,pid,flags,syscall_nr,arch,instruction_pointer,args) plus child-memory accessors.NotifAction— frozen value-object. Construct via factory classmethods:continue_(),errno(value),returns(value),hold(),kill(sig, pgid),inject_fd_send(srcfd, newfd_flags),inject_bytes(data, *, seal=True, cloexec=True)(see Inject synthetic content).ExceptionPolicy— IntEnum:KILL(default),DENY_EPERM,CONTINUE,DENY_EIO.
Read a NUL-terminated string from the child at addr. Returns the decoded
string on success, None on failure (invalid address, race, or no live
mem handle).
Read length raw bytes. Returns the bytes on success, None on failure.
Write data into the child memory at addr. Returns True on success.
Resolve a path-bearing syscall argument to a Python string. With
arg=None (default), the path-argument index is inferred from
ctx.syscall_nr via a name-keyed table. Multi-path syscalls
(renameat2, rename, linkat, link, symlinkat, symlink) and
unknown syscalls raise ValueError — pass arg= explicitly in those
cases.
Known single-path syscalls (auto-inferred):
| Syscall | path arg |
|---|---|
openat, unlinkat, mkdirat, newfstatat, statx, faccessat, readlinkat, execveat |
1 |
open, unlink, mkdir, rmdir, stat, lstat, access, readlink, execve |
0 |
Multi-path syscalls — call twice with explicit arg=:
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_()A live HandlerCtx returns the decoded string; a stale or absent mem
handle returns None.
Imported from sandlock.presets. The preset classes are deliberately
NOT re-exported from the root sandlock package — root surface stays
minimal; callers reach for presets when they want them. (The core
handler types — Handler, NotifAction, HandlerCtx,
ExceptionPolicy — are re-exported at the root.)
The set of modern path-bearing syscalls a generic file-operation handler is typically registered against:
COMMON_PATH_SYSCALLS = [
"openat", "unlinkat", "newfstatat", "statx", "faccessat",
"readlinkat", "mkdirat", "execveat", "execve",
]Used with a list comprehension to register one handler on all of them:
handlers = [(s, my_handler) for s in COMMON_PATH_SYSCALLS]on_exception=CONTINUE— audit must never block.- Calls
callback(path, ctx)on every intercepted syscall (including whenpath is None, so the caller sees "couldn't read"). - Returns
NotifAction.continue_().
on_exception=KILL— security handler, fail-closed.denyis alist[str]offnmatchpatterns; passing a single string raisesTypeError(the API is uniform).- If
pathmatches any pattern, returnsNotifAction.errno(errno); otherwise returnsNotifAction.continue_(). path is None→continue_(). A deny-list does not claim "everything else is allowed", so when the path cannot be classified we defer to Landlock and other handlers in the chain.
on_exception=KILL— security handler, fail-closed.allowis alist[str]offnmatchpatterns; passing a single string raisesTypeError.- If
pathmatches any pattern, returnsNotifAction.continue_(); otherwise returnsNotifAction.errno(errno). path is None→errno(errno). An allow-list claims "everything except the listed paths is denied", so failing to verify means failing closed.
on_exception=CONTINUE— observational.- Logs one line per intercepted syscall:
syscall=N pid=P args=(a0, a1, a2, a3, a4, a5). - Default
loggerislogging.getLogger("sandlock.audit").info. AnyCallable[[str], None]works (e.g.list.appendin tests). - If
loggerraises, the exception is absorbed byon_exception=CONTINUE— the child proceeds but the log line is silently lost.
audit = AuditPathsHandler(callback=lambda path, _ctx: print(path))
sb.run_with_handlers(cmd, [(s, audit) for s in COMMON_PATH_SYSCALLS])deny = PathDenyHandler(deny=["/etc/*", "/var/lib/*"])
sb.run_with_handlers(cmd, [(s, deny) for s in COMMON_PATH_SYSCALLS])allow = PathAllowHandler(allow=["/tmp/sandbox/*", "/usr/lib/*"])
sb.run_with_handlers(cmd, [(s, allow) for s in COMMON_PATH_SYSCALLS])Anything not under those prefixes returns EACCES. Any syscall whose
path the handler cannot read also returns EACCES (fail-closed).
from sandlock.handler import Handler, NotifAction, ExceptionPolicy
class FakePid(Handler):
on_exception = ExceptionPolicy.KILL
def handle(self, ctx):
return NotifAction.returns(777)
sb.run_with_handlers(cmd, [("getpid", FakePid())])NotifAction.inject_bytes(data) serves data to the guest as the fd returned
by its open/openat, backed by an in-memory file. Use it instead of
hand-rolling os.memfd_create + os.write + os.lseek: it builds the memfd,
rewinds it, seals it read-only, and transfers fd ownership to the supervisor
(the caller must NOT close it).
from sandlock.handler import Handler, NotifAction, ExceptionPolicy
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_()By default the fd is sealed read-only (the guest cannot modify or resize it)
and O_CLOEXEC (the content does not leak into programs the guest later
execs). Pass seal=False for a writable fd, or cloexec=False to mirror a
guest that opened the file without O_CLOEXEC. A rare allocation failure
raises OSError, which the handler's on_exception policy then governs.
import signal
from sandlock.handler import Handler, NotifAction
class KillOnEtc(Handler):
def handle(self, ctx):
path = ctx.read_path()
if path and path.startswith("/etc/"):
return NotifAction.kill(signal.SIGKILL, pgid=0)
return NotifAction.continue_()Caveat: ctx.read_path() without an explicit arg= raises
ValueError for syscalls not in the known path table (see the
read_path accessor section). Under the default
on_exception=KILL policy that ValueError becomes a kill signal
to the child. Either register the handler only against syscalls in
COMMON_PATH_SYSCALLS, pass arg= explicitly, or set
on_exception=ExceptionPolicy.CONTINUE on your handler.
Register multiple handlers on the same syscall — the supervisor calls
them in registration order, stopping at the first non-Continue action:
sb.run_with_handlers(cmd, [
("openat", audit),
("openat", deny),
])On syscalls that already carry built-in handlers (openat for chroot
path normalization, COW write redirection, procfs virtualization;
clone/fork/execve for resource accounting; and others), user
handlers are appended after all builtins — see
build_dispatch_table in
crates/sandlock-core/src/seccomp/dispatch.rs. Dispatch short-circuits
on the first non-Continue action, so a user handler only fires if
every built-in for that syscall first returned Continue. Built-ins
cannot be overridden or removed; this is the security boundary. When
testing security-critical user handlers (e.g. PathDenyHandler on
openat), exercise them against the actual built-in set on your
syscall list rather than against an empty dispatch table.
-
GIL contention. Each handler dispatch holds the GIL for the duration of
handle(). The supervisor may dispatch handler callbacks concurrently across different notifications, so protect any mutable handler state with your own synchronization. High-frequency interception (e.g. per-SYS_openataudit on a busy workload) will serialize on the GIL and slow down the sandbox's trapped syscalls. See Deferred handlers. -
Interpreter finalization. If
Py_FinalizeExruns while the sandbox is still alive (e.g. the main thread exits with handlers still registered), the trampoline checksPy_IsInitialized()and returns an error, routing the notification through the handler'son_exceptionpolicy. Do not rely on this for clean shutdown — wait for the run to finish before tearing down the interpreter. -
Native crashes inside
handle(). A segfault inside a Python handler is not recoverable: the supervisor task hangs and the trapped child is held indefinitely. Write defensive handlers; this is a user responsibility. -
Tokio runtime reentrancy. The C ABI's
sandlock_run_with_handlersbuilds and drives its own Tokio runtime internally. Do not callSandbox.run_with_handlersfrom a thread that already runs a Tokio runtime — the FFI will panic, and the panic surfaces as a Python exception. Pure-Python use (the common case) is unaffected.
Every handle() runs on a worker thread, off the supervisor's notification
loop, so a slow callback delays only its own syscall. Define handle as
async def to await slow work; the coroutine is driven to completion on
that worker thread. You do not set any flag.
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 network GET
# inject_bytes builds the sealed memfd and transfers fd ownership to
# the supervisor (see "Inject synthetic content" above).
return NotifAction.inject_bytes(data)CPython releases the GIL while a coroutine awaits I/O, so multiple async
handlers doing network work overlap. It does not parallelize CPU-bound
Python work (the GIL still serializes that); for that, push the hot path
into a C extension that releases the GIL.
ctx and its child-memory accessors (read_cstr, read, write) stay
valid for the whole call, so you can read paths and write results across
await points.
Contract (enforced by the supervisor):
- Same chain as any handler. Returning
continue_()hands the syscall to the next handler registered on it, and the result still passes the execve argv-safety checks and fork accounting. - No deadline. A syscall waits for its handler to return, however long that takes, and the run waits for every handler before completing.
See extension-handlers.md for the
underlying Rust/C contract.
-
Handler instances must outlive the run. The Sandbox holds a strong reference for the duration of the run; the reference is released when the run completes (success or failure).
-
File descriptors passed via
NotifAction.inject_fd_send(srcfd)transfer ownership to the supervisor on dispatch. The Python caller must NOT closesrcfdafterwards, regardless of whether the action was actually dispatched — the supervisor handles cleanup on all paths.
The Python wrapper sits on the C ABI declared in
crates/sandlock-ffi/include/sandlock.h. For the C ABI contract,
exception policies at the supervisor level, and ownership across the
boundary see the C/Rust sections of
extension-handlers.md.