PyMemoryEditor 2.2.0__py3-none-any.whl → 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- PyMemoryEditor/__init__.py +1 -1
- PyMemoryEditor/linux/functions.py +18 -2
- PyMemoryEditor/macos/functions.py +39 -2
- PyMemoryEditor/mcp/__init__.py +68 -0
- PyMemoryEditor/mcp/__main__.py +17 -0
- PyMemoryEditor/mcp/config.py +301 -0
- PyMemoryEditor/mcp/policy.py +311 -0
- PyMemoryEditor/mcp/server.py +513 -0
- PyMemoryEditor/mcp/session.py +545 -0
- PyMemoryEditor/mcp/toolset.py +2231 -0
- PyMemoryEditor/process/abstract.py +24 -6
- PyMemoryEditor/process/scanning.py +17 -0
- PyMemoryEditor/process/util.py +19 -0
- PyMemoryEditor/util/__init__.py +3 -0
- PyMemoryEditor/util/convert.py +154 -10
- PyMemoryEditor/util/scan.py +20 -10
- PyMemoryEditor/win32/functions.py +4 -0
- {pymemoryeditor-2.2.0.dist-info → pymemoryeditor-3.0.0.dist-info}/METADATA +61 -17
- {pymemoryeditor-2.2.0.dist-info → pymemoryeditor-3.0.0.dist-info}/RECORD +22 -15
- {pymemoryeditor-2.2.0.dist-info → pymemoryeditor-3.0.0.dist-info}/entry_points.txt +1 -0
- {pymemoryeditor-2.2.0.dist-info → pymemoryeditor-3.0.0.dist-info}/WHEEL +0 -0
- {pymemoryeditor-2.2.0.dist-info → pymemoryeditor-3.0.0.dist-info}/licenses/LICENSE +0 -0
PyMemoryEditor/__init__.py
CHANGED
|
@@ -32,6 +32,7 @@ from ..util import (
|
|
|
32
32
|
_validate_pytype,
|
|
33
33
|
as_writable_c_buffer,
|
|
34
34
|
get_c_type_of,
|
|
35
|
+
sign_extend_narrow_int,
|
|
35
36
|
values_to_bytes,
|
|
36
37
|
)
|
|
37
38
|
from ..util.pattern import PatternLike, compile_pattern
|
|
@@ -369,13 +370,23 @@ def read_process_memory(pid: int, address: int, pytype: Type[T], bufflength: int
|
|
|
369
370
|
_validate_pytype(pytype)
|
|
370
371
|
|
|
371
372
|
data = get_c_type_of(pytype, bufflength)
|
|
372
|
-
|
|
373
|
+
# `bufflength`, not `sizeof(data)`. The two differ whenever the width
|
|
374
|
+
# rounds up to a wider C type — `int` at 3 bytes gets a `c_int32` — and
|
|
375
|
+
# passing the buffer size made this backend read 4 bytes where Windows and
|
|
376
|
+
# macOS read 3, so the same call returned a different value per platform
|
|
377
|
+
# (0x11223344 here against 0x223344 there). `get_c_type_of` guarantees
|
|
378
|
+
# `sizeof(data) >= bufflength`, so reading into the front of a larger,
|
|
379
|
+
# zero-initialised buffer is safe and matches the other two backends.
|
|
380
|
+
_process_vm_readv(pid, addressof(data), address, bufflength)
|
|
373
381
|
|
|
374
382
|
if pytype is str:
|
|
375
383
|
return bytes(data).decode("utf-8", errors="replace")
|
|
376
384
|
elif pytype is bytes:
|
|
377
385
|
return bytes(data)
|
|
378
386
|
else:
|
|
387
|
+
# Narrow widths pad with zeroes, which reads a signed value as
|
|
388
|
+
# unsigned -- and a scan for the same bytes is signed.
|
|
389
|
+
sign_extend_narrow_int(data, pytype, bufflength)
|
|
379
390
|
return data.value
|
|
380
391
|
|
|
381
392
|
|
|
@@ -525,7 +536,12 @@ def write_process_memory(
|
|
|
525
536
|
data = get_c_type_of(pytype, bufflength)
|
|
526
537
|
data.value = value.encode() if isinstance(value, str) else value
|
|
527
538
|
|
|
528
|
-
|
|
539
|
+
# `bufflength`, not `sizeof(data)` — this one corrupted memory rather than
|
|
540
|
+
# merely disagreeing. A write of 3 bytes sized a 4-byte `c_int32` and then
|
|
541
|
+
# wrote all four, destroying a byte the caller never asked to touch, while
|
|
542
|
+
# Windows and macOS wrote exactly three. Verified on Linux: `w=3` touched 4
|
|
543
|
+
# bytes and `w=5` touched 8.
|
|
544
|
+
_process_vm_writev(pid, addressof(data), address, bufflength)
|
|
529
545
|
return value
|
|
530
546
|
|
|
531
547
|
|
|
@@ -30,6 +30,7 @@ from ..util import (
|
|
|
30
30
|
_validate_pytype,
|
|
31
31
|
as_writable_c_buffer,
|
|
32
32
|
get_c_type_of,
|
|
33
|
+
sign_extend_narrow_int,
|
|
33
34
|
values_to_bytes,
|
|
34
35
|
)
|
|
35
36
|
from ..util.pattern import PatternLike, compile_pattern
|
|
@@ -311,6 +312,31 @@ _PAGE_GONE_KRS = (
|
|
|
311
312
|
KERN_MEMORY_ERROR,
|
|
312
313
|
)
|
|
313
314
|
|
|
315
|
+
# KERN_PROTECTION_FAILURE is tolerated by a full-address-space *sweep* only,
|
|
316
|
+
# never by a read of caller-supplied addresses — hence a second tuple rather
|
|
317
|
+
# than another entry above.
|
|
318
|
+
#
|
|
319
|
+
# The sweeps walk every region the filter accepted and have no opinion about
|
|
320
|
+
# any single one: a range the kernel declines to hand over is a range with no
|
|
321
|
+
# matches in it, and aborting the whole scan over it loses every region that
|
|
322
|
+
# came after. `search_values_by_addresses` is the opposite case — the caller
|
|
323
|
+
# named those addresses, and its documented `raise_error=True` must still be
|
|
324
|
+
# able to report that one of them could not be read. Anything in the tuple
|
|
325
|
+
# above is swallowed even when `raise_error` is set (see
|
|
326
|
+
# process.scanning.iter_values_for_addresses), so putting kr=2 there would
|
|
327
|
+
# silently turn "permission denied" into the same `(address, None)` the caller
|
|
328
|
+
# gets for an address in a gap.
|
|
329
|
+
#
|
|
330
|
+
# Honest limits of what is known here. This was added because a plain
|
|
331
|
+
# search_by_value over the current process died with kr=2 on GitHub's
|
|
332
|
+
# virtualized macOS runners, on a 22 MB region that reported VM_PROT_READ and
|
|
333
|
+
# Shared=False. The mechanism was not established. It does *not* reproduce
|
|
334
|
+
# locally: probing all 135 scannable regions of a live process yields only kr=1
|
|
335
|
+
# and kr=10, and a page deliberately re-protected to PROT_NONE (or never
|
|
336
|
+
# mapped) answers KERN_INVALID_ADDRESS, not kr=2 — so the usual
|
|
337
|
+
# "protection changed under us" story does not explain it.
|
|
338
|
+
_SWEEP_SKIPPABLE_KRS = _PAGE_GONE_KRS + (KERN_PROTECTION_FAILURE,)
|
|
339
|
+
|
|
314
340
|
|
|
315
341
|
class MachReadError(OSError):
|
|
316
342
|
"""OSError subclass that carries the underlying kern_return_t."""
|
|
@@ -494,6 +520,14 @@ def _is_transient(exc: BaseException) -> bool:
|
|
|
494
520
|
return isinstance(exc, MachReadError) and exc.kr in _PAGE_GONE_KRS
|
|
495
521
|
|
|
496
522
|
|
|
523
|
+
def _is_skippable_by_sweep(exc: BaseException) -> bool:
|
|
524
|
+
"""
|
|
525
|
+
Same as :func:`_is_transient`, plus the codes only a full-address-space
|
|
526
|
+
sweep may skip. See :data:`_SWEEP_SKIPPABLE_KRS`.
|
|
527
|
+
"""
|
|
528
|
+
return isinstance(exc, MachReadError) and exc.kr in _SWEEP_SKIPPABLE_KRS
|
|
529
|
+
|
|
530
|
+
|
|
497
531
|
def _query_region(task: int, address: int):
|
|
498
532
|
"""Return the region containing `address`, or None when the query fails."""
|
|
499
533
|
addr = mach_vm_address_t(address)
|
|
@@ -554,6 +588,9 @@ def read_process_memory(
|
|
|
554
588
|
elif pytype is bytes:
|
|
555
589
|
return bytes(data)
|
|
556
590
|
else:
|
|
591
|
+
# Narrow widths pad with zeroes, which reads a signed value as
|
|
592
|
+
# unsigned -- and a scan for the same bytes is signed.
|
|
593
|
+
sign_extend_narrow_int(data, pytype, bufflength)
|
|
557
594
|
return data.value
|
|
558
595
|
|
|
559
596
|
|
|
@@ -683,7 +720,7 @@ def search_addresses_by_value(
|
|
|
683
720
|
scan_type,
|
|
684
721
|
_make_read_chunk(task),
|
|
685
722
|
progress_information=progress_information,
|
|
686
|
-
transient_error_check=
|
|
723
|
+
transient_error_check=_is_skippable_by_sweep,
|
|
687
724
|
)
|
|
688
725
|
|
|
689
726
|
|
|
@@ -1044,7 +1081,7 @@ def search_addresses_by_pattern(
|
|
|
1044
1081
|
length,
|
|
1045
1082
|
_make_read_chunk(task),
|
|
1046
1083
|
progress_information=progress_information,
|
|
1047
|
-
transient_error_check=
|
|
1084
|
+
transient_error_check=_is_skippable_by_sweep,
|
|
1048
1085
|
)
|
|
1049
1086
|
|
|
1050
1087
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
A Model Context Protocol server for PyMemoryEditor.
|
|
5
|
+
|
|
6
|
+
Exposes the library's process-memory tools over MCP so an AI assistant can run
|
|
7
|
+
the Cheat Engine loop itself — enumerate processes, attach, scan for a value,
|
|
8
|
+
narrow the matches as the value changes, then read (and optionally write) the
|
|
9
|
+
address it converged on.
|
|
10
|
+
|
|
11
|
+
Install and run::
|
|
12
|
+
|
|
13
|
+
pip install "PyMemoryEditor[mcp]"
|
|
14
|
+
pymemoryeditor-mcp # asks before attaching; writes allowed
|
|
15
|
+
pymemoryeditor-mcp --read-only # never modifies a target
|
|
16
|
+
|
|
17
|
+
Or register it with a client — Claude Code::
|
|
18
|
+
|
|
19
|
+
claude mcp add pymemoryeditor -- pymemoryeditor-mcp
|
|
20
|
+
|
|
21
|
+
...and any client that reads ``mcpServers`` JSON::
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"pymemoryeditor": {
|
|
26
|
+
"command": "pymemoryeditor-mcp",
|
|
27
|
+
"args": []
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
No flags are needed, because consent is asked for where it can actually be
|
|
33
|
+
given: attaching to a process the operator did not pre-approve **asks the
|
|
34
|
+
user**, and every ``write_value`` call is confirmed by their client.
|
|
35
|
+
``--read-only`` drops the write tool entirely, ``--allow-process NAME``
|
|
36
|
+
pre-approves a target so it stops prompting, and ``--allow-any-process`` turns
|
|
37
|
+
prompting off for scripted runs. Read :doc:`the MCP guide </mcp>` before
|
|
38
|
+
pointing it at anything you care about — it runs with your privileges and it is
|
|
39
|
+
**not a sandbox**.
|
|
40
|
+
|
|
41
|
+
Layout: :mod:`~PyMemoryEditor.mcp.toolset` holds the tools (and imports no MCP
|
|
42
|
+
SDK, so it is testable on its own), :mod:`~PyMemoryEditor.mcp.session` the
|
|
43
|
+
process/scan handles, :mod:`~PyMemoryEditor.mcp.policy` the access rules, and
|
|
44
|
+
:mod:`~PyMemoryEditor.mcp.server` the protocol wiring.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from .config import ServerConfig, build_parser, parse_args
|
|
48
|
+
from .policy import AccessDecision, ProcessPolicy
|
|
49
|
+
from .server import INSTRUCTIONS, build_server, main
|
|
50
|
+
from .session import ScanResult, Session, SessionError, SessionStore
|
|
51
|
+
from .toolset import MemoryToolset, ToolError
|
|
52
|
+
|
|
53
|
+
__all__ = (
|
|
54
|
+
"INSTRUCTIONS",
|
|
55
|
+
"AccessDecision",
|
|
56
|
+
"MemoryToolset",
|
|
57
|
+
"ProcessPolicy",
|
|
58
|
+
"ScanResult",
|
|
59
|
+
"ServerConfig",
|
|
60
|
+
"Session",
|
|
61
|
+
"SessionError",
|
|
62
|
+
"SessionStore",
|
|
63
|
+
"ToolError",
|
|
64
|
+
"build_parser",
|
|
65
|
+
"build_server",
|
|
66
|
+
"main",
|
|
67
|
+
"parse_args",
|
|
68
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
"""``python -m PyMemoryEditor.mcp`` — the same entry point as the console script.
|
|
4
|
+
|
|
5
|
+
Useful when the console script is not on PATH, which is the usual situation
|
|
6
|
+
when an MCP client launches the server from a virtualenv it did not activate::
|
|
7
|
+
|
|
8
|
+
{"command": "/path/to/venv/bin/python",
|
|
9
|
+
"args": ["-m", "PyMemoryEditor.mcp", "--allow-process", "game.exe"]}
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from .server import main
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
sys.exit(main())
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Command-line configuration for the MCP server.
|
|
5
|
+
|
|
6
|
+
The server is launched by an MCP *client* (Claude Code, Claude Desktop, an
|
|
7
|
+
editor plugin) from a JSON config file, not by a human at a prompt. The
|
|
8
|
+
operator writes these flags once, months before the model ever calls a tool,
|
|
9
|
+
and nobody is watching stderr when it does — so the defaults are what actually
|
|
10
|
+
governs the server, and they need to be the ones that make it useful *and*
|
|
11
|
+
keep a human in the loop.
|
|
12
|
+
|
|
13
|
+
Both halves are load-bearing here. The server is a memory **editor**: a
|
|
14
|
+
read-only default made the headline workflow — find a value, change it —
|
|
15
|
+
require a flag nobody discovers, which is a bad trade when the protections that
|
|
16
|
+
matter are elsewhere. Consent is enforced at the two points where it can
|
|
17
|
+
actually be given:
|
|
18
|
+
|
|
19
|
+
* **which process** — attaching to a target the operator did not pre-approve
|
|
20
|
+
asks the user, live, and the prompt says whether writes are possible
|
|
21
|
+
(:mod:`PyMemoryEditor.mcp.policy`);
|
|
22
|
+
* **each write** — ``write_value`` is tagged so a client prompts on every call,
|
|
23
|
+
even in its most permissive auto-approve mode
|
|
24
|
+
(see ``_ALWAYS_ASK`` in :mod:`PyMemoryEditor.mcp.server`).
|
|
25
|
+
|
|
26
|
+
``--read-only`` is there for when you want the guarantee enforced below this
|
|
27
|
+
process rather than promised by it: the write tool is not registered at all, so
|
|
28
|
+
the model never sees it, and on Windows the target is opened with a handle that
|
|
29
|
+
carries no write rights, leaving the kernel to enforce it.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import List, Literal, Optional, Sequence, Tuple, cast
|
|
35
|
+
|
|
36
|
+
from .policy import ProcessPolicy
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
#: The transports the MCP SDK can serve this server over. Spelled as a
|
|
40
|
+
#: ``Literal`` because the SDK's ``run()`` is overloaded per transport — a plain
|
|
41
|
+
#: ``str`` matches none of the overloads and fails type checking at the call
|
|
42
|
+
#: site.
|
|
43
|
+
Transport = Literal["stdio", "sse", "streamable-http"]
|
|
44
|
+
|
|
45
|
+
#: Hard ceiling on the addresses one scan keeps. A first scan for a common
|
|
46
|
+
#: value (``int`` ``0``, ``100``) legitimately matches millions of addresses;
|
|
47
|
+
#: keeping them all would blow out the server's memory for a result set no
|
|
48
|
+
#: refine loop can use anyway. At the cap the scan stops early and says so.
|
|
49
|
+
#:
|
|
50
|
+
#: 100 000 costs 4.2 MB per result set against 2.1 MB, and 21 ms to sort
|
|
51
|
+
#: against 10 ms. The figure that matters is not per set, though: a session
|
|
52
|
+
#: keeps ``MAX_SCANS_PER_SESSION`` (20) of them and a server keeps
|
|
53
|
+
#: ``MAX_OPEN_SESSIONS`` (8) sessions, so the ceiling on retained addresses
|
|
54
|
+
#: goes from ~336 MB to ~672 MB. Reaching it needs 160 capped result sets,
|
|
55
|
+
#: which a long refine chain across several targets can do.
|
|
56
|
+
#:
|
|
57
|
+
#: What it buys: a scan whose true hit count falls between the two ceilings
|
|
58
|
+
#: stops being flagged ``partial``, and refining a truncated set can converge
|
|
59
|
+
#: on an address that was never in it. That case is real but narrow — above
|
|
60
|
+
#: the new ceiling nothing changes, and a common value like ``int 0`` matches
|
|
61
|
+
#: millions and is out of reach at any sane cap.
|
|
62
|
+
#:
|
|
63
|
+
#: What it costs in time depends on the value's density, and an earlier
|
|
64
|
+
#: version of this comment got that wrong. Measured on ``int 0``, reaching
|
|
65
|
+
#: either ceiling took 0.02s against 0.03s — but that is the dense case, where
|
|
66
|
+
#: hits arrive faster than the clock can spend. For a sparser value the scan
|
|
67
|
+
#: has to walk further to collect twice as many hits, so the time roughly
|
|
68
|
+
#: doubles up to the 30-second budget. "Wall clock is unchanged" was true of
|
|
69
|
+
#: one measurement, not of the change.
|
|
70
|
+
DEFAULT_MAX_SCAN_RESULTS = 100_000
|
|
71
|
+
|
|
72
|
+
#: Wall-clock budget for one scan, in seconds. A full address-space scan of a
|
|
73
|
+
#: large process takes minutes — long past the point where an MCP client gives
|
|
74
|
+
#: up on the request and the model starts retrying. Scans check the budget
|
|
75
|
+
#: between region batches and return a partial, explicitly-flagged result
|
|
76
|
+
#: instead of hanging.
|
|
77
|
+
DEFAULT_MAX_SCAN_SECONDS = 30.0
|
|
78
|
+
|
|
79
|
+
#: Bytes of target memory one scan batch covers before the deadline is checked.
|
|
80
|
+
#: Value scans only yield on a *hit*, so a rare value produces no yields for
|
|
81
|
+
#: minutes; the only way to stay interruptible is to drive the scan region
|
|
82
|
+
#: batch by region batch and check the clock between them.
|
|
83
|
+
DEFAULT_SCAN_BATCH_BYTES = 64 * 1024 * 1024
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class ServerConfig:
|
|
88
|
+
"""Everything the server's behaviour depends on.
|
|
89
|
+
|
|
90
|
+
:param allow_write: register the memory-mutating tools (``write_value``).
|
|
91
|
+
**On by default** — editing memory is what the library is for, and each
|
|
92
|
+
write is confirmed by the client. Set it off with ``--read-only`` when
|
|
93
|
+
you want the guarantee enforced below this process: the tool then does
|
|
94
|
+
not exist, and on Windows the process handle carries no write rights.
|
|
95
|
+
:param allowed_processes: process names pre-approved so they open without
|
|
96
|
+
prompting — see :class:`~PyMemoryEditor.mcp.policy.ProcessPolicy`.
|
|
97
|
+
Leaving it empty does **not** mean "anything goes": unlisted targets
|
|
98
|
+
prompt the user instead.
|
|
99
|
+
:param allow_any_process: never prompt — open any target the denylist
|
|
100
|
+
permits. For scripted use, where there is nobody to ask.
|
|
101
|
+
:param allow_system_processes: lift the system-process denylist.
|
|
102
|
+
:param max_scan_results: per-scan cap on stored addresses.
|
|
103
|
+
:param max_scan_seconds: per-scan wall-clock budget.
|
|
104
|
+
:param scan_batch_bytes: memory covered per deadline check.
|
|
105
|
+
|
|
106
|
+
:raises ValueError: if ``max_scan_results``, ``max_scan_seconds`` or
|
|
107
|
+
``scan_batch_bytes`` is not positive.
|
|
108
|
+
|
|
109
|
+
.. note::
|
|
110
|
+
The three numeric bounds are validated here as well as in
|
|
111
|
+
``parse_args``, so an embedder constructing this in code gets the same
|
|
112
|
+
answer the CLI operator gets. They used to be checked only at the CLI,
|
|
113
|
+
which meant a config built in Python failed quietly instead of loudly:
|
|
114
|
+
``scan_batch_bytes=0`` made every region its own scan batch (thousands
|
|
115
|
+
of generator setups per scan on a desktop target), and
|
|
116
|
+
``max_scan_results=0`` made every scan return nothing while flagging
|
|
117
|
+
itself partial. Neither is unsafe — that is why the process allowlist,
|
|
118
|
+
where a bad value silently disables the consent prompt, was fixed first
|
|
119
|
+
— but both are indistinguishable from a broken server.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
allow_write: bool = True
|
|
123
|
+
allowed_processes: Tuple[str, ...] = field(default=())
|
|
124
|
+
allow_any_process: bool = False
|
|
125
|
+
allow_system_processes: bool = False
|
|
126
|
+
max_scan_results: int = DEFAULT_MAX_SCAN_RESULTS
|
|
127
|
+
max_scan_seconds: float = DEFAULT_MAX_SCAN_SECONDS
|
|
128
|
+
scan_batch_bytes: int = DEFAULT_SCAN_BATCH_BYTES
|
|
129
|
+
|
|
130
|
+
def __post_init__(self) -> None:
|
|
131
|
+
# Mirrors the three checks in `parse_args`. Kept as ValueError rather
|
|
132
|
+
# than `parser.error`: this constructor is library API, and the CLI
|
|
133
|
+
# already reports its own violations before ever reaching here.
|
|
134
|
+
if self.max_scan_results < 1:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
"max_scan_results must be at least 1 (got %r)." % (self.max_scan_results,)
|
|
137
|
+
)
|
|
138
|
+
if self.max_scan_seconds <= 0:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
"max_scan_seconds must be positive (got %r)." % (self.max_scan_seconds,)
|
|
141
|
+
)
|
|
142
|
+
if self.scan_batch_bytes < 1:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
"scan_batch_bytes must be at least 1 (got %r)." % (self.scan_batch_bytes,)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def policy(self) -> ProcessPolicy:
|
|
148
|
+
"""Build the :class:`ProcessPolicy` this configuration describes."""
|
|
149
|
+
return ProcessPolicy(
|
|
150
|
+
allowed_names=self.allowed_processes,
|
|
151
|
+
allow_system=self.allow_system_processes,
|
|
152
|
+
allow_any=self.allow_any_process,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
157
|
+
"""The ``pymemoryeditor-mcp`` argument parser."""
|
|
158
|
+
parser = argparse.ArgumentParser(
|
|
159
|
+
prog="pymemoryeditor-mcp",
|
|
160
|
+
description=(
|
|
161
|
+
"Expose PyMemoryEditor's process-memory tools over the Model "
|
|
162
|
+
"Context Protocol, so an AI assistant can run the Cheat "
|
|
163
|
+
"Engine scan/refine/read loop against a live process."
|
|
164
|
+
),
|
|
165
|
+
epilog=(
|
|
166
|
+
"Needs no flags: it asks you before attaching to a process, and "
|
|
167
|
+
"your client confirms every write. Pass --read-only to remove the "
|
|
168
|
+
"write tool entirely, or --allow-process NAME to skip the attach "
|
|
169
|
+
"prompt for a target you already trust."
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"--read-only",
|
|
174
|
+
action="store_true",
|
|
175
|
+
dest="read_only",
|
|
176
|
+
help=(
|
|
177
|
+
"do not register the write tool, so the server can read and scan "
|
|
178
|
+
"but never modify a target. On Windows the process handle is also "
|
|
179
|
+
"opened without write rights, so the kernel enforces it."
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
parser.add_argument(
|
|
183
|
+
"--allow-process",
|
|
184
|
+
action="append",
|
|
185
|
+
default=[],
|
|
186
|
+
metavar="NAME",
|
|
187
|
+
dest="allowed_processes",
|
|
188
|
+
help=(
|
|
189
|
+
"pre-approve processes whose name contains NAME (case-insensitive), "
|
|
190
|
+
"so attaching to them never prompts. Repeatable. Omitting it does "
|
|
191
|
+
"not open the server up: unlisted targets ask for your approval at "
|
|
192
|
+
"the moment they are needed."
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
parser.add_argument(
|
|
196
|
+
"--allow-any-process",
|
|
197
|
+
action="store_true",
|
|
198
|
+
help=(
|
|
199
|
+
"never ask — open any target the system denylist permits. Use it "
|
|
200
|
+
"for scripted or non-interactive runs, where no one is there to "
|
|
201
|
+
"answer a prompt. Interactively, prefer the default: it asks once "
|
|
202
|
+
"per process and can remember your answer."
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
parser.add_argument(
|
|
206
|
+
"--allow-system-processes",
|
|
207
|
+
action="store_true",
|
|
208
|
+
help=(
|
|
209
|
+
"lift the built-in denylist of OS/credential processes "
|
|
210
|
+
"(lsass.exe, launchd, systemd, ...). Rarely what you want."
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
parser.add_argument(
|
|
214
|
+
"--scan-batch-bytes",
|
|
215
|
+
type=int,
|
|
216
|
+
default=DEFAULT_SCAN_BATCH_BYTES,
|
|
217
|
+
metavar="BYTES",
|
|
218
|
+
help=(
|
|
219
|
+
"target memory covered between deadline checks during a scan "
|
|
220
|
+
"(default: %(default)s). Smaller means a scan gives up closer to "
|
|
221
|
+
"its time budget, at the cost of more per-batch overhead. The "
|
|
222
|
+
"field existed and was honoured, but had no flag — so it could "
|
|
223
|
+
"only be set from Python."
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
parser.add_argument(
|
|
227
|
+
"--max-scan-results",
|
|
228
|
+
type=int,
|
|
229
|
+
default=DEFAULT_MAX_SCAN_RESULTS,
|
|
230
|
+
metavar="N",
|
|
231
|
+
help="addresses one scan may keep (default: %(default)s).",
|
|
232
|
+
)
|
|
233
|
+
parser.add_argument(
|
|
234
|
+
"--max-scan-seconds",
|
|
235
|
+
type=float,
|
|
236
|
+
default=DEFAULT_MAX_SCAN_SECONDS,
|
|
237
|
+
metavar="SECONDS",
|
|
238
|
+
help="wall-clock budget for one scan (default: %(default)s).",
|
|
239
|
+
)
|
|
240
|
+
parser.add_argument(
|
|
241
|
+
"--transport",
|
|
242
|
+
choices=("stdio", "sse", "streamable-http"),
|
|
243
|
+
default="stdio",
|
|
244
|
+
help=(
|
|
245
|
+
"MCP transport (default: %(default)s). stdio is what desktop "
|
|
246
|
+
"clients launch; the HTTP transports are for remote hosting and "
|
|
247
|
+
"expose this machine's memory to whoever can reach the port."
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
return parser
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def parse_args(
|
|
254
|
+
argv: Optional[Sequence[str]] = None,
|
|
255
|
+
) -> Tuple[ServerConfig, Transport]:
|
|
256
|
+
"""Parse ``argv`` into a :class:`ServerConfig` and a transport name."""
|
|
257
|
+
args = build_parser().parse_args(argv)
|
|
258
|
+
|
|
259
|
+
if args.max_scan_results < 1:
|
|
260
|
+
build_parser().error("--max-scan-results must be at least 1.")
|
|
261
|
+
if args.max_scan_seconds <= 0:
|
|
262
|
+
build_parser().error("--max-scan-seconds must be positive.")
|
|
263
|
+
if args.scan_batch_bytes < 1:
|
|
264
|
+
# 0 or negative makes every region its own batch, so a desktop target
|
|
265
|
+
# with thousands of regions pays thousands of generator setups per
|
|
266
|
+
# scan — and the operator would get no error at launch.
|
|
267
|
+
build_parser().error("--scan-batch-bytes must be at least 1.")
|
|
268
|
+
|
|
269
|
+
# Stripped, not merely tested for blankness: a JSON args array carrying
|
|
270
|
+
# "notepad.exe " (trailing space) used to be stored verbatim, so the
|
|
271
|
+
# substring matched no process at all — the pre-approval silently did
|
|
272
|
+
# nothing while the operator kept being prompted.
|
|
273
|
+
allowed: List[str] = [
|
|
274
|
+
stripped
|
|
275
|
+
for stripped in (name.strip() for name in args.allowed_processes)
|
|
276
|
+
if stripped
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
config = ServerConfig(
|
|
280
|
+
allow_write=not args.read_only,
|
|
281
|
+
allowed_processes=tuple(allowed),
|
|
282
|
+
allow_any_process=args.allow_any_process,
|
|
283
|
+
allow_system_processes=args.allow_system_processes,
|
|
284
|
+
max_scan_results=args.max_scan_results,
|
|
285
|
+
max_scan_seconds=args.max_scan_seconds,
|
|
286
|
+
scan_batch_bytes=args.scan_batch_bytes,
|
|
287
|
+
)
|
|
288
|
+
# argparse's `choices` already constrains this to the three names; the cast
|
|
289
|
+
# just carries that guarantee into the type system.
|
|
290
|
+
return config, cast(Transport, args.transport)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
__all__ = (
|
|
294
|
+
"DEFAULT_MAX_SCAN_RESULTS",
|
|
295
|
+
"Transport",
|
|
296
|
+
"DEFAULT_MAX_SCAN_SECONDS",
|
|
297
|
+
"DEFAULT_SCAN_BATCH_BYTES",
|
|
298
|
+
"ServerConfig",
|
|
299
|
+
"build_parser",
|
|
300
|
+
"parse_args",
|
|
301
|
+
)
|