A view is a private mount namespace where the prefix is overlaid on the
host's system directories (tools/prefix-view.sh, installed as
$PREFIX/.sl/lib/prefix-view). In a view:
- the host's files show through, and every write lands in the prefix
(
$PREFIX/usr,$PREFIX/etc, ...), never on the host; $PREFIX/usrand/usr(and so on) are the same tree, so both spellings of a path agree;- the command runs with the user's own uid.
There are two kinds, and most programs use none:
| overlays | built | used by | |
|---|---|---|---|
| private view | /usr, /etc, /var, /opt; /var/lib/dpkg is the prefix's own database |
fresh for each call (~0.2 s), with a sandbox on top | dpkg (the install view: /run empty), services from the prefix (the service view: the services' own /run, the unit's sandbox) |
| run view | /usr, /etc, /opt; /var stays the host's |
once, kept running in the background; joining it takes ~0.02 s | installed programs that look for their files at /usr/..., /etc/..., /opt/... |
| none | — | — | every other installed program: it runs directly from $PREFIX/usr/bin |
A view only overlays the prefix. Whatever a sandbox can do (an empty
/run, read-only /usr, a hidden /home) is done by
tools/prefix-sandbox.sh on top of a private view, last:
prefix-view --private -p DIRECTIVE=VALUE ... -- CMD, with systemd's
directives (The sandbox).
In the install view dpkg runs exactly as on Debian: root /, admin dir
/var/lib/dpkg, config /etc/dpkg, log /var/log/dpkg.log. It is built
with those paths (scripts/bootstrap/build-dpkg.sh), so it needs no
relocation patch, no --instdir, no --force-script-chrootless. Maintainer
scripts, triggers and update-alternatives see a normal system too: a script
that writes /etc/foo or runs /usr/bin/foo works, and alternatives are the
standard absolute links (/usr/bin/java → /etc/alternatives/java → ...).
The install view is a private view with a sandbox on top: an empty /run
(no system bus, no systemd, so maintainer scripts cannot reach the host's
services), your home hidden but for the prefix, a private /tmp
(security.md has why, and what
it stops). The run view keeps the host's /run and your home: the programs
in it are yours, run as you, just as outside the view.
Debian builds every package for the root prefix /, so a path such as
/etc/foo/foo.conf or /usr/share/foo/templates is a constant string in
the binary. Unpacked into the prefix, the files sit at ~/.sudo-less/etc/...,
but the program still opens /etc/.... There are two ways out: rebuild
every package for the prefix (Termux, NixOS), or make the prefix look like
/ at run time (Flatpak, proot). sudo-less rebuilds only apt and dpkg,
so it does the second, and only for the programs that need it.
Environment variables are not enough. A variable works only if every path
the program reads honors it: LD_LIBRARY_PATH finds nodejs's
libnode.so, then node loads /usr/share/nodejs/undici/... by absolute
path, which no variable reaches. And a variable that reaches the library
search path affects every program started from that shell. So sudo-less
sets only PATH and XDG_DATA_DIRS, for every package, and uses the view
for the rest.
The view is overlaid on the host's directories, not put in their place: the prefix holds only the packages the user installed, and the base libraries still come from the system.
It is not a second root filesystem either (a whole Debian made with
mmdebstrap, with maintainer scripts run as namespace root). The packages
that would need one have root-only scripts (services, system users,
setuid), which are the admin's anyway (problems.md). For
a whole distribution without root, use rootless podman or distrobox.
$PREFIX/.sl/dpkg/bin/dpkg (and dpkg-query, dpkg-divert, dpkg-statoverride,
dpkg-trigger, update-alternatives) is a wrapper
(apt-dpkg/dpkg-wrapper.sh) that runs the real program from
$PREFIX/.sl/dpkg/real inside the install view. Inside the install
view (SUDO_LESS_VIEW=private) it runs it directly. Queries that only read
the database (dpkg -l, -L, -S, -s, --print-foreign-architectures,
dpkg-query) skip the view and get --admindir=$PREFIX/var/lib/dpkg, which
works anywhere, the run view included: apt asks dpkg for the foreign
architectures on every run. Anything else from inside the run view
(SUDO_LESS_VIEW=run) is refused: an install view cannot be built there.
apt stays outside the view: its Dir::* settings already point into the
prefix. It calls the wrapper and passes --admindir $PREFIX/var/lib/dpkg,
which inside the view is the same directory as /var/lib/dpkg.
After dpkg runs, prefix-integrate (tools/prefix-integrate.sh) runs
prefix-wrap (tools/prefix-wrap.sh); apt runs it once after all its dpkg
runs (hook apt-dpkg/config/apt.conf.d/02integrate.in), the dpkg wrapper
after a dpkg run apt did not make. For each package installed or changed
since the last run, and each alternative, it looks at the programs
the package puts on PATH and decides whether each one needs the view. A
program needs it when (first match):
| reason | example |
|---|---|
| it is a symlink that leaves the prefix | /usr/bin/java → /etc/alternatives/java, which the host may not have |
| its script interpreter is not on the host | #!/usr/bin/ruby when only the prefix has Ruby |
| its interpreter searches only compiled-in module paths: Python, Perl, Ruby, Node, PHP, Lua, Tcl, R, Guile | ranger (#!/usr/bin/python3), cowsay (Perl) |
ldd cannot find one of its libraries: it is in $PREFIX/usr/lib |
w3m (libgc.so.1), fortune (librecode.so.3) |
it names a file, or a directory of its own, under /usr, /etc or /opt that the prefix has |
figlet (/usr/share/figlet), gawk (/usr/lib/x86_64-linux-gnu/gawk) |
Directories many packages share (/usr/bin, /usr/share/locale, the
library directory, ...) do not count. prefix-wrap --check PROG... prints
the decision and the evidence without changing anything.
Such a program gets a script of the same name in $PREFIX/.sl/bin
(.sl/sbin for an sbin program), which comes before $PREFIX/usr/bin on PATH:
#!/bin/sh
# sudo-less view wrapper (prefix-wrap); regenerated, do not edit
exec $PREFIX/.sl/lib/prefix-view --run /usr/bin/ranger "$@"The scripts each package got are recorded in
$PREFIX/.sl/state/db/wrappers/<package> (alternatives=<name> for an
alternative), so they are removed with the package. A file in $PREFIX/.sl/bin
without that marker line (the sl-* commands) is never touched.
prefix-wrap --all redoes every package.
Every other program runs directly from $PREFIX/usr/bin, with no view:
less, sqlite3, shellcheck, sl in the test prefix. That is the common
case: the survey predicted 73% of packages need nothing
(survey-2026-09.md).
prefix-view --run CMD joins the running run view with nsenter (its user
and mount namespaces), in the current directory, with the caller's
environment; if none is running it starts one first (~0.15 s). The view is
held open by a background process, sudo-less-run-view infinity, whose pid
is in $PREFIX/.sudo-less/view/run.pid. It is started once, under a lock,
at login by the user unit sudo-less-run-view.service
(scripts/setup/install-session-env.sh), or on first use.
It is stopped, and rebuilt by the next --run, when:
prefix-wrapsaw a package change in the prefix (so the view shows the new files, and no two overlays write the same upper directory for long);- the host's
/var/lib/dpkg/statusis newer than it (the host's packages changed); prefix-view --stopis run.
Programs already running in an old view keep it until they exit. Inside the
run view prefix-view --run runs its command directly, so a wrapped program
that starts another wrapped program stays in the same view.
A package's daemon reads /etc/redis/redis.conf, writes /var/lib/redis
and makes /run/redis/redis.sock. When prefix-units translates its
systemd unit into a user unit (services.md), every Exec
line runs as prefix-view --service -p ... -- CMD: a private view, so the
daemon reads its config and keeps its state and logs where Debian puts
them, and it all lands in $PREFIX/var; the run view leaves /var the
host's. It is fresh for each start.
Its /run is $XDG_RUNTIME_DIR/sudo-less/run, a directory of the user's
that the prefix's services share, as Debian's services share /run, and
that is gone at reboot. The daemon writes /run/redis/redis.sock where
Debian has it; outside the view it is
$XDG_RUNTIME_DIR/sudo-less/run/redis/redis.sock, where systemd reads its
pid files and clients connect. What the host has in /run (the system bus,
/run/systemd/resolve, which /etc/resolv.conf points to) is bound in on
top, each on an empty file or directory of the same name in that directory.
It is not an overlay like /var: a unix socket made through an overlay
cannot be reached from outside it (measured: connect is refused on the
upper layer's path).
prefix-sandbox (tools/prefix-sandbox.sh) takes systemd.exec(5)'s
directives and builds them on top of a private view, as root of the view's
user namespace, before the command gets the user's uid back:
| directives | how |
|---|---|
ProtectSystem=, ReadOnlyPaths= |
a read-only bind of each path over itself (ro=recursive); strict leaves /dev, /proc, /sys |
ProtectHome= |
an empty read-only tmpfs on /home, /root and /run/user (the user bus, the user manager's private socket, Wayland, the agents); only $NOTIFY_SOCKET is bound back |
ReadWritePaths=, StateDirectory=, CacheDirectory=, LogsDirectory=, RuntimeDirectory= |
holes: opened before anything is hidden, and bound back from there (/proc/self/fd/N/...) once it is; the state directories are /var/lib/X, /var/cache/X, /var/log/X, /run/X, as on Debian, and $STATE_DIRECTORY, ... name them |
PrivateTmp=, TemporaryFileSystem= |
a tmpfs |
InaccessiblePaths=, BindPaths=, BindReadOnlyPaths= |
a mode 000 node bound over it; a bind |
SystemCallFilter=, SystemCallErrorNumber=, SystemCallArchitectures=, RestrictNamespaces= |
a seccomp BPF program, built in bash and loaded by util-linux's setpriv --no-new-privs --seccomp-filter after the uid is back, just before exec; the groups (@system-service) are the host's systemd-analyze syscall-filter, the syscall numbers tools/syscalls/ARCH (x86_64, aarch64; dev/syscall-tables.sh); other ABIs (i386, x32) are refused, as with SystemCallArchitectures=native |
Why the command cannot undo them, and what they do not stop:
security.md.
systemd cannot build these itself around the view: its ProtectSystem=
protects the host's /usr under the view's overlay (measured: /usr stays
writable), its paths are the host's (ReadWritePaths=/var/lib/foo), its
ProtectHome= hides the prefix before the view is built (203/EXEC), and
its SystemCallFilter=@system-service and RestrictNamespaces= forbid the
mount() and unshare() the view is made with (SIGSYS, EPERM). The
directives that name no path (PrivateNetwork=, ProtectKernelTunables=,
NoNewPrivileges=, ...) work around the view and stay systemd's.
A view's mounts are slaves of the host's (unshare --propagation slave): a
USB stick or network share mounted on the host after the view was built
shows up in it, and disappears from it when unmounted. The holder process
runs in /, so it never keeps a mount busy. (With the default, private
propagation, a long-lived run view would never see the stick, and would keep
a copy of any mount that existed when it was built.) Mounts made inside a
view never reach the host.
- unprivileged user namespaces (on by default on Debian; Ubuntu 24.04 and
later restrict them with AppArmor, see
admin/enable-userspace.sh); - overlayfs in a user namespace (kernel 5.11 or later);
- util-linux
unshare2.38 or later (--map-user). - for a sandbox's syscall filters only: util-linux
setprivwith--seccomp-filter(util-linux 2.40 or later; checked here on 2.42), on x86_64 or aarch64. Without it a service that asks for a filter does not start.
-
Locked mounts. overlayfs refuses a lower layer with mounts under it (in a user namespace they are locked).
/var, for example, holds lxcfs or Waydroid mounts. Such a directory gets a skeleton of its entries as its lower layer; plain subdirectories get an overlay of their own, and mount points are bound from the host. -
No copy-up of root's directories. Creating a file in a directory the prefix has no copy of would need overlayfs to copy the directory up, owned by root, which a user namespace cannot map. So before entering, the prefix gets its own empty copy of each host directory likely to be written to:
- the directories the
.debfiles being installed put files in (the wrapper passes them inPREFIX_VIEW_DEBS; apt's--recursivedirectory is scanned); - every directory of
/etc,/varand/opt,/usr's first two levels and/usr/share/mime, where maintainer scripts and triggers keep state, configuration and caches: about 1500, redone when the host's package set changes ($PREFIX/.sudo-less/view/mirror.stamp).
A script that writes into some other host directory its package does not ship fails with "Permission denied";
PREFIX_VIEW_MIRROR=fullcopies every host directory (about 10000) instead. - the directories the
-
No in-place writes to root's files. A host file can be replaced (write a new file, rename it over) but not appended to. dpkg appends to
/var/log/dpkg.log, soapt-dpkg/install.shgives the prefix its own. -
No hard links to root's files. dpkg hard-links a file it is about to replace as a backup; for a host file it copies it instead (
apt-dpkg/patches/dpkg/0103-link-or-copy.patch). -
Batched setup. Building a view walks the host's mount table and creates a skeleton directory for every entry of a directory with locked mounts under it. Those are made with one
mkdir -pand onecpper directory, not one per entry: the install view went from ~0.4 s to ~0.2 s.
- It sees the host's
/var, not the prefix's. No program the survey checked needs its own/var; one that keeps state there writes it in the prefix's/varonly when installed, not when run. - dpkg (anything that builds an install view) cannot run inside it; run apt from a normal shell.
- A
.desktopfile with an absoluteExec=/usr/bin/foostarts the host's path, not the wrapper; a relativeExec=foofinds the wrapper through the session'sPATH. - While an install is running, the old run view and the install view
overlay the same prefix directories; the run view is stopped right after
(
prefix-wrap), but a program started from it in between can see files half-installed. tools/prefix-run.sh(the older per-command overlay with tiers) is still there for running a command by hand (tools/prefix-run.sh --explain --print CMDshows what it would do); the wrappers do not use it.
$PREFIX/.sudo-less/view/ holds the mirror stamp, the run view's pid, lock
and log, the prefix-wrap stamp, each running view's overlay work
directories (work/<pid>, removed by the next view once that process has
exited) and a mount point for the view's temporary skeletons.
$PREFIX/.sl/state/db/wrappers/ records the scripts each package got.