eaea188e05ff8ffa755f80fdb2fd4f22ecc76073
6
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
295a575b15 |
host/taint: owner key 4 falls back to the Client's pipewire.sec.pid
Native PipeWire clients put no application.process.id on their nodes — only client.id. keys_of read node properties alone, so those nodes had no key 4, were therefore unbounded, and propagate_unresolved_owner excluded them the moment any tainted reader existed anywhere on the machine. Measured: an untagged mpv was eligible alone, and became unresolved-owner the instant peerspeak played audio. Since peerspeak playing audio is the only situation in which this feature runs, that amounted to "native PipeWire apps are never shareable". The tainted reader that armed it was sunshine, which is itself bounded — so this is the bounded-reader arm, not the keyless-reader case §6.1.1 narrates. The pid is one hop away, on the node's Client, already in the snapshot. RISK, and the guard on it: every Pulse-emulated Client carries pipewire-pulse's own PID as sec_pid — measured, 15 unrelated Clients sharing 2528 on this host. An unguarded fallback would fuse all of them into one owner. Exception 1 therefore applies to the fallback exactly as it does to the node's own property, so the fallback strictly *adds* correct bounding rather than trading it. Ambiguous client ids yield no fallback pid: inventing an owner key is the one direction that can reduce taint, so a coin toss is the wrong guess. The client index is threaded through a new OwnerCtx rather than a sixth positional Option<u32>, and evaluate() builds one and shares it, so the components and the key index cannot disagree about who is bounded. Round 10, R10-3. 6 new rows; 3 mutations verified — removing the fallback, dropping the pulse-pid exception (11 rows die), and resolving an ambiguous client id instead of dropping it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bf5f2508b8 |
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> |
||
|
|
1b01847c66 |
host/taint: match peerspeak's second ownership carrier
The consumer half of phase 1 (plan §5.1, impl plan §3). The engine's tag root becomes a union: `peerspeak.owned` truthy OR `node.name` starting with `peerspeak_owned_`. Round 8 added the second carrier because a node property is invisible to the registry `global` event and recoverable only by binding the node — which is exactly how the phase-5 gate failed — while `node.name` is announced directly. The union lives in `local_root_reason`, not in the adapter. Folding both into the one `peerspeak_owned` bool at the observation boundary would make each carrier untestable alone, which is the phase-3r lesson: a gate asserting a value two sources can satisfy gates neither. The existing `peerspeak_tagged_nodes_…` fixture now carries both carriers, so it would keep passing if either were deleted; two new tests pin them individually, and a third pins that the prefix matches only at the start of a name. Both literals are now named constants — they are a cross-repo wire contract with peerspeak, not local naming — and asserted against tests/fixtures/ownership-tag-contract.txt, committed byte-identical in both repos. That test also runs the fixture's own worked example name through the engine, so the shared file cannot document a value this side does not actually exclude. Five mutations verified: drop either carrier, loosen `starts_with` to `contains`, or rename either constant, and exactly the intended test fails. Co-Authored-By: Claude Opus 5 <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>
|