crabcode 0.1.6__py3-none-win_amd64.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.
Files changed (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
@@ -0,0 +1,1806 @@
1
+ """Process memory inspection helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import ctypes
7
+ import ctypes.wintypes
8
+ import platform
9
+ import re
10
+ import struct
11
+ import uuid
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ _NUMERIC_FORMATS: dict[str, str] = {
18
+ "int8": "b",
19
+ "uint8": "B",
20
+ "int16": "h",
21
+ "uint16": "H",
22
+ "int32": "i",
23
+ "uint32": "I",
24
+ "int64": "q",
25
+ "uint64": "Q",
26
+ "float32": "f",
27
+ "float64": "d",
28
+ }
29
+
30
+
31
+ class MemoryBackend:
32
+ name = "unavailable"
33
+ available = False
34
+ note = "direct memory backend unavailable"
35
+
36
+ def regions(self, pid: int) -> list["MemoryRegion"]:
37
+ raise RuntimeError(self.note)
38
+
39
+ def read(self, pid: int, address: int, size: int) -> bytes:
40
+ raise RuntimeError(self.note)
41
+
42
+ def write(self, pid: int, address: int, data: bytes) -> None:
43
+ raise RuntimeError(self.note)
44
+
45
+ def protect(self, pid: int, address: int, size: int, writable: bool, executable: bool) -> Any:
46
+ raise RuntimeError("memory protection changes are unavailable on this backend")
47
+
48
+ def restore_protection(self, pid: int, address: int, size: int, token: Any) -> None:
49
+ return None
50
+
51
+ def flush_instruction_cache(self, pid: int, address: int, size: int) -> None:
52
+ return None
53
+
54
+
55
+ class LinuxProcfsMemoryBackend(MemoryBackend):
56
+ name = "linux-procfs"
57
+ available = True
58
+ note = "Linux /proc/<pid>/mem backend"
59
+
60
+ def regions(self, pid: int) -> list["MemoryRegion"]:
61
+ maps = Path(f"/proc/{pid}/maps")
62
+ text = maps.read_text(encoding="utf-8", errors="replace")
63
+ regions: list[MemoryRegion] = []
64
+ for line in text.splitlines():
65
+ parts = line.split(maxsplit=5)
66
+ if len(parts) < 5:
67
+ continue
68
+ start_raw, end_raw = parts[0].split("-", 1)
69
+ path = parts[5] if len(parts) > 5 else ""
70
+ regions.append(
71
+ MemoryRegion(
72
+ start=int(start_raw, 16),
73
+ end=int(end_raw, 16),
74
+ permissions=parts[1],
75
+ offset=int(parts[2], 16),
76
+ device=parts[3],
77
+ inode=parts[4],
78
+ path=path,
79
+ )
80
+ )
81
+ return regions
82
+
83
+ def read(self, pid: int, address: int, size: int) -> bytes:
84
+ with open(f"/proc/{pid}/mem", "rb", buffering=0) as handle:
85
+ handle.seek(address)
86
+ return handle.read(size)
87
+
88
+ def write(self, pid: int, address: int, data: bytes) -> None:
89
+ with open(f"/proc/{pid}/mem", "r+b", buffering=0) as handle:
90
+ handle.seek(address)
91
+ handle.write(data)
92
+
93
+ def protect(self, pid: int, address: int, size: int, writable: bool, executable: bool) -> Any:
94
+ return None
95
+
96
+ def restore_protection(self, pid: int, address: int, size: int, token: Any) -> None:
97
+ return None
98
+
99
+
100
+ class WindowsMemoryBackend(MemoryBackend):
101
+ name = "windows-kernel32"
102
+ note = "Windows kernel32 ReadProcessMemory/WriteProcessMemory backend"
103
+
104
+ PROCESS_QUERY_INFORMATION = 0x0400
105
+ PROCESS_VM_OPERATION = 0x0008
106
+ PROCESS_VM_READ = 0x0010
107
+ PROCESS_VM_WRITE = 0x0020
108
+ MEM_COMMIT = 0x1000
109
+ PAGE_NOACCESS = 0x01
110
+ PAGE_READONLY = 0x02
111
+ PAGE_READWRITE = 0x04
112
+ PAGE_WRITECOPY = 0x08
113
+ PAGE_EXECUTE = 0x10
114
+ PAGE_EXECUTE_READ = 0x20
115
+ PAGE_EXECUTE_READWRITE = 0x40
116
+ PAGE_EXECUTE_WRITECOPY = 0x80
117
+ PAGE_GUARD = 0x100
118
+
119
+ def __init__(self) -> None:
120
+ self.available = platform.system().lower() == "windows"
121
+ if not self.available:
122
+ return
123
+ self.kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
124
+ self._configure_api()
125
+
126
+ def _configure_api(self) -> None:
127
+ wintypes = ctypes.wintypes
128
+ self.kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
129
+ self.kernel32.OpenProcess.restype = wintypes.HANDLE
130
+ self.kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
131
+ self.kernel32.CloseHandle.restype = wintypes.BOOL
132
+ self.kernel32.ReadProcessMemory.argtypes = [
133
+ wintypes.HANDLE,
134
+ ctypes.c_void_p,
135
+ ctypes.c_void_p,
136
+ ctypes.c_size_t,
137
+ ctypes.POINTER(ctypes.c_size_t),
138
+ ]
139
+ self.kernel32.ReadProcessMemory.restype = wintypes.BOOL
140
+ self.kernel32.WriteProcessMemory.argtypes = [
141
+ wintypes.HANDLE,
142
+ ctypes.c_void_p,
143
+ ctypes.c_void_p,
144
+ ctypes.c_size_t,
145
+ ctypes.POINTER(ctypes.c_size_t),
146
+ ]
147
+ self.kernel32.WriteProcessMemory.restype = wintypes.BOOL
148
+ self.kernel32.VirtualProtectEx.argtypes = [
149
+ wintypes.HANDLE,
150
+ ctypes.c_void_p,
151
+ ctypes.c_size_t,
152
+ wintypes.DWORD,
153
+ ctypes.POINTER(wintypes.DWORD),
154
+ ]
155
+ self.kernel32.VirtualProtectEx.restype = wintypes.BOOL
156
+ self.kernel32.VirtualQueryEx.argtypes = [
157
+ wintypes.HANDLE,
158
+ ctypes.c_void_p,
159
+ ctypes.c_void_p,
160
+ ctypes.c_size_t,
161
+ ]
162
+ self.kernel32.VirtualQueryEx.restype = ctypes.c_size_t
163
+ self.kernel32.FlushInstructionCache.argtypes = [
164
+ wintypes.HANDLE,
165
+ ctypes.c_void_p,
166
+ ctypes.c_size_t,
167
+ ]
168
+ self.kernel32.FlushInstructionCache.restype = wintypes.BOOL
169
+
170
+ def _open(self, pid: int, access: int) -> Any:
171
+ handle = self.kernel32.OpenProcess(access, False, pid)
172
+ if not handle:
173
+ error_code = ctypes.get_last_error()
174
+ raise OSError(
175
+ error_code,
176
+ f"OpenProcess failed for pid {pid}; try running CrabCode as Administrator, "
177
+ "ensure the target process is not running with higher privileges, and note that "
178
+ "protected/system processes may still deny memory access",
179
+ )
180
+ return handle
181
+
182
+ def regions(self, pid: int) -> list["MemoryRegion"]:
183
+ class MEMORY_BASIC_INFORMATION(ctypes.Structure):
184
+ _fields_ = [
185
+ ("BaseAddress", ctypes.c_void_p),
186
+ ("AllocationBase", ctypes.c_void_p),
187
+ ("AllocationProtect", ctypes.c_ulong),
188
+ ("PartitionId", ctypes.c_ushort),
189
+ ("RegionSize", ctypes.c_size_t),
190
+ ("State", ctypes.c_ulong),
191
+ ("Protect", ctypes.c_ulong),
192
+ ("Type", ctypes.c_ulong),
193
+ ]
194
+
195
+ regions: list[MemoryRegion] = []
196
+ handle = self._open(pid, self.PROCESS_QUERY_INFORMATION | self.PROCESS_VM_READ)
197
+ try:
198
+ address = 0
199
+ max_address = (1 << (ctypes.sizeof(ctypes.c_void_p) * 8)) - 1
200
+ mbi = MEMORY_BASIC_INFORMATION()
201
+ mbi_size = ctypes.sizeof(mbi)
202
+ while address < max_address:
203
+ result = self.kernel32.VirtualQueryEx(
204
+ handle,
205
+ ctypes.c_void_p(address),
206
+ ctypes.byref(mbi),
207
+ mbi_size,
208
+ )
209
+ if not result:
210
+ break
211
+ base = int(mbi.BaseAddress or 0)
212
+ size = int(mbi.RegionSize or 0)
213
+ if size <= 0:
214
+ address += 0x1000
215
+ continue
216
+ if int(mbi.State) == self.MEM_COMMIT:
217
+ regions.append(
218
+ MemoryRegion(
219
+ start=base,
220
+ end=base + size,
221
+ permissions=self._protection_to_permissions(int(mbi.Protect)),
222
+ offset=0,
223
+ device="",
224
+ inode="",
225
+ path="",
226
+ )
227
+ )
228
+ next_address = base + size
229
+ if next_address <= address:
230
+ break
231
+ address = next_address
232
+ finally:
233
+ self.kernel32.CloseHandle(handle)
234
+ return regions
235
+
236
+ def read(self, pid: int, address: int, size: int) -> bytes:
237
+ handle = self._open(pid, self.PROCESS_VM_READ | self.PROCESS_QUERY_INFORMATION)
238
+ try:
239
+ buffer = ctypes.create_string_buffer(size)
240
+ read = ctypes.c_size_t(0)
241
+ ok = self.kernel32.ReadProcessMemory(
242
+ handle,
243
+ ctypes.c_void_p(address),
244
+ buffer,
245
+ size,
246
+ ctypes.byref(read),
247
+ )
248
+ if not ok:
249
+ raise OSError(ctypes.get_last_error(), f"ReadProcessMemory failed at {hex(address)}")
250
+ return buffer.raw[: read.value]
251
+ finally:
252
+ self.kernel32.CloseHandle(handle)
253
+
254
+ def write(self, pid: int, address: int, data: bytes) -> None:
255
+ handle = self._open(
256
+ pid,
257
+ self.PROCESS_VM_WRITE | self.PROCESS_VM_OPERATION | self.PROCESS_QUERY_INFORMATION,
258
+ )
259
+ try:
260
+ buffer = ctypes.create_string_buffer(data)
261
+ written = ctypes.c_size_t(0)
262
+ ok = self.kernel32.WriteProcessMemory(
263
+ handle,
264
+ ctypes.c_void_p(address),
265
+ buffer,
266
+ len(data),
267
+ ctypes.byref(written),
268
+ )
269
+ if not ok or written.value != len(data):
270
+ raise OSError(ctypes.get_last_error(), f"WriteProcessMemory failed at {hex(address)}")
271
+ finally:
272
+ self.kernel32.CloseHandle(handle)
273
+
274
+ def protect(self, pid: int, address: int, size: int, writable: bool, executable: bool) -> Any:
275
+ handle = self._open(pid, self.PROCESS_VM_OPERATION | self.PROCESS_QUERY_INFORMATION)
276
+ new_protect = self.PAGE_EXECUTE_READWRITE if executable else self.PAGE_READWRITE
277
+ old_protect = ctypes.wintypes.DWORD(0)
278
+ try:
279
+ ok = self.kernel32.VirtualProtectEx(
280
+ handle,
281
+ ctypes.c_void_p(address),
282
+ size,
283
+ new_protect,
284
+ ctypes.byref(old_protect),
285
+ )
286
+ if not ok:
287
+ raise OSError(ctypes.get_last_error(), f"VirtualProtectEx failed at {hex(address)}")
288
+ return int(old_protect.value)
289
+ finally:
290
+ self.kernel32.CloseHandle(handle)
291
+
292
+ def restore_protection(self, pid: int, address: int, size: int, token: Any) -> None:
293
+ if token is None:
294
+ return
295
+ handle = self._open(pid, self.PROCESS_VM_OPERATION | self.PROCESS_QUERY_INFORMATION)
296
+ old_protect = ctypes.wintypes.DWORD(0)
297
+ try:
298
+ self.kernel32.VirtualProtectEx(
299
+ handle,
300
+ ctypes.c_void_p(address),
301
+ size,
302
+ int(token),
303
+ ctypes.byref(old_protect),
304
+ )
305
+ finally:
306
+ self.kernel32.CloseHandle(handle)
307
+
308
+ def flush_instruction_cache(self, pid: int, address: int, size: int) -> None:
309
+ handle = self._open(pid, self.PROCESS_VM_OPERATION | self.PROCESS_QUERY_INFORMATION)
310
+ try:
311
+ ok = self.kernel32.FlushInstructionCache(handle, ctypes.c_void_p(address), size)
312
+ if not ok:
313
+ raise OSError(ctypes.get_last_error(), f"FlushInstructionCache failed at {hex(address)}")
314
+ finally:
315
+ self.kernel32.CloseHandle(handle)
316
+
317
+ def _protection_to_permissions(self, protect: int) -> str:
318
+ if protect & self.PAGE_GUARD or protect == self.PAGE_NOACCESS:
319
+ return "---p"
320
+ readable = protect in {
321
+ self.PAGE_READONLY,
322
+ self.PAGE_READWRITE,
323
+ self.PAGE_WRITECOPY,
324
+ self.PAGE_EXECUTE_READ,
325
+ self.PAGE_EXECUTE_READWRITE,
326
+ self.PAGE_EXECUTE_WRITECOPY,
327
+ }
328
+ writable = protect in {
329
+ self.PAGE_READWRITE,
330
+ self.PAGE_WRITECOPY,
331
+ self.PAGE_EXECUTE_READWRITE,
332
+ self.PAGE_EXECUTE_WRITECOPY,
333
+ }
334
+ executable = protect in {
335
+ self.PAGE_EXECUTE,
336
+ self.PAGE_EXECUTE_READ,
337
+ self.PAGE_EXECUTE_READWRITE,
338
+ self.PAGE_EXECUTE_WRITECOPY,
339
+ }
340
+ return (
341
+ ("r" if readable else "-")
342
+ + ("w" if writable else "-")
343
+ + ("x" if executable else "-")
344
+ + "p"
345
+ )
346
+
347
+
348
+ class MacOSMachMemoryBackend(MemoryBackend):
349
+ name = "macos-mach"
350
+ note = "macOS Mach task memory backend"
351
+
352
+ KERN_SUCCESS = 0
353
+ VM_PROT_READ = 0x01
354
+ VM_PROT_WRITE = 0x02
355
+ VM_PROT_EXECUTE = 0x04
356
+
357
+ def __init__(self) -> None:
358
+ self.available = platform.system().lower() == "darwin"
359
+ if not self.available:
360
+ return
361
+ self.lib = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True)
362
+ self._configure_api()
363
+
364
+ def _configure_api(self) -> None:
365
+ self.lib.mach_task_self.restype = ctypes.c_uint32
366
+ self.lib.task_for_pid.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.POINTER(ctypes.c_uint32)]
367
+ self.lib.task_for_pid.restype = ctypes.c_int
368
+ self.lib.mach_port_deallocate.argtypes = [ctypes.c_uint32, ctypes.c_uint32]
369
+ self.lib.mach_vm_read_overwrite.argtypes = [
370
+ ctypes.c_uint32,
371
+ ctypes.c_uint64,
372
+ ctypes.c_uint64,
373
+ ctypes.c_void_p,
374
+ ctypes.POINTER(ctypes.c_uint64),
375
+ ]
376
+ self.lib.mach_vm_read_overwrite.restype = ctypes.c_int
377
+ self.lib.mach_vm_write.argtypes = [
378
+ ctypes.c_uint32,
379
+ ctypes.c_uint64,
380
+ ctypes.c_void_p,
381
+ ctypes.c_uint32,
382
+ ]
383
+ self.lib.mach_vm_write.restype = ctypes.c_int
384
+ self.lib.mach_vm_protect.argtypes = [
385
+ ctypes.c_uint32,
386
+ ctypes.c_uint64,
387
+ ctypes.c_uint64,
388
+ ctypes.c_int,
389
+ ctypes.c_int,
390
+ ]
391
+ self.lib.mach_vm_protect.restype = ctypes.c_int
392
+ self.lib.mach_vm_region_recurse.argtypes = [
393
+ ctypes.c_uint32,
394
+ ctypes.POINTER(ctypes.c_uint64),
395
+ ctypes.POINTER(ctypes.c_uint64),
396
+ ctypes.POINTER(ctypes.c_uint32),
397
+ ctypes.c_void_p,
398
+ ctypes.POINTER(ctypes.c_uint32),
399
+ ]
400
+ self.lib.mach_vm_region_recurse.restype = ctypes.c_int
401
+
402
+ def _task_for_pid(self, pid: int) -> int:
403
+ task = ctypes.c_uint32(0)
404
+ kr = self.lib.task_for_pid(self.lib.mach_task_self(), int(pid), ctypes.byref(task))
405
+ if kr != self.KERN_SUCCESS:
406
+ raise PermissionError(
407
+ f"task_for_pid failed for pid {pid} with kern_return={kr}; "
408
+ "grant Developer Tools/debugging permission, run with sufficient privileges, "
409
+ "and note that SIP/protected processes may still deny access"
410
+ )
411
+ return int(task.value)
412
+
413
+ def _deallocate(self, task: int) -> None:
414
+ try:
415
+ self.lib.mach_port_deallocate(self.lib.mach_task_self(), task)
416
+ except Exception:
417
+ pass
418
+
419
+ def regions(self, pid: int) -> list["MemoryRegion"]:
420
+ class VMRegionSubmapInfo64(ctypes.Structure):
421
+ _fields_ = [
422
+ ("protection", ctypes.c_uint32),
423
+ ("max_protection", ctypes.c_uint32),
424
+ ("inheritance", ctypes.c_uint32),
425
+ ("offset", ctypes.c_uint64),
426
+ ("user_tag", ctypes.c_uint32),
427
+ ("pages_resident", ctypes.c_uint32),
428
+ ("pages_shared_now_private", ctypes.c_uint32),
429
+ ("pages_swapped_out", ctypes.c_uint32),
430
+ ("pages_dirtied", ctypes.c_uint32),
431
+ ("ref_count", ctypes.c_uint32),
432
+ ("shadow_depth", ctypes.c_uint16),
433
+ ("external_pager", ctypes.c_uint8),
434
+ ("share_mode", ctypes.c_uint8),
435
+ ("is_submap", ctypes.c_uint32),
436
+ ("behavior", ctypes.c_uint32),
437
+ ("object_id", ctypes.c_uint32),
438
+ ("user_wired_count", ctypes.c_uint16),
439
+ ("pages_reusable", ctypes.c_uint32),
440
+ ("object_id_full", ctypes.c_uint64),
441
+ ]
442
+
443
+ task = self._task_for_pid(pid)
444
+ regions: list[MemoryRegion] = []
445
+ try:
446
+ address = ctypes.c_uint64(0)
447
+ size = ctypes.c_uint64(0)
448
+ depth = ctypes.c_uint32(0)
449
+ max_regions = 20000
450
+ for _ in range(max_regions):
451
+ info = VMRegionSubmapInfo64()
452
+ count = ctypes.c_uint32(ctypes.sizeof(info) // ctypes.sizeof(ctypes.c_uint32))
453
+ kr = self.lib.mach_vm_region_recurse(
454
+ task,
455
+ ctypes.byref(address),
456
+ ctypes.byref(size),
457
+ ctypes.byref(depth),
458
+ ctypes.byref(info),
459
+ ctypes.byref(count),
460
+ )
461
+ if kr != self.KERN_SUCCESS:
462
+ break
463
+ if info.is_submap:
464
+ depth.value += 1
465
+ continue
466
+ start = int(address.value)
467
+ region_size = int(size.value)
468
+ regions.append(
469
+ MemoryRegion(
470
+ start=start,
471
+ end=start + region_size,
472
+ permissions=self._protection_to_permissions(int(info.protection)),
473
+ offset=int(info.offset),
474
+ device="",
475
+ inode="",
476
+ path="",
477
+ )
478
+ )
479
+ address.value = start + region_size
480
+ finally:
481
+ self._deallocate(task)
482
+ return regions
483
+
484
+ def read(self, pid: int, address: int, size: int) -> bytes:
485
+ task = self._task_for_pid(pid)
486
+ try:
487
+ buffer = ctypes.create_string_buffer(size)
488
+ out_size = ctypes.c_uint64(0)
489
+ kr = self.lib.mach_vm_read_overwrite(
490
+ task,
491
+ ctypes.c_uint64(address),
492
+ ctypes.c_uint64(size),
493
+ buffer,
494
+ ctypes.byref(out_size),
495
+ )
496
+ if kr != self.KERN_SUCCESS:
497
+ raise OSError(f"mach_vm_read_overwrite failed at {hex(address)} with kern_return={kr}")
498
+ return buffer.raw[: int(out_size.value)]
499
+ finally:
500
+ self._deallocate(task)
501
+
502
+ def write(self, pid: int, address: int, data: bytes) -> None:
503
+ task = self._task_for_pid(pid)
504
+ try:
505
+ buffer = ctypes.create_string_buffer(data)
506
+ kr = self.lib.mach_vm_write(
507
+ task,
508
+ ctypes.c_uint64(address),
509
+ buffer,
510
+ ctypes.c_uint32(len(data)),
511
+ )
512
+ if kr != self.KERN_SUCCESS:
513
+ raise OSError(f"mach_vm_write failed at {hex(address)} with kern_return={kr}")
514
+ finally:
515
+ self._deallocate(task)
516
+
517
+ def protect(self, pid: int, address: int, size: int, writable: bool, executable: bool) -> Any:
518
+ old_protection = self._protection_for_address(pid, address)
519
+ task = self._task_for_pid(pid)
520
+ protection = self.VM_PROT_READ
521
+ if writable:
522
+ protection |= self.VM_PROT_WRITE
523
+ if executable:
524
+ protection |= self.VM_PROT_EXECUTE
525
+ try:
526
+ kr = self.lib.mach_vm_protect(
527
+ task,
528
+ ctypes.c_uint64(address),
529
+ ctypes.c_uint64(size),
530
+ 0,
531
+ protection,
532
+ )
533
+ if kr != self.KERN_SUCCESS:
534
+ raise OSError(f"mach_vm_protect failed at {hex(address)} with kern_return={kr}")
535
+ finally:
536
+ self._deallocate(task)
537
+ return old_protection
538
+
539
+ def restore_protection(self, pid: int, address: int, size: int, token: Any) -> None:
540
+ if token is None:
541
+ return
542
+ task = self._task_for_pid(pid)
543
+ try:
544
+ self.lib.mach_vm_protect(
545
+ task,
546
+ ctypes.c_uint64(address),
547
+ ctypes.c_uint64(size),
548
+ 0,
549
+ int(token),
550
+ )
551
+ finally:
552
+ self._deallocate(task)
553
+
554
+ def _protection_for_address(self, pid: int, address: int) -> int | None:
555
+ for region in self.regions(pid):
556
+ if region.start <= address < region.end:
557
+ protection = 0
558
+ if "r" in region.permissions:
559
+ protection |= self.VM_PROT_READ
560
+ if "w" in region.permissions:
561
+ protection |= self.VM_PROT_WRITE
562
+ if "x" in region.permissions:
563
+ protection |= self.VM_PROT_EXECUTE
564
+ return protection
565
+ return None
566
+
567
+ def _protection_to_permissions(self, protection: int) -> str:
568
+ return (
569
+ ("r" if protection & self.VM_PROT_READ else "-")
570
+ + ("w" if protection & self.VM_PROT_WRITE else "-")
571
+ + ("x" if protection & self.VM_PROT_EXECUTE else "-")
572
+ + "p"
573
+ )
574
+
575
+
576
+ def _select_memory_backend() -> MemoryBackend:
577
+ system = platform.system().lower()
578
+ if system == "linux" and Path("/proc").exists():
579
+ return LinuxProcfsMemoryBackend()
580
+ if system == "windows":
581
+ return WindowsMemoryBackend()
582
+ if system == "darwin":
583
+ return MacOSMachMemoryBackend()
584
+ return MemoryBackend()
585
+
586
+
587
+ @dataclass
588
+ class MemoryRegion:
589
+ start: int
590
+ end: int
591
+ permissions: str
592
+ offset: int
593
+ device: str
594
+ inode: str
595
+ path: str
596
+
597
+ @property
598
+ def size(self) -> int:
599
+ return max(0, self.end - self.start)
600
+
601
+ def to_dict(self) -> dict[str, Any]:
602
+ return {
603
+ "start": hex(self.start),
604
+ "end": hex(self.end),
605
+ "size": self.size,
606
+ "permissions": self.permissions,
607
+ "readable": "r" in self.permissions,
608
+ "writable": "w" in self.permissions,
609
+ "executable": "x" in self.permissions,
610
+ "private": "p" in self.permissions,
611
+ "shared": "s" in self.permissions,
612
+ "offset": hex(self.offset),
613
+ "device": self.device,
614
+ "inode": self.inode,
615
+ "path": self.path,
616
+ }
617
+
618
+
619
+ class MemoryInspector:
620
+ """Process memory search/read/write for supported platforms.
621
+
622
+ Linux uses /proc/<pid>/maps and /proc/<pid>/mem, Windows uses documented
623
+ kernel32 process-memory APIs, and macOS uses Mach task-memory APIs.
624
+ """
625
+
626
+ def __init__(self, *, config: dict[str, Any] | None = None) -> None:
627
+ self.config = config or {}
628
+ self.backend = _select_memory_backend()
629
+ self.max_search_results = max(1, int(self.config.get("max_search_results", 100)))
630
+ self.max_search_bytes = max(1, int(self.config.get("max_search_bytes", 128 * 1024 * 1024)))
631
+ self.chunk_size = max(1, int(self.config.get("memory_search_chunk_bytes", 1024 * 1024)))
632
+ self._searches: dict[str, dict[str, Any]] = {}
633
+ self._freezes: dict[str, dict[str, Any]] = {}
634
+ self._aob_scans: dict[str, dict[str, Any]] = {}
635
+ self._patches: dict[str, dict[str, Any]] = {}
636
+
637
+ async def close(self) -> None:
638
+ await self.unfreeze(all_freezes=True)
639
+
640
+ def capabilities(self) -> dict[str, bool | str]:
641
+ available = bool(self.backend.available)
642
+ return {
643
+ "memory_regions": available,
644
+ "memory_read": available,
645
+ "memory_search": available,
646
+ "memory_refine": available,
647
+ "memory_write": available,
648
+ "memory_freeze": available,
649
+ "memory_unfreeze": True,
650
+ "memory_freezes": True,
651
+ "aob_scan": available,
652
+ "pointer_scan": available,
653
+ "pointer_resolve": available,
654
+ "code_read": available,
655
+ "code_patch": available,
656
+ "code_restore": True,
657
+ "code_patches": True,
658
+ "backend": self.backend.name,
659
+ "backend_note": self.backend.note,
660
+ }
661
+
662
+ def regions(
663
+ self,
664
+ pid: int,
665
+ *,
666
+ readable: bool = False,
667
+ writable: bool = False,
668
+ executable: bool | None = None,
669
+ limit: int = 500,
670
+ ) -> dict[str, Any]:
671
+ if not self.backend.available:
672
+ return {"error": f"memory_regions unavailable on {platform.system()}"}
673
+ try:
674
+ parsed = self._linux_regions(pid)
675
+ except Exception as exc:
676
+ return {"pid": pid, "error": str(exc)}
677
+ filtered: list[MemoryRegion] = []
678
+ for region in parsed:
679
+ if readable and "r" not in region.permissions:
680
+ continue
681
+ if writable and "w" not in region.permissions:
682
+ continue
683
+ if executable is not None and ("x" in region.permissions) != executable:
684
+ continue
685
+ filtered.append(region)
686
+ return {
687
+ "pid": pid,
688
+ "regions": [region.to_dict() for region in filtered[: max(1, limit)]],
689
+ "total_regions": len(filtered),
690
+ }
691
+
692
+ def read(self, pid: int, *, address: int | str, size: int) -> dict[str, Any]:
693
+ parsed_address = parse_address(address)
694
+ read_size = max(1, min(int(size), int(self.config.get("max_memory_read_bytes", 4096))))
695
+ try:
696
+ data = self._read_memory(pid, parsed_address, read_size)
697
+ except Exception as exc:
698
+ return {"pid": pid, "address": hex(parsed_address), "error": str(exc)}
699
+ return {
700
+ "pid": pid,
701
+ "address": hex(parsed_address),
702
+ "size": len(data),
703
+ "hex": data.hex(),
704
+ "ascii": _ascii_preview(data),
705
+ }
706
+
707
+ def write(
708
+ self,
709
+ pid: int,
710
+ *,
711
+ address: int | str,
712
+ value_type: str,
713
+ value: Any = None,
714
+ value_hex: str | None = None,
715
+ endian: str = "little",
716
+ ) -> dict[str, Any]:
717
+ parsed_address = parse_address(address)
718
+ new_bytes = encode_value(value_type, value=value, value_hex=value_hex, endian=endian)
719
+ if not new_bytes:
720
+ return {"pid": pid, "address": hex(parsed_address), "error": "write value encoded to empty bytes"}
721
+ max_write = int(self.config.get("max_memory_write_bytes", 64))
722
+ if len(new_bytes) > max_write:
723
+ return {
724
+ "pid": pid,
725
+ "address": hex(parsed_address),
726
+ "error": f"write size {len(new_bytes)} exceeds max_memory_write_bytes={max_write}",
727
+ }
728
+ try:
729
+ old_bytes = self._read_memory(pid, parsed_address, len(new_bytes))
730
+ self._write_memory(pid, parsed_address, new_bytes)
731
+ verify = self._read_memory(pid, parsed_address, len(new_bytes))
732
+ except Exception as exc:
733
+ return {"pid": pid, "address": hex(parsed_address), "error": str(exc)}
734
+ return {
735
+ "pid": pid,
736
+ "address": hex(parsed_address),
737
+ "value_type": value_type,
738
+ "old_hex": old_bytes.hex(),
739
+ "written_hex": new_bytes.hex(),
740
+ "verify_hex": verify.hex(),
741
+ "verified": verify == new_bytes,
742
+ }
743
+
744
+ async def freeze(
745
+ self,
746
+ pid: int,
747
+ *,
748
+ address: int | str,
749
+ value_type: str,
750
+ value: Any = None,
751
+ value_hex: str | None = None,
752
+ endian: str = "little",
753
+ interval_seconds: float | None = None,
754
+ ) -> dict[str, Any]:
755
+ max_freezes = int(self.config.get("max_memory_freezes", 32))
756
+ if len(self._freezes) >= max_freezes:
757
+ return {"error": f"max_memory_freezes reached: {max_freezes}"}
758
+
759
+ parsed_address = parse_address(address)
760
+ data = encode_value(value_type, value=value, value_hex=value_hex, endian=endian)
761
+ if not data:
762
+ return {"pid": pid, "address": hex(parsed_address), "error": "freeze value encoded to empty bytes"}
763
+ max_write = int(self.config.get("max_memory_write_bytes", 64))
764
+ if len(data) > max_write:
765
+ return {
766
+ "pid": pid,
767
+ "address": hex(parsed_address),
768
+ "error": f"freeze write size {len(data)} exceeds max_memory_write_bytes={max_write}",
769
+ }
770
+
771
+ try:
772
+ old_bytes = self._read_memory(pid, parsed_address, len(data))
773
+ self._write_memory(pid, parsed_address, data)
774
+ verify = self._read_memory(pid, parsed_address, len(data))
775
+ except Exception as exc:
776
+ return {"pid": pid, "address": hex(parsed_address), "error": str(exc)}
777
+
778
+ min_interval = float(self.config.get("min_memory_freeze_interval_seconds", 0.05))
779
+ default_interval = float(self.config.get("memory_freeze_interval_seconds", 0.25))
780
+ interval = max(min_interval, float(interval_seconds or default_interval))
781
+ freeze_id = f"freeze-{uuid.uuid4().hex[:10]}"
782
+ state: dict[str, Any] = {
783
+ "freeze_id": freeze_id,
784
+ "pid": pid,
785
+ "address": hex(parsed_address),
786
+ "value_type": value_type,
787
+ "value_hex": data.hex(),
788
+ "endian": endian,
789
+ "interval_seconds": interval,
790
+ "writes": 1,
791
+ "active": True,
792
+ "last_error": None,
793
+ "old_hex": old_bytes.hex(),
794
+ "verify_hex": verify.hex(),
795
+ "verified": verify == data,
796
+ }
797
+
798
+ async def _loop() -> None:
799
+ max_errors = int(self.config.get("max_memory_freeze_errors", 3))
800
+ consecutive_errors = 0
801
+ while state.get("active"):
802
+ await asyncio.sleep(interval)
803
+ try:
804
+ self._write_memory(pid, parsed_address, data)
805
+ state["writes"] = int(state.get("writes", 0)) + 1
806
+ state["last_error"] = None
807
+ consecutive_errors = 0
808
+ except asyncio.CancelledError:
809
+ raise
810
+ except Exception as exc:
811
+ consecutive_errors += 1
812
+ state["last_error"] = str(exc)
813
+ if consecutive_errors >= max_errors:
814
+ state["active"] = False
815
+ break
816
+
817
+ task = asyncio.create_task(_loop())
818
+ state["task"] = task
819
+ self._freezes[freeze_id] = state
820
+ return self._freeze_snapshot(state, include_task=False)
821
+
822
+ def freezes(self) -> dict[str, Any]:
823
+ return {
824
+ "freezes": [
825
+ self._freeze_snapshot(state, include_task=False)
826
+ for state in self._freezes.values()
827
+ ]
828
+ }
829
+
830
+ async def unfreeze(
831
+ self,
832
+ freeze_id: str | None = None,
833
+ *,
834
+ all_freezes: bool = False,
835
+ ) -> dict[str, Any]:
836
+ if all_freezes:
837
+ target_ids = list(self._freezes)
838
+ elif freeze_id:
839
+ target_ids = [freeze_id]
840
+ else:
841
+ return {"error": "freeze_id is required unless all_freezes=true"}
842
+
843
+ stopped: list[dict[str, Any]] = []
844
+ missing: list[str] = []
845
+ for target_id in target_ids:
846
+ state = self._freezes.pop(target_id, None)
847
+ if not state:
848
+ missing.append(target_id)
849
+ continue
850
+ state["active"] = False
851
+ task = state.get("task")
852
+ if isinstance(task, asyncio.Task) and not task.done():
853
+ task.cancel()
854
+ try:
855
+ await task
856
+ except asyncio.CancelledError:
857
+ pass
858
+ stopped.append(self._freeze_snapshot(state, include_task=False))
859
+ return {"stopped": stopped, "missing": missing}
860
+
861
+ def search(
862
+ self,
863
+ pid: int,
864
+ *,
865
+ value_type: str,
866
+ value: Any = None,
867
+ value_hex: str | None = None,
868
+ endian: str = "little",
869
+ writable_only: bool = True,
870
+ max_results: int | None = None,
871
+ max_scan_bytes: int | None = None,
872
+ ) -> dict[str, Any]:
873
+ if not self.backend.available:
874
+ return {"error": f"memory_search unavailable on {platform.system()}"}
875
+ needle = encode_value(value_type, value=value, value_hex=value_hex, endian=endian)
876
+ if not needle:
877
+ return {"error": "search value encoded to empty bytes"}
878
+ limit_results = self._bounded_limit(max_results, self.max_search_results)
879
+ limit_bytes = self._bounded_limit(max_scan_bytes, self.max_search_bytes)
880
+ try:
881
+ regions = [
882
+ region
883
+ for region in self._linux_regions(pid)
884
+ if "r" in region.permissions and (not writable_only or "w" in region.permissions)
885
+ ]
886
+ except Exception as exc:
887
+ return {"pid": pid, "error": str(exc)}
888
+ results, scanned, stopped_reason = self._search_regions(
889
+ pid,
890
+ regions,
891
+ needle,
892
+ limit_results=limit_results,
893
+ limit_bytes=limit_bytes,
894
+ value_type=value_type,
895
+ endian=endian,
896
+ )
897
+ search_id = f"mem-{uuid.uuid4().hex[:10]}"
898
+ self._remember(
899
+ self._searches,
900
+ search_id,
901
+ {
902
+ "pid": pid,
903
+ "value_type": value_type,
904
+ "endian": endian,
905
+ "bytes": needle.hex(),
906
+ "results": results,
907
+ },
908
+ int(self.config.get("max_memory_searches", 64)),
909
+ )
910
+ return {
911
+ "pid": pid,
912
+ "search_id": search_id,
913
+ "value_type": value_type,
914
+ "pattern_hex": needle.hex(),
915
+ "writable_only": writable_only,
916
+ "scanned_bytes": scanned,
917
+ "stopped_reason": stopped_reason,
918
+ "result_count": len(results),
919
+ "results": results,
920
+ }
921
+
922
+ def refine(
923
+ self,
924
+ search_id: str,
925
+ *,
926
+ comparison: str = "changed",
927
+ value: Any = None,
928
+ value_hex: str | None = None,
929
+ max_results: int | None = None,
930
+ ) -> dict[str, Any]:
931
+ previous = self._searches.get(search_id)
932
+ if not previous:
933
+ return {"error": f"unknown memory search_id: {search_id}"}
934
+ pid = int(previous["pid"])
935
+ value_type = str(previous["value_type"])
936
+ endian = str(previous["endian"])
937
+ width = len(bytes.fromhex(str(previous["bytes"])))
938
+ expected = None
939
+ if comparison == "equals":
940
+ expected = encode_value(value_type, value=value, value_hex=value_hex, endian=endian)
941
+ limit_results = self._bounded_limit(max_results, self.max_search_results)
942
+ refined: list[dict[str, Any]] = []
943
+ for result in previous["results"]:
944
+ if len(refined) >= limit_results:
945
+ break
946
+ address = parse_address(result["address"])
947
+ try:
948
+ current = self._read_memory(pid, address, width)
949
+ except Exception:
950
+ continue
951
+ old = bytes.fromhex(str(result["bytes_hex"]))
952
+ if self._matches_refine(
953
+ comparison=comparison,
954
+ old=old,
955
+ current=current,
956
+ expected=expected,
957
+ value_type=value_type,
958
+ endian=endian,
959
+ ):
960
+ refined.append(self._result_entry(address, current, value_type, endian))
961
+ new_search_id = f"mem-{uuid.uuid4().hex[:10]}"
962
+ self._remember(
963
+ self._searches,
964
+ new_search_id,
965
+ {
966
+ "pid": pid,
967
+ "value_type": value_type,
968
+ "endian": endian,
969
+ "bytes": (expected or bytes.fromhex(str(previous["bytes"]))).hex(),
970
+ "results": refined,
971
+ },
972
+ int(self.config.get("max_memory_searches", 64)),
973
+ )
974
+ return {
975
+ "pid": pid,
976
+ "source_search_id": search_id,
977
+ "search_id": new_search_id,
978
+ "comparison": comparison,
979
+ "result_count": len(refined),
980
+ "results": refined,
981
+ }
982
+
983
+ def aob_scan(
984
+ self,
985
+ pid: int,
986
+ *,
987
+ pattern: str,
988
+ executable_only: bool = True,
989
+ writable_only: bool = False,
990
+ module_filter: str | None = None,
991
+ max_results: int | None = None,
992
+ max_scan_bytes: int | None = None,
993
+ ) -> dict[str, Any]:
994
+ if not self.backend.available:
995
+ return {"error": f"aob_scan unavailable on {platform.system()}"}
996
+ parsed = parse_aob_pattern(pattern)
997
+ if not parsed:
998
+ return {"error": "pattern is empty"}
999
+ limit_results = self._bounded_limit(max_results, self.max_search_results)
1000
+ limit_bytes = self._bounded_limit(max_scan_bytes, self.max_search_bytes)
1001
+ regions = []
1002
+ try:
1003
+ for region in self._linux_regions(pid):
1004
+ if "r" not in region.permissions:
1005
+ continue
1006
+ if executable_only and "x" not in region.permissions:
1007
+ continue
1008
+ if writable_only and "w" not in region.permissions:
1009
+ continue
1010
+ if module_filter and module_filter not in region.path:
1011
+ continue
1012
+ regions.append(region)
1013
+ except Exception as exc:
1014
+ return {"pid": pid, "error": str(exc)}
1015
+ results, scanned, stopped_reason = self._search_aob_regions(
1016
+ pid,
1017
+ regions,
1018
+ parsed,
1019
+ limit_results=limit_results,
1020
+ limit_bytes=limit_bytes,
1021
+ )
1022
+ aob_id = f"aob-{uuid.uuid4().hex[:10]}"
1023
+ self._remember(
1024
+ self._aob_scans,
1025
+ aob_id,
1026
+ {"pid": pid, "pattern": pattern, "results": results},
1027
+ int(self.config.get("max_aob_scans", 64)),
1028
+ )
1029
+ return {
1030
+ "pid": pid,
1031
+ "aob_id": aob_id,
1032
+ "pattern": pattern,
1033
+ "executable_only": executable_only,
1034
+ "writable_only": writable_only,
1035
+ "module_filter": module_filter,
1036
+ "scanned_bytes": scanned,
1037
+ "stopped_reason": stopped_reason,
1038
+ "result_count": len(results),
1039
+ "results": results,
1040
+ }
1041
+
1042
+ def pointer_scan(
1043
+ self,
1044
+ pid: int,
1045
+ *,
1046
+ target_address: int | str,
1047
+ max_depth: int = 3,
1048
+ max_offset: int = 4096,
1049
+ pointer_size: int | None = None,
1050
+ align: int | None = None,
1051
+ writable_only: bool = True,
1052
+ max_results: int | None = None,
1053
+ max_scan_bytes: int | None = None,
1054
+ ) -> dict[str, Any]:
1055
+ if not self.backend.available:
1056
+ return {"error": f"pointer_scan unavailable on {platform.system()}"}
1057
+ target = parse_address(target_address)
1058
+ ptr_size = pointer_size or struct.calcsize("P")
1059
+ if ptr_size not in {4, 8}:
1060
+ return {"error": "pointer_size must be 4 or 8"}
1061
+ step = max(1, int(align or ptr_size))
1062
+ depth_limit = max(1, min(int(max_depth), int(self.config.get("max_pointer_scan_depth", 5))))
1063
+ offset_limit = max(0, int(max_offset))
1064
+ pointer_result_cap = max(1, int(self.config.get("max_pointer_scan_results", 100)))
1065
+ limit_results = self._bounded_limit(max_results, pointer_result_cap)
1066
+ limit_bytes = self._bounded_limit(max_scan_bytes, self.max_search_bytes)
1067
+ endian = str(self.config.get("pointer_endian", "little"))
1068
+
1069
+ try:
1070
+ regions = [
1071
+ region
1072
+ for region in self._linux_regions(pid)
1073
+ if "r" in region.permissions and (not writable_only or "w" in region.permissions)
1074
+ ]
1075
+ except Exception as exc:
1076
+ return {"pid": pid, "error": str(exc)}
1077
+ module_bases = self._module_bases(regions)
1078
+ frontier: list[dict[str, Any]] = [
1079
+ {
1080
+ "target": target,
1081
+ "offsets": [],
1082
+ "depth": 0,
1083
+ }
1084
+ ]
1085
+ results: list[dict[str, Any]] = []
1086
+ scanned_total = 0
1087
+ stopped_reason = "completed"
1088
+ for depth in range(1, depth_limit + 1):
1089
+ if len(results) >= limit_results or not frontier:
1090
+ break
1091
+ target_nodes = frontier[: int(self.config.get("max_pointer_scan_frontier", 256))]
1092
+ next_frontier: list[dict[str, Any]] = []
1093
+ candidates, scanned, reason = self._scan_pointer_level(
1094
+ pid,
1095
+ regions,
1096
+ target_nodes,
1097
+ pointer_size=ptr_size,
1098
+ align=step,
1099
+ max_offset=offset_limit,
1100
+ max_results=limit_results - len(results),
1101
+ max_scan_bytes=max(1, limit_bytes - scanned_total),
1102
+ endian=endian,
1103
+ module_bases=module_bases,
1104
+ )
1105
+ scanned_total += scanned
1106
+ for candidate in candidates:
1107
+ results.append(candidate)
1108
+ raw_offsets = [parse_address(v) for v in candidate["offsets"]]
1109
+ next_frontier.append(
1110
+ {
1111
+ "target": parse_address(candidate["base_address"]),
1112
+ "offsets": raw_offsets,
1113
+ "depth": depth,
1114
+ }
1115
+ )
1116
+ if len(results) >= limit_results:
1117
+ stopped_reason = "max_results"
1118
+ break
1119
+ if scanned_total >= limit_bytes:
1120
+ stopped_reason = "max_scan_bytes"
1121
+ break
1122
+ if reason != "completed":
1123
+ stopped_reason = reason
1124
+ if reason == "max_results":
1125
+ break
1126
+ frontier = next_frontier
1127
+ return {
1128
+ "pid": pid,
1129
+ "target_address": hex(target),
1130
+ "pointer_size": ptr_size,
1131
+ "max_depth": depth_limit,
1132
+ "max_offset": offset_limit,
1133
+ "writable_only": writable_only,
1134
+ "scanned_bytes": scanned_total,
1135
+ "stopped_reason": stopped_reason,
1136
+ "result_count": len(results),
1137
+ "chains": results[:limit_results],
1138
+ }
1139
+
1140
+ def pointer_resolve(
1141
+ self,
1142
+ pid: int,
1143
+ *,
1144
+ base_address: int | str | None = None,
1145
+ offsets: list[Any] | None = None,
1146
+ module_path: str | None = None,
1147
+ module_offset: int | str | None = None,
1148
+ pointer_size: int | None = None,
1149
+ endian: str = "little",
1150
+ ) -> dict[str, Any]:
1151
+ if not self.backend.available:
1152
+ return {"error": f"pointer_resolve unavailable on {platform.system()}"}
1153
+ ptr_size = pointer_size or struct.calcsize("P")
1154
+ if ptr_size not in {4, 8}:
1155
+ return {"error": "pointer_size must be 4 or 8"}
1156
+ try:
1157
+ base = self._resolve_base_address(
1158
+ pid,
1159
+ base_address=base_address,
1160
+ module_path=module_path,
1161
+ module_offset=module_offset,
1162
+ )
1163
+ except Exception as exc:
1164
+ return {"pid": pid, "error": str(exc)}
1165
+ parsed_offsets = [parse_address(v) for v in offsets or []]
1166
+ current = base
1167
+ steps: list[dict[str, Any]] = []
1168
+ for offset in parsed_offsets:
1169
+ try:
1170
+ ptr_bytes = self._read_memory(pid, current, ptr_size)
1171
+ except Exception as exc:
1172
+ return {"pid": pid, "base_address": hex(base), "error": str(exc), "steps": steps}
1173
+ pointee = int.from_bytes(ptr_bytes, endian)
1174
+ next_address = pointee + offset
1175
+ steps.append(
1176
+ {
1177
+ "read_address": hex(current),
1178
+ "pointer_value": hex(pointee),
1179
+ "offset": hex(offset),
1180
+ "next_address": hex(next_address),
1181
+ }
1182
+ )
1183
+ current = next_address
1184
+ return {
1185
+ "pid": pid,
1186
+ "base_address": hex(base),
1187
+ "offsets": [hex(v) for v in parsed_offsets],
1188
+ "address": hex(current),
1189
+ "steps": steps,
1190
+ }
1191
+
1192
+ def code_read(self, pid: int, *, address: int | str, size: int) -> dict[str, Any]:
1193
+ return self.read(pid, address=address, size=size)
1194
+
1195
+ def code_patch(
1196
+ self,
1197
+ pid: int,
1198
+ *,
1199
+ address: int | str,
1200
+ patch_hex: str,
1201
+ expected_hex: str | None = None,
1202
+ patch_id: str | None = None,
1203
+ ) -> dict[str, Any]:
1204
+ parsed_address = parse_address(address)
1205
+ try:
1206
+ patch = encode_value("bytes", value_hex=patch_hex)
1207
+ expected = encode_value("bytes", value_hex=expected_hex) if expected_hex else None
1208
+ except ValueError as exc:
1209
+ return {"pid": pid, "address": hex(parsed_address), "error": str(exc)}
1210
+ if not patch:
1211
+ return {"pid": pid, "address": hex(parsed_address), "error": "patch_hex encoded to empty bytes"}
1212
+ max_patch = int(self.config.get("max_code_patch_bytes", 64))
1213
+ if len(patch) > max_patch:
1214
+ return {"pid": pid, "address": hex(parsed_address), "error": f"patch size {len(patch)} exceeds max_code_patch_bytes={max_patch}"}
1215
+ if expected is not None and len(expected) != len(patch):
1216
+ return {"pid": pid, "address": hex(parsed_address), "error": "expected_hex length must match patch_hex length"}
1217
+ saved_patch_id = patch_id or self._new_patch_id()
1218
+ if saved_patch_id in self._patches:
1219
+ return {"pid": pid, "address": hex(parsed_address), "error": f"patch_id is already active: {saved_patch_id}"}
1220
+ overlap = self._overlapping_patch(pid, parsed_address, len(patch))
1221
+ if overlap is not None:
1222
+ return {
1223
+ "pid": pid,
1224
+ "address": hex(parsed_address),
1225
+ "error": f"patch overlaps active patch: {overlap}",
1226
+ }
1227
+ write_attempted = False
1228
+ old = b""
1229
+ try:
1230
+ old = self._read_memory(pid, parsed_address, len(patch))
1231
+ if len(old) != len(patch):
1232
+ return {
1233
+ "pid": pid,
1234
+ "address": hex(parsed_address),
1235
+ "error": f"could not read all original bytes: expected {len(patch)}, got {len(old)}",
1236
+ }
1237
+ if expected is not None and old != expected:
1238
+ return {
1239
+ "pid": pid,
1240
+ "address": hex(parsed_address),
1241
+ "error": "expected bytes did not match current process memory",
1242
+ "expected_hex": expected.hex(),
1243
+ "actual_hex": old.hex(),
1244
+ }
1245
+ token, protect_error = self._try_protect_memory(
1246
+ pid,
1247
+ parsed_address,
1248
+ len(patch),
1249
+ writable=True,
1250
+ executable=True,
1251
+ )
1252
+ try:
1253
+ write_attempted = True
1254
+ self._write_memory(pid, parsed_address, patch)
1255
+ flush_error = self._try_flush_instruction_cache(pid, parsed_address, len(patch))
1256
+ verify = self._read_memory(pid, parsed_address, len(patch))
1257
+ except Exception as exc:
1258
+ if protect_error is not None:
1259
+ raise RuntimeError(f"{exc}; protection change also failed: {protect_error}") from exc
1260
+ raise
1261
+ finally:
1262
+ if token is not None:
1263
+ self._restore_protection(pid, parsed_address, len(patch), token)
1264
+ except Exception as exc:
1265
+ if write_attempted:
1266
+ record = {
1267
+ "patch_id": saved_patch_id,
1268
+ "pid": pid,
1269
+ "address": hex(parsed_address),
1270
+ "old_hex": old.hex(),
1271
+ "patch_hex": patch.hex(),
1272
+ "verified": False,
1273
+ "active": True,
1274
+ "error": f"patch write may have succeeded; restore record retained: {exc}",
1275
+ }
1276
+ self._patches[saved_patch_id] = record
1277
+ return dict(record)
1278
+ return {"pid": pid, "address": hex(parsed_address), "error": str(exc)}
1279
+ record = {
1280
+ "patch_id": saved_patch_id,
1281
+ "pid": pid,
1282
+ "address": hex(parsed_address),
1283
+ "old_hex": old.hex(),
1284
+ "patch_hex": patch.hex(),
1285
+ "verify_hex": verify.hex(),
1286
+ "verified": verify == patch,
1287
+ "active": verify != old,
1288
+ }
1289
+ if flush_error is not None:
1290
+ record["flush_warning"] = str(flush_error)
1291
+ if verify != patch:
1292
+ record["error"] = "patch verification failed; restore record retained"
1293
+ if verify != old:
1294
+ self._patches[saved_patch_id] = record
1295
+ else:
1296
+ record["recovery_not_needed"] = True
1297
+ return dict(record)
1298
+ self._patches[saved_patch_id] = record
1299
+ return dict(record)
1300
+
1301
+ def code_patches(self) -> dict[str, Any]:
1302
+ return {"patches": list(self._patches.values())}
1303
+
1304
+ def code_restore(
1305
+ self,
1306
+ *,
1307
+ patch_id: str | None = None,
1308
+ all_patches: bool = False,
1309
+ ) -> dict[str, Any]:
1310
+ if all_patches:
1311
+ target_ids = list(self._patches)
1312
+ elif patch_id:
1313
+ target_ids = [patch_id]
1314
+ else:
1315
+ return {"error": "patch_id is required unless all_patches=true"}
1316
+ restored: list[dict[str, Any]] = []
1317
+ missing: list[str] = []
1318
+ for target_id in reversed(target_ids):
1319
+ record = self._patches.get(target_id)
1320
+ if not record:
1321
+ missing.append(target_id)
1322
+ continue
1323
+ pid = int(record["pid"])
1324
+ address = parse_address(record["address"])
1325
+ old = bytes.fromhex(str(record["old_hex"]))
1326
+ patch = bytes.fromhex(str(record["patch_hex"]))
1327
+ try:
1328
+ if record.get("verified", False):
1329
+ current = self._read_memory(pid, address, len(patch))
1330
+ if current != patch:
1331
+ restored.append(
1332
+ {
1333
+ **record,
1334
+ "restored": False,
1335
+ "error": "current bytes no longer match the recorded patch",
1336
+ "actual_hex": current.hex(),
1337
+ }
1338
+ )
1339
+ continue
1340
+ token, protect_error = self._try_protect_memory(
1341
+ pid,
1342
+ address,
1343
+ len(old),
1344
+ writable=True,
1345
+ executable=True,
1346
+ )
1347
+ try:
1348
+ self._write_memory(pid, address, old)
1349
+ flush_error = self._try_flush_instruction_cache(pid, address, len(old))
1350
+ verify = self._read_memory(pid, address, len(old))
1351
+ except Exception as exc:
1352
+ if protect_error is not None:
1353
+ raise RuntimeError(f"{exc}; protection change also failed: {protect_error}") from exc
1354
+ raise
1355
+ finally:
1356
+ if token is not None:
1357
+ self._restore_protection(pid, address, len(old), token)
1358
+ except Exception as exc:
1359
+ restored.append({**record, "restored": False, "error": str(exc)})
1360
+ continue
1361
+ restore_verified = verify == old
1362
+ if restore_verified:
1363
+ record["active"] = False
1364
+ restored_record = {**record, "restored": restore_verified, "verify_hex": verify.hex()}
1365
+ if flush_error is not None:
1366
+ restored_record["flush_warning"] = str(flush_error)
1367
+ if not restore_verified:
1368
+ restored_record["error"] = "restore verification failed; restore record retained"
1369
+ restored.append(restored_record)
1370
+ continue
1371
+ restored.append(restored_record)
1372
+ self._patches.pop(target_id, None)
1373
+ return {"restored": restored, "missing": missing}
1374
+
1375
+ def _new_patch_id(self) -> str:
1376
+ while True:
1377
+ patch_id = f"patch-{uuid.uuid4().hex[:10]}"
1378
+ if patch_id not in self._patches:
1379
+ return patch_id
1380
+
1381
+ def _overlapping_patch(self, pid: int, address: int, size: int) -> str | None:
1382
+ end = address + size
1383
+ for patch_id, record in self._patches.items():
1384
+ if int(record["pid"]) != pid:
1385
+ continue
1386
+ existing_start = parse_address(record["address"])
1387
+ existing_end = existing_start + len(bytes.fromhex(str(record["patch_hex"])))
1388
+ if address < existing_end and existing_start < end:
1389
+ return patch_id
1390
+ return None
1391
+
1392
+ @staticmethod
1393
+ def _bounded_limit(requested: int | None, maximum: int) -> int:
1394
+ requested_value = maximum if requested is None else int(requested)
1395
+ return min(max(1, requested_value), max(1, int(maximum)))
1396
+
1397
+ @staticmethod
1398
+ def _remember(
1399
+ entries: dict[str, dict[str, Any]],
1400
+ entry_id: str,
1401
+ value: dict[str, Any],
1402
+ maximum: int,
1403
+ ) -> None:
1404
+ limit = max(1, int(maximum))
1405
+ while len(entries) >= limit:
1406
+ entries.pop(next(iter(entries)))
1407
+ entries[entry_id] = value
1408
+
1409
+ def _linux_regions(self, pid: int) -> list[MemoryRegion]:
1410
+ return self.backend.regions(pid)
1411
+
1412
+ def _read_memory(self, pid: int, address: int, size: int) -> bytes:
1413
+ return self.backend.read(pid, address, size)
1414
+
1415
+ def _write_memory(self, pid: int, address: int, data: bytes) -> None:
1416
+ self.backend.write(pid, address, data)
1417
+
1418
+ def _protect_memory(self, pid: int, address: int, size: int, *, writable: bool, executable: bool) -> Any:
1419
+ return self.backend.protect(pid, address, size, writable=writable, executable=executable)
1420
+
1421
+ def _try_protect_memory(self, pid: int, address: int, size: int, *, writable: bool, executable: bool) -> tuple[Any, Exception | None]:
1422
+ try:
1423
+ return self._protect_memory(pid, address, size, writable=writable, executable=executable), None
1424
+ except Exception as exc:
1425
+ return None, exc
1426
+
1427
+ def _restore_protection(self, pid: int, address: int, size: int, token: Any) -> None:
1428
+ self.backend.restore_protection(pid, address, size, token)
1429
+
1430
+ def _flush_instruction_cache(self, pid: int, address: int, size: int) -> None:
1431
+ self.backend.flush_instruction_cache(pid, address, size)
1432
+
1433
+ def _try_flush_instruction_cache(self, pid: int, address: int, size: int) -> Exception | None:
1434
+ try:
1435
+ self._flush_instruction_cache(pid, address, size)
1436
+ except Exception as exc:
1437
+ return exc
1438
+ return None
1439
+
1440
+ def _search_regions(
1441
+ self,
1442
+ pid: int,
1443
+ regions: list[MemoryRegion],
1444
+ needle: bytes,
1445
+ *,
1446
+ limit_results: int,
1447
+ limit_bytes: int,
1448
+ value_type: str,
1449
+ endian: str,
1450
+ ) -> tuple[list[dict[str, Any]], int, str]:
1451
+ results: list[dict[str, Any]] = []
1452
+ scanned = 0
1453
+ stopped_reason = "completed"
1454
+ overlap = max(0, len(needle) - 1)
1455
+ for region in regions:
1456
+ if scanned >= limit_bytes or len(results) >= limit_results:
1457
+ break
1458
+ region_remaining = min(region.size, limit_bytes - scanned)
1459
+ offset = 0
1460
+ tail = b""
1461
+ while offset < region_remaining:
1462
+ if len(results) >= limit_results:
1463
+ stopped_reason = "max_results"
1464
+ break
1465
+ read_size = min(self.chunk_size, region_remaining - offset)
1466
+ try:
1467
+ chunk = self._read_memory(pid, region.start + offset, read_size)
1468
+ except Exception:
1469
+ offset += read_size
1470
+ scanned += read_size
1471
+ tail = b""
1472
+ continue
1473
+ haystack = tail + chunk
1474
+ base = region.start + offset - len(tail)
1475
+ index = haystack.find(needle)
1476
+ while index != -1:
1477
+ address = base + index
1478
+ if address + len(needle) > region.start + offset:
1479
+ results.append(self._result_entry(address, needle, value_type, endian))
1480
+ if len(results) >= limit_results:
1481
+ stopped_reason = "max_results"
1482
+ break
1483
+ index = haystack.find(needle, index + 1)
1484
+ if stopped_reason == "max_results":
1485
+ break
1486
+ tail = haystack[-overlap:] if overlap else b""
1487
+ offset += read_size
1488
+ scanned += read_size
1489
+ if scanned >= limit_bytes and stopped_reason == "completed":
1490
+ stopped_reason = "max_scan_bytes"
1491
+ return results, scanned, stopped_reason
1492
+
1493
+ def _search_aob_regions(
1494
+ self,
1495
+ pid: int,
1496
+ regions: list[MemoryRegion],
1497
+ pattern: list[int | None],
1498
+ *,
1499
+ limit_results: int,
1500
+ limit_bytes: int,
1501
+ ) -> tuple[list[dict[str, Any]], int, str]:
1502
+ results: list[dict[str, Any]] = []
1503
+ scanned = 0
1504
+ stopped_reason = "completed"
1505
+ overlap = max(0, len(pattern) - 1)
1506
+ for region in regions:
1507
+ if scanned >= limit_bytes or len(results) >= limit_results:
1508
+ break
1509
+ region_remaining = min(region.size, limit_bytes - scanned)
1510
+ offset = 0
1511
+ tail = b""
1512
+ while offset < region_remaining:
1513
+ if len(results) >= limit_results:
1514
+ stopped_reason = "max_results"
1515
+ break
1516
+ read_size = min(self.chunk_size, region_remaining - offset)
1517
+ try:
1518
+ chunk = self._read_memory(pid, region.start + offset, read_size)
1519
+ except Exception:
1520
+ offset += read_size
1521
+ scanned += read_size
1522
+ tail = b""
1523
+ continue
1524
+ haystack = tail + chunk
1525
+ base = region.start + offset - len(tail)
1526
+ for index in find_aob_matches(haystack, pattern):
1527
+ address = base + index
1528
+ if address + len(pattern) <= region.start + offset:
1529
+ continue
1530
+ matched = haystack[index : index + len(pattern)]
1531
+ results.append(
1532
+ {
1533
+ "address": hex(address),
1534
+ "matched_hex": matched.hex(),
1535
+ "region": region.to_dict(),
1536
+ }
1537
+ )
1538
+ if len(results) >= limit_results:
1539
+ stopped_reason = "max_results"
1540
+ break
1541
+ if stopped_reason == "max_results":
1542
+ break
1543
+ tail = haystack[-overlap:] if overlap else b""
1544
+ offset += read_size
1545
+ scanned += read_size
1546
+ if scanned >= limit_bytes and stopped_reason == "completed":
1547
+ stopped_reason = "max_scan_bytes"
1548
+ return results, scanned, stopped_reason
1549
+
1550
+ def _scan_pointer_level(
1551
+ self,
1552
+ pid: int,
1553
+ regions: list[MemoryRegion],
1554
+ target_nodes: list[dict[str, Any]],
1555
+ *,
1556
+ pointer_size: int,
1557
+ align: int,
1558
+ max_offset: int,
1559
+ max_results: int,
1560
+ max_scan_bytes: int,
1561
+ endian: str,
1562
+ module_bases: dict[str, int],
1563
+ ) -> tuple[list[dict[str, Any]], int, str]:
1564
+ candidates: list[dict[str, Any]] = []
1565
+ scanned = 0
1566
+ stopped_reason = "completed"
1567
+ for region in regions:
1568
+ if scanned >= max_scan_bytes or len(candidates) >= max_results:
1569
+ break
1570
+ region_remaining = min(region.size, max_scan_bytes - scanned)
1571
+ offset = 0
1572
+ while offset + pointer_size <= region_remaining:
1573
+ read_size = min(self.chunk_size, region_remaining - offset)
1574
+ try:
1575
+ chunk = self._read_memory(pid, region.start + offset, read_size)
1576
+ except Exception:
1577
+ offset += read_size
1578
+ scanned += read_size
1579
+ continue
1580
+ for chunk_offset in range(0, max(0, len(chunk) - pointer_size + 1), align):
1581
+ pointer_address = region.start + offset + chunk_offset
1582
+ pointer_value = int.from_bytes(chunk[chunk_offset : chunk_offset + pointer_size], endian)
1583
+ for node in target_nodes:
1584
+ target = int(node["target"])
1585
+ delta = target - pointer_value
1586
+ if 0 <= delta <= max_offset:
1587
+ offsets = [delta, *[parse_address(v) for v in node["offsets"]]]
1588
+ module_ref = self._module_reference(pointer_address, region, module_bases)
1589
+ candidates.append(
1590
+ {
1591
+ "base_address": hex(pointer_address),
1592
+ "base_module": module_ref,
1593
+ "offsets": [hex(v) for v in offsets],
1594
+ "depth": len(offsets),
1595
+ "points_to": hex(target),
1596
+ "pointer_value": hex(pointer_value),
1597
+ }
1598
+ )
1599
+ if len(candidates) >= max_results:
1600
+ return candidates, scanned, "max_results"
1601
+ offset += read_size
1602
+ scanned += read_size
1603
+ if scanned >= max_scan_bytes:
1604
+ stopped_reason = "max_scan_bytes"
1605
+ return candidates, scanned, stopped_reason
1606
+
1607
+ @staticmethod
1608
+ def _module_bases(regions: list[MemoryRegion]) -> dict[str, int]:
1609
+ bases: dict[str, int] = {}
1610
+ for region in regions:
1611
+ if not region.path or region.path.startswith("["):
1612
+ continue
1613
+ bases[region.path] = min(region.start, bases.get(region.path, region.start))
1614
+ return bases
1615
+
1616
+ @staticmethod
1617
+ def _module_reference(
1618
+ address: int,
1619
+ region: MemoryRegion,
1620
+ module_bases: dict[str, int],
1621
+ ) -> dict[str, Any] | None:
1622
+ if not region.path or region.path.startswith("["):
1623
+ return None
1624
+ base = module_bases.get(region.path, region.start)
1625
+ return {
1626
+ "path": region.path,
1627
+ "base": hex(base),
1628
+ "offset": hex(address - base),
1629
+ }
1630
+
1631
+ def _resolve_base_address(
1632
+ self,
1633
+ pid: int,
1634
+ *,
1635
+ base_address: int | str | None,
1636
+ module_path: str | None,
1637
+ module_offset: int | str | None,
1638
+ ) -> int:
1639
+ if base_address is not None:
1640
+ return parse_address(base_address)
1641
+ if not module_path or module_offset is None:
1642
+ raise ValueError("base_address or module_path+module_offset is required")
1643
+ offset = parse_address(module_offset)
1644
+ matches = [
1645
+ region
1646
+ for region in self._linux_regions(pid)
1647
+ if region.path and module_path in region.path
1648
+ ]
1649
+ if not matches:
1650
+ raise ValueError(f"module not found: {module_path}")
1651
+ base = min(region.start for region in matches)
1652
+ return base + offset
1653
+
1654
+ @staticmethod
1655
+ def _result_entry(address: int, data: bytes, value_type: str, endian: str) -> dict[str, Any]:
1656
+ entry: dict[str, Any] = {
1657
+ "address": hex(address),
1658
+ "bytes_hex": data.hex(),
1659
+ }
1660
+ decoded = decode_value(value_type, data, endian=endian)
1661
+ if decoded is not None:
1662
+ entry["value"] = decoded
1663
+ return entry
1664
+
1665
+ @staticmethod
1666
+ def _matches_refine(
1667
+ *,
1668
+ comparison: str,
1669
+ old: bytes,
1670
+ current: bytes,
1671
+ expected: bytes | None,
1672
+ value_type: str,
1673
+ endian: str,
1674
+ ) -> bool:
1675
+ if comparison == "equals":
1676
+ return expected is not None and current == expected
1677
+ if comparison == "changed":
1678
+ return current != old
1679
+ if comparison == "unchanged":
1680
+ return current == old
1681
+ old_value = decode_value(value_type, old, endian=endian)
1682
+ current_value = decode_value(value_type, current, endian=endian)
1683
+ if not isinstance(old_value, (int, float)) or not isinstance(current_value, (int, float)):
1684
+ return False
1685
+ if comparison == "increased":
1686
+ return current_value > old_value
1687
+ if comparison == "decreased":
1688
+ return current_value < old_value
1689
+ return False
1690
+
1691
+ @staticmethod
1692
+ def _freeze_snapshot(state: dict[str, Any], *, include_task: bool) -> dict[str, Any]:
1693
+ if include_task:
1694
+ return dict(state)
1695
+ return {key: value for key, value in state.items() if key != "task"}
1696
+
1697
+
1698
+ def parse_address(value: int | str) -> int:
1699
+ if isinstance(value, int):
1700
+ return value
1701
+ text = str(value).strip()
1702
+ if not text:
1703
+ raise ValueError("address is required")
1704
+ return int(text, 0)
1705
+
1706
+
1707
+ def parse_aob_pattern(pattern: str) -> list[int | None]:
1708
+ text = pattern.strip()
1709
+ if not text:
1710
+ return []
1711
+ if re.search(r"\s", text):
1712
+ tokens = re.split(r"[\s,]+", text)
1713
+ else:
1714
+ compact = text.replace("0x", "").replace("0X", "")
1715
+ if len(compact) % 2:
1716
+ raise ValueError("compact AOB pattern must contain an even number of hex/wildcard characters")
1717
+ tokens = [compact[i : i + 2] for i in range(0, len(compact), 2)]
1718
+ parsed: list[int | None] = []
1719
+ for token in tokens:
1720
+ if not token:
1721
+ continue
1722
+ token = token.strip()
1723
+ if set(token) <= {"?"}:
1724
+ parsed.append(None)
1725
+ continue
1726
+ if "?" in token:
1727
+ if len(token) != 2:
1728
+ raise ValueError(f"invalid wildcard token: {token}")
1729
+ parsed.append(None)
1730
+ continue
1731
+ parsed.append(int(token.removeprefix("0x").removeprefix("0X"), 16))
1732
+ return parsed
1733
+
1734
+
1735
+ def find_aob_matches(data: bytes, pattern: list[int | None]) -> list[int]:
1736
+ if not pattern or len(data) < len(pattern):
1737
+ return []
1738
+ matches: list[int] = []
1739
+ width = len(pattern)
1740
+ first = pattern[0]
1741
+ start = 0
1742
+ while start <= len(data) - width:
1743
+ if first is None:
1744
+ index = start
1745
+ else:
1746
+ index = data.find(bytes([first]), start, len(data) - width + 1)
1747
+ if index == -1:
1748
+ break
1749
+ for pattern_index, expected in enumerate(pattern):
1750
+ if expected is not None and data[index + pattern_index] != expected:
1751
+ break
1752
+ else:
1753
+ matches.append(index)
1754
+ start = index + 1
1755
+ return matches
1756
+
1757
+
1758
+ def encode_value(
1759
+ value_type: str,
1760
+ *,
1761
+ value: Any = None,
1762
+ value_hex: str | None = None,
1763
+ endian: str = "little",
1764
+ ) -> bytes:
1765
+ normalized = value_type.lower()
1766
+ if normalized == "bytes":
1767
+ raw = value_hex if value_hex is not None else str(value or "")
1768
+ cleaned = re.sub(r"0[xX]", "", raw.strip())
1769
+ invalid = re.search(r"[^0-9a-fA-F\s,;:_-]", cleaned)
1770
+ if invalid:
1771
+ raise ValueError(f"invalid hexadecimal character: {invalid.group(0)!r}")
1772
+ cleaned = re.sub(r"[\s,;:_-]", "", cleaned)
1773
+ if len(cleaned) % 2:
1774
+ raise ValueError("hexadecimal byte strings must contain an even number of digits")
1775
+ return bytes.fromhex(cleaned)
1776
+ if normalized == "string":
1777
+ return str(value or "").encode("utf-8")
1778
+ fmt = _NUMERIC_FORMATS.get(normalized)
1779
+ if not fmt:
1780
+ raise ValueError(f"unsupported value_type: {value_type}")
1781
+ prefix = "<" if endian.lower() == "little" else ">"
1782
+ if normalized.startswith("float"):
1783
+ typed_value: int | float = float(value)
1784
+ else:
1785
+ typed_value = int(str(value), 0)
1786
+ return struct.pack(prefix + fmt, typed_value)
1787
+
1788
+
1789
+ def decode_value(value_type: str, data: bytes, *, endian: str = "little") -> Any:
1790
+ normalized = value_type.lower()
1791
+ if normalized == "bytes":
1792
+ return data.hex()
1793
+ if normalized == "string":
1794
+ return data.decode("utf-8", errors="replace")
1795
+ fmt = _NUMERIC_FORMATS.get(normalized)
1796
+ if not fmt:
1797
+ return None
1798
+ prefix = "<" if endian.lower() == "little" else ">"
1799
+ size = struct.calcsize(prefix + fmt)
1800
+ if len(data) < size:
1801
+ return None
1802
+ return struct.unpack(prefix + fmt, data[:size])[0]
1803
+
1804
+
1805
+ def _ascii_preview(data: bytes) -> str:
1806
+ return "".join(chr(b) if 32 <= b < 127 else "." for b in data)