repair: a pid is not an owner — modules carry a machine/boot/namespace token

Closes the last blocking finding: a pid is only a number, and the same number is
a different process in a different pid namespace. Repair running inside a
container that can reach the host's Pulse socket saw a live host's modules, asked
about that pid in its own namespace, was told nothing existed, and unloaded a
running host's audio. No negative signal closes that — `NSpid == 1` does not prove
the initial namespace, since its leftmost value is relative to whichever procfs
was mounted.

So the module now carries the answer with it. Every module a host loads gets
`pixelpass.owner=<version>-<machine>-<boot>-<pid_ns>-<nonce>`, and repair only
asks about a pid when all three identities match its own. Anything else is
reported and left alone, and its pid is never even looked up — asking is the bug,
because the answer would be meaningless.

**Untagged modules are refused by default.** Everything loaded before tokens
existed is unattributable, so `--repair` now lists those and does nothing, with
`--repair-legacy-untagged` to opt into the old pid-only heuristic after seeing the
candidates. That is a deliberate loss of reach: the failure being optimised
against is a false-positive destructive repair, and leaving an old orphan behind
is recoverable where destroying live routing is not. A foreign token is refused
even with the flag, since the flag speaks to missing evidence, not wrong evidence.

The vehicle was verified on the live server before anything was built on it: all
three shapes accept a property-list argument (`sink_properties`,
`sink_input_properties`, `source_output_properties`), the recorded argument comes
back byte-identical — so exact-form matching still holds — and the property really
lands on the resulting sink, sink-input and source-output.

**Audit gate passed, with the variable isolated.** The token rides on real graph
objects that phases 2/3 observe, so the partition had to be re-measured. Running
the same fixture with and without tokens gives an identical partition: 2 eligible
(FFXIV, Chromium), 2 excluded with the same `tainted-owner-bridge` reason, and the
same six-entry taint set. Everything that differs from the empty-graph baseline is
the fixture's own doing — a local-monitor loopback genuinely bridges our owned sink
into the real default sink — and none of it is the property's. Attributing that to
the token without the untokened control would have been the mistake.

A side benefit: the per-load nonce narrows the ABA window I previously documented
as unclosable. Two loads by the same pid no longer render byte-identical
arguments, so a fingerprint taken from one no longer matches the other.

Six new tests, three mutation-verified gates: `can_judge` always true, `pid_ns`
dropped from the comparison, and untagged treated as judgeable regardless of
policy — each killed by its own test. ⚠️ The third "survived" on first run because
my mutation script's indentation did not match and the edit silently did nothing;
the re-run asserts the file actually changed. A mutation that was never applied
proves the same amount as no mutation at all.

Field-verified live, three fixtures for one dead pid in one run: tokened with this
machine's identity is cleaned, tokened with a foreign pid namespace is left alone
and reported (and the legacy flag does not override it), and untagged is refused
then cleaned only when asked. The two older field fixtures were tokenised too —
without that the A/B test would have failed and the reference-gate test would have
passed for the wrong reason, which is a vacuous gate in the harness rather than the
code.

253 tests, clippy clean, fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 01:57:55 -04:00
co-authored by Claude Opus 5
parent 1cf6e915c8
commit d9ef38c1c5
6 changed files with 723 additions and 87 deletions
+35 -6
View File
@@ -67,7 +67,13 @@ impl Routing {
let pid = std::process::id();
let sink_name = repair_plan::sink_name_for(pid);
let sink_module = load_module(Shape::LegacyCaptureSink, pid)
// Every module this host loads carries an ownership token, so `--repair`
// can tell whose pid the name refers to instead of assuming the number
// means the same thing everywhere. Without it a repair run in another pid
// namespace can unload a live host's audio; see `repair::plan::OwnerToken`.
let owner = owner_token(pid).context("could not build an audio ownership token")?;
let sink_module = load_module(Shape::LegacyCaptureSink, pid, &owner)
.context("failed to load module-null-sink")?;
// In strict per-app mode we never mirror the default sink: the viewer
@@ -83,7 +89,7 @@ impl Routing {
None
} else {
Some(
load_module(Shape::LoopbackIntoCapture, pid)
load_module(Shape::LoopbackIntoCapture, pid, &owner)
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?,
)
};
@@ -111,6 +117,7 @@ impl Routing {
let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?;
let loopback_for_task = Arc::clone(&loopback_arc);
let local_monitor_for_task = Arc::clone(&local_monitor_arc);
let owner_for_task = owner.clone();
let strict = opts.strict_audio;
let event_task = tokio::spawn(async move {
use crate::common::output::{self, AppAudioState};
@@ -132,7 +139,8 @@ impl Routing {
// only, never the desktop/call — so it can't echo into
// the capture.
if local_monitor_for_task.lock().unwrap().is_none() {
match load_module(Shape::LoopbackOutOfCapture, pid) {
match load_module(Shape::LoopbackOutOfCapture, pid, &owner_for_task)
{
Ok(id) => {
tracing::info!(
module = id,
@@ -185,7 +193,7 @@ impl Routing {
tracing::info!(
"audio routing: last routed stream gone → restoring default-sink loopback"
);
match load_module(Shape::LoopbackIntoCapture, pid) {
match load_module(Shape::LoopbackIntoCapture, pid, &owner_for_task) {
Ok(id) => {
*loopback_for_task.lock().unwrap() = Some(id);
}
@@ -334,6 +342,27 @@ struct SinkInputProperties {
// pactl module helpers
// ──────────────────────────────────────────────────────────────────────
/// Mint this host's ownership token.
///
/// The nonce is what makes two loads by the same pid distinguishable, which is why
/// it is per-call rather than per-process: it narrows the window where a module that
/// vanished and a replacement that inherited its index look byte-identical.
fn owner_token(pid: u32) -> Result<repair_plan::OwnerToken> {
let local = crate::repair::local_identity()?;
// A nonce only has to be unlikely to repeat, not unguessable.
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
^ u64::from(pid) << 32;
Ok(repair_plan::OwnerToken {
machine: local.machine,
boot: local.boot,
pid_ns: local.pid_ns,
nonce,
})
}
/// Load the Pulse module for one [`Shape`] and return its index.
///
/// Both the module name and its arguments come from the shape itself
@@ -341,11 +370,11 @@ struct SinkInputProperties {
/// `--repair`'s exact-form matcher and this loader are one source of truth. A
/// latency or argument change that moved only one of them would leave repair
/// silently unable to recognise the modules this build loads.
fn load_module(shape: Shape, pid: u32) -> Result<u32> {
fn load_module(shape: Shape, pid: u32, owner: &repair_plan::OwnerToken) -> Result<u32> {
let output = Command::new("pactl")
.arg("load-module")
.arg(shape.module_name())
.args(shape.render_args(pid))
.args(shape.render_args(pid, Some(owner)))
.output()
.context("failed to run pactl load-module")?;
if !output.status.success() {