f90bee63f75ea24d6d5e525693e9660a99a8433e
7
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
279903e56e |
host/taint: correct the buffered-echo scoping (in-threat-model); pin ambiguous-client (Codex round 6)
Codex refuted my round-5 disposition and was right: the buffered-echo gap is NOT limited to keyless/unbounded readers. A normal PID-bearing app — recorder, DAW, GStreamer — can read the call, buffer it in application memory, fully tear down its PipeWire Node *and* Client, then (still the same live process) open a fresh Client + output and replay. `seed_sticky` drops the PID fingerprint once every old serial is gone, so the replayed leg is Eligible. That is in-threat-model, so my "outside the threat model" claim was false. - Rewrote the module-doc gap note honestly: in-threat-model, reachable by non-adversarial software, sitting on the design's §6.1.3 "full teardown ⇒ starts clean" boundary. Framed the two options — (A) accept as a documented v1 limitation, (B) process-generation lifetime (PID + /proc start-time, phase 3 supplies liveness, §6.1.3 revised). This is a designer's decision (it revises the security surface); NOT resolved in code. `a_fingerprint_does_not_outlive_its_owner` currently encodes Option A and flips under B. - P2 (fixed): pinned the ambiguous-client-id branch. A mutation remembering only the first of two clients claiming one global id survived the suite; added a test scoped to the ambiguous owner (the global count was masked by the peerspeak owner's client). Verified the `.next()` mutation now fails it. 57 tests. Phase 2 is NOT converged — the buffered-echo design decision is owed to the user before merge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
65fde92628 |
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> |
||
|
|
2183084ec8 |
host/taint: concede the unbounded-reader rule; pin link-group fingerprints (Codex round 4)
Round 4 adjudicated my three round-3 pushbacks. Codex ruled: F2 bool seam sufficient (YES), F3 accepted as a phase-3 contract not a phase-2 blocker (YES) — but my F1 narrowing was unsound (NO), with a clean counterexample. F1 (conceded): I had narrowed "unbounded tainted reader ⇒ exclude every output" to spare outputs carrying a real, non-daemon PID, arguing an unbounded reader must be daemon-owned. Codex refuted it: `application.process.id` is optional and client-controlled, so one real process can present NO pid on its reading leg (unbounded) and a real pid on its output leg — the narrowing spares that output and leaks the call. App properties cannot carry a soundness argument; only `pipewire.*` has protected identity. Reverted to the broad rule: an unbounded tainted reader excludes the whole candidate universe. Added the exact counterexample as a test (`a_real_app_with_no_pid_on_its_reader_leg...`) and kept a bounded-reader test to show the round-1 blast-radius guarantee still holds for the bounded tier. F2 (doc corrected): removed the "a mis-classified filter is still braced" claim — Codex showed a filter with no shared strong key, wrongly marked `session_device`, cannot trip the backstop from its reading leg and leaks through a differently-keyed output. A false positive is now documented as leak-capable; the only defence is the correct positive classifier. F3 (link-group fingerprint, pinned): a mutation dropping LinkGroup fingerprints survived all 53 tests, because the strong-key fingerprint test used pulse.module.id. Added a link-group new-connection test. Mutation-verified 2/2. 55 tests. Phase-2 open item is now only F3-as-phase-3-contract, which Codex accepted is not a phase-2 blocker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
f35bab0379 |
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> |
||
|
|
31084edcfa |
host/taint: close the partial fixes found in Codex round 2
The verification round earned its place: five of the six round-1 fixes were partial, and two of the gaps were worse than the bugs they replaced. 1. ⚠️ The round-1 sticky fix smuggled the suppressed key back in. `client_serials_of` recorded the shared `WirePlumber [export]` client as a member of a tainted hardware sink's owner, so the *second* recompute expanded that client to every sound card on the box, tainted the microphone, and excluded every app holding one — the §6.1.1 catastrophe arriving one epoch late instead of never. `client.id` may now only be recorded, or expanded, for nodes where it is a usable owner key. The regression test evaluates an unchanged snapshot three times: a correct engine's answer must not drift when nothing has. 2. Sticky followed a surviving *connection*, not a surviving *owner*. A process can leave one client idle and open a second — GStreamer opens one per stream as a matter of course — and the new leg escaped. `StickyOwner` now carries owner **fingerprints** (strong keys and a usable PID, never `client.id`), applied only while some serial member is still live, so a recyclable key cannot resurrect a dead owner. 3. An **ambiguous** link input endpoint tainted every claimant but made none of them a receiver, so their sibling output legs stayed eligible. Taint without receiver status cannot start an owner bridge. 4. `device.id` is a raw observation, not the classification the coarse-key exception needs — PipeWire defines it only as "the Device this node belongs to", so a forwarding node carrying one would have lost both its owner keys and its ability to trip the backstop. Replaced by `session_device`, a phase-3 obligation (`device.id` AND `device.api`) documented to fail closed when it cannot classify. 5. Readiness now gates sticky **retirement only**. Round 1 stopped a not-ready epoch erasing history; it also stopped it recording any, so a reader could consume and buffer the call during that epoch, vanish before readiness, and leave its output eligible. 6. Added the unresolved-output-plus-unknown-role fixture: deleting one `receivers.insert` survived all 42 previous tests. Mutation-verified: 7/7 reverts killed by their intended test. Two attempts did not land first time and both were my error, not the engine's — the client-key guard is applied at two sites so removing one is not a revert (removing the pair is, and that is killed), and the fingerprint-lifetime test put the recycled node in a snapshot *after* the entry had already been retired, so the guard was never consulted. Rewritten to place it in the same snapshot that first sees the owner gone. Cost comment corrected again, to O(D·(V+E+Σ|sources|·|targets|)). 49 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
a46c4cd20c |
host/taint: close five leaks found in Codex round 1
All five were reachable, all five now have a regression test, and each test was verified by injecting the mutation that reverts its fix. 1. Sticky taint ignored surviving Client members. An app can close every stream while keeping its PipeWire connection open and then open a new one — Firefox does this constantly — and the new leg came back Eligible while the owner's buffers still held the call. Sticky seeding now resolves live Client serials to their current nodes. 2. "Receives audio" was inferred from `media.class` alone, so a node with an absent or unexpected class sitting on a real inbound link could not start an owner bridge and its sibling re-emitted the call. A node is now a receiver if it appears as a resolved `link.input.node` OR has a receiving role. 3. The device-node coarse-key exception was keyed on `media.class` being `Audio/Sink|Source|Duplex`, which also stripped the only correlation a *native virtual sink* has (own client, no link-group, no module id). Now keyed on `device.id`, measured on the live graph as the exact discriminator: the 5 ALSA nodes carry device.id 43/45/46 and share `client.id` 42 (`WirePlumber [export]`); the 3 `support.null-audio-sink` nodes carry no device.id and hold their own clients. 4. The unbounded-owner backstop required the tainted *reader* to be unbounded. Properties can be asymmetric — a reader with a link-group whose re-emitting leg has none is bounded while its sibling is not findable — so that condition is dropped; targets stay restricted to unbounded output legs, which keeps the blast radius small. 5. A not-ready snapshot could retire sticky owners, erasing taint history on the strength of a graph already declared untrustworthy. `evaluate` now returns the prior state unchanged while `!graph_ready`. Test-quality findings, also fixed: - a single pass of each rule survived all 32 tests (every fixture needed at most one owner hop) → two-chained-forwarder test with a clean control, plus a 60-layer chain to catch an accidental blow-up - first-write-wins `raise()` survived → a node reached by bridge on one pass and by a direct link on the next must report the stronger reason - `drop_clients` left the fixture's client caches stale, so "a fresh client after teardown" was really a dangling id; the recycling row now reuses node id, client id AND `pulse.module.id` verbatim Also corrected the cost claim: this is O((V+E)·D) for owner-bridge depth D, not O(V+E) as v3.4 §6.4 states. Owner keys are now computed once per snapshot instead of per candidate pair. 42 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
6ead1fe9f8 |
host/taint: pure graph model + taint engine (phase 2)
Implements design v3.4 §6.1–§6.1.3 behind a fixture test surface. No
PipeWire types in any signature; nothing here links against libpipewire.
Not wired into anything yet — phase 3's registry observer is what will
feed it, so the module is `#![allow(dead_code)]` for now.
evaluate(&GraphSnapshot, &ExclusionCtx, &StickyState)
-> (Decisions, StickyState)
- snapshot.rs: owned Node/Port/Link/Client model keyed on `Serial`
(object.serial, 64-bit, identity) with `GlobalId` retained strictly as
a snapshot-local lookup key. Two live objects claiming one id resolve
as `Ambiguous`, which fails closed.
- owner.rs: the owner bridge — the key union (link-group, pulse.module.id,
client.id, application.process.id) with equality-not-first-present
semantics, transitive union-find components, and both suppression rules.
- mod.rs: monotone fixpoint over link edges, the conditional owner bridge
(gated on the tainted member being one that *receives* audio) and the
unbounded-owner backstop, then sticky merge. Stable `Reason` codes with
an explicit priority so the reported reason never depends on traversal
order.
Three judgement calls that go beyond what v3.4 spells out, all flagged
in the source:
1. Coarse keys (client.id, application.process.id) may not bridge
device-role nodes. Every ALSA device is created by one WirePlumber
process, so they share a client and a PID; peerspeak's playback taints
the default sink on every recompute, and without this rule that taint
reaches the microphone source and then every app holding a mic loses
its playback — the §6.1.1 catastrophe by another route.
2. "Owner is bounded" is not "has a usable key": client.id alone does not
bound an owner (the measured GStreamer split-client refutation), so
the fail-closed backstop keys on strong keys or a usable PID.
3. Sticky entries record a reason per node rather than one per owner, so
a forwarder's output leg keeps `tainted-owner-bridge` instead of
inheriting its input leg's `tainted-upstream`.
32 fixture tests, each asserting an exact partition of the full candidate
universe rather than spot-checking named nodes: v3.4 §12's matrix, the
impl plan's degenerate-snapshot boundary, and the eligible half of every
scenario so an exclude-everything build fails.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|