Skip to content
Open
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
4 changes: 4 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
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: 27 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 @@ -2095,3 +2095,28 @@ 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);
}
67 changes: 67 additions & 0 deletions docs/extension-handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -652,3 +652,70 @@ 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=[(257, AuditOpens())], # 257 = x86_64 SYS_openat
)
```

### 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