Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions crates/sandlock-ffi/include/sandlock.h
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ typedef enum sandlock_exception_policy {
SANDLOCK_EXCEPTION_DENY_EPERM = 1,
/** Let the syscall continue unchanged (explicit fail-open). */
SANDLOCK_EXCEPTION_CONTINUE = 2,
/** Fail the syscall with EIO. Idiomatic for audit-only handlers that
* propagate the failure as a plain OSError rather than
* PermissionError. */
SANDLOCK_EXCEPTION_DENY_EIO = 3,
} sandlock_exception_policy_t;

/** Opaque handler container.
Expand Down Expand Up @@ -350,6 +354,17 @@ sandlock_result_t *sandlock_run_interactive_with_handlers(
const sandlock_handler_registration_t *registrations,
size_t nregistrations);

/** Resolve a syscall name (e.g. "openat") to its kernel syscall number
* for the host architecture, for use as a `sandlock_handler_registration_t`
* `syscall_nr`. Saves callers from hard-coding architecture-specific
* numbers.
*
* Returns -1 if `name` is NULL, is not valid UTF-8, or names a syscall
* sandlock does not know. The resolvable set covers the syscalls
* sandlock filters or supervises; syscalls outside that set (e.g.
* `getpid`) return -1 and must be registered by raw number. */
int64_t sandlock_syscall_nr(const char *name);

#ifdef __cplusplus
}
#endif
Expand Down
8 changes: 7 additions & 1 deletion crates/sandlock-ffi/src/handler/abi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,11 @@ pub enum sandlock_exception_policy_t {
/// only safe when the syscall is *also* allowed by the BPF filter and
/// Landlock layer (e.g. observability handlers).
Continue = 2,
/// Treat the failure as `NotifAction::Errno(EIO)`. Idiomatic for
/// audit-only handlers: EIO propagates to the caller as a plain
/// `OSError` rather than `PermissionError`, which is closer to what
/// callers expect from a failed syscall.
DenyEio = 3,
}

/// C-callable handler entry point.
Expand Down Expand Up @@ -417,7 +422,7 @@ impl Drop for sandlock_handler_t {
/// (b) the supervisor takes ownership via `sandlock_run_with_handlers`
/// and the run completes.
/// If `on_exception` does not match a defined `sandlock_exception_policy_t`
/// discriminant (0, 1, or 2), the call returns null and no allocation occurs.
/// discriminant (0, 1, 2, or 3), the call returns null and no allocation occurs.
#[no_mangle]
pub unsafe extern "C" fn sandlock_handler_new(
handler_fn: Option<sandlock_handler_fn_t>,
Expand All @@ -432,6 +437,7 @@ pub unsafe extern "C" fn sandlock_handler_new(
0 => sandlock_exception_policy_t::Kill,
1 => sandlock_exception_policy_t::DenyEperm,
2 => sandlock_exception_policy_t::Continue,
3 => sandlock_exception_policy_t::DenyEio,
// Reject out-of-range discriminants at the FFI boundary so we never
// store an invalid enum value into the struct — reading one later
// via `match` would be undefined behaviour.
Expand Down
1 change: 1 addition & 0 deletions crates/sandlock-ffi/src/handler/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ impl FfiHandler {
}
}
sandlock_exception_policy_t::DenyEperm => NotifAction::Errno(libc::EPERM),
sandlock_exception_policy_t::DenyEio => NotifAction::Errno(libc::EIO),
sandlock_exception_policy_t::Continue => NotifAction::Continue,
}
}
Expand Down
29 changes: 29 additions & 0 deletions crates/sandlock-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,35 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_extra_allow_syscalls(
Box::into_raw(Box::new(builder.extra_allow_syscalls(names)))
}

/// Resolve a syscall name (e.g. `"openat"`) to its kernel syscall
/// number for the host architecture.
///
/// Intended for filling a `sandlock_handler_registration_t`'s
/// `syscall_nr` without hard-coding architecture-specific numbers.
///
/// Returns the syscall number on success, or `-1` if `name` is NULL,
/// is not valid UTF-8, or names a syscall sandlock does not know. The
/// resolvable set covers the syscalls sandlock filters or supervises;
/// syscalls outside that set (e.g. `getpid`) return `-1` and must be
/// registered by raw number.
///
/// # Safety
/// `name` must be NULL or a valid NUL-terminated C string.
#[no_mangle]
pub unsafe extern "C" fn sandlock_syscall_nr(name: *const c_char) -> i64 {
if name.is_null() {
return -1;
}
let name = match CStr::from_ptr(name).to_str() {
Ok(s) => s,
Err(_) => return -1,
};
match sandlock_core::context::syscall_name_to_nr(name) {
Some(nr) => i64::from(nr),
None => -1,
}
}

/// # Safety
/// `b` must be a valid builder pointer.
#[no_mangle]
Expand Down
58 changes: 56 additions & 2 deletions crates/sandlock-ffi/tests/handler_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,10 @@ fn handler_new_and_free_round_trip() {

#[test]
fn handler_new_rejects_invalid_exception_policy() {
// Cover the boundary (one past the highest valid Continue=2),
// Cover the boundary (one past the highest valid DenyEio=3),
// a mid-range value, and the extreme u32::MAX. A mutation that
// rejects only specific values would fail at least one of these.
for bad in [3u32, 4u32, 99u32, u32::MAX] {
for bad in [4u32, 5u32, 99u32, u32::MAX] {
let h = unsafe {
sandlock_handler_new(
Some(test_handler as sandlock_handler_fn_t),
Expand Down Expand Up @@ -2112,3 +2112,57 @@ fn a5_handler_free_unwinds_on_panicking_dropper() {
"expected sandlock_handler_free to unwind a panicking dropper instead of aborting",
);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ffi_handler_deny_eio_policy_on_callback_rc_nonzero() {
extern "C-unwind" fn returns_error(
_ud: *mut std::ffi::c_void,
_n: *const sandlock_ffi::notif_repr::sandlock_notif_data_t,
_m: *mut sandlock_ffi::handler::sandlock_mem_handle_t,
_out: *mut sandlock_ffi::handler::sandlock_action_out_t,
) -> i32 {
-1
}
let raw = unsafe {
sandlock_ffi::handler::sandlock_handler_new(
Some(returns_error),
std::ptr::null_mut(),
None,
sandlock_ffi::handler::sandlock_exception_policy_t::DenyEio as u32,
)
};
let h = unsafe { sandlock_ffi::handler::FfiHandler::from_raw(raw) };
let cx = fake_ctx();
let action = h.handle(&cx).await;
assert!(matches!(action, NotifAction::Errno(e) if e == libc::EIO),
"expected Errno(EIO), got {:?}", action);
}

// ----------------------------------------------------------------
// sandlock_syscall_nr — syscall-name -> number resolution.
// ----------------------------------------------------------------

#[test]
fn syscall_nr_resolves_a_known_name() {
let name = std::ffi::CString::new("openat").unwrap();
let nr = unsafe { sandlock_ffi::sandlock_syscall_nr(name.as_ptr()) };
assert_eq!(
nr, libc::SYS_openat,
"\"openat\" must resolve to the host-arch SYS_openat",
);
}

#[test]
fn syscall_nr_rejects_an_unknown_name() {
// A syscall sandlock does not filter is absent from the resolver
// table; the function must say so (-1), not guess a number.
let name = std::ffi::CString::new("definitely_not_a_syscall").unwrap();
let nr = unsafe { sandlock_ffi::sandlock_syscall_nr(name.as_ptr()) };
assert_eq!(nr, -1, "an unknown name must resolve to -1");
}

#[test]
fn syscall_nr_rejects_null() {
let nr = unsafe { sandlock_ffi::sandlock_syscall_nr(std::ptr::null()) };
assert_eq!(nr, -1, "a NULL name must resolve to -1, not dereference");
}
75 changes: 75 additions & 0 deletions docs/extension-handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -652,3 +652,78 @@ an opaque `void*`; the responsibility is on the C side.

See `crates/sandlock-ffi/tests/c/handler_smoke.c` for the canonical
end-to-end example.

## Python wrapper

The `sandlock.handler` module provides a Python-side wrapper on top of
the C ABI. See `python/tests/test_handler_smoke.py` for working
examples.

### Minimal example

```python
import sandlock
from sandlock.handler import ExceptionPolicy, Handler, NotifAction

class AuditOpens(Handler):
on_exception = ExceptionPolicy.CONTINUE # audit-only — never block

def handle(self, ctx):
path = ctx.read_cstr(ctx.args[1], max_len=4096)
print(f"opening {path!r}")
return NotifAction.continue_()

sb = sandlock.Sandbox(fs_readable=["/usr", "/etc", "/lib", "/lib64", "/bin"])
sb.run_with_handlers(
cmd=["/usr/bin/cat", "/etc/hostname"],
handlers=[("openat", AuditOpens())],
)
```

Each handler is registered for one syscall. The key is a syscall name
(`str`, e.g. `"openat"`), resolved for the host architecture, or a raw
kernel syscall number (`int`). Prefer the name — raw numbers are
architecture-specific (`openat` is 257 on x86_64 but 56 on aarch64). A
name sandlock cannot resolve raises `ValueError`; syscalls sandlock does
not filter (e.g. `getpid`) are not name-resolvable and must be passed as
an `int`. C callers can resolve a name with `sandlock_syscall_nr`.

### Threading & safety contract

- **GIL contention.** Each handler dispatch holds the GIL for the
duration of `handle()`. The supervisor may dispatch handler
callbacks concurrently across different notifications, so design
`handle()` to be fast (sub-millisecond) and to protect any mutable
handler state with your own synchronization. High-frequency
interception (e.g. per-`SYS_openat` audit 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 (e.g. the main thread exits with handlers
still registered), the trampoline checks `Py_IsInitialized()` and
returns an error, routing the notification through the handler's
`on_exception` policy. 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_handlers`
builds and drives its own Tokio runtime internally. Do not call
`Sandbox.run_with_handlers` from 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.

### Ownership rules

- **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 close `srcfd` afterwards, regardless of whether the action
was actually dispatched — the supervisor handles cleanup on all
paths.
6 changes: 6 additions & 0 deletions python/src/sandlock/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
landlock_abi_version, min_landlock_abi, confine,
)
from .inputs import inputs
from .handler import Handler, NotifAction, HandlerCtx, ExceptionPolicy
from .sandbox import Sandbox, FsIsolation, BranchAction, parse_ports, Change, DryRunResult
from ._profile import load_profile, list_profiles
from .exceptions import (
Expand Down Expand Up @@ -48,6 +49,11 @@
"parse_ports",
"Change",
"DryRunResult",
# Handler ABI
"Handler",
"NotifAction",
"HandlerCtx",
"ExceptionPolicy",
# Platform
"landlock_abi_version",
"min_landlock_abi",
Expand Down
Loading
Loading