feat(nixpak): unify desktop app sandboxes (#491)

* refactor(nixpak): unify desktop app sandboxes

Move common GUI, portal, and network policy into reusable modules and migrate browsers plus Zoom/WeChat/QQ/Telegram to the same boundary.

Assisted-by: DeepSeek Harness (deepseek-flash)
This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-10-31 22:27:58 +08:00
1 parent 707da35996
commit 818965a11f
35 files changed
+1853 -560

No files matched your search

+19 -7
View File
@@ -323,13 +323,25 @@ 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 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
probes are still needed before narrowing mounts or sockets.
- All nine desktop wrappers use shared nixpak policies: cleared environment, filtered session D-Bus
and selected mounts. The [permission inventory](hardening/README.md#app-permissions) lists each
app's file, network, credential and media grants.
- Only Firefox/Chromium share host networking. Other apps isolate loopback with pasta but can still
reach the LAN/Internet. Browser media and browserpass are enabled only for
Firefox/Chromium/Chrome; browserpass exposes password-store/GPG data and agent operations.
- Nixpak supplements Chromium's renderer sandbox, which remains enabled. Zoom/WeChat retain their
FHS runtimes inside the outer boundary; inner mounts cannot recover hidden host resources.
- Desktop access has limits: GPU drivers remain exposed, PulseAudio permits recording, read-only
sockets permit agent operations, and Zoom/WeChat's X11 connections have shared-display trust.
Zoom's 7.2.1 client receives only `/dev/video0` and `/dev/video1` because it captures the webcam
directly via V4L2; no other app gets raw camera nodes and no raw PipeWire access is added.
Chromium-family browsers use only the app-scoped Secret portal for v12 cookie encryption; no
browser receives broad GNOME Keyring authority. Firefox uses its own NSS storage.
- **Rollout note (WA-045):** v12 is verified on a disposable profile, but if the portal is
unavailable Chromium may fall back to weaker v10 storage; portal initialization is monitored.
- Runtime evidence is partial: Brave/Edge headless export times out; interactive login, media, IME
and file workflows require acceptance. See the
[validation records](hardening/README.md#validation).
#### Agent-first diagnostics by host role
+2 -1
View File
@@ -83,10 +83,11 @@ A known limitation with no fix yet.
An unfinished feature or gap.
| ID | What & where | Why | Removal condition | Added | Revisit | Status |
| ------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | ---------- | ------------------------ | ------ |
| ------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------ | ---------- |
| WA-013 | NixOS tests — `outputs/README.md` | full-host tests still rebuild custom packages (no private cache) and need the host ssh key to decrypt agenix; the shared-firewall security-exporters VM test now runs in CI (KVM); `outputs/x86_64-linux/nixos-tests/idols-ruby.nix` is exposed as `packages.x86_64-linux.ruby-nixos-tests` but is not in `checks` or CI | Resolve when a private cache exists and tests can run per-service | 2024-03-13 | quarterly | active |
| WA-027 | NFS golden store `no_root_squash` — `hosts/12kingdoms-youko/nfs-golden.nix` | the existing PVC subdirectories are root-owned, so enabling `root_squash` would break running workloads until they are migrated; the CSI node plugin is hostNetwork and sources from the allowlisted node IPs | Drop when the PVC subdirectories are migrated and `root_squash` is enabled | 2026-10-07 | next maintenance window | active |
| WA-044 | transmission AppArmor lock permission — `hosts/12kingdoms-youko/homelab-services/transmission.nix` | nixpkgs' transmission 4.1.4 profile grants `rw` on the daemon's config dir but not `k`, while 4.1.4 takes an fcntl lock on `~/.config/transmission-daemon/lock`; the daemon exits with "Another process is already using". A `local/bin.transmission-daemon` include adds `rwk` for that lock file only | Drop once the nixpkgs profile includes the lock permission | 2026-10-31 | on the next nixpkgs bump | active |
| WA-045 | Chromium cookie crypto — `hardening/nixpaks/modules/chromium-browser.nix` | Chromium-family browsers use the app-scoped Secret portal and write v12 cookies. If the portal is unavailable, Chromium may fall back to weak v10 storage. Brave/Edge headless export also times out. | Drop once the v10 fallback is eliminated and the Brave/Edge headless checks pass; monitor portal initialization and interactive browser checks | 2026-10-31 | on Chromium updates | active gap |
## Related
+63 -95
View File
@@ -1,111 +1,79 @@
# Linux Hardening
> Work in progress.
Deployed and interactively validated on `ai` (2026-10-31). Policy and limits:
[SECURITY.md](../SECURITY.md).
Application sandboxing (nixpak/bubblewrap) and Linux hardening knowledge for this repository. For
the repository security policy, architecture and audit status, see [`SECURITY.md`](../SECURITY.md);
for temporary exceptions and known gaps, see [`WORKAROUNDS.md`](../WORKAROUNDS.md).
## Shared boundaries
## Goal
- Cleared environment; filtered session D-Bus; private `/dev/shm`; per-app `/tmp`. Chromium-family
browsers share an app-specific `/tmp` so external URLs reuse one browser instance.
- Read-only `/nix/store`; GPU and display; fonts and themes; LAN and Internet.
- Portals for file chooser, URI opening, notifications, input methods and document exports.
- No whole host home, system D-Bus, SSH agent, USB/serial, raw camera or raw PipeWire socket.
- Host loopback only for Firefox/Chromium; all other apps use pasta-isolated loopback.
- **System Level**: Protect critical files from being accessed by untrusted applications.
1. Such as browser cookies, SSH keys, etc.
- **Per-App Level**: Prevent untrusted applications(such as closed-source apps) from:
1. Accessing files they shouldn't.
- Such as a malicious application accessing your browser's cookies, SSH keys, etc.
1. Accessing the network when they don't need to.
1. Accessing hardware devices they don't need.
## App permissions
## Current Structure
`Media` means audio playback, microphone, Camera and ScreenCast portals. Writable paths are listed
explicitly; other files use the document portal. Every app also has private writable state.
### 1. **System Level**
| App | Network | Writable paths | Grants |
| ------------ | ------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Firefox | Host loopback | `~/.mozilla`, personal files, Trash, Firefox artwork | Media, browserpass, MPRIS, idle |
| Chromium | Host loopback | `~/.config/chromium`, personal files | Media, browserpass, MPRIS, idle, v12 Secret |
| Chrome | Isolated | `~/.config/google-chrome`, Downloads | Media, browserpass, MPRIS, idle, v12 Secret |
| Brave Origin | Isolated | `~/.config/BraveSoftware/Brave-Origin`, Downloads | v12 Secret |
| Edge | Isolated | `~/.config/microsoft-edge`, Downloads | v12 Secret |
| QQ | Isolated | Personal files, private app state | Media, notifications, tray, MPRIS, idle, accessibility |
| Telegram | Isolated | Personal files, private app state | Media, notifications, tray, MPRIS, idle, accessibility, Ayatana indicators |
| Zoom | Isolated | `~/.zoom`, `zoomus.conf`, `zoom.conf`, Downloads, `Documents/Zoom`, private app state | Media, X11/XWayland, FHS, SNI tray, idle, `/dev/video0-1`, read-only `/sys/class/drm` |
| WeChat | Isolated | `Documents/WeChat_Data`, its HOME mapping and `xwechat_files` link, private app state | Media, X11/xcb, FHS, SNI tray |
- **AppArmor** (`apparmor/`): AppArmor profiles and configuration
- **Kernel & process hardening** (`kernel-hardening.nix`): sysctl baseline and unused-module deny
list; rationale and verification live in [`SECURITY.md`](../SECURITY.md).
All browsers get read-only NSS certificates, printing, native notifications and accessibility.
### 2. **Per-App Level**
## Browser secrets
- **Nixpak** (`nixpaks/`): Bubblewrap-based sandboxing for applications
- Firefox configuration
- QQ (Chinese messaging app) configuration
- Telegram Desktop configuration
- Modular system with reusable components
- **Bubblewrap** (`bwraps/`): Direct bubblewrap configurations
- WeChat sandboxing configuration
- Chromium family: app-scoped Secret portal only. `SecretPortalKeyProviderUseForEncryption` writes
v12 cookies; no `org.freedesktop.secrets` or broad keyring access.
- If the Secret portal is unavailable, Chromium can fall back to v10. Monitor
`Local State.os_crypt.portal.prev_init_success`.
- Firefox uses NSS: `key4.db` and `logins.json` for logins, `cookies.sqlite` for cookies. It has no
Secret portal or keyring grant. A Primary Password protects saved logins, not cookies.
- browserpass is separate: it exposes the password store and GPG-agent socket, not cookie
encryption.
- Bookmarks, history, localStorage, IndexedDB and extension data remain readable by the browser.
nixpak isolates them from other apps, not from browser compromise.
## Current Implementation Status
## Limits
| Component | Status | Notes |
| ----------------- | --------- | ----------------------------- |
| AppArmor Profiles | 🚧 WIP | Basic structure in place |
| Nixpak Firefox | ✅ Active | Firefox sandboxing via nixpak |
| Nixpak QQ | ✅ Active | QQ application sandboxing |
| Nixpak Telegram | ✅ Active | Telegram Desktop sandboxing |
| Bubblewrap WeChat | ✅ Active | WeChat specific sandboxing |
- X11: Zoom and WeChat have shared-display trust; portal permissions cannot enforce portal-only
capture.
- PulseAudio grants recording authority. Read-only GPG sockets still permit signing and decryption.
- pasta isolates loopback, not LAN access. Chromecast discovery is unverified.
- Brave/Edge renderer and permission checks pass, but headless page export still times out.
## Directory Structure
## Validation
```
hardening/
├── README.md
├── kernel-hardening.nix # sysctl baseline + unused-module deny list
├── apparmor/ # AppArmor security profiles
│ └── default.nix
├── bwraps/ # Direct bubblewrap configurations
│ ├── default.nix
│ └── wechat.nix
└── nixpaks/ # Nixpak application sandboxing
├── default.nix
├── firefox.nix
├── qq.nix
├── telegram-desktop.nix
└── modules/ # Reusable nixpak modules
├── custom-policy.nix
├── gui-base.nix
└── network.nix
```
- Passed: `just fmt`, `just test`, `ai-niri` build and `shoukei-niri` eval.
- Deployed on `ai`; interactive startup and tray checks passed for Chrome, Firefox, QQ, Telegram,
Zoom and WeChat.
- Chromium/Chrome: renderer sandbox, mounts, rendering and disposable-profile persistence passed.
Chromium reaches host loopback; Chrome cannot.
- Firefox: disposable headless screenshot passed.
- Brave/Edge: renderer and permission checks passed; headless page export timed out.
- Evidence: [browsers](tests/browser-runtime.md),
[Firefox/QQ/Telegram](tests/unified-app-runtime.md), [Zoom](tests/zoom-runtime.md),
[WeChat](tests/wechat-runtime.md).
## System Hardening
## Code map
- Apparmor: [roddhjav/apparmor.d](https://github.com/roddhjav/apparmor.d)
- https://gitlab.com/apparmor/apparmor/-/wikis/Documentation
- AppArmor.d is a set of over 1500 AppArmor profiles whose aim is to confine most Linux based
applications and processes.
- But its profiles assume an FHS filesystem, so most of them never match the Nix store paths a
NixOS system actually executes.
- Apparmor on NixOS Roadmap:
- https://discourse.nixos.org/t/apparmor-on-nixos-roadmap/57217
- https://github.com/LordGrimmauld/aa-alias-manager
- SELinux: too complex, not recommended for personal use.
## Application Sandboxing
- [Bubblewrap](https://github.com/containers/bubblewrap):
[nixpak](https://github.com/nixpak/nixpak), more secure than firejail, but no batteries included.
- NixOS's FHSEnv is implemented using bubblewrap by default.
- [Firejail](https://github.com/netblue30/firejail/tree/master/etc): A SUID security sandbox with
hundreds of security profiles for many common applications in the default installation.
- https://wiki.nixos.org/wiki/Firejail
- Firejail needs SUID to work, which is considered a security risk -
[Does firejail improve the security of my system?](https://github.com/netblue30/firejail/discussions/4601)
- [Systemd/Hardening](https://wiki.nixos.org/wiki/Systemd/Hardening): Systemd also provides some
sandboxing features.
## NOTE
**Running untrusted code is never safe, kernel hardening & sandboxing cannot change this**; run
untrusted code in a VM on an isolated network. The threat model lives in
[`SECURITY.md`](../SECURITY.md).
## References
- [Harden your NixOS workstation - dataswamp](https://dataswamp.org/~solene/2022-01-13-nixos-hardened.html)
- [Linux Insecurities - Madaidans](https://madaidans-insecurities.github.io/linux.html)
- [Sandboxing all programs by default - NixOS Discourse](https://discourse.nixos.org/t/sandboxing-all-programs-by-default/7792)
- [Paranoid NixOS Setup - xeiaso](https://xeiaso.net/blog/paranoid-nixos-2021-07-18/)
- [nix-mineral](https://github.com/cynicsketch/nix-mineral): NixOS module for convenient system
hardening.
- apparmor configs:
- https://github.com/zramctl/dotfiles/blob/4fe177f6984154960942bb47d5a375098ec6ed6a/modules/nixos/security/apparmor.nix#L4
- https://git.grimmauld.de/Grimmauld/grimm-nixos-laptop/src/branch/main/hardening
- [nixpaks/](nixpaks/): wrappers and Home Manager package overlay.
- [desktop-base.nix](nixpaks/modules/desktop-base.nix): shared runtime and XDG storage.
- [portal-groups.nix](nixpaks/modules/portal-groups.nix),
[desktop-integration.nix](nixpaks/modules/desktop-integration.nix): portal and desktop service
grants.
- [browser-permissions.nix](nixpaks/modules/browser-permissions.nix),
[chromium-browser.nix](nixpaks/modules/chromium-browser.nix): browser scopes and launcher.
- [bwraps/wechat.nix](bwraps/wechat.nix): WeChat FHS and data setup.
- [kernel-hardening.nix](kernel-hardening.nix), [apparmor/](apparmor/): kernel sysctl and AppArmor
profiles.
+16
View File
@@ -0,0 +1,16 @@
{
callPackage,
mkNixPak,
brave-origin,
...
}:
(callPackage ./modules/chromium-browser.nix { inherit mkNixPak; }) {
package = brave-origin;
appId = "com.brave.Origin";
profile = "BraveSoftware/Brave-Origin";
command = "brave-origin";
desktopName = "Brave Origin";
desktopFile = "brave-origin";
browserpass = false;
secretPortal = true;
}
+19
View File
@@ -0,0 +1,19 @@
{
callPackage,
mkNixPak,
chromium,
...
}:
(callPackage ./modules/chromium-browser.nix { inherit mkNixPak; }) {
package = chromium;
appId = "org.chromium.Chromium";
profile = "chromium";
command = "chromium";
desktopName = "Chromium";
desktopFile = "chromium-browser";
browserpass = true;
media = true;
hostLoopback = true;
personalFiles = true;
secretPortal = true;
}
+6
View File
@@ -21,6 +21,12 @@ in
qq = wrapper pkgs-master ./qq.nix;
telegram-desktop = wrapper super ./telegram-desktop.nix;
firefox = wrapper super ./firefox.nix;
google-chrome = wrapper super ./google-chrome.nix;
chromium = wrapper super ./chromium.nix;
brave-origin = wrapper super ./brave-origin.nix;
microsoft-edge = wrapper super ./microsoft-edge.nix;
zoom = wrapper super ./zoom.nix;
wechat = wrapper super ./wechat.nix;
};
})
];
+8 -34
View File
@@ -21,9 +21,6 @@ let
sloth,
...
}:
let
passwordStore = sloth.concat' sloth.homeDir "/.local/share/password-store";
in
{
app = {
package = firefox;
@@ -32,14 +29,19 @@ let
flatpak.appId = appId;
imports = [
./modules/gui-base.nix
./modules/browser-permissions.nix
./modules/network.nix
./modules/custom-policy.nix
];
browserPermissions = {
media = true;
personalFiles = true;
browserpass = true;
};
# 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.
# would hide them. Chromium uses the same personal-browser policy.
pasta.enable = lib.mkForce false;
bubblewrap = {
@@ -55,16 +57,6 @@ let
# path over MPRIS. Without this bind it lands under the sandboxed
# XDG_DATA_HOME and Noctalia (outside) cannot read it.
(sloth.mkdir (sloth.concat' sloth.homeDir "/.local/share/firefox-mpris"))
# Files deleted from Firefox go to the real trash instead of a
# private one under the sandboxed XDG_DATA_HOME.
(sloth.mkdir (sloth.concat' sloth.homeDir "/.local/share/Trash"))
sloth.xdgDocumentsDir
sloth.xdgDownloadDir
sloth.xdgMusicDir
sloth.xdgVideosDir
sloth.xdgPicturesDir
];
bind.ro = [
"/sys/bus/pci"
@@ -72,25 +64,7 @@ let
"${config.app.package}/lib/firefox"
"/app/etc/firefox"
]
# ================ for browserpass extension ===============================
"/etc/gnupg"
(sloth.concat' sloth.homeDir "/.gnupg") # gpg's config
passwordStore # my secrets
(sloth.concat' sloth.runtimeDir "/gnupg") # for access gpg-agent socket
# Match the host's default-app associations and XDG user dirs.
(sloth.concat' sloth.homeDir "/.config/mimeapps.list")
(sloth.concat' sloth.homeDir "/.config/user-dirs.dirs")
# Shared NSS client-certificate store (imported outside Firefox).
(sloth.concat' sloth.homeDir "/.pki/nssdb")
# 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;
};
};
};
+17
View File
@@ -0,0 +1,17 @@
{
callPackage,
mkNixPak,
google-chrome,
...
}:
(callPackage ./modules/chromium-browser.nix { inherit mkNixPak; }) {
package = google-chrome;
appId = "com.google.Chrome";
profile = "google-chrome";
command = "google-chrome";
desktopName = "Google Chrome";
desktopFile = "google-chrome";
browserpass = true;
media = true;
secretPortal = true;
}
+17
View File
@@ -0,0 +1,17 @@
{
callPackage,
mkNixPak,
microsoft-edge,
...
}:
(callPackage ./modules/chromium-browser.nix { inherit mkNixPak; }) {
package = microsoft-edge;
appId = "com.microsoft.Edge";
profile = "microsoft-edge";
command = "microsoft-edge";
desktopName = "Microsoft Edge";
desktopFile = "microsoft-edge";
browserpass = false;
secretPortal = true;
privateWindowFlag = "--inprivate";
}
@@ -0,0 +1,64 @@
# Shared browser capabilities. Network topology and application profiles are
# selected by the app; personal files, browserpass, Secret portal and media are explicit opt-ins.
{
config,
lib,
sloth,
...
}:
let
cfg = config.browserPermissions;
passwordStore = sloth.concat' sloth.homeDir "/.local/share/password-store";
in
{
imports = [ ./desktop-base.nix ];
options.browserPermissions = {
media = lib.mkEnableOption "audio playback/recording, camera and desktop screen sharing";
personalFiles = lib.mkEnableOption "personal Documents, media directories and Trash";
browserpass = lib.mkEnableOption "browserpass access to the password store and GPG agent";
secretPortal = lib.mkEnableOption "the app-scoped Secret portal for v12 cookie encryption";
};
config = {
portalGroups = {
fileChooser = true;
openUri = true;
notifications = true;
screenCast = cfg.media;
camera = cfg.media;
print = true;
inhibit = cfg.media;
inputMethod = true;
secret = cfg.secretPortal;
};
desktopIntegration = {
notifications = true;
mpris = cfg.media;
accessibility = true;
};
bubblewrap = {
sockets.pulse = cfg.media;
bind.rw = [
sloth.xdgDownloadDir
]
++ lib.optionals cfg.personalFiles [
sloth.xdgDocumentsDir
sloth.xdgMusicDir
sloth.xdgVideosDir
sloth.xdgPicturesDir
(sloth.mkdir (sloth.concat' sloth.homeDir "/.local/share/Trash"))
];
bind.ro = [
(sloth.concat' sloth.homeDir "/.config/mimeapps.list")
(sloth.concat' sloth.homeDir "/.config/user-dirs.dirs")
(sloth.concat' sloth.homeDir "/.pki/nssdb")
]
++ lib.optionals cfg.browserpass [
"/etc/gnupg"
(sloth.concat' sloth.homeDir "/.gnupg")
passwordStore
(sloth.concat' sloth.runtimeDir "/gnupg")
];
env = lib.optionalAttrs cfg.browserpass { PASSWORD_STORE_DIR = passwordStore; };
};
};
}
@@ -0,0 +1,121 @@
# Chromium-family wrapper: nixpak adds filesystem, D-Bus and network boundaries;
# the upstream Chromium renderer sandbox remains enabled.
{
lib,
mkNixPak,
buildEnv,
makeDesktopItem,
runCommandLocal,
makeWrapper,
...
}:
{
package,
appId,
profile,
command,
desktopName,
desktopFile ? command,
browserpass ? false,
media ? false,
secretPortal ? false,
hostLoopback ? false,
personalFiles ? false,
privateWindowFlag ? "--incognito",
}:
let
# Chromium 156 disables PipeWire camera capture by default; media apps need
# the portal FD path instead of raw video devices.
# Keep the Wayland feature from nixpkgs: the last --enable-features wins.
enabledFeatures = lib.concatStringsSep "," (
[ "WaylandWindowDecorations" ]
++ lib.optional media "WebRtcPipeWireCamera"
++ lib.optional secretPortal "SecretPortalKeyProviderUseForEncryption"
);
usernsPackage =
runCommandLocal "${package.name}-userns"
{
nativeBuildInputs = [ makeWrapper ];
}
''
mkdir -p $out/bin
makeWrapper ${package}/bin/${command} $out/bin/${command} \
--add-flags "--enable-features=${enabledFeatures}"
'';
wrapped = mkNixPak {
config =
{ sloth, ... }:
{
imports = [
./browser-permissions.nix
./network.nix
];
app = {
package = usernsPackage;
binPath = "bin/${command}";
};
flatpak = { inherit appId; };
# Firefox/Chromium need host loopback for local services; Chrome and the rest use pasta.
pasta.enable = lib.mkForce (!hostLoopback);
pasta.mode = "isolate";
browserPermissions = {
inherit
browserpass
personalFiles
media
secretPortal
;
};
bubblewrap.bind.rw = [
(sloth.mkdir (sloth.concat' sloth.homeDir "/.config/${profile}"))
];
};
};
exe = "${wrapped.config.script}/bin/${command}";
# Copy desktop assets only; the full package would expose unwrapped binaries.
assets = runCommandLocal "${command}-desktop-assets" { } ''
mkdir -p $out/share
for directory in icons pixmaps; do
if [ -d ${package}/share/$directory ]; then
ln -s ${package}/share/$directory $out/share/$directory
fi
done
'';
in
buildEnv {
inherit (wrapped.config.script) name meta passthru;
paths = [
wrapped.config.script
assets
(makeDesktopItem {
name = appId;
inherit desktopName;
genericName = "Web Browser";
exec = "${exe} %U";
icon = desktopFile;
terminal = false;
startupNotify = true;
categories = [
"Network"
"WebBrowser"
];
mimeTypes = [
"text/html"
"application/xhtml+xml"
"x-scheme-handler/http"
"x-scheme-handler/https"
];
actions = {
new-window = {
name = "New Window";
exec = "${exe} --new-window %U";
};
new-private-window = {
name = "New Private Window";
exec = "${exe} ${privateWindowFlag} %U";
};
};
extraConfig.X-Flatpak = appId;
})
];
}
-228
View File
@@ -1,228 +0,0 @@
# Custom, repo-local policy for the sandboxed desktop apps (Firefox, QQ,
# Telegram). Unlike ./gui-base.nix, which forks nixpak/pkgs's gui-base and is
# meant to track upstream, this file is hand-written for this repository: it
# grants the D-Bus surface those apps need (portals, MPRIS, tray, input
# methods, a11y), redirects the XDG dirs Flatpak-style, and adds the remaining
# document/device mounts. Keep only app-specific mounts in the app files.
#
# Adapted from:
# - https://github.com/mnixry/nixos-config/blob/74913c2b90d06e31170bbbaa0074f915721da224/desktop/packages/nixpaks-common.nix
# - https://github.com/Kraftland/portable/blob/09c4a4227538a3f42de208a6ecbdc938ac9c00dd/portable.sh
# - https://flatpak.github.io/xdg-desktop-portal/docs/api-reference.html
{
lib,
sloth,
config,
...
}:
let
inherit (config.flatpak) appId;
in
{
config = {
# list all dbus services:
# ls -al /run/current-system/sw/share/dbus-1/services/
# ls -al /etc/profiles/per-user/ryan/share/dbus-1/services/
dbus = {
# `--see`: The bus name can be enumerated by the application.
# `--talk`: The application can send messages to, and receive replies and signals from, the bus name.
# `--own`: The application can own the bus name
policies = {
"${appId}" = "own";
"${appId}.*" = "own";
"org.freedesktop.DBus" = "talk";
"ca.desrt.dconf" = "talk";
"org.freedesktop.appearance" = "talk";
"org.freedesktop.appearance.*" = "talk";
}
// (builtins.listToAttrs (
map (id: lib.nameValuePair "org.kde.StatusNotifierItem-${toString id}-1" "own") (
lib.lists.range 2 29
)
))
// {
# --- MPRIS Media Control ---
# Allows the app to register as a media player. These are derived from the appID.
"org.mpris.MediaPlayer2.${appId}" = "own";
"org.mpris.MediaPlayer2.${appId}.*" = "own";
"org.mpris.MediaPlayer2.${lib.lists.last (lib.strings.splitString "." appId)}" = "own";
"org.mpris.MediaPlayer2.${lib.lists.last (lib.strings.splitString "." appId)}.*" = "own";
# --- General Desktop Integration ---
"com.canonical.AppMenu.Registrar" = "talk"; # For Ubuntu AppMenu
"org.freedesktop.FileManager1" = "talk";
"org.freedesktop.Notifications" = "talk";
"org.kde.StatusNotifierWatcher" = "talk";
"org.gnome.Shell.Screencast" = "talk";
# --- Accessibility (a11y) 无障碍服务 ---
"org.a11y.Bus" = "see";
# --- Portal Access ---
# "org.freedesktop.portal.*" = "talk";
"org.freedesktop.portal.Documents" = "talk";
"org.freedesktop.portal.FileTransfer" = "talk";
"org.freedesktop.portal.FileTransfer.*" = "talk";
"org.freedesktop.portal.Notification" = "talk";
"org.freedesktop.portal.OpenURI" = "talk";
"org.freedesktop.portal.OpenURI.OpenFile" = "talk";
"org.freedesktop.portal.OpenURI.OpenURI" = "talk";
"org.freedesktop.portal.Print" = "talk";
"org.freedesktop.portal.Request" = "see";
# --- Input Method Portals ---
"org.freedesktop.portal.Fcitx" = "talk";
"org.freedesktop.portal.Fcitx.*" = "talk";
"org.freedesktop.portal.IBus" = "talk";
"org.freedesktop.portal.IBus.*" = "talk";
};
# '--call' rules permit specific method calls on D-Bus interfaces.
rules.call = {
# --- Accessibility (a11y) 无障碍服务 ---
"org.a11y.Bus" = [
"org.a11y.Bus.GetAddress@/org/a11y/bus"
"org.freedesktop.DBus.Properties.Get@/org/a11y/bus"
];
# --- General Portal Rules ---
"org.freedesktop.FileManager1" = [ "*" ];
"org.freedesktop.Notifications.*" = [ "*" ];
"org.freedesktop.portal.Documents" = [ "*" ];
"org.freedesktop.portal.FileTransfer" = [ "*" ];
"org.freedesktop.portal.FileTransfer.*" = [ "*" ];
"org.freedesktop.portal.Fcitx" = [ "*" ];
"org.freedesktop.portal.Fcitx.*" = [ "*" ];
"org.freedesktop.portal.IBus" = [ "*" ];
"org.freedesktop.portal.IBus.*" = [ "*" ];
"org.freedesktop.portal.Notification" = [ "*" ];
"org.freedesktop.portal.OpenURI" = [ "*" ];
"org.freedesktop.portal.OpenURI.OpenFile" = [ "*" ];
"org.freedesktop.portal.OpenURI.OpenURI" = [ "*" ];
"org.freedesktop.portal.Print" = [ "*" ];
"org.freedesktop.portal.Request" = [ "*" ];
# --- Main Desktop Portal Interface ---
# A comprehensive list of permissions for interacting with the desktop environment.
"org.freedesktop.portal.Desktop" = [
# Properties & Settings
"org.freedesktop.DBus.Properties.GetAll"
"org.freedesktop.DBus.Properties.Get@/org/freedesktop/portal/desktop"
"org.freedesktop.portal.Session.Close"
"org.freedesktop.portal.Settings.ReadAll"
"org.freedesktop.portal.Settings.Read"
"org.freedesktop.portal.Account.GetUserInformation"
# Network & Proxy
"org.freedesktop.portal.NetworkMonitor"
"org.freedesktop.portal.NetworkMonitor.*"
"org.freedesktop.portal.ProxyResolver.Lookup"
"org.freedesktop.portal.ProxyResolver.Lookup.*"
# Screenshot / Screen Capture & Sharing
"org.freedesktop.portal.ScreenCast"
"org.freedesktop.portal.ScreenCast.*"
"org.freedesktop.portal.Screenshot"
"org.freedesktop.portal.Screenshot.Screenshot"
# Device Access(Camera / USB)
"org.freedesktop.portal.Camera"
"org.freedesktop.portal.Camera.*"
"org.freedesktop.portal.Usb"
"org.freedesktop.portal.Usb.*"
# Remote Desktop
"org.freedesktop.portal.RemoteDesktop"
"org.freedesktop.portal.RemoteDesktop.*"
# File Operations
"org.freedesktop.portal.Documents"
"org.freedesktop.portal.Documents.*"
"org.freedesktop.portal.FileChooser"
"org.freedesktop.portal.FileChooser.*"
"org.freedesktop.portal.FileTransfer"
"org.freedesktop.portal.FileTransfer.*"
# Notifications & Printing
"org.freedesktop.portal.Notification"
"org.freedesktop.portal.Notification.*"
"org.freedesktop.portal.Print"
"org.freedesktop.portal.Print.*"
# Open/Launch Handlers
"org.freedesktop.portal.OpenURI"
"org.freedesktop.portal.OpenURI.*"
"org.freedesktop.portal.Email.ComposeEmail"
# Input Methods
"org.freedesktop.portal.Fcitx"
"org.freedesktop.portal.Fcitx.*"
"org.freedesktop.portal.IBus"
"org.freedesktop.portal.IBus.*"
# Secrets (Keyring)
"org.freedesktop.portal.Secret"
"org.freedesktop.portal.Secret.RetrieveSecret"
# Get/Update GlobalShortcuts
# "org.freedesktop.portal.GlobalShortcuts"
# "org.freedesktop.portal.GlobalShortcuts.*"
# -- get the user's location
# "org.freedesktop.portal.Location"
# "org.freedesktop.portal.Location.*"
# -- inhibit the user session from ending, suspending, idling or getting switched away.
"org.freedesktop.portal.Inhibit"
"org.freedesktop.portal.Inhibit.*"
# Generic Request Fallback
"org.freedesktop.portal.Request"
];
};
# 'broadcast' rules permit receiving signals from D-Bus names.
rules.broadcast = {
"org.freedesktop.portal.*" = [ "@/org/freedesktop/portal/*" ];
};
args = [
"--filter"
"--sloppy-names"
];
};
etc.sslCertificates.enable = true;
bubblewrap = {
network = lib.mkDefault true;
# 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. Only app-specific mounts belong in this
# file.
bind.rw = with sloth; [
[
(mkdir appDataDir)
xdgDataHome
]
[
(mkdir appConfigDir)
xdgConfigHome
]
# FileChooser may return a Document Portal path even for a directly mapped XDG directory;
# keep the FUSE mount writable so SaveFile can create the selected file.
(sloth.concat' sloth.runtimeDir "/doc")
(sloth.concat' sloth.runtimeDir "/dconf")
(sloth.concat' sloth.xdgCacheHome "/mesa_shader_cache_db")
(sloth.concat' sloth.xdgCacheHome "/radv_builtin_shaders")
];
bind.ro = [
(sloth.concat' sloth.xdgConfigHome "/kdeglobals")
(sloth.concat' sloth.xdgConfigHome "/dconf")
];
bind.dev = map (id: "/dev/video${toString id}") (lib.lists.range 0 9);
};
};
}
+161
View File
@@ -0,0 +1,161 @@
# Shared desktop runtime; portal and native services are opt-in. No host GVfs,
# shared caches, raw camera or PipeWire socket.
{
config,
lib,
pkgs,
sloth,
...
}:
{
imports = [
./portal-groups.nix
./desktop-integration.nix
];
gpu = {
enable = lib.mkDefault true;
provider = "nixos";
bundlePackage = pkgs.mesa.drivers;
};
fonts = {
enable = true;
# Preserve nixpak's default coverage and add Chinese/Japanese/Korean glyphs.
fonts = with pkgs; [
cantarell-fonts
dejavu_fonts
liberation_ttf
gyre-fonts
source-sans
source-code-pro
noto-fonts-color-emoji
noto-fonts-cjk-sans
];
};
locale.enable = true;
dbus = {
policies = {
"${config.flatpak.appId}" = "own";
"${config.flatpak.appId}.*" = "own";
};
rules.call."org.freedesktop.portal.Desktop" = [
"org.freedesktop.DBus.Properties.Get@/org/freedesktop/portal/desktop"
"org.freedesktop.DBus.Properties.GetAll@/org/freedesktop/portal/desktop"
"org.freedesktop.portal.Settings.Read@/org/freedesktop/portal/desktop"
"org.freedesktop.portal.Settings.ReadAll@/org/freedesktop/portal/desktop"
"org.freedesktop.portal.Request.Close@/org/freedesktop/portal/desktop/request/*"
"org.freedesktop.portal.Session.Close@/org/freedesktop/portal/desktop/session/*"
];
rules.broadcast."org.freedesktop.portal.Desktop" = [
"org.freedesktop.portal.Settings.SettingChanged@/org/freedesktop/portal/desktop"
"@/org/freedesktop/portal/desktop/*"
];
};
bubblewrap = {
network = lib.mkDefault false;
newSession = true;
dieWithParent = true;
clearEnv = true;
sockets = {
wayland = true;
x11 = lib.mkDefault false;
pulse = lib.mkDefault true;
pipewire = false;
};
bind.rw = with sloth; [
[
(mkdir appCacheDir)
xdgCacheHome
]
[
(mkdir appConfigDir)
xdgConfigHome
]
[
(mkdir appDataDir)
xdgDataHome
]
[
(mkdir (concat' appDir "/state"))
xdgStateHome
]
(concat' runtimeDir "/doc")
# Chrome/Firefox/Telegram/etc. use /tmp for single-instance IPC. Share
# this directory across launches of the same app only; app IDs differ.
[
(mkdir (concat' runtimeDir "/nixpak-${config.flatpak.appId}-tmp"))
"/tmp"
]
];
bind.ro = [
"/etc/localtime"
"/etc/zoneinfo"
(sloth.concat' sloth.xdgConfigHome "/gtk-2.0")
(sloth.concat' sloth.xdgConfigHome "/gtk-3.0")
(sloth.concat' sloth.xdgConfigHome "/gtk-4.0")
(sloth.concat' sloth.xdgConfigHome "/kdeglobals")
"/sys/devices/platform"
]
++ lib.optionals config.gpu.enable [
"/etc/egl"
"/etc/static/egl"
];
# nixpak's NixOS GPU provider maps DRM nodes, but NVIDIA also needs its
# dedicated interfaces. Missing nodes are skipped by --dev-bind-try.
bind.dev = lib.optionals config.gpu.enable [
"/dev/nvidia0"
"/dev/nvidiactl"
"/dev/nvidia-modeset"
"/dev/nvidia-uvm"
];
tmpfs = [
"/dev/shm"
];
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;
XDG_SESSION_TYPE = "wayland";
# Selects the Wayland path for portal clients.
XDG_CURRENT_DESKTOP = sloth.envOr "XDG_CURRENT_DESKTOP" "niri";
WAYLAND_DISPLAY = sloth.envOr "WAYLAND_DISPLAY" "wayland-0";
NIXOS_OZONE_WL = "1";
GTK_USE_PORTAL = "1";
GTK_A11Y = sloth.envOr "GTK_A11Y" "0";
XDG_DATA_DIRS = lib.makeSearchPath "share" [
pkgs.papirus-icon-theme
pkgs.bibata-cursors
pkgs.shared-mime-info
];
XCURSOR_PATH = "${pkgs.bibata-cursors}/share/icons:${pkgs.bibata-cursors}/share/pixmaps";
# Fontconfig configuration mounted by nixpak fonts.enable.
FONTCONFIG_FILE = "/etc/fonts/fonts.conf";
NIXOS_XDG_OPEN_USE_PORTAL = "1";
}
// lib.genAttrs [
"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"
"XCURSOR_THEME"
"XCURSOR_SIZE"
"NO_AT_BRIDGE"
"QT_QPA_PLATFORMTHEME"
"QT_WAYLAND_DISABLE_WINDOWDECORATION"
] (name: sloth.envOr name "");
};
}
@@ -0,0 +1,67 @@
# Non-portal desktop services are separate opt-ins from portal capabilities.
{
config,
lib,
sloth,
...
}:
let
cfg = config.desktopIntegration;
appId = config.flatpak.appId;
shortName = lib.last (lib.splitString "." appId);
in
{
options.desktopIntegration = {
notifications = lib.mkEnableOption "native desktop notifications";
tray = lib.mkEnableOption "StatusNotifier tray integration";
mpris = lib.mkEnableOption "MPRIS media controls for this application";
accessibility = lib.mkEnableOption "the accessibility bus and AT-SPI socket";
};
config = lib.mkMerge [
(lib.mkIf cfg.notifications {
dbus.policies."org.freedesktop.Notifications" = "talk";
})
(lib.mkIf cfg.tray {
dbus.policies = {
# Qt probes NameHasOwner/GetNameOwner before registering its SNI.
"org.freedesktop.DBus" = "talk";
"org.kde.StatusNotifierWatcher" = "talk";
}
# SNI name is <pid>-<Qt tray instance ID>; WeChat 4.1.13 uses <pid>-2.
// builtins.listToAttrs (
lib.concatMap (
id:
map (
instance: lib.nameValuePair "org.kde.StatusNotifierItem-${toString id}-${toString instance}" "own"
) (lib.range 1 5)
) (lib.range 2 5)
);
})
(lib.mkIf cfg.mpris {
# Chromium's MPRIS name is lowercase even though its app ID ends in
# Chromium; retain the full ID and toolkit spelling as well.
dbus.policies = builtins.listToAttrs (
lib.concatMap
(name: [
(lib.nameValuePair "org.mpris.MediaPlayer2.${name}" "own")
(lib.nameValuePair "org.mpris.MediaPlayer2.${name}.*" "own")
])
(
lib.unique [
appId
shortName
(lib.toLower shortName)
]
)
);
})
(lib.mkIf cfg.accessibility {
dbus.policies."org.a11y.Bus" = "see";
dbus.rules.call."org.a11y.Bus" = [
"org.a11y.Bus.GetAddress@/org/a11y/bus"
"org.freedesktop.DBus.Properties.Get@/org/a11y/bus"
];
bubblewrap.bind.ro = [ (sloth.concat' sloth.runtimeDir "/at-spi/bus") ];
})
];
}
-173
View File
@@ -1,173 +0,0 @@
# Fork of nixpak/pkgs's gui-base.nix: the shared GUI runtime plumbing (GPU,
# fonts, desktop caches, /dev/shm, and the audio/display sockets) that lets a
# GUI app run under bubblewrap. Paths bound here must not be bound again in
# ./custom-policy.nix or the app configs: nixpak emits read-only binds last, so
# a duplicate read-only bind would silently shadow a writable one.
#
# https://github.com/nixpak/pkgs/blob/master/pkgs/modules/gui-base.nix
{
config,
lib,
pkgs,
sloth,
...
}:
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;
in
{
config = {
dbus.policies = {
"${config.flatpak.appId}" = "own";
# we add other policies in ./custom-policy.nix
};
# https://github.com/nixpak/nixpak/blob/master/modules/gpu.nix
# The NixOS provider exposes host drivers at /run/opengl-driver (read-only)
# and all /dev/dri nodes (device access), including Intel, AMD and Asahi DRM devices.
# Nixpak also maps /sys/dev/char and /sys/devices/pci0000:00 read-only for discovery.
# These mappings grant access; they do not select the GPU used by an application.
gpu = {
enable = lib.mkDefault true;
provider = "nixos";
bundlePackage = pkgs.mesa.drivers;
};
# https://github.com/nixpak/nixpak/blob/master/modules/gui/fonts.nix
# it works not well, bind system's /etc/fonts directly instead
fonts.enable = false;
# https://github.com/nixpak/nixpak/blob/master/modules/locale.nix
locale.enable = true;
bubblewrap = {
# 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. 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;
};
bind.rw = [
[
(envSuffix "HOME" "/.var/app/${config.flatpak.appId}/cache")
sloth.xdgCacheHome
]
(sloth.concat' sloth.xdgCacheHome "/fontconfig")
(sloth.concat' sloth.xdgCacheHome "/mesa_shader_cache")
# gvfsd is a directory that holds FUSE mounts, not a socket, so it must
# stay writable.
(envSuffix "XDG_RUNTIME_DIR" "/gvfsd")
];
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")
(sloth.concat' sloth.xdgConfigHome "/gtk-2.0")
(sloth.concat' sloth.xdgConfigHome "/gtk-3.0")
(sloth.concat' sloth.xdgConfigHome "/gtk-4.0")
(sloth.concat' sloth.xdgConfigHome "/fontconfig")
"/etc/fonts" # for fontconfig
"/etc/localtime" # this is a symlink to /etc/zoneinfo/xxx
"/etc/zoneinfo"
# Host EGL configuration (read-only); needed by some driver setups.
"/etc/egl"
"/etc/static/egl"
# Asahi GPU discovery follows /sys/dev/char links into platform devices.
# Expose their targets read-only; this includes non-GPU platform metadata too.
"/sys/devices/platform"
];
bind.dev = [
"/dev/shm" # Shared Memory
# Additional NVIDIA interfaces, retained for primary-GPU and offload use.
# Unlike /dev/dri above, numbered NVIDIA nodes are not mapped as a directory.
"/dev/nvidia0" # GPU index 0; additional GPUs need explicit mappings.
"/dev/nvidiactl" # Driver control interface.
"/dev/nvidia-modeset" # Display mode setting.
"/dev/nvidia-uvm" # Unified virtual memory.
];
tmpfs = [
"/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
cursorTheme
pkgs.shared-mime-info
]
);
XCURSOR_PATH = lib.mkForce (
lib.concatStringsSep ":" [
"${cursorTheme}/share/icons"
"${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"
];
};
};
}
@@ -0,0 +1,62 @@
# xdg-dbus-proxy rules target interfaces on the Desktop bus name, not fictitious
# per-interface bus names. Each capability is opt-in.
{ config, lib, ... }:
let
groups = {
fileChooser = [
"FileChooser.OpenFile"
"FileChooser.SaveFile"
"FileChooser.SaveFiles"
];
openUri = [
"OpenURI.OpenURI"
"OpenURI.OpenFile"
"OpenURI.OpenDirectory"
];
notifications = [
"Notification.AddNotification"
"Notification.RemoveNotification"
];
screenCast = [
"ScreenCast.CreateSession"
"ScreenCast.SelectSources"
"ScreenCast.Start"
"ScreenCast.OpenPipeWireRemote"
];
camera = [
"Camera.AccessCamera"
"Camera.OpenPipeWireRemote"
];
print = [
"Print.PreparePrint"
"Print.Print"
];
secret = [ "Secret.RetrieveSecret" ];
inhibit = [
"Inhibit.Inhibit"
"Inhibit.CreateMonitor"
"Inhibit.QueryEndResponse"
];
};
in
{
options.portalGroups =
lib.mapAttrs (name: _: lib.mkEnableOption "the ${name} desktop portal methods") groups
// {
inputMethod = lib.mkEnableOption "Fcitx and IBus input method portals";
};
config = {
dbus.rules.call."org.freedesktop.portal.Desktop" = lib.concatLists (
lib.mapAttrsToList (
name: methods:
lib.optionals config.portalGroups.${name} (
map (method: "org.freedesktop.portal.${method}@/org/freedesktop/portal/desktop") methods
)
) groups
);
dbus.policies = lib.mkIf config.portalGroups.inputMethod {
"org.freedesktop.portal.Fcitx" = "talk";
"org.freedesktop.portal.IBus" = "talk";
};
};
}
@@ -0,0 +1,54 @@
# The existing FHS layer owns WeChat's HOME remapping and xwechat_files link.
{
config,
lib,
pkgs,
sloth,
...
}:
{
imports = [
./desktop-base.nix
./network.nix
];
pasta.mode = "isolate";
desktopIntegration.tray = true;
portalGroups = {
fileChooser = true;
openUri = true;
notifications = true;
screenCast = true;
camera = true;
inputMethod = true;
};
bubblewrap = {
sockets.x11 = true;
# libdbus aborts without /etc/machine-id; use a synthetic app-scoped value.
bind.ro = [
[
(toString (
pkgs.writeText "wechat-machine-id" "${
builtins.substring 0 32 (builtins.hashString "sha256" config.flatpak.appId)
}\n"
))
"/etc/machine-id"
]
];
# The inner FHS /home reset replaces base XDG mounts with this backing tree.
bind.rw = lib.mkForce [
(sloth.mkdir (sloth.concat' sloth.xdgDocumentsDir "/WeChat_Data"))
(sloth.concat' sloth.runtimeDir "/doc")
[
(sloth.mkdir (sloth.concat' sloth.runtimeDir "/nixpak-${config.flatpak.appId}-tmp"))
"/tmp"
]
];
env = {
DISPLAY = sloth.envOr "DISPLAY" "";
XAUTHORITY = sloth.envOr "XAUTHORITY" (sloth.concat' sloth.homeDir "/.Xauthority");
XDG_DOCUMENTS_DIR = sloth.xdgDocumentsDir;
# The existing pre-FHS script uses mkdir/ln before /usr/bin exists.
PATH = lib.makeBinPath [ pkgs.coreutils ];
};
};
}
+42
View File
@@ -0,0 +1,42 @@
# Zoom keeps the upstream FHS runtime inside the outer nixpak boundary.
{ lib, sloth, ... }:
{
imports = [
./desktop-base.nix
./network.nix
];
pasta.mode = "isolate";
desktopIntegration.tray = true;
portalGroups = {
fileChooser = true;
openUri = true;
notifications = true;
screenCast = true;
camera = true;
inhibit = true;
inputMethod = true;
};
bubblewrap = {
# Screen sharing enumerates DRM connectors through sysfs. Missing this
# directory makes Zoom abort with std::filesystem_error.
bind.ro = [ "/sys/class/drm" ];
# ZoomLauncher selects xcb; the X11 connection retains X11's display trust.
sockets.x11 = true;
env = {
DISPLAY = sloth.envOr "DISPLAY" "";
XAUTHORITY = sloth.envOr "XAUTHORITY" (sloth.concat' sloth.homeDir "/.Xauthority");
};
bind.dev = [
# Zoom 7.2.1 still opens V4L2 device nodes directly for webcam capture.
"/dev/video0"
"/dev/video1"
];
bind.rw = [
(sloth.mkdir (sloth.concat' sloth.homeDir "/.zoom"))
(sloth.concat' sloth.xdgConfigHome "/zoomus.conf")
(sloth.concat' sloth.xdgConfigHome "/zoom.conf")
(sloth.mkdir (sloth.concat' sloth.xdgDocumentsDir "/Zoom"))
sloth.xdgDownloadDir
];
};
}
+17 -2
View File
@@ -26,11 +26,26 @@ let
flatpak.appId = appId;
imports = [
./modules/gui-base.nix
./modules/desktop-base.nix
./modules/network.nix
./modules/custom-policy.nix
];
portalGroups = {
fileChooser = true;
openUri = true;
notifications = true;
screenCast = true;
camera = true;
inhibit = true;
inputMethod = true;
};
desktopIntegration = {
notifications = true;
tray = true;
mpris = true;
accessibility = true;
};
bubblewrap = {
# To trace all the home files QQ accesses, you can use the following nushell command:
# just trace-access qq
+17 -2
View File
@@ -13,9 +13,8 @@ let
{ sloth, ... }:
{
imports = [
./modules/gui-base.nix
./modules/desktop-base.nix
./modules/network.nix
./modules/custom-policy.nix
];
app.package = telegram-desktop;
flatpak = {
@@ -30,6 +29,22 @@ let
};
};
portalGroups = {
fileChooser = true;
openUri = true;
notifications = true;
screenCast = true;
camera = true;
inhibit = true;
inputMethod = true;
};
desktopIntegration = {
notifications = true;
tray = true;
mpris = true;
accessibility = true;
};
bubblewrap = {
# nixpak exposes /.flatpak-info, so Telegram uses FLATPAK_ID as its Wayland app ID.
env.FLATPAK_ID = appId;
+72
View File
@@ -0,0 +1,72 @@
{
lib,
bwraps,
mkNixPak,
buildEnv,
makeDesktopItem,
runCommandLocal,
...
}:
let
appId = "com.tencent.WeChat";
# As with Zoom, preserve the outer metadata file across the inner FHS layer.
# Keep the existing AppImage runtime and WeChat_Data layout unchanged.
package = bwraps.wechat.overrideAttrs (old: {
# Refuse unexpected layouts before the existing ln -snf can modify them.
extraPreBwrapCmds = ''
wechat_link="''${XDG_DOCUMENTS_DIR}/WeChat_Data/home/xwechat_files"
wechat_files="''${XDG_DOCUMENTS_DIR}/WeChat_Data/xwechat_files"
if [[ -e "$wechat_link" || -L "$wechat_link" ]]; then
if [[ ! -L "$wechat_link" || "$(readlink "$wechat_link")" != "$wechat_files" ]]; then
echo 'WeChat: unexpected xwechat_files layout; refusing to change existing data.' >&2
exit 1
fi
fi
''
+ (old.extraPreBwrapCmds or "");
extraBwrapArgs = (old.extraBwrapArgs or [ ]) ++ [
"--ro-bind /.flatpak-info /.flatpak-info"
# The inner /home reset must not hide an Xauthority file stored there.
''--ro-bind-try "''${XAUTHORITY}" "''${XAUTHORITY}"''
];
});
wrapped = mkNixPak {
config = {
imports = [ ./modules/wechat-policy.nix ];
app = {
inherit package;
binPath = "bin/wechat";
};
flatpak = { inherit appId; };
};
};
exe = lib.getExe wrapped.config.script;
assets = runCommandLocal "wechat-desktop-assets" { } ''
mkdir -p $out/share
ln -s ${package}/share/pixmaps $out/share/pixmaps
'';
in
buildEnv {
inherit (wrapped.config.script) name passthru;
meta = wrapped.config.script.meta // {
mainProgram = "wechat";
};
paths = [
wrapped.config.script
assets
(makeDesktopItem {
name = "wechat";
desktopName = "WeChat";
exec = "${exe} %U";
icon = "wechat";
terminal = false;
startupWMClass = "wechat";
categories = [
"Network"
"InstantMessaging"
];
mimeTypes = [ "x-scheme-handler/weixin" ];
extraConfig.X-Flatpak = appId;
})
];
}
+70
View File
@@ -0,0 +1,70 @@
{
lib,
zoom-us,
buildFHSEnv,
mkNixPak,
buildEnv,
makeDesktopItem,
runCommandLocal,
...
}:
let
appId = "us.zoom.Zoom";
# Preserve the upstream FHS wrapper and /opt mount; its root mounts omit the
# metadata file needed for portal identification inside the nested sandbox.
package = zoom-us.override {
xdgDesktopPortalSupport = true;
buildFHSEnv =
args:
buildFHSEnv (
args
// {
extraBwrapArgs = (args.extraBwrapArgs or [ ]) ++ [
"--ro-bind /.flatpak-info /.flatpak-info"
];
}
);
};
wrapped = mkNixPak {
config = {
imports = [ ./modules/zoom-policy.nix ];
app = {
inherit package;
binPath = "bin/zoom";
};
flatpak = { inherit appId; };
};
};
exe = lib.getExe wrapped.config.script;
assets = runCommandLocal "zoom-desktop-assets" { } ''
mkdir -p $out/share
for directory in icons pixmaps; do
if [ -d ${package}/share/$directory ]; then
ln -s ${package}/share/$directory $out/share/$directory
fi
done
'';
in
buildEnv {
inherit (wrapped.config.script) name meta passthru;
paths = [
wrapped.config.script
assets
(makeDesktopItem {
name = "Zoom";
desktopName = "Zoom Workplace";
comment = "Zoom Video Conference";
exec = "${exe} %U";
icon = "Zoom";
terminal = false;
startupWMClass = "zoom";
categories = [ "Network" ];
mimeTypes = [
"x-scheme-handler/zoommtg"
"x-scheme-handler/zoomus"
"application/x-zoom"
];
extraConfig.X-Flatpak = appId;
})
];
}
+126
View File
@@ -0,0 +1,126 @@
# Browser runtime verification
- Run from the repository root as the Linux desktop user, with user D-Bus; no sudo or activation.
- Tests use disposable profiles only, synthetic environment canaries, and no environment dumps.
- **Keep HOME under `/var/tmp`:** the sandbox's private `/tmp` masks a HOME created there. Rendering
reads a host-prepared HTML file through the profile bind; persistence requires a nonempty
`Local State` file in the disposable host HOME.
## Reproduce
Build against the locked flake inputs:
```bash
mkdir -p browser-validation
for app in chromium google-chrome brave-origin microsoft-edge; do
APP="$app" nix build --impure --out-link "browser-validation/$app" --expr '
let
flake = builtins.getFlake (toString ./.);
pkgs = import flake.inputs.nixpkgs {
system = builtins.currentSystem;
config.allowUnfree = true;
};
in pkgs.callPackage
(./hardening/nixpaks + "/${builtins.getEnv "APP"}.nix")
{
mkNixPak = flake.inputs.nixpak.lib.nixpak {
inherit pkgs;
inherit (pkgs) lib;
};
}
'
done
```
Run each test separately so a failing browser does not hide the other results:
```bash
python3 hardening/tests/verify-chromium.py browser-validation/chromium/bin/chromium
python3 hardening/tests/verify-chromium.py browser-validation/google-chrome/bin/google-chrome
python3 hardening/tests/verify-chromium.py browser-validation/brave-origin/bin/brave-origin
python3 hardening/tests/verify-chromium.py browser-validation/microsoft-edge/bin/microsoft-edge
```
- Each launch has a 35-second limit; failures exit nonzero. Temporary HOME is cleaned up.
- Intermediate JSON records process checks; final JSON appears only after all checks pass.
## Current measured results
Host: `ai`, x86_64 Linux. All four rebuilt after the shared policy/media opt-in changes and tested
with `/var/tmp` HOME, explicit Fontconfig configuration, CJK fonts, and narrow NVIDIA mappings.
| Browser | Process/security checks | HTML rendering | Profile persistence | Host loopback | Overall |
| ---------------------- | ----------------------- | --------------- | ------------------- | ------------- | -------- |
| Chromium 156.0.8078.73 | Pass | Pass | Pass | Pass: allowed | **Pass** |
| Chrome 156.0.8078.73 | Pass | Pass | Pass | Pass: blocked | **Pass** |
| Brave Origin 1.98.60 | Pass | **35s timeout** | Not reached | Not reached | **Fail** |
| Edge 156.0.4314.42 | Pass | **35s timeout** | Not reached | Not reached | **Fail** |
Measured evidence:
- All four build and have live renderers with `Seccomp=2`, `NoNewPrivs=1`; no sandbox-disabling
flags are present.
- Actual bwrap arguments pass user/PID namespace, effective network flag, clear environment, new
session, parent-death, and private `/dev/shm` checks. No broad HOME/`/home`/`/dev`, SSH,
system-bus, raw video, X11, GVfs, or direct PipeWire binds. Brave/Edge also have no browserpass
credential or PulseAudio binds.
- All nine canaries are absent: `NIXPAK_VALIDATION_CANARY`, `SSH_AUTH_SOCK`, `GPG_AGENT_INFO`, and
upper/lowercase `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`.
- Chromium/Chrome render the prepared file without Fontconfig errors and persist `Local State`. Host
HTTP control succeeds; Chromium fetches its marker. Chrome reports `ERR_CONNECTION_REFUSED`,
returns no marker, and sends zero requests to the host listener.
- Brave/Edge stall on `--headless=new --dump-dom` despite `--timeout=10000`. Cause unresolved;
system D-Bus/UPower warnings also occur in passing browsers. No upstream-package control was run,
so this does not establish a wrapper regression.
## Current permission checks
- **Firefox/Chromium/Chrome:** evaluated media opt-in enables PulseAudio, camera/screencast portals,
MPRIS, and inhibition. **Brave/Edge:** all disabled; live socket-bind checks pass.
- Chromium/Chrome main processes enable `WaylandWindowDecorations,WebRtcPipeWireCamera`. Brave/Edge
retain Wayland decorations and have **no camera feature**; all flag checks pass.
- Firefox/Chromium share host networking and allow personal files; Chrome/Brave/Edge use pasta
isolation without personal-file access. Firefox parity is evaluated, not runtime-tested here.
- GPU nodes remain available for webpage rendering. Interactive audio/camera/capture, hardware
acceleration, CJK/IME, portals, downloads, and browserpass remain unverified by this headless
test.
- Inventory: [hardening README](../README.md). Security limits: [SECURITY.md](../../SECURITY.md).
## Cookie crypto
- Chromium-family wrappers enable `SecretPortalKeyProviderUseForEncryption` and receive only
`org.freedesktop.portal.Secret.RetrieveSecret`; they do **not** get
`--talk=org.freedesktop.secrets`.
- A disposable-profile probe under `/var/tmp` served an HTTP `Set-Cookie`, then inspected the
profile DB: the cookie prefix was `v12` (`763132`) and
`Local State.os_crypt.portal.prev_init_success` was true.
- If the portal is unavailable, Chromium can fall back to v10. That is weak; monitor portal startup
Portal startup is a monitored degradation, not a fail-closed guarantee.
- Firefox uses NSS (`key4.db`/`logins.json`) and `cookies.sqlite`, not Chrome OSCrypt. Set a Firefox
Primary Password if saved-login at-rest protection matters.
- Sources:
[feature defaults](https://github.com/chromium/chromium/blob/156.0.8078.73/chrome/browser/browser_features.cc),
[v12 provider](https://github.com/chromium/chromium/blob/156.0.8078.73/components/os_crypt/async/browser/secret_portal_key_provider.cc),
[prefix decryption](https://github.com/chromium/chromium/blob/156.0.8078.73/components/os_crypt/async/common/encryptor.cc).
## Check these files
```bash
ruff format --check hardening/tests/verify-chromium.py
ruff check hardening/tests/verify-chromium.py
prettier --check hardening/tests/browser-runtime.md
git diff --check
```
## Manual Chrome media smoke test
Use the official WebRTC samples. They require no account and do not upload the stream; the preview
stays local. Chrome uses pasta-isolated loopback, so a test server on the host is intentionally not
reachable.
- Camera + microphone: <https://webrtc.github.io/samples/src/content/getusermedia/gum/>
- Click **Start**, accept Chrome and portal prompts, verify local video preview and audio level.
- Screen sharing: <https://webrtc.github.io/samples/src/content/getusermedia/getdisplaymedia/>
- Click **Start**, choose a window/screen, verify preview, then **Stop**.
- Files: use any site's upload control, then download the same file; confirm the file chooser and
Downloads work.
+221
View File
@@ -0,0 +1,221 @@
# Evaluate with the same pkgs/mkNixPak as the wrappers. Returns true or identifies
# the violated policy boundary; does not build or launch an application.
{ pkgs, mkNixPak }:
let
inherit (pkgs) lib;
load = name: (pkgs.callPackage (../nixpaks + "/${name}.nix") { inherit mkNixPak; }).config;
firefox = load "firefox";
chromium = load "chromium";
chrome = load "google-chrome";
restrictedBrowsers = map load [
"brave-origin"
"microsoft-edge"
];
chromiumFamily = map load [
"chromium"
"google-chrome"
"brave-origin"
"microsoft-edge"
];
browsers = map load [
"firefox"
"chromium"
"google-chrome"
"brave-origin"
"microsoft-edge"
];
nonBrowserApps = map load [
"qq"
"telegram-desktop"
"zoom"
"wechat"
];
browserAndChatApps =
browsers
++ map load [
"qq"
"telegram-desktop"
];
apps = browsers ++ nonBrowserApps;
# Inspect effective proxy arguments, not just capability switches: an extra
# direct service grant could otherwise bypass a disabled portal group.
forbiddenDbus =
argument:
lib.any (name: lib.hasInfix name argument) [
"org.freedesktop.portal.Usb"
"org.freedesktop.portal.RemoteDesktop"
"org.gnome.Shell.Screencast"
]
|| argument == "--talk=org.freedesktop.portal.Desktop";
withoutSecretPortal =
calls:
calls
// {
"org.freedesktop.portal.Desktop" = lib.filter (
arg: !lib.hasInfix "org.freedesktop.portal.Secret" arg
) calls."org.freedesktop.portal.Desktop";
};
mountStrings =
app: builtins.filter builtins.isString (lib.flatten (with app.bubblewrap.bind; rw ++ ro ++ dev));
forbiddenMount =
path:
path == "/dev"
|| lib.hasPrefix "/dev/video" path
|| lib.hasPrefix "/dev/bus/usb" path
|| path == "/run/dbus"
|| path == "/run/dbus/system_bus_socket";
checks = {
personalBrowserCapabilitiesMatch =
firefox.browserPermissions.media == chromium.browserPermissions.media
&& firefox.browserPermissions.personalFiles == chromium.browserPermissions.personalFiles
&& firefox.browserPermissions.browserpass == chromium.browserPermissions.browserpass
&&
(removeAttrs firefox.portalGroups [ "secret" ]) == (removeAttrs chromium.portalGroups [ "secret" ])
&& firefox.desktopIntegration == chromium.desktopIntegration;
personalBrowserPortalCallsMatch =
withoutSecretPortal firefox.dbus.rules.call == withoutSecretPortal chromium.dbus.rules.call;
personalBrowserHostLoopback = lib.all (app: app.bubblewrap.network && !app.pasta.enable) [
firefox
chromium
];
workBrowserIsolatedLoopback =
chrome.bubblewrap.network
&& chrome.pasta.enable
&& chrome.pasta.mode == "isolate"
&& lib.elem "--no-map-gw" chrome.pasta.args;
zoomDrmSysfs = lib.elem "/sys/class/drm" (load "zoom").bubblewrap.bind.ro;
zoomCameraDevices =
let
zoom = load "zoom";
in
lib.all (dev: lib.elem dev zoom.bubblewrap.bind.dev) [
"/dev/video0"
"/dev/video1"
];
fhsTrayIntegration =
lib.all
(
app:
app.desktopIntegration.tray
&& app.dbus.policies."org.freedesktop.DBus" == "talk"
&& app.dbus.policies."org.kde.StatusNotifierWatcher" == "talk"
&& app.dbus.policies."org.kde.StatusNotifierItem-2-1" == "own"
&& app.dbus.policies."org.kde.StatusNotifierItem-2-2" == "own"
)
(
map load [
"wechat"
"zoom"
]
);
appTmpIsolation =
let
tmpPath =
app:
lib.findFirst (
entry: builtins.isList entry && builtins.length entry == 2 && builtins.elem "/tmp" entry
) null app.bubblewrap.bind.rw;
paths = map tmpPath apps;
in
lib.all (path: builtins.isList path && builtins.length path == 2) paths
&& lib.length (lib.unique (map builtins.head paths)) == builtins.length apps;
workBrowserMedia =
chrome.portalGroups.camera
&& chrome.portalGroups.screenCast
&& chrome.bubblewrap.sockets.pulse
&& chrome.gpu.enable
&& lib.elem "--call=org.freedesktop.portal.Desktop=org.freedesktop.portal.Camera.OpenPipeWireRemote@/org/freedesktop/portal/desktop" chrome.dbus.args
&& lib.elem "--call=org.freedesktop.portal.Desktop=org.freedesktop.portal.ScreenCast.OpenPipeWireRemote@/org/freedesktop/portal/desktop" chrome.dbus.args;
secretPortalScope =
lib.all (
app:
app.browserPermissions.secretPortal
&& lib.any (
arg:
arg
== "--call=org.freedesktop.portal.Desktop=org.freedesktop.portal.Secret.RetrieveSecret@/org/freedesktop/portal/desktop"
) app.dbus.args
&& !lib.any (arg: arg == "--talk=org.freedesktop.secrets") app.dbus.args
) chromiumFamily
&& !firefox.browserPermissions.secretPortal
&& lib.all (
app:
!(app.browserPermissions.secretPortal or false)
&& !lib.any (arg: lib.hasInfix "org.freedesktop.portal.Secret" arg) app.dbus.args
) nonBrowserApps;
mediaScope =
lib.all
(
app:
app.browserPermissions.media
&& app.bubblewrap.sockets.pulse
&& app.portalGroups.camera
&& app.portalGroups.screenCast
)
[
firefox
chromium
chrome
];
restrictedBrowserMedia = lib.all (
app:
!app.browserPermissions.media
&& !app.bubblewrap.sockets.pulse
&& !app.bubblewrap.sockets.pipewire
&& !app.portalGroups.camera
&& !app.portalGroups.screenCast
&& !app.desktopIntegration.mpris
&& !lib.any (
arg:
lib.hasInfix "org.freedesktop.portal.Camera" arg
|| lib.hasInfix "org.freedesktop.portal.ScreenCast" arg
|| lib.hasInfix "org.mpris.MediaPlayer2" arg
) app.dbus.args
&& !lib.any (path: lib.hasPrefix "/dev/snd" path) (mountStrings app)
) restrictedBrowsers;
personalFileScope =
firefox.browserPermissions.personalFiles
&& chromium.browserPermissions.personalFiles
&& !chrome.browserPermissions.personalFiles;
browserpassScope =
lib.all (app: app.browserPermissions.browserpass) [
firefox
chromium
chrome
]
&& lib.all (app: !app.browserPermissions.browserpass) (
map load [
"brave-origin"
"microsoft-edge"
]
);
deniedServices = lib.all (app: !lib.any forbiddenDbus app.dbus.args) apps;
noBroadKeyring = lib.all (
app: !lib.any (arg: arg == "--talk=org.freedesktop.secrets") app.dbus.args
) apps;
deniedDevicesAndSystemBus =
let
zoomVideoDevices = [
"/dev/video0"
"/dev/video1"
];
forbiddenMountFor =
app:
if app.flatpak.appId == "us.zoom.Zoom" then
path: forbiddenMount path && !lib.elem path zoomVideoDevices
else
forbiddenMount;
in
lib.all (app: !lib.any (forbiddenMountFor app) (mountStrings app)) apps;
filteredEnvironmentAndMedia = lib.all (
app:
app.bubblewrap.clearEnv
&& app.dbus.enable
&& !app.bubblewrap.sockets.pipewire
&& !app.bubblewrap.sockets.x11
) browserAndChatApps;
};
in
lib.all (name: checks.${name} || throw "nixpak policy check failed: ${name}") (
builtins.attrNames checks
)
+35
View File
@@ -0,0 +1,35 @@
# Unified desktop-policy validation
Validation on the Linux `ai` host, using the pinned nixpkgs and nixpak inputs. These checks use
disposable profiles and do not read existing browser or chat accounts.
| Check | Result | Scope |
| --------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Firefox, QQ, Telegram derivations | Pass | All three wrappers evaluated and built after conversion to the shared desktop policy. |
| Browser policy regression checks | Pass | `policy.nix` returned `true`; covers Firefox/Chromium capability and portal-call parity, host loopback, Chrome isolated networking and media grants, browserpass scope, and forbidden service/device access. |
| Firefox headless screenshot | Pass | Exit status 0; `about:blank` produced a PNG with a fresh profile and HOME under `/var/tmp`. |
| QQ startup | Partial | With a disposable HOME and `--ozone-platform=headless`, initialized private QQ data and stayed running for 12 seconds until explicitly terminated. Headless EGL/GPU initialization errors occurred. No account login or media function was tested. |
| Telegram runtime | Not run | Evaluation and build only. |
The Firefox invocation used the built wrapper with
`--headless --no-remote --profile "$HOME/.mozilla/test" --screenshot "$HOME/Downloads/smoke.png" about:blank`.
The disposable environment retained only the launcher's PATH, USER, locale, Wayland display and
session-bus/runtime-directory variables, with HOME and all XDG storage paths redirected to the
temporary tree. Existing account profiles were not mounted. The temporary tree was removed after the
process exited.
Use `/var/tmp`, not `/tmp`, for disposable host profiles. Nixpak emits the sandbox's private `/tmp`
mount after filesystem binds; a profile prepared under host `/tmp` is hidden by that mount. An
application that creates a fresh profile inside the resulting tmpfs can appear to work without
testing persistence.
The common policy replaces the earlier direct camera-device and PipeWire-socket grants with explicit
Camera and ScreenCast portal methods. QQ and Telegram retain their private XDG data and existing
personal-file mappings, and opt into notifications, tray, MPRIS and accessibility. Firefox retains
its `.mozilla` profile and MPRIS artwork mounts and shares browser capability definitions with
Chromium.
Interactive validation remains necessary for camera capture, microphone input, video playback,
desktop/window sharing, file chooser/save operations, IME, tray, notifications and media controls.
The QQ headless startup is not evidence that those functions work. Browser encrypted-login behavior
is covered in `browser-runtime.md`.
+416
View File
@@ -0,0 +1,416 @@
#!/usr/bin/env python3
"""Runtime smoke test: verify a built nixpak Chromium without existing profiles.
Usage: python3 hardening/tests/verify-chromium.py /nix/store/.../bin/chromium
Requires Linux procfs and an active user D-Bus session. Uses only stdlib.
"""
import argparse
import http.server
import json
import os
import signal
import subprocess
import tempfile
import threading
import time
import urllib.request
from pathlib import Path
def processes():
result = {}
for path in Path("/proc").glob("[0-9]*"):
try:
status = dict(
line.split(":", 1)
for line in (path / "status").read_text().splitlines()
if ":" in line
)
args = (path / "cmdline").read_bytes().split(b"\0")
result[int(path.name)] = (int(status["PPid"]), status, args)
except (OSError, ValueError):
pass
return result
def descendants(root, table):
found = {root}
while True:
more = {pid for pid, (parent, _, _) in table.items() if parent in found}
if more <= found:
return found
found |= more
def run_browser(command, env):
try:
return subprocess.run(
command, check=False, env=env, capture_output=True, timeout=35
)
except subprocess.TimeoutExpired as error:
detail = (error.stderr or b"").decode(errors="replace")[-6000:]
raise AssertionError(
"Disposable headless command timed out:\n" + detail
) from None
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("browser", type=Path)
args = parser.parse_args()
browser = str(args.browser.absolute())
profile = {
"chromium": "chromium",
"google-chrome": "google-chrome",
"microsoft-edge": "microsoft-edge",
"brave-origin": "BraveSoftware/Brave-Origin",
}[args.browser.name]
media = args.browser.name in {"chromium", "google-chrome"}
report = {}
with tempfile.TemporaryDirectory(
prefix="nixpak-chromium-test-", dir="/var/tmp"
) as tmp:
home = Path(tmp)
for directory in [
f".config/{profile}",
"Downloads",
".cache",
".local/share",
".local/state",
]:
(home / directory).mkdir(parents=True, exist_ok=True)
(home / ".config/user-dirs.dirs").write_text(
'XDG_DOWNLOAD_DIR="$HOME/Downloads"\n'
)
env = {
key: os.environ[key]
for key in [
"PATH",
"USER",
"XDG_RUNTIME_DIR",
"DBUS_SESSION_BUS_ADDRESS",
"WAYLAND_DISPLAY",
"LANG",
]
if key in os.environ
}
canaries = [
"NIXPAK_VALIDATION_CANARY",
"SSH_AUTH_SOCK",
"GPG_AGENT_INFO",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
]
env.update({name: "disposable-test-value" for name in canaries})
env.update(
HOME=tmp,
XDG_CONFIG_HOME=f"{tmp}/.config",
XDG_CACHE_HOME=f"{tmp}/.cache",
XDG_DATA_HOME=f"{tmp}/.local/share",
XDG_STATE_HOME=f"{tmp}/.local/state",
NIXPAK_VALIDATION_CANARY="disposable-test-value",
)
base = [
browser,
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
f"--user-data-dir={tmp}/.config/{profile}/validation-profile",
]
with (home / "browser.log").open("w+") as log:
proc = subprocess.Popen(
base + ["--remote-debugging-port=0", "about:blank"],
env=env,
stdout=log,
stderr=log,
start_new_session=True,
)
try:
deadline = time.monotonic() + 35
renderers = []
while time.monotonic() < deadline:
table = processes()
owned = descendants(proc.pid, table)
renderers = [
pid
for pid in owned
if pid in table
and b"--type=renderer" in b" ".join(table[pid][2]).split()
]
if renderers or proc.poll() is not None:
break
time.sleep(0.2)
if not renderers:
log.seek(0)
# This log belongs only to the disposable browser, never a host environment dump.
raise AssertionError(
"No live renderer; disposable browser log:\n"
+ log.read()[-6000:]
)
report["renderers"] = [
{
"pid": pid,
"Seccomp": table[pid][1]["Seccomp"].strip(),
"NoNewPrivs": table[pid][1]["NoNewPrivs"].strip(),
}
for pid in renderers
]
assert all(
row["Seccomp"] == "2" and row["NoNewPrivs"] == "1"
for row in report["renderers"]
), report
bwrap = [
row[2]
for pid, row in table.items()
if pid in owned and Path(os.fsdecode(row[2][0])).name == "bwrap"
]
required = [
b"--unshare-user-try",
b"--unshare-pid",
b"--unshare-net",
b"--clearenv",
b"--new-session",
b"--die-with-parent",
]
assert any(
all(flag in command for flag in required) for command in bwrap
), "Missing bwrap isolation arguments"
report["bwrap_required_flags"] = [
os.fsdecode(flag) for flag in required
]
browser_pids = set()
for renderer in renderers:
pid = table[renderer][0]
while pid in owned and pid in table:
command = b" ".join(table[pid][2]).split()
if (
any(arg.startswith(b"--user-data-dir=") for arg in command)
and not any(arg.startswith(b"--type=") for arg in command)
and b"--clearenv" not in command
):
browser_pids.add(pid)
break
pid = table[pid][0]
checked = 0
for pid in browser_pids:
command = b" ".join(table[pid][2]).split()
assert b"--no-sandbox" not in command, "Chromium sandbox disabled"
features = [
arg.split(b"=", 1)[1].split(b",")
for arg in command
if arg.startswith(b"--enable-features=")
]
assert features and b"WaylandWindowDecorations" in features[-1], (
"Missing Wayland feature flag"
)
assert (b"WebRtcPipeWireCamera" in features[-1]) == media, (
"Camera feature does not match browser media policy"
)
assert all(
b"--no-sandbox" not in b" ".join(table[pid][2]).split()
for pid in renderers
), "Renderer sandbox disabled"
report["camera_and_wayland_features"] = {
"WaylandWindowDecorations": True,
"WebRtcPipeWireCamera": media,
}
report["chromium_sandbox_flags"] = (
"--no-sandbox absent; renderer Seccomp=2/NoNewPrivs=1"
)
for pid in browser_pids:
try:
entries = Path(f"/proc/{pid}/environ").read_bytes().split(b"\0")
except PermissionError:
continue
assert not any(
entry.startswith(name.encode() + b"=")
for entry in entries
for name in canaries
), "Canary leaked into browser"
checked += 1
assert checked, "Could not inspect browser environment"
report["browser_environments_without_canary"] = checked
report["absent_canary_variables"] = canaries
command = next(command for command in bwrap if b"--clearenv" in command)
network_flags = [
arg
for arg in command[: command.index(b"--")]
if arg in {b"--unshare-net", b"--share-net"}
]
expected_network = (
b"--share-net"
if args.browser.name == "chromium"
else b"--unshare-net"
)
assert network_flags[-1] == expected_network, (
"Unexpected effective network namespace policy"
)
report["effective_network_namespace_flag"] = os.fsdecode(
expected_network
)
for forbidden in [b"/dev/video0", b"/tmp/.X11-unix"]:
assert forbidden not in command, "Unexpected device/socket bind"
assert not any(
b"/pipewire-0" in arg or b"/gvfsd" in arg for arg in command
), "Unexpected direct desktop socket bind"
assert any(
command[i : i + 2] == [b"--tmpfs", b"/dev/shm"]
for i in range(len(command) - 1)
), "Missing private shared memory"
report["private_shm_and_no_direct_pipewire_gvfs_camera_x11"] = True
# Inspect bind operands, not synthetic --dev /dev or tmpfs mounts.
bind_options = {
b"--bind",
b"--bind-try",
b"--ro-bind",
b"--ro-bind-try",
b"--dev-bind",
b"--dev-bind-try",
}
options = command[: command.index(b"--")]
binds = [
(options[i + 1], options[i + 2])
for i, option in enumerate(options)
if option in bind_options
]
broad_paths = {
b"/",
b"/home",
b"/dev",
os.fsencode(tmp),
os.fsencode(os.environ["HOME"]),
}
for source, destination in binds:
for path in (source, destination):
assert path.rstrip(b"/") not in broad_paths and path != b"/", (
"Broad host filesystem bind"
)
parts = path.split(b"/")
if not media:
assert b"pulse" not in parts and not any(
part.startswith(b"pipewire-") for part in parts
), (
"Media-disabled browser has an audio/PipeWire socket bind"
)
assert b".ssh" not in parts, "SSH directory bind"
assert not (
path == b"/run/dbus" or path.startswith(b"/run/dbus/")
), "System D-Bus bind"
assert not (
path.startswith((b"/dev/video", b"/tmp/.X11-unix"))
), "Raw camera or X11 bind"
if args.browser.name in {"brave-origin", "microsoft-edge"}:
assert not (
{b".gnupg", b"gnupg", b"password-store"} & set(parts)
), "Unexpected browserpass credentials bind"
report["no_broad_home_dev_ssh_or_system_bus_bind"] = True
report["media_socket_policy"] = (
"PulseAudio allowed; direct PipeWire absent"
if media
else "PulseAudio and PipeWire binds absent"
)
report["browserpass_bind_scope"] = (
"allowed only for Chromium/Chrome; absent for Brave/Edge"
)
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
print(json.dumps(report, indent=2), flush=True)
render_file = home / ".config" / profile / "render-canary.html"
render_file.write_text("<html><body>NIXPAK_RENDER_OK</body></html>")
positive = run_browser(
base
+ [
"--timeout=10000",
"--dump-dom",
render_file.as_uri(),
],
env,
)
assert positive.returncode == 0 and b"NIXPAK_RENDER_OK" in positive.stdout, (
"Disposable page failed to render"
)
assert b"Fontconfig error" not in positive.stderr, (
"Fontconfig could not load sandbox configuration"
)
report["headless_page_render"] = (
"host-prepared file rendered; no Fontconfig errors"
)
state_file = home / ".config" / profile / "validation-profile" / "Local State"
assert state_file.is_file() and state_file.stat().st_size > 0, (
"Browser profile state did not persist to disposable host HOME"
)
report["disposable_profile_persistence"] = (
"nonempty Local State exists in host HOME"
)
class Handler(http.server.BaseHTTPRequestHandler):
hits = 0
def do_GET(self):
type(self).hits += 1
self.send_response(200)
self.end_headers()
self.wfile.write(
b"<html><body>NIXPAK_HOST_LOOPBACK_CANARY</body></html>"
)
def log_message(self, *args):
pass
with http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server:
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
url = f"http://127.0.0.1:{server.server_port}/"
assert (
b"NIXPAK_HOST_LOOPBACK_CANARY"
in urllib.request.urlopen(url, timeout=5).read()
)
Handler.hits = 0
try:
result = run_browser(base + ["--timeout=10000", "--dump-dom", url], env)
assert result.returncode == 0, "Loopback browser command failed"
if args.browser.name == "chromium":
assert (
b"NIXPAK_HOST_LOOPBACK_CANARY" in result.stdout
and Handler.hits > 0
), "Chromium must reach host loopback"
report["host_loopback"] = (
"allowed: host and Chromium fetched the listener marker"
)
else:
assert b"ERR_CONNECTION_REFUSED" in result.stdout + result.stderr, (
"Expected explicit browser connection refusal"
)
assert (
b"NIXPAK_HOST_LOOPBACK_CANARY" not in result.stdout
and Handler.hits == 0
), "Browser reached host loopback"
report["host_loopback"] = (
"blocked: host positive control succeeded; browser ERR_CONNECTION_REFUSED; zero server requests"
)
finally:
server.shutdown()
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()
+58
View File
@@ -0,0 +1,58 @@
# WeChat nested runtime verification
WeChat 4.1.13.26 built and opened a `Weixin` X11 window with a disposable profile. The final
candidate was `/nix/store/6wr94i7da0674k9hzs23fk2nr1ih4i3f-nixpak-wechat-4.1.13.26`. The existing
AppImage extraction, FHS runtime, X11 and fcitx settings remain in `hardening/bwraps/wechat.nix`;
the new nixpak wrapper adds an outer boundary.
The live extracted WeChat binary had three PID namespace identifiers, `NoNewPrivs: 1`, `Seccomp: 0`,
a network namespace different from the host, and `/.flatpak-info` identifying `com.tencent.WeChat`.
Its device view contained standard synthetic devices, `dri`, and the four explicit NVIDIA nodes; no
raw camera, USB or block devices. This smoke test does not establish an internal renderer sandbox
guarantee.
A sentinel in the disposable `Documents/WeChat_Data/home` was readable at WeChat's remapped HOME.
Its `xwechat_files` link retained the absolute target `Documents/WeChat_Data/xwechat_files`, which
remains bound inside both layers. The final launcher also rejected a separate disposable layout
containing a real `home/xwechat_files` directory, exited 1, and preserved its sentinel and contents.
It will not silently migrate an unexpected existing layout.
## Compatibility changes
As with Zoom, the wrapper preserves the outer `/.flatpak-info` file explicitly: the FHS automatic
root mount loop only propagates directories. The inner `--dev-bind /dev /dev` inherits the outer
filtered devices. The original inner network setting inherits pasta's isolated outer namespace,
including its private loopback.
The original inner `/home` reset hides the shared desktop base's XDG mounts. WeChat therefore
exposes its existing backing tree to the outer sandbox and keeps the original FHS HOME mapping. It
supplies `XDG_DOCUMENTS_DIR` and a coreutils-only PATH for the pre-FHS mkdir/link commands.
Xauthority is rebound after the inner HOME reset when present.
The first run aborted in libdbus because no machine ID was available. The WeChat policy supplies a
synthetic app-specific ID rather than the host ID. Login/device identity continuity with an existing
account remains untested. No credentials or real profile were used for this test.
## Historical constraint
Commit `0c7e43a2` removed `nixpaks/wechat-uos.nix`. Its previous TODO said the FHS runtime was
problematic inside another sandbox, but supplied no failure log. That old module also disabled X11
and set `QT_QPA_PLATFORM=kcb`, despite its xcb-only comment. Commit `28dee277` subsequently changed
the AppImage wrapper from Wayland to xcb and added fcitx environment settings. These records do not
prove the original failure's cause. Current runtime testing establishes that retaining FHS inside
nixpak is feasible with the explicit compatibility changes above.
## Repeat safely
Use the same disposable-home command as [Zoom](zoom-runtime.md), with WeChat's output and
executable, and additionally set `XDG_DOCUMENTS_DIR="$wechat_probe_dir/Documents"`. Prepare
`Documents/WeChat_Data/home` and `Documents/WeChat_Data/xwechat_files` beneath that disposable home
before launching. The smoke test stops before QR login and uses no account. Inspect the exact test
PID through `/proc` while the timeout is active; verify that its window and processes disappear
after timeout.
Pending manual checks: existing account persistence, QR/login, fcitx input, notifications/tray,
portal file chooser and selected-file access, downloads, voice/video calls, camera/screencast portal
support and clipboard workflows. Camera and ScreenCast methods are explicit grants; raw capture
devices and the host PipeWire socket are not exposed. X11 retains its display trust limitations, so
portal permission does not imply all capture requires portal consent.
+54
View File
@@ -0,0 +1,54 @@
# Zoom nested runtime verification
The Zoom 7.2.1.5760 candidate built and opened two `Zoom Workplace` X11 windows on the NVIDIA
desktop. The final tested output was
`/nix/store/yy6sxsgn5rqafvvgs3k53akwqjcfa2r7-nixpak-zoom-7.2.1.5760`.
The live main process had `NoNewPrivs: 1`, a network namespace different from the host, and
`/.flatpak-info` with `name=us.zoom.Zoom`. Its ancestry included ZoomLauncher and the nested
bubblewrap processes. Its `/dev` contained only standard synthetic devices, `dri`, `nvidia0`,
`nvidiactl`, `nvidia-modeset`, and `nvidia-uvm`; no camera, USB or block devices were exposed.
Zoom's main process reported `Seccomp: 0`; this test does not establish a renderer seccomp
guarantee.
The upstream FHS runtime remains intact. Its automatic root mounts propagate directories, so the
wrapper explicitly propagates the outer `/.flatpak-info` file for portal identification. Its `/dev`
bind inherits the outer sandbox's filtered devices. The outer network uses pasta isolate mode,
without host loopback sharing. X11 remains a display trust boundary: explicit ScreenCast portal
permission does not prevent access through the X11 connection.
Initial display testing found that `/tmp` tmpfs shadowed the X11 socket and that NVIDIA OpenGL
needed the four device mappings now in `desktop-base.nix`. Zoom therefore keeps the sandbox's
private root `/tmp` instead of mounting another tmpfs over the socket, and explicitly passes
`DISPLAY` and `XAUTHORITY`.
## Repeat without touching the normal Zoom profile
Build the candidate without activating a host, then substitute its output path below. The temporary
home must be outside `/tmp` when testing policies that mount a `/tmp` tmpfs. Keep the current
display and session bus environment.
```bash
zoom_probe_dir=$(mktemp -d "$HOME/.cache/zoom-nixpak-probe.XXXXXX")
mkdir -p "$zoom_probe_dir"/{.config,.cache,.local/share,.local/state,Documents,Downloads}
HOME="$zoom_probe_dir" \
XDG_CONFIG_HOME="$zoom_probe_dir/.config" \
XDG_CACHE_HOME="$zoom_probe_dir/.cache" \
XDG_DATA_HOME="$zoom_probe_dir/.local/share" \
XDG_STATE_HOME="$zoom_probe_dir/.local/state" \
timeout --kill-after=3 30 /nix/store/OUTPUT/bin/zoom
```
While it runs, inspect the exact test process through `/proc/PID/root/dev`,
`/proc/PID/root/.flatpak-info`, `/proc/PID/ns/net`, `/proc/PID/status`, and its parent processes. Do
not sign in or join a meeting during this smoke test. The timeout terminates the owned launcher and
its sandbox. Confirm the test windows and processes are gone afterward.
The disposable run created `zoomus.conf` under the private application config directory. Existing
host `zoomus.conf` and `zoom.conf` are individually mounted when present. Application caches use the
private application cache directory.
Still pending: login and meeting join, microphone and speaker routing, camera portal behavior on
supported hardware, screencast selection and PipeWire FD handoff, recording/export paths, file
chooser access, URL opening and IME. No successful camera, audio or screencast session is claimed by
this smoke test.
+9 -13
View File
@@ -1,20 +1,16 @@
{
pkgs,
lib,
...
}:
{
home.packages = with pkgs; [
home.packages =
with pkgs;
[
nixpaks.firefox
];
# source code: https://github.com/nix-community/home-manager/blob/master/modules/programs/chromium.nix
# open-source upstream Chromium, the base for all the browsers below
programs.chromium.enable = true;
# the world's most widely used browser, with deep Google account and services integration
programs.google-chrome.enable = true;
# Brave's privacy-first build (Shields ad/tracker blocking), minus the crypto/AI extras
programs.brave-origin.enable = true;
# Microsoft's browser, with built-in phishing/malware protection, x86_64 build only
programs.microsoft-edge.enable = pkgs.stdenv.hostPlatform.isx86_64;
nixpaks.google-chrome
nixpaks.chromium
nixpaks.brave-origin
]
++ lib.optionals pkgs.stdenv.hostPlatform.isx86_64 [ nixpaks.microsoft-edge ];
}
+1 -1
View File
@@ -26,7 +26,7 @@
]
# Zoom: Settings > Share Screen > Advanced > Screen Capture Mode on Wayland > PipeWire Mode.
# zoom-us ships only an x86_64 Linux client, so the aarch64 host skips it.
++ lib.optionals pkgs.stdenv.hostPlatform.isx86_64 [ pkgs.zoom-us ];
++ lib.optionals pkgs.stdenv.hostPlatform.isx86_64 [ pkgs.nixpaks.zoom ];
programs.mpv = {
enable = true;
+1 -1
View File
@@ -16,7 +16,7 @@
# my custom hardened packages
nixpaks.qq
nixpaks.telegram-desktop
bwraps.wechat
nixpaks.wechat
# discord # update too frequently, use the web version instead
];
+1 -1
View File
@@ -17,6 +17,6 @@
# nixpaks
"${pkgs.nixpaks.firefox}/share/applications/org.mozilla.firefox.desktop"
"${pkgs.nixpaks.telegram-desktop}/share/applications/org.telegram.desktop.desktop"
"${pkgs.google-chrome}/share/applications/google-chrome.desktop"
"${pkgs.nixpaks.google-chrome}/share/applications/com.google.Chrome.desktop"
];
}
+5 -2
View File
@@ -14,8 +14,8 @@
defaultApplications =
let
browser = [
"google-chrome.desktop"
"firefox.desktop"
"com.google.Chrome.desktop"
"org.mozilla.firefox.desktop"
];
editor = [
"dev.zed.Zed.desktop"
@@ -48,11 +48,14 @@
"x-scheme-handler/http" = browser;
"x-scheme-handler/https" = browser;
"x-scheme-handler/zoommtg" = [ "Zoom.desktop" ];
"x-scheme-handler/zoomus" = [ "Zoom.desktop" ];
"application/x-zoom" = [ "Zoom.desktop" ];
# all other unknown schemes will be opened by this default application.
# "x-scheme-handler/unknown" = editor;
"x-scheme-handler/tg" = [ "org.telegram.desktop.desktop" ];
"x-scheme-handler/weixin" = [ "wechat.desktop" ];
"audio/*" = [ "mpv.desktop" ];
"video/*" = [ "mpv.desktop" ];
@@ -0,0 +1 @@
{ ... }: true
@@ -0,0 +1,11 @@
{ inputs, outputs, ... }:
let
pkgs = outputs.nixosConfigurations.ai-niri.pkgs;
in
import ../../../../hardening/tests/policy.nix {
inherit pkgs;
mkNixPak = inputs.nixpak.lib.nixpak {
inherit pkgs;
inherit (pkgs) lib;
};
}