956534f63c56a50c01f4023c9b6a26f87d1ddad6
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c78eb2dd39 |
host/taint: a self-claimed pid is not provenance (F11-1)
Boundedness now requires a strong key, or a key-4 value backed by a **resolved Client** — an unambiguous Client yielding Some(pipewire.sec.pid), read before pipewire-pulse suppression. A node whose Client cannot be resolved at all is unbounded whatever `application.process.id` it puts on itself, so it can no longer spare itself from `propagate_unresolved_owner`'s sweep with a value it made up. Closes the round-11 review's finding 1: the key-4 union could *reduce* taint, because the same key list feeds boundedness and the sweep is armed by an UNbounded tainted reader. The recorded three-step path (reader bounded by its Client's real pid, output leg on an ambiguous Client claiming a bogus pid, no shared key so no bridge either) is now a test. Bridging is untouched: it still uses the full union, so boundedness is stored on OwnerKeyIndex rather than re-derived from the key set, and `bounded_by` is the single implementation of the predicate. Five-case Client matrix as tests (absent · ambiguous · unique-but-pid-less · resolved-native · resolved-to-pipewire-pulse). The pid-less row is the one that distinguishes the correct reading of "resolved" from "a unique Client exists", which would have left the hole open. Mutation-verified: dropping the provenance test fails four of the six rows and passes the two that must not regress. Measured cost on the live graph: zero. Before- and after-binaries audited the same graph simultaneously (tagged producer + parec on the monitor as a real tainted reader, so the sweep was armed) — 181 records each, the same 14 distinct decision states, none exclusive to either side, no unresolved-owner on either, eligible half non-empty throughout. O5 unmoved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b8b8b78b09 |
host/taint: pin what "resolved" must mean before F11-1 is implemented
Round 12 re-examined the deferral and agreed it holds while evaluate() is audit-only, and that the recorded rule closes the path without unbounding Pulse-emulated apps — but only under one reading of "a node whose Client cannot be resolved at all". The trap is worth writing down before anyone implements it: reading "resolved" as "a unique Client object exists" passes for a unique Client with sec_pid = None, which supplies no protected identity and leaves exactly the self-claimed-PID hole the rule exists to close. It has to mean an unambiguous Client yielding Some(pipewire.sec.pid), taken before pipewire-pulse suppression. That also means the §5.1 matrix needs five Client cases rather than two: absent, ambiguous, unique-but-pid-less, resolved-native, and resolved-to-pipewire-pulse. The pid-less row is the one that distinguishes the two readings and the one a two-case matrix skips without saying so. Docs only. Still deferred, still to be decided with matrix data in hand. 220 tests green, clippy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
289016d071 |
host/taint: record the open owner-union/boundedness interaction
Round 11 review, finding 1. Verified correct: round 10's claim that the key union was "strictly additive" was too strong. The same key list feeds owner_is_bounded, and the unresolved-owner sweep is triggered by an UNbounded tainted reader -- so adding the Client's PID can move a reader from unbounded to bounded and switch the sweep off, letting a same-process output leg with an ambiguous Client and a bogus self-claimed PID stay eligible. Cannot leak today (evaluate() is audit-only); becomes live in phase 6. Not fixed in this round, and the doc says why: the blunt repair -- only protected keys bound an owner -- makes every Pulse-emulated app unbounded, which re-triggers the mass over-exclusion the design exists to avoid and would empty the eligible half of the 5.1 matrix. The targeted rule (a node whose Client cannot be resolved at all is not bounded by its own self-claimed PID) is written down along with what it needs structurally, to be implemented with matrix data in hand rather than argued from a whiteboard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4b2b192601 |
host/taint: correct the constant's own doc, and record the ProcessId ambiguity
Verification round on the round-10 review fixes. Finding 6 named the fixture, taint/tests.rs and snapshot.rs, but the same stale claim was also on PEERSPEAK_OWNED_VALUE itself — the definition site for the very literal the finding was about, still arguing that any truthy value counts and that this is the fail-closed direction. Corrected with the reason the argument fails. Also records a known imprecision the union widened: OwnerKey::ProcessId now covers both application.process.id and the Client's pipewire.sec.pid, so a bridge reported under the former may have resolved on the latter. Pre-existing since R10-3; not fixed here because these codes are a stable contract for the audit output and the phase 6 status event, so splitting one wants its own decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
993befdedd |
host/taint: carry both pids as owner keys; gate the wiring and the contract
Round 10 review, findings 1, 4 and 6. Finding 1 (P1, phase 6) — key 4 was `node.or_else(client)`, so a node's client-controlled application.process.id REPLACED its Client's protected pipewire.sec.pid. One process using two Clients could therefore split its identity: the tainted reader reports a bogus node pid, the output leg omits the node pid and falls back to the Client's real one, the legs are bounded by different values, and they neither bridge nor trip the unbounded sweep — the output stays eligible while re-emitting the call. Now a union of both values, deduplicated, with exception 1 applied to each independently so the pipewire-pulse pid still cannot fuse unrelated Clients. Mutation-verified: reverting to or_else fails ONLY the new split-Client test (so the union changes nothing else), dropping exception 1 fails 32 rows, and using the Client pid alone fails 16. Not reachable today — evaluate() is reached only by the dry-run audit, which creates no links. It becomes live when phase 6 consumes these decisions. Finding 4 — R10-4's test called peerspeak_owned() directly, so reverting node_observation_from_props to truthy() left it green; the only case it shared with production, exact "1", passes under both. A new test builds a real pw_properties dict and drives the production wiring, and the mutation now fails exactly that test while the helper test still passes. Finding 6 — the cross-repo fixture still documented carrier 1 as "any value other than false/0", which R10-4 made exact-"1". A producer following it could emit "true" and silently lose the carrier. Fixture updated in both repos (byte-identical, verified), along with the stale prose in taint/tests and snapshot.rs, and the contract is now also exercised through the production adapter rather than only against the constants. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
abaf5d9c10 |
host/taint: a pid-less Client still makes its id ambiguous
Verification round on R10-3's own fix. The ambiguity guard detected a duplicate client id by looking it up in the pid map — which is only populated for Clients that carry a sec_pid at all. A pid-less first claimant therefore left no trace, so the next Client claiming the same id looked unique and its pid was used, resolving an ambiguous id: exactly the guess the guard exists to refuse. Reachable, not theoretical — pid-less Clients are ordinary here (the session manager's is one). Reproduced: the bystander app went eligible off a coin-toss owner attribution. Claimed ids are now tracked separately from resolved pids. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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>
|