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
+120 -71
View File
@@ -683,11 +683,17 @@ fn firefox_three_cases() {
}
#[test]
fn an_unbounded_module_forwarder_fails_closed() {
fn an_unbounded_module_forwarder_fails_closed_and_sweeps_the_desktop() {
// v3.4 §12: "a module forwarder with neither link-group nor
// pulse.module.id ⇒ unresolved ⇒ excluded". Its process id is the
// daemon's (so key 4 is suppressed) and its legs carry different
// `client.id`s, so nothing can enumerate its siblings.
//
// ⚠️ Because the reading leg is itself *unbounded*, the whole desktop's
// output is swept — an ordinary app is NOT spared (Codex round 4: a
// real app can present no PID on its reader leg, so "unbounded ⇒ not an
// app" is unsound; over-exclude instead). The trigger is anomalous: a
// keyless reader actively consuming the call.
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
@@ -703,8 +709,35 @@ fn an_unbounded_module_forwarder_fails_closed() {
);
graph.link(hw, fwd_in);
// The blast radius must stay small: an ordinary app is bounded by its
// own PID and must not be swept up.
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
graph.link(firefox, hw);
assert_partition(
&run(&graph, &ctx()),
&[],
&[
("call", call, "peerspeak-owned"),
("fwd-out", fwd_out, "unresolved-owner"),
("firefox", firefox, "unresolved-owner"),
],
);
}
#[test]
fn a_bounded_tainted_reader_leaves_ordinary_apps_alone() {
// The blast-radius guarantee survives for the *bounded* tier (round-1
// finding 4): a tainted reader with a real strong key excludes only the
// unbounded output legs that could share its identity, not real apps.
// Here an EasyEffects-shaped reader (link-group) reads the call, and its
// own re-emitting leg is excluded via the bridge — but firefox is not.
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
let ee_in = graph.group_node("ee-in", MediaRole::StreamInput, "ee", 5000);
let ee_out = graph.group_node("ee-out", MediaRole::StreamOutput, "ee", 5000);
graph.link(hw, ee_in);
graph.link(ee_out, hw);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
graph.link(firefox, hw);
@@ -713,7 +746,7 @@ fn an_unbounded_module_forwarder_fails_closed() {
&[("firefox", firefox)],
&[
("call", call, "peerspeak-owned"),
("fwd-out", fwd_out, "unresolved-owner"),
("ee-out", ee_out, "tainted-owner-bridge"),
],
);
}
@@ -1534,81 +1567,30 @@ fn an_inverse_asymmetric_forwarder_fails_closed() {
}
#[test]
fn an_unbounded_tainted_reader_sweeps_daemon_legs_but_spares_real_apps() {
// The two-tier rule, made explicit and deliberately narrower than
// Codex round 3's "exclude everything". An unbounded tainted reader is
// necessarily daemon-owned, so its sibling is another daemon leg — a
// *bounded module output* here, carrying a strong key that does not
// match the reader. An ordinary app with its own PID is provably a
// different owner and stays shareable.
fn an_unbounded_tainted_reader_excludes_every_output() {
// Round 4: conceded to Codex. An unbounded tainted reader could be a
// real app that simply exposed no PID on its reader leg, so no output
// property proves independence — exclude the whole candidate universe.
let mut graph = Graph::new();
let hw = graph.device_node("hw", MediaRole::Sink);
let call = graph.peerspeak_node("call", 7);
graph.link(call, hw);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
graph.link(firefox, hw);
// A bounded module output leg (strong key 88, daemon PID) with no
// matching reader — a possible sibling of the unbounded reader below.
// A bounded module leg — even a strong key does not spare it.
let daemon_leg = graph.module_node("daemon-leg", MediaRole::StreamOutput, 88);
graph.link(daemon_leg, hw);
// Without an unbounded reader, both non-peerspeak outputs are eligible.
// Without an unbounded reader, both are eligible.
assert_partition(
&run(&graph, &ctx()),
&[("firefox", firefox), ("daemon-leg", daemon_leg)],
&[("call", call, "peerspeak-owned")],
);
// Add a keyless daemon-PID forwarder reading the call.
let ghost_client = graph.client(Some(PULSE_PID));
let leak_in = graph.node(
"leak-in",
MediaRole::StreamInput,
app(ghost_client, PULSE_PID),
);
graph.link(hw, leak_in);
assert_partition(
&run(&graph, &ctx()),
&[("firefox", firefox)],
&[
("call", call, "peerspeak-owned"),
("daemon-leg", daemon_leg, "unresolved-owner"),
],
);
}
#[test]
fn an_unknown_daemon_pid_makes_an_unbounded_reader_exclude_everything() {
// The fallback: with the daemon PID unknown we cannot tell a real app
// from a module leg, so the rule degrades to Codex's "exclude all".
let mut graph = Graph::new();
let hw = graph.device_node("hw", MediaRole::Sink);
let call = graph.peerspeak_node("call", 7);
graph.link(call, hw);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
graph.link(firefox, hw);
let ghost_client = graph.client(None);
let leak_in = graph.node("leak-in", MediaRole::StreamInput, app(ghost_client, 0));
graph.link(hw, leak_in);
let decisions = run(
&graph,
&ExclusionCtx {
pipewire_pulse_pid: None,
..ctx()
},
);
// With pulse PID unknown, `app(_, 0)` has no suppression so PID 0 is a
// usable key and the reader is bounded... so force the reader unbounded
// by giving it no PID at all.
let _ = decisions;
let mut graph = Graph::new();
let hw = graph.device_node("hw", MediaRole::Sink);
let call = graph.peerspeak_node("call", 7);
graph.link(call, hw);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
graph.link(firefox, hw);
let keyless_client = graph.client(None);
// Add a keyless reader with no PID at all — the exact shape the round-3
// narrowing would have mishandled.
let keyless_client = graph.client(Some(PULSE_PID));
let leak_in = graph.node(
"leak-in",
MediaRole::StreamInput,
@@ -1619,17 +1601,45 @@ fn an_unknown_daemon_pid_makes_an_unbounded_reader_exclude_everything() {
);
graph.link(hw, leak_in);
assert_partition(
&run(
&graph,
&ExclusionCtx {
pipewire_pulse_pid: None,
..ctx()
},
),
&run(&graph, &ctx()),
&[],
&[
("call", call, "peerspeak-owned"),
("firefox", firefox, "unresolved-owner"),
("daemon-leg", daemon_leg, "unresolved-owner"),
],
);
}
#[test]
fn a_real_app_with_no_pid_on_its_reader_leg_does_not_leak() {
// Codex round 4's exact counterexample to the narrowing I tried: one
// process, reader leg with no PID (unbounded), output leg with a real
// PID. The narrowing spared the output; the broad rule excludes it.
let mut graph = Graph::new();
let hw = graph.device_node("hw", MediaRole::Sink);
let call = graph.peerspeak_node("call", 7);
graph.link(call, hw);
let in_client = graph.client(Some(PULSE_PID));
let out_client = graph.client(Some(PULSE_PID));
let reader = graph.node(
"reader",
MediaRole::StreamInput,
NodeProps {
client_id: Some(in_client),
..NodeProps::default()
},
);
let leaky_out = graph.node("leaky-out", MediaRole::StreamOutput, app(out_client, 4321));
graph.link(hw, reader);
assert_partition(
&run(&graph, &ctx()),
&[],
&[
("call", call, "peerspeak-owned"),
("leaky-out", leaky_out, "unresolved-owner"),
],
);
}
@@ -1672,3 +1682,42 @@ fn a_strong_key_new_connection_of_a_still_tainted_owner_inherits_the_taint() {
],
);
}
#[test]
fn a_link_group_new_connection_of_a_still_tainted_owner_inherits_the_taint() {
// Round 4 finding 3: a mutation dropping LinkGroup fingerprints (keeping
// only module/PID) survived the 53-test suite, because the strong-key
// fingerprint test used `pulse.module.id`, not `node.link-group`. Here a
// link-group owner reads the call, its nodes vanish, its client stays
// live, and a new client opens a leg with the same link-group and a
// suppressed PID — only the link-group fingerprint can catch it.
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
let g_in = graph.group_node("g-in", MediaRole::StreamInput, "filter-1", 6200);
let g_out = graph.group_node("g-out", MediaRole::StreamOutput, "filter-1", 6200);
graph.link(hw, g_in);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
let c = ctx();
let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
// New connection, same link-group, daemon PID (suppressed) so no usable
// PID remains — the module/PID fingerprints cannot reach it.
let new_client = graph.client(Some(PULSE_PID));
let late = graph.node(
"g-out-late",
MediaRole::StreamOutput,
super::fixture::link_group("filter-1", new_client, PULSE_PID),
);
let (next, _) = evaluate(&graph.build_without(&[g_in, g_out]), &c, &sticky);
assert_partition(
&next,
&[("firefox", firefox)],
&[
("call", call, "peerspeak-owned"),
("late", late, "tainted-owner-bridge"),
],
);
}