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>
This commit is contained in:
@@ -198,6 +198,17 @@ pub struct TaintRow {
|
||||
pub sticky: bool,
|
||||
}
|
||||
|
||||
/// A node carrying a peerspeak ownership carrier on a role the engine does not
|
||||
/// honour it on (round 10, R10-1). `role` is the point of the row: it says
|
||||
/// which non-producer role the tag turned up on, which is what distinguishes a
|
||||
/// producer-side bug from an impersonation attempt.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct IgnoredTagRow {
|
||||
pub serial: u64,
|
||||
pub name: Option<String>,
|
||||
pub role: &'static str,
|
||||
}
|
||||
|
||||
/// The decision content of one recompute — everything except which recompute it
|
||||
/// was. Split out from [`AuditRecord`] so "did anything actually change?" is a
|
||||
/// derived `==` rather than a hand-maintained field comparison that a later
|
||||
@@ -227,6 +238,16 @@ pub struct AuditBody {
|
||||
pub excluded_count: usize,
|
||||
/// Taint across all node roles, ascending by serial.
|
||||
pub taint: Vec<TaintRow>,
|
||||
/// Nodes carrying a peerspeak ownership carrier that the engine
|
||||
/// **ignored** because they are not `Stream/Output/Audio` (round 10,
|
||||
/// R10-1). Normally empty; a non-empty list means either peerspeak is
|
||||
/// tagging something it should not, or a process is impersonating the
|
||||
/// tag. Neither is an exclusion, and neither should be silent.
|
||||
///
|
||||
/// Omitted from the JSONL when empty, so it costs nothing on the common
|
||||
/// path and is impossible to miss when it is not.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub ignored_ownership_tags: Vec<IgnoredTagRow>,
|
||||
}
|
||||
|
||||
impl AuditBody {
|
||||
@@ -433,6 +454,16 @@ fn build_body(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let ignored_ownership_tags: Vec<IgnoredTagRow> =
|
||||
crate::host::taint::misplaced_ownership_tags(&projection.snapshot)
|
||||
.into_iter()
|
||||
.map(|node| IgnoredTagRow {
|
||||
serial: node.serial.0,
|
||||
name: node.name.clone(),
|
||||
role: node.role.code(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
AuditBody {
|
||||
graph_ready: projection.graph_ready,
|
||||
epoch: readiness_code(projection.readiness),
|
||||
@@ -444,6 +475,7 @@ fn build_body(
|
||||
eligible_count,
|
||||
candidates,
|
||||
taint,
|
||||
ignored_ownership_tags,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,55 @@ fn the_record_carries_the_complete_candidate_universe() {
|
||||
assert_eq!(outcome.record.body.excluded_count, 0);
|
||||
}
|
||||
|
||||
/// **R10-1's diagnostic reaches the record.** The engine deliberately ignores
|
||||
/// an ownership carrier on a non-producer, which means the fix removes an
|
||||
/// exclusion — so the only way an operator learns a tag was seen and dropped is
|
||||
/// this field. A matrix row that silently grew an impostor would otherwise read
|
||||
/// as a clean pass.
|
||||
#[test]
|
||||
fn an_ignored_ownership_tag_is_reported_without_excluding_anything() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
let impostor = graph.peerspeak_tagged_node("rogue", MediaRole::StreamInput, 4_242);
|
||||
let projection = ready(graph.build());
|
||||
|
||||
let body = observe(&mut auditor_off(), &projection, 0).record.body;
|
||||
|
||||
// The bystander is untouched — the point of the fix.
|
||||
let (eligible, excluded) = partition(&body);
|
||||
assert_eq!(eligible, vec!["music"]);
|
||||
assert!(excluded.is_empty(), "unexpected exclusions: {excluded:?}");
|
||||
assert!(body.taint.is_empty(), "unexpected taint: {:?}", body.taint);
|
||||
|
||||
// ...but the tag is not silent, and the row names the role it appeared on.
|
||||
assert_eq!(body.ignored_ownership_tags.len(), 1);
|
||||
let row = &body.ignored_ownership_tags[0];
|
||||
assert_eq!(row.serial, impostor.serial.0);
|
||||
assert_eq!(row.role, "stream-input");
|
||||
assert_eq!(row.name.as_deref(), Some(owned_name("rogue", 4_242).as_str()));
|
||||
}
|
||||
|
||||
/// The common path stays quiet: a correctly tagged peerspeak producer is
|
||||
/// honoured as a taint root and is *not* reported as a misplaced tag. Without
|
||||
/// this, a diagnostic that fired on every normal run would be worthless.
|
||||
#[test]
|
||||
fn a_correctly_tagged_producer_is_not_reported_as_misplaced() {
|
||||
let mut graph = Graph::new();
|
||||
let sink = graph.device_node("speakers", MediaRole::Sink);
|
||||
let call = graph.peerspeak_node("call", 200);
|
||||
graph.link(call, sink);
|
||||
let projection = ready(graph.build());
|
||||
|
||||
let body = observe(&mut auditor_off(), &projection, 0).record.body;
|
||||
|
||||
assert_eq!(body.excluded_count, 1);
|
||||
assert!(
|
||||
body.ignored_ownership_tags.is_empty(),
|
||||
"honoured tag reported as misplaced: {:?}",
|
||||
body.ignored_ownership_tags
|
||||
);
|
||||
}
|
||||
|
||||
/// The fail-closed default asserted at the boundary (impl plan §4, phase 2's
|
||||
/// "one addition"): nothing in, nothing eligible — and, just as importantly, no
|
||||
/// panic and no invented row.
|
||||
|
||||
@@ -161,6 +161,16 @@ impl Graph {
|
||||
self.node(&name, MediaRole::StreamOutput, peerspeak_owned(client, pid))
|
||||
}
|
||||
|
||||
/// Both ownership carriers on a node of **any** role — an impostor, or a
|
||||
/// producer-side tagging bug. Only [`MediaRole::StreamOutput`] makes it a
|
||||
/// taint root (round 10, R10-1); every other role must be ignored, and
|
||||
/// these are the fixtures that prove it.
|
||||
pub fn peerspeak_tagged_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
||||
let client = self.client_of_app(pid);
|
||||
let name = format!("{}{name}_{pid}", super::PEERSPEAK_OWNED_NODE_PREFIX);
|
||||
self.node(&name, role, peerspeak_owned(client, pid))
|
||||
}
|
||||
|
||||
/// Carrier 1 alone: the `peerspeak.owned` property present, the
|
||||
/// `node.name` prefix absent. What the engine sees for a node it had to
|
||||
/// bind to observe (v3.5 §6.7).
|
||||
|
||||
+47
-6
@@ -566,18 +566,59 @@ fn ambiguous_id_nodes(snapshot: &GraphSnapshot) -> BTreeSet<Serial> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Does this node carry either ownership carrier? **Tag presence only** — it
|
||||
/// deliberately says nothing about whether the tag is honoured, which is
|
||||
/// `local_root_reason`'s business (round 10 restricts that to producers).
|
||||
/// Split out so the "is it tagged?" and "does the tag count?" questions can
|
||||
/// be tested, and reported, independently.
|
||||
pub fn is_peerspeak_tagged(node: &NodeSnapshot) -> bool {
|
||||
node.props.peerspeak_owned
|
||||
|| node
|
||||
.name
|
||||
.as_deref()
|
||||
.is_some_and(|name| name.starts_with(PEERSPEAK_OWNED_NODE_PREFIX))
|
||||
}
|
||||
|
||||
/// Nodes carrying an ownership carrier that `local_root_reason` **ignored**
|
||||
/// because the node is not a producer (round 10, R10-1). Ascending by serial.
|
||||
///
|
||||
/// Purely diagnostic — nothing in the engine consumes it. It exists because
|
||||
/// R10-1 turns a formerly load-bearing tag into a no-op, and a silently
|
||||
/// ignored tag has exactly two causes, both of which someone wants to know
|
||||
/// about: peerspeak tagging a node it should not (a producer-side bug this
|
||||
/// would otherwise hide), or another process impersonating the tag (the F2
|
||||
/// attack, now defanged but still worth seeing).
|
||||
pub fn misplaced_ownership_tags(snapshot: &GraphSnapshot) -> Vec<&NodeSnapshot> {
|
||||
let mut tagged: Vec<&NodeSnapshot> = snapshot
|
||||
.nodes()
|
||||
.filter(|node| node.role != MediaRole::StreamOutput && is_peerspeak_tagged(node))
|
||||
.collect();
|
||||
tagged.sort_by_key(|node| node.serial);
|
||||
tagged
|
||||
}
|
||||
|
||||
fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option<Reason> {
|
||||
// The two ownership carriers, as a union (v3.5 §5.1). Kept here rather
|
||||
// than folded together at the observer boundary so that the union is a
|
||||
// pure, directly-testable rule: an adapter that collapsed both into the
|
||||
// one `peerspeak_owned` bool would make each carrier untestable alone,
|
||||
// which is exactly how phase 3r's row 1 nearly gated nothing.
|
||||
if node.props.peerspeak_owned
|
||||
|| node
|
||||
.name
|
||||
.as_deref()
|
||||
.is_some_and(|name| name.starts_with(PEERSPEAK_OWNED_NODE_PREFIX))
|
||||
{
|
||||
//
|
||||
// ⚠️ **Producer roles only** (round 10, R10-1). Neither carrier is a
|
||||
// security boundary — both are strings any unprivileged process can put
|
||||
// on its own node — so an unrestricted root is a denial of the whole
|
||||
// feature: an unlinked `Stream/Input/Audio` named `peerspeak_owned_x`
|
||||
// is a tainted *reader* with no owner bound to it, which fails every
|
||||
// candidate closed machine-wide (Codex phase-1 F2, reproduced live).
|
||||
// Restricting the root to `Stream/Output/Audio` costs nothing real —
|
||||
// peerspeak only ever tags playback streams — and the attack needs the
|
||||
// impostor to be a plausible playback node instead, which taints only
|
||||
// its own descendants. The AEC's virtual sink/source is unaffected: it
|
||||
// roots on [`Reason::AecIdentity`] below, by module id, not by this tag.
|
||||
// A tag on a non-producer falls through: ignored for taint, but not
|
||||
// nothing — it is either a peerspeak bug or an impostor, and
|
||||
// [`misplaced_ownership_tags`] surfaces it so neither is silent.
|
||||
if is_peerspeak_tagged(node) && node.role == MediaRole::StreamOutput {
|
||||
return Some(Reason::PeerspeakOwned);
|
||||
}
|
||||
if let (Some(module), Some(aec)) = (node.props.pulse_module_id, ctx.aec_module_id)
|
||||
|
||||
@@ -87,6 +87,20 @@ impl MediaRole {
|
||||
pub fn is_candidate(self) -> bool {
|
||||
matches!(self, Self::StreamOutput)
|
||||
}
|
||||
|
||||
/// Stable machine-readable code for the audit output. Not the raw
|
||||
/// `media.class`: `Other` has no single one, and the audit's codes are a
|
||||
/// contract with the matrix, not with PipeWire.
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::StreamOutput => "stream-output",
|
||||
Self::StreamInput => "stream-input",
|
||||
Self::Sink => "sink",
|
||||
Self::Source => "source",
|
||||
Self::Duplex => "duplex",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The subset of node properties the engine actually reasons about.
|
||||
|
||||
@@ -205,6 +205,106 @@ fn either_ownership_carrier_alone_taints_the_node() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **R10-1, the F2 fix.** Neither carrier is a security boundary — both are
|
||||
/// strings any unprivileged process can set on its own node — so the tag is
|
||||
/// honoured only on `Stream/Output/Audio`, the one role peerspeak ever tags.
|
||||
///
|
||||
/// Without the restriction, a tagged `Stream/Input/Audio` **with no links at
|
||||
/// all** 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. That is a whole-feature denial from
|
||||
/// an unprivileged process, reproduced live during the phase-1 review.
|
||||
#[test]
|
||||
fn an_ownership_tag_on_a_non_producer_is_not_a_taint_root() {
|
||||
for role in [
|
||||
MediaRole::StreamInput,
|
||||
MediaRole::Sink,
|
||||
MediaRole::Source,
|
||||
MediaRole::Duplex,
|
||||
MediaRole::Other,
|
||||
] {
|
||||
let mut graph = Graph::new();
|
||||
let sink = graph.device_node("hw-sink", MediaRole::Sink);
|
||||
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
|
||||
graph.link(firefox, sink);
|
||||
// Deliberately unlinked: the F2 shape needs no edges whatsoever.
|
||||
let impostor = graph.peerspeak_tagged_node("rogue", role, 4_242);
|
||||
|
||||
let decisions = run(&graph, &ctx());
|
||||
assert_untainted(&decisions, impostor);
|
||||
assert!(
|
||||
decisions.taint.is_empty(),
|
||||
"{role:?} impostor tainted something: {:?}",
|
||||
decisions.taint.keys().collect::<Vec<_>>()
|
||||
);
|
||||
// The whole point: the eligible half stays non-empty.
|
||||
assert_partition(&decisions, &[("firefox", firefox)], &[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// **The live F2 reproduction, verbatim.** The measured impostor was an
|
||||
/// *unbounded* reader — `client.id` present, `application.process.id` absent
|
||||
/// — which is what turns "one bogus tainted node" into "nothing on this
|
||||
/// machine is shareable": `propagate_unresolved_owner` cannot prove any
|
||||
/// candidate independent of a reader it cannot attribute to an owner.
|
||||
///
|
||||
/// Measured before the fix: `BASELINE eligible=1 excluded=[]` →
|
||||
/// `WITH IMPOSTOR eligible=0 excluded=[firefox → unresolved-owner]`.
|
||||
///
|
||||
/// Distinct from the row above, which uses a *bounded* impostor and so would
|
||||
/// still pass if only the cheap half of the fix were present.
|
||||
#[test]
|
||||
fn an_unbounded_tagged_impostor_cannot_exclude_a_bystander_app() {
|
||||
let mut graph = Graph::new();
|
||||
let sink = graph.device_node("hw-sink", MediaRole::Sink);
|
||||
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
|
||||
let mpv = graph.app_node("mpv", MediaRole::StreamOutput, 31_284);
|
||||
for node in [firefox, mpv] {
|
||||
graph.link(node, sink);
|
||||
}
|
||||
|
||||
let baseline = run(&graph, &ctx());
|
||||
assert_partition(&baseline, &[("firefox", firefox), ("mpv", mpv)], &[]);
|
||||
|
||||
// Both carriers, no pid, no links — everything an unprivileged process
|
||||
// can arrange for itself in one `pw-cli` invocation.
|
||||
let rogue_client = graph.client(Some(PULSE_PID));
|
||||
let impostor = graph.node(
|
||||
&format!("{}rogue_4242", super::PEERSPEAK_OWNED_NODE_PREFIX),
|
||||
MediaRole::StreamInput,
|
||||
NodeProps {
|
||||
peerspeak_owned: true,
|
||||
client_id: Some(rogue_client),
|
||||
..NodeProps::default()
|
||||
},
|
||||
);
|
||||
|
||||
let decisions = run(&graph, &ctx());
|
||||
assert_untainted(&decisions, impostor);
|
||||
assert_partition(&decisions, &[("firefox", firefox), ("mpv", mpv)], &[]);
|
||||
}
|
||||
|
||||
/// A tag that R10-1 ignores is still reported, so that neither a peerspeak
|
||||
/// tagging bug nor an impersonation attempt is silent.
|
||||
#[test]
|
||||
fn ignored_ownership_tags_are_surfaced_for_diagnostics() {
|
||||
let mut graph = Graph::new();
|
||||
let sink = graph.device_node("hw-sink", MediaRole::Sink);
|
||||
let call = graph.peerspeak_node("call", 7);
|
||||
graph.link(call, sink);
|
||||
let impostor = graph.peerspeak_tagged_node("rogue", MediaRole::StreamInput, 4_242);
|
||||
|
||||
let snapshot = graph.build();
|
||||
let misplaced: Vec<Serial> = super::misplaced_ownership_tags(&snapshot)
|
||||
.iter()
|
||||
.map(|node| node.serial)
|
||||
.collect();
|
||||
|
||||
// Exactly the ignored one: the honoured producer is not "misplaced".
|
||||
assert_eq!(misplaced, vec![impostor.serial]);
|
||||
assert_ne!(impostor.serial, call.serial);
|
||||
}
|
||||
|
||||
/// The prefix is a **prefix**, not a substring: an unrelated app must not be
|
||||
/// excluded because the literal appears somewhere in its name. Over-exclusion
|
||||
/// is the safe direction, but it is still wrong, and the phase-5 gate now
|
||||
|
||||
Reference in New Issue
Block a user