Skip to content

Instantly share code, notes, and snippets.

@sloonz
Last active January 3, 2026 13:26
Show Gist options
  • Select an option

  • Save sloonz/4b7f5f575a96b6fe338534dbc2480a5d to your computer and use it in GitHub Desktop.

Select an option

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]
@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.

@hashkool
Copy link

Thanks. It is a shame that xdg-dbus-proxy is under control of flatpak.

@jcoffland
Copy link

My latest commit to https://github.com/CauldronDevelopmentLLC/sandbubble writes a minimal bwrapinfo.json. I ran into the same problem after a recent upgrade.

I looked at the source for xdg-dbus-proxy. It doesn't seem that complex. I was thinking of rewriting it in Python. Also, it would be nice if the proxy supported popup dialogs that asked the user if they wanted to grant various permissions to the app when the app requested them. Like Android does. I'm not sure if this is feasible. A potential problem is the dbus request timing out before the user can respond.

@Roger-Roger-debug
Copy link

My latest commit to https://github.com/CauldronDevelopmentLLC/sandbubble writes a minimal bwrapinfo.json. I ran into the same problem after a recent upgrade.

You can get the full bwrapinfo from bwrap. Just write the output of --info-fd to a file in the correct location.

I looked at the source for xdg-dbus-proxy. It doesn't seem that complex. I was thinking of rewriting it in Python. Also, it would be nice if the proxy supported popup dialogs that asked the user if they wanted to grant various permissions to the app when the app requested them. Like Android does. I'm not sure if this is feasible. A potential problem is the dbus request timing out before the user can respond.

I don't know the specifics of dbus but that sounds like a cool idea to try out.

@jcoffland
Copy link

I've followed your advice. My latest commit uses bwrap to generate bwrapinfo.json.

@Roger-Roger-debug
Copy link

I have a yubikey that I'd like to use with firefox. I can give access to all of /dev to make it work but would like to avoid that and only what's necessary. However, I'm not entirely sure what files that are and how to add them. At the very least two /dev/hidraw* devices get added when plugging the key in.

I thought I could do something with udev rules and symlinks, but the link doesn't point anywhere in the sandbox since I don't add the hidraw device itself. As the devices get created only after you plug in the key I can't use dev-bind-try since they don't get added retroactively.

Is dev-binding /dev my only option here (how flatpak does it)?

@sloonz
Copy link
Author

sloonz commented Jan 1, 2026

Okay, my subconsious is not entirely satisfied with either my solution or sandbubble, and worked by himself to find a better solution. My subconscious does this sometimes.

I think the degin of "construct args by calling rules/presets and appending an args array" is almost right, but that almost makes fundamentally broken from an usability perspective.

The correct model is object merging, jsonnet way. For example, this pattern I currently use :

  common-permissive:
    - args: [--clearenv, --die-with-parent, --proc, /proc, --dev, /dev, --tmpfs, /tmp]
    - 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
  common:
    - use: [common-permissive]
    - args: [--unshare-pid, --new-session]

Is a bit awkward (common derives from common-permissive ?). With jsonnet (you can try it at https://jsonnet.jdocklabs.co.uk/), we have :

local common = {
  clearEnv: true,
  dieWithParent: true,
  unsharePid: true,
  newSession: true,
};

local commonPermissive = common + {
  unsharePid: false,
  newSession: false,
};

commonPermissive

Nice to have, but the "fundamentally broken from an usability perspective" part is mounts.

If ruleB starts after ruleA, then ruleB will override all ancestor mounts of ruleA, and it is not something we usually want.

Let’s say I want to carry my ~/.config/zprofile in all my sandboxes. So I put in a common rule ro-mount: ~/.config/zprofile. Problem : if a subsequent rule want to keep that zprofile bind mount but use something else for ~/.config (or, god forbid, ~), then this model doesn't allow it. I don’t know about you, but I keep running into this issue. Compare with merging :

local ruleA = {
  mounts+: {
    "~/.config/zprofile": {"ro-bind": true},
  }
};

local ruleB = {
  mounts+: {
    "~/.config": {"bind": "/mnt/shared-zpool/config"},
  }
};

ruleA + ruleB

Then you just have to sort mounts by lexicographic order so ~/.config comes before ~/.config/zprofile, and you’re good with ruleA + ruleB or ruleB + ruleA. Both will work just fine.

So, how would I design my sandbox script today ?

With merging. Possibly by leveraging kcl or nickel or jsonnet ? Core mechanism is just an "enriched bubblewrap invocation" :

type Mount = "tmpfs" | "proc" | "dev" | "mqueue" |
  {bind: string; readOnly?: boolean; allowDev?: boolean; try?: boolean; create?: boolean, skel?: string} |
  // TODO: overlay, symlink, dir...  

interface Sandbox {
  // bwrap flags
  "clear-env": boolean;
  "die-with-parent": boolean;
  "hostname": string;
  // and so on
  
  // additional flags ; I don't think there's a "and so on"
  "restrict-tty": boolean;
  
  mounts: Record<string, Mount>;
  env: Record<string, {valuestring} | {inherits: true, defaultValue?: string} | {unsettrue}>;
  // TODO: dbus-proxy stuff
}

I’ll probably try to create an updated script along those lines sometimes next week.

@hashkool
Copy link

hashkool commented Jan 1, 2026

@sloonz interesting, would love to see if you get anywhere! I guess you will have a mounts+ and a mounts property?

  1. strict union, possibly overwriting a previous mount definition. ==> property mounts
  2. merge union, trying to reconcile conflicts by reordering. ==> property mounts+

Brain dump: Maybe you can have operators too for strict union, and merge union, like + and ~+ respectively. Maybe you want to define those as unary operators instead of the property. I am mentioning that because I might see value in being able to undo a previous flag, like this

local ruleB = {
  mounts:  ~- {
     "~/.config/zprofile" 
  }
}

This wouldn't fly in a json-compatible language, but it helps to demonstrate that if a user can't define it like an operation, they will depend on you to define those as property variants, like unMounts.

It is not needed for properties you can track as booleans, like dieWithParent. I remember --share-net as the odd option to undo any previous --unshare-net, in contrast with the other --unshare-* options.

Sandbubble is nice. One limitation is the scriptability though. Like passing in a variable and using that as a selector inside the config. Yes, that is why any config language eventually wants to become a scripting language, but something more advanced than yaml might give enough runway to avoid that in not-too-complex cases.

All in all, I have a hunch that jsonnet is too limited to provide a real improvement.

@Roger-Roger-debug
Copy link

This reminds me a bit of Nix. The language takes care of merging configs (https://mirosval.sk/blog/2023/nix-merging-configs/) but you also have the option to override a value like this https://discourse.nixos.org/t/what-does-mkdefault-do-exactly/9028/2. Maybe it'd make sense to associate options similarly with values indicating their priority.

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