rea-agents 0.2.1

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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -0
  3. package/bridge/hopper_bridge.py +557 -0
  4. package/dist/application/AnalysisProvider.js +1 -0
  5. package/dist/application/BinarySession.js +173 -0
  6. package/dist/application/DirectAnalysis.js +31 -0
  7. package/dist/application/Doctor.js +110 -0
  8. package/dist/application/EnhancedTools.js +357 -0
  9. package/dist/application/EvidenceLedger.js +65 -0
  10. package/dist/application/HopperToolPort.js +1 -0
  11. package/dist/application/LoopbackReplay.js +84 -0
  12. package/dist/application/ProcessHarness.js +347 -0
  13. package/dist/application/Setup.js +240 -0
  14. package/dist/application/homebrew.js +19 -0
  15. package/dist/application/runtime.js +11 -0
  16. package/dist/cli.js +88 -0
  17. package/dist/config.js +86 -0
  18. package/dist/contracts/enhancedInputs.js +73 -0
  19. package/dist/contracts/toolContracts.js +144 -0
  20. package/dist/contracts/toolOutputSchemas.js +267 -0
  21. package/dist/domain/binaryTarget.js +321 -0
  22. package/dist/domain/errors.js +81 -0
  23. package/dist/domain/evidence.js +146 -0
  24. package/dist/domain/evidenceBundle.js +11 -0
  25. package/dist/domain/hopperValues.js +253 -0
  26. package/dist/domain/jsonValue.js +3 -0
  27. package/dist/domain/processCapture.js +252 -0
  28. package/dist/domain/result.js +4 -0
  29. package/dist/domain/symbolAnalysis.js +68 -0
  30. package/dist/hopper/BridgeLauncher.js +130 -0
  31. package/dist/hopper/HopperClient.js +314 -0
  32. package/dist/hopper/HopperProvider.js +66 -0
  33. package/dist/hopper/protocol.js +32 -0
  34. package/dist/identity.js +14 -0
  35. package/dist/logger.js +27 -0
  36. package/dist/main.js +81 -0
  37. package/dist/server/createServer.js +30 -0
  38. package/dist/server/registerEnhancedTools.js +53 -0
  39. package/dist/server/registerOfficialTools.js +59 -0
  40. package/dist/server/registerSessionTools.js +159 -0
  41. package/dist/server/toolLogging.js +11 -0
  42. package/dist/server/toolResult.js +44 -0
  43. package/package.json +109 -0
  44. package/scripts/rea.mjs +15 -0
  45. package/scripts/rebuild-native.mjs +22 -0
  46. package/skills/rea-analysis/SKILL.md +44 -0
@@ -0,0 +1,557 @@
1
+ """Authenticated REA adapter executed on Hopper's dedicated Python thread.
2
+
3
+ The bootstrap injects ``REA_SOCKET`` and a random ``REA_TOKEN`` before executing
4
+ this file with Hopper's supported ``--python`` launcher option. Keep all Hopper
5
+ API access on this thread: moving dispatch to a worker can deadlock Hopper.
6
+ """
7
+
8
+ import json
9
+ import hmac
10
+ import os
11
+ import re
12
+ import socket
13
+
14
+ MAX_LINE_BYTES = 10 * 1024 * 1024
15
+ BAD_ADDRESSES = (-1, 0xFFFFFFFFFFFFFFFF, None)
16
+ _selected_document = None
17
+
18
+
19
+ def _hex(value):
20
+ return "0x%x" % value
21
+
22
+
23
+ def _json_safe(value):
24
+ """Project Hopper-specific Python values into the JSON protocol boundary."""
25
+ if value is None or isinstance(value, (bool, int, float, str)):
26
+ return value
27
+ if isinstance(value, (list, tuple)):
28
+ return [_json_safe(item) for item in value]
29
+ if isinstance(value, dict):
30
+ return {str(key): _json_safe(item) for key, item in value.items()}
31
+ return str(value)
32
+
33
+
34
+ def _document(name=None):
35
+ """Resolve an explicit or session-selected document without changing Hopper UI."""
36
+ global _selected_document
37
+ documents = Document.getAllDocuments()
38
+ if name is not None:
39
+ for candidate in documents:
40
+ if candidate.getDocumentName() == name:
41
+ return candidate
42
+ raise ValueError("Unknown Hopper document")
43
+ if _selected_document is not None:
44
+ for candidate in documents:
45
+ if candidate.getDocumentName() == _selected_document:
46
+ return candidate
47
+ current = Document.getCurrentDocument()
48
+ if current is None:
49
+ raise ValueError("No Hopper document is loaded")
50
+ return current
51
+
52
+
53
+ def _address(document, value=None):
54
+ """Resolve hexadecimal addresses first, then fall back to Hopper symbol names."""
55
+ if value is None:
56
+ return document.getCurrentAddress()
57
+ if not isinstance(value, str):
58
+ raise ValueError("Address must be a string")
59
+ try:
60
+ return int(value, 16)
61
+ except ValueError:
62
+ result = document.getAddressForName(value)
63
+ if result in BAD_ADDRESSES:
64
+ raise ValueError("Unknown Hopper address or name")
65
+ return result
66
+
67
+
68
+ def _segment(document, address):
69
+ result = document.getSegmentAtAddress(address)
70
+ if result is None:
71
+ raise ValueError("Address is outside every segment")
72
+ return result
73
+
74
+
75
+ def _procedure(document, value=None):
76
+ address = document.getCurrentAddress() if value is None else _address(document, value)
77
+ result = _segment(document, address).getProcedureAtAddress(address)
78
+ if result is None:
79
+ raise ValueError("No procedure exists at the requested address")
80
+ return result
81
+
82
+
83
+ def _procedure_name(procedure):
84
+ entry = procedure.getEntryPoint()
85
+ return procedure.getSegment().getNameAtAddress(entry) or _hex(entry)
86
+
87
+
88
+ def _procedure_identity(procedure):
89
+ return {"address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure)}
90
+
91
+
92
+ def _containing_procedure(document, address):
93
+ segment = document.getSegmentAtAddress(address)
94
+ if segment is None:
95
+ return None, "outside_segments"
96
+ procedure = segment.getProcedureAtAddress(address)
97
+ return (procedure, None) if procedure is not None else (None, "not_in_procedure")
98
+
99
+
100
+ def _instruction_addresses(procedure, limit):
101
+ result = []
102
+ seen = set()
103
+ segment = procedure.getSegment()
104
+ truncated = False
105
+ for block in procedure.basicBlockIterator():
106
+ address = block.getStartingAddress()
107
+ end = block.getEndingAddress()
108
+ while address < end and address not in seen:
109
+ if len(result) >= limit:
110
+ truncated = True
111
+ return result, truncated
112
+ seen.add(address)
113
+ instruction = segment.getInstructionAtAddress(address)
114
+ if instruction is None:
115
+ break
116
+ result.append(address)
117
+ length = instruction.getInstructionLength()
118
+ if length <= 0:
119
+ break
120
+ address += length
121
+ return result, truncated
122
+
123
+
124
+ def _procedure_references(document, params):
125
+ procedure = _procedure(document, params.get("procedure"))
126
+ direction = params.get("direction", "outgoing")
127
+ offset = params.get("offset", 0)
128
+ limit = params.get("limit", 100)
129
+ max_instructions = params.get("max_instructions", 500)
130
+ if direction not in ("incoming", "outgoing"):
131
+ raise ValueError("direction must be incoming or outgoing")
132
+ if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
133
+ raise ValueError("offset must be a non-negative integer")
134
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1 or limit > 500:
135
+ raise ValueError("limit must be an integer between 1 and 500")
136
+ if not isinstance(max_instructions, int) or isinstance(max_instructions, bool) or max_instructions < 1 or max_instructions > 5000:
137
+ raise ValueError("max_instructions must be an integer between 1 and 5000")
138
+ addresses, scan_truncated = _instruction_addresses(procedure, max_instructions)
139
+ edges = set()
140
+ for address in addresses:
141
+ segment = _segment(document, address)
142
+ references = segment.getReferencesFromAddress(address) if direction == "outgoing" else segment.getReferencesOfAddress(address)
143
+ for reference in references:
144
+ edges.add((address, reference) if direction == "outgoing" else (reference, address))
145
+ ordered = sorted(edges)
146
+ items = []
147
+ selected = ordered[offset:offset + limit]
148
+ for source, target in selected:
149
+ source_procedure, _ = _containing_procedure(document, source)
150
+ target_procedure, _ = _containing_procedure(document, target)
151
+ items.append({
152
+ "source_address": _hex(source),
153
+ "target_address": _hex(target),
154
+ "source_procedure": _procedure_identity(source_procedure) if source_procedure is not None else None,
155
+ "target_procedure": _procedure_identity(target_procedure) if target_procedure is not None else None,
156
+ "kind": _unavailable("Hopper's public Python API does not classify reference kinds"),
157
+ })
158
+ next_offset = offset + len(selected)
159
+ has_more = next_offset < len(ordered)
160
+ truncated = scan_truncated or has_more
161
+ return {
162
+ "procedure": _procedure_identity(procedure), "direction": direction,
163
+ "references": {"items": items, "total": None if scan_truncated else len(ordered), "returned": len(items), "truncated": truncated, "next_offset": next_offset if has_more and not scan_truncated else None},
164
+ "instructions_scanned": len(addresses), "instruction_scan_truncated": scan_truncated,
165
+ }
166
+
167
+
168
+ def _procedure_map(document):
169
+ result = {}
170
+ for segment in document.getSegmentsList():
171
+ for index in range(segment.getProcedureCount()):
172
+ procedure = segment.getProcedureAtIndex(index)
173
+ result[_hex(procedure.getEntryPoint())] = _procedure_name(procedure)
174
+ return result
175
+
176
+
177
+ def _strings(document):
178
+ result = {}
179
+ for segment in document.getSegmentsList():
180
+ for value, address in segment.getStringsList():
181
+ result[_hex(address)] = value
182
+ return result
183
+
184
+
185
+ def _page(values, offset, limit):
186
+ """Return one deterministically address-sorted page without crossing an unbounded map."""
187
+ if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
188
+ raise ValueError("offset must be a non-negative integer")
189
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1 or limit > 500:
190
+ raise ValueError("limit must be an integer between 1 and 500")
191
+ ordered = sorted(values.items(), key=lambda item: int(item[0], 16))
192
+ selected = ordered[offset:offset + limit]
193
+ total = len(ordered)
194
+ next_offset = offset + len(selected)
195
+ has_more = next_offset < total
196
+ return {
197
+ "items": [{"address": address, "value": value} for address, value in selected],
198
+ "offset": offset,
199
+ "limit": limit,
200
+ "total": total,
201
+ "next_offset": next_offset if has_more else None,
202
+ "has_more": has_more,
203
+ }
204
+
205
+
206
+ def _unavailable(reason):
207
+ """Describe evidence the public Hopper API cannot truthfully provide."""
208
+ return {"available": False, "reason": reason}
209
+
210
+
211
+ def _offset(params, name):
212
+ value = params.get(name, 0)
213
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
214
+ raise ValueError("%s must be a non-negative integer" % name)
215
+ return value
216
+
217
+
218
+ def _collection_offset(params, name):
219
+ values = params.get("collection_offset", {})
220
+ if not isinstance(values, dict):
221
+ raise ValueError("collection_offset must be an object")
222
+ value = values.get(name, 0)
223
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
224
+ raise ValueError("collection_offset.%s must be a non-negative integer" % name)
225
+ return value
226
+
227
+
228
+ def _bounded(items, offset, limit, total=None, scan_truncated=False):
229
+ selected = items[offset:offset + limit]
230
+ known_total = len(items) if total is None and not scan_truncated else total
231
+ has_more = offset + len(selected) < len(items)
232
+ return {
233
+ "items": selected,
234
+ "total": known_total,
235
+ "returned": len(selected),
236
+ "truncated": scan_truncated or has_more,
237
+ "next_offset": offset + len(selected) if has_more else None,
238
+ }
239
+
240
+
241
+ def _name_map(document):
242
+ result = {}
243
+ for segment in document.getSegmentsList():
244
+ for address in segment.getNamedAddresses():
245
+ name = segment.getNameAtAddress(address)
246
+ if name is not None:
247
+ result[address] = name
248
+ return result
249
+
250
+
251
+ def _assembly(procedure, limit=None):
252
+ """Render bounded assembly while guarding against malformed instruction cycles."""
253
+ lines = []
254
+ segment = procedure.getSegment()
255
+ seen = set()
256
+ for block in procedure.basicBlockIterator():
257
+ address = block.getStartingAddress()
258
+ end = block.getEndingAddress()
259
+ while address < end and address not in seen:
260
+ seen.add(address)
261
+ instruction = segment.getInstructionAtAddress(address)
262
+ if instruction is None:
263
+ break
264
+ arguments = [instruction.getFormattedArgument(index) for index in range(instruction.getArgumentCount())]
265
+ suffix = ", ".join(value for value in arguments if value is not None)
266
+ lines.append("%s: %s%s" % (_hex(address), instruction.getInstructionString(), (" " + suffix) if suffix else ""))
267
+ if limit is not None and len(lines) >= limit:
268
+ return "\n".join(lines)
269
+ length = instruction.getInstructionLength()
270
+ if length <= 0:
271
+ break
272
+ address += length
273
+ return "\n".join(lines)
274
+
275
+
276
+ def _analyze_function(document, params):
277
+ """Collect a bounded, single-pass function dossier for agent callers."""
278
+ procedure = _procedure(document, params.get("procedure"))
279
+ limit = params.get("limit", 100)
280
+ max_chars = params.get("max_pseudocode_chars", 20000)
281
+ max_instructions = params.get("max_instructions", 500)
282
+ pseudocode_offset = _offset(params, "pseudocode_offset")
283
+ assembly_offset = _offset(params, "assembly_offset")
284
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1 or limit > 500:
285
+ raise ValueError("limit must be an integer between 1 and 500")
286
+ if not isinstance(max_chars, int) or isinstance(max_chars, bool) or max_chars < 1 or max_chars > 100000:
287
+ raise ValueError("max_pseudocode_chars must be between 1 and 100000")
288
+ if not isinstance(max_instructions, int) or isinstance(max_instructions, bool) or max_instructions < 1 or max_instructions > 5000:
289
+ raise ValueError("max_instructions must be between 1 and 5000")
290
+ addresses, instruction_scan_truncated = _instruction_addresses(procedure, max_instructions)
291
+ blocks = []
292
+ all_blocks = list(procedure.basicBlockIterator())
293
+ for block in all_blocks:
294
+ successors = []
295
+ for index in range(block.getSuccessorCount()):
296
+ successor = block.getSuccessorAddressAtIndex(index)
297
+ if successor not in BAD_ADDRESSES:
298
+ successors.append(_hex(successor))
299
+ blocks.append({
300
+ "start": _hex(block.getStartingAddress()),
301
+ "end": _hex(block.getEndingAddress()),
302
+ "successors": sorted(set(successors), key=lambda value: int(value, 16)),
303
+ })
304
+ pseudo = procedure.decompile() or ""
305
+ assembly_lines = _assembly(procedure).splitlines() if params.get("include_assembly", False) else []
306
+ callers = sorted((_procedure_identity(item) for item in procedure.getAllCallerProcedures()), key=lambda item: int(item["address"], 16))
307
+ callees = sorted((_procedure_identity(item) for item in procedure.getAllCalleeProcedures()), key=lambda item: int(item["address"], 16))
308
+ comments = []
309
+ edges = set()
310
+ for address in addresses:
311
+ segment = _segment(document, address)
312
+ comment = segment.getCommentAtAddress(address)
313
+ inline_comment = segment.getInlineCommentAtAddress(address)
314
+ if comment:
315
+ comments.append({"address": _hex(address), "kind": "comment", "text": comment})
316
+ if inline_comment:
317
+ comments.append({"address": _hex(address), "kind": "inline", "text": inline_comment})
318
+ for target in segment.getReferencesFromAddress(address):
319
+ edges.add((address, target))
320
+ for source in segment.getReferencesOfAddress(address):
321
+ edges.add((source, address))
322
+ incoming = []
323
+ outgoing = []
324
+ procedure_addresses = set(addresses)
325
+ for source, target in sorted(edges):
326
+ source_procedure, _ = _containing_procedure(document, source)
327
+ target_procedure, _ = _containing_procedure(document, target)
328
+ item = {
329
+ "source_address": _hex(source),
330
+ "target_address": _hex(target),
331
+ "source_procedure": _procedure_identity(source_procedure) if source_procedure is not None else None,
332
+ "target_procedure": _procedure_identity(target_procedure) if target_procedure is not None else None,
333
+ "kind": _unavailable("Hopper's public Python API does not classify reference kinds"),
334
+ }
335
+ if target in procedure_addresses and source not in procedure_addresses:
336
+ incoming.append(item)
337
+ if source in procedure_addresses:
338
+ outgoing.append(item)
339
+ string_map = {int(address, 16): value for address, value in _strings(document).items()}
340
+ name_map = _name_map(document)
341
+ referenced_strings = []
342
+ referenced_names = []
343
+ for edge in outgoing:
344
+ target = int(edge["target_address"], 16)
345
+ if target in string_map:
346
+ referenced_strings.append({"address": edge["target_address"], "value": string_map[target], "source_address": edge["source_address"]})
347
+ if target in name_map:
348
+ referenced_names.append({"address": edge["target_address"], "value": name_map[target], "source_address": edge["source_address"]})
349
+ comments.sort(key=lambda item: (int(item["address"], 16), item["kind"]))
350
+ referenced_strings.sort(key=lambda item: (int(item["address"], 16), int(item["source_address"], 16)))
351
+ referenced_names.sort(key=lambda item: (int(item["address"], 16), int(item["source_address"], 16)))
352
+ pseudo_text = pseudo[pseudocode_offset:pseudocode_offset + max_chars]
353
+ pseudo_next = pseudocode_offset + len(pseudo_text)
354
+ def collection(name, items, scan_limited=False):
355
+ return _bounded(items, _collection_offset(params, name), limit, None, scan_limited)
356
+ return {
357
+ "procedure": {"address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure), "signature": procedure.signatureString(), "locals": _json_safe(procedure.getLocalVariableList())},
358
+ "pseudocode": {"text": pseudo_text, "total_chars": len(pseudo), "returned_chars": len(pseudo_text), "truncated": pseudo_next < len(pseudo), "next_offset": pseudo_next if pseudo_next < len(pseudo) else None},
359
+ "assembly": _bounded(assembly_lines, assembly_offset, max_instructions),
360
+ "comments": collection("comments", comments, instruction_scan_truncated),
361
+ "callers": collection("callers", callers), "callees": collection("callees", callees),
362
+ "incoming_references": collection("incoming_references", incoming, instruction_scan_truncated),
363
+ "outgoing_references": collection("outgoing_references", outgoing, instruction_scan_truncated),
364
+ "referenced_strings": collection("referenced_strings", referenced_strings, instruction_scan_truncated),
365
+ "referenced_names": collection("referenced_names", referenced_names, instruction_scan_truncated),
366
+ "basic_blocks": collection("basic_blocks", blocks),
367
+ "instruction_scan": {"scanned": len(addresses), "truncated": instruction_scan_truncated},
368
+ }
369
+
370
+
371
+ def _dispatch(method, params):
372
+ """Dispatch only the closed operation set implemented by REA's public tools."""
373
+ global _selected_document
374
+ if method == "health":
375
+ return {"name": "REA Hopper bridge", "version": "1.0.0"}
376
+ if method == "shutdown":
377
+ return {"shutdown": True}
378
+ if method == "list_documents":
379
+ return [document.getDocumentName() for document in Document.getAllDocuments()]
380
+ if method == "current_document":
381
+ return _document().getDocumentName()
382
+ if method == "set_current_document":
383
+ document = _document(params.get("document"))
384
+ _selected_document = document.getDocumentName()
385
+ return _selected_document
386
+
387
+ document = _document(params.get("document"))
388
+
389
+ if method == "analyze_function":
390
+ return _analyze_function(document, params)
391
+ if method == "resolve_containing_procedure":
392
+ address = _address(document, params.get("address"))
393
+ procedure, reason = _containing_procedure(document, address)
394
+ if procedure is None:
395
+ return {"query_address": _hex(address), "found": False, "procedure": None, "reason": reason}
396
+ return {"query_address": _hex(address), "found": True, "procedure": _procedure_identity(procedure)}
397
+ if method == "procedure_references":
398
+ return _procedure_references(document, params)
399
+
400
+ if method == "current_address":
401
+ return _hex(document.getCurrentAddress())
402
+ if method == "current_procedure":
403
+ return _procedure_name(_procedure(document))
404
+ if method == "goto_address":
405
+ address = _address(document, params.get("address"))
406
+ document.moveCursorAtAddress(address)
407
+ return _hex(address)
408
+ if method in ("address_name", "comment", "inline_comment", "xrefs"):
409
+ target = _address(document, params.get("address"))
410
+ segment = _segment(document, target)
411
+ if method == "address_name":
412
+ return segment.getNameAtAddress(target)
413
+ if method == "comment":
414
+ return segment.getCommentAtAddress(target)
415
+ if method == "inline_comment":
416
+ return segment.getInlineCommentAtAddress(target)
417
+ return [_hex(value) for value in segment.getReferencesOfAddress(target)]
418
+ if method in ("next_address", "prev_address"):
419
+ target = _address(document, params.get("address"))
420
+ if method == "next_address":
421
+ result = target + max(1, document.getObjectLength(target))
422
+ else:
423
+ result = document.getInstructionStart(max(0, target - 1))
424
+ if result in BAD_ADDRESSES:
425
+ raise ValueError("No adjacent address")
426
+ return _hex(result)
427
+ if method == "list_segments":
428
+ result = []
429
+ for segment in document.getSegmentsList():
430
+ start = segment.getStartingAddress()
431
+ sections = [{"name": section.getName(), "start": _hex(section.getStartingAddress()), "end": _hex(section.getStartingAddress() + section.getLength())} for section in segment.getSectionsList()]
432
+ result.append({
433
+ "name": segment.getName(),
434
+ "start": _hex(start),
435
+ "end": _hex(start + segment.getLength()),
436
+ "writable": None,
437
+ "executable": None,
438
+ "permissions": _unavailable(
439
+ "Hopper's public Python API does not expose segment permissions"
440
+ ),
441
+ "sections": sections,
442
+ })
443
+ return result
444
+ if method == "list_procedures":
445
+ return _page(_procedure_map(document), params.get("offset", 0), params.get("limit", 100))
446
+ if method == "list_strings":
447
+ values = _strings(document)
448
+ requested = params.get("address")
449
+ if requested is not None:
450
+ key = _hex(_address(document, requested))
451
+ values = {key: values[key]} if key in values else {}
452
+ return _page(values, params.get("offset", 0), params.get("limit", 100))
453
+ if method == "list_names":
454
+ result = {}
455
+ for segment in document.getSegmentsList():
456
+ for item in segment.getNamedAddresses():
457
+ result[_hex(item)] = segment.getNameAtAddress(item)
458
+ requested = params.get("address")
459
+ if requested is not None:
460
+ key = _hex(_address(document, requested))
461
+ result = {key: result[key]} if key in result else {}
462
+ return _page(result, params.get("offset", 0), params.get("limit", 100))
463
+ if method in ("search_procedures", "search_strings"):
464
+ flags = 0 if params.get("case_sensitive", False) else re.IGNORECASE
465
+ expression = re.compile(params["pattern"], flags)
466
+ values = _procedure_map(document) if method == "search_procedures" else _strings(document)
467
+ return {key: value for key, value in values.items() if expression.search(value)}
468
+ if method.startswith("procedure_"):
469
+ procedure = _procedure(document, params.get("procedure"))
470
+ if method == "procedure_address":
471
+ return _hex(procedure.getEntryPoint())
472
+ if method == "procedure_assembly":
473
+ return _assembly(procedure)
474
+ if method == "procedure_pseudo_code":
475
+ return procedure.decompile()
476
+ if method == "procedure_callers":
477
+ return [_procedure_name(item) for item in procedure.getAllCallerProcedures()]
478
+ if method == "procedure_callees":
479
+ return [_procedure_name(item) for item in procedure.getAllCalleeProcedures()]
480
+ if method == "procedure_info":
481
+ blocks = list(procedure.basicBlockIterator())
482
+ length = sum(max(0, block.getEndingAddress() - block.getStartingAddress()) for block in blocks)
483
+ return {"name": _procedure_name(procedure), "entrypoint": _hex(procedure.getEntryPoint()), "basicblock_count": procedure.getBasicBlockCount(), "length": length, "signature": procedure.signatureString(), "locals": procedure.getLocalVariableList()}
484
+ if method == "set_address_name":
485
+ address = _address(document, params.get("address"))
486
+ return document.setNameAtAddress(address, params["name"])
487
+ if method == "set_addresses_names":
488
+ return {key: document.setNameAtAddress(_address(document, key), value) for key, value in params["names"].items()}
489
+ if method in ("set_comment", "set_inline_comment"):
490
+ address = _address(document, params.get("address"))
491
+ segment = _segment(document, address)
492
+ setter = segment.setCommentAtAddress if method == "set_comment" else segment.setInlineCommentAtAddress
493
+ getter = segment.getCommentAtAddress if method == "set_comment" else segment.getInlineCommentAtAddress
494
+ setter(address, params["comment"])
495
+ return getter(address) == params["comment"]
496
+ if method == "list_bookmarks":
497
+ return [{"address": _hex(item), "name": document.getBookmarkName(item)} for item in document.getBookmarks()]
498
+ if method == "set_bookmark":
499
+ address = _address(document, params.get("address"))
500
+ document.setBookmarkAtAddress(address, params.get("name"))
501
+ return document.hasBookmarkAtAddress(address)
502
+ if method == "unset_bookmark":
503
+ address = _address(document, params.get("address"))
504
+ document.removeBookmarkAtAddress(address)
505
+ return not document.hasBookmarkAtAddress(address)
506
+ raise ValueError("Unknown bridge method")
507
+
508
+
509
+ def _serve_connection(connection):
510
+ """Serve one size-bounded, capability-authenticated NDJSON connection."""
511
+ file = connection.makefile("rwb")
512
+ while True:
513
+ line = file.readline(MAX_LINE_BYTES + 1)
514
+ if not line or len(line) > MAX_LINE_BYTES:
515
+ break
516
+ request_id = None
517
+ should_stop = False
518
+ try:
519
+ request = json.loads(line.decode("utf-8"))
520
+ if set(request) != {"id", "token", "method", "params"}:
521
+ raise ValueError("Invalid bridge request shape")
522
+ request_id = request["id"]
523
+ if not isinstance(request["token"], str) or not hmac.compare_digest(request["token"], REA_TOKEN):
524
+ raise PermissionError("Invalid bridge capability")
525
+ result = _dispatch(request["method"], request["params"])
526
+ should_stop = request["method"] == "shutdown"
527
+ response = {"id": request_id, "result": _json_safe(result)}
528
+ except Exception as error:
529
+ response = {"id": request_id if isinstance(request_id, int) else 0, "error": {"code": -32000, "message": str(error)[:512]}}
530
+ file.write((json.dumps(response, separators=(",", ":")) + "\n").encode("utf-8"))
531
+ file.flush()
532
+ if should_stop:
533
+ break
534
+ file.close()
535
+ connection.close()
536
+
537
+
538
+ def _run():
539
+ """Own a permission-restricted, single-client Unix socket for this bridge."""
540
+ if os.path.exists(REA_SOCKET):
541
+ os.unlink(REA_SOCKET)
542
+ server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
543
+ server.bind(REA_SOCKET)
544
+ os.chmod(REA_SOCKET, 0o600)
545
+ server.listen(1)
546
+ try:
547
+ connection, _ = server.accept()
548
+ _serve_connection(connection)
549
+ finally:
550
+ server.close()
551
+ if os.path.exists(REA_SOCKET):
552
+ os.unlink(REA_SOCKET)
553
+
554
+
555
+ # Hopper's public objects are bound to its dedicated Python execution thread.
556
+ # Keep dispatch on that thread; moving calls to an arbitrary worker can deadlock.
557
+ _run()
@@ -0,0 +1 @@
1
+ export {};