---
title: How to Sandbox a Terminal
date: 2025-10-11
tags: [linux, security]
description: "I often pull code from remote repositories and execute it for testing. This is an area where sandboxing would be extremely beneficial."
---

I do most of my work in terminals, using a multitude of small, composable
command line tools. During development, I often pull code from remote
repositories and execute it for testing. This is an area where sandboxing would
be extremely beneficial.

The best option would probably be to explicitly use a sandboxing wrapper every
time I execute untrusted code. But that is also tedious. In this post, I want
to explore what we can restrict on the terminal as a whole.

Restricting the terminal is tricky because it is a usually thought of as a
representation of the user. It should be able to do anything that the user can
do. On the other hand, we are already used to the idea that we cannot install
packages without using `sudo`, so maybe we can expand that concept.

## Tools at our Disposal

The main mechanism I want to use for this are mount namespaces. They allow us
to run the terminal with a completely different file tree. However, I mostly
want to keep the existing tree and just hide some files, or make them
read-only. The tool that I am using for that is
[`bwrap`](https://github.com/containers/bubblewrap).

Additionally, many services on a modern Linux desktop use DBus, which means
that they all share the same socket. So we also need
[`xdg-dbus-proxy`](https://github.com/flatpak/xdg-dbus-proxy) to control access
to individual services.

To simplify the usage of both of these tools, I’ve written a wrapper called
[xiwrap](https://github.com/xi/xiwrap).

## Privilege Escalation

We need to prevent processes from escaping the sandbox. There are two DBus
services on the session bus that allow unchecked privilege escalations:
`org.freedesktop.systemd1` (used by `systemd-run`) and
`org.freedesktop.portal.Flatpak` (used by `flatpak-spawn`). They should
definitely be blocked.

On the other hand, we do want to have an escape hatch to allow users to do
things that would normally be prevented by the sandbox. Just like `sudo` allows
us to escalate our privileges by entering a password.

`org.freedesktop.systemd1` on the system bus asks for a password for any
privileged action, so it is safe to expose. It even provides the
[`run0`](https://mastodon.social/@pid_eins/112353324518585654) command as a
drop-in for `sudo`. We can also use `run0 --user $USER` to escape the sandbox
without becoming root.

I must admit that I didn't understand `run0` when it was first announced. But
now I really see the appeal of a privilege escalation with user interaction
that does not depend on a configuration file in the current mount namespace.[^2]

Of course, a more low-tech option is to add an option to launch an unsandboxed
terminal.

[^2]: Another, simpler implementation of this concept can be found in
	[s6](https://skarnet.org/software/s6/s6-sudo.html).

## Portals

The DBus services `org.freedesktop.portal.Desktop` and
`org.freedesktop.portal.Documents`, collectively known as portals, are
specifically designed for sandboxing and are generally safe to use. Most
features they expose are not particularly relevant for terminals though. The
only interface I actually use is `org.freedesktop.portal.OpenURI` to open files
or links in the appropriate applications.

Unfortunately, we are not at a point where portals are used by default. For
example, `xdg-open` will only use the portal if `$XDG_RUNTIME_DIR/flatpak-info`
exists. GTK applications behave differently depending on the contents of
`/.flatpak-info` and whether `GIO_USE_PORTALS` is set. I am still searching for
a setup that works correctly.

This is mostly an issue for the terminal GUI itself though, because, as I said,
I rarely use portals from inside the terminal.

## Wayland, AT-SPI, Pulse

The system's input and output rely on three main protocols:

-	Wayland for video, typically via the socket `$XDG_RUNTIME_DIR/wayland-0`
-	Pipewire for audio, typically via the socket `$XDG_RUNTIME_DIR/pipewire-0` or `$XDG_RUNTIME_DIR/pulse/`
-	AT-SPI for accessibility, typically via the sockets in `$XDG_RUNTIME_DIR/at-spi`

Unfortunately, these protocols do not clearly separate between clients
that provide data, and clients that consume data. For example, any client with
access to the respective sockets can access the accessibility trees of all
other clients, monitor the audio output of other clients, or access the
microphone.

Wayland provides some isolation, but is not without flaws either. See [my
previous post](https://blog.ce9e.org/posts/2025-10-03-wayland-security/) for
details.[^1]

There is not much we can do about this until the protocols are changed with
security in mind. Until then, I would recommend to not expose the AT-SPI socket
in the sandbox unless you need it.

[^1]: Pipewire seems to have a [security
	contexts](https://docs.pipewire.org/structpw__security__context__methods.html#a78e54bfd81f8e41605152161f29ad166)
	extension that is closely modelled after that of Wayland.

## Read-only Path and Config

Allowing untrusted code to install binaries or change configuration is
potentially dangerous. At the same time, this is rarely necessary during
normal operations. So mounting `~/.config/` and `~/.local/bin` read-only is a
quick win.

## Fixing SSH

Due to the way user namespaces work, root is not mapped inside of the sandbox.
This breaks ssh, because it checks that its configuration files are owned by
root:

```
Bad owner or permissions on /etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf
```

As far as I can tell, `/etc/ssh/ssh_config.d/` does not contain anything
relevant on my system, so my workaround was simply to bind a tmpfs over it.

## Nesting

The setup I am describing here is about applying restrictions on multiple
levels:

-	The terminal as a whole is restricted
-	Within the terminal, I may use a sandboxing wrapper to execute untrusted code
-	That code may in turn apply restrictions to its child processes

The kernel models namespaces as a tree structure, which works well with
nesting. Unfortunately, some user space protocols, notably
[DBus](https://gitlab.freedesktop.org/wayland/wayland-protocols/-/issues/281)
and [Wayland](https://gitlab.freedesktop.org/wayland/wayland-protocols/-/issues/281),
use a simpler model where only the app ID of the outermost sandbox is
considered. Portals also rely on app IDs to manage permissions.

This model is fundamentally incompatible with the kind of nesting I have in
mind. But the community seems to be set on its current approach, so I have
little hope of improvement in this area.

## Conclusion

Sandboxing on Linux is still incredibly bumpy. Many tools and protocols still
need to be adapted. And those that have been adapted often rely on
implementation details of Flatpak.

Still, it is possible to cobble together a decent sandbox for the terminal as a
whole. I hope this post gave you some inspiration to experiment with your own
configuration!

## Annex: xiwrap config

At the time of writing, this is the xiwrap config I am using:

```
setenv USER
setenv HOME
setenv XDG_RUNTIME_DIR
setenv PATH $HOME/.local/bin:/usr/games:/usr/bin
setenv SHELL
include locale

ro-bind /usr
ro-bind /etc
ro-bind /var

# usr-merge symlinks
ro-bind-try /bin
ro-bind-try /lib
ro-bind-try /lib32
ro-bind-try /lib64

dev /dev
proc /proc
tmpfs /tmp
tmpfs $XDG_RUNTIME_DIR

bind $HOME
ro-bind-try $HOME/.config
ro-bind-try $HOME/.local/bin

share-net

ro-bind-try $XDG_RUNTIME_DIR/$WAYLAND_DISPLAY
setenv WAYLAND_DISPLAY
setenv XDG_CURRENT_DESKTOP
setenv XDG_SEAT
setenv XDG_SESSION_CLASS
setenv XDG_SESSION_ID=1
setenv XDG_SESSION_TYPE

ro-bind-try $XDG_RUNTIME_DIR/pipewire-0

dbus-talk org.freedesktop.portal.Desktop
dbus-talk org.freedesktop.portal.Documents

# allow to use run0 and systemctl
dbus-system-talk org.freedesktop.systemd1
dbus-system-talk org.freedesktop.login1
ro-bind /run/systemd

# ignore ssh config with wrongly mapped owner
tmpfs /etc/ssh/ssh_config.d
```
