Files
nix-config/hardening/tests/runtime-validation.md
T
ryan4yin | 二花 08d2ff7578 feat(nixpak): sandbox umu-launcher with ~/Gaming as its home (#519)
* feat(nixpak): sandbox umu-launcher with ~/Games as its home

umu-run, winetricks and bbe leave the host PATH, so umu launchers
generated before this stop working until rewritten with
`umu-install.nu <name> - <launcher> <gameid>`.

Assisted-by: Claude Agent SDK (claude-opus-5-5)

* refactor(nixpak): mount ~/Gaming as the game sandbox home

idols-ai now preserves ~/Gaming instead of ~/Games, so existing games
vanish from the host after deploy until the preserved ~/Games is moved
to ~/Gaming/Games.

Assisted-by: Claude Agent SDK (claude-opus-5-5)

* docs(nixpak): record umu runtime validation

Assisted-by: Claude Agent SDK (claude-opus-5-5)
Assisted-by: DeepSeek Harness (deepseek-flash)
2026-11-05 22:39:48 -06:00

159 lines
9.5 KiB
Markdown

# Runtime validation
Nixpak wrappers are accepted in three layers:
1. **Policy/build:** `just test` evaluates every wrapper and [`policy.nix`](policy.nix) rejects
broad mounts, system D-Bus, keyring access and device grants outside each app's declared scope.
2. **Disposable smoke test:** [`verify-chromium.py`](verify-chromium.py) launches a Chromium-family
wrapper with a temporary HOME and validates renderer sandboxing, environment clearing, bind
scope, profile persistence and loopback policy.
3. **Interactive desktop check:** launch the real app, use its normal file/network/device workflows,
and confirm the sandbox does not break required integration.
## Current status
| App | Policy/build | Runtime |
| ---------------------- | ------------ | ----------------------------------------------------------------- |
| Firefox | Pass | Interactive pass; camera/remote sharing intentionally unavailable |
| Chromium | Pass | Automated smoke + interactive pass |
| Chrome | Pass | Interactive pass |
| Brave Origin | Pass | Interactive pass; backup browser without media grants |
| Edge | Pass | Interactive pass; backup browser without media grants |
| Zoom | Pass | Interactive pass |
| QQ / Telegram | Pass | Interactive startup/tray pass |
| WeChat (native nixpak) | Pass | Interactive Wayland validation pending |
| Foliate | Pass | Interactive validation pending |
| Krita | Pass | Interactive validation pending |
| Bambu Studio | Pass | Interactive pass; device_page/filament UI unavailable in nixpkgs |
| OrcaSlicer | Pass | Interactive pass; blank WebKit home page fixed |
| Remmina | Pass | Interactive validation pending |
| OBS Studio | Pass | Interactive pass; NVENC recording and local RTMP streaming work |
| Blender | Pass | Interactive validation pending |
| MuseScore | Pass | Interactive launch pass; ALSA routed to default PipeWire sink |
| mpv | Pass | Interactive validation pending |
| imv | Pass | Interactive validation pending |
| umu | Pass | Wuthering Waves and Star Rail from their desktop entries |
Intentional exceptions:
- **Chromium** keeps host loopback and personal files for local development. Firefox has the same
exception. Chrome, Brave Origin and Edge use pasta-isolated loopback.
- **Brave Origin and Edge are backup browsers.** They intentionally have no PulseAudio, Camera,
ScreenCast or MPRIS grants.
- **Firefox camera and remote screen sharing are intentionally disabled.** Firefox does not support
the XDG Camera/ScreenCast portal path used here, so enabling them would require granting raw video
devices or the direct PipeWire socket. We deliberately do not grant those privileges.
Known packaging limitations:
- **Bambu Studio:** the nixpkgs build removes the `device_page` frontend build, so
`share/BambuStudio/web/device_page/dist` is missing and filament management cannot load.
- **Bambu Studio:** the MakerWorld sidebar entry requires `firstguide.finish = "true"` in
`BambuStudio.conf`; the upstream guide writes `"1"` while the web-view code only accepts `"true"`.
- **Bambu Studio:** the startup splash can render black under XWayland. This is cosmetic; the main
UI renders normally.
- **MuseScore:** uses Qt's XCB backend, so it runs through XWayland. Its ALSA driver uses the host
`pcm.!default` configuration, which forwards to PipeWire; this requires the raw PipeWire socket.
Launch and audio routing pass, but audible playback and score/print/export workflows still need
user confirmation.
## Game launcher checklist
For umu now, and Heroic and Steam when they move into the sandbox. Run each with the game installed
through the `nix-config-umu-game` skill and launched from its desktop entry:
1. The launcher window opens and the nested pressure-vessel container starts (`ENABLE_LOG=1`, then
check `last-run.log` for bwrap or `/.flatpak-info` errors).
2. Frame rate matches an unsandboxed run of the same scene with the MangoHud overlay; the game uses
the NVIDIA GPU, and a 32-bit game renders.
3. Audio plays; a USB or Bluetooth controller connected before launch works, and a keyboard does not
appear under `/dev/input` inside the sandbox.
4. The launcher downloads at the usual speed through pasta, and online play connects.
5. Inside `umu-sandbox bash`: `ls -A ~` shows the contents of the host's `~/Gaming` and nothing else
from the host home.
6. Running `~/Gaming/Games/<name>/run` from a host shell refuses to start.
umu on dwproton-11.0-15: Wuthering Waves and Star Rail both launch from their desktop entries, and a
controller works. Wuthering Waves' `PROTON_LOG` shows pressure-vessel starting inside the sandbox,
DXVK and vkd3d-proton on the RTX 4090, NVAPI, fsync and pasta networking, with nothing denied by the
sandbox. Still to check: audio, a 32-bit game, the keyboard exclusion and item 5. Wuthering Waves'
ACE anti-cheat driver crashes `winedevice.exe` on an unimplemented `ntoskrnl.exe` function, a Wine
gap; the game runs regardless. The first launch after a Proton update or a moved umu runtime can
show no window for a minute while steamrt4 is prepared and the prefix upgraded.
## Browser smoke test
Build a wrapper from the locked inputs:
```bash
APP=chromium 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; };
}
'
python3 hardening/tests/verify-chromium.py "browser-validation/$APP/bin/$APP"
```
The script writes its disposable HOME under `/var/tmp`; the sandbox's private `/tmp` would otherwise
hide profile binds. Pasta-wrapped browsers also require a usable `/dev/net/tun` on the host. The
script checks live renderer `Seccomp=2` and `NoNewPrivs=1`, absence of `--no-sandbox`, canary-free
environments, effective bwrap arguments, profile persistence and loopback reachability.
Chromium-family browsers use the app-scoped Secret portal for v12 cookie encryption; they do not
receive `org.freedesktop.secrets`. If the portal is unavailable, Chromium can fall back to weaker
v10 storage; monitor `Local State.os_crypt.portal.prev_init_success`. Firefox uses NSS storage
instead. Browserpass separately exposes the password store and GPG-agent socket to
Firefox/Chromium/Chrome only.
## FIDO2 passthrough
Passed after a reboot on `ai` (CanoKey Canary, PR #515 head): with the key present at boot, the host
linked only its FIDO interface into `/dev/fido-hidraw`, and WebAuthn works in Firefox and Chrome.
Chromium, plugging the key in after browser launch, and the in-sandbox keyboard-interface check are
still pending. Full checklist:
1. On the host, `ls -l /dev/fido-hidraw/` lists only the FIDO interface (`ID_FIDO_TOKEN=1`); it
follows plug and unplug.
2. Firefox/Chromium: start without the key, plug it in, then register and sign in on
<https://webauthn.io>. Unplug and replug without restarting, then sign in again.
3. Chrome: the same flow with the key present at launch; record whether a later plug is detected.
4. In each sandbox, the CanoKey keyboard interface's `/dev/hidrawN` fails to open with `ENOENT`.
## Zoom
Zoom keeps its upstream FHS runtime inside the outer nixpak boundary. The nested sandbox propagates
`/.flatpak-info`, uses pasta-isolated networking, and binds only `DISPLAY`, `XAUTHORITY`,
`/dev/video0`, `/dev/video1`, DRM sysfs and the declared app directories. X11 remains a
shared-display trust boundary: ScreenCast portal permission does not prevent capture through the X11
connection. Interactive login, audio, camera, screen sharing and file workflows passed.
## OBS
Interactive validation covered PipeWire screen capture, V4L2 camera capture, desktop/microphone
audio, NVENC H.264 Hybrid MP4 recording and RTMP publishing. Streaming was tested against a
temporary MediaMTX server on the host's LAN address; the OBS pasta namespace cannot reach host
loopback. Bilibili or another external provider still needs a real account-specific stream key to
validate provider authentication and CDN ingest.
OBS records to the path configured under output settings. In Simple mode that is
`SimpleOutput.FilePath`; Advanced mode uses `AdvOut.RecFilePath` and `AdvOut.FFFilePath`. These must
point at a writable bind such as `~/Videos`, not the sandbox's transient home root.
## Commands
```bash
just fmt
just test
git diff --check
ruff format --check hardening/tests/verify-chromium.py
ruff check hardening/tests/verify-chromium.py
prettier --check hardening/tests/runtime-validation.md
```