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,
|
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
|
/// The decision content of one recompute — everything except which recompute it
|
||||||
/// was. Split out from [`AuditRecord`] so "did anything actually change?" is a
|
/// was. Split out from [`AuditRecord`] so "did anything actually change?" is a
|
||||||
/// derived `==` rather than a hand-maintained field comparison that a later
|
/// derived `==` rather than a hand-maintained field comparison that a later
|
||||||
@@ -227,6 +238,16 @@ pub struct AuditBody {
|
|||||||
pub excluded_count: usize,
|
pub excluded_count: usize,
|
||||||
/// Taint across all node roles, ascending by serial.
|
/// Taint across all node roles, ascending by serial.
|
||||||
pub taint: Vec<TaintRow>,
|
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 {
|
impl AuditBody {
|
||||||
@@ -433,6 +454,16 @@ fn build_body(
|
|||||||
})
|
})
|
||||||
.collect();
|
.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 {
|
AuditBody {
|
||||||
graph_ready: projection.graph_ready,
|
graph_ready: projection.graph_ready,
|
||||||
epoch: readiness_code(projection.readiness),
|
epoch: readiness_code(projection.readiness),
|
||||||
@@ -444,6 +475,7 @@ fn build_body(
|
|||||||
eligible_count,
|
eligible_count,
|
||||||
candidates,
|
candidates,
|
||||||
taint,
|
taint,
|
||||||
|
ignored_ownership_tags,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,55 @@ fn the_record_carries_the_complete_candidate_universe() {
|
|||||||
assert_eq!(outcome.record.body.excluded_count, 0);
|
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
|
/// 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
|
/// "one addition"): nothing in, nothing eligible — and, just as importantly, no
|
||||||
/// panic and no invented row.
|
/// panic and no invented row.
|
||||||
|
|||||||
@@ -161,6 +161,16 @@ impl Graph {
|
|||||||
self.node(&name, MediaRole::StreamOutput, peerspeak_owned(client, pid))
|
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
|
/// Carrier 1 alone: the `peerspeak.owned` property present, the
|
||||||
/// `node.name` prefix absent. What the engine sees for a node it had to
|
/// `node.name` prefix absent. What the engine sees for a node it had to
|
||||||
/// bind to observe (v3.5 §6.7).
|
/// bind to observe (v3.5 §6.7).
|
||||||
|
|||||||
+47
-6
@@ -566,18 +566,59 @@ fn ambiguous_id_nodes(snapshot: &GraphSnapshot) -> BTreeSet<Serial> {
|
|||||||
.collect()
|
.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> {
|
fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option<Reason> {
|
||||||
// The two ownership carriers, as a union (v3.5 §5.1). Kept here rather
|
// 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
|
// than folded together at the observer boundary so that the union is a
|
||||||
// pure, directly-testable rule: an adapter that collapsed both into the
|
// pure, directly-testable rule: an adapter that collapsed both into the
|
||||||
// one `peerspeak_owned` bool would make each carrier untestable alone,
|
// one `peerspeak_owned` bool would make each carrier untestable alone,
|
||||||
// which is exactly how phase 3r's row 1 nearly gated nothing.
|
// which is exactly how phase 3r's row 1 nearly gated nothing.
|
||||||
if node.props.peerspeak_owned
|
//
|
||||||
|| node
|
// ⚠️ **Producer roles only** (round 10, R10-1). Neither carrier is a
|
||||||
.name
|
// security boundary — both are strings any unprivileged process can put
|
||||||
.as_deref()
|
// on its own node — so an unrestricted root is a denial of the whole
|
||||||
.is_some_and(|name| name.starts_with(PEERSPEAK_OWNED_NODE_PREFIX))
|
// 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);
|
return Some(Reason::PeerspeakOwned);
|
||||||
}
|
}
|
||||||
if let (Some(module), Some(aec)) = (node.props.pulse_module_id, ctx.aec_module_id)
|
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 {
|
pub fn is_candidate(self) -> bool {
|
||||||
matches!(self, Self::StreamOutput)
|
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.
|
/// 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
|
/// 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
|
/// 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
|
/// is the safe direction, but it is still wrong, and the phase-5 gate now
|
||||||
|
|||||||
Reference in New Issue
Block a user