fix(probe): count mixed-credential real UID threads
This commit is contained in:
parent
79c5af1853
commit
fcb0ea7f82
5 changed files with 294 additions and 29 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue