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:
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:
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-vdisplaynot 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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.
#158 did ship.
002702bcis an ancestor ofv0.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>/exeis not readable merely because you own the process. It goes throughcap_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-unstablenixos/modules/services/desktop-managers/plasma6.nix:Our host must stay uncapped — a capability is precisely what makes it unidentifiable to KWin (#136). So it gets
EACCESon KWin'sexelink.That is the same kernel gate as #136, read from the other side, and the two NixOS traps are mutually reinforcing: the name needs
exebecause nixpkgs wrapped the binary, andexeis denied because NixOS capped it. This is the default install, not an exotic combination.detect_active_sessionwent straight back toActiveKind::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 formsecurity.wrappersactually uses:/proc/<pid>directory ownermd.uid()filter was never implicated (I expected root here; it is not)commexecmdline(argv[0])End-to-end, old and new resolvers each extracted verbatim from their own source and run against a live capped,
.kwin_wayland-wrappedprocess started withexec -a kwin_wayland:exekwin_wayland✅kwin_wayland✅security.wrappers).kwin_wayland-w❌kwin_wayland✅setcap).kwin_wayland-w❌kwin_wayland✅The change
match_namegains a third rung:comm→exe→argv[0]from/proc/<pid>/cmdline. It reads correctly for the same reasonpsdoes — make-wrapper's generated wrapperexec -a "$0"s the hidden binary, soargv[0]survives the decorationcommdoes not.argv[0]is consulted last and never overrides a readableexe: 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. Thecommfast path is still one read on every ordinary distro.Also reached by the same rung: gamescope carries
cap_sys_niceon a number of distros, so a wrapped-and-capped gamescope was equally invisible to the foreign-gamescope probe.Verification
cargo fmt --checkclean;scripts/xcheck.sh linux clippyclean (it already passes--all-targets, so the test module compiles).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/sleepstand-in dies instantly on multi-call coreutils and reads exactly like the resolver being broken.cargo test -p pf-vdisplaynot 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.rsanddetect/linux.rsreadexe/ rawcommfor conflicting-host detection, so a capped sunshine/apollo is invisible to them too. Fail-safe direction, left alone deliberately.Refs #158, #136.
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.