Profiles and sandlock learn
A policy that has stopped changing belongs in a file rather than a command line. And you do not have to write that file by hand: sandlock can watch a real run and produce it for you.
Profiles
A profile is a TOML file in ~/.config/sandlock/profiles/, referenced by name with -p. The schema is sectioned, and flat top-level keys such as fs_readable = [...] are rejected rather than ignored, so a profile written against the wrong shape fails loudly instead of silently granting nothing.
[program]
exec = "make"
args = ["-j4"]
clean_env = true
env = { CC = "gcc", LANG = "C.UTF-8" }
[filesystem]
read = ["/usr", "/lib", "/lib64", "/bin", "/etc"]
write = ["/tmp/work"]
[limits]
memory = "512M"
processes = 50
[syscalls]
extra_deny = []
$ sandlock profile list
$ sandlock profile show build
$ sandlock profile delete build
# Runs [program].exec with [program].args
$ sandlock run -p build
# A trailing command overrides [program]
$ sandlock run -p build -- make test
# A profile that is not installed in the profiles directory
$ sandlock run --profile-file ./ci.toml -- make
Every policy section is available in a profile. The full schema, with each field's type and default, is in the sandbox reference; the sections are [config], [determinism], [program], [filesystem], [network], [http], [syscalls], and [limits].
Naming a sandbox instance. A profile describes the policy, not the instance. Pass --name when you need a stable virtual hostname for a particular run, for example when several sandboxes launched from the same profile need to be told apart by sandlock ps or reached by a reverse proxy.
sandlock learn
Writing a filesystem policy by hand for a real workload is tedious and error-prone: you find the missing paths one failed run at a time. sandlock learn runs the workload under observation and emits a profile covering the paths, network connections, and resource peaks it actually used. The result feeds straight into sandlock run -p.
sandlock learn [options] -- <cmd> [args...]
| Flag | Default | Description |
|---|---|---|
-o <file> | stdout | Write the profile to a file |
--timeout <secs> | none | Kill the workload after N seconds and emit a partial profile |
--collapse [N] | off | Collapse directories where N or more files were observed (default 4) |
--collapse-prefix <path> | none | Force collapse of everything under a prefix; repeatable |
--force-sensitive-collapse | off | Allow --collapse-prefix to target sensitive paths |
What is recorded
| Domain | Mechanism |
|---|---|
| Filesystem reads | seccomp-notify on openat and open |
| Filesystem writes | The same, classified by open flags (O_WRONLY, O_RDWR, O_CREAT) |
| Binaries and libraries | /proc/<pid>/exe plus r-xp mappings from /proc/<pid>/maps |
| Network connections | seccomp-notify on connect, sendto, sendmsg |
| Resource peaks | Sampling /proc/<pid>/status: RSS, thread count, fd count |
Path collapsing
By default every observed path is recorded individually, which is precise and unreadable for a tree like /usr/lib. --collapse N aggregates directories where at least N files were touched.
After collapsing, a deduplication pass removes any individual path already covered by an ancestor in the list. Landlock grants are recursive, so the ancestor entry is sufficient and the descendant is noise.
Write collapse is automatic
Files created during the run do not exist on the real filesystem, because COW intercepted them. Landlock requires an existing path, so the collapser walks up to the nearest existing ancestor for writes. This is not optional: omitting the ancestor would produce a profile that causes sandlock run to abort.
Path tiers
Automatic collapsing on a sensitive directory is how a generated profile quietly becomes a bad one. learn classifies paths into three tiers and treats them differently.
| Tier | Paths | Write (auto) | --collapse N |
--collapse-prefix |
|---|---|---|---|---|
| Protected | /, /root, ~/.ssh, ~/.aws, ~/.kube, ~/.gnupg |
Skip and error | Never; keeps the individual file | Refused without --force-sensitive-collapse |
| Guarded | /etc, /proc, /sys, /dev, /boot, /run/secrets |
Emit, warn, and diff | Never; keeps the individual file | Refused without --force-sensitive-collapse |
| Normal | Everything else | Collapse freely | Collapse freely | Collapse freely |
The observed-versus-granted diff
When a write collapse lands on a guarded path, learn prints a warning to stderr along with a diff: the list of siblings in that directory the workload never touched but will now have write access to. That difference is the cost of the collapse, made explicit so an operator can decide whether it is acceptable rather than discovering it later.
--force-sensitive-collapse permits --collapse-prefix to target protected and guarded paths. The warning and the diff are still printed.
Examples
# Observe a Python script and generate a profile
$ sandlock learn -o profile.toml -- python3 build.py
# Run under the generated profile
$ sandlock run -p profile.toml -- python3 build.py
# Collapse common library directories for a tighter profile
$ sandlock learn --collapse -o profile.toml -- python3 build.py
A generated profile is a starting point, not a finished policy. It describes one execution. A code path that did not run contributes nothing, so the first real run under the profile may still be missing a path. Review what it produced, tighten what is too broad, and exercise the workload's less common paths before trusting it in production.
Inspecting a running sandbox
sandlock inspect prints a live sandbox's effective policy, which is the other direction of the same idea: rather than generating a policy from a run, it recovers the policy a run is actually operating under. TOML output round-trips into a profile.
$ sandlock ps
NAME PID UPTIME CMD
api.local 12345 5m python3 server.py
$ sandlock inspect api.local # JSON
$ sandlock inspect api.local --toml # TOML
A sandbox is only visible to ps and inspect when its control socket is enabled, which it is by default. Setting control_socket=False creates no runtime directory, pid file, or control-socket task at all, and the sandbox becomes invisible to both.