NixOS+KDE still has no compositor on 0.27.0 — a capped KWin refuses the /proc/exe rung that #158's fix depends on #161

Merged
enricobuehler merged 1 commits from worktree-nixos-capped-compositor-exe-denied into main 2026-08-10 21:28:12 +00:00
Owner

Field follow-up from the same NixOS + KDE reporter as #158: 0.27.0 installed, decoy shim removed, and the log came back byte-identical to the bug we thought we'd closed.

INFO  punktfunk_host::native::compositor: detected active graphical session active=None wayland="-"
WARN  punktfunk_host::native: session ended with error error=no usable compositor
      (no live graphical session for this uid; set PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)

#158 did ship. 002702bc is an ancestor of v0.27.0 — verified, not assumed. Please don't read this signature as a missing fix; it is a second cause, and the two compose.

Root cause

/proc/<pid>/exe is not readable merely because you own the process. It goes through cap_ptrace_access_check, which requires the reader's effective set to be a superset of the target's permitted set. And nixpkgs' own Plasma module caps KWin — verified in nixos-unstable nixos/modules/services/desktop-managers/plasma6.nix:

kwin_wayland = {
  owner = "root";
  group = "root";
  capabilities = "cap_sys_nice+ep";
  source = "${lib.getBin pkgs.kdePackages.kwin}/bin/kwin_wayland";
};

Our host must stay uncapped — a capability is precisely what makes it unidentifiable to KWin (#136). So it gets EACCES on KWin's exe link.

That is the same kernel gate as #136, read from the other side, and the two NixOS traps are mutually reinforcing: the name needs exe because nixpkgs wrapped the binary, and exe is denied because NixOS capped it. This is the default install, not an exotic combination. detect_active_session went straight back to ActiveKind::None.

Measured, not reasoned

Linux 6.x, same-uid non-root reader, target holding cap_sys_niceidentical for a plain file capability and for the ambient form security.wrappers actually uses:

probe capped target
/proc/<pid> directory owner the real uid — so the md.uid() filter was never implicated (I expected root here; it is not)
comm readable (decorated + truncated, so useless on its own)
exe EACCES
cmdline (argv[0]) readable

End-to-end, old and new resolvers each extracted verbatim from their own source and run against a live capped, .kwin_wayland-wrapped process started with exec -a kwin_wayland:

scenario exe v0.27.0 resolver this PR
wrapped, uncapped (what #158 fixed) readable kwin_wayland kwin_wayland
wrapped + ambient cap (security.wrappers) EACCES .kwin_wayland-w kwin_wayland
wrapped + file cap (plain setcap) EACCES .kwin_wayland-w kwin_wayland

The change

match_name gains a third rung: commexeargv[0] from /proc/<pid>/cmdline. It reads correctly for the same reason ps does — make-wrapper's generated wrapper exec -a "$0"s the hidden binary, so argv[0] survives the decoration comm does not.

argv[0] is consulted last and never overrides a readable exe: it is the process's own claim about itself, and a same-uid process can set it to anything. The exposure that buys is small and one-directional — the worst a spoof achieves is aiming detection at a compositor backend that then fails its own availability probe — whereas without the rung a capped compositor is simply invisible. The comm fast path is still one read on every ordinary distro.

Also reached by the same rung: gamescope carries cap_sys_nice on a number of distros, so a wrapped-and-capped gamescope was equally invisible to the foreign-gamescope probe.

Verification

  • cargo fmt --check clean; scripts/xcheck.sh linux clippy clean (it already passes --all-targets, so the test module compiles).
  • 11 tests, fixture-driven (a temp dir with comm / exe / cmdline) — extracted verbatim and executed on Linux as a non-root user, 0 failures. Fixtures rather than spawned processes for the reason #158 documented: a renamed /bin/sleep stand-in dies instantly on multi-call coreutils and reads exactly like the resolver being broken.
  • ⚠ Full cargo test -p pf-vdisplay not run locally — only 4.9 GiB free on the dev volume, and an emulated amd64 link risks the ENOSPC that kills OrbStack. CI covers it.

Out of scope, same class

punktfunk-host/src/procscan/linux.rs and detect/linux.rs read exe / raw comm for conflicting-host detection, so a capped sunshine/apollo is invisible to them too. Fail-safe direction, left alone deliberately.

Refs #158, #136.

Field follow-up from the same NixOS + KDE reporter as #158: 0.27.0 installed, decoy shim removed, and the log came back **byte-identical** to the bug we thought we'd closed. ``` INFO punktfunk_host::native::compositor: detected active graphical session active=None wayland="-" WARN punktfunk_host::native: session ended with error error=no usable compositor (no live graphical session for this uid; set PUNKTFUNK_COMPOSITOR or start a desktop/gaming session) ``` **#158 did ship.** `002702bc` is an ancestor of `v0.27.0` — verified, not assumed. Please don't read this signature as a missing fix; it is a *second* cause, and the two compose. ## Root cause `/proc/<pid>/exe` is **not** readable merely because you own the process. It goes through `cap_ptrace_access_check`, which requires the reader's **effective** set to be a superset of the target's **permitted** set. And nixpkgs' own Plasma module caps KWin — verified in nixos-unstable `nixos/modules/services/desktop-managers/plasma6.nix`: ```nix kwin_wayland = { owner = "root"; group = "root"; capabilities = "cap_sys_nice+ep"; source = "${lib.getBin pkgs.kdePackages.kwin}/bin/kwin_wayland"; }; ``` Our host must stay uncapped — a capability is precisely what makes it unidentifiable to KWin (#136). So it gets `EACCES` on KWin's `exe` link. That is the same kernel gate as #136, read from the other side, and the two NixOS traps are mutually reinforcing: **the name needs `exe` because nixpkgs wrapped the binary, and `exe` is denied because NixOS capped it.** This is the default install, not an exotic combination. `detect_active_session` went straight back to `ActiveKind::None`. ## Measured, not reasoned Linux 6.x, same-uid non-root reader, target holding `cap_sys_nice` — **identical for a plain file capability and for the ambient form `security.wrappers` actually uses**: | probe | capped target | |---|---| | `/proc/<pid>` directory owner | ✅ the **real uid** — so the `md.uid()` filter was never implicated (I expected root here; it is not) | | `comm` | ✅ readable (decorated + truncated, so useless on its own) | | `exe` | ❌ **EACCES** | | `cmdline` (`argv[0]`) | ✅ readable | End-to-end, old and new resolvers each extracted **verbatim** from their own source and run against a *live* capped, `.kwin_wayland-wrapped` process started with `exec -a kwin_wayland`: | scenario | `exe` | v0.27.0 resolver | this PR | |---|---|---|---| | wrapped, uncapped (what #158 fixed) | readable | `kwin_wayland` ✅ | `kwin_wayland` ✅ | | wrapped + **ambient** cap (`security.wrappers`) | **EACCES** | `.kwin_wayland-w` ❌ | `kwin_wayland` ✅ | | wrapped + file cap (plain `setcap`) | **EACCES** | `.kwin_wayland-w` ❌ | `kwin_wayland` ✅ | ## The change `match_name` gains a third rung: `comm` → `exe` → **`argv[0]`** from `/proc/<pid>/cmdline`. It reads correctly for the same reason `ps` does — make-wrapper's generated wrapper `exec -a "$0"`s the hidden binary, so `argv[0]` survives the decoration `comm` does not. `argv[0]` is consulted **last** and never overrides a readable `exe`: it is the process's own claim about itself, and a same-uid process can set it to anything. The exposure that buys is small and one-directional — the worst a spoof achieves is aiming detection at a compositor backend that then fails its own availability probe — whereas without the rung a capped compositor is simply invisible. The `comm` fast path is still one read on every ordinary distro. Also reached by the same rung: **gamescope** carries `cap_sys_nice` on a number of distros, so a wrapped-and-capped gamescope was equally invisible to the foreign-gamescope probe. ## Verification - `cargo fmt --check` clean; `scripts/xcheck.sh linux clippy` clean (it already passes `--all-targets`, so the test module compiles). - 11 tests, fixture-driven (a temp dir with `comm` / `exe` / `cmdline`) — extracted verbatim and executed on Linux **as a non-root user**, 0 failures. Fixtures rather than spawned processes for the reason #158 documented: a renamed `/bin/sleep` stand-in dies instantly on multi-call coreutils and reads exactly like the resolver being broken. - ⚠ Full `cargo test -p pf-vdisplay` **not** run locally — only 4.9 GiB free on the dev volume, and an emulated amd64 link risks the ENOSPC that kills OrbStack. CI covers it. ## Out of scope, same class `punktfunk-host/src/procscan/linux.rs` and `detect/linux.rs` read `exe` / raw `comm` for conflicting-host detection, so a *capped* sunshine/apollo is invisible to them too. Fail-safe direction, left alone deliberately. Refs #158, #136.
enricobuehler added 1 commit 2026-08-10 21:27:51 +00:00
fix(pf-vdisplay): a CAPPED compositor refuses /proc/exe — NixOS+KDE still undetectable
apple / swift (pull_request) Successful in 1m55s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 3m34s
android / android (pull_request) Successful in 4m27s
ci / rust-arm64 (pull_request) Successful in 1m57s
ci / web (pull_request) Successful in 1m4s
ci / bun-nix (pull_request) Successful in 19s
ci / docs-site (pull_request) Successful in 1m18s
c817e4bec0
v0.27.0 resolved the nixpkgs wrapper decoration (`.kwin_wayland-w`) through `/proc/<pid>/exe`.
On the box that fix was written for, the kernel refuses that link.

Reading `/proc/<pid>/exe` is not gated on owning the process: `cap_ptrace_access_check` requires
the reader's effective set to be a superset of the target's PERMITTED set. NixOS's own Plasma
module ships `security.wrappers.kwin_wayland = { capabilities = "cap_sys_nice+ep"; }`, and our
host must stay uncapped — a capability is precisely what makes it unidentifiable to KWin (#136).
So the two traps compose: the name NEEDS `exe` because nixpkgs wrapped the binary, and `exe` is
DENIED because NixOS capped it. `detect_active_session` went back to `ActiveKind::None`, and every
connect died `no usable compositor` with `wayland="-"` — the identical signature to the bug #158
fixed, which is why a box worked around with a decoy process broke again when the decoy went away.

`match_name` now falls through to `argv[0]` (`/proc/<pid>/cmdline`) when `exe` is unreadable. It
reads correctly for the same reason `ps` does: make-wrapper's wrapper `exec -a "$0"`s the hidden
binary, so `argv[0]` survives the decoration `comm` does not. It is consulted LAST and never
overrides a readable `exe` — it is the process's own claim about itself, and a same-uid process can
set it to anything; the worst a spoof achieves is aiming detection at a backend that then fails its
own availability probe.

MEASURED (Linux 6.x, same-uid reader, target holding cap_sys_nice), for a file capability and for
the ambient form `security.wrappers` actually uses — identically:

  /proc/<pid> owner   real uid   (so the uid filter upstream was never the problem)
  comm                readable   (decorated + truncated, unusable alone)
  exe                 EACCES
  cmdline (argv[0])   readable

End-to-end against a LIVE capped, `.kwin_wayland-wrapped` process, old resolver vs new, both
extracted verbatim from their own sources: v0.27.0 answers `.kwin_wayland-w` (no match) in both
capped scenarios and `kwin_wayland` uncapped; the fixed one answers `kwin_wayland` in all three.

Also reached by the same rung: gamescope carries `cap_sys_nice` on a number of distros, so a
wrapped-and-capped gamescope was equally invisible to the foreign-gamescope probe.

Tests are fixture-driven (a temp dir with `comm`/`exe`/`cmdline`), for the reason #158 documented:
a renamed `/bin/sleep` stand-in dies instantly on multi-call coreutils and reads exactly like the
resolver being broken. 11 tests extracted verbatim and executed on Linux as a non-root user —
0 failures. `cargo fmt --check` and `scripts/xcheck.sh linux clippy` (`--all-targets`) clean.

Refs #158, #136.
enricobuehler merged commit e1198495e0 into main 2026-08-10 21:28:12 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#161