Previously in this series, we discovered how to use bubblewrap to sandbox simple applications. Then, we moved on to more complex applications, and concluded that, while it works, the long command lines used were getting very unwieldy.

I will now present you the script (unimaginatively called sandbox) I use to sandbox my applications. Its configuration file is located at ~/.config/sandbox.yml.

It starts with resources : mostly path binds, but also environment variables and D-Bus services. A preset is a named set of resources. You can then associate presets to applications using rules.

Let’s start with a very basic preset, mirroring the very first command we sandboxed, plus some basic things from the second part :

presets:
  common:
    - args: [--clearenv, --unshare-pid, --die-with-parent, --proc, /proc, --dev, /dev, --tmpfs, /tmp, --new-session]
    - setenv: [PATH, LANG, XDG_RUNTIME_DIR, XDG_SESSION_TYPE, TERM, HOME, LOGNAME, USER]
    - ro-bind: /etc
    - ro-bind: /usr
    - args: [--symlink, usr/bin, /bin, --symlink, usr/bin, /sbin, --symlink, usr/lib, /lib, --symlink, usr/lib, /lib64, --tmpfs, "{env[XDG_RUNTIME_DIR]}"]
    - bind: /run/systemd/resolve

Nothing should surprise you here at this point. Let’s use this preset :

$ sandbox -p common zsh
$

Just after that, we created a per-application sandboxed home. Let’s make a preset to do that, too :

  private-home:
    - bind: ["{env[HOME]}/sandboxes/{executable}/", "{env[HOME]}"]
      bind-create: true
    - dir: "{env[HOME]}/.config"
    - dir: "{env[HOME]}/.cache"
    - dir: "{env[HOME]}/.local/share"

You can try it :

$ sandbox -p common -p private-home zsh
$

(note that all zsh instances will share the same sandboxed home)

Let’s define some more presets for desktop applications now. Again, nothing surprising if you followed the previous posts (except we don’t have to handle explicitly the DBUS_SESSION_BUS_ADDRESS environment variable or the xdg-dbus-proxy socket : the script is handling that for us as soon as we use a dbus-* resource) :

  x11:
    - setenv: [DISPLAY]
    - ro-bind: /tmp/.X11-unix/
  wayland:
    - setenv: [WAYLAND_DISPLAY]
    - ro-bind: "{env[XDG_RUNTIME_DIR]}/{env[WAYLAND_DISPLAY]}"
  pulseaudio:
    - ro-bind: "{env[XDG_RUNTIME_DIR]}/pulse/native"
    - ro-bind-try: "{env[HOME]}/.config/pulse/cookie"
    - ro-bind-try: "{env[XDG_RUNTIME_DIR]}/pipewire-0"
  drm:
    - dev-bind: /dev/dri
    - ro-bind: /sys
  portal:
    - file: ["", "{env[XDG_RUNTIME_DIR]}/flatpak-info"]
    - file: ["", "/.flatpak-info"]
    - dbus-call: "org.freedesktop.portal.*=*"
    - dbus-broadcast: "org.freedesktop.portal.*=@/org/freedesktop/portal/*"

Now that we’re done with presets, we can define rules. Applications can be matched implicitly, from the name of the executable run, or explicitly. Let’s start with an implicit rule (based on the name of the executable), for example firefox :

rules:
  - match:
      bin: firefox
    setup:
      - setenv:
          MOZ_ENABLE_WAYLAND: 1
      - use: [common, private-home, wayland, portal]
      - dbus-own: org.mozilla.firefox.*
      - bind: "{env[HOME]}/Downloads"
      - bind: ["{env[HOME]}/.config/mozilla", "{env[HOME]}/.mozilla"]

Now, running sandbox firefox will bind ~/Downloads and setup the common, private-home, wayland and portal presets. I also bind its configuration (~/.mozilla in the sandboxed environment) to my ~/.config/firefox directory (because I try to keep my ~/sandboxes directory disposable, not keeping important stuff here).

Let’s present an explicit rule :

  - match:
      name: shell
    setup:
      - use: [common, private-home]

This rule will never be matched automatically ; it has to be matched manually by sandbox --name shell zsh. It will have its private home in ~/.sandboxes/shell. I tend to use it for CLI commands I want to run sandboxed, but for which I don’t care about their persistent state (and that I don’t run often).

An interesting set of rules is the ones I use for node/npm, where I want the command to be able to use the current directory, but pretty much nothing else :

  - match:
      bin: node
    setup:
      - use: [common, private-home]
      - bind-cwd: {}
      - cwd: true

  - match:
      bin: npx
    setup:
      - use: [common, private-home]
      - bind-cwd: {}
      - cwd: true

  - match:
      bin: npm
    setup:
      - use: [common, private-home]
      - bind-cwd: {}
      - cwd: true

That way, I can just run sandbox npm ci or sandbox npm run ... in my current directory, and the stuff will just work (and be sandboxed).

You can also define a default, fallback rule :

  - match:
      name: none

  # Fallback: anything else fall backs to a sandboxed empty home
  - setup:
    - use: [common, private-home, x11, wayland, pulseaudio, portal]

The none rule, is if you don’t want to use the fallback for an unknown command ; in that case you can do sandbox --name none -p common --tmpfs ~ ...

That’s pretty much it. One thing to remember is to use -- to separate sandbox arguments from the sandboxed arguments : sandbox -- npm --save install ...

I present you this script as a simple Gist with only this blog post as documentation. There is probably enough things to do here for an opportunity to grow a whole project on the basis of this script. I won’t be the one doing it. If any brave, motivated soul want to take up that task, go ahead, you have my blessing. Everything I published here (on the blog, or the script) is released under the CC-0 license.

If you don’t want to use such a script, the closest alternative is probably Firejail. The key differences are :

  • A lot of stuff that is coded as presets in the configuration file or in the script (like D-Bus management or the pulseaudio preset) is hard-coded in the main (suid) binary in firejail. I agree with the author of bubblewrap that it is a quite larger attack surface.

  • Firejail comes with a lot of predefined rules. Most applications should work out of the box without having to work hard to make them work.

  • There is no equivalent of raw --bind in Firejail, so if you want to do things like --bind /opt/projects/my-project ~/workspace or --bind ~/.config/mozilla ~/.mozilla, you will not able to do it.

  • Firejail has some interesting additional features like per-sandbox firewall rules.

  • If you go into a default sandbox with firejail --no-profile zsh, you will see that the default sandbox in firejail allows everything — you have to explicitly blacklist stuff. We saw the very first time we launched bubblewrap that it takes the opposite approach — by default nothing is shared, you have to explicitly whitelist stuff.