host/taint: pin the role-receiver mutation; doc fixes; document the unbounded-buffer limit (Codex round 5)
Round 5 convergence check. Codex confirmed F1(broad rule)/F2(doc)/ link-group fingerprint complete, and raised three more: - P2 (fixed): a mutation deleting the *role-based* receiver insertion survived all 55 tests — every tested bridge source also had an inbound link. A pixelpass capture sink is a taint root before anything links into it, and its re-emitting sibling must bridge from it on role alone. Added `a_local_root_receiver_bridges_without_an_inbound_link`; mutation now killed. - P3 (fixed): doc drift. The backstop's preamble still described the old "targets must be unbounded / apps never swept" rule; rewritten to the two-tier trigger/sweep. The `session_device` factory guidance now says explicit allowlist, not "and the like". - P1 (dispositioned as a documented v1 limitation, not fixed): a buffered echo across a *full* teardown of an *unbounded* reader. Grounds, in the module docs: (1) it needs a stream exposing no PID/module-id/link-group, which is malformed/identity-hiding and outside v3.4 §2's non-adversarial threat model; (2) it contradicts the design's explicit "reappears after full teardown ⇒ new owner, starts clean" (§6.1.3), so closing it is a design change; (3) the only closed-form fix is a whole-share hammer (one keyless stream ⇒ desktop unshareable for the share). Reachable cases — a reader live now — are already covered by the backstop. Owed to the design doc as a round-8 note. 56 tests. Taking the P1 disposition to Codex for ratification, then to the user as a design decision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+49
-14
@@ -51,6 +51,40 @@
|
||||
//! stack**. An entry is cleared only once every member object has
|
||||
//! disappeared; a key that reappears after full teardown is a new owner and
|
||||
//! starts clean.
|
||||
//!
|
||||
//! ## Documented v1 limitation — buffered audio across a full teardown of an
|
||||
//! ## *unbounded* reader (Codex phase-2 round 5)
|
||||
//!
|
||||
//! The "clear on full teardown" boundary is the design's, verbatim
|
||||
//! (v3.4 §6.1.3: "a key that reappears after full teardown is a new owner
|
||||
//! and starts clean"). It leaves one theoretical echo: an **unbounded**
|
||||
//! reader — one carrying no `node.link-group`, no `pulse.module.id`, and no
|
||||
//! usable PID — reads the call into an internal buffer, tears down *all* its
|
||||
//! PipeWire objects while keeping that buffer, then reconnects with fresh
|
||||
//! objects and replays. With every member serial gone and no owner key to
|
||||
//! fingerprint, nothing links the new owner to the old, and if no reader is
|
||||
//! live in that epoch the [backstop](propagate_unresolved_owner) does not
|
||||
//! fire either — so the replayed leg is eligible.
|
||||
//!
|
||||
//! **Left as a documented v1 limitation, not fixed, on three grounds:**
|
||||
//! 1. **Outside the threat model.** v3.4 §2 states the model is "don't echo
|
||||
//! the user's own call back at them," *not* "defend against a hostile
|
||||
//! local process." A stream that exposes none of PID / module id /
|
||||
//! link-group is a malformed or deliberately identity-hiding stream; real
|
||||
//! software (apps carry a PID, pactl modules a module id, PipeWire
|
||||
//! filters a link-group, GStreamer a PID) does not produce one.
|
||||
//! 2. **It contradicts the design's own boundary** (§6.1.3, quoted above),
|
||||
//! so closing it is a *design change*, not a bug fix.
|
||||
//! 3. **No proportionate fix exists.** The only closed-form fix is a
|
||||
//! whole-share hammer — once any unbounded reader is seen, exclude every
|
||||
//! output for the rest of the share — which makes the desktop
|
||||
//! unshareable on the mere appearance of one keyless stream. The reachable
|
||||
//! cases (a reader live *now*) are already covered by the backstop's
|
||||
//! exclude-everything tier.
|
||||
//!
|
||||
//! Owed to the design doc as a round-8 note (impl plan §11); if field
|
||||
//! testing ever surfaces a real unbounded reader, revisit with
|
||||
//! process-generation evidence rather than the hammer.
|
||||
|
||||
// Phase 2 lands the engine behind its own test surface and nothing else:
|
||||
// the registry observer that will feed it is phase 3, so in a non-test
|
||||
@@ -651,21 +685,22 @@ fn propagate_owner_bridge(
|
||||
/// cannot know which one is re-emitting what it read. Exclude the output
|
||||
/// legs that are equally unbounded.
|
||||
///
|
||||
/// Two restrictions keep the blast radius at "small", as the design
|
||||
/// promises — and one earlier restriction was **removed** as unsafe:
|
||||
/// The trigger and the sweep, precisely (both edges hard-won across four
|
||||
/// Codex rounds):
|
||||
///
|
||||
/// - **Source must not be a real device node.** A tainted hardware sink is
|
||||
/// the normal case, not an anomaly: peerspeak's own playback taints the
|
||||
/// default sink on literally every recompute, and letting that trip this
|
||||
/// rule would exclude the whole desktop.
|
||||
/// - **Targets must themselves be unbounded.** Ordinary application streams
|
||||
/// carry a real `application.process.id` and are bounded, so they are
|
||||
/// never swept up; in practice only daemon-owned keyless module streams
|
||||
/// are.
|
||||
/// - ⚠️ **The source does not have to be unbounded** (Codex round 1): a
|
||||
/// reader carrying a `node.link-group` whose re-emitting leg carries none
|
||||
/// is *bounded* while its sibling is not findable, and requiring an
|
||||
/// unbounded source let exactly that shape through.
|
||||
/// - **Trigger — any tainted receiver that is not a real device node.** A
|
||||
/// tainted hardware sink is the normal case, not an anomaly (peerspeak's
|
||||
/// playback taints the default sink every recompute), so device nodes do
|
||||
/// not trip it. The source does **not** have to be unbounded: a reader
|
||||
/// with a `node.link-group` whose re-emitting leg carries none is bounded
|
||||
/// while its sibling is unfindable (round 1).
|
||||
/// - **Sweep — depends on whether any tainted reader is itself unbounded.**
|
||||
/// A *bounded* reader's siblings are exactly the outputs sharing its key,
|
||||
/// so only the unbounded outputs (which could share its unknowable-only-
|
||||
/// in-part identity) are swept; a differently-keyed output is provably a
|
||||
/// different owner. An *unbounded* reader could be **any** owner — a real
|
||||
/// process may present no PID on its reading leg (round 4) — so every
|
||||
/// output candidate is swept, real apps included.
|
||||
///
|
||||
/// **Two tiers, because a tainted reader we cannot bound is a bigger
|
||||
/// unknown than one we can** (Codex round 3 — the mirror image of the
|
||||
|
||||
@@ -132,9 +132,13 @@ pub struct NodeProps {
|
||||
/// **Phase-3 contract:**
|
||||
/// - Set `true` only on positively-identified passive hardware
|
||||
/// terminals: a resolved `device.id` on a real backend
|
||||
/// (`device.api` present) whose `factory.name` is a hardware PCM
|
||||
/// factory (`api.alsa.pcm.{sink,source}` and the like), never a
|
||||
/// filter/loopback/null-sink factory. Measured discriminator on the
|
||||
/// (`device.api` present) whose `factory.name` is on an **explicit
|
||||
/// hardware-PCM allowlist** — `api.alsa.pcm.sink`, `api.alsa.pcm.source`,
|
||||
/// and the equivalent for other real backends (bluez5, v4l2 for the
|
||||
/// media case) as phase 3 enumerates them — never a filter, loopback,
|
||||
/// or `support.null-audio-sink` factory. An allowlist, not a
|
||||
/// substring or a denylist: an unknown factory is not a device.
|
||||
/// Measured discriminator on the
|
||||
/// target box: the five ALSA nodes carry `device.api=alsa` +
|
||||
/// `factory.name=api.alsa.pcm.*` and share `client.id=42`
|
||||
/// (`WirePlumber [export]`); the three `support.null-audio-sink` nodes
|
||||
|
||||
@@ -1721,3 +1721,27 @@ fn a_link_group_new_connection_of_a_still_tainted_owner_inherits_the_taint() {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_root_receiver_bridges_without_an_inbound_link() {
|
||||
// Round 5 finding 2: a mutation deleting the *role-based* receiver
|
||||
// insertion survived all 55 tests, because every tested bridge source
|
||||
// also had an inbound Link that put it in `receivers` anyway. A
|
||||
// pixelpass capture sink is a taint root the moment it exists — before
|
||||
// anything links into it — and its owner's re-emitting leg must bridge
|
||||
// from it on the strength of its role alone.
|
||||
let mut graph = Graph::new();
|
||||
// A capture sink (PixelpassOwned by name), sharing module id 55 with a
|
||||
// re-emitting output leg, and NO inbound link yet.
|
||||
let sink = graph.module_node("pixelpass_capture_4242", MediaRole::Sink, 55);
|
||||
let leg = graph.module_node("capture-reemit", MediaRole::StreamOutput, 55);
|
||||
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
|
||||
|
||||
let decisions = run(&graph, &ctx());
|
||||
assert_partition(
|
||||
&decisions,
|
||||
&[("firefox", firefox)],
|
||||
&[("leg", leg, "tainted-owner-bridge")],
|
||||
);
|
||||
assert_tainted(&decisions, sink, "pixelpass-owned");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user