Rust API
sandlock-core is the library every other interface is built on. Working in Rust gives you the typed builder, the async run API, and the handler trait that lets you extend the supervisor itself.
Adding the dependency
Sandlock's crates live in one workspace. Depend on sandlock-core directly, either from a path in a checkout or from the Git repository.
[dependencies]
sandlock-core = { git = "https://github.com/multikernel/sandlock" }
Rust 1.70 or later, Linux 6.12 or later for the default protection posture.
Running a command
use sandlock_core::{Sandbox, Stage};
use sandlock_core::sandbox::ByteSize;
let mut sandbox = Sandbox::builder()
.fs_read("/usr").fs_read("/lib")
.fs_write("/tmp")
.max_memory(ByteSize::mib(256))
.name("hello-box")
.build()?;
let result = sandbox.run(&["echo", "hello"]).await?;
assert!(result.success());
The builder mirrors the policy sections used everywhere else, so a rule written here means exactly what the same rule means in a TOML profile or in Python. build() validates cross-section invariants and returns an error rather than producing a sandbox whose rules contradict each other.
HTTP ACL
let mut agent = Sandbox::builder()
.fs_read("/usr").fs_read("/lib").fs_read("/etc")
.http_allow("POST api.openai.com/v1/chat/completions")
.http_deny("* */admin/*")
.name("agent-box")
.build()?;
let result = agent.run(&["python3", "agent.py"]).await?;
Confining the current process
Confinement applies Landlock filesystem rules to the running process instead of spawning a child. It is irreversible.
use sandlock_core::{confine, Confinement};
let confinement = Confinement::builder()
.fs_read("/usr").fs_read("/lib")
.fs_write("/tmp")
.build();
confine(&confinement)?;
Pipelines
let producer = Sandbox::builder()
.fs_read("/usr").fs_read("/lib").fs_read("/bin")
.build()?;
let consumer = producer.clone();
let result = (
Stage::new(&producer, &["echo", "hello"])
| Stage::new(&consumer, &["tr", "a-z", "A-Z"])
).run(None).await?;
Cloning a sandbox copies the policy but not the runtime, so the clone starts fresh. That is what makes it safe to build one policy and derive several stages from it.
COW fork and reduce
let mut mapper = Sandbox::builder()
.fs_read("/usr").fs_read("/lib").fs_read("/bin").fs_read("/etc")
.fs_read("/data")
.name("mapper")
.init_fn(|| { load_data(); })
.work_fn(|id| { println!("{}", compute(id)); })
.build()?;
let mut clones = mapper.fork(4).await?;
let reducer = Sandbox::builder()
.fs_read("/usr").fs_read("/lib").fs_read("/bin").fs_read("/etc")
.name("reducer")
.build()?;
let result = reducer.reduce(
&["python3", "-c", "import sys; print(sum(int(l) for l in sys.stdin))"],
&mut clones,
).await?;
Dynamic policy
use sandlock_core::policy_fn::Verdict;
let mut dynamic = Sandbox::builder()
.fs_read("/usr").fs_read("/lib")
.policy_fn(|event, ctx| {
if event.argv_contains("curl") {
return Verdict::Deny;
}
if event.syscall == "execve" {
ctx.restrict_network(&[]);
ctx.deny_path("/etc/shadow");
}
Verdict::Allow
})
.build()?;
let result = dynamic.run(&["python3", "agent.py"]).await?;
See Dynamic Policy for the event fields, the verdict semantics, and why path strings are never exposed.
Extension handlers
Handlers are the layer where the sandbox stops being a policy you declare and becomes one you program. Your code runs inside the supervisor, registered on any syscall you choose, and decides what the workload observes: deny it, fabricate a return value, hand it a file that exists nowhere on disk, or park the call while you consult a remote service.
let audit = move |cx: &HandlerCtx| {
let pid = cx.notif.pid;
async move {
eprintln!("openat from pid {pid}");
NotifAction::Continue
}
};
let result = sandbox
.run_with_handlers(&cmd, [(libc::SYS_openat, audit)])
.await?;
The built-in chain always runs first, so a handler can extend confinement but never subvert it, and registering on a blocklisted syscall is rejected before fork rather than silently opening a bypass.
The full API, covering the trait, the complete set of return actions, reading and writing guest memory, injecting synthetic file content, deferring slow work off the supervisor loop, continue-site safety, and the security boundary, is covered on Extension Handlers.
Protection posture
By default Sandlock enforces every Landlock protection the host supports and refuses to build when one it expects is unavailable. Two builder methods relax that, per protection:
allow_degraded(Protection::P): enforcePwhere the kernel supports it, silently skip it where it does not. For a mixed fleet.disable(Protection::P): never enforceP, even on a kernel that supports it. For a workload that legitimately needs the capability the protection blocks.
Calling neither leaves the protection in its default strict state. The methods are last-wins per protection, so a later call for the same value supersedes an earlier one.
use sandlock_core::{Protection, Sandbox};
// Build on kernels below 6.12, where the v6 IPC scopes do not exist.
// On a kernel that does support them, they remain enforced.
let sb = Sandbox::builder()
.fs_read("/data")
.fs_write("/tmp")
.allow_degraded(Protection::SignalScope)
.allow_degraded(Protection::AbstractUnixSocketScope)
.build()?;
Sandbox::active_protections() returns each protection's resolved status: Active, Degraded, Disabled, or Unavailable. The posture is part of a checkpoint, so a restored sandbox comes back with the protections it was built with rather than whatever the new host happens to offer.
Testing
$ cargo test --release
Tests that spawn real sandboxes are heavy: each is a full process with its own supervisor. Running too many at once exhausts kernel limits and hangs, so cap the parallelism with --test-threads=4.