josie / alder-tools

VID unprivileged: setcap'd minimal helper + two-gate setup script

- aldermon-msr (new bin target): the ONLY holder of cap_sys_rawio+ep;
  argv = cpu ids (<=4096), reads hard-coded MSR 0x198, prints per-cpu
  hex/"-"; app + deps stay unprivileged
- sensors.rs: MsrAccess{Direct,Helper,None} probed once; vid_msrs(access,n)
  batches all cpus into ONE helper exec per poll; exe-sibling then PATH
  lookup; parse_helper_stdout pads short/garbled output
- ui.rs/main.rs: App.msr_access replaces msr_readable; "VID n/a (no msr
  access)" replaces "(needs root)"; --vid prints no-access hint
- powercap-pl-rules.sh -> aldermon-setup.sh: now installs BOTH rules —
  60-aldermon-powercap (RUN chmod, no devnode) AND 60-aldermon-msr
  (MODE/GROUP 0440 adm; msr IS a devnode). UAT finding: open(/dev/cpu/N/msr)
  checks DAC BEFORE msr_open's capable() — setcap alone is inert on 0600
  nodes; capability alone never grants DAC
- Makefile: install aldermon-msr + setup script; setcap in install when
  DESTDIR empty (re-applied every install, rebuilds drop file caps);
  uninstall removes helper + legacy rule script
- README: privileged-setup section rewritten (two gates, adm group)
- 28 tests, clippy -D/fmt clean; non-root degradation pty-verified 30x110;
  UAT-CONFIRMED LIVE: regular user sees blue VID overlay via setcap'd
  helper (no root)

7b77cafa6402fb1d9d01c4ae47caf2a9b0d87724
josie <josie@example.com> · 2026-10-08T19:46 · browse files at this commit

parents: e719f77

diff --git a/aldermon/Makefile b/aldermon/Makefile
index 958e459..75738f1 100644
--- a/aldermon/Makefile
+++ b/aldermon/Makefile
@@ -5,8 +5,9 @@ SYSCONF ?= /etc
 DATADIR ?= /usr/share
 
 BIN      := $(DESTDIR)$(PREFIX)/bin/aldermon
+MSRBIN   := $(DESTDIR)$(PREFIX)/bin/aldermon-msr
 CONF     := $(DESTDIR)$(SYSCONF)/aldermon/aldermon.conf
-HELPER   := $(DESTDIR)$(DATADIR)/aldermon/powercap-pl-rules.sh
+SETUP    := $(DESTDIR)$(DATADIR)/aldermon/aldermon-setup.sh
 
 .PHONY: all build check install uninstall clean
 
@@ -22,13 +23,21 @@ check:
 
 install: build
 	install -Dm755 target/release/aldermon $(BIN)
+	install -Dm755 target/release/aldermon-msr $(MSRBIN)
 	install -Dm644 aldermon.conf $(CONF)
-	install -Dm755 powercap-pl-rules.sh $(HELPER)
+	install -Dm755 aldermon-setup.sh $(SETUP)
+ifeq ($(DESTDIR),)
+	# VID needs the cap half of a two-gate check (msr nodes also go 0440 adm
+	# via aldermon-setup.sh); grant cap_sys_rawio to the read-only helper
+	# only. Re-applied on every install since rebuilding drops file caps.
+	setcap cap_sys_rawio+ep $(MSRBIN)
+endif
 
 uninstall:
-	rm -f $(BIN) $(CONF) $(HELPER)
+	rm -f $(BIN) $(MSRBIN) $(CONF) $(SETUP)
+	rm -f $(DESTDIR)$(DATADIR)/aldermon/powercap-pl-rules.sh
 	rmdir -p --ignore-fail-on-non-empty $(CONF) 2>/dev/null || true
-	rmdir -p --ignore-fail-on-non-empty $(HELPER) 2>/dev/null || true
+	rmdir -p --ignore-fail-on-non-empty $(SETUP) 2>/dev/null || true
 
 clean:
 	cargo clean
diff --git a/aldermon/README.md b/aldermon/README.md
index 8f06b95..f0ac840 100644
--- a/aldermon/README.md
+++ b/aldermon/README.md
@@ -7,7 +7,7 @@ voltage for overclock-safety work.
 
 ```sh
 aldermon --tui          # TUI (default workhorse)
-aldermon --vid          # VID spike tool (debug, needs root + msr module)
+aldermon --vid          # VID spike tool (debug; needs MSR access, see below)
 aldermon --log          # append CSV samples to ./aldermon-vid.log
 aldermon                # one-shot sensor dump
 aldermon --help         # options
@@ -15,8 +15,10 @@ aldermon --help         # options
 
 The TUI always shows the delivered vCore (SIO in0), the CPU's requested SVID
 setpoint (IA32_PERF_STATUS), and the delta (delivered − requested; negative =
-VRM droop, positive = LLC overshoot). The VID read needs root +
-`/dev/cpu/*/msr`; without it the panel shows `VID n/a (needs root)`.
+VRM droop, positive = LLC overshoot). VID needs MSR access: run under root,
+or install once (below) — the setcap'd `aldermon-msr` helper holds
+`CAP_SYS_RAWIO`, the app itself stays unprivileged. Without access the panel
+shows `VID n/a (no msr access)`.
 
 `--log` appends one CSV row per poll to `./aldermon-vid.log` (header +
 per-CPU VID / frequency / raw MSR columns). With `--tui` it logs live; with
@@ -70,21 +72,28 @@ make check      # fmt + clippy + tests
 doas make install   # PREFIX=/usr/local by default
 ```
 
-Installs `aldermon` to `$(PREFIX)/bin`, this repo's `aldermon.conf` to
-`/etc/aldermon/aldermon.conf` (system default — override per-user via
-`~/.config/aldermon/`), and the udev helper to `/usr/share/aldermon/`.
-`make uninstall` reverses it (leaves the system conf dir if non-empty).
+Installs `aldermon` + `aldermon-msr` (VID helper) to `$(PREFIX)/bin`, this
+repo's `aldermon.conf` to `/etc/aldermon/aldermon.conf` (system default —
+override per-user via `~/.config/aldermon/`), and the setup script to
+`/usr/share/aldermon/`. `make install` also setcaps the helper (skipped for
+staged `DESTDIR=` installs — `aldermon-setup.sh` applies it). `make
+uninstall` reverses it (leaves the system conf dir if non-empty).
 
-## Package power (RAPL) — one-time setup
-
-`energy_uj` is root-only on this kernel. To read watts as a normal user,
-install the udev rule once as root (comes from `make install`):
+## Privileged setup — one-time (RAPL watts + VID)
 
 ```sh
-doas /usr/share/aldermon/powercap-pl-rules.sh
+doas /usr/share/aldermon/aldermon-setup.sh
 doas usermod -aG adm $USER   # then re-login
 ```
 
+1. RAPL: `energy_uj` is root-only, so the script installs a udev rule that
+   chgrps it 0440 to group `adm` — watts then work unprivileged.
+2. VID: opening `/dev/cpu/*/msr` passes two gates — the file-mode check
+   (node is 0600; `CAP_SYS_RAWIO` does NOT override DAC) and `msr_open()`'s
+   capability check. The script installs a udev rule making the nodes
+   `0440 adm` and sets `cap_sys_rawio+ep` on `aldermon-msr`, a read-only
+   helper that touches exactly one MSR; the app never holds the capability.
+
 ## Status
 
 Working: TUI, config (+ system /etc default), RAPL power. Remaining
diff --git a/aldermon/aldermon-setup.sh b/aldermon/aldermon-setup.sh
new file mode 100755
index 0000000..39bb1c5
--- /dev/null
+++ b/aldermon/aldermon-setup.sh
@@ -0,0 +1,69 @@
+#!/bin/sh
+# aldermon-setup.sh - one-shot privileged setup for aldermon (run once as
+# root; idempotent, re-running overwrites installed files):
+#
+#   1. RAPL watts: energy_uj is 0400 root-only. Installs a udev rule + RUN
+#      helper chgrp'ing it 0440 to group `adm` so the TUI reads watts
+#      unprivileged.
+#   2. VID: opening /dev/cpu/N/msr must pass TWO gates: the DAC file-mode
+#      check (node is 0600 root:root — CAP_SYS_RAWIO does NOT override DAC),
+#      then msr_open()'s capable(CAP_SYS_RAWIO). So: udev puts the nodes at
+#      0440 group `adm` (gate 1), and cap_sys_rawio+ep goes on the minimal
+#      read-only helper aldermon-msr (gate 2, reads only 0x198). Neither
+#      alone works; the app itself never holds the capability.
+#
+#   doas ./aldermon-setup.sh            (from the source tree)
+#   doas /usr/share/aldermon/aldermon-setup.sh   (after make install)
+
+set -eu
+
+PREFIX=${PREFIX:-/usr/local}
+MSRBIN=$PREFIX/bin/aldermon-msr
+HELPER=$PREFIX/sbin/aldermon-powercap-chmod
+RULE=/etc/udev/rules.d/60-aldermon-powercap.rules
+
+# Helper: relax energy_uj to 0440, group adm, on every powercap dir.
+cat > "$HELPER" <<'EOF'
+#!/bin/sh
+for d in /sys/class/powercap/intel-rapl*; do
+    [ -e "$d/energy_uj" ] && chgrp adm "$d/energy_uj" && chmod 0440 "$d/energy_uj"
+done
+exit 0
+EOF
+chmod 0755 "$HELPER"
+
+# RUN+= (not MODE=/GROUP=) - powercap has no devnode, so udev's standard
+# MODE/GROUP assignment doesn't apply; we chmod in-process instead.
+cat > "$RULE" <<EOF
+SUBSYSTEM=="powercap", ACTION=="add", RUN+="$HELPER"
+EOF
+
+# Apply immediately so a reboot isn't required.
+"$HELPER"
+
+# VID gate 1: unlike powercap, msr IS a real devnode (SUBSYSTEM=="msr",
+# KERNEL=="msr0..N", DEVNAME=/dev/cpu/N/msr), so udev MODE/GROUP works
+# directly — put the nodes 0440 group adm.
+MSRRULE=/etc/udev/rules.d/60-aldermon-msr.rules
+cat > "$MSRRULE" <<'EOF'
+KERNEL=="msr[0-9]*", SUBSYSTEM=="msr", MODE="0440", GROUP="adm"
+EOF
+udevadm control --reload-rules
+udevadm trigger --subsystem-match=msr
+
+# VID gate 2: hand the capability to the helper only. Fail loudly if it's
+# missing — without the cap the helper is inert and the TUI shows "VID n/a".
+if [ -x "$MSRBIN" ]; then
+    setcap cap_sys_rawio+ep "$MSRBIN"
+    echo "  setcap cap_sys_rawio+ep $MSRBIN"
+else
+    echo "aldermon-setup: $MSRBIN not found - run 'doas make install' first," >&2
+    echo "  or use aldermon --vid under root." >&2
+    exit 1
+fi
+
+echo "Installed:"
+echo "  $HELPER"
+echo "  $RULE"
+echo "  $MSRRULE"
+echo "Add yourself to adm if not already: doas usermod -aG adm \$USER"
\ No newline at end of file
diff --git a/aldermon/powercap-pl-rules.sh b/aldermon/powercap-pl-rules.sh
deleted file mode 100755
index c852ef8..0000000
--- a/aldermon/powercap-pl-rules.sh
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/bin/sh
-# powercap-pl-rules.sh - one-shot installer for RAPL access (aldermon).
-#
-# RAPL energy_uj is 0400 root-only on this kernel, which gates the power
-# panel. This installs a udev rule + helper so any user in group `adm`
-# can read energy counters. Run once as root:
-#
-#   doas ./powercap-pl-rules.sh
-#
-# Idempotent: re-running overwrites installed files.
-
-set -eu
-
-HELPER=/usr/local/sbin/aldermon-powercap-chmod
-RULE=/etc/udev/rules.d/60-aldermon-powercap.rules
-
-# Helper: relax energy_uj to 0440, group adm, on every powercap dir.
-cat > "$HELPER" <<'EOF'
-#!/bin/sh
-for d in /sys/class/powercap/intel-rapl*; do
-    [ -e "$d/energy_uj" ] && chgrp adm "$d/energy_uj" && chmod 0440 "$d/energy_uj"
-done
-exit 0
-EOF
-chmod 0755 "$HELPER"
-
-# RUN+= (not MODE=/GROUP=) - powercap has no devnode, so udev's standard
-# MODE/GROUP assignment doesn't apply; we chmod in-process instead.
-cat > "$RULE" <<EOF
-SUBSYSTEM=="powercap", ACTION=="add", RUN+="$HELPER"
-EOF
-
-# Apply immediately so a reboot isn't required.
-"$HELPER"
-
-echo "Installed:"
-echo "  $HELPER"
-echo "  $RULE"
-echo "Add yourself to adm if not already: doas usermod -aG adm \$USER"
\ No newline at end of file
diff --git a/aldermon/src/bin/aldermon-msr.rs b/aldermon/src/bin/aldermon-msr.rs
new file mode 100644
index 0000000..08a1e73
--- /dev/null
+++ b/aldermon/src/bin/aldermon-msr.rs
@@ -0,0 +1,53 @@
+//! aldermon-msr — minimal setcap'd helper for VID reads.
+//!
+//! The kernel gates /dev/cpu/N/msr twice: file mode (0600 root:root;
+//! CAP_SYS_RAWIO does NOT override DAC) AND msr_open()'s capable check —
+//! so access needs BOTH a 0440 adm node (udev rule) AND the capability,
+//! which file permissions can never grant. This helper is the only binary
+//! that ever holds the capability: it reads exactly one hard-coded MSR
+//! (IA32_PERF_STATUS 0x198), read-only, and prints one line per CPU.
+//! Everything else — the app, its deps, its config writes — stays unprivileged.
+//!
+//! usage: aldermon-msr CPU [CPU...]  -> "CPU 0x<hex>" per cpu, "CPU -" on miss
+
+use std::io::{Read, Seek, Write};
+
+const PERF_STATUS: u64 = 0x198;
+const CPU_MAX: u32 = 4096;
+
+fn read_perf_status(cpu: u32) -> Option<u64> {
+    let path = format!("/dev/cpu/{cpu}/msr");
+    let mut f = std::fs::File::open(path).ok()?;
+    f.seek(std::io::SeekFrom::Start(PERF_STATUS)).ok()?;
+    let mut buf = [0u8; 8];
+    f.read_exact(&mut buf).ok()?;
+    Some(u64::from_le_bytes(buf))
+}
+
+fn main() {
+    let mut cpus = Vec::new();
+    for arg in std::env::args().skip(1) {
+        match arg.parse::<u32>() {
+            Ok(c) if c <= CPU_MAX => cpus.push(c),
+            _ => {
+                eprintln!("usage: aldermon-msr CPU [CPU...]");
+                std::process::exit(2);
+            }
+        }
+    }
+    if cpus.is_empty() {
+        eprintln!("usage: aldermon-msr CPU [CPU...]");
+        std::process::exit(2);
+    }
+    let out = std::io::stdout();
+    let mut out = out.lock();
+    for cpu in cpus {
+        let line = match read_perf_status(cpu) {
+            Some(v) => format!("{cpu} {v:#x}\n"),
+            None => format!("{cpu} -\n"),
+        };
+        if out.write_all(line.as_bytes()).is_err() {
+            std::process::exit(1);
+        }
+    }
+}
diff --git a/aldermon/src/main.rs b/aldermon/src/main.rs
index 43d4c16..a2088b8 100644
--- a/aldermon/src/main.rs
+++ b/aldermon/src/main.rs
@@ -23,12 +23,14 @@ usage: aldermon [OPTION]
 
   (no option)  one-shot sensor dump to stdout
   --tui        live monitoring TUI (q/Esc quit, F2 settings)
-  --vid        VID vs SIO in0 cross-check spike (needs root + msr module)
+  --vid        VID vs SIO in0 cross-check spike (needs MSR access, see below)
   --log        append CSV samples to ./aldermon-vid.log
   --help       show this help
 
-The TUI always shows the delivered vCore, the requested VID and the delta
-(needs root + /dev/cpu/*/msr; shows 'VID n/a (needs root)' otherwise).
+The TUI always shows the delivered vCore, the requested VID and the delta.
+VID needs MSR access: run as root, or install the setcap'd aldermon-msr
+helper (doas make install + aldermon-setup.sh); otherwise it shows
+'VID n/a (no msr access)'.
 --log appends one CSV row per poll; with --tui it logs live, with --vid it
 logs continuously until interrupted (otherwise --vid runs a 5-sample spike).
 
@@ -136,11 +138,13 @@ fn dump() {
 }
 
 /// VID cross-check: compare SIO in0 (delivered rail) with the CPU's requested
-/// SVID setpoint (IA32_PERF_STATUS) per sample. Requires root + msr module.
+/// SVID setpoint (IA32_PERF_STATUS) per sample. Needs MSR access (root or
+/// the setcap'd aldermon-msr helper).
 /// Without `log` this is a bounded 5-sample spike; with `log` it runs until
 /// interrupted, appending CSV rows to ./aldermon-vid.log.
 fn vid_spike(log: bool) {
     let cpus = sensors::cpu_count();
+    let access = sensors::probe_msr();
     let mut logger = if log {
         match log::VidLogger::open(Path::new(LOG_PATH), cpus) {
             Ok(l) => {
@@ -157,8 +161,11 @@ fn vid_spike(log: bool) {
     };
 
     if logger.is_none() {
-        println!("== VID cross-check (needs root: /dev/cpu/*/msr) ==");
+        println!("== VID cross-check (MSR access: root, or setcap'd aldermon-msr) ==");
         println!("(hint: --log appends to ./{LOG_PATH} continuously)");
+        if !access.usable() {
+            println!("(no MSR access — run under root, or install + setup the helper)");
+        }
     }
     // Package-energy counter for the logged watts (None until the second
     // sample; the first delta has no baseline).
@@ -169,7 +176,7 @@ fn vid_spike(log: bool) {
     let rounds: u64 = if logger.is_some() { u64::MAX } else { 5 };
     for round in 0..rounds {
         let vcore = sensors::sio_vcore();
-        let msrs = sensors::vid_msrs(cpus);
+        let msrs = sensors::vid_msrs(access, cpus);
         let vmax = sensors::vid_max(&msrs);
         let freqs = cpu_frequencies();
 
diff --git a/aldermon/src/sensors.rs b/aldermon/src/sensors.rs
index c6349d6..207cf97 100644
--- a/aldermon/src/sensors.rs
+++ b/aldermon/src/sensors.rs
@@ -237,10 +237,84 @@ pub fn vid_from_msr(msr: u64) -> f64 {
     ((msr >> 32) & 0xffff) as f64 / (1u32 << 13) as f64
 }
 
+/// How we can reach /dev/cpu/N/msr: opening it takes BOTH file read access
+/// (udev puts the nodes 0440 group adm) and CAP_SYS_RAWIO (msr_open), which
+/// only setcap or root can grant — so the unprivileged route is the setcap'd
+/// `aldermon-msr` helper. Probed once at startup; re-probing per poll would
+/// fork the helper on every tick.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MsrAccess {
+    Direct,
+    Helper,
+    None,
+}
+
+impl MsrAccess {
+    pub fn usable(self) -> bool {
+        self != MsrAccess::None
+    }
+}
+
+/// Locate the helper: sibling of the running exe first (installed layout and
+/// `cargo run` target/debug both put them side by side), else PATH.
+fn helper_path() -> std::path::PathBuf {
+    std::env::current_exe()
+        .ok()
+        .and_then(|exe| {
+            let sib = exe.parent()?.join("aldermon-msr");
+            sib.exists().then_some(sib)
+        })
+        .unwrap_or_else(|| std::path::PathBuf::from("aldermon-msr"))
+}
+
+/// Parse helper stdout: "CPU 0x<hex>" lines, "CPU -" for misses. Short or
+/// malformed output fills None slots so the result always covers all cpus.
+fn parse_helper_stdout(s: &str, n: usize) -> Vec<Option<u64>> {
+    let mut out = vec![None; n];
+    for (slot, line) in out.iter_mut().zip(s.lines()) {
+        if let Some(val) = line.split_once(' ').map(|(_, v)| v) {
+            if val != "-" {
+                *slot = u64::from_str_radix(val.trim_start_matches("0x"), 16).ok();
+            }
+        }
+    }
+    out
+}
+
+/// One helper exec for all cpus (a 50 ms poll = one fork+exec, not 16).
+fn helper_perf_status(n: usize) -> Option<Vec<Option<u64>>> {
+    let out = std::process::Command::new(helper_path())
+        .args((0..n).map(|c| c.to_string()).collect::<Vec<_>>())
+        .output()
+        .ok()?;
+    if !out.status.success() {
+        return None;
+    }
+    Some(parse_helper_stdout(
+        &String::from_utf8_lossy(&out.stdout),
+        n,
+    ))
+}
+
+/// Probe once which MSR route works (cpu 0 is always online).
+pub fn probe_msr() -> MsrAccess {
+    if perf_status_msr(0).is_some() {
+        return MsrAccess::Direct;
+    }
+    match helper_perf_status(1).map(|v| v[0]) {
+        Some(Some(_)) => MsrAccess::Helper,
+        _ => MsrAccess::None,
+    }
+}
+
 /// Read the raw IA32_PERF_STATUS of every logical cpu `0..n` (None where the
-/// read fails, e.g. an offline cpu).
-pub fn vid_msrs(n: usize) -> Vec<Option<u64>> {
-    (0..n).map(perf_status_msr).collect()
+/// read fails, e.g. an offline cpu), via the probed access route.
+pub fn vid_msrs(access: MsrAccess, n: usize) -> Vec<Option<u64>> {
+    match access {
+        MsrAccess::Direct => (0..n).map(perf_status_msr).collect(),
+        MsrAccess::Helper => helper_perf_status(n).unwrap_or_else(|| vec![None; n]),
+        MsrAccess::None => vec![None; n],
+    }
 }
 
 /// Highest requested VID across all logical CPUs (the core asking the VRM
@@ -296,8 +370,23 @@ mod tests {
             Some(8000u64 << 32),
         ];
         let m = vid_max(&msrs).unwrap();
-        assert!((m - 9830.0 / 8192.0).abs() < 1e-9);
+        assert!((m - 9830.0 / 8192.0).abs() < 1e-12);
         assert_eq!(vid_max(&[None, None]), None);
         assert_eq!(vid_max(&[]), None);
     }
+
+    #[test]
+    fn parse_helper_stdout_maps_values_misses_and_gaps() {
+        let got = parse_helper_stdout("0 0x266600000000\n1 -\n2 0x1f4000000000", 3);
+        assert_eq!(
+            got,
+            vec![Some(0x2666_0000_0000), None, Some(0x1f40_0000_0000)]
+        );
+        // fewer lines than cpus → trailing None; junk line → that slot None
+        let got = parse_helper_stdout("0 0xff\n", 3);
+        assert_eq!(got, vec![Some(0xff), None, None]);
+        let got = parse_helper_stdout("0 zz\n", 1);
+        assert_eq!(got, vec![None]);
+        assert_eq!(parse_helper_stdout("", 2), vec![None, None]);
+    }
 }
diff --git a/aldermon/src/ui.rs b/aldermon/src/ui.rs
index a47244f..70c5528 100644
--- a/aldermon/src/ui.rs
+++ b/aldermon/src/ui.rs
@@ -73,9 +73,9 @@ pub struct App {
     edit_buf: String,
     /// Last settings-input error (inline, cleared on next keypress).
     settings_error: Option<String>,
-    /// MSR is readable (root + /dev/cpu/*/msr), probed once at startup. When
-    /// false the VID read is skipped each poll and the panel shows "VID n/a".
-    msr_readable: bool,
+    /// MSR access route (root direct read, or setcap'd aldermon-msr helper),
+    /// probed once at startup. None = VID skipped each poll, panel "VID n/a".
+    msr_access: sensors::MsrAccess,
     /// Requested SVID setpoint this poll, max across logical CPUs (V).
     vid: Option<f64>,
     /// Per-logical-cpu raw IA32_PERF_STATUS, for the CSV row.
@@ -258,9 +258,9 @@ impl App {
     pub fn new(logger: Option<VidLogger>, n_cpus: usize) -> App {
         let cfg = config::load();
         let cap = ring_capacity(&cfg);
-        // Probe MSR once: a successful read of cpu0's IA32_PERF_STATUS means
-        // we have root + /dev/cpu/0/msr. Avoids a stat syscall every poll.
-        let msr_readable = sensors::perf_status_msr(0).is_some();
+        // Probe MSR once: root direct read, or the setcap'd aldermon-msr
+        // helper. Avoids a syscall + fork/exec every poll.
+        let msr_access = sensors::probe_msr();
         App {
             cfg,
             vcore: None,
@@ -284,7 +284,7 @@ impl App {
             settings_row: 0,
             edit_buf: String::new(),
             settings_error: None,
-            msr_readable,
+            msr_access,
             vid: None,
             vid_msr: Vec::new(),
             logger,
@@ -306,8 +306,8 @@ impl App {
 
     fn poll(&mut self) {
         self.vcore = sensors::sio_vcore();
-        if self.msr_readable {
-            self.vid_msr = sensors::vid_msrs(self.n_cpus);
+        if self.msr_access.usable() {
+            self.vid_msr = sensors::vid_msrs(self.msr_access, self.n_cpus);
             self.vid = sensors::vid_max(&self.vid_msr);
         }
         if let Some(v) = self.vcore {
@@ -548,8 +548,8 @@ fn meter_line(
 
 /// vCore panel text: delivered (SIO in0), requested SVID setpoint, and the
 /// delta (delivered − requested; negative = VRM droop, positive = LLC
-/// overshoot). VID needs root + /dev/cpu/*/msr; `msr_readable` distinguishes
-/// "not root" from a transient read miss.
+/// overshoot). VID needs MSR access (root, or the setcap'd aldermon-msr
+/// helper); `msr_readable` distinguishes "no access" from a transient miss.
 fn hero_text(vcore: Option<f64>, vid: Option<f64>, msr_readable: bool) -> String {
     let base = match vcore {
         Some(v) => format!("{v:.3} V"),
@@ -559,7 +559,7 @@ fn hero_text(vcore: Option<f64>, vid: Option<f64>, msr_readable: bool) -> String
         (Some(ask), Some(got)) => format!("{base}  VID {ask:.3}  Δ{:+.3}", got - ask),
         (Some(ask), None) => format!("{base}  VID {ask:.3}"),
         (None, _) if msr_readable => format!("{base}  VID n/a"),
-        (None, _) => format!("{base}  VID n/a (needs root)"),
+        (None, _) => format!("{base}  VID n/a (no msr access)"),
     }
 }
 
@@ -829,7 +829,7 @@ pub fn draw(f: &mut Frame, app: &App) {
         None => Color::DarkGray,
     };
 
-    let hero_text = hero_text(app.vcore, app.vid, app.msr_readable);
+    let hero_text = hero_text(app.vcore, app.vid, app.msr_access.usable());
     let hero_peak = match app.vcore_peak {
         Some(p) => format!("peak {p:.3}"),
         None => String::new(),
@@ -1379,7 +1379,7 @@ mod tests {
     fn hero_distinguishes_not_root_from_transient_miss() {
         assert_eq!(
             hero_text(Some(1.0), None, false),
-            "1.000 V  VID n/a (needs root)"
+            "1.000 V  VID n/a (no msr access)"
         );
         assert_eq!(hero_text(Some(1.0), None, true), "1.000 V  VID n/a");
     }
@@ -1387,6 +1387,6 @@ mod tests {
     #[test]
     fn hero_handles_missing_vcore() {
         assert_eq!(hero_text(None, Some(1.2), true), "n/a  VID 1.200");
-        assert_eq!(hero_text(None, None, false), "n/a  VID n/a (needs root)");
+        assert_eq!(hero_text(None, None, false), "n/a  VID n/a (no msr access)");
     }
 }