104a95a6d78644d3e4832187d4e09fa9a34ff365
153
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
104a95a6d7 |
repair: let the token beat the namespace guesswork, and ask liveness first
Round 5. Two blocking P2s, both in the execution shell rather than the token design, plus an overstated claim of mine. **The degradation signals were defeating the token in exactly the case it exists for.** A host crashing inside a container leaves a token that matches this machine, boot and pid namespace perfectly — but the probe saw a container marker or a multi-entry `NSpid` and answered `Unknown` for everything, so token-qualified repair did nothing precisely where it had just become safe. Those signals are guesswork about whether a bare pid is meaningful, and for an attributed pid that is no longer a guess. Liveness is now asked with the module's attribution in hand: a tokened pid goes straight to `kill`, while an untagged one still has to get past the signals, because there they are the only protection left. **Liveness now runs before the fresh snapshot, not after it.** The natural order — verify the module, check liveness, unload — leaves the dangerous window open: after `kill` returns ESRCH this process can be descheduled while the planned module vanishes, a new host inherits both the pid and the module index, and its differently-nonced arguments occupy that index. Nothing re-read those arguments, so the reused index would have been unloaded. Asking liveness first means the post-liveness fingerprint check catches that replacement, leaving only the irreducible snapshot-to-unload interval. **The nonce claim was overstated and is now true.** A token was minted once per `Routing` session and reused for every subsequent reload, making it a host-session nonce rather than a per-load one. It is now minted inside `load_module`, mixing a bumped counter with the clock, so two loads by the same pid really do render different arguments — which, combined with the reordering above, is what lets a fingerprint tell a module from its replacement at the same index. ⚠️ **The first version of the attribution fix had no gate, and the mutation said so.** Swapping `of_attributed` back to `of` passed all 254 tests, because on an ordinary desktop the probe is not degraded and the two paths agree, while the planner tests use a fake liveness closure that never touches the probe at all. The new test constructs a *deliberately degraded* probe, which is the only state where the distinction is observable. Both mutants — routing a tokened pid through `of`, and re-applying the degradation gate inside `of_attributed` — now fail it. Codex's answers to the questions I raised, recorded because they close them: omitting the pid from the token loses nothing, since the canonical argument already binds the token to exactly one pid; namespace inode reuse is real but only after the old namespace is destroyed, so its host is necessarily gone and no live owner is endangered; and refusing foreign tokens even under `--repair-legacy-untagged` is the right line, because the flag speaks to missing evidence rather than wrong evidence. No finding against the two-hole template derivation. 255 tests, clippy clean, fmt clean. All four live field gates re-run green: tokened cleaned, foreign refused with and without the flag, untagged refused then cleaned on request, A/B orphan removal byte-identical elsewhere, reference gate still firing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d9ef38c1c5 |
repair: a pid is not an owner — modules carry a machine/boot/namespace token
Closes the last blocking finding: a pid is only a number, and the same number is a different process in a different pid namespace. Repair running inside a container that can reach the host's Pulse socket saw a live host's modules, asked about that pid in its own namespace, was told nothing existed, and unloaded a running host's audio. No negative signal closes that — `NSpid == 1` does not prove the initial namespace, since its leftmost value is relative to whichever procfs was mounted. So the module now carries the answer with it. Every module a host loads gets `pixelpass.owner=<version>-<machine>-<boot>-<pid_ns>-<nonce>`, and repair only asks about a pid when all three identities match its own. Anything else is reported and left alone, and its pid is never even looked up — asking is the bug, because the answer would be meaningless. **Untagged modules are refused by default.** Everything loaded before tokens existed is unattributable, so `--repair` now lists those and does nothing, with `--repair-legacy-untagged` to opt into the old pid-only heuristic after seeing the candidates. That is a deliberate loss of reach: the failure being optimised against is a false-positive destructive repair, and leaving an old orphan behind is recoverable where destroying live routing is not. A foreign token is refused even with the flag, since the flag speaks to missing evidence, not wrong evidence. The vehicle was verified on the live server before anything was built on it: all three shapes accept a property-list argument (`sink_properties`, `sink_input_properties`, `source_output_properties`), the recorded argument comes back byte-identical — so exact-form matching still holds — and the property really lands on the resulting sink, sink-input and source-output. **Audit gate passed, with the variable isolated.** The token rides on real graph objects that phases 2/3 observe, so the partition had to be re-measured. Running the same fixture with and without tokens gives an identical partition: 2 eligible (FFXIV, Chromium), 2 excluded with the same `tainted-owner-bridge` reason, and the same six-entry taint set. Everything that differs from the empty-graph baseline is the fixture's own doing — a local-monitor loopback genuinely bridges our owned sink into the real default sink — and none of it is the property's. Attributing that to the token without the untokened control would have been the mistake. A side benefit: the per-load nonce narrows the ABA window I previously documented as unclosable. Two loads by the same pid no longer render byte-identical arguments, so a fingerprint taken from one no longer matches the other. Six new tests, three mutation-verified gates: `can_judge` always true, `pid_ns` dropped from the comparison, and untagged treated as judgeable regardless of policy — each killed by its own test. ⚠️ The third "survived" on first run because my mutation script's indentation did not match and the edit silently did nothing; the re-run asserts the file actually changed. A mutation that was never applied proves the same amount as no mutation at all. Field-verified live, three fixtures for one dead pid in one run: tokened with this machine's identity is cleaned, tokened with a foreign pid namespace is left alone and reported (and the legacy flag does not override it), and untagged is refused then cleaned only when asked. The two older field fixtures were tokenised too — without that the A/B test would have failed and the reference-gate test would have passed for the wrong reason, which is a vacuous gate in the harness rather than the code. 253 tests, clippy clean, fmt clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1cf6e915c8 |
repair: drop the settling ritual, record the binding's timeout leak
Both P3s from the round-4 review. The eight non-blocking mainloop iterations in `Drop` were a ritual, not a barrier: a fixed number of polls cannot guarantee any particular event became ready. They were also unnecessary — PulseAudio's context unlink cancels outstanding operations and tears down the context's socket machinery synchronously, so once `drop(context)` returns the mainloop has no obligation left to service. Removed rather than replaced with a time-bounded drain, since there is no asynchronous obligation for such a drain to wait on. Re-verified live: both field gates and a clean-graph run still exit 0, with no abort. Also recorded, at the constant it depends on: on a request timeout the `Operation` wrapper is dropped while still running, and libpulse-binding 2.30.1 only unrefs the C operation, so the boxed callback and its captured `Rc`s leak until the context cancels the operation at disconnect. Harmless here — `--repair` is a one-shot process that exits immediately after — and it cannot become a use-after-free, since the closure owns its clones and the context clears callbacks before the mainloop is touched. It would NOT be acceptable in the long-lived host, so the note says so where someone would otherwise reuse this module for host-side loading. The review's verdict on the teardown itself: disconnect, destroy the context while the mainloop lives, then the mainloop, is the correct order, and taking the context explicitly makes it independent of field declaration order. 247 tests, clippy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
45464b4af5 |
repair: read and unload Pulse modules through libpulse, not pactl text
Third review round found three more blocking P2s, and they shared one root cause: `pactl`'s text output cannot carry the guarantees repair was claiming. All three are closed by talking to the protocol instead. New dependency taken with the user's explicit sign-off after vetting. **Record boundaries were unprovable.** `pactl list short modules` prints a module's argument raw into a tab/newline-delimited format with no escaping. A *genuine* module whose argument contains a newline renders a first line that reads byte-exactly like one of our canonical forms, with the remainder dropped as an unparseable continuation — no forged index, so the duplicate-index check could not see it. Repair would have classified and unloaded a module it never saw in full. **Field-confirmed on the live server**, because this needed no adversary: loading a loopback whose argument is canonical-then-newline-then-`remix=false` (a real loopback option) produces exactly that listing. A tab in the same position is worse: it hid a sink reference from the gate that protects a still-referenced sink. **Index and argument could be mis-paired.** The listing carrying exact arguments (`-f json`) carries no index at all on pactl 17; the one carrying the index cannot carry the argument faithfully. Correlating them by position — which the previous commit did — is unsound whenever module names repeat: another client loading one module and unloading another between the two calls leaves counts and names aligned while every argument has shifted by one, so a foreign module inherits a canonical fingerprint. The name check cannot see it and the retry never fires. **Locality was a guess.** `PULSE_SERVER` is a fallback *list*, so `unix:/missing tcp:remote:4713` passes any "starts with unix:" test and then connects to another machine, where local pids mean nothing and a live remote host's modules look dead. A remote server can also be selected by client config with the variable unset entirely. New `repair/introspect.rs` owns one verified-local connection: `pa_module_info` gives index, name and exact argument in a single record, `pa_context_is_local()` answers locality about the connection actually established, and unloading goes back through that same connection so listing and destruction cannot disagree about which server they mean. It holds no policy beyond refusing the wrong server; every decision stays in the pure planner. ⚠️ **The field test caught a real bug that no unit test could have.** The first version did its work correctly and then aborted on the way out: Assertion '!e->dead' failed at ../pulseaudio/src/pulse/mainloop.c:207, function mainloop_io_free(). Aborting. SIGABRT, core dumped, exit 134 — a fully successful repair reporting failure to its caller. Cause: Rust drops fields in declaration order and the context's teardown frees IO events living in the mainloop, which I had declared first. **This is exactly the invariant phase 0b exists for, met again one layer down.** Fixed, and then hardened past the fix: `Drop` explicitly takes and destroys the context before the mainloop, so the ordering no longer depends on where the fields are written. Liveness keeps its `NSpid`/container checks but the claim is corrected: `NSpid > 1` means "definitely nested", while `NSpid == 1` is NOT proof of the initial namespace — its leftmost value is relative to the procfs that was mounted, so a nested namespace with its own `/proc` reports one entry legitimately. These are negative signals that fail closed, not a proof of trustworthy pids. Closing that properly needs modules to carry an owner token (machine/boot plus pid-namespace identity), which changes what pixelpass writes into the graph and how far back `--repair` can clean up: recorded as a design decision, not guessed at. libpulse-binding 2.30.1 vetted before use: MIT/Apache-2.0, 5.5M downloads, 3 new crates total, build script does nothing but probe pkg-config, no network or subprocess use anywhere in the sources, and all three historical RustSec advisories (2018-0020, 2018-0021, 2019-0038) were fixed by 2.6.0. The reasoning is recorded in Cargo.toml beside the dependency. 247 tests, clippy clean, fmt clean apart from the pre-existing taint/tests.rs:2683. The text parser's tests are gone with the parser; the liveness probe keeps its own, and the live field gates — A/B orphan removal, the reference/unrecognised fixture, and the newline fixture — all pass with exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
01c582427b |
repair: one atomic listing, namespace-aware liveness, renderer as authority
Second review round. Two blocking P2s and two P3s, all applied.
**The two-listing correlation was unsound, so it is gone** (P2). Pairing the short
listing's indices with the JSON listing's exact arguments by position breaks with
repeated module names: if another client loads one module and unloads another
between the two calls, the counts and names still line up while the arguments have
shifted by one — and a *foreign* module inherits a canonical fingerprint. The name
check cannot see it and the retry never fires, because correlation "succeeded".
Observations now come from a single `pactl list short modules` invocation, so every
`(index, name, argument)` comes from one server response and cannot be
mis-assembled. The two costs of that format are handled rather than hoped away:
- A tab inside an argument is invisible here, so the row is marked
`args_complete: false`. `classify` refuses such a row outright — a truncation
could otherwise coincide with a canonical form — while the reference gate can
still see that it names a sink.
- A crafted argument containing a newline can fabricate a row, but it only does
damage if it claims a *real* module's index, which makes that index appear twice.
A duplicated index now refuses the whole run.
libpulse introspection (`pa_module_info` carries index, name and argument in one
record) remains the exact route. It is a new dependency plus a mainloop in a
one-shot CLI path, so it is recorded as the upgrade rather than taken unilaterally.
**Liveness was still converting invisible-but-alive into dead** (P2). A
`/proc/self` preflight proves nothing: inside a pid namespace — a container, a
distrobox — `self` is visible while every process in the parent namespace is not,
and `hidepid` has the same shape. Repair there can reach the host's Pulse socket,
see a live host's modules, call its pid dead and unload a running host's audio.
So the probe now asks for positive confidence instead: `NSpid` in
`/proc/self/status` reports this process's pid in every namespace it appears in, so
more than one entry means our pid numbers are not the outer namespace's and every
verdict becomes `Unknown`. A kernel that does not report `NSpid`, a container
marker, and a non-local `PULSE_SERVER` all fail closed the same way. Liveness
itself is `kill(pid, 0)` via the existing `nix` dep, where `EPERM` proves
existence; pid 0 and pids past `i32::MAX` are never asked, since `kill(0, …)`
would signal our own process group.
**The renderer is the authority, not the derived template** (P3). `classify` now
re-renders the pid it extracted and demands byte equality, so the template is only
a pre-filter. `Shape` also owns the module *name* now, and `host/audio.rs` loads
through `Shape::{module_name, render_args}` — previously the "cannot drift" claim
covered only arguments while the names were still written out at both ends. The
sentinel assertion is unconditional (`assert!`), so a future shape that repeats the
pid cannot slip through a release build.
**A test I wrongly called unclosable** (P3). I argued no non-vacuous case could
prove the module name is part of the identity, because the name determines which
argument grammar can match. That was wrong: the grammars are not disjoint —
`module-echo-cancel sink_name=pixelpass_capture_9` is byte-identical to the
canonical null-sink argument, which the suite already constructs. Same index, same
arguments, different name, and `still_matches` must say no.
252 tests (+1 net; the correlation tests were replaced by parser and probe tests),
clippy clean, fmt clean apart from the pre-existing taint/tests.rs:2683. Both live
field gates re-run against the rewritten observation path: the A/B orphan test
still removes exactly the two orphans with the module table otherwise identical,
and the reference/unrecognised fixture still leaves both modules alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9145b2a726 |
repair: exact-form matching, tri-state liveness, and a reference-gated sink
Codex's review of the repair planner returned changes-requested: no P1s, four reachable P2s plus a P3, all in the observation and execution layers rather than in the discovery fix itself. All applied. **Only the canonical forms are ours** (P2, plan.rs). `classify` recognised any loopback with one pixelpass-looking endpoint, so a third party's `module-loopback source=some_mic sink=pixelpass_capture_4242` was ours to unload once that pid died — and a `sink=` token nested inside a quoted `sink_input_properties` value could be mistaken for a top-level argument. The whole recorded argument string must now equal what pixelpass itself would have written. The matcher's templates are **generated from the loader's own renderers**, not written out beside them: hard-coding `latency_msec=20` in a matcher means a loader change silently blinds repair to every module the new build loads, which is the fail-closed-and-silent failure this project has been bitten by three times. `host/audio.rs` now loads through those same renderers, so the two cannot drift. Blindness is also reported rather than assumed impossible — `unrecognised_pixelpass_modules` finds modules that name our sinks but match no canonical form, and `--repair` says so loudly. Measured on the live server before relying on it (pactl 17.0): arguments come back byte-for-byte as passed, joined with single spaces, with `@DEFAULT_SINK@` NOT resolved. Both facts are load-bearing for exact matching and both have a test. **Ordering is not a licence either** (P2, mod.rs). The plan put loopbacks before the sink, but an unload can fail or be skipped and a loopback can appear after planning, so the executor could still destroy a sink that something was attached to. The sink unload is now gated on `sink_still_referenced` against the fresh snapshot — any other module naming that sink blocks it, ours or not, because the question is what would break, not who owns it. **Undecidable is not dead** (P2, mod.rs). `Path::exists()` maps permission errors, a missing `/proc` and a foreign pid namespace all to `false`, which here read as "dead, go ahead and unload". Liveness is now `Alive | Dead | Unknown` via `try_exists()` behind a `/proc/self/stat` preflight, and `Unknown` is treated exactly like alive and reported separately. **The short listing cannot carry a fingerprint** (P2, mod.rs). Its arguments are tab-delimited text that a module argument may itself contain, and a continuation line beginning with a digit could fabricate a row. Observations now come from two listings: the short one for the module index, and `pactl -f json list modules` for the exact argument. Codex proposed JSON alone; on pactl 17 its records carry `"index": null`, so it cannot be used on its own — verified, hence the correlation. The pairing is positional and *checked* (same count, same name at every position, else refuse), which is also what makes a fabricated row harmless instead of exploitable: it has no JSON counterpart, so the sequences misalign. Normalisation is gone (P3). Within one invocation every snapshot comes from the same server, so re-rendering does not happen, and normalising only made genuinely different arguments compare equal. The residual ABA window — planned module vanishes, byte-identical one takes its index — cannot be closed through an unload API whose only argument is an index; that is now said plainly in the fingerprint's own doc comment rather than implied away. Five vacuity gaps Codex found in the tests, closed: a raw-pactl-output-to-plan test (the planner suite survived a parser that dropped every argument), the liveness-once test now uses two pids with per-pid counters, non-canonical and nested-quoted arguments have their own cases, and the reference gate has one. Field-verified on the live graph, both new rules: the A/B orphan test still removes exactly the two orphans with the module table otherwise byte-identical, and a fixture of a dead pid's legacy sink plus a non-canonical loopback naming it leaves both alone and reports why. 251 tests (+9), clippy clean, fmt clean apart from the pre-existing taint/tests.rs:2683. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
919d5bdef2 |
repair: find the orphans a connection-owned sink leaves behind
Phase 0c's first landed piece. `--repair` does not get smaller when the capture sink becomes connection-owned — it goes blind, which is the opposite of the assumption I started from and the finding that reordered this phase. Today repair learns a dead host's pid ONLY from `module-null-sink sink_name=pixelpass_capture_<pid>`, and matches loopbacks only if their pid is already in that set. After 0c the sink is a native node that removes itself with its owning connection, so a hard-killed host leaves two Pulse loopbacks and *no null-sink module to learn the pid from*. The set stays empty, nothing matches, and the orphans are invisible forever. Candidate pids are now derived independently from all three module shapes. Split into a pure planner (`repair/plan.rs`) and an I/O shell, because every interesting property here is a decision — which pid is dead, which module is whose, in what order to unload — and none of them need PipeWire to exercise. The rule that is new, and that the old code could not express: **a plan is not a licence.** Pulse module indices are reused verbatim, so an id planned against one module can name a different live module by the time the unload runs; a pid recheck alone does not catch that. Every action now carries a full fingerprint (id, module name, normalized args, derived pid, shape) which is re-verified against a FRESH snapshot immediately before each unload, with liveness rechecked last, closest to the destruction. Anything that does not match exactly is skipped and said out loud — never unloaded on the strength of a stale plan. Ordering is carried by `Shape`'s declaration order rather than by two separate passes, so loopbacks unload before the sink they reference by construction. Liveness is asked once per pid, not once per module: a flapping answer must not be able to half-repair a host, which is the one outcome worse than doing nothing. Field-tested against a real post-0c orphan, not just mocked: a connection-owned sink created via `pw-cli create-node adapter` (module-null-sink count: 0, so the old discovery provably could not see it), both loopback shapes loaded against it, then SIGKILL of the owning connection. The sink vanished on its own, both loopbacks survived, `--repair` removed exactly those two, and the module table was otherwise byte-identical before and after — the collateral-damage half of the two-host safety property. Mutation-verified, five mutants, each killed by its own gate: null-sink-only discovery (the 0c blindness itself), id-ordered unloads instead of shape-ordered, a fingerprint that compares only the index (exactly one), no liveness filter, and un-normalized args (exactly one). Still owed: the live two-host gate (two real hosts, kill one, prove the other's graph and modules are untouched) cannot run until the native sink exists, so it is deferred to 0c's combined exit gate. This lands with test + single-host field proof only, which Codex agreed is an acceptable phase dependency rather than an objection to landing repair first. 242 tests (+11), clippy clean, fmt clean apart from the known pre-existing `taint/tests.rs:2683` — pixelpass is never `cargo fmt`ed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
91c4dedcb0 |
host/observer: derive pipewire-pulse's PID from comm, not from repetition
MEASURED DEFECT, found by the §5.1 row-1 matrix run: the daemon PID was unresolvable on this host, permanently, which switched key 4's suppression off and fused every Pulse-emulated node into a single owner. Row 1's clean control forwarder was excluded, Firefox was excluded across an application.process.id bridge, and the taint reached the sunshine sinks and both sound cards -- the same machine-wide over-exclusion cascade as phase 5's F2, from a new cause. Stage 1 returned the one sec_pid shared by 2+ Clients, reasoning that only the Pulse shim repeats a value. WirePlumber repeats one too: it holds Clients 'WirePlumber' and 'WirePlumber [export]', both sec_pid 1747. Two values repeated, the rule called that ambiguous and returned None, and owner::keys_of's documented fail-closed asymmetry did the rest. The rule was wrong in both directions, so the prefilter is gone rather than patched: a second process holding two Clients defeats it (permanent, not a corner case), and a session where pipewire-pulse holds exactly one Client never repeats anything so the candidate is missed. comm was always the authoritative check; repetition stood in front of it and was a guess about other processes' Client counts. Now: candidates() lists every distinct sec_pid, resolve() picks the unique one whose /proc comm is exactly pipewire-pulse, and several matches still fail closed (a single Option<u32> cannot suppress two daemons -- recorded, not approximated). The adapter probes only PIDs entering the set, and retain_probed_comms bounds the map to live PIDs so a PID that leaves and returns is re-probed instead of answered from a stale comm. Row 1 now passes its exact partition, key named: tainted forwarder leg excluded on node.link-group, clean forwarder leg and Firefox eligible, taint confined to the tainted half. 225 tests green (WirePlumber-pair and single-Client regressions covered), clippy clean. |
||
|
|
d462754894 |
host/audit: name the owner key the bridge resolved on
Impl plan section 5.1 row 1 asserts "reason = owner bridge, naming the key",
and the record could not express that: Reason::code collapses
TaintedOwnerBridge { key } to one string, so an exclusion that arrived across a
named owner key was indistinguishable from one that arrived by an incidental
link walk reaching the same verdict. Telling those apart is the entire point of
the row.
OwnerKey::code already documented itself as ending up in the phase 5 audit
output; it was simply never wired to it. Adds owner_key to AuditRow and
TaintRow, omitted when absent, and absent is meaningful: a bridge whose tainted
member shared no key directly reached the node transitively, so there is no
single key to name and naming one would be a false diagnosis.
Read-only and diagnostic-only. New test mutation-verified (stubbing the key to
None fails it); 221 tests green, clippy clean.
|
||
|
|
90efa51c26 |
Merge phase 1: ownership carriers (taint roots from peerspeak's tags)
pixelpass-side half of phase 1: the taint engine recognises both registry-visible ownership carriers peerspeak now emits (peerspeak.owned and the peerspeak_owned_ node.name prefix), giving the primary taint root a path that survives the filtered registry global event (design v3.5 section 6.7). Reviewed by Codex rounds 10-12. F11-1 (the owner-key union can REDUCE taint via owner_is_bounded, switching the unresolved-owner sweep off) remains OPEN and still blocks phase 6; it is deliberately deferred to be decided with section 5.1 matrix data, and its sharpened rule is recorded in owner_is_bounded's doc. 220 tests green, clippy clean. |
||
|
|
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> |
||
|
|
67f4ff931e |
host/observer: match the ownership carrier exactly, not leniently
The lenient `truthy` spelling was wrong for this one property. Under it, `peerspeak.owned=""` and `peerspeak.owned="false "` both read as owned, so any process could suppress a rival application's audio from the share with a property it did not have to spell correctly. The justification for leniency was that treating an unexpected value as "owned" over-excludes and is therefore safe. That does not hold: leniency here buys false-positive exclusion, not safety. Fail-closed on this feature is about ancestry — an unresolvable graph is not eligible — not about parsing. The producer emits exactly PEERSPEAK_OWNED_VALUE at all three of its sites and is pinned to it by the shared cross-repo fixture, and a garbled property still leaves carrier 2's node.name prefix, which is a union with this one. `truthy` stays as it is for port.exclusive, port.monitor and node.passthrough: those are PipeWire's own, their spelling varies by producer, and each causes exclusion when true, so leniency really is the safe direction there. Both halves now have a row saying so. Codex phase-1 review F6. Round 10, R10-4. Mutation-verified. 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> |
||
|
|
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> |
||
|
|
45ca5057f8 |
host/taint: refuse an ambiguous contract fixture instead of resolving it
Codex phase-1 review, finding 3 (P2), concrete half. This side searched a list and took the first match for a key; peerspeak's side collected into a map and took the last. A byte-identical fixture containing a duplicated key would therefore leave both suites green while the two repos had selected *different* contracts — the precise drift the shared file exists to prevent. Both sides now assert the key is not already defined. Verified by appending a duplicate `prop_value` to both fixtures: both suites fail. The rest of finding 3 — one CI gate that feeds peerspeak's real tag_child output through this repo's actual adapter and classifier, rather than two per-repo literal tests — is a larger piece of work and is not attempted here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8b41f64e12 |
host/observer: tag the live prop-recovery row with the real wire value
Verification-round follow-up to
|
||
|
|
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> |
||
|
|
0af0124e17 |
Merge round 9: uncertainty is not history
Found by the phase-5 audit minutes after phase 3r landed — a permanent sticky taint on a hardware sink, from one link seen during enumeration. Sticky state is now built from an evidence-only pass; decisions still fail closed. |
||
|
|
e34289fdb5 |
Merge phase 3r: the registry global is an index, properties come from a bind
v3.5 §6.7, the fix for the phase-5 gate failure (F1/F2). Pure core + adapter, each half reviewed by the other author; four-part exit gate passed, including row 1 live on this host, plus an added live gate for the Device-side path. |
||
|
|
471b8221ff |
host/observer: address the Codex phase-3r review (2 fixes, both verified)
Codex's adversarial review of the pure core found no *certain* P1. Two findings taken, both mutation-verified (the fix reverted, the intended test dies, nothing else moves): **F2, certain, P2 — `device_props` tested the wrong kind of ambiguity.** It required exactly one live *Device* on the claimed id rather than exactly one live *global*. With `[Device, Port]` on one id — a missed removal, the same precondition as every other recycled-id hazard — it kept answering from the older Device, so a node claiming that id held a stale `session_device = true`. That flag strips the node's owner keys and its fail-closed backstop, so a forwarder wearing it can put its output leg back on the eligible side. Now: one slot total, and it must be the Device. **F3, worth checking, P3 — `device.api` was corroborating by presence.** `device.api=v4l2` under `factory.name=api.alsa.pcm.sink` satisfied the positive classifier. No truthful configuration produces that pair, which is the argument for reading it as an observation gone wrong rather than as corroboration. The API must now equal the one the factory allowlist is written for, an empty value is not a value, and the two sides disagreeing fails closed. Tied to the allowlist being ALSA-only via a named constant. Two findings NOT fixed here, both pre-existing and neither introduced by round 8 — raised to the design doc instead: - **P1, worth checking: hardware playback-to-capture paths** (Stereo Mix, Digital Loopback) on a card whose driver is an ordinary `snd_hda_intel`. Both its sink and source classify `session_device`, taint cannot cross the hardware hop, and a capture app reading that source can re-emit the call. This is `snd_aloop` again in a form the driver name cannot detect; distinguishing it needs ALSA control inspection, which is a design change and a new I/O surface, not a local fix. - **P3: the 2 s readiness budget** can in principle never see an obligation-free instant under sustained startup churn, and `TimedOut` is sticky by design, so the process would be silent for its lifetime. Measured here: readiness at ~3 ms with 19 binds, so the margin is three orders of magnitude — but it wants a calibration argument, not a guess. 197 unit + 3 live green, clippy -D warnings and fmt clean. |
||
|
|
64f98990c8 |
host/taint: uncertainty is not history — it never enters sticky state
Found by the phase-5 audit on the live graph, immediately after phase 3r landed: a hardware sink carried a permanent `unresolved-ancestry` taint. The cause was one link observed while its output node was still unbound — a correct fail-closed answer — which was then written into sticky state, where retirement requires every member object to be absent. A live sound card never is, so the mark survived readiness, 21 recomputes and deliberate churn. Phase 3r makes this systematic rather than rare: every node is now withheld until its bind resolves, so any link seen across that gap raises `UnresolvedAncestry` on its input side. It fires at startup, every startup. User decision (2026-07-25): uncertainty-based taint retires once the uncertainty is gone; evidence-based taint keeps the absence rule. Retiring by reason *code* would not be enough, because uncertainty launders itself — an unresolved node propagates `TaintedUpstream`, which is indistinguishable from real contamination once recorded. So the split is by **provenance**: `evaluate` runs the fixpoint twice. Pass 1 fails closed exactly as before and is what every decision is made from; pass 2 raises no uncertainty root at all, and is the only thing sticky state is built from. Nothing derived from an uncertainty can reach the sticky path. Decisions are unchanged by construction — all 57 existing taint tests pass untouched, including the fail-closed and sticky-survival rows. 3 new tests, mutation-verified (pointing `build_sticky` back at the fail-closed taint kills exactly the two new uncertainty tests and nothing else): unresolved ancestry clears once resolved; taint laundered downstream of an uncertainty clears with it; real taint still survives its topology disappearing. Live: the audit's post-readiness records now report taint 0 where they reported a permanent sticky entry before. Recompute cost roughly doubles as expected (two fixpoints) — 80 µs worst case observed, against a 47 Hz event rate. `taint/tests.rs` keeps its one pre-existing hand-formatted line; everything else in both files is rustfmt-clean. |
||
|
|
306b601490 |
host/observer: phase 3r adapter — bind every Node and Device
The I/O half of round 8 (Codex, gpt-5.6-sol xhigh; reviewed, formatted and extended here). The adapter now reads `object.serial` and nothing else off a Node or Device global, binds the object, and takes every property the engine reasons about from its `info` props. - `BoundProxy` generalises `BoundLink` to Node/Device/Link, each holding its listener *before* its proxy so the listener is dropped first — the original Link variant had that order inverted. - Bind attachment now finds its slot by never-recycled serial rather than taking the queue's back, so nested callback activity during a bind cannot attach one generation's proxy to another's slot on a recycled id. A proxy that finds no slot is returned to the caller and dropped after the borrow ends. Removal still pops oldest-first, matching the model's `live_ids`. - An `info` is parsed and emitted on the first callback carrying props and thereafter only when `change_mask` contains PROPS. I considered emitting unconditionally and leaning on the model's suppression rule, and rejected it: if a state-only `info` ever delivered a partial props dict, that would overwrite a complete observation with an incomplete one — a worse failure than the one it guards against, and the same class as F1. - Ports stay unbound (v3.5 §6.7 / impl plan §4 item 6). Gates: exit-gate row 1 (live prop recovery) passes on this host — the tagged null sink projects `peerspeak.owned`, `pulse.module.id`, `node.passthrough`, the loopback legs share a `node.link-group`, and a real ALSA node classifies `session_device`. Added a second live test for the Device half. Row 1's `session_device` assertion is satisfied by a *union*: WirePlumber 0.5.15 copies `device.api` and `alsa.driver_name` onto ALSA nodes here, so it passes through the node fallback and would keep passing if the Device bind delivered nothing — leaving §6.7 decision 4 ungated on the development machine. The new test binds every Device and requires an ALSA card to announce both keys. Mutation-verified: breaking the Device-side driver read fails the new test while row 1 still passes, which is the gap as claimed. 195 unit + 3 live green, clippy -D warnings and fmt clean. |
||
|
|
b3d71724ae |
host/observer: phase 3r pure core — node/device props come from a bind
v3.5 §6.7. The registry `global` event announces only a fixed 13-key subset
of a Node's properties, and eight the engine depends on are never among them
(phase-5 gate failure F1/F2). The core now treats the global as an index and
takes every property from the object's bound `info`.
- `RegEvent::NodeAdded { serial, id }` is identity only; `RegEvent::NodeInfo`
carries the properties and is both the first resolution and every later
PROPS change for the node's lifetime (decision 2). Same split for Device
(`DeviceAdded` / `DeviceInfo`).
- A node with no `info` is withheld from the snapshot and is a readiness
obligation; an unresolvable bind ends in sticky `TimedOut`, fail closed
(decision 3). Devices are keyed by serial too, so a recycled device id with
two live claimants is ambiguous ⇒ withheld rather than guessed.
- One live-node map replaces the admitted/withheld pair; classification is
recomputed at projection time from current inputs, since both sides of it
now change over an object's lifetime.
- `classify` takes the bound Device's props: presence is a union with the
Device winning (this recovers a real card whose node was never given
`alsa.driver_name` — the phase-3 review's owed fix), while the
non-terminal-driver denylist is a union in the safe direction.
- `apply` returns `Outcome`, the only sound place to enforce the suppression
rule: a property update is dropped only when model state provably did not
change, i.e. the resulting projection is identical.
Adapter: stops reading properties off Node/Device globals and emits the new
index events. Binding every Node and Device — the I/O half — is the next
commit (Codex's), so until then every node is withheld and readiness times
out by design.
Tests: 55 observer (was 38) — the prop-update matrix, readiness with node
binds, and recycled-Node-id churn (phase 3r gate rows 2–4). 195 green,
clippy -D warnings and fmt clean.
|
||
|
|
a1ac7ea8d5 |
Merge phase 5: dry-run audit mode (read-only)
The audit machinery is complete and verified live. The §5.1 gate itself FAILED — see peerspeak docs/screenshare-audio-exclusion-phase5-results.md — but both findings are defects in phase 3's observation boundary, not in this code, and round 8 needs the audit tool on main to re-run the matrix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bbf6744444 |
host/audit: phase 5 — dry-run audit mode (read-only)
Runs phases 2-4 against the live PipeWire graph on every registry event and
reports the complete eligible/excluded candidate partition with stable reason
codes. Creates no links, loads no modules, changes no routing.
Impl plan §5. Two entry points behind the hidden PIXELPASS_AUDIO_AUDIT=1
trigger: inside a real `pixelpass host` run (the plan-literal reading, proves
the path phase 6 will mutate), and a hidden `--audit-audio` standalone mode
with no iroh endpoint or capture pipeline, which is what drives the §5.1
matrix.
The recompute runs inline on the observer thread via a new ProjectionSink
hook, once per applied event. Polling `latest()` was rejected: it coalesces,
and phase 4 detects a module unload by observing the empty gap before the next
module appears — with indices reused verbatim (v3.4 §5.2 correction 3), a
missed gap aliases a fresh module onto a dead identity. Running inline is what
makes phase 4's "one observe per graph event" contract true, and it puts the
cost where O5 can measure it.
Split as usual: the auditor and the metrics are pure and unit-tested; the
clock, the writer and the env parsing are the thin edge in `sink`/`run`.
- audit/mod.rs Auditor: AEC validator + taint engine + record building.
The AEC gate and the engine's own reasons stay
distinguishable — a shut gate must not erase the reason codes
the §5.1 rows assert.
- audit/metrics.rs O5: event rate, bucketed recompute distribution + exact
max, busy fraction, and a documented lower-bound queueing
proxy (libpipewire exposes no queue depth).
- audit/sink.rs JSON Lines to stderr, or PIXELPASS_AUDIO_AUDIT_FILE. Never
stdout — peerspeak parses that stream.
- audit/run.rs Env parsing; a malformed AEC value is fatal, matching phase
4's rule that it must not silently become "no AEC".
Observer gains `EventKind` (derived from RegEvent, so a consumer's view of
"was this a real graph change?" cannot disagree with the model's) and
`Projection::readiness`, which distinguishes the three ways graph_ready can be
false. taint::fixture is now pub(crate) so audit tests share one graph
vocabulary with the taint tests.
33 new tests, 178 green, clippy -D warnings and fmt clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e0fe47d5a9 |
Merge phase 4: AEC identity validation state machine (pure core)
Bounded read-only state machine (NotConfigured/Validating/Validated/ Failed/Revoked) that validates peerspeak's live echo-cancel module identity and fills ExclusionCtx.aec_module_id — the last field the taint engine needed. Design v3.4 §5.2/§5.3, impl plan §4. Adversarial Codex review: no merge-blockers; five worth-checking items addressed via documentation + closing two test holes (both fixes mutation-verified). No core logic change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
4a844ac6fa |
host/aec: address Codex phase-4 review (no merge-blockers; docs + test holes)
Codex adversarial review found no merge-blocking defects. Five worth-checking items, all triaged for reachability: - F1 (spurious pre-ready revoke): unreachable — the AEC's four nodes are two Stream/* legs + a null-sink-like virtual sink/source, none claiming a device.id, so the phase-3 observer never withholds them; index_present goes false only on a genuine full unload. Documented why revoke is NOT gated on graph_ready, and why gating it would reopen the reused-index alias trap (F4) during a hot-reload-under-churn. Pinned with revokes_on_empty_even_while_not_ready (mutation-verified: `&& graph_ready` on the revoke guard dies here). - F4 (test relies on observing the empty gap): documented the phase-5/6 integration contract it rests on (one observe per graph event, no coalescing across a module lifetime boundary) and owed the robust fix (serial-continuity / observer-generation) to a later hardening round. - F2 (late positive evidence beats the deadline): intentional and correct — a demonstrably-present identity is ground truth. Documented + late_positive_evidence_wins_over_expired_deadline (both arms: node-first validates, Tick-first fails closed and stays sticky). - F3 (real P3 coverage hole): strengthened deadline_is_not_armed_until_ graph_ready to prove the budget starts at first-ready, not construction (mutation-verified: a construction-relative deadline now dies). - F5 (`+7` grammar mismatch): documented the producer contract — peerspeak emits bare decimal (pactl returns unsigned decimal), the narrow parser is deliberate. Unreachable on the measured stack. No core logic change. 24 pure aec tests, cargo test --bins green (145 unit + 1 ignored live), clippy -D warnings + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
b74ed17823 |
host/aec: phase 4 — AEC identity validation state machine (pure core)
Bounded, read-only state machine that validates peerspeak's live echo-cancel module identity before the taint engine trusts it, filling the last ExclusionCtx field (aec_module_id). Design v3.4 §5.2/§5.3, impl plan §4. States (v3.4 §5.3 verbatim): NotConfigured / Validating / Validated / Failed / Revoked. No fan-out while Validating; Failed and Revoked are sticky terminals so a reused module index (indices ARE reused, §5.2 correction 3) cannot alias a Revoked epoch onto an unrelated reload. Revocation is loss of the whole identity (every node bearing the index gone), never one leg corking. The Failed deadline is armed only on the first graph_ready, so a slow initial enumeration is "unknown" not "absent" and never times out spuriously. parse_aec_arg handles --aec=off|pulse-module:<idx> (D5): bare-u64 decimal accepted past u32::MAX, rejecting sign/whitespace/non-digit/ overflow/unknown-form. 22 pure tests (the exit-gate transition matrix), cargo test --bins green (143 unit + 1 ignored live), clippy -D warnings + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
8973e5dc19 |
Merge phase 3: registry observer (pure core + I/O adapter)
Split-seam mutual-review build: pure reducer/classifiers (Claude) + libpipewire adapter (Codex), each reviewed by the other. Two review rounds closed 3 P1s (dynamic graph_ready over invisible edges; snd_aloop absent-driver fail-closed; FIFO lockstep). Exit gate incl. live topology-diff row passes on the host. Additive/read-only — does not yet replace the audio.rs router (integration phase). DAG: 0a -> 2 -> 3 done; next is Phase 4 (AEC validation state machine). Co-Authored-By: Codex (gpt-5.6-sol) <codex@openai.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
f90bee63f7 |
host/observer: close the snd_aloop absent-driver leak (Codex re-review)
Codex's re-review of the phase-3 fixes confirmed finding 1/5/6 closed but found the finding-2 fix incomplete: the denylist only rejected a *present* snd_aloop driver, so an snd_aloop node whose alsa.driver_name was not copied onto the node still classified session_device=true — the original leak. The absence is reachable: PipeWire >=1.2.6 stopped overwriting node props with card props, and WirePlumber only began copying alsa.* onto nodes in 0.5.13. Fix: session_device now requires a PRESENT, non-denied ALSA driver; a missing alsa.driver_name fails closed to NotSessionDevice (a real card without the prop is over-excluded — safe; recovering it needs reading the driver from the backing Device global, owed to a later round). Mutation-verified: reverting to fail-open on absence is killed by classify_alsa_without_driver_name_fails_closed. Also: corrected the finding-3 limitation doc to cite PipeWire's object.serial identity contract rather than overclaiming the live gate proves it (Codex P3, non-blocking). 121 unit + live gate row 6 green, clippy clean, observer files fmt-clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
557c1030a7 |
host/observer: address Codex phase-3 review (2 P1 + P3s)
Cross-review round: Codex adversarially reviewed my pure core, found two merge-blocking P1s and several P3s. Triaged each for reachability; fixes below, each mutation-verified (revert killed by its intended test). P1 finding 1 — graph_ready was sticky-once-Complete, so a Link added post-enumeration whose endpoints are still binding (an INVISIBLE edge, absent from the snapshot) left graph_ready=true and a candidate could be reported eligible over unseen tainted ancestry. graph_ready is now dynamic: Complete AND no outstanding obligations. Readiness::Complete stays sticky as the epoch marker. New regression test + flipped the old sticky-churn test. P1 finding 2 — snd_aloop presents with an allowlisted ALSA factory and device.api=alsa exactly like a real card but forwards audio through a kernel hop the Link graph cannot see; it was classified session_device=true, dropping its owner keys + backstop (leak). Added alsa.driver_name to DeviceClaim and a NON_TERMINAL_ALSA_DRIVERS denylist under the factory allowlist; adapter now populates it. Negative fixture added. P3 finding 5 — the BlueZ allowlist entries (api.bluez5.pcm.*) were invented; removed them (real names are api.bluez5.media.*). A BT sink now over-excludes (safe) pending a measured fixture. P3 finding 6 — strengthened the timeout test to assert TimedOut stays sticky through later DeviceAdded/sync/tick. Findings 3 (dropped-link unrepresented) and 4 (missed-removal generation ambiguity) documented as accepted low-reachability limitations (links carry object.serial — confirmed by the live gate; registry does not drop removals). Codex confirmed the pulse-PID matrix fails safe and the adapter add() FIFO is lockstep. 120 unit + live gate row 6 green, clippy + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
66a0dd54df |
host/observer: phase 3 I/O adapter (read-only registry observer)
Codex authored the adapter half of the phase-3 split; I reviewed it and applied one robustness fix (below). Additive/read-only — does not yet replace the existing audio.rs router (that migration is a later integration phase). adapter.rs: a dedicated libpipewire main-loop thread translating registry globals into RegEvents and publishing the latest Projection via RegistryObserverHandle::latest(). core.sync(0)/done is matched one-shot → ServerSynced; a 250 ms loop timer emits Tick for the fail-closed readiness timeout; pulse-PID candidates are probed from /proc/<pid>/comm only when the candidate changes; Links missing endpoint props are bound (LinkInfoRef, weak back-ref to avoid the listener cycle) and resolved via LinkEndpointsResolved. mod.rs: `pub mod adapter;`. audio.rs: parse_object_serial → pub(crate) so the adapter reuses the strict 64-bit parser. Review fix: record_global was called unconditionally per global (including unknown object types and dropped globals), which could desync the bound-link FIFO from the model's live_ids and leak a Link proxy on a recycled id. Now folded into `add()` so a slot is recorded only when an Added event is applied — the two id queues are provably lockstep. Exit gate complete: 5 pure rows + the live topology-diff row (row 6) — the #[ignore] adapter test PASSES on this host against the live daemon (module-null-sink + module-loopback observed appearing and disappearing). cargo test --bins 117 + 1 live green, clippy clean, no fmt sweep. Co-Authored-By: Codex (gpt-5.6-sol) <codex@openai.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
8206864a43 |
host/observer: phase 3 pure core — RegistryModel reducer + classifiers
My half of the phase-3 split (impl plan §4). Pure, no PipeWire: the adapter (Codex's half) translates live registry callbacks / binds / /proc reads / core.sync into RegEvents and feeds this reducer. - RegistryModel::apply folds RegEvents into serial-keyed maps with an insertion-ordered id index so global_remove accounts for the oldest generation first; recycled ids stay Ambiguous until accounted (v3.4 §6.1.3). - Readiness epoch: graph_ready false until ServerSynced + no outstanding obligations (withheld nodes, pending link binds); bounded timeout fails closed. Gates sticky retirement only; sticky once terminal. - session_device classifier: hardware-PCM factory allowlist, exact match, fail closed to false; a node on an unresolved Device is withheld, never admitted provisional. - pulse-PID derivation split into pure candidate (repeated sec_pid) + validate (/proc comm), so the 6-case failure matrix is unit-testable; any failure => None (key 4 unusable). 34 tests cover 5 of 6 exit-gate rows (the live topology-diff row is the adapter's). cargo test --bins 117 green, fmt + clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ab597c332d |
Merge phase 2: pure graph model + taint engine
Impl-plan §4/Phase 2, design v3.4 §6.1-§6.1.3. Pure engine (evaluate(&GraphSnapshot,&ExclusionCtx,&StickyState)->(Decisions,StickyState)), never linked against libpipewire; fed by the phase-3 observer to come. Six adversarial review rounds with Codex (gpt-5.6-sol xhigh). Real echo leaks found and closed in rounds 1-3 (owner-bridge, sticky-client contamination, asymmetric forwarders, device mis-classification); my F1 narrowing refuted and conceded in round 4; contract strengthenings in 5-6. Every fix mutation-verified (reverting it is killed by its intended test). 57 tests, each asserting an exact eligible/excluded partition. KNOWN v1 LIMITATION (user-accepted 2026-07-22, owed to design doc round 8): an app that buffers the call, fully tears down its PipeWire objects, and replays after reconnecting can leak. In-threat-model but contrived; the fix (process-generation sticky lifetime, revising v3.4 §6.1.3) is deferred to phase 3's process-liveness work. Documented in src/host/taint/mod.rs. Phase-3 obligations recorded in the taint module docs and Codex's round-6 report. Unblocks phase 3 (registry observer). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
d54e2b99fc |
Merge phase 0a: object.serial u32→u64
Impl-plan §2/0a. Exit gate (boundary parse tests) met; reviewed by Codex (gpt-5.6-sol xhigh) round 1 — APPROVE-WITH-NITS, one P3 fixed and its mutant verified. Unblocks phase 2 (pure taint engine). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
87de5213fe |
audio: cover ordinary serial lengths in parse tests
Codex round 1 (P3): the valid cases were only 1, 10 and 20 digits long,
so `if (2..10).contains(&raw.len()) { return None }` survived all four
tests while rejecting every serial a freshly started daemon hands out.
Verified: that mutant passes the old suite and fails the new test.
Also corrects the doc comment — leading zeroes are accepted (harmless
and unambiguous), only whitespace padding is rejected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
9b6c8bb5c3 |
audio: parse object.serial as u64 (phase 0a)
`object.serial` is a 64-bit PipeWire counter, not a u32 object id. Parsing it with `parse::<u32>()` returns None past u32::MAX, which silently leaves `RouterState::sink_serial` unset — `try_flush` then routes nothing and app-filter mode is dead with no diagnostic. - factor the parse into a pure `parse_object_serial(&str) -> Option<u64>` (strict decimal; rejects signs, padding, overflow) with unit tests at the u32 boundary, past it, and at u64::MAX - widen `RouterState::sink_serial` to `Option<u64>` - log a warning when the sink's serial is unusable instead of returning silently - audit the other `parse::<u32>` in this file: `load_module` returns a PulseAudio module index (uint32_t), genuinely 32-bit — annotated, not changed Prerequisite for the taint engine's lifetime-awareness, which is keyed on object.serial (screenshare-audio-exclusion-impl-plan.md §1, §2/0a). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
40604c716c |
debug: add PIXELPASS_TS_DUMP tap for A/V drift analysis
When PIXELPASS_TS_DUMP=<path> is set, tee the muxed MPEG-TS to a file in addition to the normal fd=1 serve path, so the host-side stream can be ffprobe'd for capture-side audio/video PTS drift. Each tee branch gets its own queue so the disk sink cannot backpressure the live serve branch. No effect when the variable is unset, mirroring PIXELPASS_GST_DEBUG. Used to establish that the host produces an A/V-clean realtime stream (+/-18 ms over 170 s), ruling out the capture side in the screen-share drift investigation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |