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

Nix-Bwrapper

Nix-Bwrapper is a utility aimed at creating a user-friendly method of sandboxing applications using bubblewrap with portals support. To do this, bwrapper leverages NixOS’ built-in buildFHSEnv wrapper.

Key features:

  • fully declarative & composable configuration of app permissions
  • supports config presets by way of nixpkgs’ module system
    • Nix-Bwrapper also comes with a couple presets out of the box to help get you started
  • can pre-configure entire applications based on their Flatpak manifest file
  • properly sandbox X11 apps via xwayland-satellite in such a way that they cannot even spy on other X11 apps
  • full interoparability with portals, e.g. to selectively read / save files outside the sandbox
  • selective filtering of dbus interfaces via xdg-dbus-proxy and the operations an application may perform on them

For a list of all available options and their functionality, refer to our option search.

The examples/flake.nix contains a few examples with some commonly used (unfree) applications.

Getting Started

Note

Starting with version 1.0.0, nix-bwrapper publishes to FlakeHub with semantic versioning. It is recommended to lock your flake input to a major version (as shown in the example below), to avoid sudden unexpected breaking changes.

Alternatively, if you don’t care about that and just want the latest version no matter what, you may set it to any of the following:

# For any tagged release
https://flakehub.com/f/Naxdy/nix-bwrapper/*

# To get the latest commit to `main`
github:Naxdy/nix-bwrapper

Packages

Import this flake like you would any other. It provides an overlay, which in turn provides the mkBwrapper and mkBwrapperFHSEnv functions.

Both functions take in a module describing the app you want sandboxed, and exactly how you want it to be sandboxed. Here is a minimal flake that exports a wrapped discord package:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";

    nix-bwrapper.url = "https://flakehub.com/f/Naxdy/nix-bwrapper/1.*";
  };

  outputs = { self, nixpkgs, nix-bwrapper }:
  let
    pkgs = import nixpkgs {
      system = "x86_64-linux";
      config.allowUnfree = true; # discord is unfree
      overlays = [
        nix-bwrapper.overlays.default # provides `mkBwrapper`
      ];
    };
  in {
    packages.x86_64-linux.discord-wrapped = pkgs.mkBwrapper {
      imports = [
        # Enables common desktop functionality (bind sockets for audio, display, dbus, mount
        # directories for fonts, theming, etc.).
        pkgs.bwrapperPresets.desktop
      ];
      app = {
        package = pkgs.discord;
        runScript = "discord";
      };
      # ...
    };
  };
}

Presets for commonly shared functionality are available under the bwrapperPresets attribute. To see exactly which options they enable, have a look at the ./presets directory in this repo.

bwrapper also provides a nixosModule that simply enables the overlay, which can be used in NixOS configurations, like so:

nixosConfigurations.myMachine = nixpkgs.lib.nixosSystem {
  system = "x86_64-linux";
  modules = [
    bwrapper.nixosModules.default
    ({ pkgs, ... }: {
      environment.systemPackages = [
        (pkgs.mkBwrapper {
          app = {
            imports = [ pkgs.bwrapperPresets.desktop ];
            package = pkgs.discord;
          };
          # ...
        })
      ];
    })
  ];
};

Packages already using buildFHSEnv can also be wrapped, like so:

packages.lutris-wrapped = pkgs.mkBwrapper ({
  imports = [ pkgs.bwrapperPresets.desktop ];
  app = {
    package = pkgs.lutris;
    isFhsenv = true; # tells bwrapper that the app is already using buildFHSEnv
    id = "net.lutris.Lutris";
  };
  # ...
});

Packages using buildFHSEnv in a custom manner can also be wrapped, by using mkBwrapperFHSEnv like so:

{
  packages.bottles-wrapped = pkgs.bottles.override {
    # Need to override it like this because `pkgs.bottles` is a `symlinkJoin`.
    # Also, when using `mkBwrapperFHSEnv`, `app.isFhsenv` is implicitly set to `true`, and
    # it is only necessary to specify `package-unwrapped`, as opposed to `package`, which should
    # be set to the _unwrapped_ package (the package that is meant to be passed to `buildFHSEnv`)
    buildFHSEnv = pkgs.mkBwrapperFHSEnv {
      imports = [ pkgs.bwrapperPresets.desktop ];
      app = {
        package-unwrapped = pkgs.bottles-unwrapped;
        id = "com.usebottles.bottles";
      };
      # ...
    };
  };
}

See examples/flake.nix for more complete use cases. Note that they are only intended as examples, and may not be (fully) usable as-is.

DevShells

You can also use mkBwrapper to declare a sandboxed devShell in your flake:

{
  devShells.zsh =
    (pkgs.mkBwrapper {
      app = {
        package = pkgs.zsh;
        # Your dev dependencies & tools go here
        addPkgs = [
          pkgs.gron
        ];
      };
      imports = [ pkgs.bwrapperPresets.devshell ];
      mounts.readWrite = [
        "$HOME/.zshrc"
        "$HOME/.p10k.zsh"
        "$HOME/.zshrc.zni"
        "$HOME/.oh-my-zsh"

        # Note that you may not want this, depending on what is
        # contained in your history!
        "$HOME/.zsh_history"
      ];
    }).env;
}

Then, you can enter the sandboxed dev environment as you would normally, using nix develop .#, or using tools like direnv. Additional dependencies and tools can be declared under config.app.addPkgs.

Options

Note

A comprehensive interactive option search is available at https://naxdy.github.io/nix-bwrapper/options-search

Desktop Applications

Nix-Bwrapper provides a preset under bwrapperPresets.desktop that is preconfigured in such a way that it should integrate with your DE and your theming, and as such includes a number of read-only mounts for some of your home directories (e.g. ~/.fonts, ~/.icons, etc.). If you don’t need this behavior (e.g. because you’re sandboxing a CLI app), you can omit importing this preset.

For details on the exact configuration this preset provides, see its doc page.

The preset’s configuration can be overridden just like in any other NixOS module, by using lib.mkForce. For example:

{
  packages.discord-wrapped = pkgs.mkBwrapper {
    imports = [ pkgs.bwrapperPresets.desktop ];
    app = {
      package = pkgs.discord;
      runScript = "discord";
      id = "com.discordapp.Discord";
    };
    mounts = {
      read = lib.mkForce [ ]; # do not grant Discord access to any other paths
      readWrite = [
        "$XDG_RUNTIME_DIR/app/com.discordapp.Discord" # for rich presence
      ];
    };
    dbus.session.owns = [
      "com.discordapp.Discord"
    ];
  };
}

Additionally, bwrapperPresets.desktop will attempt to bind pulse, pipewire and wayland sockets from $XDG_RUNTIME_DIR.

If sockets.x11 is enabled (which bwrapperPresets.desktop enables by default), Nix-Bwrapper will also provide an X11 socket via xwayland-satellite. This ensures that every sandboxed app receives its own xorg instance, meaning that sandboxed X11 apps cannot spy on each other.

This can be disabled by setting the respective sockets option to false:

{
  packages.slack-wrapped = pkgs.mkBwrapper {
    imports = [ pkgs.bwrapperPresets.desktop ];
    app = {
      package = pkgs.slack;
      runScript = "slack";
      execArgs = "-s %U";
    };
    sockets.x11 = false; # do not spawn an X11 socket in this sandbox
    # ...
  };
}

Sandboxed files are stored in $HOME/.bwrapper/${config.app.bwrapPath} on the host system.

Access is granted to all hardware devices by default.

Packaging Applications

Packaging applications is easiest if your application exists as a flatpak. In this case, you can simply import the flatpak manifest into config.flatpak.manifestFile and have Nix-Bwrapper handle most (if not all) of the necessary configuration for you.

When doing this, you can of course still add / revoke additional permissions using the module system as necessary.

Your application has a flatpak manifest

If the application has a Flatpak manifest (either .json or .yaml/.yml), you can pre-fill most of bwrapper’s options by setting flatpak.manifestFile accordingly. For example, for librewolf you could have:

{
  packages.librewolf-wrapped = pkgs.mkBwrapper {
    imports = [ pkgs.bwrapperPresets.desktop ];
    app.package = pkgs.librewolf;
    flatpak.manifestFile = pkgs.fetchurl {
      url = "https://github.com/flathub/io.gitlab.librewolf-community/raw/refs/heads/master/io.gitlab.librewolf-community.json";
      hash = "...";
    };
  };
}

YAML manifests are also supported directly:

{
  packages.signal-wrapped = pkgs.mkBwrapper {
    imports = [ pkgs.bwrapperPresets.desktop ];
    app.package = pkgs.signal-desktop;
    flatpak.manifestFile = pkgs.fetchurl {
      url = "https://github.com/flathub/org.signal.Signal/raw/refs/heads/master/org.signal.Signal.yaml";
      hash = "...";
    };
  };
}

The manifest is automatically normalized at build time, including conversion of the app-id field (used in YAML manifests) to id (used in JSON manifests).

This will already take care of most (if not all) necessary options for you. You can still override options normally using Nix’ module system, e.g. to disallow access to some directory or socket that is listed in the manifest by default.

At the moment, bwrapper supports pre-filling options for the following:

  • app.id
  • app.env variables
  • sockets: pulseaudio, pipewire, cups
  • fhsenv options: unshareNet, unshareIpc
  • mounts.read and mounts.readWrite, including substitutions for: home, xdg-desktop, xdg-documents, xdg-download, xdg-music, xdg-pictures, xdg-public-share, xdg-templates, xdg-videos, xdg-run, xdg-config, xdg-cache, xdg-data
  • mounts.sandbox
  • dbus.{session,system}.{talks,owns,calls}

Inspecing & overriding options

If you want to see exactly what the final config will be, you can use bwrapperEval and inspect the resulting config attribute, for example:

{
  packages.my-librewolf-config = (pkgs.bwrapperEval {
    imports = [ pkgs.bwrapperPresets.desktop ];
    flatpak.manifestFile = pkgs.fetchurl {
      url = "https://github.com/flathub/io.gitlab.librewolf-community/raw/refs/heads/master/io.gitlab.librewolf-community.json";
      hash = "...";
    };
  }).config;
}

Then you can run nix eval .#my-librewolf-config to get a full dump of your final config, or run something like nix eval .#my-librewolf-config.app instead, to only inspect parts of it.

You can also pass --json to parts of the config that can be converted to valid JSON, i.e. anything not under build. For example, running nix eval .#my-librewolf-config.app --json could produce something like this:

{
  "addPkgs": [
    "/nix/store/wbmwh79ccgjfm6xl9zgxrk6l62xivds6-gtk+3-3.24.49-dev"
  ],
  "bwrapPath": "librewolf",
  "env": {
    "DBUS_SESSION_BUS_ADDRESS": "unix:path=$XDG_RUNTIME_DIR/bus",
    "DICPATH": "/usr/share/hunspell",
    "MOZ_USE_XINPUT2": "1",
    "NOTIFY_IGNORE_PORTAL": 1,
    "WAYLAND_DISPLAY": "$WAYLAND_DISPLAY"
  },
  "execArgs": "",
  "id": "io.gitlab.librewolf-community",
  "isFhsenv": false,
  "overwriteExec": true,
  "package": "/nix/store/g91s89dc8lwnwdwhnyr2mq02k5xnc227-librewolf-143.0-1",
  "package-unwrapped": null,
  "renameDesktopFile": true,
  "runScript": "librewolf"
}

If you then decide, for example, that you want to change the app id, or add an additional environment variable, you can do this as follows:

{
  packages.my-librewolf-config = (pkgs.bwrapperEval {
    imports = [ pkgs.bwrapperPresets.desktop ];
    flatpak.manifestFile = pkgs.fetchurl {
      url = "https://github.com/flathub/io.gitlab.librewolf-community/raw/refs/heads/master/io.gitlab.librewolf-community.json";
      hash = "...";
    };
    app = {
      # will completely override the app id from the manifest
      id = pkgs.lib.mkForce "my-custom-id";
      env = {
        # will be merged with the env vars from the manifest file
        MY_CUSTOM_ENV_VAR = "example";
      };
    };
  }).config;
}

Manually packaging applications with help of a manifest

If the application doesn’t have a suitable Flatpak manifest, or you prefer to configure permissions manually, you can specify all options explicitly.

First, obtain the info about the permissions the app needs:

  1. Go to flathub.org and look for the application you want to wrap. We’ll use Slack as an example here.

  2. At the bottom, click on “Links”, and then “Manifest”. For Slack, it should lead you here.

  3. Open the manifest file (.json, .yaml, or .yml), in this case com.slack.Slack.yaml

This file shows all the permissions that are being granted to the application. You can use this as a blueprint for which permissions to grant in your wrapper. We can see that the file contains the following:

finish-args:
    - --device=all
    - --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons
    - --share=ipc
    - --share=network
    - --socket=pulseaudio
    - --socket=x11

    # Filesystems
    - --filesystem=xdg-download

    # D-Bus Access
    - --talk-name=com.canonical.AppMenu.Registrar
    - --talk-name=org.freedesktop.Notifications
    - --talk-name=org.freedesktop.ScreenSaver
    - --talk-name=org.freedesktop.secrets
    - --talk-name=org.kde.StatusNotifierWatcher
    - --talk-name=org.kde.kwalletd5
    - --talk-name=org.kde.kwalletd6

    # System D-Bus Access
    - --system-talk-name=org.freedesktop.UPower
    - --system-talk-name=org.freedesktop.login1

Now it’s time to translate the above to nix. Let’s first see what we can ignore:

  • --device=all can be ignored, since bwrapper by default already grants access to all devices.

  • --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons can also be ignored, since the $HOME/.icons directory is mounted readonly as is, therefore this environment variable would only lead to confusion.

  • --socket=pulseaudio and --socket=x11 can be ignored, since pulseaudio and X11 sockets are shared by default anyway.

That leaves the rest to be added. The final wrapper looks as follows:

{
  packages.slack-wrapped = pkgs.mkBwrapper {
    imports = [ pkgs.bwrapperPresets.desktop ];
    # basic settings
    app = {
      package = pkgs.slack;
      runScript = "slack";
      execArgs = "-s %U";
      # to make global menu work in KDE
      addPkgs = [
        pkgs.libdbusmenu
      ];
    };

    # taken from the .yaml file above
    mounts.readWrite = [
      "$HOME/Downloads"
    ];
    dbus.system.talks = [
      "org.freedesktop.UPower"
      "org.freedesktop.login1"
    ];
    dbus.session.talks = [
      "com.canonical.AppMenu.Registrar"
      "org.freedesktop.Notifications"
      "org.freedesktop.ScreenSaver"
      "org.freedesktop.secrets"
      "org.kde.StatusNotifierWatcher"
      "org.kde.kwalletd5"
      "org.kde.kwalletd6"
    ];
  };
}

Note that even though some dbus talks, e.g. org.freedesktop.Notifications, are already granted by bwrapper by default, specifying it here again doesn’t do any harm, since it filters for unique names anyway.

Your application does not have a flatpak manifest

Begin with a minimal example (see the flake.nix for a minimal example using brave). Then, run your application from a terminal (to see the logs it outputs). Use it as you would normally, if you notice something doesn’t work quite right (or at all), see if the terminal gives you a clue as to what’s wrong. Amend your wrapper, and repeat.

As you might be able to tell, there’s a lot of trial and error involved if you go down this route.

Tip

Set dbus.logging = true to see which DBus interfaces the application attempts to access.

desktop

This preset enables various options useful for sandboxing most graphical desktop applications.

Note that this merely includes basic options, such as allowing commonly used dbus interfaces, display & audio sockets, mounting directories needed for fonts, themes, etc.

Use via:

{
  myPackage = pkgs.mkBwrapper {
    imports = [
      pkgs.bwrapperPresets.desktop
    ];
    # your config here
  };
}

Source reference:

{ config, lib, ... }:
let
  inherit (lib) mkDefault;
in
{
  config = {
    app = {
      renameDesktopFile = true;
      overwriteExec = true;
    };

    dbus = {
      enable = mkDefault true;
      session = {
        talks = [
          "org.freedesktop.Notifications"
          "com.canonical.AppMenu.Registrar"
          "com.canonical.Unity.LauncherEntry"
          "org.freedesktop.ScreenSaver"
          "com.canonical.indicator.application"
          "org.kde.StatusNotifierWatcher"
          "org.freedesktop.portal.Documents"
          "org.freedesktop.portal.Flatpak"
          "org.freedesktop.portal.Desktop"
          "org.freedesktop.portal.Notifications"
          "org.freedesktop.portal.FileChooser"
        ];
        calls = [
          "org.freedesktop.portal.*=*@/org/freedesktop/portal/desktop"
        ];
        broadcasts = [
          "org.freedesktop.portal.Desktop=*@/org/freedesktop/portal/desktop"
        ];
      };
      system = {
        talks = [
          "com.canonical.AppMenu.Registrar"
          "com.canonical.Unity.LauncherEntry"
        ];
      };
    };

    sockets = {
      pipewire = mkDefault true;
      pulseaudio = mkDefault true;
      wayland = mkDefault true;

      # Enabling this is safe, because we use a separate `xwayland-satellite` for every sandbox.
      x11 = mkDefault true;
    };

    flatpak.enable = mkDefault true;

    fhsenv = {
      performDesktopPostInstall = mkDefault (!config.app.isFhsenv);
      opts = {
        unshareUser = mkDefault false;
        unshareUts = mkDefault false;
        unshareCgroup = mkDefault false;
        unshareNet = mkDefault false;
      };
    };

    mounts = {
      read = [
        "$HOME/.icons"
        "$HOME/.fonts"
        "$HOME/.themes"
        "$HOME/.config/gtk-3.0"
        "$HOME/.config/gtk-4.0"
        "$HOME/.config/gtk-2.0"
        "$HOME/.config/Kvantum"
        "$HOME/.config/gtkrc-2.0"
        "$HOME/.local/share/color-schemes"
      ];

      sandbox = [
        {
          name = "config";
          path = "$HOME/.config";
        }
        {
          name = "local";
          path = "$HOME/.local";
        }
        {
          name = "cache";
          path = "$HOME/.cache";
        }
      ];
    };
  };

  meta = {
    name = "desktop";
    description = ''
      This preset enables various options useful for sandboxing most graphical desktop applications.

      Note that this merely includes basic options, such as allowing commonly used dbus interfaces, display & audio sockets,
      mounting directories needed for fonts, themes, etc.
    '';
  };
}

devshell

A preset designed to be used as part of a development environment, for example to confine AI agents, or to limit the impact of potentially malicious dependencies / supply chain attacks.

Confines any application to the current directory (at time of execution), and provides persistence within the sandbox for a number of commonly used directories (e.g. $HOME/.cache).

Use via:

{
  myPackage = pkgs.mkBwrapper {
    imports = [
      pkgs.bwrapperPresets.devshell
    ];
    # your config here
  };
}

Source reference:

{ lib, ... }:
let
  inherit (lib) mkDefault;
in
{
  config = {
    fhsenv.opts = {
      unshareNet = mkDefault false;
    };

    mounts = {
      readWrite = [
        "$PWD"
      ];

      sandbox = [
        {
          name = "config";
          path = "$HOME/.config";
        }
        {
          name = "local";
          path = "$HOME/.local";
        }
        {
          name = "cache";
          path = "$HOME/.cache";
        }
      ];
    };
  };

  meta = {
    name = "devshell";
    description = ''
      A preset designed to be used as part of a development environment, for example to confine AI agents, or to limit the
      impact of potentially malicious dependencies / supply chain attacks.

      Confines any application to the current directory (at time of execution), and provides persistence within the sandbox
      for a number of commonly used directories (e.g. `$HOME/.cache`).
    '';
  };
}