fix(probe): count mixed-credential real UID threads

This commit is contained in:
fy59 2026-10-17 08:07:25 +02:00
parent 79c5af1853
commit fcb0ea7f82
5 changed files with 294 additions and 29 deletions

View file

@ -9,6 +9,10 @@ records probe-local cgroup admission as EXPERIMENTAL / IMPLEMENTED_OFFLINE /
OFFLINE_REVIEW_ACCEPTED, with inert validation only. Qualification against the real
manager and new EGL runtime remain NOT_RUN. Separate approval is required for
benign qualification, then for any new EGL attempt; no retry is authorized.
The first benign nominal invocation refused in real-UID accounting before
ATTEMPT or scope creation. The narrowly scoped UID count correction and its
offline/read-only validation are recorded in that protocol; native admission
and parent-death cleanup remain unqualified.
[Gate 2 headless boot](design/STEP20C_HEADLESS_BOOT_GATE2.md) records the single
V5 native failure: EXPERIMENTAL / PRISTINE_ALLOCATOR_BLOCKED / CORE_NOT_RUN,

View file

@ -25,6 +25,58 @@ benign qualification and EGL execution each need separate conversational approva
The historical validation section below retains its original provenance and
does not describe current implementation coverage.
## First benign preflight refusal and real-UID accounting — 2026-10-17
The single nominal invocation refused with `UID process divergent` inside
`ProcFS.uid_threads()`, before ATTEMPT, scope creation or payload execution.
The two historical benign LABs remain unconsumed and must not be reused.
Native cgroup qualification has not passed; parent-death remains NOT_RUN.
Resource accounting must include every thread belonging to the requested real
UID, including legitimate mixed credentials such as `(1000, 0, 0, 0)` used by
a privileged portal helper. This diagnostic is not a graphical-service admission
scan. Sample PID/startticks, proc directory identity, all four UIDs, cgroup,
Threads and task entries before/after; unreadability, disappearance or observed
drift refuses rather than undercounting. The snapshot is not an atomic reservation.
Authentication of the supervisor, manager and bootstrap remains separate:
`ProcFS.identity()` still requires homogeneous real/effective/saved/filesystem
UIDs matching proc ownership, with strict lifetime and cgroup checks. The count
fix must not relax that predicate or introduce process-name/privilege exceptions.
Only source correction, inert regression tests, read-only diagnostics and fresh
LAB preparation are authorized here; any real benign execution requires fresh
conversational approval.
### UID count correction validation — 2026-10-17
The actual counter now uses two observations and compares the PID set, process
lifetime, proc inode/device, all four UIDs, cgroup, Threads and exact task names.
Foreign real UIDs contribute zero; their credentials and directory identity are
compared across observations. No whitelist, privilege bypass or partial total
is accepted. The strict `identity()` source is unchanged.
The offline suite passes **86 tests** (75 existing plus 11 new regressions).
The unchanged private parent-death observer passes its **14 deterministic
tests**. Two fresh 0700 LABs have distinct nonces and sealed manifests; both
isolated passive CLI plans pass via Desktop Commander on ArchASP under
app.slice, without approval flags, ATTEMPT or payload execution.
One live counter diagnostic refused on a precisely identified disappearing
proc entry (ENOENT); it sampled the legitimate mixed-UID helper and its one
thread without the historical homogeneous-UID refusal. A separate read-only
preflight refused a changed process set. Neither was repeated to obtain PASS.
These are expected fail-closed outcomes, not a measured complete UID total or
a successful real preflight. Snapshots cannot detect every transient change or
reserve task capacity. The next approved invocation can still refuse on drift.
Native admission and parent-death cleanup remain **NOT_RUN / NOT QUALIFIED**;
no cgroup runtime PASS, Mesa capacity or STEP20C readiness follows.
Bounded independent delta review is ACCEPTED; the single final audit is PASS
with no blocking finding and independently reproduces 86/86 plus 14/14 tests,
manifest/fixture hashes and historical preservation. Disposition:
**CGROUP_UID_COUNT_FIX_READY_FOR_BENIGN_APPROVAL**, source-only. Publication of
the correction does not authorize the three future native qualification commands.
## Evidence, guarantees and admission
| Route / evidence | Risk | Bound actually supplied | Admission condition / disposition |

View file

@ -14,6 +14,16 @@ ne prouvent aucun lancement sur le manager réel et ne valident pas le budget
Mesa. L'exécution bénigne et une éventuelle exécution EGL demandent chacune une
nouvelle autorisation conversationnelle avec leur propre LAB et manifeste.
Le premier essai bénin nominal du 17 octobre 2026 a refusé avant ATTEMPT,
création de scope ou payload : `UID process divergent` dans le diagnostic
`ProcFS.uid_threads()`. Les deux LABs bénins historiques restent non consommés
et ne doivent pas être réutilisés. Le comptage corrigé inclut les threads de
tous les processus dont l'UID réel correspond, même avec des UID effectif,
sauvé ou filesystem différents. Il refuse toute incohérence observée ; ce
snapshot ne réserve aucune capacité. `ProcFS.identity()` conserve ses contrôles
stricts pour le superviseur, le manager et le bootstrap. Les régressions restent
hors ligne ; cette correction n'établit aucun PASS cgroup natif.
`probe.c` crée seulement un event loop, un backend headless non démarré et EGL
via le constructeur interne pinned `wlr_egl_create_with_drm_fd(-1)`. Il interroge
**le device associé à ce display**, exige `EGL_MESA_device_software` et refuse

View file

@ -90,33 +90,66 @@ class ProcFS:
return {"pid": pid, "uid": before.st_uid, "startticks": int(fields[19]),
"cgroup": lines[0][3:]}
def _thread_snapshot(self, directory, uid):
"""Observe une durée de vie sans authentifier ses credentials mixtes."""
before = directory.stat()
status = dict(line.split(":", 1) for line in
(directory / "status").read_text().splitlines()
if ":" in line)
uids = tuple(map(int, status["Uid"].split()))
require(len(uids) == 4, "UID diagnostic incomplet")
# Foreign real UIDs contribute nothing; compare their credentials and
# directory lifetime across passes to detect a transition into this UID.
if uids[0] != uid:
return (int(directory.name), None, before.st_dev, before.st_ino,
uids, None, None, ())
raw_stat = (directory / "stat").read_text()
pid = int(raw_stat.split("(", 1)[0].strip())
fields = raw_stat.rsplit(")", 1)[1].split()
require(pid == int(directory.name), "PID divergent pendant diagnostic")
lines = (directory / "cgroup").read_text().splitlines()
require(len(lines) == 1 and lines[0].startswith("0::/"),
"cgroup v1/hybride pendant diagnostic")
threads = int(status["Threads"])
tasks = tuple(sorted(entry.name for entry in
(directory / "task").iterdir()))
require(all(name.isdecimal() for name in tasks)
and threads > 0 and threads == len(tasks),
f"PID {pid}: compte threads incohérent")
after = directory.stat()
require((before.st_dev, before.st_ino) == (after.st_dev, after.st_ino),
"proc identity drift pendant diagnostic")
return (pid, int(fields[19]), before.st_dev, before.st_ino,
uids, lines[0][3:], threads, tasks)
def uid_threads(self, uid):
total = 0
# Every disappearing/unreadable process refuses; a snapshot never reserves.
for directory in self.root.iterdir():
if not directory.name.isdecimal():
continue
status = dict(line.split(":", 1) for line in
(directory / "status").read_text().splitlines()
if ":" in line)
observed_uid = int(status["Uid"].split()[0])
if observed_uid == uid:
before = self.identity(int(directory.name))
require(before["uid"] == uid, "UID drift pendant diagnostic")
threads = int(status["Threads"])
tasks = list((directory / "task").iterdir())
require(threads > 0 and threads == len(tasks),
"compte threads incohérent")
after_status = dict(line.split(":", 1) for line in
(directory / "status").read_text().splitlines()
if ":" in line)
require(status["Uid"] == after_status["Uid"]
and status["Threads"] == after_status["Threads"]
and self.identity(int(directory.name)) == before,
"UID/count/startticks drift pendant diagnostic")
total += threads
require(total > 0, "charge UID inconnue")
return total
"""Compte le real UID ; toute incertitude observée refuse le diagnostic."""
try:
directories = {entry.name: entry for entry in self.root.iterdir()
if entry.name.isdecimal()}
# RLIMIT_NPROC counts real-UID threads, including privileged helpers.
# Authentication remains exclusively in the unchanged identity().
before = {name: self._thread_snapshot(directory, uid)
for name, directory in directories.items()}
after = {name: self._thread_snapshot(directory, uid)
for name, directory in directories.items()}
final_names = {entry.name for entry in self.root.iterdir()
if entry.name.isdecimal()}
require(final_names == set(directories),
"process creation/end pendant diagnostic")
fields = ("pid", "startticks", "device", "inode", "UID",
"cgroup", "Threads", "task")
for name, sample in before.items():
changed = [field for field, old, new in
zip(fields, sample, after[name]) if old != new]
require(not changed,
f"PID {name}: {','.join(changed)} drift pendant diagnostic")
total = sum(sample[6] for sample in after.values()
if sample[4][0] == uid)
require(total > 0, "charge UID inconnue")
return total
except (OSError, ValueError, KeyError, IndexError) as error:
raise Refusal(f"proc diagnostic illisible/disparu: {error}") from error
def nproc(self):
return resource.getrlimit(resource.RLIMIT_NPROC)

View file

@ -181,6 +181,172 @@ class FakeBarrier:
self.closed = True
class UIDThreadTests(unittest.TestCase):
"""Faux procfs sur disque ; exerce le vrai compteur, sans enfant natif."""
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.proc = gate.ProcFS(self.root)
self.add_process(101)
def add_process(self, pid, uids=(1000, 1000, 1000, 1000), threads=2):
directory = self.root / str(pid)
directory.mkdir()
fields = ["S"] + ["0"] * 18 + ["123"]
(directory / "stat").write_text(f"{pid} (fixture) " + " ".join(fields))
(directory / "cgroup").write_text("0::/fixture.scope\n")
(directory / "status").write_text(
"Uid:\t" + " ".join(map(str, uids)) + f"\nThreads:\t{threads}\n")
(directory / "task").mkdir()
for tid in range(pid, pid + threads):
(directory / "task" / str(tid)).mkdir()
return directory
def change_before_second_sample(self, action):
"""Injecte une mutation entre les deux lectures réelles du compteur."""
original = Path.read_text
reads = 0
def read(path, *args, **kwargs):
nonlocal reads
if path == self.root / "101" / "status":
reads += 1
if reads == 2:
action()
return original(path, *args, **kwargs)
return patch.object(Path, "read_text", read)
def test_homogeneous_uid_count(self):
self.assertEqual(self.proc.uid_threads(1000), 2)
def test_mixed_uid_count_does_not_call_identity(self):
status = self.root / "101" / "status"
status.write_text("Uid: 1000 0 0 0\nThreads: 2\n")
original = Path.stat
def root_owned(path, *args, **kwargs):
info = original(path, *args, **kwargs)
if path == self.root / "101":
values = list(info)
values[4] = 0 # A setuid helper's proc directory may be root-owned.
return os.stat_result(values)
return info
with patch.object(Path, "stat", root_owned):
with patch.object(self.proc, "identity", side_effect=AssertionError):
self.assertEqual(self.proc.uid_threads(1000), 2)
def test_multiple_processes_and_foreign_real_uid(self):
self.add_process(201, (1000, 0, 0, 0), 3)
self.add_process(301, (2000, 1000, 1000, 1000), 4)
self.assertEqual(self.proc.uid_threads(1000), 5)
def test_identity_still_refuses_mixed_credentials_for_owned_roles(self):
(self.root / "101" / "status").write_text(
"Uid: 1000 0 0 0\nThreads: 2\n")
for role in ("supervisor", "bootstrap", "manager"):
with self.subTest(role=role):
with self.assertRaisesRegex(gate.Refusal, "UID process divergent"):
self.proc.identity(101)
def test_uid_startticks_cgroup_and_threads_drifts_refuse(self):
directory = self.root / "101"
changes = {
"uid": ("status", "Uid: 1000 0 0 0\nThreads: 2\n"),
"real_uid": ("status", "Uid: 2000 0 0 0\nThreads: 2\n"),
"threads": ("status", "Uid: 1000 1000 1000 1000\nThreads: 1\n"),
"startticks": ("stat", "101 (fixture) " +
" ".join(["S"] + ["0"] * 18 + ["456"])),
"pid": ("stat", "999 (fixture) " +
" ".join(["S"] + ["0"] * 18 + ["123"])),
"cgroup": ("cgroup", "0::/other.scope\n"),
}
for name, (filename, text) in changes.items():
target = directory / filename
original = target.read_text()
with self.subTest(field=name):
with self.change_before_second_sample(lambda: target.write_text(text)):
with self.assertRaises(gate.Refusal):
self.proc.uid_threads(1000)
target.write_text(original)
def test_inode_and_device_drifts_refuse(self):
original = Path.stat
for field in ("st_ino", "st_dev"):
calls = 0
def stat(path, *args, **kwargs):
nonlocal calls
info = original(path, *args, **kwargs)
if path == self.root / "101":
calls += 1
if calls >= 3:
values = list(info)
values[1 if field == "st_ino" else 2] += 1
return os.stat_result(values)
return info
with self.subTest(field=field), patch.object(Path, "stat", stat):
with self.assertRaises(gate.Refusal):
self.proc.uid_threads(1000)
def test_disappearance_refuses(self):
target = self.root / "101" / "status"
with self.change_before_second_sample(target.unlink):
with self.assertRaisesRegex(gate.Refusal, "illisible/disparu"):
self.proc.uid_threads(1000)
def test_unreadable_task_directory_refuses(self):
original = Path.iterdir
def entries(path):
if path.name == "task":
raise PermissionError("fixture task unreadable")
return original(path)
with patch.object(Path, "iterdir", entries):
with self.assertRaisesRegex(gate.Refusal, "fixture task unreadable"):
self.proc.uid_threads(1000)
def test_threads_contradiction_refuses(self):
(self.root / "101" / "task" / "102").rmdir()
with self.assertRaisesRegex(gate.Refusal, "compte threads incohérent"):
self.proc.uid_threads(1000)
def test_equal_count_task_replacement_refuses(self):
def replace_task():
(self.root / "101" / "task" / "102").rename(
self.root / "101" / "task" / "103")
with self.change_before_second_sample(replace_task):
with self.assertRaisesRegex(gate.Refusal, "task drift"):
self.proc.uid_threads(1000)
def test_process_creation_and_end_refuse(self):
original = Path.iterdir
for creation in (True, False):
calls = 0
def entries(path):
nonlocal calls
if path == self.root:
calls += 1
if calls == 2:
if creation:
self.add_process(201)
else:
return iter(())
return original(path)
with self.subTest(creation=creation):
with patch.object(Path, "iterdir", entries):
with self.assertRaisesRegex(gate.Refusal, "process creation/end"):
self.proc.uid_threads(1000)
class AdmissionTests(unittest.TestCase):
def setUp(self):
self.sentinel = patch.object(
@ -589,15 +755,15 @@ class FilesystemTests(unittest.TestCase):
nonlocal reads
if path == directory / "status":
reads += 1
if reads > 2:
if reads > 1:
return status.replace("Threads:\t1", "Threads:\t2")
return original(path, *args, **kwargs)
with patch.object(Path, "read_text", changed):
with self.assertRaisesRegex(gate.Refusal, "drift"):
with self.assertRaisesRegex(gate.Refusal, "threads incohérent"):
proc.uid_threads(UID)
(directory / "status").unlink()
with self.assertRaises(FileNotFoundError):
with self.assertRaisesRegex(gate.Refusal, "illisible/disparu"):
proc.uid_threads(UID)