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.
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 False | Allow the syscall |
True or -1 | Deny with EPERM |
| Positive integer | Deny with that errno |
"audit" or -2 | Allow, and flag the event |
Event fields
| Field | Contents |
|---|---|
syscall | Syscall name, for example execve or connect |
category | One of file, network, process, memory |
pid / parent_pid | The calling process and its parent |
host / port | Network destination, for connect, sendto, and bind |
argv | Command line, for execve. See below. |
denied | Whether 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
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.
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.