From fcb0ea7f825fc314ffb66b781e343d250cf4af53 Mon Sep 17 00:00:00 2001 From: fy59 Date: Sat, 17 Oct 2026 08:07:25 +0200 Subject: [PATCH] fix(probe): count mixed-credential real UID threads --- docs/README.md | 4 + .../STEP20C_EGL_FX_TASK_LIMIT_PROTOCOL.md | 52 ++++++ tooling/probes/step20c-egl-fx/README.md | 10 + tooling/probes/step20c-egl-fx/cgroup_gate.py | 85 ++++++--- .../step20c-egl-fx/test_cgroup_admission.py | 172 +++++++++++++++++- 5 files changed, 294 insertions(+), 29 deletions(-) diff --git a/docs/README.md b/docs/README.md index e0cc11b..c9c9d1e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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, diff --git a/docs/design/STEP20C_EGL_FX_TASK_LIMIT_PROTOCOL.md b/docs/design/STEP20C_EGL_FX_TASK_LIMIT_PROTOCOL.md index e7eb350..eaff009 100644 --- a/docs/design/STEP20C_EGL_FX_TASK_LIMIT_PROTOCOL.md +++ b/docs/design/STEP20C_EGL_FX_TASK_LIMIT_PROTOCOL.md @@ -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 | diff --git a/tooling/probes/step20c-egl-fx/README.md b/tooling/probes/step20c-egl-fx/README.md index 4549b91..ef0684e 100644 --- a/tooling/probes/step20c-egl-fx/README.md +++ b/tooling/probes/step20c-egl-fx/README.md @@ -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 diff --git a/tooling/probes/step20c-egl-fx/cgroup_gate.py b/tooling/probes/step20c-egl-fx/cgroup_gate.py index 8b7c7fd..c168e56 100644 --- a/tooling/probes/step20c-egl-fx/cgroup_gate.py +++ b/tooling/probes/step20c-egl-fx/cgroup_gate.py @@ -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) diff --git a/tooling/probes/step20c-egl-fx/test_cgroup_admission.py b/tooling/probes/step20c-egl-fx/test_cgroup_admission.py index baf41e7..0b25e72 100644 --- a/tooling/probes/step20c-egl-fx/test_cgroup_admission.py +++ b/tooling/probes/step20c-egl-fx/test_cgroup_admission.py @@ -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)