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

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