Files
nix-config/hardening/tests/policy-lib.nix
T
ryan4yin | 二花 b726c28c14 feat(nixpak): sandbox Steam and Heroic in ~/Gaming (#520)
* feat(nixpak): sandbox Steam and Heroic in ~/Gaming

idols-ai stops preserving ~/.local/share/Steam, ~/.steam, ~/.config/heroic
and their game data, which must be moved into ~/Gaming before deploying.

Assisted-by: DeepSeek Harness (deepseek-flash)

* refactor(nixpak): replace steam-fix.nu with sandboxed protontricks

Assisted-by: DeepSeek Harness (claude-opus-5-5)

* fix(nixpak): hide host documents and GTK config from game sandboxes

Assisted-by: DeepSeek Harness (claude-opus-5-5)

* docs(ai): locate the Steam library under ~/Gaming

Assisted-by: DeepSeek Harness (claude-opus-5-5)

* fix(umu-game): confine writes to ~/Gaming and keep foreign entries

Assisted-by: DeepSeek Harness (claude-opus-5-5)

* fix(nixpak): keep Steam and Heroic in the application launcher

The wrappers only carried bin/, so neither launcher had a desktop entry
after replacing programs.steam.

Assisted-by: DeepSeek Harness (deepseek-flash)

* fix(nixpak): give the game sandboxes a fontconfig that sees CJK fonts

Steam rendered Chinese as tofu: the FHS layers build their own /etc/fonts,
whose config lists only DejaVu and the XDG font directory, so neither the
host fonts nor the FHS /usr/share/fonts were ever scanned. Point
FONTCONFIG_FILE at a small config that includes /usr/share/fonts, and ship
WenQuanYi Zen Hei and Noto Sans CJK in all three FHS layers.

Assisted-by: DeepSeek Harness (deepseek-flash)

* fix(nixpak): grant the game launchers a StatusNotifier tray icon

Steam and Heroic register a tray icon over D-Bus, and the game sandboxes
granted no StatusNotifierWatcher access, so the desktop shell had nothing
to show for them. Enable desktopIntegration.tray for all three launchers
and teach the policy suites to expect it.

Assisted-by: DeepSeek Harness (deepseek-flash)

* fix(nixpak): drop the duplicate host CJK font and tidy the font overrides

Steam reads the fonts inside its own FHS layer, so the system-wide wqy_zenhei
added for it no longer serves any sandbox. Give gaming-policy its own comment
for FONTCONFIG_FILE instead of leaving it under the compat-tool one, make the
desktop-base value a default, and note the SNI tray grant in the README table.

Assisted-by: DeepSeek Harness (deepseek-flash)
2026-11-06 02:18:33 -06:00

257 lines
9.3 KiB
Nix

{ pkgs, mkNixPak }:
let
inherit (pkgs) lib;
load = name: (pkgs.callPackage (../nixpaks + "/${name}.nix") { inherit mkNixPak; }).config;
# Wrapper files are the source of truth for the app inventory. The test
# loader compares this with the per-app suites under ./policy.
appNames = map (lib.removeSuffix ".nix") (
builtins.attrNames (
lib.filterAttrs (
name: type: type == "regular" && name != "default.nix" && lib.hasSuffix ".nix" name
) (builtins.readDir ../nixpaks)
)
);
apps = map load appNames;
mountStrings =
app: builtins.filter builtins.isString (lib.flatten (with app.bubblewrap.bind; rw ++ ro ++ dev));
# Render a sloth value as the launcher would, with $VAR for environment
# lookups, so suites can assert on paths built from $HOME and friends.
renderSloth =
value:
if builtins.isString value then
value
else if value.type == "env" then
"$" + value.key
else if value.type == "concat" then
renderSloth value.a + renderSloth value.b
else if value.type == "mkdir" then
renderSloth value.dir
else
throw "cannot render sloth value of type ${value.type}";
# A bind entry is a path, or a [ source destination ] pair.
renderBind = entry: if builtins.isList entry then map renderSloth entry else renderSloth entry;
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";
};
forbiddenMount =
path:
path == "/dev"
|| lib.hasPrefix "/dev/video" path
|| lib.hasPrefix "/dev/bus/usb" path
|| path == "/dev/uinput"
|| path == "/run/dbus"
|| path == "/run/dbus/system_bus_socket"
|| path == "/sys"
|| path == "/run/udev";
hasHostFontconfig = app: lib.elem [ "/etc/static/fonts" "/etc/fonts" ] app.bubblewrap.bind.ro;
hasPrivateTmp =
app:
let
tmpPath = lib.findFirst (
entry: builtins.isList entry && builtins.length entry == 2 && builtins.elem "/tmp" entry
) null app.bubblewrap.bind.rw;
in
builtins.isList tmpPath && builtins.length tmpPath == 2;
hasDbusFragment = app: fragment: lib.any (argument: lib.hasInfix fragment argument) app.dbus.args;
networkCheck =
app: mode:
if mode == "host" then
app.bubblewrap.network && !app.pasta.enable
else if mode == "pasta" then
app.bubblewrap.network
&& app.pasta.enable
&& app.pasta.mode == "isolate"
&& lib.elem "--no-map-gw" app.pasta.args
else if mode == "none" then
!app.bubblewrap.network && !app.pasta.enable
else
throw "unknown network mode for ${app.flatpak.appId}: ${mode}";
mediaCheck =
app: expected: allowPipewire:
if expected then
app.portalGroups.camera && app.portalGroups.screenCast
else
(!app.bubblewrap.sockets.pipewire || allowPipewire)
&& !app.portalGroups.camera
&& !app.portalGroups.screenCast
&& !(hasDbusFragment app "org.freedesktop.portal.Camera")
&& !(hasDbusFragment app "org.freedesktop.portal.ScreenCast");
secretPortalCheck =
app: expected:
if expected then
(app.browserPermissions.secretPortal or false)
&& lib.any (
argument:
argument
== "--call=org.freedesktop.portal.Desktop=org.freedesktop.portal.Secret.RetrieveSecret@/org/freedesktop/portal/desktop"
) app.dbus.args
else
!(app.browserPermissions.secretPortal or false)
&& !(hasDbusFragment app "org.freedesktop.portal.Secret");
# FIDO2 needs both the udev-maintained node directory and the bwrap wrapper
# that links /dev/hidrawN into it; neither may leak to other apps.
fido2Check =
app: expected:
(app.browserPermissions.fido2 or false) == expected
&& lib.elem "/dev/fido-hidraw" app.bubblewrap.bind.dev == expected
&& (lib.getName app.bubblewrap.package == "bwrap") == expected;
trayCheck =
app: expected:
if expected then
app.desktopIntegration.tray
&& (app.dbus.policies."org.freedesktop.DBus" or null) == "talk"
&& (app.dbus.policies."org.kde.StatusNotifierWatcher" or null) == "talk"
else
!app.desktopIntegration.tray;
# Checks shared by every game launcher suite (modules/gaming-policy.nix):
# ~/Gaming is the sandbox home and the only host home path, joysticks are
# the only input devices, compat tools come from the store, and the launcher
# grants no file chooser or document portal.
gamingChecks =
name:
let
app = load name;
rw = map renderBind app.bubblewrap.bind.rw;
source = entry: if builtins.isList entry then builtins.head entry else entry;
sources = binds: map (entry: source (renderBind entry)) binds;
in
{
sharedHome = lib.elem [ "$HOME/Gaming" "$HOME" ] rw;
noXdgStorage = !app.xdgStorage.enable;
writableScope = lib.all (
path: path == "$HOME/Gaming" || path == "$XDG_RUNTIME_DIR/nixpak-${app.flatpak.appId}-tmp"
) (sources app.bubblewrap.bind.rw);
noHostHomeReads =
!lib.any (
path:
lib.any (prefix: lib.hasPrefix prefix path) [
"$HOME"
"$XDG_CACHE_HOME"
"$XDG_CONFIG_HOME"
"$XDG_DATA_HOME"
"$XDG_STATE_HOME"
]
) (sources app.bubblewrap.bind.ro);
gamepadsOnly =
lib.elem [
"/dev/gamepad-input"
"/dev/input"
] app.bubblewrap.bind.dev
&& !lib.elem "/dev/input" (sources app.bubblewrap.bind.dev)
&& !lib.any (path: lib.hasPrefix "/dev/hidraw" path) (lib.flatten app.bubblewrap.bind.dev);
driver32 = lib.elem "/run/opengl-driver-32" app.bubblewrap.bind.ro;
compatToolsInStore = lib.all (lib.hasPrefix builtins.storeDir) (
lib.splitString ":" app.bubblewrap.env.STEAM_EXTRA_COMPAT_TOOLS_PATHS
);
openUri = app.portalGroups.openUri;
noFileChooser = !app.portalGroups.fileChooser;
};
mkAppPolicy =
name: spec:
let
app = load name;
allowedVideoDevices = spec.allowedVideoDevices or [ ];
forbiddenMountFor = path: forbiddenMount path && !lib.elem path allowedVideoDevices;
checks = {
"${name}/clearEnv" = app.bubblewrap.clearEnv;
"${name}/dbus" = app.dbus.enable;
"${name}/noRawPipewire" = !app.bubblewrap.sockets.pipewire || (spec.allowPipewire or false);
"${name}/noForbiddenDbus" = !lib.any forbiddenDbus app.dbus.args;
"${name}/noBroadKeyring" =
!lib.any (argument: argument == "--talk=org.freedesktop.secrets") app.dbus.args;
"${name}/noForbiddenMounts" = !lib.any forbiddenMountFor (mountStrings app);
"${name}/hostFontconfig" = hasHostFontconfig app;
"${name}/noSandboxFonts" = !app.fonts.enable;
"${name}/privateTmp" = hasPrivateTmp app;
"${name}/network" = networkCheck app spec.network;
"${name}/x11" = app.bubblewrap.sockets.x11 == (spec.x11 or false);
"${name}/media" = mediaCheck app (spec.media or false) (spec.allowPipewire or false);
"${name}/pulse" = app.bubblewrap.sockets.pulse == (spec.pulse or (spec.media or false));
"${name}/mpris" = app.desktopIntegration.mpris == (spec.mpris or false);
"${name}/notifications" = app.desktopIntegration.notifications == (spec.notifications or true);
"${name}/tray" = trayCheck app (spec.tray or false);
"${name}/browserpass" =
(app.browserPermissions.browserpass or false) == (spec.browserpass or false);
"${name}/fido2" = fido2Check app (spec.fido2 or false);
"${name}/personalFiles" =
(app.browserPermissions.personalFiles or false) == (spec.personalFiles or false);
"${name}/secretPortal" = secretPortalCheck app (spec.secretPortal or false);
# nixpak resolves "env" entries from the launcher environment.
"${name}/timeZonePassthrough" =
app.bubblewrap.env ? TZ
&& app.bubblewrap.env.TZ.type == "env"
&& app.bubblewrap.env ? TZDIR
&& app.bubblewrap.env.TZDIR.type == "env";
};
extraChecks = lib.mapAttrs' (checkName: value: {
name = "${name}/${checkName}";
inherit value;
}) (spec.extraChecks or { });
duplicates = lib.attrNames (lib.intersectAttrs checks extraChecks);
in
if duplicates != [ ] then
throw "duplicate nixpak policy checks for ${name}: ${lib.concatStringsSep ", " duplicates}"
else
checks // extraChecks;
globalChecks =
let
paths = map (app: app.bubblewrap.bind.rw) apps;
tmpPaths = map (
bind:
lib.findFirst (
entry: builtins.isList entry && builtins.length entry == 2 && builtins.elem "/tmp" entry
) null bind
) paths;
in
{
appTmpIsolation =
lib.all (path: builtins.isList path && builtins.length path == 2) tmpPaths
&& lib.length (lib.unique (map builtins.head tmpPaths)) == builtins.length apps;
};
in
{
inherit
lib
appNames
load
mountStrings
renderBind
gamingChecks
withoutSecretPortal
mkAppPolicy
globalChecks
;
}