Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.py and 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 --user to start something outside the sandbox (Session bus).
  • Resources and everything else a systemd unit can say: MemoryMax=, CPUQuota=, IOWeight=, capabilities, private /tmp and 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/npm by 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, pip and cargo build away 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 --user to 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 $HOME that 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_DIR takes 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.

scudoFlatpakbubblewrapFirejail
What it confinesthe commands already on your systemapplications packaged as Flatpakswhatever command line you write for itthe commands already on your system
Configured byper-command profiles, matched by name, and per-directory .scudo.toml filesthe app’s manifest, plus overridesflags on each invocationper-program profiles
Privilegesnone; an optional helper with file capabilities for network filteringnone (it uses bubblewrap)nonea setuid-root binary
Networkper host, address and port, DNS answered only for named hostsall or nothingall or nothingall or nothing, or a firewall inside a network namespace of its own
Resource limitsanything a systemd unit can setnone of its ownnonerlimits
Reportingevery 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.toml files 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’s cargo may 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 --user running;
  • 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:

  • fuse3 for the FUSE shim;
  • xdg-dbus-proxy for the session bus filter;
  • a kernel with cgroup eBPF support, and scudo-bpfd with its file capabilities and its scudo-bpfd.socket user 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:

FeatureDrops
bpfscudo-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 with confirm = true or verbose = 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. Reserve confirm and verbose for 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:

PathScope
/etc/scudo/config.tomlsystem-wide
$XDG_CONFIG_HOME/scudo/config.tomlper-user
/.scudo.toml … $PWD/.scudo.tomlper-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

ProfileMatchesWhat 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, denotheir commandssupply-chain and the tool’s cache; ~/.npmrc stays hidden
pip, uv, poetrytheir commandssupply-chain, the tool’s config read-only
cargocargosupply-chain, ~/.cargo and ~/.rustup read-only except the registry, credentials denied
composer, go, rubytheir commandssupply-chain and the tool’s cache
cargo-installcargo install, cargo binstall, cargo install-update, cargo-binstall, cargo-install-updatecargo, with all of ~/.cargo writable except the credentials; not delegable
npm-authnpm publish, npm login~/.npmrc writable on top of npm; not delegable
agentsclaude, opencodegit-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

  • $HOME is hidden, not made read-only. A read-only $HOME still 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.
  • .git is read-only for build and install tools. Hooks, core.hooksPath and core.fsmonitor are run by git outside any sandbox, so a writable .git is a way out. The agent, which commits, gets it back.
  • Directories on PATH stay read-only: ~/.local/bin, ~/.cargo/bin, Composer’s vendor/bin. A build script that could write there would replace npm or git with something that runs unsandboxed the next time. Installing into them is what the cargo-install profile is for: it matches cargo install on its arguments, and says delegable = false so that a sandbox delegating cargo cannot install through it.
  • $XDG_RUNTIME_DIR is 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.

FieldTypeDescription
matchstring or array of stringsGlob(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.
extendstring or array of stringsProfile(s) applied before this one, left to right; see Combining profiles
delegatetrue, string or array of stringsProfiles 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
delegableboolWhether a sandbox may have this profile’s commands run under it on its request; inherited through extend (default: false). See Delegating commands
replacearray of stringsFields 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
inheritboolWhether 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
execstringAbsolute path of the binary to execute, bypassing PATH resolution
verboseboolShorthand for log.level = "debug", which among other things prints the full systemd-run command before executing (default: false)
confirmboolPrompt for confirmation before executing (default: false); silently skipped in non-interactive shells
deny_envstring or array of stringsEnvironment variables, by name or glob, kept out of the sandbox; see Environment
allow_envstring or array of stringsEnvironment variables, by name or glob, passed on even though deny_env matches them; see Environment
argstableMaps $arg_* variable names to the flag(s) that fill them; see Command argument variables
varstableVariables naming a directory found by looking up the tree; see Lookup variables
filesystemtableWhich paths the command may write, read, not see or not touch; see Filesystem access
networktableNetwork policy; see Network filtering
dbustableWhat the command gets of the session bus; see Session bus
logtableLevel and destination for scudo’s own output; see Logging
systemdtableUnit 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 $NAME expands 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 (regex syntax), 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.

FormMeaning
absentnever 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.

InvocationProfiles applied
scudo --profile no-net -- curl https://example.comeverything matching curl, then no-net
scudo --profile a,b -- curl https://example.commatches for curl, then a, then b
scudo --profile agents,npm,cargo -- zsha shell with everything those three profiles allow, and their restrictions
scudo --no-match --profile no-net -- curl https://example.comonly no-net
scudo --no-match -- curl https://example.comnone — the command is executed directly, without systemd-run
scudo --no-match -p MemoryMax=1G -- curl https://example.comnone, 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, dbus and systemd — 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 as MemoryMax.
  • args declarations are merged by name, the last one applied winning.

The order profiles are applied in:

  1. 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;
  2. 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"]
KeyTypeDescription
lookupstring or array of stringsName(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
VariableValue
$arg_1first positional argument
$arg_2second positional argument
$arg_NNth positional argument
$arg_restall 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:

VariableUnset when
$arg_*the argument was not passed (or only with empty values)
$VAR from the environmentit is not defined, or is empty
lookup variablenothing 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:

ListMeaningBecomes
writeread and writeReadWritePaths=, or BindPaths= inside a hidden or replaced directory
readread onlyReadOnlyPaths=, or BindReadOnlyPaths= inside a hidden or replaced directory
hidereplaced by an empty, throwaway directoryTemporaryFileSystem=
denyno access at allInaccessiblePaths=
[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: write beats read, which beats hide, which beats deny. 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, so read and write turn into bind mounts there — even when the hide comes from a different profile than the grant.
  • So is what is granted inside a directory the unit replaces. The systemd table can hide directories too: PrivateTmp= gives the unit its own /tmp and /var/tmp, PrivateDevices= its own /dev, ProtectHome=tmpfs empties /home, /root and /run/user, and a TemporaryFileSystem= written by hand counts like a hide. With PrivateTmp = true, write = ["/tmp/claude-1000"] becomes BindPaths=-/tmp/claude-1000. Read-only settings (ProtectSystem=, ProtectHome=read-only) are not replacements: a nested ReadWritePaths= lifts them.
  • Nothing can be granted inside an inaccessible directory. systemd mounts nothing under an InaccessiblePaths=, not even a bind mount, so a read or write under a deny, under systemd.InaccessiblePaths or under ProtectHome=yes has no effect, and scudo warns about it. Use hide instead: 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 happenedHow it reads
The path is there outside the sandboxblocked open of … (hidden by the sandbox), a warn
EACCES/EPERM, whatever is outsideblocked open of … (permission denied), a warn
EROFS, whatever is outsideblocked mkdir of … (read-only), a warn
It was not there outside eitheropen 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.

How refused file accesses are reported: two raw tracepoints see every syscall on the machine, keep only path syscalls from watched cgroups, and hand the failures to scudo-bpfd, which checks whether the path exists outside and reports it to the sandbox's watchers kernel one object for the whole daemon, every syscall on the machine raw tracepoints sys_enter, sys_exit does the syscall name a path? no: done, without touching a map is its cgroup in WATCHED? no: nothing leaves the kernel enter: keep the path in PENDING exit: an error goes to the ring buffer success only drops the PENDING entry scudo-bpfd outside every sandbox's mount namespace routes each event by its cgroup id fsmon is the path there outside the sandbox? hidden: warn · never there: debug the sandbox's notification bus shared with the network refusals watcher, stderr --tui panel

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:

PropertyWhy
InaccessiblePaths=-$XDG_RUNTIME_DIR/systemdthe service manager’s private socket
InaccessiblePaths=-$XDG_RUNTIME_DIR/busthe session bus, which leads there too
ProtectControlGroups=yesmoving 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 target delegable) 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-delegate and --tui cannot be had inside, so they fail the command rather than being dropped. --confirm still 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:

  • --delegate adds every delegable profile, as delegate = true would, to whatever the profiles already delegate.
  • --no-delegate delegates 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.

A delegated command: the scudo inside the agent's sandbox hands the command to the broker outside, which starts it in a sandbox of its own agent sandbox scudo-sealed-…a.service claude npm install npm is a symlink to scudo …/scudo/broker socket, mounted here only a command no delegable profile matches runs here, as it always did outside any sandbox started with the agent, gone with it checks the caller's cgroup, reads its directory from /proc, matches the name to a delegable profile broker scudo --broker 1command, args, fds 0 1 2 4exit code scudo --delegated config, trust, systemd-run 2started in its directory npm sandbox scudo-sealed-…b npm's own profile: its cache, its config, none of the agent's sockets writes straight to the agent's stdout and stderr --bpf-shim claims the policies npm install 3unit

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 match patterns. --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 (--global for -g, git -C . push for git 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 /tmp or 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 turn make -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.toml that is not trusted yet, or a profile with confirm = 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_env lets through, from the environment the broker was started with: the one the sandbox around the agent was started from. A delegable profile should keep at least the credentials out, as sandbox in the recommended configuration does, or all of it with deny_env = "*" and an allow_env for 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
debugdelegated (with the directory, the profiles and the unit), finished (with the exit code), not delegated
warnrefused, 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 allow list grants nothing, not everything. A dimension with nothing in it abstains and the other one decides.
  • ports.allow opens those ports at every address, including ones dest never 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.deny is what narrows, and it revokes everywhere — including at an address dest.allow names.

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:

FormExample
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:

FormExample
Port443
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.

How naming a host allows it: the sandbox's DNS is redirected to a resolver of its own, which answers only for names a rule mentions and adds the addresses it returns to the destination tries that every connection is checked against the sandbox the command cgroup hooks connect · sendmsg · skb resolver one per sandbox, in bpfd nameservers DNS names a rule mentions any other name: refused destination tries deny and allow × IPv4 and IPv6, longest prefix wins ports in bitmaps beside them; no loop, no rule limit adds A, AAAA as /32, /128 every connection: deny first, then allow refused: EPERM a learnt address is removed when its TTL runs out (30 s to 1 h); what the config names outright is never removed

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.com covers whatever that name ends up resolving to.
  • A name in dest.deny still 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 any ports.allow at 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.

How a sandbox with a network policy starts: scudo registers the policy and gets a token, the shim inside the unit claims it, and only then the command runs scudo becomes systemd-run scudo-bpfd CAP_BPF, CAP_NET_ADMIN the unit its own cgroup watcher scudo --bpf-watch Register(policies) token spawns, if reporting Watch(token) exec systemd-run starts it scudo --bpf-shim Attach(token) attaches the programs to the shim's cgroup Attached exec the command refusals prints to stderr

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:

ValueBehaviour
fail (default)The command does not run.
no-networkThe command runs with no network at all.
warnA 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:

KeyEffect
^A qquit, stopping the command if it is still running
^A ^Asend Ctrl-A to the command
^A Tabmove focus between the terminal and the panel’s two lists
^A PgUpread the scrollback with the arrows, PgUp/PgDn and Home/End; Esc returns
^A rresize the panel with the arrows by one column and h/l by five; = restores automatic width, Esc returns
^A wshow or hide the panel
^A sshow or hide the profiles that matched and the command line scudo built
^A mshow 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 ntoggle between network lines only and all lines
^A ftoggle 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;
  • Tab switches between showing and hiding matches;
  • Ctrl-U clears the pattern;
  • Enter keeps the pattern and returns the keyboard to the command;
  • Esc closes 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/PgDn and Home/End scroll the scrollback;
  • Tab moves focus between the terminal and the lists;
  • q, Esc or Ctrl-D close 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.

screenpanel
10034
16040
20050
240 and wider60

^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