host/taint: close the inverse asymmetric leak; strengthen contracts (Codex round 3)

Round 3 was the second verification round. One real leak, one accepted
narrowing of Codex's own suggested fix, two contract strengthenings, and
a test-gap fix.

F1 (P1, real leak, fixed): the inverse of round-1 finding 4. A tainted
reader that is itself *unbounded* (client.id only, daemon PID suppressed)
whose re-emitting leg carried an *unmatched* strong key left that leg
"bounded" and Eligible. An unbounded reader cannot be positively related
to any output, so a strong key that does not match it back proves nothing.

  Two-tier backstop. A bounded tainted reader excludes only unbounded
  outputs (a differently-keyed output is provably a different owner). An
  unbounded tainted reader also excludes daemon-owned outputs — but NOT
  ordinary apps.

  ⚠️ Deliberately narrower than Codex's suggested "exclude every output".
  An unbounded reader is necessarily daemon-owned (a real app has its own
  PID, which is a usable key, so it would be bounded), so its sibling is
  another daemon leg, never an app. Sweeping in real apps would lose the
  round-1 "blast radius stays small" guarantee for no safety gain. When
  the daemon PID is unknown the app/leg distinction collapses and the rule
  degrades to Codex's exclude-all. Both directions are pinned by tests,
  and the over-aggressive variant fails the spares-real-apps test.

F2 (contract, strengthened): `session_device` is documented as a positive
high-confidence phase-3 classification, not `device.id`+`device.api`
(measured insufficient — a card filter can carry both; node.physical is
null on the real ALSA nodes so it is not a discriminator). Fail closed:
unknown ⇒ false. Documented why a mis-classified filter still does not
leak in practice — its legs share a link-group (strong-key bridge) and an
unbounded reading leg trips the two-tier backstop.

F5 (P2, test gap): a mutation keeping only PID fingerprints survived all
49 tests. Added a strong-key (pulse.module.id) new-connection fixture.

Mutation-verified 3/3 including the over-aggressive counter-mutation.

Still OWED to round 3, carried to round 4 for adjudication: finding 3
(a not-ready epoch can persist provisional owner *fusion* as sticky
over-exclusion). It is over-exclusion, never an echo leak, and closing it
needs a readiness/provenance model decision rather than a local patch —
see the round-4 handoff. 53 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 17:40:24 -04:00
co-authored by Claude Opus 4.8
parent 31084edcfa
commit f35bab0379
3 changed files with 280 additions and 28 deletions
+182
View File
@@ -1490,3 +1490,185 @@ fn a_not_ready_snapshot_still_records_new_taint() {
],
);
}
// ──────────────────────────────────────────────────────────────────────
// Regressions from Codex round 3 (second verification round)
// ──────────────────────────────────────────────────────────────────────
#[test]
fn an_inverse_asymmetric_forwarder_fails_closed() {
// Round 3 finding 1 — the mirror of round-1 finding 4, and a real leak.
// The reader is unbounded (client.id only, daemon PID suppressed) while
// its re-emitting leg carries an *unmatched* strong key, so the leg was
// "bounded" and stayed Eligible. When the reader itself cannot be
// bounded, its sibling could be any output, so a strong key that does
// not match it back proves nothing.
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 fwd_in = graph.node("fwd-in", MediaRole::StreamInput, app(in_client, PULSE_PID));
let fwd_out = graph.node(
"fwd-out",
MediaRole::StreamOutput,
NodeProps {
pulse_module_id: Some(77),
client_id: Some(out_client),
process_id: Some(PULSE_PID),
..NodeProps::default()
},
);
graph.link(hw, fwd_in);
let decisions = run(&graph, &ctx());
assert_eq!(
decisions.candidates[&fwd_out.serial]
.reason()
.map(Reason::code),
Some("unresolved-owner")
);
assert!(decisions.eligible().is_empty());
}
#[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.
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.
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.
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);
let leak_in = graph.node(
"leak-in",
MediaRole::StreamInput,
NodeProps {
client_id: Some(keyless_client),
..NodeProps::default()
},
);
graph.link(hw, leak_in);
assert_partition(
&run(
&graph,
&ExclusionCtx {
pipewire_pulse_pid: None,
..ctx()
},
),
&[],
&[
("call", call, "peerspeak-owned"),
("firefox", firefox, "unresolved-owner"),
],
);
}
#[test]
fn a_strong_key_new_connection_of_a_still_tainted_owner_inherits_the_taint() {
// Round 3 finding 5: a mutation that kept only PID fingerprints survived
// the 49-test suite, because no fixture exercised a *strong-key*
// fingerprint reaching a new connection. Here the owner is tainted via
// its `pulse.module.id`, all its nodes vanish, its client stays live,
// and a second client opens a new leg carrying the same module id.
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 mod_in = graph.module_node("mod-in", MediaRole::StreamInput, 77);
let mod_out = graph.module_node("mod-out", MediaRole::StreamOutput, 77);
graph.link(hw, mod_in);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
let c = ctx();
let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
// The module's original client stays live; a new connection carries the
// same module id. The daemon PID is suppressed, so only the module-id
// fingerprint can catch this.
let new_client = graph.client(Some(PULSE_PID));
let late = graph.node(
"mod-out-late",
MediaRole::StreamOutput,
super::fixture::pulse_module(new_client, 77, PULSE_PID),
);
let (next, _) = evaluate(&graph.build_without(&[mod_in, mod_out]), &c, &sticky);
assert_partition(
&next,
&[("firefox", firefox)],
&[
("call", call, "peerspeak-owned"),
("late", late, "tainted-owner-bridge"),
],
);
}