feat(nixpak): clear env, drop system bus, isolate network (#490)

* fix(nixpak): stop exposing the system D-Bus to apps
* fix(nixpak): bind only the PulseAudio socket
* feat(nixpak): run apps in a new session tied to the launcher
* feat(nixpak): start apps from an allowlisted environment
* feat(nixpak): isolate app networking with pasta
* fix(nixpak): keep Firefox on the host network

Assisted-by: Claude Code (claude-opus-5-5)
This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-10-31 16:39:25 +08:00
1 parent 3a6f03814e
commit 707da35996
5 files changed
+74 -21

No files matched your search

+3 -2
View File
@@ -323,8 +323,9 @@ isolation. Source review and socket metadata—not reads of private credentials
- `ssh -G` reports forwarding for explicit `192.168.*` destinations, but not the sampled `shoryu`
alias or GitHub destination. Do not treat forwarding as enabled for every LAN alias, or remove it
without checking deployment/build workflows.
- Nixpak apps retain deliberate host networking, GUI/audio/portal/GPU access and selected writable
document directories. Firefox additionally retains browserpass/GnuPG integration. These are
- Nixpak apps retain outbound networking (through pasta, without the host's loopback),
GUI/audio/portal/GPU access, selected writable document directories, and a read-only view of the
whole `/nix/store`. Firefox additionally retains browserpass/GnuPG integration. These are
filesystem sandboxes, not blanket credential or management-network isolation.
- WeChat retains networking and X11 through its FHS wrapper. Source arguments alone do not prove
complete runtime isolation. App launch, file chooser, IME, audio/video, GPU and disposable-agent
+11 -1
View File
@@ -21,6 +21,9 @@ let
sloth,
...
}:
let
passwordStore = sloth.concat' sloth.homeDir "/.local/share/password-store";
in
{
app = {
package = firefox;
@@ -34,6 +37,11 @@ let
./modules/custom-policy.nix
];
# Firefox keeps the host network: local development servers and the
# mihomo controller live on 127.0.0.1, and pasta's isolated loopback
# would hide them. The other sandboxed apps do not need host loopback.
pasta.enable = lib.mkForce false;
bubblewrap = {
# To trace all the home files Firefox accesses, you can use the following nushell command:
# just trace-access firefox
@@ -68,7 +76,7 @@ let
# ================ for browserpass extension ===============================
"/etc/gnupg"
(sloth.concat' sloth.homeDir "/.gnupg") # gpg's config
(sloth.concat' sloth.homeDir "/.local/share/password-store") # my secrets
passwordStore # my secrets
(sloth.concat' sloth.runtimeDir "/gnupg") # for access gpg-agent socket
# Match the host's default-app associations and XDG user dirs.
@@ -81,6 +89,8 @@ let
# dconf settings. Not verified as needed; candidate for removal.
(sloth.concat' sloth.xdgConfigHome "/dconf")
];
# browserpass looks for the store here, not at pass's default path.
env.PASSWORD_STORE_DIR = passwordStore;
};
};
};
+2 -2
View File
@@ -197,8 +197,8 @@ in
# Desktop-wide mounts (Wayland/at-spi/gvfsd, the app cache, fontconfig and
# Mesa caches, GTK config, /dev/shm) are provided by ./gui-base.nix, so
# each path is bound exactly once. This is what keeps a read-only bind
# from shadowing a writable one (see the note on the PulseAudio runtime
# dir there). Only app-specific mounts belong in this file.
# from shadowing a writable one. Only app-specific mounts belong in this
# file.
bind.rw = with sloth; [
[
(mkdir appDataDir)
+53 -12
View File
@@ -14,6 +14,9 @@
}:
let
envSuffix = envKey: suffix: sloth.concat' (sloth.env envKey) suffix;
# nixpak cannot pass a variable only when it is set, so an unset one arrives
# empty; only list variables whose readers treat empty as unset.
passHostEnv = names: lib.genAttrs names (name: sloth.envOr name "");
# cursor & icon's theme should be the same as the host's one.
cursorTheme = pkgs.bibata-cursors;
iconTheme = pkgs.papirus-icon-theme;
@@ -43,15 +46,20 @@ in
# Nixpak uses --ro-bind-try / --dev-bind-try: absent host paths are skipped,
# so shared mappings work on Intel/NVIDIA hosts and Apple Silicon alike.
network = lib.mkDefault false;
# setsid keeps the app off the launching terminal (TIOCSTI), and the
# sandbox goes down with the launcher instead of outliving it.
newSession = true;
dieWithParent = true;
# nixpak binds sockets read-only, which is sufficient: connect() only needs
# the socket inode's write bit, and the kernel's read-only-filesystem check
# does not apply to sockets. PulseAudio is the exception (see bind.rw).
# Every app here is Wayland-only, so X11 stays disabled (the nixpak default,
# spelled out for intent) and PipeWire is shared for screensharing.
# does not apply to sockets. Every app here is Wayland-only, so X11 stays
# disabled (the nixpak default, spelled out for intent) and PipeWire is
# shared for screensharing.
sockets = {
wayland = true;
pipewire = true;
pulse = true;
x11 = false;
};
@@ -66,19 +74,11 @@ in
# gvfsd is a directory that holds FUSE mounts, not a socket, so it must
# stay writable.
(envSuffix "XDG_RUNTIME_DIR" "/gvfsd")
# libpulse prepares its runtime dir before connecting (mkdir + chmod 0700),
# so this directory must be writable. nixpak's built-in `sockets.pulse`
# binds it read-only instead, and libpulse then aborts with EROFS when the
# directory is not already 0700 (e.g. pipewire-pulse has not started yet).
# Keep this the only bind of $XDG_RUNTIME_DIR/pulse.
(envSuffix "XDG_RUNTIME_DIR" "/pulse")
];
bind.ro = [
# Sockets only need to be connectable, and connect() works on a read-only
# bind (see the note on `sockets` above).
(envSuffix "XDG_RUNTIME_DIR" "/at-spi/bus")
"/run/dbus"
(sloth.concat' sloth.xdgConfigHome "/gtk-2.0")
(sloth.concat' sloth.xdgConfigHome "/gtk-3.0")
@@ -112,7 +112,23 @@ in
"/tmp"
];
# Start from an empty environment so host secrets (tokens, credentials)
# stay outside; everything the apps need is listed here.
clearEnv = true;
env = {
HOME = sloth.homeDir;
USER = sloth.env "USER";
XDG_RUNTIME_DIR = sloth.runtimeDir;
XDG_CACHE_HOME = sloth.xdgCacheHome;
XDG_CONFIG_HOME = sloth.xdgConfigHome;
XDG_DATA_HOME = sloth.xdgDataHome;
XDG_STATE_HOME = sloth.xdgStateHome;
# Same fallback as nixpak's Wayland socket bind, so they always agree.
WAYLAND_DISPLAY = sloth.envOr "WAYLAND_DISPLAY" "wayland-0";
XDG_SESSION_TYPE = "wayland";
# GTK reads an empty GTK_A11Y as an unknown backend; "0" is its unset value.
GTK_A11Y = sloth.envOr "GTK_A11Y" "0";
XDG_DATA_DIRS = lib.mkForce (
lib.makeSearchPath "share" [
iconTheme
@@ -126,7 +142,32 @@ in
"${cursorTheme}/share/pixmaps"
]
);
};
}
// passHostEnv [
"LANG"
"LANGUAGE"
"LC_ALL"
"LC_ADDRESS"
"LC_COLLATE"
"LC_CTYPE"
"LC_IDENTIFICATION"
"LC_MEASUREMENT"
"LC_MESSAGES"
"LC_MONETARY"
"LC_NAME"
"LC_NUMERIC"
"LC_PAPER"
"LC_TELEPHONE"
"LC_TIME"
"NO_AT_BRIDGE"
"NIXOS_XDG_OPEN_USE_PORTAL"
"NIXOS_OZONE_WL" # QQ's wrapper adds its Wayland flags only when set
"XCURSOR_THEME"
"XCURSOR_SIZE"
"QT_QPA_PLATFORMTHEME"
"QT_WAYLAND_DISABLE_WINDOWDECORATION"
];
};
};
}
+5 -4
View File
@@ -1,8 +1,9 @@
# https://github.com/nixpak/pkgs/blob/master/pkgs/modules/network.nix
{
etc.sslCertificates.enable = true;
bubblewrap = {
bind.ro = [ "/etc/resolv.conf" ];
network = true;
};
bubblewrap.network = true;
# pasta's default isolate mode gives the app its own loopback, so host
# services on 127.0.0.1 are out of reach. Proxying still works because
# mihomo runs in TUN mode, which sees pasta's outbound traffic.
pasta.enable = true;
}