PyMemoryEditor 2.2.1__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.
@@ -8,7 +8,7 @@ Supported platforms: Windows, Linux and macOS (32-bit and 64-bit).
8
8
  """
9
9
 
10
10
  __author__ = "Jean Loui Bernard Silva de Jesus"
11
- __version__ = "2.2.1"
11
+ __version__ = "3.0.0"
12
12
 
13
13
  import logging
14
14
  import sys
@@ -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
- _process_vm_readv(pid, addressof(data), address, sizeof(data))
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
- _process_vm_writev(pid, addressof(data), address, sizeof(data))
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
@@ -587,6 +588,9 @@ def read_process_memory(
587
588
  elif pytype is bytes:
588
589
  return bytes(data)
589
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)
590
594
  return data.value
591
595
 
592
596
 
@@ -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
+ )