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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user