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:
+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)
|
||||
|
||||
Reference in New Issue
Block a user