From bf5f2508b877ba9c43ad6f0dd256da08d88c550c Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sat, 25 Jul 2026 20:46:40 -0400 Subject: [PATCH] host/taint: honour the ownership carriers on producers only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/host/audit/mod.rs | 32 ++++++++++++ src/host/audit/tests.rs | 49 ++++++++++++++++++ src/host/taint/fixture.rs | 10 ++++ src/host/taint/mod.rs | 53 +++++++++++++++++--- src/host/taint/snapshot.rs | 14 ++++++ src/host/taint/tests.rs | 100 +++++++++++++++++++++++++++++++++++++ 6 files changed, 252 insertions(+), 6 deletions(-) diff --git a/src/host/audit/mod.rs b/src/host/audit/mod.rs index 996805a..b76dfc9 100644 --- a/src/host/audit/mod.rs +++ b/src/host/audit/mod.rs @@ -198,6 +198,17 @@ pub struct TaintRow { pub sticky: bool, } +/// A node carrying a peerspeak ownership carrier on a role the engine does not +/// honour it on (round 10, R10-1). `role` is the point of the row: it says +/// which non-producer role the tag turned up on, which is what distinguishes a +/// producer-side bug from an impersonation attempt. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct IgnoredTagRow { + pub serial: u64, + pub name: Option, + pub role: &'static str, +} + /// The decision content of one recompute — everything except which recompute it /// was. Split out from [`AuditRecord`] so "did anything actually change?" is a /// derived `==` rather than a hand-maintained field comparison that a later @@ -227,6 +238,16 @@ pub struct AuditBody { pub excluded_count: usize, /// Taint across all node roles, ascending by serial. pub taint: Vec, + /// Nodes carrying a peerspeak ownership carrier that the engine + /// **ignored** because they are not `Stream/Output/Audio` (round 10, + /// R10-1). Normally empty; a non-empty list means either peerspeak is + /// tagging something it should not, or a process is impersonating the + /// tag. Neither is an exclusion, and neither should be silent. + /// + /// Omitted from the JSONL when empty, so it costs nothing on the common + /// path and is impossible to miss when it is not. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub ignored_ownership_tags: Vec, } impl AuditBody { @@ -433,6 +454,16 @@ fn build_body( }) .collect(); + let ignored_ownership_tags: Vec = + crate::host::taint::misplaced_ownership_tags(&projection.snapshot) + .into_iter() + .map(|node| IgnoredTagRow { + serial: node.serial.0, + name: node.name.clone(), + role: node.role.code(), + }) + .collect(); + AuditBody { graph_ready: projection.graph_ready, epoch: readiness_code(projection.readiness), @@ -444,6 +475,7 @@ fn build_body( eligible_count, candidates, taint, + ignored_ownership_tags, } } diff --git a/src/host/audit/tests.rs b/src/host/audit/tests.rs index 473a6b3..f62a0de 100644 --- a/src/host/audit/tests.rs +++ b/src/host/audit/tests.rs @@ -125,6 +125,55 @@ fn the_record_carries_the_complete_candidate_universe() { assert_eq!(outcome.record.body.excluded_count, 0); } +/// **R10-1's diagnostic reaches the record.** The engine deliberately ignores +/// an ownership carrier on a non-producer, which means the fix removes an +/// exclusion — so the only way an operator learns a tag was seen and dropped is +/// this field. A matrix row that silently grew an impostor would otherwise read +/// as a clean pass. +#[test] +fn an_ignored_ownership_tag_is_reported_without_excluding_anything() { + let mut graph = Graph::new(); + graph.app_node("music", MediaRole::StreamOutput, 100); + let impostor = graph.peerspeak_tagged_node("rogue", MediaRole::StreamInput, 4_242); + let projection = ready(graph.build()); + + let body = observe(&mut auditor_off(), &projection, 0).record.body; + + // The bystander is untouched — the point of the fix. + let (eligible, excluded) = partition(&body); + assert_eq!(eligible, vec!["music"]); + assert!(excluded.is_empty(), "unexpected exclusions: {excluded:?}"); + assert!(body.taint.is_empty(), "unexpected taint: {:?}", body.taint); + + // ...but the tag is not silent, and the row names the role it appeared on. + assert_eq!(body.ignored_ownership_tags.len(), 1); + let row = &body.ignored_ownership_tags[0]; + assert_eq!(row.serial, impostor.serial.0); + assert_eq!(row.role, "stream-input"); + assert_eq!(row.name.as_deref(), Some(owned_name("rogue", 4_242).as_str())); +} + +/// The common path stays quiet: a correctly tagged peerspeak producer is +/// honoured as a taint root and is *not* reported as a misplaced tag. Without +/// this, a diagnostic that fired on every normal run would be worthless. +#[test] +fn a_correctly_tagged_producer_is_not_reported_as_misplaced() { + let mut graph = Graph::new(); + let sink = graph.device_node("speakers", MediaRole::Sink); + let call = graph.peerspeak_node("call", 200); + graph.link(call, sink); + let projection = ready(graph.build()); + + let body = observe(&mut auditor_off(), &projection, 0).record.body; + + assert_eq!(body.excluded_count, 1); + assert!( + body.ignored_ownership_tags.is_empty(), + "honoured tag reported as misplaced: {:?}", + body.ignored_ownership_tags + ); +} + /// The fail-closed default asserted at the boundary (impl plan §4, phase 2's /// "one addition"): nothing in, nothing eligible — and, just as importantly, no /// panic and no invented row. diff --git a/src/host/taint/fixture.rs b/src/host/taint/fixture.rs index 4ae652f..7d146aa 100644 --- a/src/host/taint/fixture.rs +++ b/src/host/taint/fixture.rs @@ -161,6 +161,16 @@ impl Graph { self.node(&name, MediaRole::StreamOutput, peerspeak_owned(client, pid)) } + /// Both ownership carriers on a node of **any** role — an impostor, or a + /// producer-side tagging bug. Only [`MediaRole::StreamOutput`] makes it a + /// taint root (round 10, R10-1); every other role must be ignored, and + /// these are the fixtures that prove it. + pub fn peerspeak_tagged_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef { + let client = self.client_of_app(pid); + let name = format!("{}{name}_{pid}", super::PEERSPEAK_OWNED_NODE_PREFIX); + self.node(&name, role, peerspeak_owned(client, pid)) + } + /// Carrier 1 alone: the `peerspeak.owned` property present, the /// `node.name` prefix absent. What the engine sees for a node it had to /// bind to observe (v3.5 §6.7). diff --git a/src/host/taint/mod.rs b/src/host/taint/mod.rs index 2479be6..488d267 100644 --- a/src/host/taint/mod.rs +++ b/src/host/taint/mod.rs @@ -566,18 +566,59 @@ fn ambiguous_id_nodes(snapshot: &GraphSnapshot) -> BTreeSet { .collect() } +/// Does this node carry either ownership carrier? **Tag presence only** — it +/// deliberately says nothing about whether the tag is honoured, which is +/// `local_root_reason`'s business (round 10 restricts that to producers). +/// Split out so the "is it tagged?" and "does the tag count?" questions can +/// be tested, and reported, independently. +pub fn is_peerspeak_tagged(node: &NodeSnapshot) -> bool { + node.props.peerspeak_owned + || node + .name + .as_deref() + .is_some_and(|name| name.starts_with(PEERSPEAK_OWNED_NODE_PREFIX)) +} + +/// Nodes carrying an ownership carrier that `local_root_reason` **ignored** +/// because the node is not a producer (round 10, R10-1). Ascending by serial. +/// +/// Purely diagnostic — nothing in the engine consumes it. It exists because +/// R10-1 turns a formerly load-bearing tag into a no-op, and a silently +/// ignored tag has exactly two causes, both of which someone wants to know +/// about: peerspeak tagging a node it should not (a producer-side bug this +/// would otherwise hide), or another process impersonating the tag (the F2 +/// attack, now defanged but still worth seeing). +pub fn misplaced_ownership_tags(snapshot: &GraphSnapshot) -> Vec<&NodeSnapshot> { + let mut tagged: Vec<&NodeSnapshot> = snapshot + .nodes() + .filter(|node| node.role != MediaRole::StreamOutput && is_peerspeak_tagged(node)) + .collect(); + tagged.sort_by_key(|node| node.serial); + tagged +} + fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option { // The two ownership carriers, as a union (v3.5 §5.1). Kept here rather // than folded together at the observer boundary so that the union is a // pure, directly-testable rule: an adapter that collapsed both into the // one `peerspeak_owned` bool would make each carrier untestable alone, // which is exactly how phase 3r's row 1 nearly gated nothing. - if node.props.peerspeak_owned - || node - .name - .as_deref() - .is_some_and(|name| name.starts_with(PEERSPEAK_OWNED_NODE_PREFIX)) - { + // + // ⚠️ **Producer roles only** (round 10, R10-1). Neither carrier is a + // security boundary — both are strings any unprivileged process can put + // on its own node — so an unrestricted root is a denial of the whole + // feature: an unlinked `Stream/Input/Audio` named `peerspeak_owned_x` + // is a tainted *reader* with no owner bound to it, which fails every + // candidate closed machine-wide (Codex phase-1 F2, reproduced live). + // Restricting the root to `Stream/Output/Audio` costs nothing real — + // peerspeak only ever tags playback streams — and the attack needs the + // impostor to be a plausible playback node instead, which taints only + // its own descendants. The AEC's virtual sink/source is unaffected: it + // roots on [`Reason::AecIdentity`] below, by module id, not by this tag. + // A tag on a non-producer falls through: ignored for taint, but not + // nothing — it is either a peerspeak bug or an impostor, and + // [`misplaced_ownership_tags`] surfaces it so neither is silent. + if is_peerspeak_tagged(node) && node.role == MediaRole::StreamOutput { return Some(Reason::PeerspeakOwned); } if let (Some(module), Some(aec)) = (node.props.pulse_module_id, ctx.aec_module_id) diff --git a/src/host/taint/snapshot.rs b/src/host/taint/snapshot.rs index 29b02f5..1f669f7 100644 --- a/src/host/taint/snapshot.rs +++ b/src/host/taint/snapshot.rs @@ -87,6 +87,20 @@ impl MediaRole { pub fn is_candidate(self) -> bool { matches!(self, Self::StreamOutput) } + + /// Stable machine-readable code for the audit output. Not the raw + /// `media.class`: `Other` has no single one, and the audit's codes are a + /// contract with the matrix, not with PipeWire. + pub fn code(self) -> &'static str { + match self { + Self::StreamOutput => "stream-output", + Self::StreamInput => "stream-input", + Self::Sink => "sink", + Self::Source => "source", + Self::Duplex => "duplex", + Self::Other => "other", + } + } } /// The subset of node properties the engine actually reasons about. diff --git a/src/host/taint/tests.rs b/src/host/taint/tests.rs index eff5f24..ee9c285 100644 --- a/src/host/taint/tests.rs +++ b/src/host/taint/tests.rs @@ -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::>() + ); + // 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 = 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