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 /Dynamic Policy

Dynamic Policy

Static rules cover most policies. When a decision depends on what the workload is actually doing, a callback sees the syscall before it runs and returns a verdict.

The callback

A policy function receives an event and a context, and returns a verdict. It is invoked from the supervisor while the child is held at the syscall, so the decision happens before anything takes effect.

python
from sandlock import Sandbox
import errno

def on_event(event, ctx):
    # Block download tools by argv
    if event.syscall == "execve" and event.argv_contains("curl"):
        return True                       # deny

    # Deny connections to a specific IP
    if event.syscall == "connect" and event.host == "10.0.0.5":
        return errno.EACCES

    # Lock down once the program has finished starting up
    if event.syscall == "execve":
        ctx.restrict_network([])            # block all network
        ctx.deny_path("/etc/shadow")       # dynamic fs deny

    # Audit every file access (allow, but flag it)
    if event.category == "file":
        return "audit"

    return 0                                # allow

sandbox = Sandbox(
    fs_readable=["/usr", "/lib", "/etc"],
    net_allow=["api.example.com:443"],
    policy_fn=on_event,
)
result = sandbox.run(["python3", "agent.py"])

Verdicts

Return value Meaning
0 or FalseAllow the syscall
True or -1Deny with EPERM
Positive integerDeny with that errno
"audit" or -2Allow, and flag the event

Event fields

Field Contents
syscallSyscall name, for example execve or connect
categoryOne of file, network, process, memory
pid / parent_pidThe calling process and its parent
host / portNetwork destination, for connect, sendto, and bind
argvCommand line, for execve. See below.
deniedWhether the static policy would already have denied this

event.argv_contains(s) is a convenience for matching a substring anywhere in the command line.

Held syscalls

The child is blocked until the callback returns for these:

execve, connect, sendto, bind, openat

A slow callback therefore slows the workload. Keep the hot path cheap, and push expensive analysis into an audit verdict plus an out-of-band consumer.

Tightening the sandbox at runtime

The context object lets a callback narrow the policy while the sandbox runs. A common pattern is to allow a program the network access it needs to start, then revoke it once initialization completes.

Method Effect
restrict_network(ips)Replace the allowed destinations; an empty list blocks all network
grant_network(ips)Add allowed destinations
restrict_pid_network(pid, ips)Per-PID network override
restrict_max_memory(bytes)Lower the memory limit
restrict_max_processes(n)Lower the concurrent process limit
deny_path(path)Add a filesystem denial
allow_path(path)Add a filesystem grant

Why events carry no path strings

This is the most important thing to understand about the callback, and it is a deliberate design decision rather than a missing feature.

Per seccomp_unotify(2), the kernel re-reads user-memory pointers after the supervisor returns a continue verdict. A supervisor that reads a path string, approves it, and continues the syscall has validated a string the workload is free to overwrite in the interval. Path filtering built this way looks correct and is not.

So Sandlock does not offer it. Path-based access control belongs in static Landlock rules, fs_readable, fs_writable, and fs_denied, where the kernel resolves the path itself at access time and no window exists. For restrictions that genuinely have to be added at runtime, use ctx.deny_path(), which adds a kernel-side rule rather than filtering a string.

argv is exposed, and made safe first

Command lines are too useful to withhold, so Sandlock makes them safe rather than hiding them. Before exposing argv to a callback, or returning a continue verdict for an execve, the supervisor freezes every task in its process index, including peer processes that might alias the argv memory through a shared mapping. While a callback is active, fork-like syscalls are traced for one ptrace creation event so children are registered in the index before they can run user code.

If the freeze or the creation tracking cannot be established, for example because a YAMA policy blocks ptrace, the syscall is denied with EPERM. The invariant is never silently relaxed to let the workload proceed.

Use the callback to add restrictions, not to implement them. A policy whose only defence is a callback returning deny is one bug away from open. Write the static policy so that the callback failing open would still leave the sandbox safe.

Rust

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?;

Beyond the callback: extension handlers

A policy callback is a single function consulted for a fixed set of syscalls, and its verdict is allow, deny, or audit. When you need to register on arbitrary syscalls, synthesize a return value rather than merely permitting or refusing one, hand the guest a file that exists nowhere on disk, or do slow work without stalling the supervisor, that is a handler rather than a policy function.

Handlers are available in Rust, Python, and any language with a C FFI, and the built-in chain always runs first, so a handler can extend confinement but never subvert it. Python gets a Handler base class, an ExceptionPolicy for what happens when a handler raises, and ready-made presets for auditing and path allow/deny lists.

python
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],
)

The full API for every language is on Extension Handlers.