host/taint: honour the ownership carriers on producers only

Neither ownership carrier is a security boundary — both are strings any
unprivileged process can put on its own node — so an unrestricted taint
root is a denial of the whole feature. An unlinked Stream/Input/Audio
named `peerspeak_owned_rogue` is a tainted *reader* (receivers includes
nodes by role, no link required) and an unbounded one, so
propagate_unresolved_owner fails every candidate on the machine closed.

Measured before this change: BASELINE eligible=1 excluded=[] became
WITH IMPOSTOR eligible=0 excluded=[firefox -> unresolved-owner].

Restricting the root to Stream/Output/Audio costs nothing real —
peerspeak only ever tags playback streams — and the AEC's virtual
sink/source is untouched, since it roots on module id, not on this tag.

A tag that is ignored is not silent: misplaced_ownership_tags feeds a
new `ignored_ownership_tags` audit field (omitted when empty), because
the fix *removes* an exclusion, and the two causes of a dropped tag —
a peerspeak tagging bug, or an impersonation attempt — both want seeing.

Codex phase-1 review F2, reproduced live. Round 10, R10-1.
5 new rows, mutation-verified: dropping the role restriction kills both
engine rows, and stubbing the diagnostic kills the third.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 20:46:40 -04:00
co-authored by Claude Opus 5
parent 45ca5057f8
commit bf5f2508b8
6 changed files with 252 additions and 6 deletions
+100
View File
@@ -205,6 +205,106 @@ fn either_ownership_carrier_alone_taints_the_node() {
);
}
/// **R10-1, the F2 fix.** Neither carrier is a security boundary — both are
/// strings any unprivileged process can set on its own node — so the tag is
/// honoured only on `Stream/Output/Audio`, the one role peerspeak ever tags.
///
/// Without the restriction, a tagged `Stream/Input/Audio` **with no links at
/// all** is a tainted *reader* (`receivers` includes nodes by role, no link
/// required), and an unbounded one, so `propagate_unresolved_owner` fails
/// every candidate on the machine closed. That is a whole-feature denial from
/// an unprivileged process, reproduced live during the phase-1 review.
#[test]
fn an_ownership_tag_on_a_non_producer_is_not_a_taint_root() {
for role in [
MediaRole::StreamInput,
MediaRole::Sink,
MediaRole::Source,
MediaRole::Duplex,
MediaRole::Other,
] {
let mut graph = Graph::new();
let sink = graph.device_node("hw-sink", MediaRole::Sink);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
graph.link(firefox, sink);
// Deliberately unlinked: the F2 shape needs no edges whatsoever.
let impostor = graph.peerspeak_tagged_node("rogue", role, 4_242);
let decisions = run(&graph, &ctx());
assert_untainted(&decisions, impostor);
assert!(
decisions.taint.is_empty(),
"{role:?} impostor tainted something: {:?}",
decisions.taint.keys().collect::<Vec<_>>()
);
// The whole point: the eligible half stays non-empty.
assert_partition(&decisions, &[("firefox", firefox)], &[]);
}
}
/// **The live F2 reproduction, verbatim.** The measured impostor was an
/// *unbounded* reader — `client.id` present, `application.process.id` absent
/// — which is what turns "one bogus tainted node" into "nothing on this
/// machine is shareable": `propagate_unresolved_owner` cannot prove any
/// candidate independent of a reader it cannot attribute to an owner.
///
/// Measured before the fix: `BASELINE eligible=1 excluded=[]` →
/// `WITH IMPOSTOR eligible=0 excluded=[firefox → unresolved-owner]`.
///
/// Distinct from the row above, which uses a *bounded* impostor and so would
/// still pass if only the cheap half of the fix were present.
#[test]
fn an_unbounded_tagged_impostor_cannot_exclude_a_bystander_app() {
let mut graph = Graph::new();
let sink = graph.device_node("hw-sink", MediaRole::Sink);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
let mpv = graph.app_node("mpv", MediaRole::StreamOutput, 31_284);
for node in [firefox, mpv] {
graph.link(node, sink);
}
let baseline = run(&graph, &ctx());
assert_partition(&baseline, &[("firefox", firefox), ("mpv", mpv)], &[]);
// Both carriers, no pid, no links — everything an unprivileged process
// can arrange for itself in one `pw-cli` invocation.
let rogue_client = graph.client(Some(PULSE_PID));
let impostor = graph.node(
&format!("{}rogue_4242", super::PEERSPEAK_OWNED_NODE_PREFIX),
MediaRole::StreamInput,
NodeProps {
peerspeak_owned: true,
client_id: Some(rogue_client),
..NodeProps::default()
},
);
let decisions = run(&graph, &ctx());
assert_untainted(&decisions, impostor);
assert_partition(&decisions, &[("firefox", firefox), ("mpv", mpv)], &[]);
}
/// A tag that R10-1 ignores is still reported, so that neither a peerspeak
/// tagging bug nor an impersonation attempt is silent.
#[test]
fn ignored_ownership_tags_are_surfaced_for_diagnostics() {
let mut graph = Graph::new();
let sink = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("call", 7);
graph.link(call, sink);
let impostor = graph.peerspeak_tagged_node("rogue", MediaRole::StreamInput, 4_242);
let snapshot = graph.build();
let misplaced: Vec<Serial> = super::misplaced_ownership_tags(&snapshot)
.iter()
.map(|node| node.serial)
.collect();
// Exactly the ignored one: the honoured producer is not "misplaced".
assert_eq!(misplaced, vec![impostor.serial]);
assert_ne!(impostor.serial, call.serial);
}
/// The prefix is a **prefix**, not a substring: an unrelated app must not be
/// excluded because the literal appears somewhere in its name. Over-exclusion
/// is the safe direction, but it is still wrong, and the phase-5 gate now