sandy runs a program inside the sandbox of the operating system. A small policy says which paths the program may read, execute or write, whether it may use the network, and which environment variables it gets. Everything else is denied. The same policy works on Linux, macOS and Windows.
sandy fails closed. If the host cannot enforce every restriction of the policy, sandy does not start the program and says why.
sandy run \
--read /srv/app/share \
--read-exec /srv/app/bin \
--read-write /srv/app/data \
--ip all \
-- /srv/app/bin/server --port 8080Version 1.2.0. Policies use format version = 1.
- When to use sandy
- How it works
- Install
- Quick start
- Examples
- Commands
- Policy reference
- Platform notes
- Using sandy from another program
- Security model
- Building and testing
- Documentation
- License
Use sandy for a program that handles untrusted input and might be compromised, for example a network daemon, a file converter or a parser. If an attacker takes over the program, sandy keeps the attacker away from the user's other files, from other processes and from the network, as far as the policy says.
sandy is not a container. There is no separate root file system, no image and no daemon. The program sees the normal file system, but it can open only what the policy allows.
| Platform | Mechanism |
|---|---|
| Linux | Landlock (file access, TCP and UDP ports, scopes), up to four seccomp-bpf filters, no_new_privs, closing inherited descriptors, then execve |
| macOS | A Seatbelt profile generated from the policy, applied with sandbox_init_with_parameters, then execve |
| Windows | An AppContainer with one capability per policy path, a job object with UI restrictions, and a list of inherited handles |
On Linux and macOS, sandy confines itself and then replaces itself with the program, so the
program keeps sandy's process ID; with children = "contained" sandy stays the program's parent
instead (see Processes). On Windows, sandy starts the program and waits for it; when
sandy exits, the job object ends the program.
For every policy, sandy derives a list of requirements, for example "filesystem confined to policy
paths" or "IP networking denied". The platform backend reports for each requirement how it
enforces it on this host, or why it cannot. sandy check prints that list. sandy run starts the
program only when every requirement is enforced.
There are no published packages yet. Build sandy from source, from a release tag, with the
toolchain check and the crates of Cargo.lock:
git clone https://github.com/bisq-network/sandy.git sandy
cd sandy
git checkout v1.2.0
scripts/verify-toolchain.sh # installs and checks the pinned Rust toolchain
cargo build --locked --release -p sandy-cli --bin sandyThe binary is target/release/sandy (sandy.exe on Windows). It has no runtime dependencies
besides the operating system. scripts/verify-toolchain.sh installs the Rust toolchain that
rust-toolchain.toml pins through rustup and checks it before it runs (see "The supply chain
checks"); --locked builds the crate versions of Cargo.lock and no others. On Windows, run the
script in Git Bash.
The rest of this README runs it as sandy. Install it into a directory on your PATH that lies
outside every writable path of your policies (see "Where to install sandy" below), for example
/usr/local/bin on Linux and macOS, which macOS does not create by itself:
sudo mkdir -p /usr/local/bin
sudo install -m 755 target/release/sandy /usr/local/bin/sandyOn Windows, copy sandy.exe into a new folder under C:\Program Files and add that folder to
the system PATH.
For a fully static Linux binary:
cargo build --release --target x86_64-unknown-linux-musl -p sandy-cli --bin sandyRelease archives for each platform are built with scripts/release-build.sh, by the release
workflow or by hand (see Building and testing and below), for these
targets:
x86_64-unknown-linux-musl(static PIE)aarch64-unknown-linux-musl(static PIE)aarch64-apple-darwinx86_64-apple-darwinx86_64-pc-windows-msvc(static C runtime)aarch64-pc-windows-msvc(static C runtime)
Verifying a download. SHA256SUMS in the release lists the SHA-256 sum of every archive,
and SHA256SUMS.asc signs it with the release key (see "Release keys" below). gpg --verify
alone reports a good signature for any key in your keyring, so check that the signature is good
and made by that key:
gpg --keyserver hkps://keys.openpgp.org --recv-keys B493319106CC3D1F252E19CBF806F422E222AA02
gpg --status-fd 1 --verify SHA256SUMS.asc SHA256SUMS 2> /dev/null | grep -E 'GOODSIG|VALIDSIG'
sha256sum --check --ignore-missing SHA256SUMSThe output must hold a GOODSIG line (not EXPKEYSIG or REVKEYSIG) and a VALIDSIG line
that ends with B493319106CC3D1F252E19CBF806F422E222AA02. scripts/check-release.sh (below)
makes these checks itself.
An archive that the release workflow built also carries a GitHub build provenance attestation,
which names the workflow, the tag and the commit that built it. --repo alone accepts an
attestation from any workflow, branch or commit of the repository, so name all three, with the
commit of the release, which its notes give, and gh 2.102.0 or later, which matches them exactly:
gh attestation verify sandy-1.0.0-x86_64-unknown-linux-musl.tar.gz --repo bisq-network/sandy \
--signer-workflow bisq-network/sandy/.github/workflows/release.yml \
--source-ref refs/tags/v1.0.0 --source-digest e755aeb1a5dd0ffe9a83845128bfb1a0b8bcd3e8 \
--deny-self-hosted-runnersThe attestation says where and from what an archive was built, not that its source is good, so it does not replace the signature. An archive built outside the workflow has none.
Release key. SHA256SUMS of a release is signed by
B493 3191 06CC 3D1F 252E 19CB F806 F422 E222 AA02 (alejandrogarcia83), after rebuilding every
archive of the release independently. That signature is what vouches for a release; who pushed
its tag does not matter. scripts/check-release.sh pins this fingerprint and fetches the key from
GitHub and keys.openpgp.org. A change of the key is a pull request that changes the fingerprint
in scripts/check-release.sh and here together.
Building a release binary again. The simplest check needs git, curl, gpg and Docker, on Linux or macOS, and one command in a clone of this repository:
XWIN_ACCEPT_LICENSE=true scripts/check-release.sh v1.2.0It checks that SHA256SUMS.asc of the release is signed by the release key, whose fingerprint the
script pins, downloads the six archives, builds them again from the commit of the tag with that
tag's own scripts/reproduce.sh, and prints for each archive whether it matches SHA256SUMS and
the archive built again, then a verdict; it exits with 0 only when every archive is reproduced. Run
outside a clone, it offers to clone one. It changes neither the clone's tags nor your keyring: the
tag is fetched into FETCH_HEAD, the build runs in a temporary worktree, and the signature is
checked in a temporary GnuPG home. Should the signing key change before this script does, pass its
full fingerprint with --key FINGERPRINT.
scripts/reproduce.sh [TAG [DIRECTORY]] does the build alone: it builds and packs all six archives
of a tag in a container, with the names and bytes of the ones in the release, and writes them with
their SHA256SUMS to DIRECTORY, by default dist/reproduced/COMMIT/ of the checkout, which git
ignores:
XWIN_ACCEPT_LICENSE=true scripts/reproduce.sh v1.2.0 ~/sandy-v1.2.0The container (scripts/reproduce.Dockerfile) is an x86_64 Linux host, the triple every release
archive is built on, emulated by Docker on other hosts. Its inputs are pinned: the Debian image by
digest (the Dockerfile records which official-images and debuerreotype commits it comes from), its
packages by a dated snapshot of the Debian archive and the sums of the snapshot's signed indexes,
rustup by the sum in rustup-init.sha256, the toolchain by rust-toolchain.toml.
XWIN_ACCEPT_LICENSE=true accepts the license of the Visual Studio Build Tools, whose libraries
the Windows archives are linked against (see below). The source goes into the containers and the
archives come out as tar streams, without a directory shared with the host, so the file system of
the host does not matter. What a build needs is fetched first, in containers of its own: the Windows
libraries by xwin, the crates and the toolchain manifest by scripts/release-fetch.sh. The six
targets are then built in one container without network, which mounts what was fetched read-only,
and which cannot write the toolchain of the image. So no build script or procedural macro can fetch
code or send anything out. A local tag
given as TAG is compared with the tag of that name on the remote of bisq-network/sandy, and a
different one fails. SANDY_REPRODUCE_NO_CACHE=true builds the image without Docker's cache,
as scripts/check-release.sh does. Archives built on x86_64 Linux and on an Apple silicon Mac,
under emulation, are identical to those of the release workflow. A tag can be checked this way
from the first release that has these scripts.
Without Docker, on an x86_64 Linux host with rustup, each archive can be built the same way the
container does it: every release binary is built with scripts/release-build.sh TARGET from a
checkout of the release tag and packed with scripts/release-pack.sh TARGET. Fetch the tag from
the project's remote first, since a clone can hold a tag of the same name for other code:
git fetch https://github.com/bisq-network/sandy.git tag v1.2.0 --force
git checkout v1.2.0
scripts/release-build.sh x86_64-unknown-linux-musl
scripts/release-pack.sh x86_64-unknown-linux-musl
sha256sum dist/sandy-1.2.0-x86_64-unknown-linux-musl.tar.gzBoth scripts run with only the variables a release build may depend on (scripts/release-env.sh):
cargo would otherwise take flags, wrappers and profiles from the environment, and they refuse a
cargo config.toml in CARGO_HOME or a parent directory of the checkout for the same reason.
The archive comes from tools/pack, a small program of this repository and a workspace of its
own, not from the tar, gzip or 7-Zip of the host: sorted entries, the time of the commit on every
entry, owner 0, permissions 0755 or 0644 from the executable bit only, and the pure Rust deflate
of flate2, so the bytes of the archive depend only on the files in it.
The script removes from the binary what would tie it to the machine that built it: the directory of
the checkout, CARGO_HOME and the toolchain, and the build time. It cannot remove the host that
runs the toolchain, the host: line of rustc -vV: cargo mixes the host into the metadata of the
build scripts and procedural macros and passes that on to every crate that uses them. In the Linux
and macOS binaries this changes the order of the functions, though not their size: a build on
x86_64-unknown-linux-gnu and a build on arm64 Linux or an arm64 Mac give other bytes, and every
release binary is built on x86_64-unknown-linux-gnu. So a binary built again matches only on a
host of the triple that built the release. Apart from the host, every binary is linked with the
linker of the Rust toolchain, so it depends only on the source, on the toolchain that
rust-toolchain.toml pins and, for Windows, on the Microsoft libraries that
link/windows-sdk.sha256 pins. The macOS binaries are linked against
link/MacOSX.sdk instead of Apple's SDK: it declares the symbols of libSystem that sandy uses and
nothing else, and the CI checks each one against Apple's SDK on a Mac
(scripts/check-macos-stubs.sh). A change that calls into libSystem for a symbol not declared there
fails to link; add the symbol for its architectures. The binaries record SDK 27.0, the version the
declarations follow, as the SDK they were built against, which decides some behaviour of the system
libraries. The Windows binaries are linked against the static C runtime of MSVC and the import
libraries of the Windows SDK, which scripts/windows-sdk.sh fetch ARCH fetches with xwin from
Microsoft's package manifest of Visual Studio 18.11.3 (MSVC 14.51, SDK 10.0.26100) into
target/windows-sdk/ARCH: it checks the manifest and every library file against
link/windows-sdk.sha256. The libraries come under the license of the Visual Studio Build Tools,
so fetching them needs XWIN_ACCEPT_LICENSE=true, and works on Linux only. xwin and its
dependencies are not reviewed, so the release build never runs it: scripts/release-build.sh
only checks the libraries against link/windows-sdk.sha256 (scripts/windows-sdk.sh check), and
the libraries are fetched apart from the build, by a job of their own in CI
(.github/workflows/windows-sdk.yml) and in a container of their own by scripts/reproduce.sh,
whose only output is the library files. To build a Windows target without Docker, fetch the
libraries first with XWIN_ACCEPT_LICENSE=true scripts/windows-sdk.sh fetch x86_64 (or
aarch64), best somewhere other than the build, and bring a tar of crt/lib and sdk/lib from
there with scripts/windows-sdk.sh unpack ARCH ARCHIVE. Or let the build fetch them on the same
machine, without that isolation:
WINDOWS_SDK_FETCH=true XWIN_ACCEPT_LICENSE=true scripts/release-build.sh x86_64-pc-windows-msvcThe build then checks the toolchain again after the fetch. CI refuses WINDOWS_SDK_FETCH=true, so
that a broken handoff of the libraries fails instead of quietly fetching them in the build.
scripts/release-build.sh refuses a toolchain that rustup did not install from the pinned
archives (see "The supply chain checks" below). The reproducible job of the release workflow
builds and packs every target twice, in two directories, and compares the binaries and the
archives, and compares the archive with the one the build job packed on another runner. The
verify-release workflow, started by hand with the tag of a published release, builds and packs
every archive of the release again on a runner like the one that built it, and compares each
archive with the one built again, byte for byte.
The script links both Linux binaries as static position-independent executables, which the
kernel loads at a random address. rustc does that by itself for x86_64-unknown-linux-musl. For
aarch64-unknown-linux-musl it links a fixed address, and adding -pie alone does not help: the
link keeps crt1.o, the start file of a fixed address, which does not relocate the image, and
such a binary crashes at once (measured on Debian 13 for arm64). So the script links that binary
through scripts/link-static-pie.sh, which makes rustc's link the same as for x86_64, with
rcrt1.o, the start file of a static PIE, which relocates the image before musl starts.
The macOS binaries are not signed or notarized, and sandy.exe has no Authenticode signature.
macOS refuses a copy that a browser downloaded, because it is not notarized, and SmartScreen
warns about a freshly downloaded sandy.exe. A program that ships sandy inside a signed macOS
app signs it together with the app.
Where to install sandy. Put the sandy binary, and your policy files, where the sandboxed
program cannot write: not inside any read_write path of any policy. A compromised program
could otherwise replace sandy or its policy, and the next start would run without a sandbox.
sandy checks this at every start and refuses the policy, but only for the binary and the policy
file it is using, so the rule stays yours to keep.
See how this host enforces the default policy:
$ sandy check
backend: linux
enforced filesystem confined to policy paths: Landlock ABI 9; ...
enforced no privilege escalation: no_new_privs; seccomp denies namespaces, mounts ...
enforced other processes cannot be debugged or read: Landlock ptrace scope; ...
enforced only standard input, output and error inherited: close-on-exec on every descriptor above 2 ...
enforced environment limited to policy: execve with only the policy environment
enforced IP networking denied: seccomp socket family allowlist ...
enforced Unix sockets outside writable paths denied: seccomp denies AF_UNIX sockets ...
enforced metadata of files outside policy paths protected: seccomp denies chmod, chown ...
warning: [signals-any] signals = "any" lets the program signal every process of this user ...Warnings go to standard error, requirements to standard output. Every default policy warns
about signals = "any", which lets the program signal processes outside the sandbox. The name
in brackets is the warning's ID: --hide-warning signals-any leaves that warning out once you
have accepted it (see Warnings).
Run a program that may read one directory and write another, without network:
$ mkdir -p ~/demo/in ~/demo/out ~/demo/secret
$ echo hello > ~/demo/in/a.txt
$ echo secret > ~/demo/secret/key
$ sandy run --read ~/demo/in --read-write ~/demo/out -- /usr/bin/cp ~/demo/in/a.txt ~/demo/out/
$ cat ~/demo/out/a.txt
hello
$ sandy run --read ~/demo/in -- /usr/bin/cat ~/demo/secret/key
cat: /home/user/demo/secret/key: Permission deniedThe program path must be absolute. Paths in the policy must be absolute and must exist. The shell
expands ~, so the examples pass absolute paths.
The examples use Linux paths. On macOS and Windows the options are the same; only the paths of
the programs differ. On Windows, every run also needs --appcontainer NAME (see
Windows).
The converter may read its input directory and write its output directory. It may not use the network and gets no environment variables except the ones passed.
sandy run \
--read /srv/convert/inbox \
--read-write /srv/convert/outbox \
--pass-env LANG \
-- /usr/bin/convert-tool --in /srv/convert/inbox --out /srv/convert/outbox--ip client lets the program connect out over IPv4 and IPv6 and resolve names, but not listen
for or accept connections, so a compromised client cannot open a port for others. On Linux it
also adds the files for name resolution and the CA certificates to the system set.
$ sandy run -- /usr/bin/curl -sS -o /dev/null -w '%{http_code}\n' https://example.org
curl: (6) Could not resolve host: example.org
000
$ sandy run --ip client -- /usr/bin/curl -sS -o /dev/null -w '%{http_code}\n' https://example.org
200With --ip client or --ip all the program can connect to any address; sandy does not filter
destinations. --ip all also lets it listen. On Linux and macOS a UDP socket that is not
connected still receives datagrams from every sender under --ip client, and on Windows the
program reaches no host of a network that Windows counts as a private network (see
Network).
A server in the sandbox listens on a loopback port. --expose ADDRESS=PORT makes it reachable from
outside the sandbox: sandy listens on ADDRESS and relays every connection to the program's
loopback PORT.
$ mkdir -p ~/site && echo '<h1>hi</h1>' > ~/site/index.html
$ sandy run \
--read ~/site --read /etc/mime.types --cwd ~/site \
--ip all --expose 127.0.0.1:18000=8000 \
-- /usr/bin/python3 -m http.server 8000 --bind 127.0.0.1 &
$ curl -s http://127.0.0.1:18000/index.html
<h1>hi</h1>Clients use the outer port (18000). On Linux and macOS the inner port (8000) is also reachable directly, but on Windows it never is: Windows blocks every inbound loopback connection into an AppContainer. Always use the exposed port, so the setup works the same on every platform.
The relay ends when the program ends. On Linux and macOS it runs as a separate process with its own sandbox and no file access; on Windows it runs in the program's AppContainer.
This example also shows a typical first problem: Python's web server reads /etc/mime.types,
which is not in the minimal system set. Without --read /etc/mime.types, each request fails with
PermissionError. When a program fails inside the sandbox, look for "Permission denied" (Linux),
"No such file or directory" and the sandbox log (macOS, where every path outside the policy looks
missing; the log from an administrator account: log show --last 5m --predicate 'sender == "Sandbox"') or "Access is denied" (Windows), and add exactly the path it needs.
Options can come from a TOML file. Path options on the command line add to the file's paths; every other option replaces the file's value, also with a weaker one.
# /etc/sandy/convert.toml
version = 1
[filesystem]
system = "minimal"
read = ["/srv/convert/inbox"]
read_write = ["/srv/convert/outbox"]
[network]
ip = "none"
unix_sockets = "none"
[environment]
pass = ["LANG"]
set = { HOME = "/srv/convert/outbox" }sandy check --policy /etc/sandy/convert.toml -- /usr/bin/convert-tool
sandy run --policy /etc/sandy/convert.toml -- /usr/bin/convert-tool --in /srv/convert/inboxA daemon keeps state in a data directory, reads its configuration, uses the network, and offers a local control port. Its binaries live in a directory that the daemon cannot write.
# /etc/sandy/daemon.toml
version = 1
[filesystem]
read = ["/etc/daemon"]
read_exec = ["/opt/daemon/bin"]
read_write = ["/var/lib/daemon"]
[network]
ip = "all"
expose = [
{ listen = "127.0.0.1:18080", port = 8080 },
{ listen = "127.0.0.1:18081", port = 8081 },
]
[process]
signals = "isolated"
[windows]
appcontainer_name = "example.daemon"The daemon listens on 127.0.0.1:8080 and 127.0.0.1:8081 inside the sandbox; clients connect to
18080 and 18081. If the daemon starts helper programs, they must lie in a read_exec path, and
they inherit the sandbox. signals = "isolated" needs Linux 6.12 or newer; on older kernels
sandy refuses this policy, so leave it out there.
sandy rejects policies that would let a compromised program widen its own access:
$ sandy run --read-write ~/demo/out --read-exec ~/demo/out -- /usr/bin/true
sandy: writable path /home/user/demo/out overlaps executable path /home/user/demo/out; a path must not be both writable and executable
$ sandy run --read-write ~/demo/out --read ~/demo/out/sub -- /usr/bin/true
sandy: policy path /home/user/demo/out/sub lies inside writable path /home/user/demo/out; the program could replace it with a symbolic link before the next start
$ sandy run -- true
sandy: path must be absolute: trueAll three exit with status 125, and the program does not start.
sandy run [OPTIONS] [--] PROGRAM [ARGS...] run PROGRAM inside the sandbox
sandy check [OPTIONS] [[--] PROGRAM] show how this host enforces the policy (needs
PROGRAM when the policy has fallback levels)
sandy setup [OPTIONS] prepare the host (needs admin rights on Windows,
and on Linux under Ubuntu's AppArmor restriction)
sandy setup --remove [OPTIONS] take back what setup and the runs of the policy
left on the host
sandy run starts the program only if every requirement is enforced. Everything after -- (or
after the first argument that is not an option) belongs to the program.
sandy check prints one line per requirement: enforced with the mechanism, or UNAVAILABLE
with the reason. Pass the program if the policy should include it. A requirement that this
platform gives only in part is printed as enforced too, because run starts the program with
it, and a warning names what stays open. Two more kinds of line make check exit with status
125 instead of 1, because they say nothing about what this host can do: UNDECIDED, when a
probe failed for a reason that can pass (out of memory or processes, a timeout, a service that
is starting) and sandy cannot tell now, and CONFLICT, when the policy contradicts itself or
this host's layout, for example a writable path that reaches a startup file. For a policy with
fallback levels, check prints a [NAME] line before the plan of each
level it tried, and last level: NAME, the level that run takes, or level: none.
check --json prints the same as one JSON object instead (see Fallback levels).
Four kinds of finding get a warning: line on standard error: a setting that gives up part of the
isolation (unix_sockets = "all", signals = "any", path_metadata = "allow",
symlinks = "allow"), an option that
replaced a stricter value of the policy file, a guarantee of the policy that this platform gives
only in part (for example signals = "isolated" on macOS, which leaves SIGIO open; the list is in
docs/DESIGN.md, "Requirements and the enforcement plan"), and a fact about this
host that makes the program behave differently inside the sandbox (a directory of your PATH or a
usual bin directory inside a writable path, on Linux an OpenSSL include the policy does not cover
or a cgroup limit the program cannot see). Warnings change neither the exit status nor the one line
per requirement on standard output, so a caller that reads it is not affected. sandy run prints
only the warning that it takes a fallback level: its standard error belongs to the program. Each
warning line starts with warning: [ID]; see Warnings for the IDs and how to hide
them.
sandy setup does the one-time host preparation that needs administrator rights:
- Windows: the loopback exemption of the policy's AppContainer, and with relays a grant on
sandy.exe(see Windows). - Linux, on Ubuntu 23.10 and newer, where AppArmor restricts the user namespaces of
unconfined programs: an AppArmor profile that lets sandy use them, which
ip = "private"needs. A profile gives that right to whatever file is at its path, so setup, run as root, prepares only a sandy whose file and directories up to/only root can change, as a package installs it. It refuses any other sandy with status 3.sandy setup --install-copyinstalls a root-owned copy of the running sandy as/usr/local/libexec/sandy/VERSION/sandyand prepares that copy instead; a sandy of that version elsewhere then hands itsrunandcheckover to the copy, which still checks the first sandy's file against the policy: the variableSANDY_STARTED_AScarries its names, and sandy removes it before the program starts. Where user namespaces are disabled altogether, a profile cannot help, and setup exits with status 3. Elsewhere on Linux setup has nothing to do. See docs/linux.md, "Setup on Ubuntu", also for packaging scripts. - macOS: nothing to do.
For a policy with fallback levels, sandy setup prepares every level in turn, or only the one
--level names.
sandy setup --elevate asks for those rights instead of needing them: through pkexec on Linux,
whose authentication agent asks for an administrator's password, and through the UAC prompt on
Windows. It never runs a file with the rights that someone without them can change: on Linux it
runs sandy's own file only when only root can change it, and otherwise, with --install-copy,
only system tools and the root-owned copy, which it makes from the image of the running sandy;
on Windows it asks only when only administrators can change sandy.exe and its directories.
Otherwise it refuses with status 3. Where nothing is to do, it asks for nothing. With
--elevate, --policy needs an absolute path, and cannot be -.
sandy setup --remove, with the same policy, takes that preparation back, together with what
the runs of the policy left on the host: on Windows the loopback exemption, the access control
entries of the policy paths and the AppContainer profile; on Linux the profile of the running
sandy, and with --install-copy also the copy and its profile. It removes only what sandy
wrote. The entry on the program file goes only when the removal names the program as a
read_exec path (see docs/windows.md). It is idempotent, takes --elevate too, and on macOS
it does nothing.
| Setup option | Meaning |
|---|---|
--install-copy |
On Linux, install a root-owned copy of the running sandy as /usr/local/libexec/sandy/VERSION/sandy and prepare the host for it, for a sandy whose own file is not root-owned. |
--elevate |
Ask for the administrator rights setup needs: pkexec on Linux, UAC on Windows. |
| Option | Meaning | Default |
|---|---|---|
--policy FILE |
Read the policy from a TOML file (at most one). Path options add to it; the other options replace its values, but not one that a fallback level sets, which is an error. With -, read it from standard input; the program then gets the null device as its standard input. A policy may be at most 1 MiB long. |
none |
--level NAME |
Use only this level of the policy: base or the name of a fallback level. run refuses when this host cannot enforce it, instead of taking another. |
|
--json |
check only: print the result as one JSON object (see Fallback levels). |
off |
--hide-warning ID |
Leave out the warning: lines with this ID. Repeatable; run and check only. See Warnings. |
|
--no-warnings |
Leave out every warning: line. run and check only. See Warnings. |
off |
--show-warning ID |
Print the warning: lines with this ID despite --no-warnings. Repeatable; run and check only. See Warnings. |
|
--read PATH |
Allow reading files and listing directories below PATH. |
|
--read-exec PATH |
Allow reading and executing below PATH. |
|
--read-write PATH |
Allow reading, creating, changing and deleting below PATH. Never executing. |
|
--system minimal|none |
Add the minimal set of system paths programs need to start. | minimal |
--view all|policy |
policy makes every path outside the policy look missing. See The file system view. |
all |
--symlinks deny|allow |
Whether the program may create symbolic links in its read_write paths. No effect on Windows, where it can make only junctions that stay within them. See Symbolic links. |
deny |
--ip all|client|private|none |
IPv4 and IPv6 networking, including loopback. client connects out but cannot listen or accept. private keeps the program on a loopback of its own. |
none |
--unix-sockets all|none |
Unix domain sockets. Read the warning below before using all. |
none |
--signals any|isolated |
Whether the program may signal processes outside its sandbox. | any |
--end-with-parent |
End the program when the process that started sandy ends. Needs --signals isolated. See Processes. |
off |
--parent-pid PID |
With --end-with-parent: the caller's own process ID. sandy refuses to start if its parent is another process. |
|
--parent-start TOKEN |
With --parent-pid: when that process started, so sandy can tell the caller from a process that got its ID later. The caller may then start sandy through a wrapper. See Processes. |
|
--status-fd N |
Report the program's process ID and how it ended on descriptor N (on Windows: an inherited handle). run only. See Exit status. |
|
--status-file PATH |
The same record in a file sandy creates at the absolute PATH, which must not exist yet and must lie outside every writable path. run only. See The status record. |
|
--explain |
Name, on standard error, the paths of the run that the policy does not grant (Linux only) and the connections the egress broker refuses or cannot make (every platform, with network.egress or --egress). run only. See Finding the grants a program needs. |
off |
--explain-path PATH |
Say what the policy allows for PATH and which rule decides it. check only, Linux only. |
|
--children allowed|none|contained |
Whether the program may start other processes, and whether they may outlive it. See Processes. | allowed |
--expose ADDRESS=PORT |
Relay connections on the loopback ADDRESS to the program's loopback PORT. Needs --ip all or --ip private. |
|
--forward PORT=ADDRESS |
Relay the program's connections to its loopback PORT to the loopback ADDRESS outside the sandbox. Needs --ip private, client or all. |
|
--egress PORT[=REACH] |
A SOCKS5 broker on the program's 127.0.0.1:PORT that connects out only to REACH: public or public-and-local, never to this host. Needs --ip private. |
public |
--linux-path-metadata deny|allow |
On Linux, whether chmod, chown, utimensat and setxattr by path are allowed. |
deny |
--macos-mach-service NAME |
On macOS, let the program look up the Mach service NAME. sandy takes only com.apple.trustd.agent, the certificate verifier of the system. Repeatable; needs --ip client or all. See macOS. |
|
--macos-relays dual-stack|ipv4 |
On macOS, how the program reaches its forwarded and egress ports. ipv4 closes a route to hosts of the local network through IPv6 link-local addresses and denies IPv6 sockets to those ports, so Java needs -Djava.net.preferIPv4Stack=true. ipv4 needs --ip private. See macOS. |
dual-stack |
--pass-env NAME |
Copy the variable NAME into the program's environment, if it is set. |
|
--set-env NAME=VALUE |
Set a variable in the program's environment. Overrides --pass-env. |
|
--appcontainer NAME |
The AppContainer name (Windows; required there). | |
--windows-junctions grant|refuse |
On Windows, whether sandy may add a read entry to a junction on the way to a directory policy path, so that the program can open the directory under the junction's name. refuse changes nothing but the policy paths and makes such a path a conflict. |
grant |
--windows-win32k allow|deny |
On Windows, whether the program may make the system calls of win32k (the GUI part of the kernel). deny stops many programs that load user32.dll. |
allow |
--cwd DIR |
Working directory of the program. | sandy's |
The options that take a path or a variable can be repeated.
Every warning: line names its ID in brackets, for example
warning: [signals-any] signals = "any" lets .... A caller that has read a warning and accepted
what it says passes --hide-warning ID to leave that one out, and still sees every other
warning, also one that a later release adds. --no-warnings leaves out every warning line, for
a caller that shows sandy's standard error to its users and has checked the warnings itself.
--show-warning ID keeps the warnings with that ID despite --no-warnings, so
--no-warnings --show-warning fallback-level prints only the fallback level line. Like
--no-warnings alone, such a list also hides every warning a later release adds. An ID given
to both --hide-warning and --show-warning is an error. Hiding changes nothing sandy enforces, and check --json keeps every warning with its ID, in
warnings and warning_ids.
sandy accepts the ID of every platform on every platform, so one command line serves all of them; an ID it does not know is an error. The IDs stay stable; the texts can change between releases.
| ID | Platforms | What the warning reports |
|---|---|---|
unix-sockets-all |
all | unix_sockets = "all" gives up most of the isolation |
signals-any |
all | signals = "any" lets the program signal every process of this user |
egress-probing |
all | what the egress broker's answers tell the program about this host's addresses |
path-metadata-allow |
all | path_metadata = "allow" |
symlinks-followed-outside |
Linux, macOS | symlinks = "allow": a process outside the sandbox follows the links the program leaves in writable paths |
writable-home |
all | a writable path holds the home directory |
terminals-reachable |
Linux | a policy path holds /dev/pts, with the other terminals of this user |
writable-path-directory |
all | a writable path reaches a directory on PATH |
mach-services-ignored |
Linux, Windows | macos.mach_services allows nothing on this platform |
mach-service-reach |
macOS | what an allowed Mach service lets the program do besides its purpose |
option-widens-policy |
all | a command line option replaced a stricter value of the policy file |
fallback-level |
all | run takes a fallback level |
ip-client-udp |
Linux, macOS | ip = "client" receives datagrams from every sender on a socket that is not connected |
ip-client-unix-listen |
Linux | ip = "client" cannot listen on a Unix socket either |
ip-client-private-networks |
Windows | ip = "client" reaches no host of a private or domain network |
ip-private-same-user |
Linux | a process of this user can enter the private network namespace |
ip-private-exposed-addresses |
macOS | exposed ports can listen at every address of this Mac |
ip-private-later-addresses |
macOS | an address the Mac gains after the relay started |
ip-private-link-local |
macOS | IPv6 link-local addresses reach other hosts |
relays-ipv4-only |
macOS | macos.relays = "ipv4" denies IPv6 sockets to the relayed ports |
loopback-within-appcontainer |
Windows | the program's own processes reach each other over loopback |
signals-isolated-terminal |
Linux, macOS | signals = "isolated" and the process group of the terminal |
signals-isolated-sigio |
macOS | signals = "isolated" leaves SIGIO and SIGURG open |
forward-other-users |
Linux, macOS | another local user can use a forwarded port |
egress-other-users |
macOS | another local user can use the egress broker |
relay-outlives-program |
macOS | with signals = "any" the relay can outlive the program |
path-metadata-open-files |
Linux | path_metadata = "deny" and descriptors opened for reading |
unix-sockets-existence |
macOS | unix_sockets = "all" tells existing paths from missing ones |
view-holds-root |
Linux | view = "policy" with / readable hides nothing |
view-partial |
Linux, macOS | what view = "policy" leaves visible of the host |
contained-posix-spawn |
macOS | children = "contained" denies posix_spawn |
openssl-include |
Linux | an OpenSSL include the policy does not let the program read |
cgroup-limit |
Linux | a cgroup limit the program cannot see |
ecryptfs-unlisted |
Linux | a granted directory on eCryptfs that the program cannot list |
appcontainer-shared |
Windows | a named AppContainer is shared with every user of the machine |
grant-follows-file |
Windows | a grant follows the file, not the path |
session-zero |
Windows | sandy runs in session 0 |
junction-closed |
Windows | a junction on the way to a directory policy path that sandy does not open for the program |
A program that is denied a path often says nothing about it. It treats the file as missing and runs on with less than it was built for: without its country database, without its configuration, with built-in defaults instead of yours. Nothing in the output says so, and the program's own logs say nothing either.
Two options answer the question "which grant does this program need". For paths both are Linux
only today; the platform documents say what the other two hosts can and cannot report. For the
egress broker --explain works on every platform (see below).
sandy check --explain-path PATH says what the policy allows for one path and which rule
decides it, without running anything:
$ sandy check --policy convert.toml --explain-path /usr/share/data/table
backend: linux
enforced filesystem confined to policy paths: Landlock ABI 10 ...
/usr/share/data/table: no rule covers itsandy run --explain PROGRAM watches the run and names every path it uses that the policy does
not grant:
$ sandy run --read /srv/in --explain -- /usr/bin/convert /srv/in/x
sandy explain: on. It names the paths of this run that the policy does not grant, and it does
not see every denial (docs/linux.md, "Diagnostic mode").
sandy explain: cannot read /usr/share/data/table: no rule covers it; grant it with read
(openat in process 4711)
sandy explain: 1 path the policy does not grant, from 812 watched callsThe mode is off by default and changes nothing the sandbox enforces: it adds a seccomp filter that only reports, and sandy's watcher always lets the kernel carry the call out, so Landlock decides as it would without the mode. It is meant for working out a policy, not for production: it slows every watched call down (measured: about 25 microseconds each), sandy stays the program's parent while it runs, and a process that outlives sandy loses the watcher. Read "Diagnostic mode" in docs/linux.md before you trust its silence: it reports the paths a call uses, not what the kernel answered, and several kinds of denial never reach it.
With --egress, --explain also names every connection the egress broker refuses or cannot
make, with the SOCKS5 reply the program got and the reason, on Linux, macOS and Windows alike:
$ sandy run --ip private --egress 9050 --explain -- ./client
sandy explain: on for egress. It names the connections that the egress broker refuses or cannot make, but not the paths the sandbox denies: the windows backend cannot report those.
sandy explain: egress to 10.1.2.3:80 refused with reply 2: private use (10.0.0.0/8); reach = "public-and-local" allows it
sandy explain: egress to 93.184.215.14:443 refused with reply 2: on 0.0.0.0/1, a network this host reaches without a router, which is the local network; reach = "public-and-local" allows it
sandy explain: egress to 1.1.1.1:9 failed with reply 4: the connect failed: connection timed outEach distinct line appears once, at most 1000 in a run (on Windows the relay in front of the
broker can add one more), and each in one write, so it stays whole next to the program's output.
The broker answers first and reports after, from a queue: when standard error takes nothing for
a while, lines are dropped and the report says how many, so a caller that reads standard error
only at the end holds up no connection. On macOS and Windows, where sandy cannot name denied
paths, --explain is refused for a policy without network.egress, because it would report
nothing. Unlike the path report on Linux, the egress report does not change how sandy runs the
program. The broker then holds a copy of sandy's standard error until it ends, at most two
seconds after the program: the broker's drain, then at most a second for the lines it has
queued.
| Status | Meaning |
|---|---|
| the program's status | sandy run started the program |
| 128 + signal number | Linux and macOS with children = "contained", and Linux with --explain: a signal ended the program |
| 125 | sandy failed or refused: invalid policy, a requirement this host cannot enforce, a socket on a standard stream that the policy denies, a pseudo-terminal master on one with signals = "isolated", a setup error; with children = "contained", on Linux with --explain, and on Windows also a failure while sandy supervises the program. For sandy check: sandy cannot tell now whether this host enforces the policy (an UNDECIDED line, or a relay port that another process holds), or the policy conflicts with this host (a CONFLICT line) |
| 0 / 1 | sandy check: the policy, or one of its levels, is enforceable / none is |
| 2 | sandy check: no level is enforceable, but sandy setup, run once with administrator rights, would make one so |
| 0 | sandy setup, with or without --remove: done, or nothing to do |
| 3 | sandy setup, with or without --remove: not done, and nothing changed: the rights were not given (no administrator rights and no --elevate; with --elevate, a dismissed prompt, no authentication agent, not authorized), setup is not allowed for this binary, or on Linux user namespaces are disabled on this host |
A program that starts sandy can act on these without reading text. On status 2 from check, it
runs sandy setup --elevate with the same policy, adding --install-copy on Linux when it
ships sandy outside a root-only path. On 0 it runs check again; on 3 it does without what
setup prepares, best with a fallback level that needs no setup (on Linux,
without ip = "private"), which check then selects; 125 means setup failed. A refusal also
names the command that does it without --elevate, such as sudo. Status 125 from check
means "try again later or fix the policy", never "use a weaker policy": sandy itself never
moves to a weaker level for it.
On Windows the program's 32-bit exit code is passed through unchanged.
The exit status leaves two things open. With children = "contained", on Linux with
--explain, and always on Windows, sandy stays the parent, so the caller holds sandy's process
ID and not the program's; and 125 means both "sandy refused" and "the program exited with 125".
--status-fd N answers both without changing any exit status. sandy writes short lines to the
descriptor, in this order:
| Line | When |
|---|---|
level NAME |
first, for a policy with fallback levels: the level that runs; absent when sandy refuses before it chooses one |
pid N |
the program runs; N is its process ID |
exited N |
sandy saw the program exit with status N, never negative: on Windows the 32-bit exit code as an unsigned number, such as 3221225786 for 0xC000013A, which cmd, PowerShell, Java and .NET show as -1073741510 |
signaled N |
sandy saw signal N end the program (Linux and macOS with contained, Linux with --explain) |
failed |
sandy itself failed or refused; without a pid line the program never ran |
In the exec model (Linux and macOS without contained, and Linux without --explain) sandy becomes
the program, so the record ends after pid and the caller's own wait status is the program's.
sandy writes that line right before it confines itself and replaces itself with the program, so
a failed line after it means that applying the sandbox or the execve failed. sandy makes the descriptor
close-on-exec, and on Windows it keeps the handle out of what the program inherits, so the
program never has it. Writing is best effort: a caller that closed the read end gets no record,
and the program keeps running. An empty record with status 125 means that sandy stopped before
it took the descriptor, at a usage error for example.
A caller that cannot pass a descriptor, such as Java's ProcessBuilder, which passes only the
standard streams, names a file instead: --status-file PATH. sandy creates it where it would
take the descriptor and writes the same record; the caller reads it after sandy has exited and
removes it. PATH must be absolute and must not exist, so sandy follows no link and changes no
file that someone left there, an earlier run of the program for example; sandy refuses to
create it otherwise. It refuses the run, after writing failed, when the file lies inside a
writable path of the policy, where the program could write the record itself. On Windows the
file gets the permissions of its directory, so put it where AppContainers have no access, as
in the user's own folders. No file with status 125 means that sandy stopped before it created
it, at a usage error for example.
version = 1 # required; the only version so far
[filesystem]
system = "minimal" # "minimal" | "none"
read = ["/path"] # read files, list directories
read_exec = ["/path"] # read, list, execute
read_write = ["/path"] # read, list, create, change, delete; never execute
view = "all" # "all" | "policy"; policy hides paths outside the policy
symlinks = "deny" # "deny" | "allow"; allow: new symbolic links in read_write paths
[network]
ip = "none" # "none" | "private" | "client" | "all"
unix_sockets = "none" # "none" | "all"
expose = [{ listen = "127.0.0.1:18080", port = 8080 }]
forward = [{ port = 18081, to = "127.0.0.1:8081" }]
egress = { port = 1080, reach = "public" } # needs ip = "private"; "public" | "public-and-local"
[process]
signals = "any" # "any" | "isolated"
children = "allowed" # "allowed" | "none" | "contained"
end_with_parent = false # end the program when sandy's parent ends
[environment]
pass = ["LANG"] # copied from sandy's environment, if set
set = { HOME = "/path" } # fixed values; override pass
[linux]
path_metadata = "deny" # "deny" | "allow"
[macos]
relays = "dual-stack" # "dual-stack" | "ipv4"; ipv4: forwarded and egress ports over IPv4
mach_services = [] # ["com.apple.trustd.agent"]: the certificate verifier
[windows]
appcontainer_name = "example.app"
win32k = "allow" # "allow" | "deny"; deny blocks win32k system calls
junctions = "grant" # "grant" | "refuse"; refuse: sandy changes no junctionThe block names every key; it is not a policy that runs as written, because expose needs
ip = "all" or "private", forward needs any ip but "none", egress and
relays = "ipv4" need ip = "private", mach_services needs ip = "client" or "all", and one
path cannot be both read_exec and read_write. Unknown keys are errors, so a misspelled key
never becomes a silent default.
A policy may be at most 1 MiB long and may nest arrays and inline tables at most 32 levels deep; a longer or deeper one is refused before it is parsed. Real policies are a few KiB and nest a few levels. The depth limit is what keeps a malformed policy from overflowing the stack of the TOML parser, whose own limit malformed input can get past.
The platforms limit the number of forwarded ports. On Windows one relay serves all exposed and
forwarded ports, and its command line holds about 1200 forwarded ports. On macOS ip = "private"
holds each forwarded port at every address of the Mac, so the 10240 open files of a process allow
about 600 on a Mac with 16 addresses. A run beyond the limit fails before the program starts.
-
Absolute, existing paths. sandy resolves symbolic links before it builds the sandbox. A rule applies to the path and everything below it. On Windows a path must be local: sandy refuses a network or share path (
\\server\share,\\localhost\C$, a mapped network drive) and asks for the local path instead. -
Never writable and executable. No path may be both writable and executable, also not through nesting (a
read_writedirectory inside aread_execdirectory, or the reverse). The rule compares the paths the policy names, not the history of the files below them: a hard link that gives an executable file a second name inside aread_writepath lets the program change the file through that name. Such a link comes from the host, or from an earlier run that could write both names, since a program may link its own files. Which trees a policy makes executable is the choice of whoever writes it: giveread_execonly to trees you trust, not to one an earlier sandbox could write. -
Nothing inside a writable path. No policy path may lie inside a
read_writepath, or be reached through a symbolic link (on Windows also a junction) inside one. The program could replace it with a symbolic link, and the next start would follow the link. sandy's own executable and the policy file get the same check: aread_writepath must not contain them, lead to them through a link or an alias, or hold a second name of them. On Linux a writable devpts (/dev/pts) may hold policy paths: only the kernel adds and removes its entries, so nothing there can be replaced. A pseudo-terminal needs/dev/ptmxand/dev/pts, and on some hosts/dev/ptmxis theptmxof/dev/ptsitself. -
The program file gets read and execute access automatically. It must not lie inside a
read_writepath. -
The minimal system set (
system = "minimal") adds, read-only, what programs need to start: shared libraries, the dynamic loader, time zone data,/dev/nulland the random devices. It never contains user data. Each platform documents its exact list. Programs that need more (locale files, configuration in/etc, framework resources) need--readfor those paths. No writable path may overlap this set (for example--read-write /etc); sandy refuses such a policy. The comparison is the one of rules 2 and 3: another name of a directory of the set, a bind mount for example, counts as that directory. -
The environment contains only
passandsetvariables, plus what a platform must add for any program to start (on WindowsSystemRootandLOCALAPPDATA). -
Children inherit the sandbox. A program that starts other programs needs them in a
read_execpath; they run with the same restrictions. A script needs its interpreter there too, because the kernel executes the interpreter;sandy checkreads the#!line and says so instead of letting the program fail to start. -
No writable path over a trusted user location. A writable path gives the program your write rights there, and a file it leaves behind runs with your rights, outside the sandbox. sandy refuses a writable path that reaches a location whose content another program reads or runs later: your home directory, the startup files of bash, zsh and fish (
~/.bashrc,~/.profile,~/.config/fish), the git configuration (~/.gitconfig,~/.config/git), the GnuPG configuration (~/.gnupg), cargo's configuration (~/.cargo/config.toml),~/.ssh; on Linux also autostart entries, user services and the KDE Plasma login scripts, on macOS also~/Library/LaunchAgents, on Windows the Startup folders, the PowerShell profile folders,WindowsAppsand sandy's own state folder. Each platform documents its list. A directory below your home directory stays writable, but not one that holds such a location:--read-write ~/.cargois refused, and a program that downloads crates needs~/.cargo/registryand~/.cargo/gitinstead. When sandy cannot find your home directory (HOMEunset and no entry in the password database), it cannot check this rule and refuses a policy with a writable path; start it withHOMEset.GNUPGHOME,GIT_CONFIG_GLOBALandGIT_CONFIG_SYSTEMmove the GnuPG, the global and the system git configuration, so when they are set to an absolute path (forGNUPGHOMEalso one that starts with~/, which gpg expands), what they name is protected as well, and a refusal names the variable, for exampleoverlaps /work/gnupg (GNUPGHOME). A relative value is not protected: git and gpg resolve it against their working directory, which sandy does not know. A device they name holds nothing that persists and stays writable: withGIT_CONFIG_GLOBAL=/dev/null,--read-write /dev/nullis allowed. The.git/configand.git/hooksof a checkout inside a writable path are not protected and stay the program's to change, as before: git reads them only for commands in that checkout, so review a checkout the program could write as you would its other output.A directory on your
PATHgets a warning instead, naming it, because a writable path that holds one is often what you mean:--read-write ~/toolswith~/tools/binonPATH, or--read-write /usr/local. On Linux and macOS sandy also warns about the directories the usual shell setups put onPATH(~/.local/bin,~/bin,~/.cargo/bin,/usr/local/bin,/usr/local/sbin, and Homebrew's on macOS) when its ownPATHlacks them, because a service or a desktop application starts sandy with a shorterPATHthan your shell has. The escape is real, a file the program leaves there runs when you next type its name. Only a policy that keeps the directory out of every writable path removes it: make only the subdirectories the program needs writable. Starting sandy with anotherPATHcan silence the warning, but leaves the risk. A directory below a directory onPATHis on noPATHand says nothing.sandy cannot know which of your files other programs trust, so the list is a heuristic and can never be complete. Keep the writable paths of a policy as narrow as the program needs, and do not make a directory writable that holds files other programs run or read as configuration.
ip = "none"denies IPv4 and IPv6, including loopback.ip = "all"allows every destination and listening.ip = "private"gives the program a loopback of its own: it may listen and connect there, but reaches nothing outside the sandbox, not the host, not the local network, not the internet, except throughexpose,forwardandegress.egress = { port = 1080 }adds a SOCKS5 broker on the program's127.0.0.1:1080. The program asks it for a destination by IPv4 or IPv6 address (names are refused), and sandy connects only when the destination is a public internet address, or withreach = "public-and-local"also one of the local network, and never an address of this host. Point the program's SOCKS5 proxy option there (for example curl's--socks5 127.0.0.1:1080orALL_PROXY=socks5://127.0.0.1:1080). A program without such an option gets no way out. See docs/DESIGN.md, "Private network and egress", and the platform notes for what each platform gives.ip = "client"lets the program connect and send to every destination, loopback included, resolve names and receive the answers, but not listen for or accept a connection. On Linuxlistenandacceptfail withEPERMfor every socket, Unix sockets too (checkwarns whenunix_sockets = "all"), and from kernel 6.7 (Landlock ABI 4) a TCP socket binds only to a port the kernel chooses, from Landlock ABI 10 a UDP socket too. On macOSlistenfails. On Windows the program may listen, but no connection or datagram of another process or host that it did not ask for reaches it. What stays open, andsandy checknames with a warning: on Linux and macOS a UDP socket that is not connected receives datagrams from every sender, not only from the addresses it sent to, at the port the kernel chose for it, and on macOS and Linux before Landlock ABI 10 at any port the program binds it to. On Windowsclientreaches no host of a network that Windows counts as private or domain network, such as a printer on the local network, because Windows has no capability for connections out into such a network alone; names still resolve there, andip = "all"reaches those hosts.unix_sockets = "none"guarantees that the program cannot connect to Unix sockets outside the paths it may write. Linux and macOS deny connections to every Unix socket; Windows checks socket access through the socket file, so it allows the sockets in theread_writepaths and in the program's private AppContainer folder.- WARNING:
unix_sockets = "all"gives up most of the isolation. The program can reach every local service on a Unix socket. On Linux that includes the D-Bus session bus, through which the systemd user manager starts arbitrary commands outside the sandbox. On macOS it includes the DNS resolver, so even withip = "none"the program can send data out in DNS queries. Use it only for programs you would also run without a sandbox. - A socket on standard input, output or error keeps the access it was created with: it exists
before the sandbox, and no sandbox can take rights from what is already open. sandy therefore
accepts such a socket only when the policy lets the program create one like it, and else refuses
to start the program, with status 125 and the stream and the socket named. It refuses an IP
socket under
ip = "none"and underip = "private", where it would belong to the host's network, an unconnected, datagram or listening Unix socket underunix_sockets = "none", and under every policy a raw IP socket, which only a privileged process can create, an IP socket of a protocol other than TCP, UDP, ICMP and MPTCP (SCTP, for example), and a socket of any other family, for example a netlink socket that lists the sockets of the host. An ICMP datagram socket counts as an IP socket, because an unprivileged program can create one where the system allows it. On Linuxip = "all"andip = "client"also accept a route netlink socket, which the program may create. A connected Unix stream or seqpacket socket is accepted: that is how a parent passes a pipe. Underip = "client"only a connected TCP socket is accepted: a listening socket would let the program take connections, and a datagram socket, also a connected one, datagrams from every sender. A service that is handed its connection on standard input (inetd-style, systemdAccept=yes) needsip = "client", one that is handed a listening socketip = "all".sandy checkcannot report this, because its own streams are not the streams of the laterrun. exposeentries:listenmust be a loopback address, must differ from the program's own address, and may appear only once. They needip = "all"orip = "private": the program listens for the relay's connections, whichip = "client"denies, so sandy refusesexposewith it. At most 256 connections per port are relayed at the same time. A connection that is still open when the relay ends, one second after the program, is reset, so a cut stream never looks complete. Withsignals = "any"the program may stop its own relay. On Linux a stopped relay is killed two seconds later and its ports are released; on macOS nothing bounds it, so onlysignals = "isolated"keeps the bound there. A relay that is continued before that can serve one more connection, made after the program ended, on whatever now holds the address it forwards to.signals = "isolated", whichprocess.children = "contained"requires, closes that: the program cannot stop its relay then, and the Linux relay has no timer, so a busy host does not kill it either (docs/linux.md).forwardentries: on Linux the relay closes a connection whose other end the kernel names as another local user's, so it never reaches the service. A socket its process has closed has no owner for the kernel to name, and such a connection is served, because it is the program's own in any ordinary run, unless the kernel still names another user for it, as Linux 6.17 and newer do for a moment. Another local user can therefore still deliver a blind write, on Linux 6.8 usually on the first attempt, by writing and closing at once, but never read the answer (docs/linux.md). Underip = "private"on Linux the forward port is in the sandbox's own network namespace, which no other user reaches. On macOS every connection is served. Forward only to services that authenticate their clients.- A dead relay frees the program's forward port, and any local user can then bind it and
receive what the program sends there. With
children = "contained"on Linux and macOS, sandy therefore ends the program when its relay dies, and exits with status 125. Without it nothing watches the relay there. On Windows sandy does so with any policy, also for the egress broker. - A port another process already holds makes
sandy runrefuse with status 125.sandy checkbinds the same listeners for a moment and reportsUNAVAILABLEfor such a port, so it gives the same answer. Between the two the port can change hands, sorunis the final word.
-
signals = "isolated"limits signals to processes in the same sandbox. On Linux this needs Landlock ABI 6 (kernel 6.12 or newer), or withchildren = "contained"a PID namespace, which sandy creates where unprivileged user namespaces work; otherwise sandy refuses such a policy. The kernel also signals whole process groups for terminal job control, without that check, so on Linux the program can then neither join another job's process group nor take the terminal from the foreground job nor resize it, nor signal the other side of a pseudo-terminal withTIOCSIG. A shell inside the sandbox therefore has no job control. On Linux and macOS, sandy also refuses to start a program that gets the master side of a pseudo-terminal on a standard stream: typing Ctrl+C into a master signals the processes on the other side. The program's reads from the terminal still stop the job that sandy runs in, including processes of that job outside the sandbox, as they would without a sandbox. On macOS a profile cannot filter the arguments ofsetpgid, so the program can still join another job's process group of its terminal session and have the kernel stop that job; taking the terminal and resizing it are denied there in every mode, andchildren = "contained"closes the rest. A program on macOS can also still have the kernel send SIGIO to a process outside the sandbox, including one of another user, through an ioctl on a socket or a pipe; the macOS kernel sends it with no owner check, so this is outside sandy's reach, and a process that does not handle SIGIO ignores it (see docs/macos.md). -
childrendecides whether the program may start processes:allowed(the default): yes. On Linux and macOS these processes can outlive the program; on Windows they end with it.none: the program cannot start processes at all. Threads still work.contained: the program may start processes, and sandy ends all of them when the program ends, also when sandy itself is killed. Use it for a program that starts helpers, so that no helper keeps running after the program has stopped. On Linux and macOS, sandy then stays the parent of the program instead of becoming the program: it forwards signals, gives the program the terminal when the program uses it, and exits with the program's status (128 plus the signal number if a signal ended the program). Processes in the sandbox cannot callsetsidorsetpgid. On Linux, where unprivileged user namespaces work, the program also runs as process 2 in a PID namespace of its own, below an init of sandy, and the namespace ends with the program or sandy, also when sandy and its guard are both killed; inside, the program's parent and process group have no process ID, sogetppidreturns 0, and files of root and of other users show the owner 65534. On macOS they cannot callposix_spawneither, because its flags can move the new process out of the group as well: programs that start processes withforkandexec(for example shells) work, but programs that useposix_spawngetEPERMwhen they start one. Among them are Rust'sstd::process::Command(unless it setspre_exec), Java'sProcessBuilder, Node.js, andsystem()andpopen()in C.
noneandcontainedneedunix_sockets = "none", andcontainedalso needssignals = "isolated"; otherwise the program could have a process started outside the sandbox, or kill sandy before sandy ends its processes. To allow only some programs, giveread_execto those files instead of their whole directory:[filesystem] read = ["/opt/app/bin"] # libraries and data next to the programs read_exec = ["/opt/app/bin/app", "/opt/app/bin/helper"] [process] signals = "isolated" children = "contained"
On Linux, a program can also start a readable file through the dynamic loader, so there this limits which files can be executed directly, not which code runs. Every process stays in the sandbox either way.
-
end_with_parent = trueends the program when the process that started sandy ends, for example when your application crashes while its helper runs in the sandbox. sandy asks the program to end (SIGTERM on Linux and macOS) and kills it 5 seconds later if it is still running; on Windows it ends the program at once. Unlike a program's own check of its parent, this works also when the program is compromised. It needssignals = "isolated". Combine it withchildren = "contained"so that the processes the program started end too.Tell sandy which process you are.
--parent-pidis your process ID, which sandy accepts only when it is sandy's own parent, so start sandy from your application itself and not through a shell or another wrapper.--parent-startadds when your process started, which only you can know, so sandy can tell you from a process that got your ID after you ended. With both, a wrapper between you and sandy is allowed, and a crash in the moment sandy starts makes sandy refuse instead of going unnoticed. The value is platform-specific: field 22 of/proc/PID/staton Linux (docs/linux.md), the recorded start time on macOS (docs/macos.md), the creation time on Windows (docs/windows.md). -
Debugging and reading the memory of processes outside the sandbox are always denied, and the program can never gain privileges, for example through setuid programs.
view = "all", the default, shows the program every path as the host has it; the sandbox denies access to the ones outside the policy, but the program can tell that they exist.view = "policy"makes every path outside the policy paths and the system set look missing:stat,access,readlink,openand a directory watch answer as for a path that does not exist. On Linux the program then runs in a view of the file system that holds only those paths (a mount namespace), and every path the policy does not make executable is mounted without execute, so areador writable path also cannot hold code that the program maps. A rename or a link between two writable paths fails there withEXDEV, and files of other users show the owner 65534. On macOS every path outside the policy looks missing under every policy already, so the setting changes nothing there, except that sandy refuses it withunix_sockets = "all". Windows cannot hide paths and refuses the setting. See docs/DESIGN.md, "The file system view".
With the default symlinks = "deny", the program cannot create symbolic links, named pipes or
devices anywhere, also not in its read_write paths, and a rename or a clone that would give an
existing link or named pipe a new name there fails too. A process outside the sandbox that later
opens a name in a writable path, a configuration file it writes before every start for example,
would otherwise follow a link the program left there to any file of the user.
Some tools need links. npm and pnpm put the commands of a package in node_modules/.bin as
links, and Emacs marks a file it edits with a lock link .#NAME; under "deny" these fail
("Permission denied" on Linux, "Operation not permitted" on macOS). python3 -m venv links the
interpreter into the environment: Python 3.14 on Linux fails, Python 3.9 on macOS prints "Unable
to symlink" and copies it. git checks out a link of a repository as a plain file holding the
link's target, because git init and git clone find that they cannot make links and set
core.symlinks = false (measured with git 2.56 on Linux and 2.54 on macOS).
symlinks = "allow" (--symlinks allow) lets the program create symbolic links in its
read_write paths; named pipes and devices stay denied. The program gains no access through a
link: the sandbox checks the path the link resolves to, so a link to a file outside the policy
leads nowhere for it. A link can point anywhere, though, and neither Landlock nor Seatbelt can
check its target when it is created, so whatever runs outside the sandbox and opens names in a
writable path follows the links there with its own rights; sandy check warns about it. Grant it
only when no process outside the sandbox relies on the content of the writable paths, and never
for a path that one of your own programs reads as configuration or runs from. Windows refuses
an AppContainer every symbolic link, so there the setting changes nothing; the program can still
make a junction to a directory within its writable paths, as before, but none that leads out
of them.
Landlock does not control chmod, chown, utimensat and setxattr. With the default
path_metadata = "deny", sandy denies these calls when they take a path, everywhere, also inside
writable paths, and the owner and extended attribute calls on an open file (fchown, fsetxattr)
too. The mode and time calls on an open file (fchmod, futimens) keep working, also on a file
the program may only read, which sandy check warns about; with view = "policy" such a file is
on a read-only mount, where they fail. The file system ioctls that change a
file the same way, and fs-verity, fscrypt, deduplication and btrfs subvolume ioctls, are denied
under every policy. Tools that change metadata by path (install, cp -a on a directory,
rsync -a, unzip, git) need "allow"; then the program can change the metadata of every file
the user owns. cp -p, and cp -a on a single file, work under "deny", because they set the
metadata through the descriptor they wrote.
A program that runs on many hosts cannot know which hardening each of them supports: an older
kernel without the newest Landlock, Ubuntu's restriction of user namespaces, a platform that
cannot hide paths. A policy can list, after its base, weaker levels in order, and check and
run take the first one this host can enforce:
version = 1
[filesystem]
read_exec = ["/opt/example/bin"]
read_write = ["/var/lib/example"]
view = "policy"
[network]
ip = "private"
egress = { port = 1080 }
[process]
signals = "isolated"
end_with_parent = true
[windows]
appcontainer_name = "example.app"
[[fallback]]
name = "contained"
filesystem.view = "all"
process.children = "contained"
windows.appcontainer_name = "example.app.contained"
[[fallback]]
name = "host-network"
network.ip = "all"
network.egress = "none"
filesystem.view = "all"
process.signals = "any"
process.end_with_parent = false
windows.appcontainer_name = "example.app.host"- Each level is the base plus its own keys. Levels do not build on each other, so each one
reads alone;
host-networkabove does not contain the program's children. The base is namedbase; a level name is 1 to 32 characters ofa-z,0-9and-. - A level sets only the keys that choose how strongly the program is isolated:
filesystem.view,network.ip,network.unix_sockets,network.egress(a table, or"none"for no broker),process.signals,process.children,process.end_with_parent,linux.path_metadata,macos.relays,windows.win32kandwindows.appcontainer_name. The paths,system,symlinks, the environment,expose,forwardandmacos.mach_servicesare the same in every level, so the program finds the same files and ports in each. Any other key in a level is an error. - Every level is checked as a policy of its own on every host, so a level that could never
run (for example
children = "contained"withsignals = "any") is an error everywhere, not a surprise on the one host that reaches it. - Every level needs an AppContainer name of its own once one of them names one, because on Windows levels that shared a name would share its loopback exemption and its profile folder.
- A level is skipped only for a lasting shortfall of the host: a kernel, a platform or a
build that lacks what the level needs, or something
sandy setupwould prepare. A probe that fails for a reason that can pass (UNDECIDED), a conflict between the policy and the host (CONFLICT), a relay port another process holds, a socket on a standard stream: each of these stops sandy with status 125 instead, because a weaker level chosen for it would let a passing condition, or another user of the host, pick how weakly the program runs. The last level is the floor; nothing weaker runs. docs/DESIGN.md, "Fallback levels", has the rules. runthat takes a level other thanbasesays so on standard error, before the program starts:warning: [fallback-level] running fallback level contained: this host cannot enforce level base (...).--hide-warning fallback-levelleaves it out.--level NAMEpins a level:runthen refuses with status 125 when this host cannot enforce it. A program that configures itself per level, for example writes another configuration file for a level without the egress broker, runscheckfirst and thenrun --levelwith the levelchecknamed, so a host that changed in between makesrunrefuse rather than start the program with the configuration of another level.--parent-pidand--parent-startbelong to the policy:checkandruntake them, also with--level, when any level ends with its parent, and a level that does not ignores them, so both commands get the same options.- An option that sets a key some level sets (
--ipwhen a level setsnetwork.ip) is an error, because it would be unclear which levels it changes. Other options apply to every level. checkof a policy with levels needs the program, because whether a level can run can depend on it.
sandy check --json prints one JSON object on one line on standard output, and nothing there
when it exits with status 125. A refusal then has its reason on standard error, as without
--json, whatever it is: an invalid policy, a policy this host cannot enforce as written (a
writable path over a location other programs read), or on Windows a .bat or .cmd program.
The object:
{"backend":"linux","selected":"contained","setup_would_help":false,
"levels":[{"name":"base","enforceable":false,"needs_setup":false,
"enforced":[{"requirement":"filesystem-confined","mechanism":"..."}],
"missing":[{"requirement":"signals-isolated","status":"unavailable","reason":"..."}],
"warnings":["..."],"warning_ids":["signals-any"]},
{"name":"contained","enforceable":true,"needs_setup":false,
"enforced":[...],"missing":[],"warnings":[],"warning_ids":[]}],
"warnings":[],"warning_ids":[]}| Key | Meaning |
|---|---|
backend |
linux, macos or windows |
selected |
the level run takes, or null when none is enforceable |
setup_would_help |
no level is enforceable, but sandy setup would make one so (exit status 2) |
levels |
the levels check planned, in order, up to the selected one |
levels[].name |
base or the name of the fallback level |
levels[].enforceable |
the host enforces every requirement of the level |
levels[].needs_setup |
only something sandy setup prepares is missing |
levels[].enforced |
each requirement this host enforces, with requirement and mechanism |
levels[].missing |
each requirement it does not, with requirement, status (unavailable or needs-setup) and reason |
levels[].warnings |
the warning: lines of the level, without their prefix and ID |
levels[].warning_ids |
the ID of each warning in levels[].warnings, in the same order |
warnings |
warnings that concern every level, such as an option that widens the policy file |
warning_ids |
the ID of each warning in warnings, in the same order |
The requirement names (filesystem-confined, ip-network-private, signals-isolated, ...),
the keys and the warning IDs stay stable; the mechanism, reason and warning texts are for
people and can change. A policy without levels has one entry in levels, named base.
- Kernel. sandy needs Landlock enabled (Linux 5.13 or newer, and
landlockin the active security modules) and seccomp. It runs on Ubuntu 22.04 and newer, Debian 12 and newer, Fedora, Arch, RHEL 9.6 and newer, and openSUSE Leap and SLES 16. It refuses to run on RHEL 8, RHEL 9.0 to 9.5, and SLES 15 or Leap 15.6 unless Landlock is enabled with thelsm=boot parameter.sandy checknames the reason and the fix. - Not as root. sandy refuses to run as root or with any capability, because those rights would survive the sandbox.
/procis not readable by default. Landlock cannot limit it to the program's own entries, and/procshows the command lines of every process. Add--read /proconly if the program needs it.- GNU tar cannot extract an archive that holds a directory. It opens each directory with
openat2, which sandy denies because seccomp cannot read that call's flags and therefore cannot reject the open mode that gets past Landlock, and tar does not fall back. Creating and listing archives work, and so doesbsdtar(docs/linux.md). ip = "private"runs the program in a user and network namespace of its own, so it needs unprivileged user namespaces. Ubuntu 23.10 and newer restrict them for programs without an AppArmor profile; theresandy checkexits with status 2 untilsandy setupinstalls one for sandy. That works for a sandy that only root can change, such as a packaged one; for a portable sandy,sandy setup --install-copy(or--elevate --install-copy) installs a root-owned copy that the portable sandy hands over to (docs/linux.md, "Setup on Ubuntu"). Inside, files of other users and of root show the owner 65534, andpingdoes not work. Other users cannot reach the program's ports, but your own processes can enter the namespace, for example withnsenter, andsandy checkwarns about that (docs/linux.md, "Private network"). Withegress, a broker in the host's network namespace connects for the program. Other users cannot reach its port either, and withchildren = "contained"sandy ends the program when the broker dies, as when its relay dies (docs/linux.md, "Egress broker").view = "policy"runs the program in a mount namespace that holds only its policy paths and the system set, so it needs unprivileged user namespaces asip = "private"does (on Ubuntu 23.10 and newersandy checkexits with status 2 untilsandy setup), and a kernel with the fix of Landlock erratum 3. A rename between two writable paths fails withEXDEV, a file outside aread_execpath cannot be mapped as code,/procis read-only, andsandy checkwarns about what the view still shows: the host's file systems throughstatfs, the directories above granted paths, link counts, and with/procthe mount tables, also the host's (docs/linux.md, "File system view").- Pseudo-terminals. A program that makes them, such as
scriptorexpect, needs--read-write /dev/ptmx --read-write /dev/pts, which also lets it open your terminals in/dev/pts, read what you type in them and write to them; a read grant of/dev/ptsor/devlets it read what you type.sandy checkwarns about both. Withview = "policy"it gets one only where/dev/ptmxis a bind mount of or a link to/dev/pts/ptmx(docs/linux.md, "Pseudo-terminals"). - Name resolution across network changes. A Landlock rule binds a file, so a file the
system replaces by rename is lost to a program that is already running. With
--ip allor--ip client, sandy grants systemd-resolved's directory, so name resolution keeps working when the network changes. Where/etc/resolv.confis a plain file or leads elsewhere, a long-running program needs a restart after the system replaces it (docs/linux.md). - Tested on Fedora 44 (kernel 7.2), Ubuntu 22.04 (6.8) and Ubuntu 25.10 (6.17), on x86_64.
CI runs the suite on the GitHub Ubuntu 22.04, 24.04 and 24.04-arm runners (see
What CI runs). The code paths of older Landlock ABIs are tested on a newer
kernel with the test harness
sandy-abi-cap, which lowers the ABI sandy reads (see docs/linux.md). - Details, the exact system set and all limitations: docs/linux.md.
- Tested on macOS 26 and 27 (arm64). CI runs the suite on the GitHub macOS 15 runner.
- Not through Rosetta: x86_64 code cannot run in the sandbox on Apple silicon, because Rosetta
cannot load its runtime there. sandy refuses a program without arm64 code on Apple silicon,
and for a script the interpreter of its
#!line, which is what the kernel loads. Its x86_64 build refuses every program there. Use the arm64 build and arm64 or universal programs. The x86_64 build has not run on an Intel Mac yet. - sandy uses
sandbox_init_with_parameters, which Apple does not document. Chromium uses the same function. system = "none"cannot start any program on macOS, because every program needs the dynamic loader and the system library cache. sandy refuses it there unless a policy path covers "/".- No Mach services are reachable from the sandbox, so programs that need them (for example to open
other apps, use the pasteboard or read preferences) do not work. The one exception a policy can
make is the certificate verifier:
mach_services = ["com.apple.trustd.agent"]under[macos](--macos-mach-service com.apple.trustd.agent) lets programs verify TLS servers through the system, as Go programs and every program that uses Security.framework do; without it they fail withOSStatus -26276. It needsip = "client"or"all", because the verifier fetches the URLs that a certificate names from outside the sandbox.sandy checkwarns what else the service gives the program; it cannot make a certificate trusted (docs/macos.md, "Mach services and other IPC"). sandy refuses every other service name, and Linux and Windows say that the key allows nothing there. - Every path outside the policy looks missing: calls answer "No such file or directory", not
"Operation not permitted". With
unix_sockets = "all",access,statfsandchdirstill tell whether a path exists, because reaching a socket needs that check. A program that saves its working directory first, such ascp -R, fails when it starts in a directory outside the policy. Other processes look gone, and the program cannot read the priority, group or session of any process, its own included. The names in/and the directories above the policy paths stay visible, also withview = "policy". ip = "private"has no loopback of its own on macOS: the program may only connect to its forwarded ports and the egress port and listen on its exposed ports, so its own processes cannot talk to each other over IP, names resolve only from/etc/hosts, and Network.framework and URLSession programs cannot use those ports. A forwarded or egress port needs--signals isolatedthere. The program can listen on its exposed ports at every address of the Mac, where other hosts reach it, every local user can connect out through the egress broker, a process of your user can take a forwarded or egress port at an address the Mac gains while sandy runs, and the program can connect to those ports at a link-local address of the Mac on another link, where another host may have it, such as a router atfe80::1;sandy checkwarns about each.macos.relays = "ipv4"(--macos-relays ipv4) closes that link-local route: the program then reaches its forwarded and egress ports from IPv4 sockets only. An IPv6 socket gets "Operation not permitted" there, also one that connects to 127.0.0.1 as::ffff:127.0.0.1, which is how Java and .NET connect to an IPv4 address by default; start Java with-Djava.net.preferIPv4Stack=trueand .NET withDOTNET_SYSTEM_NET_DISABLEIPV6=1. With the key sandy refuses a forward to an IPv6 address, and withoutip = "private"it refuses the key. On Linux and Windows the key changes nothing but these refusals. On macOS 15 and 26::ffff:127.0.0.1gets "Operation not permitted" at those ports without the key too, so the same Java and .NET settings are needed there (docs/macos.md).- The system writes the crash report of a program that sandy runs to
/Library/Logs/DiagnosticReports, which administrators can read and standard users cannot, instead of the user's~/Library/Logs/DiagnosticReports. - Terminal programs that change terminal modes fail; only a few terminal requests are allowed.
- With
children = "contained", the program cannot useposix_spawn(see Processes). - Details: docs/macos.md.
-
Versions. Windows 10 1809 or newer, or Windows Server 2019 or newer, with the fix for CVE-2021-41338 (an AppContainer firewall bypass). Tested on Windows 11; CI runs the suite on the GitHub Windows Server 2022 and 2025 runners. Windows 10 is expected to work but is not tested.
-
AppContainer name. Every run needs
--appcontainer NAMEorwindows.appcontainer_name. The name identifies the AppContainer profile; file grants and the loopback exemption belong to it. Use one name per policy. sandy refuses to start a second program under a name while another program with a different policy runs under it, because programs in one AppContainer can control each other. -
One-time setup. Run as administrator, once per machine and policy:
sandy setup --policy C:\ProgramData\example\policy.toml
It adds the loopback exemption when the policy has
ip = "client"orip = "all"(removes it otherwise), so the program can connect to services on 127.0.0.1, and withexposeit grants the relay access tosandy.exeif your user cannot.--elevateasks for the rights with the UAC prompt, when only administrators can changesandy.exeand its directories, for example belowC:\Program Files. The elevated sandy's messages do not reach the caller, only its exit status does. Without administrator rights and without--elevate, setup andsetup --removechange nothing and exit with status 3. -
Inbound loopback. Windows blocks every connection from outside into an AppContainer, also on 127.0.0.1. Use
exposefor programs that accept connections. -
Private network.
ip = "private"runs the program in its AppContainer without network capabilities and without the loopback exemption: its processes and the relays ofexposeandforwardtalk over loopback, and nothing else on this machine or the network reaches the program or is reached by it. Names do not resolve, and a datagram the program sends out is dropped although the send reports success.sandy setupremoves the exemption for it, as forip = "none". -
Egress broker.
egressadds a SOCKS5 broker that the program reaches on its loopback. On Windows it runs as a separatesandyprocess in an AppContainer of its own, with only the capability it needs to connect out, reached from the program over the same AF_UNIX path asforward. It ends with the program. It connects only wherereachallows and never to this host, and its own AppContainer isolation keeps it off this machine even if that check were wrong. See docs/windows.md, "Egress broker". -
No hidden paths.
view = "policy"is refused: Windows cannot hide paths from an AppContainer without administrator rights, so the program can tell an existing path outside the policy from a missing one. -
File grants. sandy grants access by adding entries to the access control lists of the policy paths, and a read entry to those of this user's junctions on the way to directory policy paths, so that the program can open such a directory under the junction's name (
junctions = "refuse"keeps sandy from the junctions and refuses such a policy instead). These entries stay; they grant nothing to programs outside this AppContainer.docs/windows.mdexplains how to remove them and the profile. -
Private folder. Windows gives every AppContainer a private folder (its
TEMP). The program can always write there, but sandy makes sure it cannot execute anything it writes there. -
Console. The program gets its own hidden console; sandy relays standard input and output. Ctrl+C ends the program.
-
Win32k lockdown.
win32k = "deny"starts the program (and its children and relays) with the system calls of win32k, the GUI part of the Windows kernel, disabled. It cuts a large attack surface, but a program that loadsuser32.dllends at startup, so turn it on only for a program you have checked starts with it (see docs/windows.md). -
Details and limitations: docs/windows.md.
sandy is a command line tool, so any language can use it. Start sandy instead of the program:
[ "/opt/example/sandy", "run", "--policy", "/opt/example/policy.toml", "--", "/opt/example/bin/daemon", "--flag" ]
Recommendations:
- Call
sandy checkfirst and show its output when the exit status is 1, so users learn why the program cannot run on their host. Its standard output has one line per requirement; thewarning:lines on standard error are advice about the policy and do not change the exit status. Hide the ones you have accepted with--hide-warning ID(see Warnings).check --jsongives the same to a program (see Fallback levels). - Put the weaker variants of the policy into the policy, as fallback levels, instead of
retrying with another policy when
checkfails: sandy knows which hardening each host supports, and it never moves to a weaker level for a reason that can pass. When the program needs to know the level, for example to write its own configuration for it, runcheck --json, configure the program for theselectedlevel, and start it withrun --leveland that name. - Pass the policy on standard input (
--policy -) when it ships inside the calling program, for example as a resource: there is then no policy file to place where the program cannot write it, none is left behind, and nothing can change it between writing and reading. It does not protect against other processes of the same user, which can change the caller or sandy just as well. The program then reads the null device on its standard input, so this suits programs that need no input. - Treat exit status 125 as "not started". sandy prints the reason on standard error. Two
exceptions: a program can exit with 125 itself, and with
children = "contained", on Linux with--explainand on Windows sandy can fail while it supervises a running program, for example when the relay of its ports died (its message issandy: run failed: ...). Pass--status-fdor--status-filewhen you need to tell these apart, or need the program's process ID (see The status record): without apidline the program never ran. - Keep sandy, the policy and the program's binaries out of every writable path. Use separate
directories for binaries (
read_exec) and data (read_write). A program that can rewrite its own binary or library directory can escape at the next start. - Treat everything the program writes as untrusted input, including files in its data directory and everything it sends over exposed ports.
- Do not pass secrets in arguments or the environment unless the program needs them.
- Run
sandy setupduring installation, with administrator rights: on Windows, with a stable AppContainer name per policy; on Linux, forip = "private"under Ubuntu's AppArmor restriction, as in the packaging scripts of docs/linux.md. A program without an installer runssandy setup --elevatewhencheckexits with 2 (see Exit status). The program's uninstaller runssandy setup --removewith the same policy; on Windows as the user who ran the program, because the AppContainer profile belongs to that user (docs/windows.md, "Removing the changes", also for the removal by hand). - Give the program pipes, files or a terminal on its standard streams. sandy passes
descriptors 0, 1 and 2 on as it got them, so a socket among them would keep its access; sandy
refuses to start the program then. The connected Unix stream socket that most runtimes pass
for a pipe (libuv, and so Node.js) is accepted. With
signals = "isolated"sandy also refuses the master side of a pseudo-terminal, which a terminal emulator orexpectholds, because the program could type Ctrl+C into it and so signal the processes on the other side. Pass the slave side, the terminal a program normally runs in, or a pipe. - Do not give an untrusted program your terminal. What the program writes to a terminal reaches the terminal emulator, which acts on some escape sequences, for example to set the clipboard, and on Linux the program can also change the terminal's modes. These are ordinary writes, which no system call filter can tell apart, so sandy cannot stop them. Redirect the program's output to a file or a pipe, and show it to a person only through a filter that removes control sequences. A later option could have sandy relay the terminal and filter it.
The sandboxed program and all its children are assumed to be under the attacker's control. They can run any code and make any system call. sandy, its policy, and the files sandy reads before the sandbox applies are trusted.
sandy protects:
- the user's files outside the policy (their contents, and their metadata against changes),
- other processes of the user (memory, debugging; signals with
signals = "isolated", on Linux including the terminal's job control signals), - the user's privileges (no setuid, no capabilities, no elevation),
- the network, as far as
ipandunix_socketsdeny it, - the host's local services on Unix sockets.
sandy does not protect:
- secrets the program itself holds, and the data in its
read_writepaths, - network traffic the program may send with
ip = "client"orip = "all", - the terminal the program writes to (recommendation 10 above),
- the availability of the machine (CPU, memory, disk use), and of files the program may read: a lock it holds on such a file stalls the programs outside the sandbox that lock the same file, and on Windows also those that read or write it,
- against bugs in the operating system kernel.
What remains visible from inside the sandbox differs by platform; for example, on Linux the
program can see which paths exist outside the policy, but not their contents, while on macOS
every path outside the policy looks missing (with unix_sockets = "all" a few calls still tell
whether it exists) and every other process looks gone. Each platform document lists these
limitations.
Independent reviews tried to escape the sandbox on every platform. Their findings were fixed, or
are written down as limitations in the platform documents. Either way a test holds the result
(crates/sandy-cli/tests/review_*.rs).
cargo build --workspace
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warningsThe escape tests in crates/sandy-cli/tests/ run a probe program (sandy-probe) inside the
sandbox. Each test tries an action, first outside the sandbox (it must work there) and then inside
(it must fail), so a test cannot pass because the action was impossible anyway. Platform tests
are in escape_linux.rs, escape_macos.rs and escape_windows.rs; check.rs checks that
sandy check reports what sandy run refuses, and the tests from the reviews are in
review_*.rs.
To run the tests on another machine, build the test executables, copy them together with sandy
and sandy-probe (and sandy-marker on macOS) into one directory there, and set
SANDY_TEST_BIN_DIR to that directory:
cargo test --no-run --target x86_64-unknown-linux-musl -p sandy-cli
SANDY_TEST_BIN_DIR=/path/to/dir ./escape-<hash>Some checks need something the host has to offer: a pseudo-terminal, a session bus, a Landlock right of a newer kernel, AppContainer processes that may start child processes. libtest has no outcome between ok and failed, so such a check would report ok like a check that ran. These variables keep that visible:
| Variable | Effect |
|---|---|
SANDY_TEST_SKIP_LOG |
File every skipped check is appended to: binary, test, feature, reason. |
SANDY_TEST_RUNNER |
The name of this runner. crates/sandy-cli/tests/expected-skips/<runner>.txt holds the checks it is expected to skip. |
SANDY_TEST_REQUIRE |
Features that must be checkable here, separated by commas, or all. Skipping one fails that test. |
SANDY_TEST_MIN_LANDLOCK_ABI |
The lowest Landlock ABI this runner must report (Linux). |
SANDY_TEST_LOCAL_PEER |
ADDRESS:PORT of another host of the local network that takes TCP connections there, as an SSH server does. The test of egress with reach = "public-and-local" reaches it (Linux); without it that test skips local-network-peer. |
After a run, cargo test -p sandy-cli --test runner -- --ignored --exact skips_match_the_expected_list compares the log with the list of the runner and fails on a skip
that is not in the list, and on a listed skip that did not happen. A check that quietly stops
running is then a diff to review, not a silent pass. A runner without a list fails that
comparison and prints the list to commit. A run for another target is a runner of its own, named
<runner>-<target> as in CI, because what it can check differs: the static musl build, for
example, has no loader to check (fedora-44-x86_64-unknown-linux-musl.txt).
compiled_programs_match_the_snapshot in crates/sandy-linux/src/filter.rs compares the
compiled Linux BPF programs with the snapshot of the architecture,
crates/sandy-linux/snapshots/x86_64.txt or aarch64.txt. Each section of that file names one
program, its instruction count, a checksum of its instructions, and the action it
returns for every system call number. A change to a rule, to the reviewed system call table or to
seccompiler's code generation makes the test fail.
Update it deliberately. The failure prints the new text; write it to the file, then read the diff of the file and check that every line that changed is a line you meant to change. Each architecture has its own system call numbers, so its file is written on a Linux host of that architecture.
What goes into a build is pinned in this repository, and the builds check it:
- Crates.
Cargo.lockholds the SHA-256 sum of every crate, and cargo checks each download against it. Every cargo command of CI and of the release build runs with--locked, so a lock file that no longer matchesCargo.tomlfails the command instead of being resolved again. - The crates that may be in the tree.
deny.tomllists them. A crate that is not in the list, an advisory, a yanked version, an unexpected license or a source other than crates.io failscargo deny. docs/DESIGN.md says whyseccompilerstays although its standalone repository is archived. - Who vouches for each crate version.
supply-chain/holds the records of cargo-vet. Every crate version ofCargo.lockmust be covered by an audit imported from Mozilla, Google, the Bytecode Alliance, Zcash or ISRG (supply-chain/imports.lockkeeps the audits that apply), by an audit of this repository or a trusted publisher (supply-chain/audits.toml), or by an exemption (supply-chain/config.toml). The notes of each audit of this repository say who reviewed the crate, what they checked and what they found. Today every crate version is covered by audits, with no trusted publishers and no exemptions. A trusted publisher entry, if one is added, names the organizations that trust that publisher for that crate and ends after a year; an exemption is a crate version that nobody audited, and its notes say why sandy uses it anyway.scripts/check-audits.shfails on a crate version without cover, on an entry that nothing needs any more, and on an audit, exemption or trusted publisher without notes.tools/pack, which packs the release archives, has its ownCargo.lock,deny.tomlandsupply-chain/, checked the same way (scripts/check-audits.sh tools/pack); its crates,flate2with its pure Rust deflate andcrc32fast, are all covered by imported audits. - The toolchain.
rust-toolchain.tomlpins the Rust release, andrust-toolchain.sha256the SHA-256 sum of its channel manifest, which lists the sum of every archive of that release.scripts/verify-toolchain.shinstalls the toolchain if it is missing, downloads the manifest, checks it against the pinned sum, and checks that rustup installed the toolchain from archives that the manifest lists, all before the toolchain runs once. CI runs it before anything is built, andscripts/release-build.shbefore every release build.scripts/check-toolchain-signature.sh, which theauditworkflow runs, checks that the pinned manifest is signed by the Rust release key,keys/rust-release-key.asc. - rustup. CI installs rustup from the
rustup-initwhose sumrustup-init.sha256pins (scripts/install-rustup.sh), not the rustup of the runner image, and the container ofscripts/reproduce.shtakes the same pin. rustup publishes no signature, so the pin is the sum as it was when the version was chosen. - The tools of the checks.
scripts/install-tool.shbuilds a tool such as cargo-deny from the crate with the sum that the workflow names, so the crates.io index of the day does not decide which code runs.
The audit workflow runs these, and so does:
scripts/verify-toolchain.sh
scripts/install-tool.sh cargo-deny 0.20.2 e528dfcbe739af7ce37a77d3d6df1b29dd6887b1c701d888820c0f16b864f737
cargo deny --locked check all
scripts/install-tool.sh cargo-vet 0.10.3 173c2501643f10d4b4f1157da393d9dedb9c288f2ec8687c08bd5b71f89478d6
scripts/check-audits.shWhen a dependency changes. Crates and action pins are updated by hand, after review; Dependabot
opens pull requests only for security updates (.github/dependabot.yml). A new crate version, or
a new crate, comes in one pull request of its own, with the change of Cargo.lock in its
description, and only once the version has been out for a week, unless it fixes a RustSec
advisory: most malicious versions are found and yanked within days. Run cargo fetch --locked
on the base branch and on the change, so that cargo has both versions, checked against a
Cargo.lock; cargo-vet reads them from there, and downloads a version that cargo does not have
without checking its sum. Then review the change with cargo vet diff --mode=local (or
cargo vet inspect --mode=local for a new crate): without --mode=local both open a web page of
diff.rs or crates.io rather than the files that cargo builds. Look first at build scripts,
procedural macros, unsafe code and new dependencies. Then run cargo vet: it fetches the audits
of the imported organizations again and names every crate version that is still not covered.
cargo vet suggest lists the audits that would cover them, with their size. Then record, for each one, an audit you
did (cargo vet certify), a publisher to trust (cargo vet trust CRATE PUBLISHER --notes ...,
only when the imported organizations trust it for that crate), or an exemption with notes in
supply-chain/config.toml. Finish with cargo vet prune --no-imports and
scripts/check-audits.sh, and add a new crate to deny.toml as well. Before a trusted publisher
entry ends, check that the organizations still trust that publisher and move its end date,
which is at most a year away; the weekly run fails once an entry has ended.
Pinning a new toolchain. Change channel in rust-toolchain.toml, then let the script
download the channel manifest of that release with its signature, check the signature with the
Rust release key and write the sum of the manifest into rust-toolchain.sha256:
scripts/check-toolchain-signature.sh --writeIt accepts only a signature by the key of keys/rust-release-key.asc, the primary key
108F 6620 5EAE B0AA A8DD 5E1C 85AB 96E6 FA1B E5FE (Rust Language (Tag and Release Signing Key)
rust-key@rust-lang.org), which https://static.rust-lang.org/rust-key.gpg.ascii publishes.
Pinning a new rustup. Write the sums of the new version's rustup-init for the six hosts of
CI into rustup-init.sha256, from the .sha256 files next to them on static.rust-lang.org.
The tests run on Ubuntu 22.04 and 24.04 (x86_64) and 24.04 (arm64), macOS 15, and Windows Server
2022 and 2025. On Ubuntu 24.04 they run a second time for the musl target of the Linux release
on that architecture, because musl and glibc differ in constants that sandy builds its filters
from; on arm64 that run links its binaries as the release does, as static PIEs through
scripts/link-static-pie.sh. On macOS the suite runs sandy as scripts/release-build.sh
links it, after scripts/check-macos-stubs.sh has checked the libSystem declarations of
link/MacOSX.sdk against the runner's SDK. On Windows Server 2025 the suite also runs against
sandy.exe as scripts/release-build.sh links it on Linux. Each runner names the features it must be able to check and, on
Linux, the lowest Landlock ABI it must report; after the suite, CI compares the skips with the
runner's list. Every job that builds checks the toolchain first, with
scripts/verify-toolchain.sh. The audit workflow runs cargo deny and
scripts/check-audits.sh, weekly as well, because an advisory can appear, and a trusted
publisher entry can end, without a push. The release workflow builds archives for Linux, macOS
and Windows on x86_64 and arm64 when a tag v<version> is pushed, all of them on x86_64 Linux,
the runner that verify-release uses too, and without network after scripts/release-fetch.sh
has fetched what they need (scripts/without-network.sh). It starts the x86_64 Linux binary
(sandy --version) and the arm64 Linux binary on an arm64 runner, runs the whole suite against
the packaged macOS binaries on the macOS 15 and 26 runners and, for x86_64, an Intel Mac, and
against the packaged Windows binaries on Windows Server 2025 and 2022 and, for arm64, Windows 11
on arm64, and, once the audit workflow has passed for the same commit and a reviewer of the
release environment has approved, checks that it holds exactly the six archives, attests their
build provenance, verifies the attestations, and creates a draft release with SHA256SUMS. It also builds every target twice from two directories and compares the
binaries. The verify-release workflow builds a published release again and compares it with its archives
(see "Building a release binary again"); scripts/check-release.sh does the same check outside
GitHub, in a container on any machine with Docker.
Expected-skip lists exist only for the development hosts the suite ran on, not yet for the runners
above, so the comparison step fails on each runner until its list is committed: the failure prints
the skips of the run, which become crates/sandy-cli/tests/expected-skips/<runner>.txt once
checked.
| Crate | Role |
|---|---|
sandy-core |
Policy, validation, requirements, enforcement plan, backend contract |
sandy-linux, sandy-macos, sandy-windows |
One backend each |
sandy-relay |
The byte relay behind expose |
sandy-cli |
The sandy command, backend selection, the test probe and harnesses, and the escape tests |
- docs/DESIGN.md: goals, threat model, policy rules, requirements
- docs/linux.md, docs/macos.md, docs/windows.md: mechanisms, system sets, limitations, sources
- docs/research: background research (Linux mechanisms, Windows 10 support)
Licensed under either of Apache License, Version 2.0 or MIT license at your option.