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>
This commit is contained in:
2026-07-25 23:57:09 -04:00
co-authored by Claude Opus 5
parent abaf5d9c10
commit 993befdedd
5 changed files with 254 additions and 37 deletions
+41 -19
View File
@@ -183,26 +183,48 @@ fn keys_of(node: &NodeSnapshot, ctx: &OwnerCtx) -> Vec<(OwnerKey, KeyValue)> {
if let Some(client) = node.props.client_id {
out.push((OwnerKey::ClientId, KeyValue::Num(u64::from(client.0))));
}
// Key 4, from the node if it has one, otherwise from its Client
// (round 10, R10-3). The node's own property wins when both exist: it is
// the direct statement, and the Client's is a one-hop inference.
// Key 4, from the node **and** from its Client (round 10, R10-3; made a
// union rather than a fallback by the round-10 review, finding 1).
//
// ⚠️ **Exception 1 must apply to the fallback too, and this is the whole
// risk of the fallback.** Measured on this host: 15 unrelated Clients
// share `sec_pid` 2528, which is pipewire-pulse's own — every
// Pulse-emulated app has one. Adding key 4 unguarded would fuse all
// fifteen into a single owner and bridge taint between completely
// unrelated applications, which is enormous over-exclusion. Guarded, the
// fallback strictly *adds* correct bounding: it fires only for native
// clients, which are exactly the ones that carry a real per-app pid here.
if let Some(pid) = node.props.process_id.or_else(|| ctx.client_pid(node)) {
// Note the fail-closed asymmetry when the daemon PID is unknown
// (`None`): the exception does *not* fire, key 4 applies to
// everything, and Pulse modules fuse into one owner. That is broad
// over-exclusion — annoying and safe — which is the direction v3.4
// §6.1.2's failure-mode paragraph asks for.
if Some(pid) != ctx.pipewire_pulse_pid {
out.push((OwnerKey::ProcessId, KeyValue::Num(u64::from(pid))));
// ⚠️ **A union, not `node.or_else(client)`, and the difference is a leak.**
// The node's `application.process.id` is client-controlled and optional;
// the Client's `pipewire.sec.pid` is `pipewire.*`, protected, and the only
// one that can carry a soundness argument (the same reason
// `propagate_unresolved_owner` sweeps everything for an unbounded reader).
// Letting the node's value *replace* the Client's meant one process using
// two Clients could escape the bridge entirely: its tainted reader reports
// a bogus node pid, its output leg omits the node pid and falls back to
// the Client's real one, the two legs are bounded by different values, so
// they neither bridge nor trip the unbounded sweep — and the output stays
// eligible while re-emitting the call. Carrying both values costs nothing
// and closes it: a leg that presents *either* value bridges.
//
// ⚠️ **Exception 1 applies to each value independently, and that is the
// whole risk here.** Measured on this host: 15 unrelated Clients share
// `sec_pid` 2528, which is pipewire-pulse's own — every Pulse-emulated app
// has one. Suppressing it per value is what keeps the union from fusing
// all fifteen into a single owner while still keeping each app's real
// per-app pid. For the common Pulse shape (node pid = the app's, Client
// `sec_pid` = the daemon's) the union therefore reduces to exactly the
// node's pid, as before.
//
// Note the fail-closed asymmetry when the daemon PID is unknown (`None`):
// the exception does *not* fire, key 4 applies to everything, and Pulse
// modules fuse into one owner. That is broad over-exclusion — annoying and
// safe — which is the direction v3.4 §6.1.2's failure-mode paragraph asks
// for.
for pid in [node.props.process_id, ctx.client_pid(node)]
.into_iter()
.flatten()
{
if Some(pid) == ctx.pipewire_pulse_pid {
continue;
}
let key = (OwnerKey::ProcessId, KeyValue::Num(u64::from(pid)));
// The two agree far more often than not; a duplicate entry would be
// harmless but would make the audit's key list read oddly.
if !out.contains(&key) {
out.push(key);
}
}
out