From b14ad12d3ea12ebf7af74a7c06c0d686cb395d18 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 12 Oct 2026 16:36:55 -0300 Subject: [PATCH 1/2] test: keep the MCP suite's made-up pids clear of the real ancestor chain ProcessPolicy refuses the server process and every ancestor. The MCP tests use fixed fake pids (1000, 5000, ...), so when a real ancestor owned one -- a macOS CI runner process at pid 1001 -- the fake was refused and 7 policy and list_processes tests failed for reasons unrelated to the change under test. The protected set is now computed by policy._self_and_ancestor_pids(), and an autouse fixture in tests/mcp_server/conftest.py swaps it for two pids above every OS's pid range. The tests about that protection itself opt back into the real chain with @pytest.mark.real_self_protection. --- PyMemoryEditor/mcp/policy.py | 10 +++++++--- pyproject.toml | 1 + tests/mcp_server/conftest.py | 23 +++++++++++++++++++++++ tests/mcp_server/test_parsing.py | 1 + tests/mcp_server/test_policy.py | 2 ++ tests/mcp_server/test_toolset.py | 1 + 6 files changed, 35 insertions(+), 3 deletions(-) diff --git a/PyMemoryEditor/mcp/policy.py b/PyMemoryEditor/mcp/policy.py index ee2fb41..325aeed 100644 --- a/PyMemoryEditor/mcp/policy.py +++ b/PyMemoryEditor/mcp/policy.py @@ -218,6 +218,12 @@ class AccessDecision: needs_approval: bool = False +def _self_and_ancestor_pids() -> FrozenSet[int]: + """This process, its parent and every ancestor: what ``protect_self`` + refuses. A function of its own so tests can swap the real chain out.""" + return frozenset({os.getpid(), os.getppid()}) | ancestor_pids() + + class ProcessPolicy: """Decides whether a given ``(pid, name)`` may be attached to. @@ -282,9 +288,7 @@ def __init__( # further up. Taken once: the chain that launched the server does not # change while it runs. self._protected: FrozenSet[int] = ( - frozenset({os.getpid(), os.getppid()}) | ancestor_pids() - if protect_self - else frozenset() + _self_and_ancestor_pids() if protect_self else frozenset() ) self._identify = identify # Only Linux truncates names; applying the rule elsewhere would let a diff --git a/pyproject.toml b/pyproject.toml index 15b9f28..5a5c144 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -241,6 +241,7 @@ skip_empty = true [tool.pytest.ini_options] addopts = "-n auto --dist=loadfile" markers = [ + "real_self_protection: MCP tests that check the real self/ancestor protection; the others see stand-in pids (tests/mcp_server/conftest.py)", "slow: integration tests that read/scan the current process's whole address space (minutes, not seconds)", "scan_heavy: tests that scan a *live* process's entire address space. Separated from `slow` because the cost is wildly platform-dependent: a macOS process exposes ~390 GiB across ~145 regions and a Windows one is not much better, so on a CI runner each scan is minutes rather than the ~1s it takes on a developer machine. CI runs these on Linux only (see the build-mcp job); the library's own live-scan coverage on the other two platforms comes from the build/build-macos jobs.", ] \ No newline at end of file diff --git a/tests/mcp_server/conftest.py b/tests/mcp_server/conftest.py index 814c4b2..c4240f9 100644 --- a/tests/mcp_server/conftest.py +++ b/tests/mcp_server/conftest.py @@ -424,3 +424,26 @@ def factory(config: ServerConfig) -> MemoryToolset: def session(toolset: MemoryToolset) -> Dict[str, Any]: """An open session on the fake target.""" return toolset.open_process(pid=4242) + + +#: Stand-ins for this process and its ancestors, far above any pid a test +#: makes up (and above every OS's pid range), so a fake pid never collides. +FAKE_SELF_AND_ANCESTOR_PIDS = frozenset({0x7FFF_FF01, 0x7FFF_FF02}) + + +@pytest.fixture(autouse=True) +def _fake_self_and_ancestors(request, monkeypatch): + """Keep made-up pids clear of the real ancestor chain. + + ``ProcessPolicy`` refuses this process and every ancestor. Tests use fixed + fake pids (1000, 5000, ...), and when a real ancestor happens to own one -- + a CI runner process at pid 1001 did -- the fake is refused and the test + fails for reasons unrelated to the code. Tests about that protection itself + opt back into the real chain with ``@pytest.mark.real_self_protection``. + """ + if request.node.get_closest_marker("real_self_protection") is None: + from PyMemoryEditor.mcp import policy as policy_module + + monkeypatch.setattr( + policy_module, "_self_and_ancestor_pids", lambda: FAKE_SELF_AND_ANCESTOR_PIDS + ) diff --git a/tests/mcp_server/test_parsing.py b/tests/mcp_server/test_parsing.py index 942758e..0b8d164 100644 --- a/tests/mcp_server/test_parsing.py +++ b/tests/mcp_server/test_parsing.py @@ -864,6 +864,7 @@ def test_the_help_text_carries_no_changelog(self): class TestServerConfigProtectsItself: + @pytest.mark.real_self_protection def test_default_policy_refuses_the_server_pid(self): import os diff --git a/tests/mcp_server/test_policy.py b/tests/mcp_server/test_policy.py index 50bce2c..d033aa9 100644 --- a/tests/mcp_server/test_policy.py +++ b/tests/mcp_server/test_policy.py @@ -228,6 +228,7 @@ def test_describe_reports_the_live_policy(self): assert described["denied_system_process_count"] > 0 +@pytest.mark.real_self_protection class TestSelfProtection: """The server never opens itself or the client that launched it.""" @@ -336,6 +337,7 @@ def test_other_platforms_do_not_truncate(self): assert policy.check(5000, "my-very-long-ga").needs_approval is True +@pytest.mark.real_self_protection class TestTheWholeLaunchChainIsProtected: """Under ``uvx`` the server's parent is ``uv``; the client is further up.""" diff --git a/tests/mcp_server/test_toolset.py b/tests/mcp_server/test_toolset.py index 2235fe9..1780e5c 100644 --- a/tests/mcp_server/test_toolset.py +++ b/tests/mcp_server/test_toolset.py @@ -1809,6 +1809,7 @@ def test_a_target_whose_name_cannot_be_read_is_not_refused(self, make_toolset): toolset._name_for_pid = lambda pid: next(names) assert toolset.open_process(pid=4242)["opened"] is True + @pytest.mark.real_self_protection def test_the_server_refuses_its_own_pid(self, make_toolset): import os From ea88aa331382f5aa641142311bf61c44f94da3a5 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 12 Oct 2026 16:40:51 -0300 Subject: [PATCH 2/2] test: swap the ancestor chain for the whole session; keep own pid protected - The swap was function-scoped, so the fidelity suite's module-scoped attach (fake pid 4242) built its policy against the real chain before it ran. It is now a session-scoped patch, and a function-scoped fixture gives @pytest.mark.real_self_protection tests the real chain back. - The stand-in set keeps os.getpid(): only the ancestors are swapped, so a test that reaches the real opener still cannot attach to pytest itself. - test_filter_hides_them runs against the real chain, so its "other" process now takes a pid outside it instead of a fixed 5000. --- tests/mcp_server/conftest.py | 48 ++++++++++++++++++++++++--------- tests/mcp_server/test_policy.py | 6 +++-- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/tests/mcp_server/conftest.py b/tests/mcp_server/conftest.py index c4240f9..a76d427 100644 --- a/tests/mcp_server/conftest.py +++ b/tests/mcp_server/conftest.py @@ -46,6 +46,7 @@ from PyMemoryEditor.enums import ScanTypesEnum from PyMemoryEditor.mcp import MemoryToolset, ServerConfig +from PyMemoryEditor.mcp import policy as policy_module from PyMemoryEditor.process.module_info import ModuleInfo from PyMemoryEditor.process.region import MemoryRegion, MemoryRegionSnapshot from PyMemoryEditor.process.thread_info import ThreadInfo @@ -426,24 +427,45 @@ def session(toolset: MemoryToolset) -> Dict[str, Any]: return toolset.open_process(pid=4242) -#: Stand-ins for this process and its ancestors, far above any pid a test -#: makes up (and above every OS's pid range), so a fake pid never collides. -FAKE_SELF_AND_ANCESTOR_PIDS = frozenset({0x7FFF_FF01, 0x7FFF_FF02}) +#: The real chain, captured at import -- before the session fixture swaps it. +_REAL_SELF_AND_ANCESTOR_PIDS = policy_module._self_and_ancestor_pids +#: Stand-ins for this process's ancestors: far above any pid a test makes up, +#: and above the Linux and macOS pid ranges (Windows pids reach this high only +#: in theory), so a fake pid never collides with one. +FAKE_ANCESTOR_PIDS = frozenset({0x7FFF_FF01, 0x7FFF_FF02}) -@pytest.fixture(autouse=True) -def _fake_self_and_ancestors(request, monkeypatch): + +@pytest.fixture(scope="session", autouse=True) +def _fake_ancestor_chain(): """Keep made-up pids clear of the real ancestor chain. ``ProcessPolicy`` refuses this process and every ancestor. Tests use fixed - fake pids (1000, 5000, ...), and when a real ancestor happens to own one -- - a CI runner process at pid 1001 did -- the fake is refused and the test - fails for reasons unrelated to the code. Tests about that protection itself - opt back into the real chain with ``@pytest.mark.real_self_protection``. + fake pids (1000, 4242, 5000, ...), and when a real ancestor happens to own + one -- a CI runner process at pid 1001 did -- the fake is refused and the + test fails for reasons unrelated to the code. So the ancestors are swapped + for stand-ins; this process itself stays protected, as a safety net for a + test that reaches the real opener. + + Session-scoped so policies built by module-scoped fixtures (the fidelity + suite's shared attach) see the stand-ins too. Tests about the protection + itself opt back into the real chain with + ``@pytest.mark.real_self_protection`` -- a new one must carry it, or it + checks the stand-ins. """ - if request.node.get_closest_marker("real_self_protection") is None: - from PyMemoryEditor.mcp import policy as policy_module + import os - monkeypatch.setattr( - policy_module, "_self_and_ancestor_pids", lambda: FAKE_SELF_AND_ANCESTOR_PIDS + with pytest.MonkeyPatch.context() as patch: + patch.setattr( + policy_module, + "_self_and_ancestor_pids", + lambda: frozenset({os.getpid()}) | FAKE_ANCESTOR_PIDS, ) + yield + + +@pytest.fixture(autouse=True) +def _real_self_protection(request, monkeypatch): + """Give ``@pytest.mark.real_self_protection`` tests the real chain back.""" + if request.node.get_closest_marker("real_self_protection") is not None: + monkeypatch.setattr(policy_module, "_self_and_ancestor_pids", _REAL_SELF_AND_ANCESTOR_PIDS) diff --git a/tests/mcp_server/test_policy.py b/tests/mcp_server/test_policy.py index d033aa9..7cc6750 100644 --- a/tests/mcp_server/test_policy.py +++ b/tests/mcp_server/test_policy.py @@ -273,8 +273,10 @@ def test_filter_hides_them(self): import os policy = ProcessPolicy(platform="linux", allow_any=True) - kept = policy.filter([(os.getpid(), "python"), (5000, "game.exe")]) - assert kept == [(5000, "game.exe")] + # Any pid outside the real chain: this class runs against it. + other = next(pid for pid in range(5000, 6000) if pid not in policy.protected_pids) + kept = policy.filter([(os.getpid(), "python"), (other, "game.exe")]) + assert kept == [(other, "game.exe")] def test_describe_reports_it(self): assert ProcessPolicy(platform="linux").describe()["self_protected"] is True