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>
This commit is contained in:
2026-07-27 02:18:52 -04:00
co-authored by Claude Opus 5
parent d9ef38c1c5
commit 104a95a6d7
3 changed files with 208 additions and 63 deletions
+63 -13
View File
@@ -279,6 +279,20 @@ impl LocalIdentity {
}
}
/// Whether a module proved which pid space its pid belongs to.
///
/// Passed to the liveness callback because the answer changes *how* the question
/// may be asked: an attributed pid needs no guessing about namespaces, while an
/// untagged one is a bare number whose meaning has to be guarded some other way.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Attribution {
/// Carries a token matching this machine, boot and pid namespace.
Tokened,
/// Carries no token; judged by pid alone, and only under
/// [`UntaggedPolicy::CleanByPidAlone`].
Untagged,
}
/// What repair may do about modules that carry no token at all.
///
/// Every module loaded before tokens existed is untagged, and there is no way to
@@ -625,7 +639,7 @@ pub fn sink_still_referenced(
pub fn plan(
observations: &[ModuleObservation],
policy: &Policy,
liveness: impl Fn(u32) -> Liveness,
liveness: impl Fn(u32, Attribution) -> Liveness,
) -> Plan {
// Deduplicate by module id: a snapshot should not repeat one, but a repeated
// entry must not become a repeated unload of an index that has since been
@@ -642,13 +656,15 @@ pub fn plan(
// one cannot see, and an absent token names nothing at all.
let mut untagged: Vec<Fingerprint> = Vec::new();
let mut foreign: Vec<Fingerprint> = Vec::new();
let mut judgeable: Vec<Fingerprint> = Vec::new();
let mut judgeable: Vec<(Fingerprint, Attribution)> = Vec::new();
for fp in seen.into_values() {
match &fp.owner {
Some(token) if policy.local.can_judge(token) => judgeable.push(fp),
Some(token) if policy.local.can_judge(token) => {
judgeable.push((fp, Attribution::Tokened))
}
Some(_) => foreign.push(fp),
None => match policy.untagged {
UntaggedPolicy::CleanByPidAlone => judgeable.push(fp),
UntaggedPolicy::CleanByPidAlone => judgeable.push((fp, Attribution::Untagged)),
UntaggedPolicy::Refuse => untagged.push(fp),
},
}
@@ -659,8 +675,12 @@ pub fn plan(
let mut live_pids = BTreeSet::new();
let mut dead_pids = BTreeSet::new();
let mut unknown_pids = BTreeSet::new();
for pid in judgeable.iter().map(|fp| fp.pid).collect::<BTreeSet<_>>() {
match liveness(pid) {
for (pid, attribution) in judgeable
.iter()
.map(|(fp, attribution)| (fp.pid, *attribution))
.collect::<BTreeMap<_, _>>()
{
match liveness(pid, attribution) {
Liveness::Alive => live_pids.insert(pid),
Liveness::Dead => dead_pids.insert(pid),
Liveness::Unknown => unknown_pids.insert(pid),
@@ -669,6 +689,7 @@ pub fn plan(
let mut unload: Vec<Fingerprint> = judgeable
.into_iter()
.map(|(fp, _)| fp)
.filter(|fp| dead_pids.contains(&fp.pid))
.collect();
// `Shape`'s declaration order is the unload order: loopbacks before the sink
@@ -744,7 +765,7 @@ mod tests {
obs(id, Shape::LoopbackIntoCapture, pid, None)
}
fn nothing_is_alive(_: u32) -> Liveness {
fn nothing_is_alive(_: u32, _: Attribution) -> Liveness {
Liveness::Dead
}
@@ -885,7 +906,7 @@ mod tests {
null_sink(6, 200),
mirror(12, 200),
];
let plan = plan(&modules, &our_policy(), |pid| {
let plan = plan(&modules, &our_policy(), |pid, _| {
if pid == 200 {
Liveness::Alive
} else {
@@ -903,7 +924,7 @@ mod tests {
#[test]
fn an_undecidable_pid_is_never_touched() {
let modules = [null_sink(5, 100), mirror(10, 100), local_monitor(11, 200)];
let plan = plan(&modules, &our_policy(), |pid| {
let plan = plan(&modules, &our_policy(), |pid, _| {
if pid == 100 {
Liveness::Unknown
} else {
@@ -934,7 +955,7 @@ mod tests {
null_sink(6, 200),
mirror(12, 200),
];
let plan = plan(&modules, &our_policy(), |_| Liveness::Alive);
let plan = plan(&modules, &our_policy(), |_, _| Liveness::Alive);
assert!(plan.is_empty(), "no live host may be touched: {plan:?}");
assert_eq!(plan.live_pids, BTreeSet::from([100, 200]));
assert!(plan.dead_pids.is_empty());
@@ -1049,7 +1070,7 @@ mod tests {
mirror(12, 99),
local_monitor(13, 99),
];
let plan = plan(&modules, &our_policy(), |pid| {
let plan = plan(&modules, &our_policy(), |pid, _| {
*calls.borrow_mut().entry(pid).or_insert(0) += 1;
Liveness::Dead
});
@@ -1240,7 +1261,7 @@ mod tests {
legacy_mirror(11, 5555),
];
let asked = std::cell::RefCell::new(Vec::new());
let plan = plan(&modules, &our_policy(), |pid| {
let plan = plan(&modules, &our_policy(), |pid, _| {
asked.borrow_mut().push(pid);
Liveness::Dead
});
@@ -1288,7 +1309,7 @@ mod tests {
local: our_identity(),
untagged: UntaggedPolicy::CleanByPidAlone,
},
|_| Liveness::Alive,
|_, _| Liveness::Alive,
);
assert!(live.is_empty(), "{live:?}");
}
@@ -1309,6 +1330,35 @@ mod tests {
);
}
/// The attribution handed to the liveness callback decides *how* the question
/// may be asked, so it must be right per module. Getting this wrong made the
/// token useless in a container — the one place it exists for — because the
/// probe's namespace guesswork answered `Unknown` for a pid the token had
/// already proven local.
#[test]
fn attribution_is_reported_per_module() {
let modules = [mirror(10, 100), legacy_mirror(11, 200)];
let asked = std::cell::RefCell::new(Vec::new());
let plan = plan(
&modules,
&Policy {
local: our_identity(),
untagged: UntaggedPolicy::CleanByPidAlone,
},
|pid, attribution| {
asked.borrow_mut().push((pid, attribution));
Liveness::Dead
},
);
let asked = asked.into_inner();
assert_eq!(
asked,
vec![(100, Attribution::Tokened), (200, Attribution::Untagged)],
"each pid must be asked about with its own module's attribution"
);
assert_eq!(ids(&plan), vec![10, 11]);
}
/// Tokened and legacy forms must both classify, and carry the difference.
#[test]
fn both_forms_classify_and_record_whether_they_are_attributable() {