Filesystem access
The filesystem table says what the command may do with a path, in four lists:
| List | Meaning | Becomes |
|---|---|---|
write | read and write | ReadWritePaths=, or BindPaths= inside a hidden or replaced directory |
read | read only | ReadOnlyPaths=, or BindReadOnlyPaths= inside a hidden or replaced directory |
hide | replaced by an empty, throwaway directory | TemporaryFileSystem= |
deny | no access at all | InaccessiblePaths= |
[claude.filesystem]
hide = ["$HOME"]
deny = ["/etc/shadow"]
read = ["/", "$HOME/.gitconfig"]
write = ["$PROJECT_DIR", "$HOME/.claude"]
Writing the lists rather than the systemd properties is what lets profiles written without knowing about each other be combined:
- A path named more than once gets the most permissive access among them:
writebeatsread, which beatshide, which beatsdeny. Which profile came first does not matter, so a profile that only reads a directory cannot take writing away from one that writes it. - What is granted inside a hidden directory is mounted back in. A path under a
TemporaryFileSystem=is not there to be made read-only, soreadandwriteturn into bind mounts there — even when thehidecomes from a different profile than the grant. - So is what is granted inside a directory the unit replaces. The
systemdtable can hide directories too:PrivateTmp=gives the unit its own/tmpand/var/tmp,PrivateDevices=its own/dev,ProtectHome=tmpfsempties/home,/rootand/run/user, and aTemporaryFileSystem=written by hand counts like ahide. WithPrivateTmp = true,write = ["/tmp/claude-1000"]becomesBindPaths=-/tmp/claude-1000. Read-only settings (ProtectSystem=,ProtectHome=read-only) are not replacements: a nestedReadWritePaths=lifts them. - Nothing can be granted inside an inaccessible directory. systemd mounts nothing under an
InaccessiblePaths=, not even a bind mount, so areadorwriteunder adeny, undersystemd.InaccessiblePathsor underProtectHome=yeshas no effect, and scudo warns about it. Usehideinstead: it is what lets paths inside be given back.
Every entry is made absolute against the current directory, and an entry that expands to nothing (an absent $arg_*) is dropped. Paths that do not exist are skipped rather than failing the command (they are passed with systemd’s - prefix): a tool’s config directory often does not exist yet.
Properties written by hand in the systemd table are passed as they are, next to the ones generated here.
From the command line
--fs-hide, --fs-read and --fs-write change the access to a path for one run, without
touching the config. Each takes one path and is repeatable; there is no comma-separated form,
since a comma is a valid character in a path:
scudo --fs-hide ~/.ssh --fs-write ~/.cache/pip --fs-read /srv/data pip install .
They are applied after every profile, those selected with --profile included, and they have
the last word on the paths they name: whatever the profiles listed for that same path is
replaced, not merged. Without that, hiding a directory a profile reads would change nothing,
since the most permissive access wins. Only the path itself is replaced: what the profiles grant
below it stays, and is bind-mounted back in if the flag hid the directory, as with hide. So
--fs-hide $HOME next to a profile that writes $HOME/.claude still leaves .claude writable.
The paths are taken as written — the shell has already expanded them — and made absolute against the current directory. Giving the same path two accesses at once is an error. With no profile matched, the flags alone are enough to run the command in a sandbox.
Seeing what the filesystem restrictions hid
ReadOnlyPaths=, TemporaryFileSystem=, InaccessiblePaths=, ProtectHome= and PrivateTmp=
are applied by systemd, by rearranging the unit’s mount namespace. scudo has no part in the
verdict and no hook that sees it: the command is handed an ENOENT, EACCES or EROFS with
no way to say which path it was about, and a profile that is one BindPaths= short looks
exactly like a program that is broken.
filesystem.log = "denied" on the profile — or --verbose, or --tui — asks scudo-bpfd to
watch the syscalls that name a path and report the ones that come back with an error a sandbox
could have caused. That needs the daemon reachable, with scudo-bpfd.socket enabled and active
in systemctl --user (see Installation); without it the
restrictions still apply, and only the report is lost. With sandbox from the
recommended configuration:
$ scudo --verbose --profile sandbox -- bash -lc 'git status'
scudo: warning: blocked open of /home/me/.bashrc (hidden by the sandbox)
scudo: warning: blocked open of /home/me/.gitconfig (hidden by the sandbox)
scudo: warning: blocked open of /etc/shadow (permission denied)
[code]
match = ["code"]
filesystem.log = "denied" # off (the default) | denied
filesystem.hide = ["$HOME"]
filesystem.write = ["$HOME/.config/Code"]
Not every ENOENT is a refusal, and telling them apart is the point. A shell walking
$PATH, a linker searching for a library, node resolving a module — all of them ask for
dozens of files that were never anywhere, and the kernel answers them exactly as it answers a
file the sandbox hid. So the daemon, which lives outside the unit’s mount namespace, simply
goes and looks:
| What happened | How it reads |
|---|---|
| The path is there outside the sandbox | blocked open of … (hidden by the sandbox), a warn |
EACCES/EPERM, whatever is outside | blocked open of … (permission denied), a warn |
EROFS, whatever is outside | blocked mkdir of … (read-only), a warn |
| It was not there outside either | open of … failed: not there outside the sandbox either, a debug |
| Could not be established | … cannot tell whether it exists outside, an info |
Nothing is thrown away — the level is what keeps a linker’s search out of an ordinary run. Raise
the level to debug to see all of it, or in the terminal UI press ^A m, which
shows the missing paths greyed beside the ones that were really taken away. On journald the
path, operation, error and outside verdict are fields of their own:
$ journalctl -t scudo F_OUTSIDE=present -o json | jq -r .F_PATH
The daemon looks outside after the call has returned, so a file can appear in between. Many
programs check for a path and then create it, such as a lock file or a cache directory. If
the directory is one the sandbox shares with the outside, the command’s own file may already
be there when the daemon looks, and a path that was simply missing is reported as hidden by the
sandbox, at warn. Such a report only names a wrong cause. Nothing is enforced from it, and
the access itself really did fail with ENOENT.
Only calls that name a path are watched: open, openat, openat2, creat, stat, lstat,
newfstatat, statx, access, faccessat, faccessat2, readlink, readlinkat, execve,
execveat, unlink, unlinkat, rmdir, mkdir, mkdirat, rename, renameat,
renameat2, truncate and chdir. One working on a file descriptor has already got past the
sandbox: the open that produced the descriptor is the call that would have been refused.
Unlike the network programs, which are loaded once per sandbox and attached to its cgroup,
these sit on the machine’s sys_enter and sys_exit tracepoints, so one object is loaded for
the whole daemon — on the first sandbox that asks, and unloaded with the last. The cgroups
being watched live in a map the programs consult first, which is also what keeps a path
belonging to any other process on the machine from ever leaving the kernel.
They are raw tracepoints, attached by name through bpf(2). A classic tracepoint per syscall
would be the obvious shape, but attaching one means reading its id out of
<tracefs>/events/…/id, and tracefs is drwx------ root on an ordinary system — no capability
opens it. Only x86-64 is implemented, since the syscall numbers are per-architecture and a
table guessed for another one would report the wrong calls rather than fail.
Reporting is off unless asked for, and asking for it costs the command a registration with the
daemon and a ProtectControlGroups=yes it would not otherwise have (unless the profile sets
that property itself, which is then kept and warned about) — the events are matched on
the cgroup they came from, and a command that can move itself out of that cgroup stops being
reported. A daemon without CAP_PERFMON says so and the command runs anyway: not being able to
watch something is never a reason not to run it.