host/taint: concede the unbounded-reader rule; pin link-group fingerprints (Codex round 4)

Round 4 adjudicated my three round-3 pushbacks. Codex ruled: F2 bool seam
sufficient (YES), F3 accepted as a phase-3 contract not a phase-2 blocker
(YES) — but my F1 narrowing was unsound (NO), with a clean counterexample.

F1 (conceded): I had narrowed "unbounded tainted reader ⇒ exclude every
output" to spare outputs carrying a real, non-daemon PID, arguing an
unbounded reader must be daemon-owned. Codex refuted it:
`application.process.id` is optional and client-controlled, so one real
process can present NO pid on its reading leg (unbounded) and a real pid
on its output leg — the narrowing spares that output and leaks the call.
App properties cannot carry a soundness argument; only `pipewire.*` has
protected identity. Reverted to the broad rule: an unbounded tainted
reader excludes the whole candidate universe. Added the exact
counterexample as a test (`a_real_app_with_no_pid_on_its_reader_leg...`)
and kept a bounded-reader test to show the round-1 blast-radius guarantee
still holds for the bounded tier.

F2 (doc corrected): removed the "a mis-classified filter is still braced"
claim — Codex showed a filter with no shared strong key, wrongly marked
`session_device`, cannot trip the backstop from its reading leg and leaks
through a differently-keyed output. A false positive is now documented as
leak-capable; the only defence is the correct positive classifier.

F3 (link-group fingerprint, pinned): a mutation dropping LinkGroup
fingerprints survived all 53 tests, because the strong-key fingerprint
test used pulse.module.id. Added a link-group new-connection test.

Mutation-verified 2/2. 55 tests.

Phase-2 open item is now only F3-as-phase-3-contract, which Codex accepted
is not a phase-2 blocker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 17:54:08 -04:00
co-authored by Claude Opus 4.8
parent f35bab0379
commit 2183084ec8
3 changed files with 149 additions and 107 deletions
+20 -30
View File
@@ -361,8 +361,7 @@ pub fn evaluate(
let mut changed = false;
changed |= propagate_links(&edges.edges, &mut taint);
changed |= propagate_owner_bridge(&keys, &components, &edges, &mut taint);
changed |=
propagate_unresolved_owner(snapshot, ctx.pipewire_pulse_pid, &keys, &edges, &mut taint);
changed |= propagate_unresolved_owner(snapshot, &keys, &edges, &mut taint);
if !changed {
break;
}
@@ -678,25 +677,25 @@ fn propagate_owner_bridge(
/// owner and stays eligible; only unbounded output legs are its possible
/// siblings. → exclude unbounded outputs.
/// - An *unbounded* tainted reader has nothing that identifies its owner, so
/// its re-emitting leg could carry a strong key we cannot match back to
/// it. But it cannot be *anything*: a reader with no usable owner key is
/// necessarily **daemon-owned** — a real application has its own PID,
/// which is a usable key, so it would be bounded. Its sibling is therefore
/// another daemon-owned output, never an ordinary app. → also exclude the
/// daemon-owned outputs (bounded by a strong key or not); leave outputs
/// carrying a real, non-daemon PID eligible, because a real app is
/// provably a different owner from a daemon module leg.
/// its re-emitting leg could be **any** output on the box, and no property
/// on an output leg can prove it is unrelated. → exclude every output
/// candidate.
///
/// ⚠️ This is deliberately **narrower than Codex round 3's suggested
/// "exclude every output"**, which would make an ordinary app unshareable
/// whenever any keyless module forwarder reads the call — losing the
/// round-1 "blast radius stays small" guarantee for no safety gain, since
/// a real app cannot be the sibling of a daemon leg. When the daemon PID
/// is *unknown* the distinction collapses (we cannot tell a real app from
/// a module leg) and the rule degrades to Codex's: exclude everything.
/// ⚠️ I tried to narrow this to "daemon-owned outputs only", on the
/// theory that an unbounded reader must be daemon-owned (a real app has a
/// PID, which would bound it) so a real-PID output is provably a different
/// owner. **Codex refuted it (round 4):** `application.process.id` is
/// optional and client-controlled, so a real process can present *no* PID
/// on its reading leg (unbounded) and a real PID on its output leg — one
/// owner, spared by the narrowing, leaking the call. Only `pipewire.*`
/// properties have protected identity; app properties cannot carry a
/// soundness argument. So: exclude everything. The trigger is genuinely
/// anomalous — a keyless reader actively consuming the call; EasyEffects
/// and loopbacks carry a `node.link-group` and are *bounded*, so they do
/// not trip this tier — and phase 5's dry run surfaces it before it can
/// gate anything real.
fn propagate_unresolved_owner(
snapshot: &GraphSnapshot,
ctx_pulse_pid: Option<u32>,
keys: &owner::OwnerKeyIndex,
edges: &Edges,
taint: &mut BTreeMap<Serial, Reason>,
@@ -717,18 +716,9 @@ fn propagate_unresolved_owner(
}
let mut changed = false;
for node in snapshot.nodes() {
if node.role != MediaRole::StreamOutput {
continue;
}
// A real, non-daemon PID proves the node is an ordinary app, not a
// module leg — the one thing an unbounded daemon reader's sibling
// cannot be. Unknown daemon PID ⇒ cannot prove it ⇒ swept in.
let is_real_app = matches!(
(node.props.process_id, ctx_pulse_pid),
(Some(pid), Some(daemon)) if pid != daemon
);
let swept_by_unbounded = has_unbounded_tainted_reader && !is_real_app;
if !keys.is_bounded(node.serial) || swept_by_unbounded {
if node.role == MediaRole::StreamOutput
&& (has_unbounded_tainted_reader || !keys.is_bounded(node.serial))
{
changed |= raise(taint, node.serial, Reason::UnresolvedOwner);
}
}