scudo
scudo wraps commands with systemd-run, applying resource limits and sandboxing constraints defined in per-command profiles. It requires Linux with systemd.
What it is
Much of what runs on a developer’s machine executes code that nobody has read:
- package managers running install scripts;
- build systems running
build.rs,setup.pyand Makefiles; - editors loading extensions;
- AI coding agents running whatever they decide to.
All of it runs as you, with the same access you have: your $HOME, your SSH keys and tokens,
your session bus, the whole network.
scudo narrows that access, one command at a time. The command still runs on your system,
with your toolchain and your files, but inside a transient systemd unit that is told what it
may see, write and reach. A profile says what that is, and is matched by the command’s
name. Once scudo is symlinked under a command’s name, or its FUSE shim is on your PATH, the
command is sandboxed every time it runs, without anyone having to remember to ask
(Symlinks, aliases and the FUSE shim).
What a profile can say:
- Files: which paths are writable, read-only, replaced by an empty directory, or
inaccessible. Typically: the project and the tool’s own cache, and nothing else of
$HOME(Filesystem access). - Network: which addresses, hosts and ports may be reached, enforced by eBPF programs on the command’s cgroup, with DNS answered only for the names the rules mention (Network filtering).
- Session bus: none of it by default, or a filtered view, so that the command cannot ask
systemd --userto start something outside the sandbox (Session bus). - Resources and everything else a systemd unit can say:
MemoryMax=,CPUQuota=,IOWeight=, capabilities, private/tmpand so on.
It also tells you what it took away. A sandboxed command that fails because a file was hidden or a connection refused looks exactly like a broken program, so scudo can report every refusal, as it happens or in a terminal UI beside the command.
What it is not
-
Not an antivirus. scudo does not scan, detect or recognise anything. It has no idea whether the code it runs is malicious, and it never will. It limits what any code run under a profile can do, whatever that code turns out to be. A malicious package is still installed, still runs, and can still do whatever its profile allows. What changes is what that amounts to.
-
Not a container or a virtual machine. The command runs on your kernel, as your user, against your real filesystem, seen through mount restrictions. A kernel vulnerability, or a privileged program it is allowed to talk to, is outside what scudo can contain.
-
Not stronger than its profile. Whatever a profile grants is granted:
- a project directory left writable can be damaged;
- a token left readable can be read;
- a network left open can carry it away.
A profile that hides nothing protects nothing.
-
Not something that cannot be avoided. Commands that are not run through scudo are not sandboxed: a script that calls
/usr/bin/npmby its full path skips the symlink. scudo confines the commands you route through it, not everything on the machine. -
Not a replacement for reviewing what you install. It limits the damage of a bad dependency, it does not tell you that you have one.
Use cases
- Keep
npm install,pipandcargo buildaway from your secrets. Hide$HOME, give back only the project and the tool’s cache, and let the tool reach its registry and nothing else. - Fence an AI coding agent into the project it was started in. The project is writable, everything else is read-only or hidden, and the network is limited to the agent’s API. The tools the agent runs can be delegated to sandboxes of their own, so that an install script run by the agent does not inherit the agent’s access.
- Run an editor or an Electron app without letting it escape. The session bus is filtered,
so the program cannot ask
systemd --userto move it out of its sandbox. - Cap a hungry program with memory, CPU and I/O limits, per command.
- Find out what a program actually touches. Refused connections, DNS lookups and hidden files are reported as they happen.
What it protects against
A typical case is code from a compromised dependency, run by a tool you trusted. Without
scudo it has everything you have. Under a profile that hides $HOME and restricts the
network, it is kept from:
- Reading your secrets: SSH keys, cloud credentials, registry tokens, browser profiles,
password stores and anything else under
$HOMEthat the profile does not give back. - Using your agents: the ssh-agent and gpg-agent sockets let a program push or sign as you
without ever reading a key. Hiding
$XDG_RUNTIME_DIRtakes them away with every other socket of your session. - Sending what it found somewhere: with a network policy, the command can only reach the hosts its rules name, and can only resolve those names.
- Staying behind: no writing to shell startup files, to directories on your
PATH, to.git/hooks, or to anything else that would run its code again later, outside the sandbox. - Leaving the sandbox: no session bus and no way to the service manager, so it cannot start a process in a unit of its own, nor move itself out of its cgroup.
- Taking the machine down: memory, CPU and process limits, if the profile sets them.
Each of these holds only as far as the profile says so: scudo enforces the profile, and the profile decides how much protection there is.
How it differs from other sandboxes
Flatpak, bubblewrap and Firejail all confine programs on Linux, and all three are good at what they were made for. scudo was made for a different job: putting the tools you already have, installed the way they already are, under a policy that follows the command wherever it is run from.
| scudo | Flatpak | bubblewrap | Firejail | |
|---|---|---|---|---|
| What it confines | the commands already on your system | applications packaged as Flatpaks | whatever command line you write for it | the commands already on your system |
| Configured by | per-command profiles, matched by name, and per-directory .scudo.toml files | the app’s manifest, plus overrides | flags on each invocation | per-program profiles |
| Privileges | none; an optional helper with file capabilities for network filtering | none (it uses bubblewrap) | none | a setuid-root binary |
| Network | per host, address and port, DNS answered only for named hosts | all or nothing | all or nothing | all or nothing, or a firewall inside a network namespace of its own |
| Resource limits | anything a systemd unit can set | none of its own | none | rlimits |
| Reporting | every refused connection, lookup and file, live | — | — | blacklisted files, to syslog |
Flatpak is a way to distribute desktop applications. An application is built against a
runtime, ships with a description of what it needs, and talks to the desktop through portals.
That is the right shape for a GUI app you install from Flathub, but not for the npm, cargo
or make on your PATH: those are not Flatpaks, they work on your files and your toolchain,
and repackaging them would mean replacing the environment you build in. scudo confines those
binaries as they are.
bubblewrap is the low-level tool Flatpak is built on: it sets up namespaces and bind mounts from its command line and runs the command. It has no notion of a profile, of which command is being run, or of a policy beyond the flags it was given, so using it day to day means writing a wrapper script for each command. It also stops at namespaces: there are no cgroup limits and no network rules finer than having a network or not. scudo gets its namespaces from systemd instead, and adds what a wrapper script would otherwise have to reinvent:
- profiles chosen by the command’s name;
- variables from the project and the command’s arguments;
- limits, network filtering and refusal reports.
Firejail is the closest in spirit. It also confines the programs you already have, symlinked under their own names, and ships a large collection of profiles for desktop software. The differences are in how it gets there and what it aims at:
- Privileges. Firejail is setuid root, so every sandbox starts from a root process that parses user input. scudo runs entirely as you. Its one privileged part, the network helper, holds two file capabilities and can only ever add restrictions to a cgroup you already own.
- Per-directory profiles. Firejail’s profiles belong to the program and are the same
wherever it runs. scudo also reads the
.scudo.tomlfiles in the directory the command is started in and in each of its parents, so each project can carry its own profiles — or add to the global ones — next to its code: one checkout’scargomay reach only its registry and hold 4G, another’s a private mirror. A project file applies only once you have trusted it. - The network. Filtering by host name, with DNS refused for every name the rules do not mention, works on the command’s own cgroup. It needs no network namespace, bridge or firewall setup.
- Developer tools and agents. Profiles can follow the project the command was started in, or a directory named in its arguments. An agent’s sandbox can delegate the tools it runs to sandboxes of their own. Neither of these is something a desktop application needs, which is what Firejail’s profiles are written for.
What scudo gives up in return:
- it needs Linux with systemd, and
systemd --userrunning; - reporting refused file accesses is implemented on x86-64 only;
- it has no answer for desktop integration beyond filtering the session bus: an app that lives on portals is better served by Flatpak.
License
Licensed under either of Apache License, Version 2.0 or MIT License, at your option.
Installation
scudo needs Linux with systemd and a user session (systemd --user). Some features need a
little more:
fuse3for the FUSE shim;xdg-dbus-proxyfor the session bus filter;- a kernel with cgroup eBPF support, and
scudo-bpfdwith its file capabilities and itsscudo-bpfd.socketuser unit enabled and active, for network filtering and for reporting refused file accesses.
Prebuilt binaries
Each release has an archive for x86-64,
scudo-<version>-x86_64-linux.tar.xz. Its binaries are statically linked, glibc included, so
they depend on no library of the host. They are built against glibc 2.28 and run on RHEL 8,
Ubuntu 22.04 and anything newer.
The archive holds scudo, scudo-fuse and scudo-bpfd, the systemd user units under
systemd/, the licenses and the README. The units expect the binaries in /usr/bin:
tar -xJf scudo-<version>-x86_64-linux.tar.xz
cd scudo-<version>-x86_64-linux
sudo install -m755 scudo scudo-fuse scudo-bpfd /usr/bin/
sudo install -m644 systemd/* /usr/lib/systemd/user/
A tar archive does not carry file capabilities, so they have to be given back to
scudo-bpfd. Without cap_bpf and cap_net_admin it can do nothing; cap_perfmon is only
for reporting refused file accesses:
sudo setcap cap_bpf,cap_net_admin,cap_perfmon=ep /usr/bin/scudo-bpfd
systemctl --user enable --now scudo-bpfd.socket
Enabling scudo-bpfd
scudo-bpfd is socket-activated: systemd --user listens on $XDG_RUNTIME_DIR/scudo/bpfd.sock
and starts the daemon on the first connection. scudo-bpfd.socket has to be enabled and
active in systemctl --user, or nothing that depends on the daemon works:
- network policies finer than denying everything — destinations, ports, host names and the DNS allowlist;
- reports of refused connections and DNS lookups, including for a deny-all policy;
- reports of refused file accesses (
filesystem.log = "denied",--verbose); - the live list beside the command in
--tui.
With the socket down, a policy that needs the daemon falls to
on_unavailable — by default the command does not run — and
the reports are lost, with a warning saying so. To check:
systemctl --user status scudo-bpfd.socket
Every release also has a .sha256 file beside the archive, to check it with
sha256sum -c.
Building
cargo build --release
The stable toolchain is enough. The eBPF programs additionally need its rust-src component, since
core is built from source for their tier 3 target, and bpf-linker:
rustup component add rust-src --toolchain stable
cargo install bpf-linker
Two features are on by default and can be dropped:
| Feature | Drops |
|---|---|
bpf | scudo-bpfd and the eBPF programs, along with the rust-src component and bpf-linker needed to build them. scudo still talks to a daemon that is installed. |
tui | --tui and the terminal UI. A config naming [scudo.tui] still parses, so one shared with a full build is not rejected. |
cargo build --release --no-default-features --features bpf
The binaries go in target/release, and are installed as above:
sudo install -m755 target/release/scudo target/release/scudo-fuse target/release/scudo-bpfd /usr/bin/
sudo install -m644 scudo-bpfd.service scudo-bpfd.socket scudo-fuse.service scudo-dbus@.service \
/usr/lib/systemd/user/
sudo setcap cap_bpf,cap_net_admin,cap_perfmon=ep /usr/bin/scudo-bpfd
systemctl --user enable --now scudo-bpfd.socket
As with the prebuilt binaries, the socket must be enabled for network filtering and reporting.
Static binaries
The release archives are built by packaging/build-static.sh, in an AlmaLinux 8 container.
The same build can be run locally with podman or docker:
podman run --rm -v "$PWD:/src:Z" -w /src almalinux:8 packaging/build-static.sh
The archive ends up in dist/.
Usage
Usage: scudo [OPTIONS] <COMMAND> [COMMAND_ARGS]...
Arguments:
<COMMAND>
[COMMAND_ARGS]...
Options:
-v, --verbose Enable verbose output. A shorthand for `--log-level debug`
--log-level <LEVEL> How much to report: off, error, warn, info, debug or trace [env: SCUDO_LOG_LEVEL]
--log-target <TARGET> Where to report it: stderr, file, syslog or journald (repeatable) [env: SCUDO_LOG_TARGET]
--log-file <PATH> Write the log to this file. Implies `--log-target file` [env: SCUDO_LOG_FILE]
--log-syslog-facility <FACILITY> The syslog facility to log under. Only used by `--log-target syslog` [env: SCUDO_LOG_SYSLOG_FACILITY]
--confirm Confirm before running the command
-P, --property <PROPERTY=VALUE> Set a property for the systemd-run command
-p, --profile <NAME> Apply the named profile, ignoring its match rules (repeatable, comma-separated)
--no-match Skip profile matching; apply only the profiles given with --profile
--delegate Run commands started inside the sandbox under their own profile when it is delegable, as `delegate = true` does
--no-delegate Run every command started inside the sandbox in the sandbox itself, whatever `delegate` says
--allow-dest <DEST> Allow a destination (address, CIDR, host or glob), even one a profile denies (repeatable, comma-separated)
--deny-dest <DEST> Deny a destination, even one a profile allows (repeatable, comma-separated)
--allow-port <PORT[/PROTO]> Allow a port or range, optionally `/tcp` or `/udp`, even one a profile denies (repeatable, comma-separated)
--deny-port <PORT[/PROTO]> Deny a port or range, optionally `/tcp` or `/udp`, even one a profile allows (repeatable, comma-separated)
--fs-hide <PATH> Hide a directory behind an empty one, even one a profile grants (repeatable)
--fs-read <PATH> Make a path read-only, even one a profile hides or makes writable (repeatable)
--fs-write <PATH> Make a path writable, even one a profile hides or makes read-only (repeatable)
--deny-env <PATTERN> Keep environment variables out of the sandbox, by name or glob, as `deny_env` in a profile does (repeatable, comma-separated)
-E, --allow-env <PATTERN> Copy environment variables into the sandbox even when denied, as `allow_env` in a profile does (repeatable, comma-separated)
--tui Run the command in scudo's terminal UI, beside a live list of blocks
--tui-prefix <KEY> The UI's prefix key, as `C-a`, `M-x` or `F12`. Overrides `[scudo]`
-h, --help Print help
-V, --version Print version
Symlinks, aliases and the FUSE shim
Via symlink — scudo detects it is being called through a symlink and selects the matching profile automatically:
ln -s /usr/bin/scudo /usr/local/bin/curl
curl https://example.com # runs through scudo with the "curl" profile
Via shell alias — a lighter alternative that requires no changes to the filesystem:
alias curl='scudo curl'
alias wget='scudo wget'
Via FUSE shim — a virtual filesystem that intercepts every command automatically, with no per-command setup:
scudo-fuse ~/.scudo/shims /usr/bin/scudo
export PATH="$HOME/.scudo/shims:$PATH"
scudo-fuse mounts a read-only virtual directory. Every executable found in PATH appears there as a symlink pointing to the target binary (/usr/bin/scudo). Commands not present in the real PATH remain invisible, so tab-completion and ls only show entries that actually exist. Placing the shims directory first in PATH causes any command with a matching profile to be dispatched through scudo automatically, with no aliases or individual symlinks needed.
The shims directory itself is excluded from the PATH search used for the existence check, which prevents FUSE re-entrancy deadlocks when the shims directory is already in PATH.
Warning: When using the FUSE shim, avoid broad globs (
*,code*) that match large numbers of commands in combination withconfirm = trueorverbose = true. Because the shim intercepts every command in your session — including those run by scripts, your shell itself, and every subprocess — an overly wide profile with interactive options will trigger a confirmation prompt or dump debug output for each of them, making the environment unusable. Reserveconfirmandverbosefor profiles that match a few commands by name, and validate broad profiles without those options first.
By default the command search path is read from the PATH environment variable; pass --path / -p to override it:
scudo-fuse ~/.scudo/shims /usr/bin/scudo --path /usr/local/bin:/usr/bin:/bin
Autostart with systemd (user service)
A ready-made unit file is included in the repository as scudo-fuse.service. It uses RuntimeDirectory=scudo so the shims directory is created automatically under $XDG_RUNTIME_DIR (e.g. /run/user/1000/scudo/shims).
cp scudo-fuse.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now scudo-fuse
Add the PATH export to your shell’s init file (.bashrc, .zshrc, …):
export PATH="${XDG_RUNTIME_DIR}/scudo/shims:$PATH"
Configuration
Profiles are loaded from the following paths in order, with later files taking precedence over earlier ones:
| Path | Scope |
|---|---|
/etc/scudo/config.toml | system-wide |
$XDG_CONFIG_HOME/scudo/config.toml | per-user |
/.scudo.toml … $PWD/.scudo.toml | per-directory |
To start from something that already works, copy the
recommended configuration to
$XDG_CONFIG_HOME/scudo/config.toml and adapt it.
For the per-directory tier, scudo walks every ancestor of $PWD from the filesystem root down to the current directory, loading each .scudo.toml it finds. This lets a project directory override settings defined higher up the tree. For example, with $PWD set to /home/user/projects/myapp, the lookup order is /.scudo.toml, /home/.scudo.toml, /home/user/.scudo.toml, /home/user/projects/.scudo.toml, /home/user/projects/myapp/.scudo.toml.
Every top-level section is a profile, with one exception: [scudo] holds settings that are
about scudo itself rather than about a command — the logging defaults, the
terminal UI and the lookup variables every profile can
use. It is merged key by key across the files above like everything else, and scudo is
therefore not available as a profile name.
[scudo]
log.level = "info"
tui.prefix = "C-b"
tui.scrollback = 20000
vars.PROJECT_DIR = { lookup = ".git", fallback = "cwd" }
Recommended configuration
scudo ships a configuration meant to be copied and adapted:
examples/config.toml,
also installed with the documentation by the packages and included in the release
archives. It is reproduced in full below.
It is a starting point, not a guarantee. You are responsible for the configuration you choose to use, and for judging whether it protects what you need it to protect in your own case. scudo enforces exactly what the profiles say, and a profile that grants too much protects nothing (What it is not). Read it, adapt it to the tools and the paths you actually have, and check what a command can still reach before relying on it —
scudo --tui -- <command>, or a shell under one of its profiles:scudo --no-match --profile supply-chain -- sh.
Copy it to your own configuration, whole or only the profiles you want:
mkdir -p ~/.config/scudo
curl -fsSLo ~/.config/scudo/config.toml \
https://raw.githubusercontent.com/mtorromeo/scudo/main/examples/config.toml
scudo itself has to be installed outside $HOME, as the packages do, or in ~/.local/bin.
It runs inside every sandbox that reports refusals, filters the network or delegates
commands, and these profiles hide the rest of $HOME. For the same reason the
FUSE shim has to be mounted under
$XDG_RUNTIME_DIR/scudo/shims, the one place these profiles give back, for commands started
inside a sandbox to be wrapped too.
Beware if you install it as /etc/scudo/config.toml. Profiles there are merged into every user’s
profiles of the same name (Same-named profiles across
files), and its match rules would sandbox
every user’s package managers.
What is in it
| Profile | Matches | What it is |
|---|---|---|
sandbox | — | Everything read-only, $HOME and $XDG_RUNTIME_DIR replaced by empty directories, a private /tmp; ~/.cache, ~/.local/bin (read-only) and scudo’s own daemon socket and FUSE shim (read-only) given back; variables named *_SECRET, *_TOKEN or *_KEY kept out |
git-project | — | sandbox, plus the project writable and /var denied |
no-net | — | No network beyond localhost; for --profile |
supply-chain | — | git-project, with the project’s .git read-only; delegable |
npm, pnpm, yarn, bun, deno | their commands | supply-chain and the tool’s cache; ~/.npmrc stays hidden |
pip, uv, poetry | their commands | supply-chain, the tool’s config read-only |
cargo | cargo | supply-chain, ~/.cargo and ~/.rustup read-only except the registry, credentials denied |
composer, go, ruby | their commands | supply-chain and the tool’s cache |
cargo-install | cargo install, cargo binstall, cargo install-update, cargo-binstall, cargo-install-update | cargo, with all of ~/.cargo writable except the credentials; not delegable |
npm-auth | npm publish, npm login | ~/.npmrc writable on top of npm; not delegable |
agents | claude, opencode | git-project, writing .git and the agent’s own state; delegates every delegable profile |
Several pages of this documentation build their examples on these profiles.
Why it is written this way
$HOMEis hidden, not made read-only. A read-only$HOMEstill hands out SSH keys, tokens and browser profiles to anyone who reads them. Each profile gives back what its tool needs, which is usually its cache and its configuration.- The session’s sockets are hidden with it, in the base every profile extends.
ssh-agent,gpg-agent, the keyring and the container sockets let a program act as you without reading a single secret, and connecting to a socket needs no write access, so a read-only runtime directory is not enough. Only one entry of scudo’s own comes back, read-only:shims, so that a command started inside is still wrapped. The sockets scudo needs in there —scudo-bpfd’s when a policy needs the daemon, the broker’s when the sandbox delegates — it mounts by itself, read-only. Do not give back the whole$XDG_RUNTIME_DIR/scudo: it also holds the session bus proxies and the brokers of other sandboxes. .gitis read-only for build and install tools. Hooks,core.hooksPathandcore.fsmonitorare run by git outside any sandbox, so a writable.gitis a way out. The agent, which commits, gets it back.- Directories on
PATHstay read-only:~/.local/bin,~/.cargo/bin, Composer’svendor/bin. A build script that could write there would replacenpmorgitwith something that runs unsandboxed the next time. Installing into them is what thecargo-installprofile is for: it matchescargo installon its arguments, and saysdelegable = falseso that a sandbox delegatingcargocannot install through it. $XDG_RUNTIME_DIRis never written by a base profile. The most permissive access to a path wins (Filesystem access), so a base that wrote it would undo the hiding in every profile built on it. A profile that needs one socket, an editor talking to the display say, gives back that socket alone.- What the agent can use as you is opt-in. The display (to paste images),
gpg-agent(to sign commits) and the keyring’s SSH agent (to push) are listed in comments at the end of[agents]: each one is something a prompt injection could use too.
The file
# A recommended starting point for ~/.config/scudo/config.toml
#
# This file is a starting point, not a guarantee. You are responsible for the
# configuration you choose to use, and for judging whether it protects what you
# need it to protect in your own case: scudo enforces exactly what the profiles
# say, and a profile that grants too much protects nothing. Read it, adapt it,
# and check what a command can still reach (`scudo --tui -- <command>`, or
# `scudo -v --profile sandbox -- sh`) before relying on it.
#
# Copy it as it is, or only the profiles you want:
#
# mkdir -p ~/.config/scudo
# cp examples/config.toml ~/.config/scudo/config.toml
#
# Do not install it as /etc/scudo/config.toml: profiles there are merged into
# every user's profiles of the same name, and its `match` rules would sandbox
# every user's package managers.
#
# A profile only applies to a command run through scudo: `scudo npm install`,
# a symlink named after the command, or the FUSE shim on PATH.
#
# scudo itself has to be installed outside $HOME (/usr/bin, as the packages
# do), or in ~/.local/bin: it runs inside every sandbox that reports refusals,
# filters the network or delegates, and the rest of $HOME is hidden.
# The project a command runs in: the closest directory holding a `.git`, or the
# current directory outside a repository.
[scudo]
vars.PROJECT_DIR = { lookup = ".git", fallback = "cwd" }
# ── Bases ───────────────────────────────────────────────────────────────────
# No `match`: these never apply on their own, only through `extend` or
# `--profile`.
# Everything is read-only, $HOME and the session's sockets are replaced by
# empty directories and /tmp is private. Give back what a tool needs in the
# profile that extends this one.
#
# The session's sockets hand out credentials (ssh-agent, gpg-agent, keyrings)
# or run code outside the sandbox (podman, docker), and new ones keep
# appearing, so the whole runtime dir goes and a profile that needs a socket
# gives that one back. Hiding it is what matters, not making it read-only:
# connecting to a socket needs no write access. For the same reason, do not
# write $XDG_RUNTIME_DIR here or in a profile everything extends: the most
# permissive access wins, and nothing would be hidden.
#
# Only one thing of scudo's own comes back, read-only: the FUSE shim, so that
# commands run from inside are still wrapped. The sockets scudo itself needs
# in there — the daemon's, when a policy needs it, and the broker's, when the
# sandbox delegates — are mounted by scudo on its own. Never give back the
# whole $XDG_RUNTIME_DIR/scudo: it also holds the session bus proxies and the
# brokers of other sandboxes.
[sandbox]
systemd.WorkingDirectory = "/"
systemd.PrivateTmp = true
filesystem.read = [
"/",
"$HOME/.local/bin",
"$XDG_RUNTIME_DIR/scudo/shims",
]
filesystem.hide = ["$HOME", "$XDG_RUNTIME_DIR"]
filesystem.write = ["/tmp", "$HOME/.cache"]
# Credentials exported in the shell, or by the desktop session, stay outside.
deny_env = ["*_SECRET", "*_TOKEN", "*_KEY"]
# Works on the project it was started in. /var is denied, since nothing a
# project tool needs lives there and service data often does.
[git-project]
extend = "sandbox"
systemd.WorkingDirectory = "$PWD"
filesystem.deny = ["/var"]
filesystem.write = ["${PROJECT_DIR:-$PWD}"]
# No network at all, except the machine itself. Explicit only:
# `scudo -p no-net -- <command>`.
[no-net.network]
default = "deny"
inbound = "deny"
on_unavailable = "fail"
dest.allow = ["localhost"]
# ── Supply chain ────────────────────────────────────────────────────────────
# Package managers run code they downloaded (install scripts, build.rs,
# setup.py): assume that code is hostile.
# Base for package managers: the project is writable, its .git is not, since
# hooks, core.hooksPath and core.fsmonitor are run by git outside the sandbox.
[supply-chain]
extend = "git-project"
# Built to run hostile code confined, so a sandbox may ask for its commands to
# run here instead of inside it; see `delegate` in [agents].
delegable = true
filesystem.read = ["${PROJECT_DIR:-$PWD}/.git"]
# ── JavaScript ──────────────────────────────────────────────────────────────
# ~/.npmrc holds the registry token that a postinstall would steal and
# republish with (Shai-Hulud): it stays hidden with the rest of $HOME, and npm
# runs with its default settings.
#
# If npm needs settings of yours (a private registry, a scope), write them to
# a second file without the token, and add to [npm]:
# systemd.Environment = ["NPM_CONFIG_USERCONFIG=$HOME/.config/scudo/npm/npmrc"]
# filesystem.read = ["$HOME/.config/scudo/npm/npmrc"]
# and to [npm-auth], which would otherwise inherit that variable:
# systemd.Environment = ["NPM_CONFIG_USERCONFIG=$HOME/.npmrc"]
[npm]
extend = "supply-chain"
match = ["npm", "npx"]
filesystem.write = ["$HOME/.npm"]
[npm-auth]
match = ['/^npm (publish|login)( |$)/']
delegable = false
filesystem.write = ["$HOME/.npmrc"]
[pnpm]
extend = "npm"
match = ["pnpm", "pnpx", "pnx"]
filesystem.read = ["$HOME/.local/share/pnpm"]
filesystem.write = ["$HOME/.local/share/pnpm/store", "$HOME/.pnpm-store"]
[yarn]
extend = "npm"
match = ["yarn"]
filesystem.read = ["$HOME/.yarnrc"]
filesystem.write = ["$HOME/.yarn"]
[bun]
extend = "npm"
match = ["bun", "bunx"]
filesystem.read = ["$HOME/.bun"]
filesystem.write = ["$HOME/.bun/install/cache"]
[deno]
extend = "supply-chain"
match = ["deno"]
# ── Python ──────────────────────────────────────────────────────────────────
# Caches live in ~/.cache. Installs go into the project's venv; --user and
# tool installs land in directories on PATH and need running unsandboxed.
[pip]
extend = "supply-chain"
match = ["pip", "pip3"]
filesystem.read = ["$HOME/.config/pip"]
[uv]
extend = "supply-chain"
match = ["uv", "uvx"]
filesystem.read = ["$HOME/.config/uv", "$HOME/.local/share/uv"]
filesystem.write = ["$HOME/.local/share/uv/python"]
[poetry]
extend = "supply-chain"
match = ["poetry"]
filesystem.read = ["$HOME/.config/pypoetry"]
# ── Rust ────────────────────────────────────────────────────────────────────
# ~/.cargo/bin is usually first on PATH: a build.rs that could write there
# would shadow npm, git or cargo itself with no sandbox at all.
[cargo]
extend = "supply-chain"
match = ["cargo"]
filesystem.read = ["$HOME/.cargo", "$HOME/.rustup"]
filesystem.write = [
"$HOME/.cargo/registry",
"$HOME/.cargo/git",
"$HOME/.cargo/advisory-db",
"$HOME/.cargo/script-cache",
]
filesystem.deny = ["$HOME/.cargo/credentials", "$HOME/.cargo/credentials.toml"]
# `cargo install`, `cargo install-update` and `cargo binstall` (with or without a `+toolchain`),
# and `cargo-binstall`/`cargo-install-update` run directly: what installing needs on top of [cargo].
# Not delegable, though it extends a profile that is: a sandbox that delegates
# `cargo` must not get to install into ~/.cargo/bin, outside of it, by asking.
[cargo-install]
extend = "cargo"
match = ['/^cargo( \+\S+ |[ -])?(b?install|install-update)( |$)/']
delegable = false
filesystem.write = ["$HOME/.cargo"]
# ── Others ──────────────────────────────────────────────────────────────────
# COMPOSER_HOME is ~/.config/composer; its vendor/bin is on PATH, so it is
# read-only like ~/.cargo/bin.
[composer]
extend = "supply-chain"
match = ["composer"]
filesystem.read = ["$HOME/.config/composer"]
filesystem.write = ["$HOME/.config/composer/cache"]
[go]
extend = "supply-chain"
match = ["go"]
filesystem.read = ["$HOME/.config/go", "$HOME/go"]
filesystem.write = ["$HOME/go/pkg/mod"]
[ruby]
extend = "supply-chain"
match = ["gem", "bundle", "bundler", "rake"]
filesystem.write = ["$HOME/.gem", "$HOME/.bundle", "$HOME/.local/share/gem"]
filesystem.deny = ["$HOME/.gem/credentials"]
# ── AI coding agents ────────────────────────────────────────────────────────
# The agent works on its project and keeps its own state; everything it runs
# that has a delegable profile (all of the above) runs in a sandbox of its own
# instead of inheriting the agent's access.
[agents]
extend = "git-project"
match = ["claude", "opencode"]
delegate = true
# The broker's reports would otherwise land in the agent's terminal.
log.target = "journald"
# Your git identity, and where the native installers put the agents' own
# binaries (~/.local/bin only holds links to them), read-only.
filesystem.read = [
"$HOME/.gitconfig",
"$HOME/.config/git",
"$HOME/.local/share/claude",
"$HOME/.opencode",
]
filesystem.write = [
# The agent commits: the project's .git, which [supply-chain] keeps read-only.
"${PROJECT_DIR:-$PWD}/.git",
"$HOME/.claude",
"$HOME/.claude.json",
"$HOME/.config/opencode",
"$HOME/.local/share/opencode",
"$HOME/.local/state/opencode",
]
# Opt-ins: each of these hands the agent something it can use as you. Add the
# ones you want to the `filesystem.read` list above.
#
# "$XDG_RUNTIME_DIR/${WAYLAND_DISPLAY:-wayland-0}", # pasting images
# "$HOME/.gnupg", "$XDG_RUNTIME_DIR/gnupg", # gpg: signs commits, and anything else
# "$XDG_RUNTIME_DIR/gcr", # the keyring's ssh agent: pushes wherever you can
Trust confirmation for per-directory configs
Because .scudo.toml files can be committed to a repository and may arrive from external sources, scudo requires explicit user approval before loading them. On first encounter scudo prints the full file contents and asks for confirmation. When a previously approved file changes, scudo shows a unified diff against the trusted version and asks again.
New config file: /home/user/projects/myapp/.scudo.toml
[curl]
PrivateNetwork = true
MemoryMax = "256M"
Trust this config file? [y/N]
Approved files are recorded in $XDG_CONFIG_HOME/scudo/trusted.toml. Matching is done on the canonical absolute path and the exact file contents. If the user declines, scudo exits with an error rather than running the command without the intended sandboxing constraints.
Profiles
Each TOML section defines a profile. The section name identifies the profile for extend and --profile; it is not a match pattern.
| Field | Type | Description |
|---|---|---|
match | string or array of strings | Glob(s) or /regex/flags matched against the command name, or against the whole command line when the pattern holds a space; any one is enough. See Match patterns. Absent = the profile never matches a command on its own and is reachable only through extend or --profile. |
extend | string or array of strings | Profile(s) applied before this one, left to right; see Combining profiles |
delegate | true, string or array of strings | Profiles whose commands, started from inside this sandbox, run in a sandbox of their own: true for every delegable one, or their names. See Delegating commands |
delegable | bool | Whether a sandbox may have this profile’s commands run under it on its request; inherited through extend (default: false). See Delegating commands |
replace | array of strings | Fields that replace, instead of adding to, what a same-named profile from a lower-priority config file says; only "match" for now. See Same-named profiles across files |
inherit | bool | Whether this profile adds to a same-named profile from a lower-priority config file (true, the default) or replaces it (false); see Same-named profiles across files |
exec | string | Absolute path of the binary to execute, bypassing PATH resolution |
verbose | bool | Shorthand for log.level = "debug", which among other things prints the full systemd-run command before executing (default: false) |
confirm | bool | Prompt for confirmation before executing (default: false); silently skipped in non-interactive shells |
deny_env | string or array of strings | Environment variables, by name or glob, kept out of the sandbox; see Environment |
allow_env | string or array of strings | Environment variables, by name or glob, passed on even though deny_env matches them; see Environment |
args | table | Maps $arg_* variable names to the flag(s) that fill them; see Command argument variables |
vars | table | Variables naming a directory found by looking up the tree; see Lookup variables |
filesystem | table | Which paths the command may write, read, not see or not touch; see Filesystem access |
network | table | Network policy; see Network filtering |
dbus | table | What the command gets of the session bus; see Session bus |
log | table | Level and destination for scudo’s own output; see Logging |
systemd | table | Unit properties passed to systemd-run; see below |
Every key of the systemd table is passed directly to systemd-run as -p Key=value. Use the exact systemd unit property names (PascalCase). Values can be strings, booleans (true/false → yes/no), integers, or arrays. Write it either as a sub-table header ([curl.systemd]) or with dotted keys (systemd.MemoryMax = "256M").
The command starts in the current directory, unless WorkingDirectory is set (in the table or with -P): then systemd-run is not given -d and the property decides.
Any other key is a typo and scudo says so instead of ignoring it.
Environment
The command gets the environment scudo is started with, as it would without scudo. systemd-run starts the unit from the service manager’s environment (systemctl --user show-environment), so scudo changes that one into its own: a variable the manager has with a different value, or not at all, is passed on with systemd-run --setenv=NAME, and one the manager has but scudo’s environment does not is removed with UnsetEnvironment=.
deny_env keeps variables out, wherever they come from: what it matches is neither passed on nor left in from the manager’s environment. allow_env lets back in some of what deny_env keeps out. Both take names or globs (*, ?, [A-Z]), and a variable allow_env matches is passed on whatever deny_env says:
# Credentials stay outside, as in the recommended configuration's `sandbox`.
[sandbox]
deny_env = ["*_SECRET", "*_TOKEN", "*_KEY"]
# A profile that extends it lets back in the one it needs.
[sentry-wizard]
match = "npx @sentry/wizard*"
extend = "sandbox"
allow_env = "SENTRY_ACCESS_TOKEN"
The alternative is to keep out everything but what is named, which leaves nothing to a naming convention:
[supply-chain]
deny_env = "*"
allow_env = [
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "TERM", "COLORTERM",
"LANG", "LANGUAGE", "LC_*", "TZ", "XDG_*",
]
--deny-env and --allow-env (-E) do the same for one run, adding to what the profiles say:
scudo --deny-env '*' -E PATH,HOME,TERM,SENTRY_ACCESS_TOKEN npx @sentry/wizard
The lists add up across combined profiles like any other list, so any profile can deny a variable and any profile can allow it back.
Some variables are never passed on:
- those a profile or scudo sets in the unit with
Environment=:systemd.Environment = ["NAME=value"]sets a fixed value that the caller’s environment does not replace, and$NAMEexpands there as in any other value; - those systemd sets for the unit scudo itself runs in (
NOTIFY_SOCKET,LISTEN_*,INVOCATION_ID,JOURNAL_STREAM,MANAGERPID, …), which describe that unit and not the sandbox, and those scudo uses between a broker and the run it starts; - names that are not valid variable names, such as the
BASH_FUNC_name%%bash exports functions with.
Some variables are never taken away, since the command would get them in any unit: those a profile or scudo sets with Environment=, and those systemd sets for every unit it starts (INVOCATION_ID, SYSTEMD_EXEC_PID, MEMORY_PRESSURE_WATCH, and HOME, USER, PATH when the manager’s environment lacks them), even with deny_env = "*". systemd.UnsetEnvironment removes them by name.
To read the manager’s environment scudo runs busctl --user. When that fails, every variable is passed on with --setenv and none is removed, unless deny_env or --deny-env names any, which makes it an error: what is denied could not be taken out.
Only the names appear on the systemd-run command line that scudo prints, but the values become properties of the unit: while the command runs, systemctl --user show on the unit shows them to your own user.
Example
The bases here are those of the recommended configuration, cut down to what the example needs.
# The project a command runs in: the closest directory holding a `.git`
[scudo]
vars.PROJECT_DIR = { lookup = ".git", fallback = "cwd" }
# Base profiles — no `match`, so they are never matched directly, only used via extend
[sandbox]
filesystem.read = ["/", "$XDG_RUNTIME_DIR/scudo/shims"]
filesystem.hide = ["$HOME", "$XDG_RUNTIME_DIR"]
filesystem.write = ["/tmp", "$HOME/.cache"]
systemd.PrivateTmp = true
[git-project]
extend = "sandbox"
filesystem.write = ["$PROJECT_DIR"]
# curl: extends the base sandbox
[curl]
match = "curl"
extend = "sandbox"
systemd.MemoryMax = "256M"
# claude: works on the project it is started in, plus any --add-dir
[claude]
match = "claude"
extend = "git-project"
args.add_dir = { flags = "--add-dir", greedy = true }
filesystem.read = ["$HOME/.gitconfig", "$arg_add_dir"]
filesystem.write = ["$HOME/.claude", "$HOME/.claude.json"]
network.dest.allow = ["*.anthropic.com"]
# code: VS Code and its variants
[code]
match = ["code", "code-oss", "codium", "vscode"]
extend = "git-project"
filesystem.write = [
"/proc",
"/dev/shm",
"/dev/dri",
"$HOME/.vscode",
"$HOME/.vscode-oss",
"$HOME/.config/Code",
"$HOME/.config/Code - OSS",
]
# The display: the one socket of the session's an editor needs, given back alone.
filesystem.read = ["$XDG_RUNTIME_DIR/${WAYLAND_DISPLAY:-wayland-0}"]
# The launcher leaves the editor running and exits: keep the unit until it is gone.
systemd.ExitType = "cgroup"
dbus.talk = [
"org.freedesktop.secrets",
"org.freedesktop.Notifications",
"org.freedesktop.portal.*",
]
dbus.own = ["com.visualstudio.code.*"]
# npm: the npm cache, and the project
[npm]
match = "npm"
extend = "git-project"
filesystem.write = ["$HOME/.npm"]
[pnpm]
match = "pnpm"
extend = "npm"
filesystem.write = ["$HOME/.pnpm-store"]
Every list adds to the ones it inherits: pnpm can write ~/.npm, the project and ~/.pnpm-store, and read everything sandbox lets it read.
Matching and combining profiles
Match patterns
match holds one pattern, or an array of them. Any one of them matching is enough.
- A pattern written
/…/is a regular expression (regexsyntax), optionally followed by flags as in sed:i(case-insensitive),m(multi-line),s(.matches newlines),x(verbose),U(swap greedy and lazy). It is not anchored: it matches anywhere unless it says^…$. The last/closes it, so a/inside needs no escaping, and an unknown flag is an error. Write regexes in single quotes, as TOML literal strings, so that backslashes reach the regex as written ("\S"is not a valid TOML string). - Any other pattern is a glob, which has to match the whole text.
- A pattern holding a space is compared with the command line: the command’s name, then its arguments, joined by single spaces (no quoting). Any other pattern is compared with the name alone.
The name is the command’s last path component, so /usr/bin/curl is curl, also at the start of the command line.
| Form | Meaning |
|---|---|
| absent | never match — base profile usable only via extend or --profile |
match = "curl" | curl |
match = ["zed", "zeditor"] | either zed or zeditor |
match = "code*" | code, code-oss, code-insiders, … |
match = "*" | every command: a catch-all default |
match = "git push*" | git push, git push origin main, … but not git status |
match = '/^python3?$/' | python or python3, not python3.12 |
match = '/^npm( \S+)* (-g|--global)( |$)/' | npm run with -g or --global anywhere among its arguments |
Every profile that matches is applied, in the order the profiles were written (see Combining profiles).
Selecting profiles explicitly
--profile <NAME> applies a profile by name, whatever its match field says — including base profiles that declare no match at all. It is repeatable and also accepts a comma-separated list. Named profiles are applied after the ones that matched, so they take precedence; among themselves they are applied in the order given on the command line, with the last one winning.
--no-match skips pattern matching altogether. Combined with --profile it means “use exactly these profiles and nothing else”; on its own it runs the command with no profile at all.
| Invocation | Profiles applied |
|---|---|
scudo --profile no-net -- curl https://example.com | everything matching curl, then no-net |
scudo --profile a,b -- curl https://example.com | matches for curl, then a, then b |
scudo --profile agents,npm,cargo -- zsh | a shell with everything those three profiles allow, and their restrictions |
scudo --no-match --profile no-net -- curl https://example.com | only no-net |
scudo --no-match -- curl https://example.com | none — the command is executed directly, without systemd-run |
scudo --no-match -p MemoryMax=1G -- curl https://example.com | none, but systemd-run still runs with the explicit property |
A profile that both matches and is named explicitly is applied only once, in its explicit position. An unknown profile name is a fatal error: scudo exits with status 1 without running the command, rather than running it with less sandboxing than was asked for.
$ scudo --profile sandbx -- curl https://example.com
scudo: profile 'sandbx' not found
Both flags belong to scudo itself, so they are only available through the explicit scudo <command> form. When scudo is invoked through a symlink or the FUSE shim, every argument belongs to the wrapped command.
Combining profiles
Profiles come together in four ways — extend, matching the same command, --profile, and sharing a name across config files — and all four combine them the same way:
- Lists accumulate. Every array — in
match,extend,filesystem,network,dbusandsystemd— adds the entries it has to the ones already there, skipping duplicates. No profile can take away what another one added; for paths, see also Filesystem access. - Everything else is taken from the profile applied last, when it says anything:
exec,confirm,log.level,network.default,dbus.session, a scalar systemd property such asMemoryMax. argsdeclarations are merged by name, the last one applied winning.
The order profiles are applied in:
- every profile that matches the command, in the order they were written: config files from the lowest priority to the highest, then by position in the file;
- the profiles named with
--profile, in the order given.
Each one is preceded by the profiles it extends, left to right, recursively. A profile is applied once even when several others extend it, so a base shared by two profiles does not undo what the first one added on top of it.
[base]
systemd.BindPaths = ["/base"]
[cargo]
extend = "base"
systemd.BindPaths = ["$HOME/.cargo"]
[composer]
extend = "base"
systemd.BindPaths = ["$HOME/.composer"]
[dev]
match = "dev"
extend = ["cargo", "composer"]
systemd.BindPaths = ["$PWD"] # → /base, ~/.cargo, ~/.composer, $PWD
Same-named profiles across files
A profile defined again in a higher-priority config file adds to the one below it, by the rules above: its lists extend the inherited ones and its scalars win. A project’s .scudo.toml can therefore give the system-wide cargo profile one more directory without restating it:
# /home/user/projects/myapp/.scudo.toml
[cargo]
filesystem.write = ["$SCUDO_CONFIG_DIR/target"]
To replace only its match patterns and keep adding to the rest, list match in replace. With replace = ["match"] and no match, the profile no longer matches anything and becomes a base:
# the system-wide profile matches "cargo" and "rustc"
[cargo]
replace = ["match"]
match = "cargo"
filesystem.write = ["$SCUDO_CONFIG_DIR/target"]
To replace the lower-priority profile as a whole, set inherit = false:
[cargo]
inherit = false
match = "cargo"
filesystem.read = ["/"]
filesystem.write = ["$PROJECT_DIR"]
Variables
Environment variables
String values (including array elements) support $VAR and ${VAR} expansion using the environment of the calling process. A reference to an undefined variable is an error.
$SCUDO_CONFIG_DIR
Inside a profile, $SCUDO_CONFIG_DIR (also ${SCUDO_CONFIG_DIR}) expands to the absolute path of the directory containing the config file that profile was defined in. This is resolved per file, so a profile in /etc/scudo/config.toml sees /etc/scudo, while one in a project’s .scudo.toml sees that project directory. It is handy for referencing files that live next to the config:
[myapp]
exec = "$SCUDO_CONFIG_DIR/wrapper.sh"
systemd.BindReadOnlyPaths = ["$SCUDO_CONFIG_DIR/allowlist.txt"]
The value is substituted when the config is loaded, before $arg_* and other $VAR expansion. When a profile inherits values via extend or from a same-named profile, each inherited value keeps the config dir of the file it was written in.
Lookup variables
The vars table defines variables that name a directory, found by looking for a file or directory from the current directory up to /: the first directory holding it is the value. A command run anywhere inside a repository can thus be given the whole repository, or the package it is in:
[scudo]
vars.PROJECT_DIR = { lookup = ".git", fallback = "cwd" }
[npm]
match = "npm"
vars.PACKAGE_DIR = { lookup = "package.json" }
filesystem.write = ["$PACKAGE_DIR/node_modules", "$PROJECT_DIR/.git"]
| Key | Type | Description |
|---|---|---|
lookup | string or array of strings | Name(s), relative to each directory, to look for; a file and a directory count alike (a worktree has a .git file). With several, the closest directory holding any of them wins. |
fallback | "skip", "cwd" or "error" | What the variable is when no directory holds any of them (default "skip") |
skip: the value that references the variable is dropped as if it was never written — a list loses that entry, a property or a key is left out."$PACKAGE_DIR/node_modules"outside a package is gone, not/node_modules.cwd: the current directory.error: the command does not run. Only a variable some value actually references is looked up, so an unused one never fails.
Variables in [scudo] are available to every profile; a profile’s own vars add to them and win over a same-named one, and among profiles combined together the one applied last wins, as for args. A lookup variable takes precedence over an environment variable of the same name. Names follow the environment variable rules, and cannot start with arg_ or be SCUDO_CONFIG_DIR.
Command argument variables
Profile values can also reference the arguments of the command being run. These variables all start with $arg_ and are silently empty if the corresponding argument was not passed (unlike environment variables, which are an error when undefined). Empty strings produced by absent argument variables are filtered out from arrays.
Flag variables
The args table names a variable and the flag(s) that fill it:
[myagent]
match = "myagent"
args.add_dir = "--add-dir" # one flag
args.target = ["-t", "--target-directory"] # several spellings of it
filesystem.read = ["$arg_add_dir"]
filesystem.write = ["$arg_target"]
The value can be passed as --add-dir value or --add-dir=value, and every occurrence adds to the same variable: myagent --add-dir a --add-dir b --add-dir=c gives $arg_add_dir three values, which in a list become three entries.
For a flag that takes every argument after it, up to the next one starting with -, use the table form with greedy = true:
[claude]
match = "claude"
args.add_dir = { flags = "--add-dir", greedy = true }
filesystem.read = ["$arg_add_dir"]
# claude --add-dir a b c → $arg_add_dir = ["a", "b", "c"]
A greedy flag cannot tell a value from an argument that happens to follow it: in claude --add-dir a "fix the bug" the prompt is taken as a directory too. Paths that do not exist are skipped, so the harm is limited to that.
Positional variables
Arguments that are neither flags nor the value of a declared flag are positional. Flags not declared in args never take a value, so declare every flag whose value could otherwise be mistaken for a positional:
[install]
match = "install"
args.target = ["-t", "--target-directory"]
filesystem.write = ["$arg_1"] # first positional argument
| Variable | Value |
|---|---|
$arg_1 | first positional argument |
$arg_2 | second positional argument |
$arg_N | Nth positional argument |
$arg_rest | all positionals space-joined (like $*) |
-- terminates option parsing; all subsequent arguments are treated as positional.
Array expansion in string templates
When a named flag variable holds multiple values and appears inside a string template, the template is replicated once per value:
[rsync]
args.include = ["-I", "--include"]
systemd.BindReadOnlyPaths = ["/prefix/$arg_include"]
# -I a -I b → ["/prefix/a", "/prefix/b"]
Using more than one multi-value argument variable inside the same string template is an error; use separate array entries instead.
Default values
${NAME:-DEFAULT} stands for DEFAULT when NAME is unset or empty, as in the shell. It works
with every kind of variable, so a profile can give an optional argument a sensible fallback:
[ls]
match = "ls"
filesystem.read = ["${arg_1:-$PWD}"] # ls → $PWD, ls dir → dir
[build]
match = "build"
args.out = "--out"
filesystem.write = ["${arg_out:-${PROJECT_DIR:-$PWD}/out}"] # the argument, else the project, else the current directory
DEFAULT is itself a template — literal text, $VAR, or another ${…:-…} to chain more
fallbacks — and is expanded only when it is used: a $VAR that is undefined in an unused default
is not an error. What counts as unset:
| Variable | Unset when |
|---|---|
$arg_* | the argument was not passed (or only with empty values) |
$VAR from the environment | it is not defined, or is empty |
| lookup variable | nothing is found; its own fallback is then not consulted |
${arg_1:-} is the same as $arg_1.
Path canonicalization
Entries of the filesystem lists are always made absolute. When a variable ($arg_* or $VAR) is used inside a systemd property that expects filesystem paths (such as BindPaths, ReadWritePaths, WorkingDirectory, etc.), its expanded value is automatically made absolute relative to $PWD. This handles the common case where the user passes a relative path on the command line:
[rsync]
args.include = ["-I", "--include"]
systemd.BindReadOnlyPaths = ["$arg_include"]
# rsync -I ./src → BindReadOnlyPaths=/abs/cwd/src
Only the expanded variable value is canonicalized — literal text in the template is left untouched — and only when the variable opens a path: at the start of the value, after systemd’s prefix characters (-, +, !, !!, ~), or after a : or a space separating paths. So prefixes can be used freely in the literal part, and a variable inside a path component is inserted as it is:
[app]
systemd.ReadWritePaths = ["-$arg_1", "-/tmp/claude-$UID"]
# ./relative → -/abs/cwd/relative ('-' is literal, path is made absolute)
# UID=1000 → -/tmp/claude-1000 (the variable is part of a component)
A default value taking a variable’s place counts as the variable’s value, literal text included: ${arg_1:-out} becomes /abs/cwd/out when no argument is given.
Absolute paths are passed through unchanged. Canonicalization is purely lexical (it does not require the path to exist).
Filesystem access
The filesystem table says what the command may do with a path, in four lists:
| List | Meaning | Becomes |
|---|---|---|
write | read and write | ReadWritePaths=, or BindPaths= inside a hidden or replaced directory |
read | read only | ReadOnlyPaths=, or BindReadOnlyPaths= inside a hidden or replaced directory |
hide | replaced by an empty, throwaway directory | TemporaryFileSystem= |
deny | no access at all | InaccessiblePaths= |
[claude.filesystem]
hide = ["$HOME"]
deny = ["/etc/shadow"]
read = ["/", "$HOME/.gitconfig"]
write = ["$PROJECT_DIR", "$HOME/.claude"]
Writing the lists rather than the systemd properties is what lets profiles written without knowing about each other be combined:
- A path named more than once gets the most permissive access among them:
writebeatsread, which beatshide, which beatsdeny. Which profile came first does not matter, so a profile that only reads a directory cannot take writing away from one that writes it. - What is granted inside a hidden directory is mounted back in. A path under a
TemporaryFileSystem=is not there to be made read-only, soreadandwriteturn into bind mounts there — even when thehidecomes from a different profile than the grant. - So is what is granted inside a directory the unit replaces. The
systemdtable can hide directories too:PrivateTmp=gives the unit its own/tmpand/var/tmp,PrivateDevices=its own/dev,ProtectHome=tmpfsempties/home,/rootand/run/user, and aTemporaryFileSystem=written by hand counts like ahide. WithPrivateTmp = true,write = ["/tmp/claude-1000"]becomesBindPaths=-/tmp/claude-1000. Read-only settings (ProtectSystem=,ProtectHome=read-only) are not replacements: a nestedReadWritePaths=lifts them. - Nothing can be granted inside an inaccessible directory. systemd mounts nothing under an
InaccessiblePaths=, not even a bind mount, so areadorwriteunder adeny, undersystemd.InaccessiblePathsor underProtectHome=yeshas no effect, and scudo warns about it. Usehideinstead: it is what lets paths inside be given back.
Every entry is made absolute against the current directory, and an entry that expands to nothing (an absent $arg_*) is dropped. Paths that do not exist are skipped rather than failing the command (they are passed with systemd’s - prefix): a tool’s config directory often does not exist yet.
Properties written by hand in the systemd table are passed as they are, next to the ones generated here.
From the command line
--fs-hide, --fs-read and --fs-write change the access to a path for one run, without
touching the config. Each takes one path and is repeatable; there is no comma-separated form,
since a comma is a valid character in a path:
scudo --fs-hide ~/.ssh --fs-write ~/.cache/pip --fs-read /srv/data pip install .
They are applied after every profile, those selected with --profile included, and they have
the last word on the paths they name: whatever the profiles listed for that same path is
replaced, not merged. Without that, hiding a directory a profile reads would change nothing,
since the most permissive access wins. Only the path itself is replaced: what the profiles grant
below it stays, and is bind-mounted back in if the flag hid the directory, as with hide. So
--fs-hide $HOME next to a profile that writes $HOME/.claude still leaves .claude writable.
The paths are taken as written — the shell has already expanded them — and made absolute against the current directory. Giving the same path two accesses at once is an error. With no profile matched, the flags alone are enough to run the command in a sandbox.
Seeing what the filesystem restrictions hid
ReadOnlyPaths=, TemporaryFileSystem=, InaccessiblePaths=, ProtectHome= and PrivateTmp=
are applied by systemd, by rearranging the unit’s mount namespace. scudo has no part in the
verdict and no hook that sees it: the command is handed an ENOENT, EACCES or EROFS with
no way to say which path it was about, and a profile that is one BindPaths= short looks
exactly like a program that is broken.
filesystem.log = "denied" on the profile — or --verbose, or --tui — asks scudo-bpfd to
watch the syscalls that name a path and report the ones that come back with an error a sandbox
could have caused. That needs the daemon reachable, with scudo-bpfd.socket enabled and active
in systemctl --user (see Installation); without it the
restrictions still apply, and only the report is lost. With sandbox from the
recommended configuration:
$ scudo --verbose --profile sandbox -- bash -lc 'git status'
scudo: warning: blocked open of /home/me/.bashrc (hidden by the sandbox)
scudo: warning: blocked open of /home/me/.gitconfig (hidden by the sandbox)
scudo: warning: blocked open of /etc/shadow (permission denied)
[code]
match = ["code"]
filesystem.log = "denied" # off (the default) | denied
filesystem.hide = ["$HOME"]
filesystem.write = ["$HOME/.config/Code"]
Not every ENOENT is a refusal, and telling them apart is the point. A shell walking
$PATH, a linker searching for a library, node resolving a module — all of them ask for
dozens of files that were never anywhere, and the kernel answers them exactly as it answers a
file the sandbox hid. So the daemon, which lives outside the unit’s mount namespace, simply
goes and looks:
| What happened | How it reads |
|---|---|
| The path is there outside the sandbox | blocked open of … (hidden by the sandbox), a warn |
EACCES/EPERM, whatever is outside | blocked open of … (permission denied), a warn |
EROFS, whatever is outside | blocked mkdir of … (read-only), a warn |
| It was not there outside either | open of … failed: not there outside the sandbox either, a debug |
| Could not be established | … cannot tell whether it exists outside, an info |
Nothing is thrown away — the level is what keeps a linker’s search out of an ordinary run. Raise
the level to debug to see all of it, or in the terminal UI press ^A m, which
shows the missing paths greyed beside the ones that were really taken away. On journald the
path, operation, error and outside verdict are fields of their own:
$ journalctl -t scudo F_OUTSIDE=present -o json | jq -r .F_PATH
The daemon looks outside after the call has returned, so a file can appear in between. Many
programs check for a path and then create it, such as a lock file or a cache directory. If
the directory is one the sandbox shares with the outside, the command’s own file may already
be there when the daemon looks, and a path that was simply missing is reported as hidden by the
sandbox, at warn. Such a report only names a wrong cause. Nothing is enforced from it, and
the access itself really did fail with ENOENT.
Only calls that name a path are watched: open, openat, openat2, creat, stat, lstat,
newfstatat, statx, access, faccessat, faccessat2, readlink, readlinkat, execve,
execveat, unlink, unlinkat, rmdir, mkdir, mkdirat, rename, renameat,
renameat2, truncate and chdir. One working on a file descriptor has already got past the
sandbox: the open that produced the descriptor is the call that would have been refused.
Unlike the network programs, which are loaded once per sandbox and attached to its cgroup,
these sit on the machine’s sys_enter and sys_exit tracepoints, so one object is loaded for
the whole daemon — on the first sandbox that asks, and unloaded with the last. The cgroups
being watched live in a map the programs consult first, which is also what keeps a path
belonging to any other process on the machine from ever leaving the kernel.
They are raw tracepoints, attached by name through bpf(2). A classic tracepoint per syscall
would be the obvious shape, but attaching one means reading its id out of
<tracefs>/events/…/id, and tracefs is drwx------ root on an ordinary system — no capability
opens it. Only x86-64 is implemented, since the syscall numbers are per-architecture and a
table guessed for another one would report the wrong calls rather than fail.
Reporting is off unless asked for, and asking for it costs the command a registration with the
daemon and a ProtectControlGroups=yes it would not otherwise have (unless the profile sets
that property itself, which is then kept and warned about) — the events are matched on
the cgroup they came from, and a command that can move itself out of that cgroup stops being
reported. A daemon without CAP_PERFMON says so and the command runs anyway: not being able to
watch something is never a reason not to run it.
Session bus
A command that can reach systemd --user can leave its sandbox: it only has to ask for a
process to be started in a sibling unit, or for its own to be moved into one. Chromium and
Electron do the latter on their own, to give themselves an app-*.scope. Out of the
sandbox’s cgroup nothing bound to it applies any more — resource limits, IP filters, the
network policy — and the unit around it ends while the command is still
running, taking its PrivateTmp= with it.
So every sandboxed command, unless its profile says otherwise, gets:
| Property | Why |
|---|---|
InaccessiblePaths=-$XDG_RUNTIME_DIR/systemd | the service manager’s private socket |
InaccessiblePaths=-$XDG_RUNTIME_DIR/bus | the session bus, which leads there too |
ProtectControlGroups=yes | moving by hand, through cgroup.procs |
A property the profile sets itself is left alone: systemd.ProtectControlGroups = false stays
no, with a warning that the sandbox can now be left. private and strict get no warning:
they give the unit a cgroup namespace rooted at its own cgroup, and the kernel refuses every
migration across that root. Lists such as InaccessiblePaths= are
only ever added to, so a profile’s own entries never stop these from being hidden.
Plenty of programs want a session bus, for the keyring, notifications or portals. The dbus
table gives them a filtered one instead:
[code.dbus]
session = "filter" # "deny" (default), "filter" or "allow"
talk = ["org.freedesktop.secrets", "org.freedesktop.Notifications", "org.freedesktop.portal.*"]
see = [] # names that may be seen, but not called
own = ["com.visualstudio.code.*"]
call = [] # "NAME=RULE": calls allowed on a name not talked to
broadcast = [] # "NAME=RULE": broadcasts received from a name not talked to
log = false # log every message, to the proxy unit's journal
The lists are an allow-list: whatever they do not name, the command cannot see on the bus, so
it cannot reach org.freedesktop.systemd1 unless it is named. Naming anything implies
session = "filter". Names may end in .*, which also matches the name itself; that is the
only wildcard there is. The options and their rules are
xdg-dbus-proxy’s, which does the
filtering. session = "allow" takes none of the three properties above away and leaves the
bus as it is, and with it the way out.
A name let through is let through entirely, whatever it can do on the command’s behalf. Some
services start processes outside the sandbox when asked, org.freedesktop.portal.* included
(opening a link launches the browser, for instance), so name what the command needs and
nothing more.
How the filter works
The proxy runs as a unit of its own, scudo-dbus@<id>.service, so that it can reach the real
bus that the sandbox cannot. The sandbox Wants= it and starts After= it is listening; its
socket is bind-mounted over $XDG_RUNTIME_DIR/bus, where every client already looks, and the
directory holding the other sandboxes’ proxies is made inaccessible. Nothing needs the proxy
once the sandbox is gone (StopWhenUnneeded=yes), so systemd stops it. A proxy that fails to
start fails the sandbox as well: the bind mount has nothing to mount.
It needs xdg-dbus-proxy, and the scudo-dbus@.service template installed as a user unit:
sudo install -Dm644 scudo-dbus@.service -t /usr/lib/systemd/user
systemctl --user daemon-reload
Without either, a profile that asked for a filter gets no bus at all, and a warning.
A bus listening on an abstract socket (unix:abstract=) is not a file, and no mount can hide
it: scudo warns when it finds one, and only PrivateNetwork=yes keeps the command from it.
Programs that detach
Some launchers, VS Code’s included, start the real program in the background and exit. The
unit ends with its main process, and systemd kills what is left in it. Now that the program
cannot move itself to a unit of its own, add systemd.ExitType = "cgroup": the unit then
lasts until the last process in it is gone, and scudo waits that long too.
Nested calls
A sandboxed command that runs another one scudo wraps — make running cargo through a
shim, say — starts scudo again, inside the sandbox. When that sandbox cannot reach
systemd --user (the bus denied, or filtered without anything that lets
org.freedesktop.systemd1 through), no new sandbox can be started from it, so the inner
scudo runs the command directly instead, without reading any config: it stays in the
sandbox it was started from, under that sandbox’s restrictions and not the inner profile’s.
This grants nothing the command did not already have: the outer sandbox could have run the
binary itself. What matters is that a scudo running outside any sandbox can never be made
to do the same, so the sign it looks for is not something the environment or a config file
could set, but its cgroup: the units of such sandboxes are named
scudo-sealed-<id>.service, and only a process already free to run systemd-run or write
to cgroup.procs could put itself in one.
A profile that sets systemd.ProtectControlGroups = "private" (or "strict"), as rootless
podman wants, hides that name: the unit gets a cgroup namespace of its own, in which its
cgroup reads as /. So such sandboxes also have /dev/null mounted on
/run/user/<uid>/scudo/sealed, and scudo takes a mount point there as the same sign. A file
at that path proves nothing — only a mount does, and making one takes a mount namespace,
which is as much freedom as writing to cgroup.procs.
- In a sandbox that can reach the manager (
session = "allow", a filter that names it, or a bus on an abstract socket), scudo starts a sandbox of its own as before, and the inner profile applies. - A sealed sandbox whose profile delegates the command’s profile (
delegate, and the targetdelegable) hands the command to a broker outside, which runs it in a sandbox of its own: see Delegating commands. - Restrictions asked for on the command line (
--profile,-P,--allow-dest,--deny-dest,--allow-port,--deny-port,--fs-hide,--fs-read,--fs-write,--deny-env),--allow-env,--delegate,--no-delegateand--tuicannot be had inside, so they fail the command rather than being dropped.--confirmstill asks.
Delegating commands
A command started inside a sealed sandbox runs with that sandbox’s access: see
Nested calls. For an AI coding agent that is the wrong way
round. The npm install it runs has the agent’s access rather than npm’s, so a postinstall
script reaches everything the agent was trusted with: its git credentials, its signing key,
the container socket it was given.
Instead, two settings let such commands run in a sandbox of their own. The recommended configuration is set up this way; cut down to the two settings:
# The tools: built to run hostile code confined.
[supply-chain]
extend = ["git-project"]
delegable = true
[npm]
extend = "supply-chain"
match = ["npm", "npx"]
# The agent: whatever it starts that has a delegable profile runs under that profile.
[agents]
extend = ["git-project"]
match = ["claude", "opencode"]
delegate = true
delegable = true is what the tool’s profile says: it may be run on someone else’s
request. It is inherited through extend, so it can be set once on a base profile, and
delegable = false takes it back for a profile that extends one. A profile that says
delegable = false, itself or through what it extends, also stops every delegated run it
matches, even when another profile matching the same command is delegable: the run fails
rather than get what that profile grants. That is how cargo-install in the
recommended configuration keeps a sandbox that delegates
cargo from installing into ~/.cargo/bin by asking for cargo install. Mark only profiles meant to
contain what they run. Delegation hands over whatever the profile grants, so a profile that
grants much — an editor writing its config, anything with the container socket — would turn
every sandbox that may delegate to it into one that grants as much.
delegate is what the caller’s profile says. With true it delegates every profile that
is delegable and has a match. It can also be given a name or a list of names, to delegate
only those. A name that does not exist, has no match or is not delegable is an error.
Both forms accumulate when profiles are combined.
A command is delegated when a delegated profile matches it (by name, or by the whole command line for a pattern that holds a space), and it then runs exactly as it would have from a terminal, under every profile that matches it. It runs in a sandbox beside the agent’s, not inside it, so the agent’s profile no longer has to carry each tool’s caches and credentials: it can stay as narrow as the agent itself needs.
Commands that no delegated profile matches run inside the agent’s sandbox, as before. That includes a command whose profile is not delegable.
The command line can override both for one run:
--delegateadds every delegable profile, asdelegate = truewould, to whatever the profiles already delegate.--no-delegatedelegates nothing: no broker is started, and every command runs inside the sandbox.
If both are given, the last one wins. Like the other options, they are refused by a scudo started inside a sandbox, since there is nothing they could change there.
How it works
When the agent’s sandbox starts, scudo starts a broker outside it. The broker listens on
a socket that is bind-mounted at /run/user/<uid>/scudo/broker in that one sandbox; every
other broker’s socket is made inaccessible. When the scudo inside is asked for a command, it
hands the broker the command, its arguments and its three standard streams. The broker
checks the request and starts scudo again, outside, which reads the configuration and runs
systemd-run like any other invocation. The exit code comes back the same way, and signals
the scudo inside receives are passed on to the command.
The broker ends with the agent’s sandbox. Delegated commands still running then are stopped, and so is one whose caller is killed.
What a request cannot do
The request is the agent’s, and its command line is the agent’s to write, so nothing in it gets to widen what the delegated sandbox may do:
- Only the command line chooses the profiles, through the profiles’ own
matchpatterns.--profile,--no-match,-P,--deny-env,--allow-env, the network flags and the filesystem flags are refused. Since the arguments are the agent’s to write, a pattern on the command line can be dodged (--globalfor-g,git -C . pushforgit push): it can choose what to delegate, but never keep a command from being delegated. - The working directory comes from the kernel, not from the request. It has to be the
very same directory outside the sandbox, which rules out a private
/tmpor a hidden$HOME. It also has to lie inside something the agent can already write. - Paths worked out from the request are held to the same rule. If a delegated profile
derives a path it grants from
$PWD, an$arg_*, a lookup variable or a relative path, that path has to lie inside what the agent can write, or the command is refused. Without this, a profile that writes${arg_directory}would turnmake -C ~into write access to the whole home directory. Paths written out in full ($HOME/.npm) are the profile’s policy and are not checked. - Nothing is asked. The delegated command’s standard input is the agent’s, so a
.scudo.tomlthat is not trusted yet, or a profile withconfirm = true, is an error rather than a[y/N]the agent could answer. - The agent’s environment is not passed on. Variables in the profiles expand, and the
delegated command gets the variables
deny_envlets through, from the environment the broker was started with: the one the sandbox around the agent was started from. Adelegableprofile should keep at least the credentials out, assandboxin the recommended configuration does, or all of it withdeny_env = "*"and anallow_envfor what the command needs (see Environment). - A delegated command has to be sandboxed. If its profiles add up to nothing, it is refused rather than run outside every sandbox.
A delegated command that is refused fails with the reason on its stderr, so the agent can see why. If the broker cannot be reached at all, scudo fails too. It does not fall back to running the command in place, because whether the command should have been delegated is the broker’s to decide.
delegate needs a sealed sandbox. With dbus.session = "allow" the sandbox could start
anything it liked anyway, and scudo refuses the combination.
What is reported
The broker reports each request through scudo’s logging:
| Level | |
|---|---|
debug | delegated (with the directory, the profiles and the unit), finished (with the exit code), not delegated |
warn | refused, with the reason |
Without --tui the broker writes where the scudo that started it would: by default that is
stderr, which is the terminal the agent is drawing on. For an agent, prefer
log.target = "journald" or a file in its profile. With --tui the reports go to the context
area instead (^A s), which keeps the last few.
What the delegated sandbox itself turns away (its refused connections and hidden files) is reported to the agent’s stderr, beside the command’s own output, for the same reason a refusal is: the agent is the one who has to understand why the command failed.
Limits
The broker protects against what the tools do, not against the agent. An agent that runs
/usr/bin/npm instead of npm skips scudo altogether and runs it in its own sandbox. To close
that, the agent’s profile can deny the real binaries, for instance
filesystem.deny = ["/usr/lib/node_modules/npm"]. scudo resolves the command outside, so
delegation keeps working. Running the code a tool installed, node ./node_modules/.bin/x
say, also happens in the agent’s sandbox unless that command is delegated too.
A SIGTERM passed on to a delegated command ends it with code 0: systemd counts SIGTERM as
a clean exit for a service, exactly as when scudo is run from a terminal.
Network filtering
PrivateNetwork = true is all-or-nothing. The [profile.network] table asks for something
finer: deny everything by default and allow only what the command actually needs. Here
supply-chain is the base of the
recommended configuration.
[cargo]
match = "cargo"
extend = "supply-chain"
[cargo.network]
default = "deny" # "deny" (default) or "allow"
inbound = "deny" # "deny" (default) or "allow"
on_unavailable = "fail" # "fail" (default), "no-network", or "warn"
log = "off" # "off" (default) or "denied"; "denied" is watchable
# even when the policy denies everything
dest = { allow = ["10.0.0.0/8", "*.crates.io"], deny = ["10.1.2.0/24"] }
ports = { allow = [443, "53/udp"], deny = [22] }
The two dimensions
dest and ports are independent sources of permission, not filters that narrow one
another. A packet is judged by:
if the destination is denied or the port is denied -> deny
if the destination is allowed or the port is allowed -> allow
otherwise -> the default
Three things follow, and the second one is the one to read twice:
- An empty
allowlist grants nothing, not everything. A dimension with nothing in it abstains and the other one decides. ports.allowopens those ports at every address, including onesdestnever mentions. It widens; it does not narrow. There is no way to say “port 443, but only on this host” — that is the price of dropping the per-rule port, and it is what lets the whole policy be two map lookups instead of a scan.ports.denyis what narrows, and it revokes everywhere — including at an addressdest.allownames.
Traffic with no port of its own — ICMP, raw sockets — needs no special case: the port dimension abstains on both sides and the destination decides alone.
Destinations
A dest entry is a bare string:
| Form | Example |
|---|---|
| CIDR | "10.0.0.0/8", "2606:4700::/32" |
| Address | "1.1.1.1", "2606:4700::1" |
| Host or glob | "api.github.com", "*.crates.io" |
| Symbolic name | "any", "localhost", "link-local", "multicast" |
IPv6 addresses are written plainly — there is no port to tell them apart from, so they need no
brackets. Hosts accept * and ? globs, and * crosses dots: *.crates.io covers
a.b.crates.io but not the bare crates.io. A symbolic name is systemd’s, and expands to its
IPv4 and IPv6 networks.
Ports
A ports entry is a number, or a string holding a range, either of them optionally narrowed to
one protocol with a /tcp or /udp suffix:
| Form | Example |
|---|---|
| Port | 443 |
| Range | "8000-8100" |
| Port and protocol | "53/udp" |
| Range and protocol | "8000-8100/tcp" |
A port written without a protocol covers TCP and UDP both.
Order does not matter
Every entry of a list carries the same verdict, so which one matches cannot change the outcome,
and deny beating allow is a property of how the two are consulted rather than of where
rules sit. That makes merging two profiles’ lists a plain union: commutative, idempotent, and
impossible to get wrong by reordering. Entries take $VAR and $arg_* expansions like any
other profile value, and each of the four lists adds to the one it inherits.
From the command line
--allow-dest, --deny-dest, --allow-port and --deny-port change the rules for one run,
without touching the config. They take the same values as dest and ports, are repeatable
and accept comma-separated lists:
scudo --allow-dest api.github.com,10.0.0.0/8 --allow-port 53/udp --deny-port 22 cargo build
They are applied after every profile, those selected with --profile included, and they
move an entry rather than only adding it: --allow-dest X takes X out of dest.deny and
puts it in dest.allow, and --deny-dest X does the opposite. Without that, allowing something a
profile denied would change nothing, since a deny always wins. Entries are compared once
normalised — localhost stands for both of its networks, host names ignore case — and a port
is only the same rule with the same range and protocol, so --deny-port 443 leaves an allowed
443/tcp alone. Allowing and denying the same entry at once is an error.
When no profile has a network table, the flags make a policy of their own. If any of them
allows something, it denies everything else, as network = { ... } would; if they only deny,
everything else stays allowed. Either way the other settings keep their defaults — the service
manager stays out of reach and on_unavailable is "fail". When a profile does have a policy,
its default and inbound are left as they are.
Denying everything (network = {}) is realised with PrivateNetwork=yes and needs nothing
else — unless it also asks to be told what it turned away, which attaches an observer beside it;
see Seeing what was blocked. Anything finer needs the scudo-bpfd helper, which attaches eBPF programs to the
command’s cgroup; systemd’s own IPAddressAllow= and SocketBindAllow= are silently ignored
in user units, since systemd --user may not load BPF programs.
Names
Naming a host in dest is what allows that domain to be resolved, and the DNS allowlist is
nothing more than the set of names the destination lists mention. scudo-bpfd answers the
sandbox’s DNS itself: the eBPF programs redirect every query to a resolver bound to loopback for
that one sandbox, which refuses names no rule mentions, forwards the rest to the system’s
nameservers, and records the A and AAAA addresses it hands back. From that moment those
addresses are covered by the list that named the host, as /32 and /128 prefixes like any
other. Both UDP and TCP queries are served, and a truncated answer is retried over TCP.
The redirect happens before any check, so a policy whose port lists never mention 53 still
resolves names — and, for the same reason, ports.deny = [53] does not take that away: the
query never reaches the port rules, because it never reaches port 53. What decides which names
may be looked up is the set of names the destination rules mention, and nothing else. A policy
that names no host gets no resolver at all, and then port 53 is judged like any other port.
Three things follow from this design, and all three are deliberate:
- The rule is matched against the question, not the CNAME chain, so a rule for
api.github.comcovers whatever that name ends up resolving to. - A name in
dest.denystill resolves. That is how its addresses become known, so the rule keeps them out of reach even for a program that had the address hardcoded. Since the deny side is consulted first, the connection fails either way. - A name no rule mentions is refused — unless refusing it would buy nothing. With
default = "allow", with a destination covering the whole address space (any,0.0.0.0/0), or with anyports.allowat all — each of these already lets the command reach an address it never named, so refusing the lookup would only break it.
Addresses learnt this way expire with the record’s TTL, clamped to between 30 seconds and an
hour — a zero TTL would otherwise expire the address before the program managed to connect.
Expiry is a removal: the resolver takes the prefix back out, and the programs carry no expiry
check of their own. That is not an oversight. A prefix that had expired but was still present
would keep covering the shorter prefixes underneath it, with no way to fall back to the
next-longest match — so a stale /32 in the deny trie would read as “no match” and let traffic
through. With removal alone, a sweep that runs late merely leaves an entry doing what it was
already doing for another half second, which errs the safe way in both directions.
A DNS-over-HTTPS or DNS-over-TLS resolver bypasses this entirely, but not the address rules: its server has to be in the allowlist like anything else.
How it is enforced
scudo-bpfd attaches four cgroup/sock_addr programs (connect4, connect6, sendmsg4,
sendmsg6) so a blocked destination fails with a clean EPERM at connect() rather than
timing out, plus a cgroup_skb egress program as a backstop — the only layer that also covers
raw sockets, ICMP and anything that is neither TCP nor UDP. With inbound = "deny" an ingress
program matches the source address, and the source port, against the same two lists, the way
IPAddressAllow= does, so replies from an allowed peer still arrive. recvmsg4 and recvmsg6
put the source of a redirected DNS reply back to the nameserver the query was sent to — the
address is remembered per socket, keyed by its cookie, when the query is redirected — so a stub
resolver on an unconnected socket, which checks that the answer came from the server it asked,
does not throw it away.
Programs are attached as BPF links, which stack: the kernel combines the verdicts of every program on a cgroup with a logical AND, so a second policy on the same cgroup can only ever narrow what is permitted, never widen it.
Profiles with an enforced policy also get ProtectControlGroups=yes, which keeps the command
from migrating out of the cgroup the programs are attached to, and one that needs the resolver
loses systemd-resolved’s varlink sockets, since a lookup made through nss-resolve would
neither obey the allowlist nor teach the policy the address it resolved to. A profile that sets
ProtectControlGroups itself keeps its own value, and is warned that the policy can then be
walked around — unless the value is private or strict, which confine it at least as well.
private still lets the command into sub-cgroups of its own: the network programs follow it
there, the filesystem report does not, so a profile that only reports is warned about it.
None of this holds if the command can leave its cgroup — by asking systemd --user for a
sibling unit, for one, which has a network and no program of ours attached. Every sandbox is
kept in its cgroup whether it has a network policy or not; see Session bus.
Installing the helper
sudo install -m755 target/release/scudo-bpfd /usr/bin/scudo-bpfd
sudo setcap cap_bpf,cap_net_admin,cap_perfmon=ep /usr/bin/scudo-bpfd
systemctl --user enable --now scudo-bpfd.socket
The daemon is socket-activated, so scudo-bpfd.socket must be enabled and active in
systemctl --user (systemctl --user status scudo-bpfd.socket tells): without it scudo
cannot reach scudo-bpfd, and every policy that needs it is handled by on_unavailable.
The daemon holds CAP_BPF and CAP_NET_ADMIN as file capabilities, so anyone on the system
can execute it. CAP_PERFMON is a third it can do without: it is needed only to load the
tracepoint programs behind file access reporting,
and a daemon started without it does everything else and says so when that one thing is asked
for. What keeps that harmless is that it offers exactly one operation — add
restrictions to a cgroup the caller already owns — and no way to remove them: policies are
detached only when the cgroup itself disappears. It refuses any cgroup outside the calling
user’s own systemd subtree, which it establishes from SO_PEERCRED rather than from anything
the caller says.
Registration happens in two steps for a reason. scudo registers the policy and gets a
single-use token; the command is then started as scudo --bpf-shim <token> -- <command>, and
that shim — running inside the unit, in the final cgroup — claims the token and only then
execs the real command. The cgroup does not exist before the unit starts, so registering
earlier is impossible, and attaching afterwards would leave a window in which the command runs
unfiltered.
Since the shim reaches the daemon from inside the unit, scudo bind-mounts the daemon’s socket
there, read-only, whenever a policy needs it. A profile that hides $XDG_RUNTIME_DIR does not
have to give it back, and nothing inside can replace it.
When the policy cannot be enforced — the helper is not installed, lacks its capabilities,
scudo-bpfd.socket is not active in systemctl --user, or the kernel does not support it — on_unavailable decides what happens:
| Value | Behaviour |
|---|---|
fail (default) | The command does not run. |
no-network | The command runs with no network at all. |
warn | A warning is printed and the command runs unfiltered. |
The default is fail-closed: a profile that asks for a network policy never silently runs without one.
Seeing what was blocked
A denied connection reaches the command as a bare EPERM, and a denied packet as nothing at
all, so a policy that is one rule too tight looks like a program that is simply broken.
--verbose, or log = "denied" on the profile, turns every refusal into a line on stderr:
$ scudo --verbose -- curl -sS https://example.com/
scudo: warning: blocked DNS lookup of example.com (no rule names it)
scudo: warning: blocked connect to 1.1.1.1:443/tcp
scudo: curl: blocked example.com (2x), 1.1.1.1:443 (7x) [5 repeats suppressed]
Mixed into the command’s own output, those lines are easy to lose; the terminal UI puts them in a panel of their own instead:
$ scudo --tui -- curl -sS https://example.com/
Each refusal is a warn event and the closing summary an info one, so they follow whatever
logging is configured. On journald they carry the address, port, stage and
transport as fields of their own, which is what makes them filterable there:
$ journalctl -t scudo F_STAGE=connect -o json | jq -r '.F_ADDR + ":" + .F_PORT'
Refused DNS lookups are on the same stream, and usually come first: naming a host in a rule is also what permits resolving it, so a missing rule shows up as a name that will not resolve long before any address is reached.
Reporting is off unless asked for. It costs a ring buffer per sandbox and a small process to
read it, and with it off the emit sites are folded out of the eBPF programs entirely, so a
sandbox nobody is watching pays nothing. Repeats of the same refusal within a few seconds are
collapsed into one line — a denied connection is retried, and one curl would otherwise fill
the screen — and the closing summary counts every attempt, including the ones that were never
printed.
A policy that denies everything is realised by taking the network away rather than by attaching
anything, so on its own it has nothing to report from. Asking to be told is what changes that:
an observer is attached beside the empty namespace, allowing localhost and denying the rest
— which is exactly what that namespace already permits, so nothing is enforced that was not
enforced before and loopback keeps working without reporting itself. The cgroup hooks are not
scoped to a network namespace, which is what lets them see refusals inside one.
The observer is reporting and nothing else, so losing it costs only the reporting: if
scudo-bpfd cannot be reached, on_unavailable does not apply — the command runs, with the
network still taken away, and a warning says the refusals will not be named. scudo-bpfd is
needed to watch a deny-all policy; it is never needed to enforce one.
Logging
Everything scudo says about itself — warnings, the resolution trace, what a sandbox refused —
goes through one leveled logger. Both the level and the destination come from a log table on
the profile:
[curl]
match = "curl"
log.level = "debug" # off, error, warn, info, debug, trace
log.target = "file" # stderr, file, syslog, journald
log.path = "$SCUDO_CONFIG_DIR/scudo.log"
log.syslog_facility = "user" # only read by target = "syslog"
log.target takes one target or a list of them, and a message goes to all of them. Naming a
log.path and no target selects the file, since there is nothing else the path could be for.
The default is warn on stderr — exactly the set of messages scudo printed before it had
levels at all.
Defaults for every command go in the reserved [scudo] section:
[scudo]
log.level = "info"
log.target = "journald"
It sits under the profiles rather than over them, which is what makes it a default: a
profile can be quieter than it as well as louder — unlike verbose, which only ever adds. A
profile matching everything says the same thing and is still perfectly good:
[default]
match = "*"
log.level = "info"
Between two profiles the ordinary order applies (the one written later wins, and --profile
above all; see Combining profiles), so write the catch-all first. verbose = true is still accepted and means log.level = "debug"; a level
written out next to it wins, being the more specific of the two.
The command line overrides the profiles:
$ scudo --log-level debug -- curl https://example.com/
$ scudo --log-file /tmp/scudo.log -- npm install
$ scudo --log-target syslog,stderr -- rsync -a src/ dst/
-v/--verbose raises the floor to debug rather than setting it, so it cannot quieten a
--log-level trace written beside it. Each flag also reads an environment variable
(SCUDO_LOG_LEVEL, SCUDO_LOG_TARGET, SCUDO_LOG_FILE, SCUDO_LOG_SYSLOG_FACILITY).
Anything scudo emits while it is still reading the config obeys the command line and the
environment, not the config — the setting is not known yet. Use --log-level or
SCUDO_LOG_LEVEL to see that part of a run.
Stderr keeps the shape it always had (scudo: warning: …), with the event’s fields appended
only at debug and below; a file adds a timestamp and the level; syslog and journald receive
the fields as fields. The confirmation prompt and the trust diff are not logs: they are
interactive output and always go to stderr, whatever the destination.
Terminal UI
With --tui, scudo stays running instead of executing into the command. The screen splits in
two. The left side is a full terminal running the command, and the right side is a panel that
lists what the sandbox blocked. The list comes from scudo-bpfd, so it stays empty unless
scudo-bpfd.socket is enabled and active in systemctl --user. See
Installation.
$ scudo --tui -- curl https://example.com/
$ scudo --tui -- bash
│▌ BLOCKED 3 │
$ curl https://ads.example/ │ ● ads.example 2x │
curl: (6) Could not resolve host │ ● 1.1.1.1:443 1x │
$ │ □ /usr/lib/libz.so 9x │
│ │
│ RECENT │
│ 1s ● blocked DNS for a…│
│ 4s ■ blocked open of /…│
^A ? for help terminal │ blocked 3
scudo paints the panel, its headings and the status bar with its own dark theme. It leaves the command’s terminal unpainted, so that area shows the host terminal’s background.
A shell works as it does anywhere else. Ctrl-C and Ctrl-Z go to the command as bytes.
scudo does not treat them as signals for itself.
Piping works the same as without the UI:
$ cat Cargo.toml | scudo --tui -- less
If scudo’s standard input is not a terminal, scudo passes it to the command. The command reads
the pipe, not the panel’s terminal, so a command line does the same thing with and without
--tui. The panel shows only what the command writes.
This has two consequences, and both also apply without the UI. First, systemd-run switches
from --pty to --pipe unless all three standard descriptors are terminals. A command that
reads from a pipe therefore has no controlling terminal. It gets no resize notifications, and
it receives keys from the UI only if it opens a terminal itself, as less does. Second, a
command with empty input reads nothing: scudo --tui -- bash < /dev/null exits at once.
--tui turns on denial reporting for the run, as --verbose does, because the panel has
nothing to show without it. When there is nothing to report, the panel gives the reason. The
profile may have no network policy, the policy may remove the network entirely, or scudo may
have failed to watch the sandbox.
--tui works only when you call scudo by its own name. Through a symlink or the FUSE shim,
every argument after the command name belongs to the command, so scudo passes --tui on to it.
Keys
Every key goes to the command except the prefix, Ctrl-A by default. The key after the prefix
is a scudo command:
| Key | Effect |
|---|---|
^A q | quit, stopping the command if it is still running |
^A ^A | send Ctrl-A to the command |
^A Tab | move focus between the terminal and the panel’s two lists |
^A PgUp | read the scrollback with the arrows, PgUp/PgDn and Home/End; Esc returns |
^A r | resize the panel with the arrows by one column and h/l by five; = restores automatic width, Esc returns |
^A w | show or hide the panel |
^A s | show or hide the profiles that matched and the command line scudo built |
^A m | show or hide paths that were missing outside the sandbox too; see file access reporting |
^A / | show only lines that match a pattern |
^A \ | hide lines that match a pattern |
^A n | toggle between network lines only and all lines |
^A f | toggle between filesystem lines only and all lines |
^A ? | show this table |
When one of the panel’s lists has focus, the arrows, PgUp/PgDn and Home/End scroll that
list. Every other key still goes to the command. The tally scrolls from the top down, the recent
list from its newest line back, and each stops at its last line.
To change the prefix, set tui.prefix in [scudo] or pass --tui-prefix. Write it as C-a,
M-x or F12. The prefix must include a modifier, because a bare character would become
impossible to type in the command.
Filtering the panel
A build or a package manager can produce thousands of paths. Two filters reduce the panel to the lines you care about, and you can combine them.
^A / opens a field for a regular expression and shows only the lines that match it. scudo
uses the same regex engine as the profile matchers. ^A \ opens the same field but hides the
matching lines instead. In the field:
- the filter updates as you type;
Tabswitches between showing and hiding matches;Ctrl-Uclears the pattern;Enterkeeps the pattern and returns the keyboard to the command;Esccloses the field and restores the filter that was active before.
Matching ignores case unless the pattern starts with (?-i). While the pattern does not
compile, scudo ignores it and shows the error in red in the status bar, so the lists do not
empty out on every keystroke.
^A n shows only network lines and ^A f only filesystem lines. Pressing the same key again
shows everything. The heading of each list names the active filters, for example
[net] /usr, so you can tell a filtered list from an empty one.
Filters change only what the panel displays. The blocked N count in the status bar and the
summary printed when the UI closes always cover the whole run.
After the command exits
The UI stays open when the command exits. You can still scroll the scrollback and the lists,
show, hide and resize the panel, and open the help with ^A ?. Keys that went to the command
now work without the prefix:
- the arrows,
PgUp/PgDnandHome/Endscroll the scrollback; Tabmoves focus between the terminal and the lists;q,EscorCtrl-Dclose the UI.
scudo exits with the command’s exit code. After it restores the terminal, it prints the summary of what the sandbox blocked to stderr, so the summary stays in your scrollback.
Panel width
By default the panel takes a quarter of the screen width, with a minimum of 34 columns and a maximum of 60. The panel’s contents never need more than 60 columns.
| screen | panel |
|---|---|
| 100 | 34 |
| 160 | 40 |
| 200 | 50 |
| 240 and wider | 60 |
^A r sets the width by hand. The keys move the border between the terminal and the panel.
← and h move it left and widen the panel, → and l move it right and narrow it. The
arrows move it by one column, the letters by five. scudo sends the new size to the command as
the border moves, so a full-screen program redraws while you resize.
Settings
[scudo]
tui.prefix = "C-b" # default "C-a"
tui.scrollback = 20000 # default 10000 lines