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 /Rust API

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.

Cargo.toml
[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

rust
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

rust
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.

rust
use sandlock_core::{confine, Confinement};

let confinement = Confinement::builder()
    .fs_read("/usr").fs_read("/lib")
    .fs_write("/tmp")
    .build();

confine(&confinement)?;

Pipelines

rust
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

rust
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

rust
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.

rust
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): enforce P where the kernel supports it, silently skip it where it does not. For a mixed fleet.
  • disable(Protection::P): never enforce P, 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.

rust
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

shell
$ 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.