Skip to content

Instantly share code, notes, and snippets.

@sloonz
Last active March 27, 2025 13:05
Show Gist options
  • Save sloonz/4b7f5f575a96b6fe338534dbc2480a5d to your computer and use it in GitHub Desktop.
Save sloonz/4b7f5f575a96b6fe338534dbc2480a5d to your computer and use it in GitHub Desktop.
Sandboxing wrapper script for bubblewrap ; see https://sloonz.github.io/posts/sandboxing-3/
#!/usr/bin/python
import argparse
import os
import shlex
import sys
import tempfile
import yaml
config = yaml.full_load(open(os.path.expanduser("~/.config/sandbox.yml")))
parser = argparse.ArgumentParser()
parser.add_argument("--name", "-n", action="store")
parser.add_argument("--preset", "-p", nargs=1, action="append")
parser.add_argument("--as", "-a", action="store")
bwrap_args0 = ("unshare-all", "share-net", "unshare-user", "unshare-user-try", "unshare-ipc", "unshare-net", "unshare-uts", "unshare-cgroup", "unshare-cgroup-try", "clearenv", "new-session", "die-with-parent", "as-pid-1")
bwrap_args1 = ("args", "userns", "userns2", "pidns", "uid", "gid", "hostname", "chdir", "unsetenv", "lock-file", "sync-fd", "remount-ro", "exec-label", "file-label", "proc", "dev", "tmpfs", "mqueue", "dir", "seccomp", "add-seccomp-fd", "block-fd", "userns-block-fd", "json-status-fd", "cap-add", "cap-drop", "perms")
bwrap_args2 = ("setenv", "bind", "bind-try", "dev-bind", "dev-bind-try", "ro-bind", "ro-bind-try", "file", "bind-data", "ro-bind-data", "symlink", "chmod")
for a in bwrap_args0:
parser.add_argument("--" + a, action="store_true")
for a in bwrap_args1:
parser.add_argument("--" + a, nargs=1, action="append")
for a in bwrap_args2:
parser.add_argument("--" + a, nargs=2, action="append")
parser.add_argument("command", nargs="+")
args = parser.parse_args()
bwrap_command = ["bwrap"]
system_bus_args = set()
session_bus_args = set()
executable = getattr(args, "as") or args.command[0]
executable = executable.split("/")[-1]
def expand(s, extra_env):
return str(s).format(env={**os.environ, **extra_env}, command=args.command, executable=executable, pid=os.getpid())
def handle_bind(params, create, typ, extra_env):
if isinstance(params, str):
params = [params, params]
src, dst = params
src = expand(src, extra_env)
dst = expand(dst, extra_env)
if create:
os.makedirs(src, exist_ok=True)
return ("--" + typ, src, dst)
def handle_setup(config, setup, extra_env):
setup = setup.copy()
setup_args = []
use_params = setup.pop("use", None)
if use_params:
for preset in use_params:
for preset_setup in config["presets"][preset]:
setup_args.extend(handle_setup(config, preset_setup, extra_env))
args_params = setup.pop("args", None)
if args_params:
setup_args.extend(expand(a, extra_env) for a in args_params)
setenv_params = setup.pop("setenv", None)
if isinstance(setenv_params, dict):
for k, v in setenv_params.items():
extra_env[k] = expand(v, extra_env)
setup_args.extend(("--setenv", k, extra_env[k]))
elif isinstance(setenv_params, list):
for k in setenv_params:
if k in os.environ:
setup_args.extend(("--setenv", k, os.environ[k]))
for bind_type in ("bind", "bind-try", "dev-bind", "dev-bind-try", "ro-bind", "ro-bind-try"):
bind_params = setup.pop(bind_type, None)
if bind_params:
setup_args.extend(handle_bind(bind_params, setup.pop("bind-create", None), bind_type, extra_env))
for dbus_setup in ("see", "talk", "own", "call", "broadcast"):
dbus_setup_params = setup.pop("dbus-" + dbus_setup, None)
if dbus_setup_params:
is_system = setup.pop("system-bus", False)
if is_system:
system_bus_args.add("--%s=%s" % (dbus_setup, dbus_setup_params))
else:
session_bus_args.add("--%s=%s" % (dbus_setup, dbus_setup_params))
file_params = setup.pop("file", None)
if file_params:
data, dst = file_params
pr, pw = os.pipe2(0)
if os.fork() == 0:
os.close(pr)
os.write(pw, data.encode())
sys.exit(0)
else:
os.close(pw)
setup_args.extend(("--file", str(pr), expand(dst, extra_env)))
dir_params = setup.pop("dir", None)
if dir_params:
setup_args.extend(("--dir", expand(dir_params, extra_env)))
bind_args_params = setup.pop("bind-args", None)
if bind_args_params:
added_paths = set()
strict = setup.pop("strict", True)
ro = setup.pop("ro", True)
for a in args.command[1:]:
if os.path.exists(a):
path = os.path.abspath(a)
if not strict:
path = os.path.dirname(path)
if not path in added_paths:
setup_args.extend((ro and "--ro-bind" or "--bind", path, path))
added_paths.add(path)
cwd = os.getcwd()
bind_cwd_params = setup.pop("bind-cwd", None)
if bind_cwd_params is not None:
ro = setup.pop("ro", False)
setup_args.extend((ro and "--ro-bind" or "--bind", cwd, cwd))
cwd_params = setup.pop("cwd", None)
if cwd_params is not None:
if type(cwd_params) == "str":
setup_args.extend(("--chdir", expand(cwd_params, extra_env)))
elif cwd_params:
setup_args.extend(("--chdir", cwd))
if setup.pop("restrict-tty", None):
# --new-session breaks interactive sessions, this is an alternative way of fixing CVE-2017-5226
import seccomp
import termios
f = seccomp.SyscallFilter(defaction=seccomp.ALLOW)
f.add_rule(seccomp.KILL_PROCESS, "ioctl", seccomp.Arg(1, seccomp.MASKED_EQ, 0xffffffff, termios.TIOCSTI))
f.add_rule(seccomp.KILL_PROCESS, "ioctl", seccomp.Arg(1, seccomp.MASKED_EQ, 0xffffffff, termios.TIOCLINUX))
f.load()
if len(setup) != 0:
print("unknown setup actions: %s" % list(setup.keys()))
sys.exit(1)
return setup_args
def exec_bwrap(rule):
extra_env = {}
for setup in rule.get("setup", []):
bwrap_command.extend(handle_setup(config, setup, extra_env))
for (preset,) in args.preset or []:
for preset_setup in config["presets"][preset]:
bwrap_command.extend(handle_setup(config, preset_setup, extra_env))
for a in bwrap_args0:
if getattr(args, a.replace("-", "_")):
bwrap_command.append("--" + a)
for a in bwrap_args1:
for (val,) in getattr(args, a.replace("-", "_")) or []:
bwrap_command.extend(("--" + a, val))
for a in bwrap_args2:
for (v1, v2) in getattr(args, a.replace("-", "_")) or []:
bwrap_command.extend(("--" + a, v1, v2))
dbus_proxy_args = []
dbus_proxy_dir = f"{os.environ['XDG_RUNTIME_DIR']}/xdg-dbus-proxy"
if session_bus_args or system_bus_args:
os.makedirs(dbus_proxy_dir, exist_ok=True)
if session_bus_args:
proxy_socket = tempfile.mktemp(prefix="session-", dir=dbus_proxy_dir)
dbus_proxy_args.extend((os.environ["DBUS_SESSION_BUS_ADDRESS"], proxy_socket))
dbus_proxy_args.append("--filter")
dbus_proxy_args.extend(session_bus_args)
bwrap_command.extend(("--bind", proxy_socket, os.environ["DBUS_SESSION_BUS_ADDRESS"].removeprefix("unix:path="),
"--setenv", "DBUS_SESSION_BUS_ADDRESS", os.environ["DBUS_SESSION_BUS_ADDRESS"]))
if system_bus_args:
proxy_socket = tempfile.mktemp(prefix="system-", dir=dbus_proxy_dir)
dbus_proxy_args.extend(("/run/dbus/system_bus_socket", proxy_socket))
dbus_proxy_args.append("--filter")
dbus_proxy_args.extend(system_bus_args)
bwrap_command.extend(("--bind", "/run/dbus/system_bus_socket", "/run/dbus/system_bus_socket"))
if dbus_proxy_args:
pr, pw = os.pipe2(0)
if os.fork() == 0:
os.close(pr)
dbus_proxy_command = ["xdg-dbus-proxy", "--fd=%d" % pw] + list(dbus_proxy_args)
os.execlp(dbus_proxy_command[0], *dbus_proxy_command)
# I would like to use bwrap's --block-fd, but bwrap setups then wait, and therefore may try to bind an non-existent socket
assert os.read(pr, 1) == b"x" # wait for xdg-dbus-proxy to be ready
bwrap_command.extend(("--sync-fd", str(pr)))
bwrap_command.extend(args.command)
if os.getenv("SANDBOX_DEBUG") == "1":
print(bwrap_command, file=sys.stderr)
os.execvp(bwrap_command[0], bwrap_command)
for rule in config["rules"]:
is_match = False
assert not (set(rule.keys()) - {"match", "no-sandbox", "setup"})
if "match" in rule:
assert not (set(rule["match"].keys()) - {"bin", "name"})
if executable and rule["match"].get("bin") == executable:
is_match = True
if args.name and rule["match"].get("name") == args.name:
is_match = True
else:
is_match = True
if is_match:
if rule.get("no-sandbox"):
os.execvp(args.command[0], args.command)
else:
exec_bwrap(rule)
break
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
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"
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/*"
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"]
- match:
name: shell
setup:
- use: [common, private-home]
- 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
- match:
name: none
# Fallback: anything else fall backs to a sandboxed empty home
- setup:
- use: [common, private-home, x11, wayland, pulseaudio, portal]
@jcoffland
Copy link

Hello. I started working on expanding this into a more complete tool. See https://github.com/CauldronDevelopmentLLC/sandbubble It's still a work in progress but it's serving me well. I've changed the config syntax slightly.

@sloonz
Copy link
Author

sloonz commented Jan 16, 2025

Thank you !

@jcoffland
Copy link

Let me know if you have any suggestions.

My direction with this is a little different than I believe you first intended. Instead of using the script with all it's parameters each time you run it, my idea is to create a named sandbox instance on the first run. You can then reenter the sandbox anytime by naming it without specifying the required rules or command to run. I also just took out the ability to pass through arguments to bwrap from the command line. You can still do this via the args rule. I didn't like having two ways to do most things.

Anyway, thanks for starting this. I've been bothered for a long time by the insecurity of running code from vscode, npm and pip on my normal user account. I know there are still possibilities for malicious software to break out given certain configurations but this gives me a lot more confidence running unknown code.

Ideally, every piece of software would be run in a restricted environment. I'm waiting for the OS that offers this. RedHat's efforts with flatpak are interesting but too complicated for quickly wrapping my own applications. I like your approach of applying named sets of rules.

@sloonz
Copy link
Author

sloonz commented Jan 16, 2025

  1. The config file being in ~/sandboxes/<name>/.config is risky. Remove dir: .config and now you have your sandbox config file editable from inside the sandbox
  2. I’ve moved away from having "a single private home" to having .config/sandboxes/<name> (for .config/ in the sandbox), .local/share/sandboxes/<name>, .cache/sandboxes/<name>, mostly for consistency of my backup strategy. Not sure if you want that, but it’s nice to be able just rm -rf ~/.cache/sandboxes once in a while when npm & yarn & go & co have grown a bit too fat
  3. While I’ve probably went too far with passing through all argument to bwrap, there’s still arguments to be made for temporarily applying a preset, and for --bind. Like --bind ~/Downloads ~/Downloads to have a quick sharing space for your sandbox and your non-sandobxed environment. Or a temporary x11 rule for a sandbox not usually needing it, but needing it once.
  4. On the topic of defaults, from reading the homepage I understand that the defaults are written once, at creation. Looking at your code, it is rewritten at each run. If think the homepage behavior makes more sense, from the previous point ?
  5. Why "copy-paste" the rules from ~/.config/sandbubble.yaml instead of re-importing them from that file each time ? I think if I want to change a default rule, I want it to apply to all past sandboxes too.

@jcoffland
Copy link

  1. That's a bit of a blunder. You're right, the config is editable from within the sandbox. I'll fix that.
  2. I like the idea of putting the sandboxes in .config/sandboxes or maybe in .local/sandboxes. It's better than my current setup which adds sandboxes to your home directory.
  3. What if you could have conditional rules? I.e. rules that only apply if an environment variable is set to a value. Then you could do something like WITH_DOWNLOADS=1 sandbubble <name> to enable the bind.
  4. The config is only rewritten if it does not already exist. See the line if not os.path.exists(config):.
  5. I can see arguments for both. I'll add the ability to import other configs and a command line option to choose to either import the global config in a new sandbox or copy the applied rules.

Great feedback. If you have anything more to add, would mind creating issues on my Github? It would be better to track these things there.

I'm going to try to make all these changes today.

@jcoffland
Copy link

I've implemented these changes and a bunch of other features.

  • Renamed sandbubble command to sbx.
  • Added subcommands: create, reconfig, run, list, list-rules, delete, show, edit, help
  • Added new actions: del-arg, ifdef, ifeq, ifneq, proc, dev, tmpfs
  • Switch to $NAME or ${NAME} variable references instead of the Python format.
  • Relocated sandbox configs and home directories.
  • Added rule help strings.
  • Implemented config importing.
  • Dropped rule copying "feature".
  • Updated docs.
  • Probably more I'm forgetting.

This has become a really handy tool, for me at least. Any further input is welcome.

@jcoffland
Copy link

I'm having a problem. If I try to open files inside the sandbox with a browser such as Firefox, I get the system file dialog that shows all my files. However, when I select a file the browser is unable to access it. It seems that xdg-document-portal detects that the calling application is not inside a real snap or flatpak and returns the actually absolute path to the file instead of a remapped path. When xdg-document-portal talks to an application that is in a flatpak it will instead return a path in the form /run/user/$UID/doc/$INODE/<filename>. Which the app can access if /run/user/$UID/doc is mounted.

My sandbox is creating /.flatpak-info and $XDG_RUNTIME_DIR/flatpak-info but this is not enough. I even tried copying the contents of a real /.flatpak-info into my sandbox but that still doesn't work. Apparently xdg-document-portal determines the root fs of the calling process and uses that to determine if it is a real flatpak or snap app. I've searched through the xdg-document-portal code but I've not found a way to trick it into thinking it's talking to a flatpak.

Anyway, this is pretty frustrating because without this the portal's file dialog is useless. Any ideas on how to fix this? Your blog seems to indicate that this at least used to work. Am I missing something?

@sloonz
Copy link
Author

sloonz commented Jan 21, 2025

Not 100% sure it worked before, I can’t remember if I just tried directory listing or actually opening a file.

But yes, I can confirm it doesn’t work now. No idea, sorry.

@jcoffland
Copy link

Ok. Thanks. It looks like it will take more effort to get this working.

@hashkool
Copy link

@jcoffland

It seems you need to share a fake flatpak file twice. I don't know if you tried that?
You do it one time while launching a wrapped xdg-document-portal, and the other time while launching the wrapped program. At least that is what I gather from here: https://www.standingpad.org/posts/2023/08/sandboxing-time/

I also see a bit of delay added between those commands in the referenced blog.

@jcoffland
Copy link

@hashkool That looks like the solution. Thank you! I'll implement this in https://github.com/CauldronDevelopmentLLC/sandbubble

@hashkool
Copy link

@jcoffland Cool! If you want, let us know if you got it working. Btw, the blog author has his complete script here: https://github.com/StandingPadAnimations/sandboxing-scripts/blob/main/vivaldi

@jcoffland
Copy link

It works. See my updated code. https://github.com/CauldronDevelopmentLLC/sandbubble/blob/main/sbx#L334

One small problem with this method is that it shares the whole run/user/$UID/doc with every sandbox. That exposes any files opened by one sandbox to any other running sandboxes. It would probably be better to work out how to get xdg-document-portal to use the run/user/$UID/doc/by-app/<app-id> directory and mount that to run/user/$UID/doc instead.

@jcoffland
Copy link

Now I've implemented the isolation part too.

@hashkool
Copy link

hashkool commented Jan 21, 2025

Nice! 👍

There must be something to achieve that separation, otherwise it would defeat the idea of portals and sandboxing. Won't have time to dig into that right now, but I think this should be documented somewhere...

Edit: I see you got it working already. :-)

@Roger-Roger-debug
Copy link

If you are getting some dbus errors about key files, know that just having an empty /flatpak-info is not enough.

I was trying to sandbox firefox and it complained about a missing Instance field. Looking into the flatpak-info of the flatpak version shows something like this

[Instance]
instance-id=3159835439
instance-path=/home/roger/.var/app/org.mozilla.firefox
...
arch=x86_64
flatpak-version=1.16.0
session-bus-proxy=true
system-bus-proxy=true
devel=true
extra-args=--usb-list=;--usb-list=;

I recreated some of those fields and it seems that you also need to create $XDG_RUNTIME_DIR/.flatpak/$instance-id/bwrapinfo.json with yet more stuff. An example file looks like this

{
    "child-pid": 1501,
    "ipc-namespace": 4026532793,
    "mnt-namespace": 4026532736,
    "net-namespace": 4026532795,
    "pid-namespace": 4026532738
}

I haven't tried implementing that part so far, but hopefully everything will work afterwards.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment