Merge pull request #46 from morluto/morl/reverse_improvements
Evolve REA into an evidence-backed analysis platform
This commit is contained in:
commit
9f60348d1b
16
README.md
16
README.md
|
|
@ -10,11 +10,11 @@
|
|||
|
||||
[](https://www.npmjs.com/package/@morluto/rea)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#42-tools-for-deeper-investigation)
|
||||
[](#46-tools-for-deeper-investigation)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[Quick start](#quick-start) · [See the workflow](#one-prompt-a-full-investigation) · [From app to feature](#from-app-to-feature) · [42 tools](#42-tools-for-deeper-investigation) · [How it works](#how-it-works) · [FAQ](#faq)
|
||||
[Quick start](#quick-start) · [See the workflow](#one-prompt-a-full-investigation) · [From app to feature](#from-app-to-feature) · [46 tools](#46-tools-for-deeper-investigation) · [How it works](#how-it-works) · [FAQ](#faq)
|
||||
|
||||
<br />
|
||||
|
||||
|
|
@ -147,13 +147,13 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its
|
|||
- Analyze Swift and Objective-C metadata without manually untangling every mangled symbol.
|
||||
- Leave names, comments, and bookmarks in Hopper so human and agent analysis reinforce each other.
|
||||
|
||||
## 42 tools for deeper investigation
|
||||
## 46 tools for deeper investigation
|
||||
|
||||
| Tool family | Count | Examples |
|
||||
| ----------------- | ----: | ------------------------------------------------------------------------------------------------------ |
|
||||
| Binary inspection | 31 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations |
|
||||
| Composed analysis | 8 | `binary_overview`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift and ObjC discovery |
|
||||
| Binary session | 3 | `open_binary`, `binary_session`, `close_binary` |
|
||||
| Tool family | Count | Examples |
|
||||
| ----------------- | ----: | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Binary inspection | 33 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations |
|
||||
| Composed analysis | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift and ObjC discovery |
|
||||
| Binary session | 3 | `open_binary`, `binary_session`, `close_binary` |
|
||||
|
||||
## Using REA with other coding agents
|
||||
|
||||
|
|
|
|||
10
README_ar.md
10
README_ar.md
|
|
@ -10,11 +10,11 @@
|
|||
|
||||
[](https://www.npmjs.com/package/@morluto/rea)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#منصة-من-42-أداة)
|
||||
[](#منصة-من-46-أداة)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [42 أداة](#منصة-من-42-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
|
||||
[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [46 أداة](#منصة-من-46-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
|
||||
|
||||
<br />
|
||||
|
||||
|
|
@ -145,12 +145,12 @@ npx -y @morluto/rea doctor
|
|||
|
||||
افحص التحقق من الإدخال، والتشفير، والصلاحيات، والتخزين، ومسارات الأخطاء من دون رفع الملف التنفيذي إلى خدمة بعيدة.
|
||||
|
||||
## منصة من 42 أداة
|
||||
## منصة من 46 أداة
|
||||
|
||||
| عائلة الأدوات | العدد | أمثلة |
|
||||
| --------------------- | ----: | ----------------------------------------------------------------------------------- |
|
||||
| فحص الملفات التنفيذية | 31 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
|
||||
| التحليل المركب | 8 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
|
||||
| فحص الملفات التنفيذية | 33 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
|
||||
| التحليل المركب | 10 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
|
||||
| جلسة الملف التنفيذي | 3 | `open_binary` و`binary_session` و`close_binary` |
|
||||
|
||||
## استخدام REA مع وكلاء برمجة آخرين
|
||||
|
|
|
|||
16
README_ja.md
16
README_ja.md
|
|
@ -10,11 +10,11 @@
|
|||
|
||||
[](https://www.npmjs.com/package/@morluto/rea)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#42-ツールのワークベンチ)
|
||||
[](#46-ツールのワークベンチ)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [42 ツール](#42-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
|
||||
[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [46 ツール](#46-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
|
||||
|
||||
<br />
|
||||
|
||||
|
|
@ -135,13 +135,13 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
|||
- Swift / Objective-C のメタデータを解析する。
|
||||
- Hopper に名前、コメント、ブックマークを残し、人間とエージェントの調査を共有する。
|
||||
|
||||
## 42 ツールのワークベンチ
|
||||
## 46 ツールのワークベンチ
|
||||
|
||||
| ツール群 | 数 | 例 |
|
||||
| ------------------ | --: | ----------------------------------------------------------------------------------------------- |
|
||||
| バイナリ調査 | 31 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 |
|
||||
| 合成解析 | 8 | `binary_overview`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 |
|
||||
| バイナリセッション | 3 | `open_binary`, `binary_session`, `close_binary` |
|
||||
| ツール群 | 数 | 例 |
|
||||
| ------------------ | --: | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| バイナリ調査 | 33 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 |
|
||||
| 合成解析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 |
|
||||
| バイナリセッション | 3 | `open_binary`, `binary_session`, `close_binary` |
|
||||
|
||||
## 他のコーディングエージェントで使う
|
||||
|
||||
|
|
|
|||
16
README_ko.md
16
README_ko.md
|
|
@ -10,11 +10,11 @@
|
|||
|
||||
[](https://www.npmjs.com/package/@morluto/rea)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#42개-도구로-구성된-워크벤치)
|
||||
[](#46개-도구로-구성된-워크벤치)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [42개 도구](#42개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
|
||||
[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [46개 도구](#46개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
|
||||
|
||||
<br />
|
||||
|
||||
|
|
@ -135,13 +135,13 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
|||
- Swift 및 Objective-C 메타데이터를 분석합니다.
|
||||
- Hopper에 이름, 주석, 북마크를 남겨 사람과 에이전트의 분석을 연결합니다.
|
||||
|
||||
## 42개 도구로 구성된 워크벤치
|
||||
## 46개 도구로 구성된 워크벤치
|
||||
|
||||
| 도구 그룹 | 수 | 예시 |
|
||||
| ------------- | --: | ------------------------------------------------------------------------------------------------ |
|
||||
| 바이너리 검사 | 31 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 |
|
||||
| 합성 분석 | 8 | `binary_overview`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 |
|
||||
| 바이너리 세션 | 3 | `open_binary`, `binary_session`, `close_binary` |
|
||||
| 도구 그룹 | 수 | 예시 |
|
||||
| ------------- | --: | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| 바이너리 검사 | 33 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 |
|
||||
| 합성 분석 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 |
|
||||
| 바이너리 세션 | 3 | `open_binary`, `binary_session`, `close_binary` |
|
||||
|
||||
## 다른 코딩 에이전트에서 사용하기
|
||||
|
||||
|
|
|
|||
16
README_zh.md
16
README_zh.md
|
|
@ -10,11 +10,11 @@
|
|||
|
||||
[](https://www.npmjs.com/package/@morluto/rea)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#42-个工具组成的工作台)
|
||||
[](#46-个工具组成的工作台)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [42 个工具](#42-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
|
||||
[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [46 个工具](#46-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
|
||||
|
||||
<br />
|
||||
|
||||
|
|
@ -135,13 +135,13 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
|||
- 分析 Swift 和 Objective-C 元数据。
|
||||
- 在 Hopper 中留下名称、注释与书签,使人与智能体的分析互相增强。
|
||||
|
||||
## 42 个工具组成的工作台
|
||||
## 46 个工具组成的工作台
|
||||
|
||||
| 工具类别 | 数量 | 示例 |
|
||||
| ---------- | ---: | ------------------------------------------------------------------------------------------------ |
|
||||
| 二进制检查 | 31 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 |
|
||||
| 组合分析 | 8 | `binary_overview`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 |
|
||||
| 二进制会话 | 3 | `open_binary`, `binary_session`, `close_binary` |
|
||||
| 工具类别 | 数量 | 示例 |
|
||||
| ---------- | ---: | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| 二进制检查 | 33 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 |
|
||||
| 组合分析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 |
|
||||
| 二进制会话 | 3 | `open_binary`, `binary_session`, `close_binary` |
|
||||
|
||||
## 与其他编程智能体一起使用
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,86 @@ def _procedure_name(procedure):
|
|||
return procedure.getSegment().getNameAtAddress(entry) or _hex(entry)
|
||||
|
||||
|
||||
def _procedure_identity(procedure):
|
||||
return {"address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure)}
|
||||
|
||||
|
||||
def _containing_procedure(document, address):
|
||||
segment = document.getSegmentAtAddress(address)
|
||||
if segment is None:
|
||||
return None, "outside_segments"
|
||||
procedure = segment.getProcedureAtAddress(address)
|
||||
return (procedure, None) if procedure is not None else (None, "not_in_procedure")
|
||||
|
||||
|
||||
def _instruction_addresses(procedure, limit):
|
||||
result = []
|
||||
seen = set()
|
||||
segment = procedure.getSegment()
|
||||
truncated = False
|
||||
for block in procedure.basicBlockIterator():
|
||||
address = block.getStartingAddress()
|
||||
end = block.getEndingAddress()
|
||||
while address < end and address not in seen:
|
||||
if len(result) >= limit:
|
||||
truncated = True
|
||||
return result, truncated
|
||||
seen.add(address)
|
||||
instruction = segment.getInstructionAtAddress(address)
|
||||
if instruction is None:
|
||||
break
|
||||
result.append(address)
|
||||
length = instruction.getInstructionLength()
|
||||
if length <= 0:
|
||||
break
|
||||
address += length
|
||||
return result, truncated
|
||||
|
||||
|
||||
def _procedure_references(document, params):
|
||||
procedure = _procedure(document, params.get("procedure"))
|
||||
direction = params.get("direction", "outgoing")
|
||||
offset = params.get("offset", 0)
|
||||
limit = params.get("limit", 100)
|
||||
max_instructions = params.get("max_instructions", 500)
|
||||
if direction not in ("incoming", "outgoing"):
|
||||
raise ValueError("direction must be incoming or outgoing")
|
||||
if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
|
||||
raise ValueError("offset must be a non-negative integer")
|
||||
if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1 or limit > 500:
|
||||
raise ValueError("limit must be an integer between 1 and 500")
|
||||
if not isinstance(max_instructions, int) or isinstance(max_instructions, bool) or max_instructions < 1 or max_instructions > 5000:
|
||||
raise ValueError("max_instructions must be an integer between 1 and 5000")
|
||||
addresses, scan_truncated = _instruction_addresses(procedure, max_instructions)
|
||||
edges = set()
|
||||
for address in addresses:
|
||||
segment = _segment(document, address)
|
||||
references = segment.getReferencesFromAddress(address) if direction == "outgoing" else segment.getReferencesOfAddress(address)
|
||||
for reference in references:
|
||||
edges.add((address, reference) if direction == "outgoing" else (reference, address))
|
||||
ordered = sorted(edges)
|
||||
items = []
|
||||
selected = ordered[offset:offset + limit]
|
||||
for source, target in selected:
|
||||
source_procedure, _ = _containing_procedure(document, source)
|
||||
target_procedure, _ = _containing_procedure(document, target)
|
||||
items.append({
|
||||
"source_address": _hex(source),
|
||||
"target_address": _hex(target),
|
||||
"source_procedure": _procedure_identity(source_procedure) if source_procedure is not None else None,
|
||||
"target_procedure": _procedure_identity(target_procedure) if target_procedure is not None else None,
|
||||
"kind": _unavailable("Hopper's public Python API does not classify reference kinds"),
|
||||
})
|
||||
next_offset = offset + len(selected)
|
||||
has_more = next_offset < len(ordered)
|
||||
truncated = scan_truncated or has_more
|
||||
return {
|
||||
"procedure": _procedure_identity(procedure), "direction": direction,
|
||||
"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},
|
||||
"instructions_scanned": len(addresses), "instruction_scan_truncated": scan_truncated,
|
||||
}
|
||||
|
||||
|
||||
def _procedure_map(document):
|
||||
result = {}
|
||||
for segment in document.getSegmentsList():
|
||||
|
|
@ -102,7 +182,33 @@ def _strings(document):
|
|||
return result
|
||||
|
||||
|
||||
def _assembly(procedure):
|
||||
def _page(values, offset, limit):
|
||||
"""Return one deterministically address-sorted page without crossing an unbounded map."""
|
||||
if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
|
||||
raise ValueError("offset must be a non-negative integer")
|
||||
if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1 or limit > 500:
|
||||
raise ValueError("limit must be an integer between 1 and 500")
|
||||
ordered = sorted(values.items(), key=lambda item: int(item[0], 16))
|
||||
selected = ordered[offset:offset + limit]
|
||||
total = len(ordered)
|
||||
next_offset = offset + len(selected)
|
||||
has_more = next_offset < total
|
||||
return {
|
||||
"items": [{"address": address, "value": value} for address, value in selected],
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"next_offset": next_offset if has_more else None,
|
||||
"has_more": has_more,
|
||||
}
|
||||
|
||||
|
||||
def _unavailable(reason):
|
||||
"""Describe evidence the public Hopper API cannot truthfully provide."""
|
||||
return {"available": False, "reason": reason}
|
||||
|
||||
|
||||
def _assembly(procedure, limit=None):
|
||||
"""Render bounded assembly while guarding against malformed instruction cycles."""
|
||||
lines = []
|
||||
segment = procedure.getSegment()
|
||||
|
|
@ -118,6 +224,8 @@ def _assembly(procedure):
|
|||
arguments = [instruction.getFormattedArgument(index) for index in range(instruction.getArgumentCount())]
|
||||
suffix = ", ".join(value for value in arguments if value is not None)
|
||||
lines.append("%s: %s%s" % (_hex(address), instruction.getInstructionString(), (" " + suffix) if suffix else ""))
|
||||
if limit is not None and len(lines) >= limit:
|
||||
return "\n".join(lines)
|
||||
length = instruction.getInstructionLength()
|
||||
if length <= 0:
|
||||
break
|
||||
|
|
@ -125,6 +233,61 @@ def _assembly(procedure):
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _analyze_function(document, params):
|
||||
"""Collect a bounded, single-pass function dossier for agent callers."""
|
||||
procedure = _procedure(document, params.get("procedure"))
|
||||
limit = params.get("limit", 100)
|
||||
max_chars = params.get("max_pseudocode_chars", 20000)
|
||||
max_instructions = params.get("max_instructions", 500)
|
||||
if not isinstance(limit, int) or limit < 1 or limit > 500:
|
||||
raise ValueError("limit must be an integer between 1 and 500")
|
||||
if not isinstance(max_chars, int) or max_chars < 1 or max_chars > 100000:
|
||||
raise ValueError("max_pseudocode_chars must be between 1 and 100000")
|
||||
if not isinstance(max_instructions, int) or max_instructions < 1 or max_instructions > 5000:
|
||||
raise ValueError("max_instructions must be between 1 and 5000")
|
||||
blocks = []
|
||||
all_blocks = list(procedure.basicBlockIterator())
|
||||
for block in all_blocks[:limit]:
|
||||
blocks.append({
|
||||
"start": _hex(block.getStartingAddress()),
|
||||
"end": _hex(block.getEndingAddress()),
|
||||
"successors": _unavailable(
|
||||
"Hopper's public Python API does not expose CFG successor edges"
|
||||
),
|
||||
})
|
||||
pseudo = procedure.decompile() or ""
|
||||
assembly_sample = _assembly(procedure, max_instructions + 1).splitlines() if params.get("include_assembly", False) else []
|
||||
assembly_truncated = len(assembly_sample) > max_instructions
|
||||
assembly = assembly_sample[:max_instructions]
|
||||
callers = [_procedure_name(item) for item in procedure.getAllCallerProcedures()]
|
||||
callees = [_procedure_name(item) for item in procedure.getAllCalleeProcedures()]
|
||||
def bounded(items):
|
||||
return {"items": items[:limit], "total": len(items), "returned": min(len(items), limit), "truncated": len(items) > limit, "next_offset": limit if len(items) > limit else None}
|
||||
entry = procedure.getEntryPoint()
|
||||
segment = procedure.getSegment()
|
||||
comments = []
|
||||
comment = segment.getCommentAtAddress(entry)
|
||||
inline_comment = segment.getInlineCommentAtAddress(entry)
|
||||
if comment:
|
||||
comments.append({"address": _hex(entry), "kind": "comment", "text": comment})
|
||||
if inline_comment:
|
||||
comments.append({"address": _hex(entry), "kind": "inline", "text": inline_comment})
|
||||
incoming = []
|
||||
for candidate in document.getSegmentsList():
|
||||
incoming.extend(_hex(value) for value in candidate.getReferencesOfAddress(entry))
|
||||
incoming = sorted(set(incoming), key=lambda value: int(value, 16))
|
||||
return {
|
||||
"procedure": {"address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure), "signature": procedure.signatureString(), "locals": _json_safe(procedure.getLocalVariableList())},
|
||||
"pseudocode": {"text": pseudo[:max_chars], "total_chars": len(pseudo), "returned_chars": min(len(pseudo), max_chars), "truncated": len(pseudo) > max_chars, "next_offset": max_chars if len(pseudo) > max_chars else None},
|
||||
"assembly": {"items": assembly, "total": None if assembly_truncated else len(assembly), "returned": len(assembly), "truncated": assembly_truncated, "next_offset": len(assembly) if assembly_truncated else None},
|
||||
"comments": bounded(comments), "callers": bounded(callers), "callees": bounded(callees),
|
||||
"incoming_references": bounded(incoming),
|
||||
"referenced_strings": _unavailable("Hopper's public API does not expose typed outgoing string references for this traversal"),
|
||||
"referenced_names": _unavailable("Hopper's public API does not expose typed outgoing name references for this traversal"),
|
||||
"basic_blocks": bounded(blocks),
|
||||
}
|
||||
|
||||
|
||||
def _dispatch(method, params):
|
||||
"""Dispatch only the closed operation set implemented by REA's public tools."""
|
||||
global _selected_document
|
||||
|
|
@ -143,6 +306,17 @@ def _dispatch(method, params):
|
|||
|
||||
document = _document(params.get("document"))
|
||||
|
||||
if method == "analyze_function":
|
||||
return _analyze_function(document, params)
|
||||
if method == "resolve_containing_procedure":
|
||||
address = _address(document, params.get("address"))
|
||||
procedure, reason = _containing_procedure(document, address)
|
||||
if procedure is None:
|
||||
return {"query_address": _hex(address), "found": False, "procedure": None, "reason": reason}
|
||||
return {"query_address": _hex(address), "found": True, "procedure": _procedure_identity(procedure)}
|
||||
if method == "procedure_references":
|
||||
return _procedure_references(document, params)
|
||||
|
||||
if method == "current_address":
|
||||
return _hex(document.getCurrentAddress())
|
||||
if method == "current_procedure":
|
||||
|
|
@ -175,21 +349,37 @@ def _dispatch(method, params):
|
|||
for segment in document.getSegmentsList():
|
||||
start = segment.getStartingAddress()
|
||||
sections = [{"name": section.getName(), "start": _hex(section.getStartingAddress()), "end": _hex(section.getStartingAddress() + section.getLength())} for section in segment.getSectionsList()]
|
||||
result.append({"name": segment.getName(), "start": _hex(start), "end": _hex(start + segment.getLength()), "writable": False, "executable": False, "sections": sections})
|
||||
result.append({
|
||||
"name": segment.getName(),
|
||||
"start": _hex(start),
|
||||
"end": _hex(start + segment.getLength()),
|
||||
"writable": None,
|
||||
"executable": None,
|
||||
"permissions": _unavailable(
|
||||
"Hopper's public Python API does not expose segment permissions"
|
||||
),
|
||||
"sections": sections,
|
||||
})
|
||||
return result
|
||||
if method == "list_procedures":
|
||||
return _procedure_map(document)
|
||||
return _page(_procedure_map(document), params.get("offset", 0), params.get("limit", 100))
|
||||
if method == "list_strings":
|
||||
values = _strings(document)
|
||||
requested = params.get("address")
|
||||
return values if requested is None else values.get(_hex(_address(document, requested)))
|
||||
if requested is not None:
|
||||
key = _hex(_address(document, requested))
|
||||
values = {key: values[key]} if key in values else {}
|
||||
return _page(values, params.get("offset", 0), params.get("limit", 100))
|
||||
if method == "list_names":
|
||||
result = {}
|
||||
for segment in document.getSegmentsList():
|
||||
for item in segment.getNamedAddresses():
|
||||
result[_hex(item)] = segment.getNameAtAddress(item)
|
||||
requested = params.get("address")
|
||||
return result if requested is None else result.get(_hex(_address(document, requested)))
|
||||
if requested is not None:
|
||||
key = _hex(_address(document, requested))
|
||||
result = {key: result[key]} if key in result else {}
|
||||
return _page(result, params.get("offset", 0), params.get("limit", 100))
|
||||
if method in ("search_procedures", "search_strings"):
|
||||
flags = 0 if params.get("case_sensitive", False) else re.IGNORECASE
|
||||
expression = re.compile(params["pattern"], flags)
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@
|
|||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"verify:hopper": "npm run build && node scripts/verify-real-hopper.mjs",
|
||||
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
|
||||
"verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs",
|
||||
"verify:package": "npm run build && node scripts/verify-package.mjs",
|
||||
"config:print": "node scripts/print-mcp-config.mjs",
|
||||
"docs:generate": "typedoc",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { arch, platform } from "node:os";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import {
|
||||
generateLargeFixture,
|
||||
LARGE_FIXTURE_COUNT,
|
||||
sha256,
|
||||
sourceDigest,
|
||||
} from "./lib/conformance-fixtures.mjs";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const root = process.cwd();
|
||||
const sourceRoot = join(root, "tests/conformance");
|
||||
const outputRoot = resolve(
|
||||
process.env.REA_CONFORMANCE_BUILD ?? join(root, "build/conformance"),
|
||||
);
|
||||
if (platform() !== "darwin")
|
||||
throw new Error(
|
||||
"Mach-O conformance fixtures require macOS and Xcode command-line tools",
|
||||
);
|
||||
await rm(outputRoot, { recursive: true, force: true });
|
||||
await mkdir(outputRoot, { recursive: true });
|
||||
|
||||
const clang = (await exec("xcrun", ["--find", "clang"])).stdout.trim();
|
||||
const clangVersion = (await exec(clang, ["--version"])).stdout.split("\n")[0];
|
||||
const sdkPath = (await exec("xcrun", ["--show-sdk-path"])).stdout.trim();
|
||||
const architecture = arch();
|
||||
const common = [
|
||||
"-arch",
|
||||
architecture,
|
||||
"-isysroot",
|
||||
sdkPath,
|
||||
"-O0",
|
||||
"-g",
|
||||
"-fno-inline",
|
||||
];
|
||||
const fixtures = [];
|
||||
|
||||
await build(
|
||||
"c",
|
||||
["c/fixture.c"],
|
||||
[...common, join(sourceRoot, "c/fixture.c"), "-o", join(outputRoot, "c")],
|
||||
{
|
||||
expectations: {
|
||||
symbols: ["_rea_entry", "_rea_branch", "_rea_leaf", "_rea_c_global"],
|
||||
strings: ["REA_C_ENTRY", "REA_C_LEAF"],
|
||||
},
|
||||
},
|
||||
);
|
||||
await build(
|
||||
"version-v1",
|
||||
["versions/v1.c"],
|
||||
[
|
||||
...common,
|
||||
join(sourceRoot, "versions/v1.c"),
|
||||
"-o",
|
||||
join(outputRoot, "version-v1"),
|
||||
],
|
||||
{
|
||||
expectations: {
|
||||
symbols: ["_rea_version_entry", "_rea_version_leaf"],
|
||||
strings: ["REA_VERSION_ONE"],
|
||||
},
|
||||
},
|
||||
);
|
||||
await build(
|
||||
"version-v2",
|
||||
["versions/v2.c"],
|
||||
[
|
||||
...common,
|
||||
join(sourceRoot, "versions/v2.c"),
|
||||
"-o",
|
||||
join(outputRoot, "version-v2"),
|
||||
],
|
||||
{
|
||||
expectations: {
|
||||
symbols: ["_rea_version_entry", "_rea_version_leaf", "_rea_added"],
|
||||
strings: ["REA_VERSION_TWO"],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const largeSource = generateLargeFixture();
|
||||
const largePath = join(outputRoot, "large.c");
|
||||
await writeFile(largePath, largeSource);
|
||||
await build(
|
||||
"large",
|
||||
[{ path: "generated/large.c", content: largeSource }],
|
||||
[...common, largePath, "-o", join(outputRoot, "large")],
|
||||
{
|
||||
expectations: {
|
||||
symbolPrefix: "_rea_page_",
|
||||
symbolCount: LARGE_FIXTURE_COUNT,
|
||||
stringPrefix: "REA_PAGE_",
|
||||
stringCount: LARGE_FIXTURE_COUNT,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await build(
|
||||
"objc",
|
||||
["objc/fixture.m"],
|
||||
[
|
||||
...common,
|
||||
"-fobjc-arc",
|
||||
join(sourceRoot, "objc/fixture.m"),
|
||||
"-framework",
|
||||
"Foundation",
|
||||
"-o",
|
||||
join(outputRoot, "objc"),
|
||||
],
|
||||
{
|
||||
expectations: {
|
||||
symbols: ["_OBJC_CLASS_$_REAWidget"],
|
||||
strings: ["REAWidget", "REAWidgetDelegate", "performAction:error:"],
|
||||
},
|
||||
},
|
||||
);
|
||||
await build(
|
||||
"napi",
|
||||
["napi/fixture.c"],
|
||||
[
|
||||
...common,
|
||||
"-bundle",
|
||||
"-undefined",
|
||||
"dynamic_lookup",
|
||||
join(sourceRoot, "napi/fixture.c"),
|
||||
"-o",
|
||||
join(outputRoot, "fixture.node"),
|
||||
],
|
||||
{
|
||||
expectations: {
|
||||
symbols: ["_napi_register_module_v1"],
|
||||
strings: ["reaProbeOne", "reaProbeTwo", "reaProbeThree"],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const swiftc = (await exec("xcrun", ["--find", "swiftc"])).stdout.trim();
|
||||
const swiftVersion = (await exec(swiftc, ["--version"])).stdout.split(
|
||||
"\n",
|
||||
)[0];
|
||||
await build(
|
||||
"swift",
|
||||
["swift/fixture.swift"],
|
||||
[
|
||||
"-sdk",
|
||||
sdkPath,
|
||||
"-Onone",
|
||||
"-g",
|
||||
join(sourceRoot, "swift/fixture.swift"),
|
||||
"-o",
|
||||
join(outputRoot, "swift"),
|
||||
],
|
||||
{
|
||||
expectations: {
|
||||
strings: [
|
||||
"REA_SWIFT_EXECUTE",
|
||||
"REAService",
|
||||
"REARecord",
|
||||
"REAState",
|
||||
"REAProtocol",
|
||||
],
|
||||
},
|
||||
compiler: swiftc,
|
||||
compilerVersion: swiftVersion,
|
||||
},
|
||||
);
|
||||
} catch (cause) {
|
||||
process.stderr.write(
|
||||
`Skipping optional Swift fixture: ${cause instanceof Error ? cause.message : String(cause)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
platform: platform(),
|
||||
architecture,
|
||||
generatedAt: new Date(0).toISOString(),
|
||||
fixtures,
|
||||
};
|
||||
await writeFile(
|
||||
join(outputRoot, "manifest.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ outputRoot, fixtureCount: fixtures.length })}\n`,
|
||||
);
|
||||
|
||||
async function build(name, sources, args, options) {
|
||||
const compiler = options.compiler ?? clang;
|
||||
const compilerVersion = options.compilerVersion ?? clangVersion;
|
||||
const normalized = await Promise.all(
|
||||
sources.map(async (source) => {
|
||||
if (typeof source !== "string") return source;
|
||||
return {
|
||||
path: source,
|
||||
content: await readFile(join(sourceRoot, source), "utf8"),
|
||||
};
|
||||
}),
|
||||
);
|
||||
await exec(compiler, args, { maxBuffer: 10 * 1024 * 1024 });
|
||||
const artifactPath = args.at(-1);
|
||||
if (typeof artifactPath !== "string")
|
||||
throw new Error(`Missing output for ${name}`);
|
||||
const artifact = await readFile(artifactPath);
|
||||
fixtures.push({
|
||||
name,
|
||||
artifact: relative(outputRoot, artifactPath),
|
||||
artifactSha256: sha256(artifact),
|
||||
sourceSha256: sourceDigest(normalized),
|
||||
sources: normalized.map(({ path, content }) => ({
|
||||
path,
|
||||
sha256: sha256(content),
|
||||
})),
|
||||
compiler: { path: compiler, version: compilerVersion, arguments: args },
|
||||
expectations: options.expectations,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export const LARGE_FIXTURE_COUNT: number;
|
||||
export function sha256(value: string | Uint8Array): string;
|
||||
export function generateLargeFixture(count?: number): string;
|
||||
export function sourceDigest(
|
||||
sources: readonly { readonly path: string; readonly content: string }[],
|
||||
): string;
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { createHash } from "node:crypto";
|
||||
|
||||
export const LARGE_FIXTURE_COUNT = 1_205;
|
||||
|
||||
export const sha256 = (value) =>
|
||||
createHash("sha256").update(value).digest("hex");
|
||||
|
||||
export const generateLargeFixture = (count = LARGE_FIXTURE_COUNT) => {
|
||||
if (!Number.isInteger(count) || count < 1)
|
||||
throw new Error("Fixture count must be a positive integer");
|
||||
const declarations = Array.from(
|
||||
{ length: count },
|
||||
(_, index) =>
|
||||
`__attribute__((noinline, used)) int rea_page_${String(index).padStart(4, "0")}(void) { puts("REA_PAGE_${String(index).padStart(4, "0")}"); return ${index}; }`,
|
||||
);
|
||||
return [
|
||||
"#include <stdio.h>",
|
||||
...declarations,
|
||||
"int main(void) { return rea_page_0000(); }",
|
||||
"",
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
export const sourceDigest = (sources) => {
|
||||
const canonical = [...sources]
|
||||
.sort(({ path: left }, { path: right }) => left.localeCompare(right))
|
||||
.map(({ path, content }) => `${path}\0${sha256(content)}\n`)
|
||||
.join("");
|
||||
return sha256(canonical);
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// Route production MCP before importing Incur. Incur owns registration helpers
|
||||
// such as `mcp add`, while only dist/main.js may serve the 42-tool stdio server.
|
||||
// such as `mcp add`, while only dist/main.js may serve the 46-tool stdio server.
|
||||
const args = process.argv.slice(2);
|
||||
const isMcpMode =
|
||||
args[0] === "--mcp" || (args.length === 1 && args[0] === "mcp");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { sha256 } from "./lib/conformance-fixtures.mjs";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const manifestPath = resolve(
|
||||
process.argv[2] ?? "build/conformance/manifest.json",
|
||||
);
|
||||
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.fixtures))
|
||||
throw new Error("Unsupported conformance manifest");
|
||||
const root = dirname(manifestPath);
|
||||
|
||||
for (const fixture of manifest.fixtures) {
|
||||
const artifactPath = join(root, fixture.artifact);
|
||||
const artifact = await readFile(artifactPath);
|
||||
if (sha256(artifact) !== fixture.artifactSha256)
|
||||
throw new Error(`${fixture.name}: artifact hash mismatch`);
|
||||
const fileOutput = (await exec("file", [artifactPath])).stdout;
|
||||
if (!fileOutput.includes("Mach-O"))
|
||||
throw new Error(`${fixture.name}: not Mach-O`);
|
||||
const symbols = (
|
||||
await exec("nm", ["-gU", artifactPath], { maxBuffer: 20 * 1024 * 1024 })
|
||||
).stdout;
|
||||
const strings = (
|
||||
await exec("strings", [artifactPath], { maxBuffer: 20 * 1024 * 1024 })
|
||||
).stdout;
|
||||
for (const symbol of fixture.expectations.symbols ?? [])
|
||||
if (!symbols.includes(symbol))
|
||||
throw new Error(`${fixture.name}: missing symbol ${symbol}`);
|
||||
for (const value of fixture.expectations.strings ?? [])
|
||||
if (!strings.includes(value))
|
||||
throw new Error(`${fixture.name}: missing string ${value}`);
|
||||
verifyCount(
|
||||
symbols,
|
||||
fixture.expectations.symbolPrefix,
|
||||
fixture.expectations.symbolCount,
|
||||
fixture.name,
|
||||
);
|
||||
verifyCount(
|
||||
strings,
|
||||
fixture.expectations.stringPrefix,
|
||||
fixture.expectations.stringCount,
|
||||
fixture.name,
|
||||
);
|
||||
}
|
||||
|
||||
const v1 = manifest.fixtures.find(({ name }) => name === "version-v1");
|
||||
const v2 = manifest.fixtures.find(({ name }) => name === "version-v2");
|
||||
if (v1?.artifactSha256 === v2?.artifactSha256)
|
||||
throw new Error("Version-pair artifacts unexpectedly have equal hashes");
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ verified: manifest.fixtures.length, manifestPath })}\n`,
|
||||
);
|
||||
|
||||
function verifyCount(output, prefix, expected, fixture) {
|
||||
if (prefix === undefined || expected === undefined) return;
|
||||
const count = output
|
||||
.split("\n")
|
||||
.filter((line) => line.includes(prefix)).length;
|
||||
if (count !== expected)
|
||||
throw new Error(
|
||||
`${fixture}: expected ${expected} ${prefix} records, received ${count}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -161,8 +161,8 @@ try {
|
|||
const client = new Client({ name: "package-smoke", version: "1.0.0" });
|
||||
try {
|
||||
await client.connect(transport);
|
||||
if ((await client.listTools()).tools.length !== 42)
|
||||
throw new Error("packaged MCP did not expose 42 tools");
|
||||
if ((await client.listTools()).tools.length !== 46)
|
||||
throw new Error("packaged MCP did not expose 46 tools");
|
||||
const result = await client.callTool({
|
||||
name: "current_document",
|
||||
arguments: {},
|
||||
|
|
@ -177,7 +177,7 @@ try {
|
|||
}
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ cli: true, incurMcpCommand: "npx -y @morluto/rea mcp", doctor: true, setup: "idempotent", clients: 2, backupReadback: true, failureRecovery: true, skill: true, mcpTools: 42, targetFree: true })}\n`,
|
||||
`${JSON.stringify({ cli: true, incurMcpCommand: "npx -y @morluto/rea mcp", doctor: true, setup: "idempotent", clients: 2, backupReadback: true, failureRecovery: true, skill: true, mcpTools: 46, targetFree: true })}\n`,
|
||||
);
|
||||
} finally {
|
||||
if (tarball) await rm(join(root, tarball), { force: true });
|
||||
|
|
|
|||
|
|
@ -25,6 +25,99 @@ const textValue = (result) => {
|
|||
|
||||
const jsonValue = (result) => JSON.parse(textValue(result));
|
||||
|
||||
const requireSuccessfulTool = (result, operation) => {
|
||||
if (result.isError === true) {
|
||||
throw new Error(`${operation} failed: ${textValue(result)}`);
|
||||
}
|
||||
const value = jsonValue(result);
|
||||
if (value === null || typeof value !== "object" || !("result" in value)) {
|
||||
throw new Error(`${operation} omitted its evidence result`);
|
||||
}
|
||||
return value.result;
|
||||
};
|
||||
|
||||
const firstProcedureAddress = (result) => {
|
||||
const items = result.structuredContent?.result?.items;
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
throw new Error("The verifier target contains no analyzed procedure");
|
||||
}
|
||||
const address = items[0]?.address;
|
||||
if (typeof address !== "string" || !/^0x[0-9a-f]+$/iu.test(address)) {
|
||||
throw new Error("list_procedures returned an invalid procedure address");
|
||||
}
|
||||
return address;
|
||||
};
|
||||
|
||||
const requirePseudocode = (value, operation) => {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`${operation} returned empty pseudocode`);
|
||||
}
|
||||
if (value === "No output" || value.startsWith("Error:")) {
|
||||
throw new Error(`${operation} returned an embedded failure: ${value}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const requireFunctionDossier = (value, expectedAddress) => {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("analyze_function returned no function dossier");
|
||||
}
|
||||
if (value.procedure?.address !== expectedAddress) {
|
||||
throw new Error("analyze_function returned the wrong procedure");
|
||||
}
|
||||
if (
|
||||
typeof value.procedure?.name !== "string" ||
|
||||
value.procedure.name.trim().length === 0
|
||||
) {
|
||||
throw new Error("analyze_function omitted the procedure name");
|
||||
}
|
||||
requirePseudocode(value.pseudocode?.text, "analyze_function");
|
||||
for (const field of [
|
||||
"comments",
|
||||
"callers",
|
||||
"callees",
|
||||
"incoming_references",
|
||||
"basic_blocks",
|
||||
]) {
|
||||
const collection = value[field];
|
||||
if (
|
||||
collection === null ||
|
||||
typeof collection !== "object" ||
|
||||
!Array.isArray(collection.items) ||
|
||||
!Number.isInteger(collection.total)
|
||||
) {
|
||||
throw new Error(`analyze_function returned an invalid ${field} result`);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const requireSafeDiagnostics = (chunks) => {
|
||||
const lines = chunks
|
||||
.join("")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
for (const line of lines) {
|
||||
let diagnostic;
|
||||
try {
|
||||
diagnostic = JSON.parse(line);
|
||||
} catch {
|
||||
throw new Error(`The MCP runtime emitted malformed stderr: ${line}`);
|
||||
}
|
||||
if (
|
||||
diagnostic === null ||
|
||||
typeof diagnostic !== "object" ||
|
||||
diagnostic.application !== "rea" ||
|
||||
typeof diagnostic.level !== "number" ||
|
||||
diagnostic.level >= 40
|
||||
) {
|
||||
throw new Error(`The MCP runtime emitted unsafe stderr: ${line}`);
|
||||
}
|
||||
}
|
||||
return lines.length;
|
||||
};
|
||||
|
||||
const targetPath = process.env.HOPPER_TARGET_PATH;
|
||||
const secondTargetPath = process.env.HOPPER_SECOND_TARGET_PATH;
|
||||
if (!targetPath || !secondTargetPath)
|
||||
|
|
@ -49,8 +142,10 @@ const transport = new StdioClientTransport({
|
|||
stderr: "pipe",
|
||||
});
|
||||
let stderrBytes = 0;
|
||||
const stderrChunks = [];
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderrBytes += chunk.length;
|
||||
if (stderrBytes <= 16_384) stderrChunks.push(chunk.toString("utf8"));
|
||||
});
|
||||
const client = new Client({ name: "real-hopper-verifier", version: "1.0.0" });
|
||||
let summary;
|
||||
|
|
@ -61,7 +156,7 @@ try {
|
|||
const expectedNames = TOOL_CONTRACTS.map(({ name }) => name).sort();
|
||||
const actualNames = listed.tools.map(({ name }) => name).sort();
|
||||
if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) {
|
||||
throw new Error("The real server did not expose the intended 42 tools");
|
||||
throw new Error("The real server did not expose the intended 46 tools");
|
||||
}
|
||||
|
||||
const options = { timeout };
|
||||
|
|
@ -92,15 +187,73 @@ try {
|
|||
{ name: "list_procedures", arguments: {} },
|
||||
options,
|
||||
);
|
||||
const procedureMap = jsonValue(procedures);
|
||||
const firstAddress = Object.keys(procedureMap)[0];
|
||||
if (firstAddress === undefined) {
|
||||
throw new Error("The verifier target contains no analyzed procedure");
|
||||
requireSuccessfulTool(procedures, "list_procedures");
|
||||
const firstAddress = firstProcedureAddress(procedures);
|
||||
const containment = requireSuccessfulTool(
|
||||
await client.callTool(
|
||||
{
|
||||
name: "resolve_containing_procedure",
|
||||
arguments: { address: firstAddress },
|
||||
},
|
||||
options,
|
||||
),
|
||||
"resolve_containing_procedure",
|
||||
);
|
||||
if (
|
||||
containment?.found !== true ||
|
||||
containment.procedure?.address !== firstAddress
|
||||
) {
|
||||
throw new Error(
|
||||
"resolve_containing_procedure returned the wrong procedure",
|
||||
);
|
||||
}
|
||||
const references = requireSuccessfulTool(
|
||||
await client.callTool(
|
||||
{
|
||||
name: "procedure_references",
|
||||
arguments: {
|
||||
procedure: firstAddress,
|
||||
direction: "outgoing",
|
||||
limit: 10,
|
||||
max_instructions: 100,
|
||||
},
|
||||
},
|
||||
options,
|
||||
),
|
||||
"procedure_references",
|
||||
);
|
||||
if (!Array.isArray(references?.references?.items)) {
|
||||
throw new Error("procedure_references returned an invalid bounded result");
|
||||
}
|
||||
const bounded = await client.callTool(
|
||||
{ name: "batch_decompile", arguments: { addresses: [firstAddress] } },
|
||||
options,
|
||||
);
|
||||
const boundedResult = requireSuccessfulTool(bounded, "batch_decompile");
|
||||
if (
|
||||
boundedResult === null ||
|
||||
typeof boundedResult !== "object" ||
|
||||
Array.isArray(boundedResult)
|
||||
) {
|
||||
throw new Error("batch_decompile returned an invalid result");
|
||||
}
|
||||
const boundedPseudocode = requirePseudocode(
|
||||
boundedResult[firstAddress],
|
||||
"batch_decompile",
|
||||
);
|
||||
const dossier = requireFunctionDossier(
|
||||
requireSuccessfulTool(
|
||||
await client.callTool(
|
||||
{
|
||||
name: "analyze_function",
|
||||
arguments: { procedure: firstAddress },
|
||||
},
|
||||
options,
|
||||
),
|
||||
"analyze_function",
|
||||
),
|
||||
firstAddress,
|
||||
);
|
||||
const switched = await client.callTool(
|
||||
{ name: "open_binary", arguments: { path: targetB } },
|
||||
options,
|
||||
|
|
@ -127,9 +280,7 @@ try {
|
|||
"Hopper's bundled MCP server was running during verification",
|
||||
);
|
||||
}
|
||||
if (stderrBytes !== 0) {
|
||||
throw new Error("The MCP runtime emitted stderr during verification");
|
||||
}
|
||||
const diagnosticCount = requireSafeDiagnostics(stderrChunks);
|
||||
|
||||
const closed = await client.callTool(
|
||||
{ name: "close_binary", arguments: {} },
|
||||
|
|
@ -144,9 +295,13 @@ try {
|
|||
segmentCount: jsonValue(segments).length,
|
||||
boundedTool: "batch_decompile",
|
||||
boundedInputCount: 1,
|
||||
boundedResultKeys: Object.keys(jsonValue(bounded)).length,
|
||||
boundedResultKeys: Object.keys(boundedResult).length,
|
||||
boundedPseudocodeChars: boundedPseudocode.length,
|
||||
analyzedProcedure: dossier.procedure,
|
||||
analyzedPseudocodeChars: dossier.pseudocode.text.length,
|
||||
bundledMcpRunning,
|
||||
stderrBytes,
|
||||
diagnosticCount,
|
||||
dynamicSession: true,
|
||||
targets: [targetA, targetB],
|
||||
switched: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { AnalysisError } from "../domain/errors.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
|
||||
interface ExecutionOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Provider-neutral application capability for one analysis operation. */
|
||||
export interface AnalysisOperationPort {
|
||||
execute(
|
||||
operation: string,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<JsonValue, AnalysisError>>;
|
||||
}
|
||||
|
||||
/** Provider session bound to exactly one parsed artifact. */
|
||||
export interface AnalysisClient extends AnalysisOperationPort {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export type AnalysisClientFactory = (target: BinaryTarget) => AnalysisClient;
|
||||
|
||||
export interface ProviderIdentity {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly version: string | null;
|
||||
}
|
||||
|
||||
/** Factory and capability declaration for an analysis implementation. */
|
||||
export interface AnalysisProvider {
|
||||
identity(): ProviderIdentity;
|
||||
capabilities(): readonly string[];
|
||||
createClient(target: BinaryTarget): AnalysisClient;
|
||||
}
|
||||
|
|
@ -3,18 +3,30 @@ import { parseBinaryTarget } from "../domain/binaryTarget.js";
|
|||
import {
|
||||
HopperCancelledError,
|
||||
NoBinaryOpenError,
|
||||
type HopperError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { JsonValue } from "../hopper/protocol.js";
|
||||
import type { HopperToolPort } from "./HopperToolPort.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisClientFactory,
|
||||
AnalysisOperationPort,
|
||||
AnalysisProvider,
|
||||
} from "./AnalysisProvider.js";
|
||||
|
||||
/** A closeable Hopper port created for exactly one parsed target. */
|
||||
export interface BinaryClient extends HopperToolPort {
|
||||
close(): Promise<void>;
|
||||
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
|
||||
export interface BinarySessionPort extends AnalysisOperationPort {
|
||||
open(
|
||||
path: string,
|
||||
options?: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly targetKind?: BinaryTarget["kind"];
|
||||
},
|
||||
): Promise<Result<BinaryTarget, AnalysisError>>;
|
||||
close(): Promise<Result<null, AnalysisError>>;
|
||||
status(): JsonValue;
|
||||
activeTarget(): BinaryTarget | undefined;
|
||||
}
|
||||
/** Production seam for creating one unstarted client for a parsed target. */
|
||||
export type BinaryClientFactory = (target: BinaryTarget) => BinaryClient;
|
||||
|
||||
/**
|
||||
* Owns the single active target shared by CLI and MCP adapters.
|
||||
|
|
@ -24,13 +36,20 @@ export type BinaryClientFactory = (target: BinaryTarget) => BinaryClient;
|
|||
* down. A failed switch recreates the previous target instead of retaining a
|
||||
* client whose bridge was already shut down.
|
||||
*/
|
||||
export class BinarySession implements HopperToolPort {
|
||||
export class BinarySession implements BinarySessionPort {
|
||||
#active:
|
||||
| { readonly target: BinaryTarget; readonly client: BinaryClient }
|
||||
| { readonly target: BinaryTarget; readonly client: AnalysisClient }
|
||||
| undefined;
|
||||
#transition: Promise<void> = Promise.resolve();
|
||||
readonly #calls = new Set<Promise<unknown>>();
|
||||
constructor(readonly createClient: BinaryClientFactory) {}
|
||||
readonly #createClient: AnalysisClientFactory;
|
||||
|
||||
constructor(readonly provider: AnalysisProvider | AnalysisClientFactory) {
|
||||
this.#createClient =
|
||||
typeof provider === "function"
|
||||
? provider
|
||||
: (target) => provider.createClient(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open or switch targets after draining calls against the current target.
|
||||
|
|
@ -42,7 +61,7 @@ export class BinarySession implements HopperToolPort {
|
|||
readonly signal?: AbortSignal;
|
||||
readonly targetKind?: BinaryTarget["kind"];
|
||||
} = {},
|
||||
): Promise<Result<BinaryTarget, HopperError>> {
|
||||
): Promise<Result<BinaryTarget, AnalysisError>> {
|
||||
return this.#serialize(async () => {
|
||||
if (isAborted(options.signal)) return err(new HopperCancelledError());
|
||||
const parsed = await parseBinaryTarget(
|
||||
|
|
@ -59,8 +78,8 @@ export class BinarySession implements HopperToolPort {
|
|||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
await previous?.client.close();
|
||||
const client = this.createClient(parsed.value);
|
||||
const started = await client.callTool("health", {}, options);
|
||||
const client = this.#createClient(parsed.value);
|
||||
const started = await client.execute("health", {}, options);
|
||||
if (!started.ok) {
|
||||
await client.close();
|
||||
await this.#restore(previous);
|
||||
|
|
@ -77,7 +96,7 @@ export class BinarySession implements HopperToolPort {
|
|||
}
|
||||
|
||||
/** Close the active target, if any. */
|
||||
close(): Promise<Result<null, HopperError>> {
|
||||
close(): Promise<Result<null, AnalysisError>> {
|
||||
return this.#serialize(async () => {
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
|
|
@ -95,26 +114,33 @@ export class BinarySession implements HopperToolPort {
|
|||
: {
|
||||
open: true,
|
||||
path: target.path,
|
||||
sha256: target.sha256,
|
||||
format: target.format,
|
||||
kind: target.kind,
|
||||
architecture: target.architecture ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Return the immutable artifact identity captured before Hopper launched. */
|
||||
activeTarget(): BinaryTarget | undefined {
|
||||
return this.#active?.target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke a Hopper tool against the active target.
|
||||
* Calls may overlap, but a pending target transition prevents new calls from
|
||||
* entering until the transition has settled.
|
||||
*/
|
||||
async callTool(
|
||||
async execute(
|
||||
name: string,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
options?: { readonly signal?: AbortSignal },
|
||||
): Promise<Result<JsonValue, HopperError>> {
|
||||
): Promise<Result<JsonValue, AnalysisError>> {
|
||||
const transitioned = await this.#waitForTransition(options?.signal);
|
||||
if (!transitioned.ok) return transitioned;
|
||||
const active = this.#active;
|
||||
if (active === undefined) return err(new NoBinaryOpenError());
|
||||
const call = active.client.callTool(name, arguments_, options);
|
||||
const call = active.client.execute(name, arguments_, options);
|
||||
this.#calls.add(call);
|
||||
try {
|
||||
return await call;
|
||||
|
|
@ -138,12 +164,12 @@ export class BinarySession implements HopperToolPort {
|
|||
|
||||
async #restore(
|
||||
previous:
|
||||
| { readonly target: BinaryTarget; readonly client: BinaryClient }
|
||||
| { readonly target: BinaryTarget; readonly client: AnalysisClient }
|
||||
| undefined,
|
||||
): Promise<void> {
|
||||
if (previous === undefined) return;
|
||||
const client = this.createClient(previous.target);
|
||||
const started = await client.callTool("health", {});
|
||||
const client = this.#createClient(previous.target);
|
||||
const started = await client.execute("health", {});
|
||||
if (started.ok) this.#active = { target: previous.target, client };
|
||||
else await client.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { parseConfig } from "../config.js";
|
||||
import type { JsonValue } from "../hopper/protocol.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { createBinarySession } from "./runtime.js";
|
||||
import { silentLogger, type Logger } from "../logger.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
|
||||
/**
|
||||
* Open one binary, execute one tool, and always release the bridge session.
|
||||
|
|
@ -22,9 +23,13 @@ export const runDirectAnalysis = async (
|
|||
const opened = await session.open(path);
|
||||
if (!opened.ok)
|
||||
return { error: opened.error._tag, message: opened.error.message };
|
||||
const result = await session.callTool(tool, arguments_);
|
||||
const result = await session.execute(tool, arguments_);
|
||||
return result.ok
|
||||
? result.value
|
||||
? createEvidence(opened.value, {
|
||||
operation: tool,
|
||||
parameters: arguments_,
|
||||
result: result.value,
|
||||
})
|
||||
: { error: result.error._tag, message: result.error.message };
|
||||
} finally {
|
||||
await session.close();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import type { HopperToolPort } from "./HopperToolPort.js";
|
||||
import type { AnalysisOperationPort } from "./AnalysisProvider.js";
|
||||
import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
|
||||
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
|
||||
import { HopperProtocolError, type HopperError } from "../domain/errors.js";
|
||||
import { AnalysisProtocolError, type AnalysisError } from "../domain/errors.js";
|
||||
import {
|
||||
parseDocuments,
|
||||
parseAddressedPage,
|
||||
parseListCount,
|
||||
parseNames,
|
||||
parseProcedures,
|
||||
parseRelatedAddresses,
|
||||
parseSegments,
|
||||
} from "../domain/hopperValues.js";
|
||||
|
|
@ -17,9 +16,15 @@ import {
|
|||
discoverObjcProtocols,
|
||||
discoverSwiftClasses,
|
||||
} from "../domain/symbolAnalysis.js";
|
||||
import type { JsonValue } from "../hopper/protocol.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
type EnhancedResult = Promise<Result<JsonValue, HopperError>>;
|
||||
type EnhancedResult = Promise<Result<JsonValue, AnalysisError>>;
|
||||
type TraceMatch = { type: string; address: string; value: string };
|
||||
type TraceReference = {
|
||||
target_address: string;
|
||||
source_address: string;
|
||||
containing_procedure: JsonValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Composes official Hopper operations into bounded reverse-engineering tools.
|
||||
|
|
@ -27,7 +32,7 @@ type EnhancedResult = Promise<Result<JsonValue, HopperError>>;
|
|||
* validates them, allowing direct callers to use the same service safely.
|
||||
*/
|
||||
export class EnhancedTools {
|
||||
constructor(private readonly hopper: HopperToolPort) {}
|
||||
constructor(private readonly analysis: AnalysisOperationPort) {}
|
||||
|
||||
/** Parse inputs again at the direct-call boundary, then dispatch exhaustively. */
|
||||
execute(
|
||||
|
|
@ -70,31 +75,41 @@ export class EnhancedTools {
|
|||
? this.#findXrefs(parsed.data.name, signal)
|
||||
: invalidInput(name, parsed.error);
|
||||
}
|
||||
case "binary_overview":
|
||||
return this.#binaryOverview(signal);
|
||||
case "binary_overview": {
|
||||
const parsed = enhancedInputSchemas.binary_overview.safeParse(input);
|
||||
return parsed.success
|
||||
? this.#binaryOverview(parsed.data, signal)
|
||||
: invalidInput(name, parsed.error);
|
||||
}
|
||||
case "analyze_function": {
|
||||
const parsed = enhancedInputSchemas.analyze_function.safeParse(input);
|
||||
return parsed.success
|
||||
? this.#call("analyze_function", parsed.data, signal)
|
||||
: invalidInput(name, parsed.error);
|
||||
}
|
||||
case "trace_feature": {
|
||||
const parsed = enhancedInputSchemas.trace_feature.safeParse(input);
|
||||
return parsed.success
|
||||
? this.#traceFeature(parsed.data, signal)
|
||||
: invalidInput(name, parsed.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #swiftClasses(pattern: string, signal?: AbortSignal): EnhancedResult {
|
||||
const result = await this.#call("list_procedures", {}, signal);
|
||||
if (!result.ok) return result;
|
||||
const procedures = parseProcedures(result.value);
|
||||
const procedures = await this.#allProcedures(signal);
|
||||
return procedures.ok
|
||||
? ok(discoverSwiftClasses(procedures.value, pattern))
|
||||
: procedures;
|
||||
}
|
||||
|
||||
async #objcClasses(pattern: string, signal?: AbortSignal): EnhancedResult {
|
||||
const result = await this.#call("list_names", {}, signal);
|
||||
if (!result.ok) return result;
|
||||
const names = parseNames(result.value);
|
||||
const names = await this.#allAddressed("list_names", signal);
|
||||
return names.ok ? ok(discoverObjcClasses(names.value, pattern)) : names;
|
||||
}
|
||||
|
||||
async #objcProtocols(signal?: AbortSignal): EnhancedResult {
|
||||
const result = await this.#call("list_names", {}, signal);
|
||||
if (!result.ok) return result;
|
||||
const names = parseNames(result.value);
|
||||
const names = await this.#allAddressed("list_names", signal);
|
||||
return names.ok ? ok(discoverObjcProtocols(names.value)) : names;
|
||||
}
|
||||
|
||||
|
|
@ -187,9 +202,7 @@ export class EnhancedTools {
|
|||
}
|
||||
|
||||
async #analyzeSwiftTypes(signal?: AbortSignal): EnhancedResult {
|
||||
const result = await this.#call("list_procedures", {}, signal);
|
||||
if (!result.ok) return result;
|
||||
const procedures = parseProcedures(result.value);
|
||||
const procedures = await this.#allProcedures(signal);
|
||||
return procedures.ok
|
||||
? ok(categorizeSwiftTypes(procedures.value))
|
||||
: procedures;
|
||||
|
|
@ -213,45 +226,230 @@ export class EnhancedTools {
|
|||
);
|
||||
}
|
||||
|
||||
async #binaryOverview(signal?: AbortSignal): EnhancedResult {
|
||||
const [segmentsResult, documentsResult, proceduresResult, stringsResult] =
|
||||
async #binaryOverview(
|
||||
input: { detail: "concise" | "detailed"; limit: number },
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult {
|
||||
const [segmentsResult, documentsResult, procedures, stringsResult] =
|
||||
await Promise.all([
|
||||
this.#call("list_segments", {}, signal),
|
||||
this.#call("list_documents", {}, signal),
|
||||
this.#call("list_procedures", {}, signal),
|
||||
this.#allProcedures(signal),
|
||||
this.#call("list_strings", {}, signal),
|
||||
]);
|
||||
if (!segmentsResult.ok) return segmentsResult;
|
||||
if (!documentsResult.ok) return documentsResult;
|
||||
if (!proceduresResult.ok) return proceduresResult;
|
||||
if (!procedures.ok) return procedures;
|
||||
if (!stringsResult.ok) return stringsResult;
|
||||
|
||||
const segments = parseSegments(segmentsResult.value);
|
||||
if (!segments.ok) return segments;
|
||||
const documents = parseDocuments(documentsResult.value);
|
||||
if (!documents.ok) return documents;
|
||||
const procedures = parseProcedures(proceduresResult.value);
|
||||
if (!procedures.ok) return procedures;
|
||||
const stringCount = parseListCount(stringsResult.value, "strings");
|
||||
if (!stringCount.ok) return stringCount;
|
||||
|
||||
return ok({
|
||||
document: documents.value[0] ?? "unknown",
|
||||
detail: input.detail,
|
||||
segments: segments.value
|
||||
.slice(0, 10)
|
||||
.map(({ name, start, end }) => ({ name, start, end })),
|
||||
.slice(0, input.limit)
|
||||
.map(({ name, start, end }) =>
|
||||
input.detail === "detailed"
|
||||
? { name, start, end, length: addressDistance(start, end) }
|
||||
: { name, start, end },
|
||||
),
|
||||
segment_count: segments.value.length,
|
||||
procedure_count: procedures.value.length,
|
||||
string_count: stringCount.value,
|
||||
});
|
||||
}
|
||||
|
||||
async #traceFeature(
|
||||
input: {
|
||||
readonly query: string;
|
||||
readonly case_sensitive: boolean;
|
||||
readonly limit: number;
|
||||
readonly max_operations: number;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult {
|
||||
const searched = await this.#literalMatches(input, signal);
|
||||
if (!searched.ok) return searched;
|
||||
const residual = new Set<string>();
|
||||
const traced = await this.#traceReferences(
|
||||
searched.value.matches,
|
||||
input.limit,
|
||||
input.max_operations - searched.value.operations,
|
||||
signal,
|
||||
);
|
||||
if (!traced.ok) return traced;
|
||||
for (const reason of traced.value.residual) residual.add(reason);
|
||||
const operations = searched.value.operations + traced.value.operations;
|
||||
if (operations >= input.max_operations)
|
||||
residual.add("Investigation reached the operation budget.");
|
||||
if (searched.value.matches.length >= input.limit)
|
||||
residual.add("Literal matches reached the configured limit.");
|
||||
return ok({
|
||||
query: input.query,
|
||||
search_mode: "literal",
|
||||
operations_used: operations,
|
||||
operation_budget: input.max_operations,
|
||||
matches: searched.value.matches,
|
||||
references: traced.value.references,
|
||||
truncated: residual.size > 0,
|
||||
residual_unknowns: [...residual],
|
||||
});
|
||||
}
|
||||
|
||||
async #literalMatches(
|
||||
input: {
|
||||
readonly query: string;
|
||||
readonly case_sensitive: boolean;
|
||||
readonly limit: number;
|
||||
readonly max_operations: number;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
Result<{ matches: TraceMatch[]; operations: number }, AnalysisError>
|
||||
> {
|
||||
let operations = 0;
|
||||
const matches: TraceMatch[] = [];
|
||||
const needle = input.case_sensitive
|
||||
? input.query
|
||||
: input.query.toLowerCase();
|
||||
for (const [tool, type] of [
|
||||
["list_strings", "string"],
|
||||
["list_procedures", "procedure"],
|
||||
] as const) {
|
||||
let offset = 0;
|
||||
while (
|
||||
operations < input.max_operations &&
|
||||
matches.length < input.limit
|
||||
) {
|
||||
operations += 1;
|
||||
const result = await this.#call(tool, { offset, limit: 500 }, signal);
|
||||
if (!result.ok) return result;
|
||||
const page = parseAddressedPage(result.value);
|
||||
if (!page.ok) return page;
|
||||
for (const item of page.value.items) {
|
||||
const haystack = input.case_sensitive
|
||||
? item.name
|
||||
: item.name.toLowerCase();
|
||||
if (haystack.includes(needle))
|
||||
matches.push({ type, address: item.address, value: item.name });
|
||||
if (matches.length >= input.limit) break;
|
||||
}
|
||||
if (!page.value.hasMore || page.value.nextOffset === null) break;
|
||||
offset = page.value.nextOffset;
|
||||
}
|
||||
}
|
||||
return ok({ matches, operations });
|
||||
}
|
||||
|
||||
async #traceReferences(
|
||||
matches: readonly TraceMatch[],
|
||||
limit: number,
|
||||
operationBudget: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
Result<
|
||||
{ references: TraceReference[]; operations: number; residual: string[] },
|
||||
AnalysisError
|
||||
>
|
||||
> {
|
||||
let operations = 0;
|
||||
const references: TraceReference[] = [];
|
||||
const residual = new Set<string>();
|
||||
for (const match of matches) {
|
||||
if (operations >= operationBudget) {
|
||||
residual.add("Reference traversal stopped at the operation budget.");
|
||||
break;
|
||||
}
|
||||
operations += 1;
|
||||
const xrefs = await this.#call(
|
||||
"xrefs",
|
||||
{ address: match.address },
|
||||
signal,
|
||||
);
|
||||
if (!xrefs.ok) return xrefs;
|
||||
if (!Array.isArray(xrefs.value))
|
||||
return err(
|
||||
new AnalysisProtocolError("xrefs returned a non-array result"),
|
||||
);
|
||||
for (const source of xrefs.value) {
|
||||
if (typeof source !== "string")
|
||||
return err(
|
||||
new AnalysisProtocolError("xrefs returned a non-address value"),
|
||||
);
|
||||
if (operations >= operationBudget) {
|
||||
residual.add(
|
||||
"Containing-procedure resolution stopped at the operation budget.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
operations += 1;
|
||||
const resolved = await this.#call(
|
||||
"resolve_containing_procedure",
|
||||
{ address: source },
|
||||
signal,
|
||||
);
|
||||
if (!resolved.ok) return resolved;
|
||||
references.push({
|
||||
target_address: match.address,
|
||||
source_address: source,
|
||||
containing_procedure: resolved.value,
|
||||
});
|
||||
if (references.length >= limit) {
|
||||
residual.add("Reference results reached the configured limit.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (references.length >= limit) break;
|
||||
}
|
||||
return ok({
|
||||
references,
|
||||
operations,
|
||||
residual: [...residual],
|
||||
});
|
||||
}
|
||||
|
||||
async #allProcedures(signal?: AbortSignal) {
|
||||
return this.#allAddressed("list_procedures", signal);
|
||||
}
|
||||
|
||||
async #allAddressed(
|
||||
tool: "list_names" | "list_procedures",
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const entries: Array<{ address: string; name: string }> = [];
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const result = await this.#call(tool, { offset, limit: 500 }, signal);
|
||||
if (!result.ok) return result;
|
||||
const page = parseAddressedPage(result.value);
|
||||
if (!page.ok) return page;
|
||||
entries.push(...page.value.items);
|
||||
if (!page.value.hasMore || page.value.nextOffset === null) {
|
||||
return ok(entries);
|
||||
}
|
||||
if (page.value.nextOffset <= offset) {
|
||||
return err(
|
||||
new AnalysisProtocolError(
|
||||
`${tool} returned a non-advancing pagination offset`,
|
||||
),
|
||||
);
|
||||
}
|
||||
offset = page.value.nextOffset;
|
||||
}
|
||||
}
|
||||
|
||||
#call(
|
||||
name: string,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<JsonValue, HopperError>> {
|
||||
return this.hopper.callTool(
|
||||
): Promise<Result<JsonValue, AnalysisError>> {
|
||||
return this.analysis.execute(
|
||||
name,
|
||||
arguments_,
|
||||
signal === undefined ? {} : { signal },
|
||||
|
|
@ -262,7 +460,7 @@ export class EnhancedTools {
|
|||
const invalidInput = (name: EnhancedToolName, cause: Error): EnhancedResult =>
|
||||
Promise.resolve(
|
||||
err(
|
||||
new HopperProtocolError(`Invalid ${name} input after MCP validation`, {
|
||||
new AnalysisProtocolError(`Invalid ${name} input after MCP validation`, {
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
|
|
@ -278,3 +476,6 @@ const resolveAddress = (value: JsonValue): string | undefined => {
|
|||
? candidate
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const addressDistance = (start: string, end: string): number =>
|
||||
Math.max(0, Number.parseInt(end, 16) - Number.parseInt(start, 16));
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import type { HopperError } from "../domain/errors.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type { JsonValue } from "../hopper/protocol.js";
|
||||
|
||||
/** Application-owned capability for invoking one official Hopper tool. */
|
||||
export interface HopperToolPort {
|
||||
/**
|
||||
* Invoke a declared bridge operation with JSON-safe arguments.
|
||||
* Implementations return expected Hopper, timeout, and cancellation failures
|
||||
* as values and must honor an already-aborted signal.
|
||||
*/
|
||||
callTool(
|
||||
name: string,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
options?: { readonly signal?: AbortSignal },
|
||||
): Promise<Result<JsonValue, HopperError>>;
|
||||
}
|
||||
|
|
@ -1,9 +1,6 @@
|
|||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { AppConfig } from "../config.js";
|
||||
import { BinarySession } from "./BinarySession.js";
|
||||
import { HopperApplicationLauncher } from "../hopper/BridgeLauncher.js";
|
||||
import { HopperClient } from "../hopper/HopperClient.js";
|
||||
import { HopperProvider } from "../hopper/HopperProvider.js";
|
||||
import { silentLogger, type Logger } from "../logger.js";
|
||||
|
||||
/**
|
||||
|
|
@ -15,23 +12,5 @@ export const createBinarySession = (
|
|||
config: AppConfig,
|
||||
logger: Logger = silentLogger,
|
||||
): BinarySession => {
|
||||
const bridgeScriptPath = fileURLToPath(
|
||||
new URL("../../bridge/hopper_bridge.py", import.meta.url),
|
||||
);
|
||||
return new BinarySession(
|
||||
(target) =>
|
||||
new HopperClient({
|
||||
launcher: new HopperApplicationLauncher({
|
||||
launcherPath: config.hopperLauncherPath,
|
||||
targetPath: target.path,
|
||||
targetKind: target.kind,
|
||||
loaderArgs:
|
||||
config.hopperLoaderArgs.length > 0
|
||||
? config.hopperLoaderArgs
|
||||
: target.loaderArgs,
|
||||
bridgeScriptPath,
|
||||
}),
|
||||
logger: logger.child({ layer: "bridge" }),
|
||||
}),
|
||||
);
|
||||
return new BinarySession(new HopperProvider(config, logger));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,7 +18,23 @@ export const enhancedInputSchemas = {
|
|||
}),
|
||||
analyze_swift_types: z.object({}),
|
||||
find_xrefs_to_name: z.object({ name: z.string() }),
|
||||
binary_overview: z.object({}),
|
||||
binary_overview: z.object({
|
||||
detail: z.enum(["concise", "detailed"]).default("concise"),
|
||||
limit: z.number().int().min(1).max(50).default(10),
|
||||
}),
|
||||
analyze_function: z.object({
|
||||
procedure: z.string().describe("A procedure name or address"),
|
||||
include_assembly: z.boolean().default(false),
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
max_pseudocode_chars: z.number().int().min(1).max(100_000).default(20_000),
|
||||
max_instructions: z.number().int().min(1).max(5_000).default(500),
|
||||
}),
|
||||
trace_feature: z.object({
|
||||
query: z.string().min(1),
|
||||
case_sensitive: z.boolean().default(false),
|
||||
limit: z.number().int().min(1).max(100).default(20),
|
||||
max_operations: z.number().int().min(1).max(100).default(20),
|
||||
}),
|
||||
} as const;
|
||||
|
||||
export type EnhancedToolName = keyof typeof enhancedInputSchemas;
|
||||
|
|
@ -33,4 +49,6 @@ export const enhancedToolNameSchema = z.enum([
|
|||
"analyze_swift_types",
|
||||
"find_xrefs_to_name",
|
||||
"binary_overview",
|
||||
"analyze_function",
|
||||
"trace_feature",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { enhancedInputSchemas } from "./enhancedInputs.js";
|
||||
import {
|
||||
enhancedOutputSchemas,
|
||||
officialOutputSchemas,
|
||||
sessionOutputSchemas,
|
||||
} from "./toolOutputSchemas.js";
|
||||
|
||||
const document = z.string().optional().describe("The document name");
|
||||
const address = z.string().describe("A Hopper address");
|
||||
|
|
@ -11,6 +16,17 @@ const caseSensitive = z
|
|||
.boolean()
|
||||
.default(false)
|
||||
.describe("Whether to match case");
|
||||
const pagination = {
|
||||
offset: z.number().int().min(0).default(0),
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
};
|
||||
|
||||
export interface ToolAnnotations {
|
||||
readonly readOnlyHint: boolean;
|
||||
readonly destructiveHint: boolean;
|
||||
readonly idempotentHint: true;
|
||||
readonly openWorldHint: false;
|
||||
}
|
||||
|
||||
/** Adapter family responsible for implementing a public MCP tool. */
|
||||
type ToolKind = "official-proxy" | "enhanced" | "session";
|
||||
|
|
@ -21,161 +37,248 @@ export interface ToolContract {
|
|||
readonly description: string;
|
||||
readonly kind: ToolKind;
|
||||
readonly inputSchema: z.ZodObject;
|
||||
readonly outputSchema: z.ZodObject;
|
||||
readonly annotations: ToolAnnotations;
|
||||
}
|
||||
|
||||
const annotations = (name: string, kind: ToolKind): ToolAnnotations => ({
|
||||
readOnlyHint:
|
||||
kind === "enhanced" ||
|
||||
(!name.startsWith("set_") &&
|
||||
name !== "unset_bookmark" &&
|
||||
name !== "goto_address" &&
|
||||
kind !== "session"),
|
||||
destructiveHint:
|
||||
name === "unset_bookmark" ||
|
||||
name === "set_address_name" ||
|
||||
name === "set_addresses_names" ||
|
||||
name === "set_comment" ||
|
||||
name === "set_inline_comment",
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
});
|
||||
|
||||
const official = (
|
||||
name: string,
|
||||
description: string,
|
||||
inputSchema: z.ZodObject,
|
||||
): ToolContract => ({ name, description, kind: "official-proxy", inputSchema });
|
||||
): ToolContract => ({
|
||||
name,
|
||||
description,
|
||||
kind: "official-proxy",
|
||||
inputSchema,
|
||||
outputSchema: requireOutputSchema(officialOutputSchemas, name),
|
||||
annotations: annotations(name, "official-proxy"),
|
||||
});
|
||||
|
||||
const enhanced = (
|
||||
name: string,
|
||||
description: string,
|
||||
inputSchema: z.ZodObject,
|
||||
): ToolContract => ({ name, description, kind: "enhanced", inputSchema });
|
||||
): ToolContract => ({
|
||||
name,
|
||||
description,
|
||||
kind: "enhanced",
|
||||
inputSchema,
|
||||
outputSchema: requireOutputSchema(enhancedOutputSchemas, name),
|
||||
annotations: annotations(name, "enhanced"),
|
||||
});
|
||||
|
||||
const session = (
|
||||
name: string,
|
||||
description: string,
|
||||
inputSchema: z.ZodObject,
|
||||
): ToolContract => ({ name, description, kind: "session", inputSchema });
|
||||
): ToolContract => ({
|
||||
name,
|
||||
description,
|
||||
kind: "session",
|
||||
inputSchema,
|
||||
outputSchema: requireOutputSchema(sessionOutputSchemas, name),
|
||||
annotations: annotations(name, "session"),
|
||||
});
|
||||
|
||||
const requireOutputSchema = (
|
||||
schemas: Readonly<Record<string, z.ZodObject>>,
|
||||
name: string,
|
||||
): z.ZodObject => {
|
||||
const schema = schemas[name];
|
||||
if (schema === undefined)
|
||||
throw new Error(`Missing output schema for ${name}`);
|
||||
return schema;
|
||||
};
|
||||
|
||||
/** Bridge operations exposed without additional application composition. */
|
||||
export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
official(
|
||||
"address_name",
|
||||
"Get the name at an address",
|
||||
"Resolve the analyzed name at a code or data address, defaulting to Hopper's current cursor. Use before following a symbol into xrefs; null means Hopper has no name at that address.",
|
||||
z.object({ document, address: optionalAddress }),
|
||||
),
|
||||
official(
|
||||
"comment",
|
||||
"Get the comment at an address",
|
||||
"Read the regular analysis comment at an address, defaulting to the current cursor. This is read-only and returns null when no comment exists; use set_comment to persist a finding.",
|
||||
z.object({ document, address: optionalAddress }),
|
||||
),
|
||||
official(
|
||||
"current_address",
|
||||
"Get the current address",
|
||||
"Return Hopper's current cursor address for the selected document. Use only for interactive navigation state; prefer explicit addresses in reproducible investigations.",
|
||||
z.object({ document }),
|
||||
),
|
||||
official(
|
||||
"current_procedure",
|
||||
"Get the current procedure",
|
||||
"Return the analyzed procedure containing Hopper's current cursor. Use procedure_info or analyze_function next; this depends on GUI cursor state and is not an exhaustive lookup.",
|
||||
z.object({ document }),
|
||||
),
|
||||
official("current_document", "Get the current document", z.object({})),
|
||||
official(
|
||||
"current_document",
|
||||
"Return the document currently selected by REA's Hopper bridge. Use list_documents before switching when several documents are open.",
|
||||
z.object({}),
|
||||
),
|
||||
official(
|
||||
"goto_address",
|
||||
"Navigate to an address",
|
||||
"Move Hopper's GUI cursor to a hexadecimal address and return the resolved address. This changes navigation state but not analysis data; use explicit-address tools for headless workflows.",
|
||||
z.object({ address, document }),
|
||||
),
|
||||
official(
|
||||
"inline_comment",
|
||||
"Get the inline comment at an address",
|
||||
"Read the inline instruction comment at an address, defaulting to the current cursor. Returns null when absent; use set_inline_comment to write one.",
|
||||
z.object({ document, address: optionalAddress }),
|
||||
),
|
||||
official("list_bookmarks", "List bookmarks", z.object({ document })),
|
||||
official("list_documents", "List open documents", z.object({})),
|
||||
official(
|
||||
"list_bookmarks",
|
||||
"List every bookmark in the selected Hopper document as address and name pairs. Use bookmarks as analyst-authored navigation aids; this does not discover code references.",
|
||||
z.object({ document }),
|
||||
),
|
||||
official(
|
||||
"list_documents",
|
||||
"List all Hopper documents visible to the bridge. Use before set_current_document when a provider session contains multiple documents.",
|
||||
z.object({}),
|
||||
),
|
||||
official(
|
||||
"list_names",
|
||||
"List names",
|
||||
z.object({ document, address: optionalAddress }),
|
||||
"Page through analyzed names as address/value pairs. Results are bounded by offset and limit; follow next_offset until has_more is false before claiming exhaustive symbol coverage.",
|
||||
z.object({ document, address: optionalAddress, ...pagination }),
|
||||
),
|
||||
official(
|
||||
"list_procedures",
|
||||
"Page through analyzed procedures as address/value pairs. Wait for Hopper analysis first, then follow next_offset for exhaustive coverage; use returned addresses with analyze_function or procedure_pseudo_code.",
|
||||
z.object({ document, ...pagination }),
|
||||
),
|
||||
official(
|
||||
"list_segments",
|
||||
"List all segments and sections in the selected document. Hopper's public API does not expose permissions, so writable and executable are null with explicit capability metadata.",
|
||||
z.object({ document }),
|
||||
),
|
||||
official("list_procedures", "List procedures", z.object({ document })),
|
||||
official("list_segments", "List segments", z.object({ document })),
|
||||
official(
|
||||
"list_strings",
|
||||
"List strings",
|
||||
z.object({ document, address: optionalAddress }),
|
||||
"Page through analyzed strings, or filter to one address, as address/value pairs. Follow next_offset for exhaustive results and use xrefs on interesting string addresses.",
|
||||
z.object({ document, address: optionalAddress, ...pagination }),
|
||||
),
|
||||
official(
|
||||
"next_address",
|
||||
"Get the next address",
|
||||
"Return the next analyzed object address after an explicit address or current cursor. This is a navigation primitive, not instruction-flow or CFG analysis.",
|
||||
z.object({ document, address: optionalAddress }),
|
||||
),
|
||||
official(
|
||||
"prev_address",
|
||||
"Get the previous address",
|
||||
"Return the previous analyzed instruction start before an explicit address or current cursor. This is a navigation primitive and may fail at document boundaries.",
|
||||
z.object({ document, address: optionalAddress }),
|
||||
),
|
||||
official(
|
||||
"procedure_address",
|
||||
"Resolve a procedure address",
|
||||
"Resolve a procedure symbol name or hexadecimal address to its entry address. Use this to canonicalize user-supplied identifiers before xrefs, assembly, or decompilation.",
|
||||
z.object({ procedure, document }),
|
||||
),
|
||||
official(
|
||||
"procedure_assembly",
|
||||
"Get procedure assembly",
|
||||
"Return assembly for one analyzed procedure identified by symbol or hexadecimal address. Use when pseudocode loses calling-convention or instruction-level detail; output is currently returned as one unpaginated string.",
|
||||
z.object({ procedure, document }),
|
||||
),
|
||||
official(
|
||||
"procedure_callees",
|
||||
"List procedure callees",
|
||||
"Return Hopper's analyzed direct callees for one procedure identified by symbol or address. Indirect calls may be absent; use analyze_function and xrefs to corroborate the call path.",
|
||||
z.object({ procedure, document }),
|
||||
),
|
||||
official(
|
||||
"procedure_callers",
|
||||
"List procedure callers",
|
||||
"Return Hopper's analyzed direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit indirect references.",
|
||||
z.object({ procedure, document }),
|
||||
),
|
||||
official(
|
||||
"procedure_info",
|
||||
"Get procedure metadata",
|
||||
"Return bounded metadata for one procedure identified by symbol or address: entrypoint, signature, locals, size, and block count. Follow with decompilation or assembly for behavior.",
|
||||
z.object({ procedure, document }),
|
||||
),
|
||||
official(
|
||||
"procedure_references",
|
||||
"Return a bounded set of raw incoming or outgoing reference edges for one procedure. Endpoint procedures are resolved only when Hopper reports containment; reference kinds remain explicitly unavailable.",
|
||||
z.object({
|
||||
procedure,
|
||||
direction: z.enum(["incoming", "outgoing"]).default("outgoing"),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
max_instructions: z.number().int().min(1).max(5000).default(500),
|
||||
document,
|
||||
}),
|
||||
),
|
||||
official(
|
||||
"procedure_pseudo_code",
|
||||
"Decompile a procedure",
|
||||
"Decompile one analyzed procedure by symbol name or hexadecimal address. Returns Hopper pseudocode, not original source, and may return null; request procedure_assembly when instruction precision matters.",
|
||||
z.object({ procedure, document }),
|
||||
),
|
||||
official(
|
||||
"resolve_containing_procedure",
|
||||
"Resolve an arbitrary address, including an xref source or interior instruction, to its Hopper-analyzed containing procedure. A negative result includes an explicit reason and is not guessed from nearby symbols.",
|
||||
z.object({ address, document }),
|
||||
),
|
||||
official(
|
||||
"search_procedures",
|
||||
"Search procedure names",
|
||||
"Regex-search all analyzed procedure names with optional case sensitivity. The current bridge returns an unpaginated address/name map, so constrain patterns and use list_procedures for controlled exhaustive traversal.",
|
||||
z.object({ pattern, case_sensitive: caseSensitive, document }),
|
||||
),
|
||||
official(
|
||||
"search_strings",
|
||||
"Search strings",
|
||||
"Regex-search all analyzed strings with optional case sensitivity. The current bridge is unpaginated and evaluates Python regex, so use narrow patterns and follow matches with xrefs.",
|
||||
z.object({ pattern, case_sensitive: caseSensitive, document }),
|
||||
),
|
||||
official(
|
||||
"set_address_name",
|
||||
"Set an address name",
|
||||
"Assign an analyst name to one hexadecimal address and report Hopper's boolean result. This mutates analysis metadata; read it back with address_name before relying on it.",
|
||||
z.object({ address, name: z.string(), document }),
|
||||
),
|
||||
official(
|
||||
"set_addresses_names",
|
||||
"Set multiple address names",
|
||||
"Assign analyst names to an address/name map and return per-address success booleans. This mutates analysis metadata; use for bounded batches and verify failures individually.",
|
||||
z.object({ names: z.record(z.string(), z.string()), document }),
|
||||
),
|
||||
official(
|
||||
"set_bookmark",
|
||||
"Set a bookmark",
|
||||
"Create or replace a bookmark at a hexadecimal address. This mutates navigation metadata; verify with list_bookmarks and do not treat bookmarks as binary evidence.",
|
||||
z.object({ address, name: z.string().optional(), document }),
|
||||
),
|
||||
official(
|
||||
"set_comment",
|
||||
"Set an address comment",
|
||||
"Write a regular analysis comment at a hexadecimal address and return whether readback matched. This mutates the Hopper document; use comments to record evidence IDs or reasoning.",
|
||||
z.object({ address, comment: z.string(), document }),
|
||||
),
|
||||
official(
|
||||
"set_current_document",
|
||||
"Select the current document",
|
||||
"Select an already-open Hopper document by exact document name. This changes subsequent default-document routing; call list_documents first and prefer explicit document inputs where reproducibility matters.",
|
||||
z.object({ document: z.string() }),
|
||||
),
|
||||
official(
|
||||
"set_inline_comment",
|
||||
"Set an inline comment",
|
||||
"Write an inline instruction comment at a hexadecimal address and return whether readback matched. This mutates analysis metadata; confirm with inline_comment.",
|
||||
z.object({ address, comment: z.string(), document }),
|
||||
),
|
||||
official(
|
||||
"unset_bookmark",
|
||||
"Remove a bookmark",
|
||||
"Remove the bookmark at a hexadecimal address and return whether it is absent. This mutates navigation metadata and does not alter binary bytes.",
|
||||
z.object({ address, document }),
|
||||
),
|
||||
official(
|
||||
"xrefs",
|
||||
"Get cross-references to an address",
|
||||
"Return analyzed references to a code or data address, defaulting to the current cursor. Use to connect strings, globals, selectors, and functions; bare addresses are untyped and indirect references may be incomplete.",
|
||||
z.object({ document, address: optionalAddress }),
|
||||
),
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
|
@ -184,60 +287,78 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
|||
export const ENHANCED_TOOL_CONTRACTS = [
|
||||
enhanced(
|
||||
"swift_classes",
|
||||
"Discover Swift classes",
|
||||
"Discover legacy-mangled Swift class procedures after exhaustively paging analyzed procedures. Returns at most 100 entries and scans at most 5,000 symbols; use analyze_swift_types for other Swift kinds.",
|
||||
enhancedInputSchemas.swift_classes,
|
||||
),
|
||||
enhanced(
|
||||
"get_objc_classes",
|
||||
"Discover Objective-C classes",
|
||||
"Discover and deduplicate Objective-C class labels after exhaustively paging names, optionally filtering by literal substring. Returns at most 100 classes; inspect matching metadata and references next.",
|
||||
enhancedInputSchemas.get_objc_classes,
|
||||
),
|
||||
enhanced(
|
||||
"get_objc_protocols",
|
||||
"Discover Objective-C protocols",
|
||||
"Discover and deduplicate Objective-C and Swift protocol labels after exhaustively paging names. Returns at most 100 entries; use xrefs or analyze_function to connect a protocol to implementations.",
|
||||
enhancedInputSchemas.get_objc_protocols,
|
||||
),
|
||||
enhanced(
|
||||
"batch_decompile",
|
||||
"Decompile up to 20 procedures",
|
||||
"Decompile up to 20 explicit procedure symbols or addresses concurrently. Per-item strings may contain errors or no-output markers, so validate each result; use analyze_function for a richer single-function dossier.",
|
||||
enhancedInputSchemas.batch_decompile,
|
||||
),
|
||||
enhanced(
|
||||
"get_call_graph",
|
||||
"Traverse a bounded call graph",
|
||||
"Traverse Hopper's caller or callee relationships from one symbol or address for at most five levels. Nodes preserve per-procedure errors; indirect calls may be missing and results are not a whole-program CFG.",
|
||||
enhancedInputSchemas.get_call_graph,
|
||||
),
|
||||
enhanced(
|
||||
"analyze_swift_types",
|
||||
"Categorize Swift mangled names",
|
||||
"Categorize exhaustively paged procedure names into Swift classes, structs, enums, protocols, extensions, and other symbols. Scans at most 5,000 names and returns at most 50 entries per category.",
|
||||
enhancedInputSchemas.analyze_swift_types,
|
||||
),
|
||||
enhanced(
|
||||
"find_xrefs_to_name",
|
||||
"Find cross-references by name",
|
||||
"Resolve a name through Hopper and return analyzed references to its address. Use when starting from a selector or symbol; resolution failure is returned explicitly and xrefs remain untyped.",
|
||||
enhancedInputSchemas.find_xrefs_to_name,
|
||||
),
|
||||
enhanced(
|
||||
"binary_overview",
|
||||
"Summarize the loaded binary",
|
||||
"Use immediately after opening a target to summarize document, exhaustive procedure/string counts, and a bounded segment sample. detail controls segment fields and limit controls only the returned segment sample.",
|
||||
enhancedInputSchemas.binary_overview,
|
||||
),
|
||||
enhanced(
|
||||
"analyze_function",
|
||||
"Preferred bounded analysis for one procedure symbol or address. Returns identity, pseudocode, optional assembly, comments, calls, incoming references, and blocks; unsupported outgoing references and CFG edges carry explicit unavailable metadata.",
|
||||
enhancedInputSchemas.analyze_function,
|
||||
),
|
||||
enhanced(
|
||||
"trace_feature",
|
||||
"Trace a bounded literal feature query through matching strings and procedures, xrefs, and truthful containing-procedure resolution. Returns the operation budget, truncation, and residual unknowns; it does not infer reference kinds.",
|
||||
enhancedInputSchemas.trace_feature,
|
||||
),
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
||||
/** Target lifecycle tools available only on the long-lived MCP adapter. */
|
||||
export const SESSION_TOOL_CONTRACTS = [
|
||||
session(
|
||||
"open_binary",
|
||||
"Open or switch the active binary",
|
||||
"Open a local executable, application bundle, or Hopper database, replacing the active target only after validation. This launches Hopper and may show UI; call binary_overview after success.",
|
||||
z.object({ path: z.string().min(1) }),
|
||||
),
|
||||
session("close_binary", "Close the active binary", z.object({})),
|
||||
session("binary_session", "Describe the active binary session", z.object({})),
|
||||
session(
|
||||
"close_binary",
|
||||
"Close the active Hopper-backed target and release its provider process. The operation is idempotent; call binary_session to verify the session is closed.",
|
||||
z.object({}),
|
||||
),
|
||||
session(
|
||||
"binary_session",
|
||||
"Report whether a target is open and, when open, its canonical path, format, and kind. Use before analysis calls or target switches; this performs no analysis.",
|
||||
z.object({}),
|
||||
),
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
||||
/**
|
||||
* Complete ordered public inventory used by registration and verification.
|
||||
* Keep this collection at 42 tools unless a deliberate contract change updates
|
||||
* Keep this collection at 46 tools unless a deliberate contract change updates
|
||||
* snapshots, package verification, and real-Hopper verification together.
|
||||
*/
|
||||
export const TOOL_CONTRACTS = [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,287 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const evidenceMetadata = {
|
||||
schema_version: z.literal(1),
|
||||
artifact: z
|
||||
.object({
|
||||
path: z.string(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
format: z.enum(["hopper", "mach-o", "elf", "pe"]),
|
||||
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
provider: z.object({ id: z.literal("hopper"), version: z.null() }),
|
||||
operation: z.string().min(1),
|
||||
parameters: z.record(z.string(), z.json()),
|
||||
confidence: z.literal("observed"),
|
||||
limitations: z.array(z.string()),
|
||||
};
|
||||
const resultOf = (schema: z.ZodType) =>
|
||||
z.object({ ...evidenceMetadata, result: schema });
|
||||
const lifecycleResultOf = (schema: z.ZodType) => z.object({ result: schema });
|
||||
const nullableText = z.string().nullable();
|
||||
const addressList = z.array(z.string());
|
||||
const addressedEntry = z.object({ address: z.string(), name: z.string() });
|
||||
const procedureIdentity = z.object({ address: z.string(), name: z.string() });
|
||||
const containingProcedureResolution = z.discriminatedUnion("found", [
|
||||
z.object({
|
||||
query_address: z.string(),
|
||||
found: z.literal(true),
|
||||
procedure: procedureIdentity,
|
||||
}),
|
||||
z.object({
|
||||
query_address: z.string(),
|
||||
found: z.literal(false),
|
||||
procedure: z.null(),
|
||||
reason: z.enum(["outside_segments", "not_in_procedure"]),
|
||||
}),
|
||||
]);
|
||||
const unavailable = z.object({
|
||||
available: z.literal(false),
|
||||
reason: z.string(),
|
||||
});
|
||||
const bounded = (item: z.ZodType) =>
|
||||
z.object({
|
||||
items: z.array(item),
|
||||
total: z.number().int().min(0).nullable(),
|
||||
returned: z.number().int().min(0),
|
||||
truncated: z.boolean(),
|
||||
next_offset: z.number().int().min(0).nullable(),
|
||||
});
|
||||
const pageOutput = z.object({
|
||||
items: z.array(z.object({ address: z.string(), value: z.string() })),
|
||||
offset: z.number().int().min(0),
|
||||
limit: z.number().int().min(1),
|
||||
total: z.number().int().min(0),
|
||||
next_offset: z.number().int().min(0).nullable(),
|
||||
has_more: z.boolean(),
|
||||
});
|
||||
const segmentOutput = resultOf(
|
||||
z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
start: z.string(),
|
||||
end: z.string(),
|
||||
writable: z.boolean().nullable(),
|
||||
executable: z.boolean().nullable(),
|
||||
permissions: unavailable,
|
||||
sections: z.array(
|
||||
z.object({ name: z.string(), start: z.string(), end: z.string() }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const procedureInfoOutput = resultOf(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
entrypoint: z.string(),
|
||||
basicblock_count: z.number().int().min(0),
|
||||
length: z.number().min(0),
|
||||
signature: nullableText,
|
||||
locals: z.array(z.json()),
|
||||
}),
|
||||
);
|
||||
const symbolDiscoveryOutput = (property: "classes" | "protocols") =>
|
||||
resultOf(
|
||||
z.object({
|
||||
count: z.number().int().min(0),
|
||||
[property]: z.array(addressedEntry),
|
||||
}),
|
||||
);
|
||||
const graphNode = z.union([
|
||||
z.object({ address: z.string(), calls: z.array(z.string()) }),
|
||||
z.object({ address: z.string(), error: z.string() }),
|
||||
]);
|
||||
const unavailableOrBoundedStrings = z.union([unavailable, bounded(z.string())]);
|
||||
const functionDossierOutput = resultOf(
|
||||
z.object({
|
||||
procedure: z.object({
|
||||
address: z.string(),
|
||||
name: z.string(),
|
||||
signature: nullableText,
|
||||
locals: z.array(z.json()),
|
||||
}),
|
||||
pseudocode: z.object({
|
||||
text: z.string(),
|
||||
total_chars: z.number().int().min(0),
|
||||
returned_chars: z.number().int().min(0),
|
||||
truncated: z.boolean(),
|
||||
next_offset: z.number().int().min(0).nullable(),
|
||||
}),
|
||||
assembly: bounded(z.string()),
|
||||
comments: bounded(
|
||||
z.object({
|
||||
address: z.string(),
|
||||
kind: z.enum(["comment", "inline"]),
|
||||
text: z.string(),
|
||||
}),
|
||||
),
|
||||
callers: bounded(z.string()),
|
||||
callees: bounded(z.string()),
|
||||
incoming_references: bounded(z.string()),
|
||||
referenced_strings: unavailableOrBoundedStrings,
|
||||
referenced_names: unavailableOrBoundedStrings,
|
||||
basic_blocks: bounded(
|
||||
z.object({
|
||||
start: z.string(),
|
||||
end: z.string(),
|
||||
successors: z.union([unavailable, z.array(z.string())]),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Exact structured-content schemas for the direct Hopper operations. */
|
||||
export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
address_name: resultOf(nullableText),
|
||||
comment: resultOf(nullableText),
|
||||
current_address: resultOf(z.string()),
|
||||
current_procedure: resultOf(z.string()),
|
||||
current_document: resultOf(z.string()),
|
||||
goto_address: resultOf(z.string()),
|
||||
inline_comment: resultOf(nullableText),
|
||||
list_bookmarks: resultOf(z.array(addressedEntry)),
|
||||
list_documents: resultOf(z.array(z.string())),
|
||||
list_names: resultOf(pageOutput),
|
||||
list_procedures: resultOf(pageOutput),
|
||||
list_segments: segmentOutput,
|
||||
list_strings: resultOf(pageOutput),
|
||||
next_address: resultOf(z.string()),
|
||||
prev_address: resultOf(z.string()),
|
||||
procedure_address: resultOf(z.string()),
|
||||
procedure_assembly: resultOf(z.string()),
|
||||
procedure_callees: resultOf(addressList),
|
||||
procedure_callers: resultOf(addressList),
|
||||
procedure_info: procedureInfoOutput,
|
||||
procedure_references: resultOf(
|
||||
z.object({
|
||||
procedure: procedureIdentity,
|
||||
direction: z.enum(["incoming", "outgoing"]),
|
||||
references: bounded(
|
||||
z.object({
|
||||
source_address: z.string(),
|
||||
target_address: z.string(),
|
||||
source_procedure: procedureIdentity.nullable(),
|
||||
target_procedure: procedureIdentity.nullable(),
|
||||
kind: unavailable,
|
||||
}),
|
||||
),
|
||||
instructions_scanned: z.number().int().min(0),
|
||||
instruction_scan_truncated: z.boolean(),
|
||||
}),
|
||||
),
|
||||
procedure_pseudo_code: resultOf(nullableText),
|
||||
resolve_containing_procedure: resultOf(containingProcedureResolution),
|
||||
search_procedures: resultOf(z.record(z.string(), z.string())),
|
||||
search_strings: resultOf(z.record(z.string(), z.string())),
|
||||
set_address_name: resultOf(z.boolean()),
|
||||
set_addresses_names: resultOf(z.record(z.string(), z.boolean())),
|
||||
set_bookmark: resultOf(z.boolean()),
|
||||
set_comment: resultOf(z.boolean()),
|
||||
set_current_document: resultOf(z.string()),
|
||||
set_inline_comment: resultOf(z.boolean()),
|
||||
unset_bookmark: resultOf(z.boolean()),
|
||||
xrefs: resultOf(addressList),
|
||||
};
|
||||
|
||||
/** Exact structured-content schemas for composed analysis workflows. */
|
||||
export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
swift_classes: symbolDiscoveryOutput("classes"),
|
||||
get_objc_classes: symbolDiscoveryOutput("classes"),
|
||||
get_objc_protocols: symbolDiscoveryOutput("protocols"),
|
||||
batch_decompile: resultOf(
|
||||
z.union([
|
||||
z.object({ error: z.literal("No addresses provided") }),
|
||||
z.record(z.string(), z.string()),
|
||||
]),
|
||||
),
|
||||
get_call_graph: resultOf(z.record(z.string(), z.array(graphNode))),
|
||||
analyze_swift_types: resultOf(
|
||||
z.object({
|
||||
total: z.number().int().min(0),
|
||||
categories: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
count: z.number().int().min(0),
|
||||
items: z.array(addressedEntry),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
find_xrefs_to_name: resultOf(
|
||||
z.union([
|
||||
z.object({ xrefs: addressList }),
|
||||
z.object({ error: z.string() }),
|
||||
]),
|
||||
),
|
||||
binary_overview: resultOf(
|
||||
z.object({
|
||||
document: z.string(),
|
||||
detail: z.enum(["concise", "detailed"]),
|
||||
segments: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
start: z.string(),
|
||||
end: z.string(),
|
||||
length: z.number().min(0).optional(),
|
||||
}),
|
||||
),
|
||||
segment_count: z.number().int().min(0),
|
||||
procedure_count: z.number().int().min(0),
|
||||
string_count: z.number().int().min(0),
|
||||
}),
|
||||
),
|
||||
analyze_function: functionDossierOutput,
|
||||
trace_feature: resultOf(
|
||||
z.object({
|
||||
query: z.string(),
|
||||
search_mode: z.literal("literal"),
|
||||
operations_used: z.number().int().min(0),
|
||||
operation_budget: z.number().int().min(1),
|
||||
matches: z.array(
|
||||
z.object({
|
||||
type: z.enum(["string", "procedure"]),
|
||||
address: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
references: z.array(
|
||||
z.object({
|
||||
target_address: z.string(),
|
||||
source_address: z.string(),
|
||||
containing_procedure: containingProcedureResolution,
|
||||
}),
|
||||
),
|
||||
truncated: z.boolean(),
|
||||
residual_unknowns: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
/** Exact structured-content schemas for target lifecycle operations. */
|
||||
export const sessionOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
open_binary: lifecycleResultOf(
|
||||
z.object({
|
||||
path: z.string(),
|
||||
format: z.enum(["hopper", "mach-o", "elf", "pe"]),
|
||||
kind: z.enum(["executable", "database"]),
|
||||
loaderArgs: z.array(z.string()),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
|
||||
}),
|
||||
),
|
||||
close_binary: lifecycleResultOf(z.null()),
|
||||
binary_session: lifecycleResultOf(
|
||||
z.union([
|
||||
z.object({ open: z.literal(false) }),
|
||||
z.object({
|
||||
open: z.literal(true),
|
||||
path: z.string(),
|
||||
format: z.enum(["hopper", "mach-o", "elf", "pe"]),
|
||||
kind: z.enum(["executable", "database"]),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { constants } from "node:fs";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
access,
|
||||
open,
|
||||
|
|
@ -25,6 +27,7 @@ type BinaryArchitecture = "x86" | "x86_64" | "arm" | "arm64";
|
|||
*/
|
||||
export interface BinaryTarget {
|
||||
readonly path: string;
|
||||
readonly sha256: string;
|
||||
readonly kind: "executable" | "database";
|
||||
readonly format: "hopper" | "mach-o" | "elf" | "pe";
|
||||
readonly architecture?: BinaryArchitecture;
|
||||
|
|
@ -56,7 +59,13 @@ export const parseBinaryTarget = async (
|
|||
targetKind === "database" ||
|
||||
(targetKind === undefined && path.toLowerCase().endsWith(".hop"))
|
||||
)
|
||||
return ok({ path, kind: "database", format: "hopper", loaderArgs: [] });
|
||||
return ok({
|
||||
path,
|
||||
sha256: await sha256File(path),
|
||||
kind: "database",
|
||||
format: "hopper",
|
||||
loaderArgs: [],
|
||||
});
|
||||
const handle = await open(path, "r");
|
||||
let detected: Result<ExecutableMetadata, string>;
|
||||
try {
|
||||
|
|
@ -65,7 +74,12 @@ export const parseBinaryTarget = async (
|
|||
await handle.close();
|
||||
}
|
||||
if (!detected.ok) return err(new BinaryTargetError(path, detected.error));
|
||||
return ok({ path, kind: "executable", ...detected.value });
|
||||
return ok({
|
||||
path,
|
||||
sha256: await sha256File(path),
|
||||
kind: "executable",
|
||||
...detected.value,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new BinaryTargetError(candidate, "path is not readable", { cause }),
|
||||
|
|
@ -73,6 +87,12 @@ export const parseBinaryTarget = async (
|
|||
}
|
||||
};
|
||||
|
||||
const sha256File = async (path: string): Promise<string> => {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
||||
return hash.digest("hex");
|
||||
};
|
||||
|
||||
const resolveAppBundle = async (
|
||||
path: string,
|
||||
): Promise<Result<string, BinaryTargetError>> => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
/** Base class for expected Hopper integration failures. */
|
||||
export abstract class HopperError extends Error {
|
||||
/** Base class for expected analysis, provider, and session failures. */
|
||||
export abstract class AnalysisError extends Error {
|
||||
abstract readonly _tag: string;
|
||||
}
|
||||
|
||||
/** Base class for failures produced specifically by the Hopper provider. */
|
||||
export abstract class HopperError extends AnalysisError {}
|
||||
|
||||
/** Provider-neutral invalid analysis input or output at an application boundary. */
|
||||
export class AnalysisProtocolError extends AnalysisError {
|
||||
readonly _tag = "HopperProtocolError";
|
||||
}
|
||||
|
||||
/** Hopper did not respond within the configured operation deadline. */
|
||||
export class HopperTimeoutError extends HopperError {
|
||||
readonly _tag = "HopperTimeoutError";
|
||||
|
|
@ -57,12 +65,12 @@ export class HopperStartError extends HopperError {
|
|||
}
|
||||
|
||||
/** Runtime configuration could not be parsed safely. */
|
||||
export class ConfigurationError extends HopperError {
|
||||
export class ConfigurationError extends AnalysisError {
|
||||
readonly _tag = "ConfigurationError";
|
||||
}
|
||||
|
||||
/** No app or binary session exists for an analysis request. */
|
||||
export class NoBinaryOpenError extends HopperError {
|
||||
export class NoBinaryOpenError extends AnalysisError {
|
||||
readonly _tag = "NoBinaryOpenError";
|
||||
constructor() {
|
||||
super(
|
||||
|
|
@ -72,7 +80,7 @@ export class NoBinaryOpenError extends HopperError {
|
|||
}
|
||||
|
||||
/** A supplied target path could not be safely opened as a supported app or binary. */
|
||||
export class BinaryTargetError extends HopperError {
|
||||
export class BinaryTargetError extends AnalysisError {
|
||||
readonly _tag = "BinaryTargetError";
|
||||
constructor(
|
||||
readonly path: string,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import type { BinaryTarget } from "./binaryTarget.js";
|
||||
import { jsonValueSchema, type JsonValue } from "./jsonValue.js";
|
||||
|
||||
const artifactEvidenceSchema = z.object({
|
||||
path: z.string(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
format: z.enum(["hopper", "mach-o", "elf", "pe"]),
|
||||
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
|
||||
});
|
||||
|
||||
/** Strict JSON representation of one successful public analysis observation. */
|
||||
export const evidenceSchema = z.object({
|
||||
schema_version: z.literal(1),
|
||||
artifact: artifactEvidenceSchema.nullable(),
|
||||
provider: z.object({ id: z.literal("hopper"), version: z.null() }),
|
||||
operation: z.string().min(1),
|
||||
parameters: z.record(z.string(), jsonValueSchema),
|
||||
result: jsonValueSchema,
|
||||
confidence: z.literal("observed"),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type Evidence = z.infer<typeof evidenceSchema>;
|
||||
|
||||
export interface EvidenceObservation {
|
||||
readonly operation: string;
|
||||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
readonly result: JsonValue;
|
||||
readonly limitations?: readonly string[];
|
||||
}
|
||||
|
||||
/** Build deterministic evidence without claiming unavailable provider metadata. */
|
||||
export const createEvidence = (
|
||||
target: BinaryTarget | undefined,
|
||||
observation: EvidenceObservation,
|
||||
): Evidence =>
|
||||
evidenceSchema.parse({
|
||||
schema_version: 1,
|
||||
artifact:
|
||||
target === undefined
|
||||
? null
|
||||
: {
|
||||
path: target.path,
|
||||
sha256: target.sha256,
|
||||
format: target.format,
|
||||
architecture: target.architecture ?? null,
|
||||
},
|
||||
provider: { id: "hopper", version: null },
|
||||
operation: observation.operation,
|
||||
parameters: observation.parameters,
|
||||
result: observation.result,
|
||||
confidence: "observed",
|
||||
limitations:
|
||||
target === undefined
|
||||
? [
|
||||
"Artifact identity is unavailable for this fixed-target adapter.",
|
||||
...(observation.limitations ?? []),
|
||||
]
|
||||
: [...(observation.limitations ?? [])],
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import type { JsonValue } from "../hopper/protocol.js";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import { HopperProtocolError } from "./errors.js";
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
|
||||
|
|
@ -15,6 +15,12 @@ export interface SegmentSummary {
|
|||
readonly end: string;
|
||||
}
|
||||
|
||||
export interface AddressedPage {
|
||||
readonly items: readonly AddressedName[];
|
||||
readonly nextOffset: number | null;
|
||||
readonly hasMore: boolean;
|
||||
}
|
||||
|
||||
const procedureMapSchema = z.record(z.string(), z.string());
|
||||
const addressedNamesSchema = z.array(
|
||||
z.object({ address: z.string(), name: z.string() }),
|
||||
|
|
@ -25,11 +31,35 @@ const segmentSchema = z.object({
|
|||
start: z.string().default(""),
|
||||
end: z.string().default(""),
|
||||
});
|
||||
const addressedPageSchema = z.object({
|
||||
items: z.array(z.object({ address: z.string(), value: z.string() })),
|
||||
next_offset: z.number().int().min(0).nullable(),
|
||||
has_more: z.boolean(),
|
||||
});
|
||||
|
||||
/** Parse page entries and continuation metadata returned by list operations. */
|
||||
export const parseAddressedPage = (
|
||||
value: JsonValue,
|
||||
): Result<AddressedPage, HopperProtocolError> => {
|
||||
const parsed = addressedPageSchema.safeParse(value);
|
||||
return parsed.success
|
||||
? ok({
|
||||
items: parsed.data.items.map(({ address, value: name }) => ({
|
||||
address,
|
||||
name,
|
||||
})),
|
||||
nextOffset: parsed.data.next_offset,
|
||||
hasMore: parsed.data.has_more,
|
||||
})
|
||||
: invalid("addressed page", parsed.error);
|
||||
};
|
||||
|
||||
/** Parse Hopper's direct or wrapped procedure map into stable entries. */
|
||||
export const parseProcedures = (
|
||||
value: JsonValue,
|
||||
): Result<readonly AddressedName[], HopperProtocolError> => {
|
||||
const page = parseAddressedPage(value);
|
||||
if (page.ok) return ok(page.value.items);
|
||||
const parsed = procedureMapSchema.safeParse(
|
||||
unwrapProperty(value, "procedures"),
|
||||
);
|
||||
|
|
@ -40,7 +70,7 @@ export const parseProcedures = (
|
|||
name,
|
||||
})),
|
||||
)
|
||||
: invalid("procedure map", parsed.error);
|
||||
: page;
|
||||
};
|
||||
|
||||
/** Parse Hopper's direct or wrapped list of address/name records. */
|
||||
|
|
@ -48,6 +78,15 @@ export const parseNames = (
|
|||
value: JsonValue,
|
||||
): Result<readonly AddressedName[], HopperProtocolError> => {
|
||||
const unwrapped = unwrapProperty(value, "names");
|
||||
const page = z
|
||||
.object({
|
||||
items: z.array(z.object({ address: z.string(), value: z.string() })),
|
||||
})
|
||||
.safeParse(unwrapped);
|
||||
if (page.success)
|
||||
return ok(
|
||||
page.data.items.map(({ address, value: name }) => ({ address, name })),
|
||||
);
|
||||
const records = addressedNamesSchema.safeParse(unwrapped);
|
||||
if (records.success) return ok(records.data);
|
||||
const map = addressedNameMapSchema.safeParse(unwrapped);
|
||||
|
|
@ -97,6 +136,12 @@ export const parseListCount = (
|
|||
property: string,
|
||||
): Result<number, HopperProtocolError> => {
|
||||
const unwrapped = unwrapProperty(value, property);
|
||||
const pageTotal = z
|
||||
.object({ total: z.number().int().min(0) })
|
||||
.safeParse(unwrapped);
|
||||
if (pageTotal.success) return ok(pageTotal.data.total);
|
||||
const page = z.object({ items: z.array(z.unknown()) }).safeParse(unwrapped);
|
||||
if (page.success) return ok(page.data.items.length);
|
||||
const list = z.array(z.unknown()).safeParse(unwrapped);
|
||||
if (list.success) return ok(list.data.length);
|
||||
const map = z.record(z.string(), z.unknown()).safeParse(unwrapped);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/** JSON-safe value shared by domain, application, and adapter boundaries. */
|
||||
export const jsonValueSchema = z.json();
|
||||
|
||||
export type JsonValue = z.infer<typeof jsonValueSchema>;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { JsonValue } from "../hopper/protocol.js";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import type { AddressedName } from "./hopperValues.js";
|
||||
|
||||
const MAX_SCANNED_SYMBOLS = 5_000;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisProvider,
|
||||
ProviderIdentity,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { HopperApplicationLauncher } from "./BridgeLauncher.js";
|
||||
import { HopperClient } from "./HopperClient.js";
|
||||
|
||||
const IDENTITY: ProviderIdentity = {
|
||||
id: "hopper",
|
||||
name: "Hopper Disassembler",
|
||||
version: null,
|
||||
};
|
||||
|
||||
const CAPABILITIES = [
|
||||
"direct-analysis",
|
||||
"decompilation",
|
||||
"disassembly",
|
||||
"cross-references",
|
||||
"containing-procedure-resolution",
|
||||
"procedure-references",
|
||||
"analysis-metadata-mutation",
|
||||
] as const;
|
||||
|
||||
/** Concrete analysis provider backed by REA's private Hopper bridge. */
|
||||
export class HopperProvider implements AnalysisProvider {
|
||||
constructor(
|
||||
private readonly config: AppConfig,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
identity(): ProviderIdentity {
|
||||
return IDENTITY;
|
||||
}
|
||||
|
||||
capabilities(): readonly string[] {
|
||||
return CAPABILITIES;
|
||||
}
|
||||
|
||||
createClient(target: BinaryTarget): AnalysisClient {
|
||||
const client = new HopperClient({
|
||||
launcher: new HopperApplicationLauncher({
|
||||
launcherPath: this.config.hopperLauncherPath,
|
||||
targetPath: target.path,
|
||||
targetKind: target.kind,
|
||||
loaderArgs:
|
||||
this.config.hopperLoaderArgs.length > 0
|
||||
? this.config.hopperLoaderArgs
|
||||
: target.loaderArgs,
|
||||
bridgeScriptPath: fileURLToPath(
|
||||
new URL("../../bridge/hopper_bridge.py", import.meta.url),
|
||||
),
|
||||
}),
|
||||
logger: this.logger.child({ layer: "bridge" }),
|
||||
});
|
||||
return {
|
||||
execute: (operation, parameters, options) =>
|
||||
client.callTool(operation, parameters, options),
|
||||
close: () => client.close(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { HopperProtocolError, HopperRemoteError } from "../domain/errors.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
export const jsonValueSchema = z.json();
|
||||
export type JsonValue = z.infer<typeof jsonValueSchema>;
|
||||
export { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
const responseSchema = z.union([
|
||||
z.object({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { McpServer } from "@modelcontextprotocol/server";
|
||||
|
||||
import type { HopperToolPort } from "../application/HopperToolPort.js";
|
||||
import type { BinarySession } from "../application/BinarySession.js";
|
||||
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
|
||||
import type { BinarySessionPort } from "../application/BinarySession.js";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { registerEnhancedTools } from "./registerEnhancedTools.js";
|
||||
import { registerOfficialTools } from "./registerOfficialTools.js";
|
||||
|
|
@ -14,8 +14,8 @@ import { silentLogger, type Logger } from "../logger.js";
|
|||
* fixed-target seam used by focused tests and embedders.
|
||||
*/
|
||||
export const createServer = (
|
||||
hopper: HopperToolPort,
|
||||
session?: BinarySession,
|
||||
analysis: AnalysisOperationPort,
|
||||
session?: BinarySessionPort,
|
||||
logger: Logger = silentLogger,
|
||||
): McpServer => {
|
||||
const server = new McpServer(
|
||||
|
|
@ -24,13 +24,15 @@ export const createServer = (
|
|||
capabilities: { tools: {} },
|
||||
instructions:
|
||||
session === undefined
|
||||
? "Reverse-engineering tools for an active Hopper Disassembler target. Start with binary_overview."
|
||||
: "Reverse-engineering tools for Hopper Disassembler. Open a target with open_binary, then start with binary_overview.",
|
||||
? "Reverse-engineering tools for an active analysis target. Start with binary_overview."
|
||||
: "Reverse-engineering tools for configured analysis providers. Open a target with open_binary, then start with binary_overview.",
|
||||
},
|
||||
);
|
||||
const toolLogger = logger.child({ layer: "server" });
|
||||
registerOfficialTools(server, hopper, toolLogger);
|
||||
registerEnhancedTools(server, hopper, toolLogger);
|
||||
const activeTarget =
|
||||
session === undefined ? undefined : () => session.activeTarget();
|
||||
registerOfficialTools(server, analysis, toolLogger, activeTarget);
|
||||
registerEnhancedTools(server, analysis, toolLogger, activeTarget);
|
||||
if (session !== undefined) registerSessionTools(server, session, toolLogger);
|
||||
return server;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
|
||||
import { EnhancedTools } from "../application/EnhancedTools.js";
|
||||
import type { HopperToolPort } from "../application/HopperToolPort.js";
|
||||
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
|
||||
import { enhancedToolNameSchema } from "../contracts/enhancedInputs.js";
|
||||
import {
|
||||
ENHANCED_TOOL_CONTRACTS,
|
||||
|
|
@ -10,16 +10,21 @@ import {
|
|||
import { toCallToolResult } from "./toolResult.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
|
||||
|
||||
/** Register composed workflows against the same port as direct bridge tools. */
|
||||
export const registerEnhancedTools = (
|
||||
server: McpServer,
|
||||
hopper: HopperToolPort,
|
||||
analysis: AnalysisOperationPort,
|
||||
logger: Logger,
|
||||
activeTarget?: () => BinaryTarget | undefined,
|
||||
): void => {
|
||||
const services = new EnhancedTools(hopper);
|
||||
const services = new EnhancedTools(analysis);
|
||||
for (const contract of ENHANCED_TOOL_CONTRACTS) {
|
||||
registerEnhancedTool(server, services, contract, logger);
|
||||
registerEnhancedTool(server, services, contract, { logger, activeTarget });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -27,17 +32,47 @@ const registerEnhancedTool = (
|
|||
server: McpServer,
|
||||
services: EnhancedTools,
|
||||
contract: ToolContract,
|
||||
logger: Logger,
|
||||
registration: {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
},
|
||||
): void => {
|
||||
const name = enhancedToolNameSchema.parse(contract.name);
|
||||
server.registerTool(
|
||||
name,
|
||||
{ description: contract.description, inputSchema: contract.inputSchema },
|
||||
async (input, context) =>
|
||||
toCallToolResult(
|
||||
await logToolExecution(logger, name, () =>
|
||||
services.execute(name, input, context.mcpReq.signal),
|
||||
),
|
||||
),
|
||||
{
|
||||
description: contract.description,
|
||||
inputSchema: contract.inputSchema,
|
||||
outputSchema: contract.outputSchema,
|
||||
annotations: contract.annotations,
|
||||
},
|
||||
async (input, context) => {
|
||||
const parsedInput = jsonValueSchema.parse(
|
||||
enhancedInputSchemas[name].parse(input),
|
||||
);
|
||||
if (
|
||||
typeof parsedInput !== "object" ||
|
||||
parsedInput === null ||
|
||||
Array.isArray(parsedInput)
|
||||
)
|
||||
throw new Error("Validated enhanced input was not a JSON object");
|
||||
const parameters: Record<string, JsonValue> = { ...parsedInput };
|
||||
const result = await logToolExecution(registration.logger, name, () =>
|
||||
services.execute(name, input, context.mcpReq.signal),
|
||||
);
|
||||
return toCallToolResult(
|
||||
result.ok
|
||||
? {
|
||||
ok: true,
|
||||
value: createEvidence(registration.activeTarget?.(), {
|
||||
operation: name,
|
||||
parameters,
|
||||
result: result.value,
|
||||
}),
|
||||
}
|
||||
: result,
|
||||
contract,
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,46 +1,69 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
|
||||
import type { HopperToolPort } from "../application/HopperToolPort.js";
|
||||
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
|
||||
import {
|
||||
OFFICIAL_TOOL_CONTRACTS,
|
||||
type ToolContract,
|
||||
} from "../contracts/toolContracts.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../hopper/protocol.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { toCallToolResult } from "./toolResult.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
|
||||
/** Register direct bridge proxies, preserving MCP cancellation and typed errors. */
|
||||
export const registerOfficialTools = (
|
||||
server: McpServer,
|
||||
hopper: HopperToolPort,
|
||||
analysis: AnalysisOperationPort,
|
||||
logger: Logger,
|
||||
activeTarget?: () => BinaryTarget | undefined,
|
||||
): void => {
|
||||
for (const contract of OFFICIAL_TOOL_CONTRACTS) {
|
||||
registerOfficialTool(server, hopper, contract, logger);
|
||||
registerOfficialTool(server, analysis, contract, { logger, activeTarget });
|
||||
}
|
||||
};
|
||||
|
||||
const registerOfficialTool = (
|
||||
server: McpServer,
|
||||
hopper: HopperToolPort,
|
||||
analysis: AnalysisOperationPort,
|
||||
contract: ToolContract,
|
||||
logger: Logger,
|
||||
registration: {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
},
|
||||
): void => {
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
{
|
||||
description: contract.description,
|
||||
inputSchema: contract.inputSchema,
|
||||
outputSchema: contract.outputSchema,
|
||||
annotations: contract.annotations,
|
||||
},
|
||||
async (input, context) => {
|
||||
const arguments_ = projectOfficialArguments(contract, input);
|
||||
const result = await logToolExecution(logger, contract.name, () =>
|
||||
hopper.callTool(contract.name, arguments_, {
|
||||
signal: context.mcpReq.signal,
|
||||
}),
|
||||
const result = await logToolExecution(
|
||||
registration.logger,
|
||||
contract.name,
|
||||
() =>
|
||||
analysis.execute(contract.name, arguments_, {
|
||||
signal: context.mcpReq.signal,
|
||||
}),
|
||||
);
|
||||
return toCallToolResult(
|
||||
result.ok
|
||||
? {
|
||||
ok: true,
|
||||
value: createEvidence(registration.activeTarget?.(), {
|
||||
operation: contract.name,
|
||||
parameters: arguments_,
|
||||
result: result.value,
|
||||
}),
|
||||
}
|
||||
: result,
|
||||
contract,
|
||||
);
|
||||
return toCallToolResult(result);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { BinarySession } from "../application/BinarySession.js";
|
||||
import type { BinarySessionPort } from "../application/BinarySession.js";
|
||||
import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
import { toCallToolResult } from "./toolResult.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
|
|
@ -10,7 +10,7 @@ import { logToolExecution } from "./toolLogging.js";
|
|||
/** Register MCP-only target lifecycle operations on a long-lived session. */
|
||||
export const registerSessionTools = (
|
||||
server: McpServer,
|
||||
session: BinarySession,
|
||||
session: BinarySessionPort,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
const [openContract, closeContract, statusContract] = SESSION_TOOL_CONTRACTS;
|
||||
|
|
@ -19,6 +19,8 @@ export const registerSessionTools = (
|
|||
{
|
||||
description: openContract.description,
|
||||
inputSchema: openContract.inputSchema,
|
||||
outputSchema: openContract.outputSchema,
|
||||
annotations: openContract.annotations,
|
||||
},
|
||||
async (input, context) => {
|
||||
const parsed = z.object({ path: z.string().min(1) }).parse(input);
|
||||
|
|
@ -26,16 +28,21 @@ export const registerSessionTools = (
|
|||
session.open(parsed.path, { signal: context.mcpReq.signal }),
|
||||
);
|
||||
return opened.ok
|
||||
? toCallToolResult({
|
||||
ok: true,
|
||||
value: {
|
||||
path: opened.value.path,
|
||||
format: opened.value.format,
|
||||
kind: opened.value.kind,
|
||||
loaderArgs: [...opened.value.loaderArgs],
|
||||
? toCallToolResult(
|
||||
{
|
||||
ok: true,
|
||||
value: {
|
||||
path: opened.value.path,
|
||||
format: opened.value.format,
|
||||
kind: opened.value.kind,
|
||||
loaderArgs: [...opened.value.loaderArgs],
|
||||
sha256: opened.value.sha256,
|
||||
architecture: opened.value.architecture ?? null,
|
||||
},
|
||||
},
|
||||
})
|
||||
: toCallToolResult(opened);
|
||||
openContract,
|
||||
)
|
||||
: toCallToolResult(opened, openContract);
|
||||
},
|
||||
);
|
||||
server.registerTool(
|
||||
|
|
@ -43,12 +50,15 @@ export const registerSessionTools = (
|
|||
{
|
||||
description: closeContract.description,
|
||||
inputSchema: closeContract.inputSchema,
|
||||
outputSchema: closeContract.outputSchema,
|
||||
annotations: closeContract.annotations,
|
||||
},
|
||||
async () =>
|
||||
toCallToolResult(
|
||||
await logToolExecution(logger, closeContract.name, () =>
|
||||
session.close(),
|
||||
),
|
||||
closeContract,
|
||||
),
|
||||
);
|
||||
server.registerTool(
|
||||
|
|
@ -56,7 +66,10 @@ export const registerSessionTools = (
|
|||
{
|
||||
description: statusContract.description,
|
||||
inputSchema: statusContract.inputSchema,
|
||||
outputSchema: statusContract.outputSchema,
|
||||
annotations: statusContract.annotations,
|
||||
},
|
||||
() => toCallToolResult({ ok: true, value: session.status() }),
|
||||
() =>
|
||||
toCallToolResult({ ok: true, value: session.status() }, statusContract),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
|
||||
import type { HopperError } from "../domain/errors.js";
|
||||
import type { AnalysisError } from "../domain/errors.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type { JsonValue } from "../hopper/protocol.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { ToolContract } from "../contracts/toolContracts.js";
|
||||
|
||||
/**
|
||||
* Translate an application result into MCP text content.
|
||||
|
|
@ -10,10 +11,11 @@ import type { JsonValue } from "../hopper/protocol.js";
|
|||
* output, and other potentially sensitive details stay private.
|
||||
*/
|
||||
export const toCallToolResult = (
|
||||
result: Result<JsonValue, HopperError>,
|
||||
result: Result<JsonValue, AnalysisError>,
|
||||
contract: ToolContract,
|
||||
): CallToolResult =>
|
||||
result.ok
|
||||
? { content: [{ type: "text", text: formatValue(result.value) }] }
|
||||
? successResult(result.value, contract)
|
||||
: {
|
||||
content: [
|
||||
{
|
||||
|
|
@ -24,6 +26,29 @@ export const toCallToolResult = (
|
|||
isError: true,
|
||||
};
|
||||
|
||||
const successResult = (
|
||||
value: JsonValue,
|
||||
contract: ToolContract,
|
||||
): CallToolResult => {
|
||||
const candidate = contract.kind === "session" ? { result: value } : value;
|
||||
const parsed = contract.outputSchema.safeParse(candidate);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "HopperProtocolError: Analysis output does not match the tool contract",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: formatValue(value) }],
|
||||
structuredContent: candidate,
|
||||
};
|
||||
};
|
||||
|
||||
const formatValue = (value: JsonValue): string => {
|
||||
if (value === null) return "OK";
|
||||
if (typeof value === "string") return value;
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BinarySession,
|
||||
type BinaryClient,
|
||||
} from "../src/application/BinarySession.js";
|
||||
import { BinarySession } from "../src/application/BinarySession.js";
|
||||
import type { AnalysisClient } from "../src/application/AnalysisProvider.js";
|
||||
import type { AnalysisProvider } from "../src/application/AnalysisProvider.js";
|
||||
import { HopperStartError } from "../src/domain/errors.js";
|
||||
import { err, ok } from "../src/domain/result.js";
|
||||
|
||||
|
|
@ -18,9 +17,35 @@ afterEach(async () => {
|
|||
});
|
||||
|
||||
describe("binary session", () => {
|
||||
it("runs through a non-Hopper analysis provider", async () => {
|
||||
const [first] = await targets();
|
||||
const operations: string[] = [];
|
||||
const provider: AnalysisProvider = {
|
||||
identity: () => ({ id: "fixture", name: "Fixture", version: "1" }),
|
||||
capabilities: () => ["fixture-analysis"],
|
||||
createClient: () => ({
|
||||
execute: (operation) => {
|
||||
operations.push(operation);
|
||||
return Promise.resolve(ok(operation));
|
||||
},
|
||||
close: () => Promise.resolve(),
|
||||
}),
|
||||
};
|
||||
const session = new BinarySession(provider);
|
||||
expect((await session.open(first)).ok).toBe(true);
|
||||
expect(await session.execute("fixture_operation", {})).toEqual({
|
||||
ok: true,
|
||||
value: "fixture_operation",
|
||||
});
|
||||
expect(provider.identity().id).toBe("fixture");
|
||||
expect(provider.capabilities()).toEqual(["fixture-analysis"]);
|
||||
expect(operations).toEqual(["health", "fixture_operation"]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("requires an open binary and closes idempotently", async () => {
|
||||
const session = new BinarySession(() => client());
|
||||
expect((await session.callTool("binary_overview", {})).ok).toBe(false);
|
||||
expect((await session.execute("binary_overview", {})).ok).toBe(false);
|
||||
expect(await session.close()).toEqual({ ok: true, value: null });
|
||||
});
|
||||
|
||||
|
|
@ -34,7 +59,12 @@ describe("binary session", () => {
|
|||
const session = new BinarySession(() => client(created++ === 1));
|
||||
expect((await session.open(first)).ok).toBe(true);
|
||||
expect((await session.open(second)).ok).toBe(false);
|
||||
expect(session.status()).toMatchObject({ open: true });
|
||||
expect(session.status()).toMatchObject({
|
||||
open: true,
|
||||
sha256:
|
||||
"7692c3ad3540bb803c020b3aee66cd8887123234ea0c6e7143c0add73ff431ed",
|
||||
architecture: null,
|
||||
});
|
||||
expect(JSON.stringify(session.status())).toContain("first.hop");
|
||||
expect(created).toBe(3);
|
||||
});
|
||||
|
|
@ -70,7 +100,7 @@ describe("binary session", () => {
|
|||
return value;
|
||||
});
|
||||
await session.open(first);
|
||||
const call = session.callTool("long", {});
|
||||
const call = session.execute("long", {});
|
||||
const switching = session.open(second);
|
||||
await Promise.resolve();
|
||||
expect(clients[0]?.closed).toBe(0);
|
||||
|
|
@ -106,11 +136,7 @@ describe("binary session", () => {
|
|||
const session = new BinarySession(() => new TestClient(health.promise));
|
||||
const opening = session.open(first);
|
||||
const controller = new AbortController();
|
||||
const call = session.callTool(
|
||||
"overview",
|
||||
{},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const call = session.execute("overview", {}, { signal: controller.signal });
|
||||
controller.abort();
|
||||
const result = await call;
|
||||
expect(result.ok).toBe(false);
|
||||
|
|
@ -143,7 +169,7 @@ describe("binary session", () => {
|
|||
let liveClients = 0;
|
||||
let overlapped = false;
|
||||
const session = new BinarySession(() => ({
|
||||
callTool: () => {
|
||||
execute: () => {
|
||||
if (liveClients > 0) overlapped = true;
|
||||
liveClients += 1;
|
||||
return Promise.resolve(ok(null));
|
||||
|
|
@ -160,20 +186,19 @@ describe("binary session", () => {
|
|||
});
|
||||
});
|
||||
|
||||
const client = (fail = false): BinaryClient => ({
|
||||
callTool: () =>
|
||||
Promise.resolve(fail ? err(new HopperStartError()) : ok(null)),
|
||||
const client = (fail = false): AnalysisClient => ({
|
||||
execute: () => Promise.resolve(fail ? err(new HopperStartError()) : ok(null)),
|
||||
close: () => Promise.resolve(),
|
||||
});
|
||||
|
||||
class TestClient implements BinaryClient {
|
||||
class TestClient implements AnalysisClient {
|
||||
closed = 0;
|
||||
constructor(
|
||||
readonly pendingHealth?: Promise<ReturnType<typeof ok<null>>>,
|
||||
readonly failHealth = false,
|
||||
readonly pendingCall?: Promise<ReturnType<typeof ok<null>>>,
|
||||
) {}
|
||||
callTool(name: string) {
|
||||
execute(name: string) {
|
||||
if (name === "health")
|
||||
return this.failHealth
|
||||
? Promise.resolve(err(new HopperStartError()))
|
||||
|
|
|
|||
|
|
@ -77,7 +77,10 @@ describe("binary target I/O", () => {
|
|||
directory = await mkdtemp(join(tmpdir(), "rea-target-"));
|
||||
await writeFile(join(directory, "sample.hop"), "database");
|
||||
await writeFile(join(directory, "text"), "hello");
|
||||
expect((await parseBinaryTarget("sample.hop", directory)).ok).toBe(true);
|
||||
const database = await parseBinaryTarget("sample.hop", directory);
|
||||
expect(database.ok && database.value.sha256).toBe(
|
||||
"3549b0028b75d981cdda2e573e9cb49dedc200185876df299f912b79f69dabd8",
|
||||
);
|
||||
expect((await parseBinaryTarget("text", directory)).ok).toBe(false);
|
||||
expect((await parseBinaryTarget("missing", directory)).ok).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const bridgeSource = await readFile(
|
||||
new URL("../bridge/hopper_bridge.py", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("Hopper bridge truthfulness", () => {
|
||||
it("marks unavailable CFG successors instead of returning deceptive empty arrays", () => {
|
||||
expect(bridgeSource).toContain('"successors": _unavailable(');
|
||||
expect(bridgeSource).not.toContain('"successors": []');
|
||||
});
|
||||
|
||||
it("does not fabricate non-writable and non-executable segment permissions", () => {
|
||||
expect(bridgeSource).toContain('"writable": None');
|
||||
expect(bridgeSource).toContain('"executable": None');
|
||||
expect(bridgeSource).toContain('"permissions": _unavailable(');
|
||||
expect(bridgeSource).not.toContain('"writable": False');
|
||||
expect(bridgeSource).not.toContain('"executable": False');
|
||||
});
|
||||
|
||||
it("uses Hopper containment and raw-reference APIs without inferring kinds", () => {
|
||||
expect(bridgeSource).toContain("segment.getProcedureAtAddress(address)");
|
||||
expect(bridgeSource).toContain("segment.getReferencesFromAddress(address)");
|
||||
expect(bridgeSource).toContain("segment.getReferencesOfAddress(address)");
|
||||
expect(bridgeSource).toContain(
|
||||
"Hopper's public Python API does not classify reference kinds",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# Source-built conformance fixtures
|
||||
|
||||
These sources are deliberately small semantic oracles for REA providers. Run
|
||||
`npm run verify:fixtures` on macOS to compile them into the ignored
|
||||
`build/conformance/` directory, emit a hash-and-toolchain manifest, and verify
|
||||
their Mach-O symbols and strings with native command-line tools.
|
||||
|
||||
Only source files are versioned. Generated binaries, generated large-fixture
|
||||
source, debug information, and manifests must remain under `build/`.
|
||||
|
||||
The C fixture fixes a known call chain and strings. The version pair fixes an
|
||||
added symbol, changed string, and changed call relationship. The generated
|
||||
large fixture contains 1,205 uniquely named functions and strings so provider
|
||||
pagination can be checked at boundaries of 500, 1,000, and 1,205. Objective-C,
|
||||
Swift, and N-API-like fixtures provide metadata-oriented inputs without package
|
||||
or network dependencies.
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
#include <stdio.h>
|
||||
|
||||
volatile int rea_c_global = 7;
|
||||
|
||||
__attribute__((noinline, used)) int rea_leaf(int value) {
|
||||
puts("REA_C_LEAF");
|
||||
return value + rea_c_global;
|
||||
}
|
||||
|
||||
__attribute__((noinline, used)) int rea_branch(int value) {
|
||||
return value > 3 ? rea_leaf(value) : rea_leaf(-value);
|
||||
}
|
||||
|
||||
__attribute__((noinline, used)) int rea_entry(void) {
|
||||
puts("REA_C_ENTRY");
|
||||
return rea_branch(5);
|
||||
}
|
||||
|
||||
int main(void) { return rea_entry() == 12 ? 0 : 1; }
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
typedef void *napi_env;
|
||||
typedef void *napi_value;
|
||||
typedef napi_value (*napi_callback)(napi_env, void *);
|
||||
extern int napi_create_function(napi_env, const char *, unsigned long,
|
||||
napi_callback, void *, napi_value *);
|
||||
extern int napi_set_named_property(napi_env, napi_value, const char *, napi_value);
|
||||
static napi_value rea_probe(napi_env env, void *info) { return (napi_value)info; }
|
||||
__attribute__((visibility("default"))) napi_value napi_register_module_v1(
|
||||
napi_env env, napi_value exports) {
|
||||
const char *names[] = {"reaProbeOne", "reaProbeTwo", "reaProbeThree"};
|
||||
for (int i = 0; i < 3; i++) {
|
||||
napi_value function = 0;
|
||||
napi_create_function(env, names[i], 0, rea_probe, 0, &function);
|
||||
napi_set_named_property(env, exports, names[i], function);
|
||||
}
|
||||
return exports;
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
#import <Foundation/Foundation.h>
|
||||
@protocol REAWidgetDelegate <NSObject>
|
||||
- (void)widgetDidFinish;
|
||||
@end
|
||||
@interface REAWidget : NSObject
|
||||
@property(nonatomic, weak) id<REAWidgetDelegate> delegate;
|
||||
- (BOOL)performAction:(NSString *)action error:(NSError **)error;
|
||||
@end
|
||||
@implementation REAWidget
|
||||
- (BOOL)performAction:(NSString *)action error:(NSError **)error {
|
||||
NSLog(@"REA_OBJC_ACTION:%@", action);
|
||||
return action.length > 0;
|
||||
}
|
||||
@end
|
||||
int main(void) {
|
||||
@autoreleasepool {
|
||||
Protocol *protocol = @protocol(REAWidgetDelegate);
|
||||
NSLog(@"%@", NSStringFromProtocol(protocol));
|
||||
return [[[REAWidget alloc] init] performAction:@"run" error:0] ? 0 : 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import Darwin
|
||||
|
||||
protocol REAProtocol { func execute() -> Int }
|
||||
struct REARecord { let value: Int }
|
||||
enum REAState { case ready, finished }
|
||||
final class REAService: REAProtocol {
|
||||
@inline(never) func execute() -> Int { print("REA_SWIFT_EXECUTE"); return 42 }
|
||||
}
|
||||
extension REARecord { @inline(never) func doubled() -> Int { value * 2 } }
|
||||
let service = REAService()
|
||||
exit(service.execute() == 42 ? 0 : 1)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
#include <stdio.h>
|
||||
__attribute__((noinline, used)) int rea_version_leaf(void) {
|
||||
puts("REA_VERSION_ONE");
|
||||
return 1;
|
||||
}
|
||||
__attribute__((noinline, used)) int rea_version_entry(void) {
|
||||
return rea_version_leaf();
|
||||
}
|
||||
int main(void) { return rea_version_entry() == 1 ? 0 : 1; }
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
#include <stdio.h>
|
||||
__attribute__((noinline, used)) int rea_version_leaf(void) {
|
||||
puts("REA_VERSION_TWO");
|
||||
return 2;
|
||||
}
|
||||
__attribute__((noinline, used)) int rea_added(void) { return 40; }
|
||||
__attribute__((noinline, used)) int rea_version_entry(void) {
|
||||
return rea_version_leaf() + rea_added();
|
||||
}
|
||||
int main(void) { return rea_version_entry() == 42 ? 0 : 1; }
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
generateLargeFixture,
|
||||
LARGE_FIXTURE_COUNT,
|
||||
sha256,
|
||||
sourceDigest,
|
||||
} from "../scripts/lib/conformance-fixtures.mjs";
|
||||
|
||||
describe("source-built conformance fixtures", () => {
|
||||
it("generates a deterministic large pagination fixture", () => {
|
||||
const first = generateLargeFixture();
|
||||
const second = generateLargeFixture();
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(first.match(/int rea_page_/gu)).toHaveLength(LARGE_FIXTURE_COUNT);
|
||||
expect(first).toContain("rea_page_0000");
|
||||
expect(first).toContain("rea_page_1204");
|
||||
expect(() => generateLargeFixture(0)).toThrow(/positive integer/u);
|
||||
});
|
||||
|
||||
it("hashes source manifests independently of input ordering", () => {
|
||||
const left = [
|
||||
{ path: "b.c", content: "b" },
|
||||
{ path: "a.c", content: "a" },
|
||||
];
|
||||
const right = [...left].reverse();
|
||||
|
||||
expect(sourceDigest(left)).toBe(sourceDigest(right));
|
||||
expect(sourceDigest(left)).toMatch(/^[0-9a-f]{64}$/u);
|
||||
expect(sha256("fixture")).not.toBe(sha256("changed"));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
|
|
@ -7,28 +6,34 @@ import {
|
|||
OFFICIAL_TOOL_CONTRACTS,
|
||||
SESSION_TOOL_CONTRACTS,
|
||||
} from "../src/contracts/toolContracts.js";
|
||||
import {
|
||||
enhancedOutputSchemas,
|
||||
officialOutputSchemas,
|
||||
sessionOutputSchemas,
|
||||
} from "../src/contracts/toolOutputSchemas.js";
|
||||
|
||||
const ORIGINAL_39_SHA256 =
|
||||
"43ffddd91b775a3e00fb736c6b1a644210e5917a1e1f02745d0283ac6ab99db4";
|
||||
|
||||
describe("original tool contract snapshot", () => {
|
||||
it("preserves every original name, description, kind, and complete JSON schema", () => {
|
||||
const projection = [
|
||||
...OFFICIAL_TOOL_CONTRACTS,
|
||||
...ENHANCED_TOOL_CONTRACTS,
|
||||
].map((contract) => ({
|
||||
name: contract.name,
|
||||
description: contract.description,
|
||||
kind: contract.kind,
|
||||
inputSchema: z.toJSONSchema(contract.inputSchema, {
|
||||
describe("tool contract surface", () => {
|
||||
it("advertises complete typed schemas and annotations for all analysis tools", () => {
|
||||
const contracts = [...OFFICIAL_TOOL_CONTRACTS, ...ENHANCED_TOOL_CONTRACTS];
|
||||
expect(contracts).toHaveLength(43);
|
||||
for (const contract of contracts) {
|
||||
const inputSchema = z.toJSONSchema(contract.inputSchema, {
|
||||
target: "draft-07",
|
||||
unrepresentable: "any",
|
||||
}),
|
||||
}));
|
||||
expect(projection).toHaveLength(39);
|
||||
expect(
|
||||
createHash("sha256").update(JSON.stringify(projection)).digest("hex"),
|
||||
).toBe(ORIGINAL_39_SHA256);
|
||||
});
|
||||
const outputSchema = z.toJSONSchema(contract.outputSchema, {
|
||||
target: "draft-07",
|
||||
unrepresentable: "any",
|
||||
});
|
||||
expect(inputSchema.type).toBe("object");
|
||||
expect(outputSchema.type).toBe("object");
|
||||
expect(contract.annotations).toMatchObject({
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
});
|
||||
expect(typeof contract.annotations.readOnlyHint).toBe("boolean");
|
||||
expect(typeof contract.annotations.destructiveHint).toBe("boolean");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps exactly three additive session contracts", () => {
|
||||
|
|
@ -40,4 +45,30 @@ describe("original tool contract snapshot", () => {
|
|||
{ name: "binary_session", kind: "session" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("publishes a dedicated output schema and agent guidance for every tool", () => {
|
||||
expect(Object.keys(officialOutputSchemas).sort()).toEqual(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name).sort(),
|
||||
);
|
||||
expect(Object.keys(enhancedOutputSchemas).sort()).toEqual(
|
||||
ENHANCED_TOOL_CONTRACTS.map(({ name }) => name).sort(),
|
||||
);
|
||||
expect(Object.keys(sessionOutputSchemas).sort()).toEqual(
|
||||
SESSION_TOOL_CONTRACTS.map(({ name }) => name).sort(),
|
||||
);
|
||||
|
||||
for (const contract of [
|
||||
...OFFICIAL_TOOL_CONTRACTS,
|
||||
...ENHANCED_TOOL_CONTRACTS,
|
||||
...SESSION_TOOL_CONTRACTS,
|
||||
]) {
|
||||
const schema = z.toJSONSchema(contract.outputSchema, {
|
||||
target: "draft-07",
|
||||
unrepresentable: "any",
|
||||
});
|
||||
expect(JSON.stringify(schema)).not.toContain('"result":{}');
|
||||
expect(contract.description.length).toBeGreaterThan(100);
|
||||
expect(contract.description).toMatch(/[.;]/u);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,17 +4,17 @@ import { describe, expect, it } from "vitest";
|
|||
import { ok } from "../src/domain/result.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
|
||||
const hopper = {
|
||||
callTool: () => Promise.resolve(ok(null)),
|
||||
const analysis = {
|
||||
execute: () => Promise.resolve(ok(null)),
|
||||
};
|
||||
|
||||
describe("beta.3 server composition", () => {
|
||||
it("constructs independent MCP server instances", () => {
|
||||
expect(createServer(hopper)).not.toBe(createServer(hopper));
|
||||
expect(createServer(analysis)).not.toBe(createServer(analysis));
|
||||
});
|
||||
|
||||
it("does not advertise unavailable session tools", async () => {
|
||||
const server = createServer(hopper);
|
||||
const server = createServer(analysis);
|
||||
const client = new Client({ name: "composition-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { HopperToolPort } from "../src/application/HopperToolPort.js";
|
||||
import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js";
|
||||
import { ENHANCED_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
|
||||
import { ok } from "../src/domain/result.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../src/hopper/protocol.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../src/domain/jsonValue.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
|
||||
const PROCEDURES = {
|
||||
|
|
@ -17,18 +17,29 @@ const PROCEDURES = {
|
|||
"0x6": "prefix_TtOther",
|
||||
};
|
||||
|
||||
const fixturePort = (): HopperToolPort => ({
|
||||
callTool: (name, arguments_) => {
|
||||
const page = (values: Readonly<Record<string, string>>) => ({
|
||||
items: Object.entries(values).map(([address, value]) => ({ address, value })),
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
total: Object.keys(values).length,
|
||||
next_offset: null,
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const fixturePort = (): AnalysisOperationPort => ({
|
||||
execute: (name, arguments_) => {
|
||||
switch (name) {
|
||||
case "list_procedures":
|
||||
return Promise.resolve(ok(PROCEDURES));
|
||||
return Promise.resolve(ok(page(PROCEDURES)));
|
||||
case "list_names":
|
||||
return Promise.resolve(
|
||||
ok([
|
||||
{ address: "0x10", name: "_OBJC_CLASS_$_Fixture" },
|
||||
{ address: "0x11", name: "_OBJC_CLASS_$_Fixture" },
|
||||
{ address: "0x12", name: "_OBJC_PROTOCOL_$_FixtureDelegate" },
|
||||
]),
|
||||
ok(
|
||||
page({
|
||||
"0x10": "_OBJC_CLASS_$_Fixture",
|
||||
"0x11": "_OBJC_CLASS_$_Fixture",
|
||||
"0x12": "_OBJC_PROTOCOL_$_FixtureDelegate",
|
||||
}),
|
||||
),
|
||||
);
|
||||
case "procedure_pseudo_code": {
|
||||
const procedure = arguments_.procedure;
|
||||
|
|
@ -56,6 +67,18 @@ const fixturePort = (): HopperToolPort => ({
|
|||
);
|
||||
case "xrefs":
|
||||
return Promise.resolve(ok(["0x20", "0x21"]));
|
||||
case "resolve_containing_procedure":
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
query_address:
|
||||
typeof arguments_.address === "string"
|
||||
? arguments_.address
|
||||
: "0x0",
|
||||
found: false,
|
||||
procedure: null,
|
||||
reason: "not_in_procedure",
|
||||
}),
|
||||
);
|
||||
case "list_segments":
|
||||
return Promise.resolve(
|
||||
ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]),
|
||||
|
|
@ -63,13 +86,53 @@ const fixturePort = (): HopperToolPort => ({
|
|||
case "list_documents":
|
||||
return Promise.resolve(ok(["fixture"]));
|
||||
case "list_strings":
|
||||
return Promise.resolve(ok([{ address: "0x30", value: "hello" }]));
|
||||
return Promise.resolve(ok(page({ "0x30": "hello" })));
|
||||
case "analyze_function":
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
procedure: {
|
||||
address: "0x1",
|
||||
name: "entry",
|
||||
signature: null,
|
||||
locals: [],
|
||||
},
|
||||
pseudocode: {
|
||||
text: "return 0;",
|
||||
total_chars: 9,
|
||||
returned_chars: 9,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
assembly: {
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
comments: emptyBounded(),
|
||||
callers: emptyBounded(),
|
||||
callees: emptyBounded(),
|
||||
incoming_references: emptyBounded(),
|
||||
referenced_strings: { available: false, reason: "unsupported" },
|
||||
referenced_names: { available: false, reason: "unsupported" },
|
||||
basic_blocks: emptyBounded(),
|
||||
}),
|
||||
);
|
||||
default:
|
||||
return Promise.resolve(ok(null));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const emptyBounded = () => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
});
|
||||
|
||||
const resources: Array<{ close(): Promise<void> }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -78,8 +141,8 @@ afterEach(async () => {
|
|||
);
|
||||
});
|
||||
|
||||
const connect = async (hopper: HopperToolPort = fixturePort()) => {
|
||||
const server = createServer(hopper);
|
||||
const connect = async (analysis: AnalysisOperationPort = fixturePort()) => {
|
||||
const server = createServer(analysis);
|
||||
const client = new Client({ name: "enhanced-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
|
|
@ -90,6 +153,19 @@ const connect = async (hopper: HopperToolPort = fixturePort()) => {
|
|||
};
|
||||
|
||||
const jsonResult = (result: CallToolResult): JsonValue => {
|
||||
if (result.structuredContent === undefined)
|
||||
throw new Error("Tool result omitted structured content");
|
||||
const structured = jsonValueSchema.safeParse(result.structuredContent);
|
||||
if (!structured.success)
|
||||
throw new Error("Tool structured result was not JSON");
|
||||
if (
|
||||
typeof structured.data === "object" &&
|
||||
structured.data !== null &&
|
||||
!Array.isArray(structured.data) &&
|
||||
"result" in structured.data
|
||||
) {
|
||||
return structured.data.result ?? null;
|
||||
}
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
if (text?.type !== "text")
|
||||
throw new Error("Tool result omitted text content");
|
||||
|
|
@ -100,10 +176,10 @@ const jsonResult = (result: CallToolResult): JsonValue => {
|
|||
};
|
||||
|
||||
describe("enhanced MCP tools", () => {
|
||||
it("lists the complete 31 plus 8 surface", async () => {
|
||||
it("lists the complete 33 plus 10 surface", async () => {
|
||||
const client = await connect();
|
||||
const listed = await client.listTools();
|
||||
expect(listed.tools).toHaveLength(39);
|
||||
expect(listed.tools).toHaveLength(43);
|
||||
expect(
|
||||
listed.tools
|
||||
.map(({ name }) => name)
|
||||
|
|
@ -114,7 +190,7 @@ describe("enhanced MCP tools", () => {
|
|||
).toEqual(ENHANCED_TOOL_CONTRACTS.map(({ name }) => name).sort());
|
||||
});
|
||||
|
||||
it("executes all eight tools through production registration", async () => {
|
||||
it("executes all ten tools through production registration", async () => {
|
||||
const client = await connect();
|
||||
const calls = [
|
||||
["swift_classes", { pattern: "Fixture" }],
|
||||
|
|
@ -125,6 +201,8 @@ describe("enhanced MCP tools", () => {
|
|||
["analyze_swift_types", {}],
|
||||
["find_xrefs_to_name", { name: "entry" }],
|
||||
["binary_overview", {}],
|
||||
["analyze_function", { procedure: "0x1" }],
|
||||
["trace_feature", { query: "hello", max_operations: 10 }],
|
||||
] as const;
|
||||
const results = [];
|
||||
for (const [name, arguments_] of calls) {
|
||||
|
|
@ -152,13 +230,26 @@ describe("enhanced MCP tools", () => {
|
|||
procedure_count: 6,
|
||||
string_count: 1,
|
||||
});
|
||||
expect(results[8]).toMatchObject({
|
||||
procedure: { address: "0x1", name: "entry" },
|
||||
pseudocode: { text: "return 0;" },
|
||||
});
|
||||
expect(results[9]).toMatchObject({
|
||||
query: "hello",
|
||||
search_mode: "literal",
|
||||
truncated: false,
|
||||
references: [
|
||||
{ target_address: "0x30", source_address: "0x20" },
|
||||
{ target_address: "0x30", source_address: "0x21" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds batch concurrency at the parsed maximum of 20", async () => {
|
||||
let active = 0;
|
||||
let maximum = 0;
|
||||
const hopper: HopperToolPort = {
|
||||
callTool: async (name) => {
|
||||
const analysis: AnalysisOperationPort = {
|
||||
execute: async (name) => {
|
||||
if (name !== "procedure_pseudo_code") return ok(null);
|
||||
active += 1;
|
||||
maximum = Math.max(maximum, active);
|
||||
|
|
@ -167,7 +258,7 @@ describe("enhanced MCP tools", () => {
|
|||
return ok("pseudo");
|
||||
},
|
||||
};
|
||||
const client = await connect(hopper);
|
||||
const client = await connect(analysis);
|
||||
const result = await client.callTool({
|
||||
name: "batch_decompile",
|
||||
arguments: {
|
||||
|
|
@ -181,9 +272,198 @@ describe("enhanced MCP tools", () => {
|
|||
expect(maximum).toBe(20);
|
||||
});
|
||||
|
||||
it("follows procedure pagination for whole-binary workflows", async () => {
|
||||
const offsets: number[] = [];
|
||||
const client = await connect({
|
||||
execute: (_name, arguments_) => {
|
||||
const offset = arguments_.offset;
|
||||
offsets.push(typeof offset === "number" ? offset : 0);
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
items: [
|
||||
{
|
||||
address: offset === 500 ? "0x2" : "0x1",
|
||||
value: offset === 500 ? "_TtC4Last" : "_TtC5First",
|
||||
},
|
||||
],
|
||||
offset: typeof offset === "number" ? offset : 0,
|
||||
limit: 500,
|
||||
total: 2,
|
||||
next_offset: offset === 500 ? null : 500,
|
||||
has_more: offset !== 500,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const result = jsonResult(
|
||||
await client.callTool({ name: "swift_classes", arguments: {} }),
|
||||
);
|
||||
expect(offsets).toEqual([0, 500]);
|
||||
expect(result).toMatchObject({ count: 2 });
|
||||
});
|
||||
|
||||
it("follows name pagination for exhaustive Objective-C discovery", async () => {
|
||||
const offsets: number[] = [];
|
||||
const client = await connect({
|
||||
execute: (name, arguments_) => {
|
||||
expect(name).toBe("list_names");
|
||||
const offset =
|
||||
typeof arguments_.offset === "number" ? arguments_.offset : 0;
|
||||
offsets.push(offset);
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
items: [
|
||||
{
|
||||
address: offset === 500 ? "0x2" : "0x1",
|
||||
value:
|
||||
offset === 500 ? "_OBJC_CLASS_$_Last" : "_OBJC_CLASS_$_First",
|
||||
},
|
||||
],
|
||||
offset,
|
||||
limit: 500,
|
||||
total: 2,
|
||||
next_offset: offset === 500 ? null : 500,
|
||||
has_more: offset !== 500,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const result = jsonResult(
|
||||
await client.callTool({ name: "get_objc_classes", arguments: {} }),
|
||||
);
|
||||
expect(offsets).toEqual([0, 500]);
|
||||
expect(result).toMatchObject({ count: 2 });
|
||||
});
|
||||
|
||||
it("honors overview detail and limit while reporting exhaustive totals", async () => {
|
||||
const procedureOffsets: number[] = [];
|
||||
const client = await connect({
|
||||
execute: (name, arguments_) => {
|
||||
switch (name) {
|
||||
case "list_segments":
|
||||
return Promise.resolve(
|
||||
ok([
|
||||
{ name: "__TEXT", start: "0x1000", end: "0x1800" },
|
||||
{ name: "__DATA", start: "0x1800", end: "0x2000" },
|
||||
]),
|
||||
);
|
||||
case "list_documents":
|
||||
return Promise.resolve(ok(["fixture"]));
|
||||
case "list_strings":
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
items: [{ address: "0x30", value: "first page only" }],
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
total: 700,
|
||||
next_offset: 100,
|
||||
has_more: true,
|
||||
}),
|
||||
);
|
||||
case "list_procedures": {
|
||||
const offset =
|
||||
typeof arguments_.offset === "number" ? arguments_.offset : 0;
|
||||
procedureOffsets.push(offset);
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
items: [
|
||||
{
|
||||
address: offset === 500 ? "0x2" : "0x1",
|
||||
value: offset === 500 ? "last" : "first",
|
||||
},
|
||||
],
|
||||
offset,
|
||||
limit: 500,
|
||||
total: 2,
|
||||
next_offset: offset === 500 ? null : 500,
|
||||
has_more: offset !== 500,
|
||||
}),
|
||||
);
|
||||
}
|
||||
default:
|
||||
return Promise.resolve(ok(null));
|
||||
}
|
||||
},
|
||||
});
|
||||
const result = jsonResult(
|
||||
await client.callTool({
|
||||
name: "binary_overview",
|
||||
arguments: { detail: "detailed", limit: 1 },
|
||||
}),
|
||||
);
|
||||
expect(procedureOffsets).toEqual([0, 500]);
|
||||
expect(result).toEqual({
|
||||
document: "fixture",
|
||||
detail: "detailed",
|
||||
segments: [
|
||||
{ name: "__TEXT", start: "0x1000", end: "0x1800", length: 2048 },
|
||||
],
|
||||
segment_count: 2,
|
||||
procedure_count: 2,
|
||||
string_count: 700,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-advancing pagination metadata", async () => {
|
||||
const client = await connect({
|
||||
execute: () =>
|
||||
Promise.resolve(
|
||||
ok({
|
||||
items: [{ address: "0x1", value: "_TtC5First" }],
|
||||
offset: 0,
|
||||
limit: 500,
|
||||
total: 2,
|
||||
next_offset: 0,
|
||||
has_more: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
const result = await client.callTool({
|
||||
name: "swift_classes",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
expect(text?.type === "text" ? text.text : "").toContain(
|
||||
"non-advancing pagination offset",
|
||||
);
|
||||
});
|
||||
|
||||
it("stops trace_feature at its explicit operation budget", async () => {
|
||||
let operations = 0;
|
||||
const client = await connect({
|
||||
execute: () => {
|
||||
operations += 1;
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
items: [{ address: "0x1", value: "needle" }],
|
||||
offset: 0,
|
||||
limit: 500,
|
||||
total: 2,
|
||||
next_offset: 1,
|
||||
has_more: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const result = jsonResult(
|
||||
await client.callTool({
|
||||
name: "trace_feature",
|
||||
arguments: { query: "needle", max_operations: 1 },
|
||||
}),
|
||||
);
|
||||
expect(operations).toBe(1);
|
||||
expect(result).toMatchObject({
|
||||
operations_used: 1,
|
||||
operation_budget: 1,
|
||||
truncated: true,
|
||||
});
|
||||
expect(JSON.stringify(result)).toContain("operation budget");
|
||||
});
|
||||
|
||||
it("returns a typed tool error for malformed Hopper boundary values", async () => {
|
||||
const client = await connect({
|
||||
callTool: () => Promise.resolve(ok(["not", "a", "procedure", "map"])),
|
||||
execute: () => Promise.resolve(ok(["not", "a", "procedure", "map"])),
|
||||
});
|
||||
const result = await client.callTool({
|
||||
name: "swift_classes",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { BinaryTarget } from "../src/domain/binaryTarget.js";
|
||||
import { createEvidence, evidenceSchema } from "../src/domain/evidence.js";
|
||||
import { jsonValueSchema } from "../src/hopper/protocol.js";
|
||||
|
||||
const TARGET: BinaryTarget = {
|
||||
path: "/tmp/fixture",
|
||||
sha256: "a".repeat(64),
|
||||
kind: "executable",
|
||||
format: "mach-o",
|
||||
architecture: "arm64",
|
||||
availableArchitectures: ["arm64"],
|
||||
loaderArgs: ["-l", "Mach-O", "--aarch64"],
|
||||
};
|
||||
|
||||
describe("analysis evidence", () => {
|
||||
it("preserves strict JSON inputs and results without timestamps", () => {
|
||||
const evidence = createEvidence(TARGET, {
|
||||
operation: "procedure_info",
|
||||
parameters: { procedure: "0x1000", document: null },
|
||||
result: { name: "main" },
|
||||
});
|
||||
expect(evidenceSchema.parse(evidence)).toEqual(evidence);
|
||||
expect(jsonValueSchema.parse(evidence)).toEqual(evidence);
|
||||
expect(evidence).toMatchObject({
|
||||
schema_version: 1,
|
||||
artifact: {
|
||||
path: "/tmp/fixture",
|
||||
sha256: "a".repeat(64),
|
||||
architecture: "arm64",
|
||||
},
|
||||
provider: { id: "hopper", version: null },
|
||||
operation: "procedure_info",
|
||||
confidence: "observed",
|
||||
limitations: [],
|
||||
});
|
||||
expect(evidence).not.toHaveProperty("timestamp");
|
||||
expect(evidence).not.toHaveProperty("evidence_id");
|
||||
});
|
||||
|
||||
it("marks missing fixed-target identity as unavailable", () => {
|
||||
expect(
|
||||
createEvidence(undefined, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: null,
|
||||
}),
|
||||
).toMatchObject({
|
||||
artifact: null,
|
||||
limitations: [
|
||||
"Artifact identity is unavailable for this fixed-target adapter.",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -44,6 +44,34 @@ const server = createServer((socket) => {
|
|||
id: request.id,
|
||||
error: { code: -32001, message: "safe fake failure" },
|
||||
});
|
||||
} else if (request.method === "current_document") {
|
||||
send({ id: request.id, result: "fixture" });
|
||||
} else if (request.method === "resolve_containing_procedure") {
|
||||
send({
|
||||
id: request.id,
|
||||
result: {
|
||||
query_address: request.params.address,
|
||||
found: true,
|
||||
procedure: { address: "0x1000", name: "fixture" },
|
||||
},
|
||||
});
|
||||
} else if (request.method === "procedure_references") {
|
||||
send({
|
||||
id: request.id,
|
||||
result: {
|
||||
procedure: { address: "0x1000", name: "fixture" },
|
||||
direction: request.params.direction ?? "outgoing",
|
||||
references: {
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
instructions_scanned: 1,
|
||||
instruction_scan_truncated: false,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const delay = request.params?.delay ?? 0;
|
||||
setTimeout(
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ describe("HopperClient", () => {
|
|||
const results = await Promise.all(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => client.callTool(name, {})),
|
||||
);
|
||||
expect(results).toHaveLength(31);
|
||||
expect(results).toHaveLength(33);
|
||||
expect(results.every((result) => result.ok)).toBe(true);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BinarySession,
|
||||
type BinaryClient,
|
||||
} from "../../src/application/BinarySession.js";
|
||||
import type { HopperToolPort } from "../../src/application/HopperToolPort.js";
|
||||
import { BinarySession } from "../../src/application/BinarySession.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisOperationPort,
|
||||
} from "../../src/application/AnalysisProvider.js";
|
||||
import { HopperRemoteError } from "../../src/domain/errors.js";
|
||||
import { err, ok } from "../../src/domain/result.js";
|
||||
import { createServer } from "../../src/server/createServer.js";
|
||||
|
|
@ -19,8 +19,8 @@ afterEach(async () => {
|
|||
);
|
||||
});
|
||||
|
||||
const connect = async (hopper: HopperToolPort) => {
|
||||
const server = createServer(hopper);
|
||||
const connect = async (analysis: AnalysisOperationPort) => {
|
||||
const server = createServer(analysis);
|
||||
const client = new Client({
|
||||
name: "integration-test",
|
||||
version: "1.0.0",
|
||||
|
|
@ -39,14 +39,33 @@ const text = (result: CallToolResult): string => {
|
|||
return content.text;
|
||||
};
|
||||
|
||||
const structured = (result: CallToolResult): Record<string, unknown> => {
|
||||
if (
|
||||
typeof result.structuredContent !== "object" ||
|
||||
result.structuredContent === null
|
||||
)
|
||||
throw new Error("missing structured result");
|
||||
return Object.fromEntries(Object.entries(result.structuredContent));
|
||||
};
|
||||
|
||||
describe("full MCP integration with multi-tool sequences", () => {
|
||||
it("executes a realistic workflow: list methods, decompile selected, get xrefs", async () => {
|
||||
const client = await connect({
|
||||
callTool: (name, args) => {
|
||||
execute: (name, args) => {
|
||||
switch (name) {
|
||||
case "list_procedures":
|
||||
return Promise.resolve(
|
||||
ok({ "0x1000": "main", "0x2000": "helper" }),
|
||||
ok({
|
||||
items: [
|
||||
{ address: "0x1000", value: "main" },
|
||||
{ address: "0x2000", value: "helper" },
|
||||
],
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
total: 2,
|
||||
next_offset: null,
|
||||
has_more: false,
|
||||
}),
|
||||
);
|
||||
case "procedure_pseudo_code":
|
||||
return Promise.resolve(
|
||||
|
|
@ -65,32 +84,50 @@ describe("full MCP integration with multi-tool sequences", () => {
|
|||
arguments: {},
|
||||
});
|
||||
expect(listResult.isError).not.toBe(true);
|
||||
const procedures = JSON.parse(text(listResult));
|
||||
expect(procedures).toEqual({ "0x1000": "main", "0x2000": "helper" });
|
||||
expect(structured(listResult)).toMatchObject({
|
||||
artifact: null,
|
||||
operation: "list_procedures",
|
||||
provider: { id: "hopper", version: null },
|
||||
result: {
|
||||
items: [
|
||||
{ address: "0x1000", value: "main" },
|
||||
{ address: "0x2000", value: "helper" },
|
||||
],
|
||||
total: 2,
|
||||
has_more: false,
|
||||
},
|
||||
});
|
||||
|
||||
const decompileResult = await client.callTool({
|
||||
name: "procedure_pseudo_code",
|
||||
arguments: { procedure: "0x1000" },
|
||||
});
|
||||
expect(text(decompileResult)).toBe("pseudo for 0x1000");
|
||||
expect(structured(decompileResult)).toMatchObject({
|
||||
operation: "procedure_pseudo_code",
|
||||
parameters: { document: null, procedure: "0x1000" },
|
||||
result: "pseudo for 0x1000",
|
||||
});
|
||||
|
||||
const xrefResult = await client.callTool({
|
||||
name: "xrefs",
|
||||
arguments: {},
|
||||
});
|
||||
expect(JSON.parse(text(xrefResult))).toEqual(["0x1000"]);
|
||||
expect(structured(xrefResult)).toMatchObject({
|
||||
operation: "xrefs",
|
||||
result: ["0x1000"],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the complete 42-tool inventory with a session", async () => {
|
||||
it("preserves the complete 46-tool inventory with a session", async () => {
|
||||
const session = new BinarySession(
|
||||
(_path) =>
|
||||
({
|
||||
callTool: () => Promise.resolve(ok(null)),
|
||||
execute: () => Promise.resolve(ok(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}) satisfies BinaryClient,
|
||||
}) satisfies AnalysisClient,
|
||||
);
|
||||
const server = createServer(
|
||||
{ callTool: () => Promise.resolve(ok(null)) },
|
||||
{ execute: () => Promise.resolve(ok(null)) },
|
||||
session,
|
||||
);
|
||||
const client = new Client({
|
||||
|
|
@ -104,7 +141,7 @@ describe("full MCP integration with multi-tool sequences", () => {
|
|||
await client.connect(clientTransport);
|
||||
|
||||
const listed = await client.listTools();
|
||||
expect(listed.tools).toHaveLength(42);
|
||||
expect(listed.tools).toHaveLength(46);
|
||||
const names = listed.tools.map((t) => t.name);
|
||||
expect(names).toContain("open_binary");
|
||||
expect(names).toContain("close_binary");
|
||||
|
|
@ -113,12 +150,12 @@ describe("full MCP integration with multi-tool sequences", () => {
|
|||
expect(names).toContain("batch_decompile");
|
||||
});
|
||||
|
||||
it("preserves the 39-tool inventory without a session", async () => {
|
||||
it("preserves the 43-tool inventory without a session", async () => {
|
||||
const client = await connect({
|
||||
callTool: () => Promise.resolve(ok(null)),
|
||||
execute: () => Promise.resolve(ok(null)),
|
||||
});
|
||||
const listed = await client.listTools();
|
||||
expect(listed.tools).toHaveLength(39);
|
||||
expect(listed.tools).toHaveLength(43);
|
||||
const names = listed.tools.map((t) => t.name);
|
||||
expect(names).toContain("binary_overview");
|
||||
expect(names).toContain("batch_decompile");
|
||||
|
|
@ -127,7 +164,7 @@ describe("full MCP integration with multi-tool sequences", () => {
|
|||
|
||||
it("propagates remote Hopper errors with structured content", async () => {
|
||||
const client = await connect({
|
||||
callTool: () =>
|
||||
execute: () =>
|
||||
Promise.resolve(err(new HopperRemoteError(-32000, "bridge timeout"))),
|
||||
});
|
||||
|
||||
|
|
@ -143,9 +180,22 @@ describe("full MCP integration with multi-tool sequences", () => {
|
|||
it("handles concurrent tool calls without corruption", async () => {
|
||||
const invocations: string[] = [];
|
||||
const client = await connect({
|
||||
callTool: (name) => {
|
||||
execute: (name) => {
|
||||
invocations.push(name);
|
||||
return Promise.resolve(ok({ handled: name }));
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
["list_procedures", "list_strings"].includes(name)
|
||||
? {
|
||||
items: [],
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
total: 0,
|
||||
next_offset: null,
|
||||
has_more: false,
|
||||
}
|
||||
: [],
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { HopperToolPort } from "../src/application/HopperToolPort.js";
|
||||
import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
|
||||
import { HopperRemoteError } from "../src/domain/errors.js";
|
||||
import { err, ok } from "../src/domain/result.js";
|
||||
import type { JsonValue } from "../src/hopper/protocol.js";
|
||||
import type { JsonValue } from "../src/domain/jsonValue.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
|
||||
const VALID_INPUTS: Readonly<
|
||||
|
|
@ -31,7 +31,9 @@ const VALID_INPUTS: Readonly<
|
|||
procedure_callees: { procedure: "main" },
|
||||
procedure_callers: { procedure: "main" },
|
||||
procedure_info: { procedure: "main" },
|
||||
procedure_references: { procedure: "main" },
|
||||
procedure_pseudo_code: { procedure: "main" },
|
||||
resolve_containing_procedure: { address: "0x1000" },
|
||||
search_procedures: { pattern: "main" },
|
||||
search_strings: { pattern: "hello" },
|
||||
set_address_name: { address: "0x1000", name: "entry" },
|
||||
|
|
@ -57,8 +59,8 @@ afterEach(async () => {
|
|||
);
|
||||
});
|
||||
|
||||
const connect = async (hopper: HopperToolPort) => {
|
||||
const server = createServer(hopper);
|
||||
const connect = async (analysis: AnalysisOperationPort) => {
|
||||
const server = createServer(analysis);
|
||||
const client = new Client({ name: "contract-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
|
|
@ -68,10 +70,71 @@ const connect = async (hopper: HopperToolPort) => {
|
|||
return client;
|
||||
};
|
||||
|
||||
const page: JsonValue = {
|
||||
items: [],
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
total: 0,
|
||||
next_offset: null,
|
||||
has_more: false,
|
||||
};
|
||||
|
||||
const outputFor = (name: string): JsonValue => {
|
||||
if (["list_procedures", "list_names", "list_strings"].includes(name))
|
||||
return page;
|
||||
if (["address_name", "comment", "inline_comment"].includes(name)) return null;
|
||||
if (["procedure_callees", "procedure_callers", "xrefs"].includes(name))
|
||||
return [];
|
||||
if (["list_bookmarks", "list_documents", "list_segments"].includes(name))
|
||||
return [];
|
||||
if (
|
||||
[
|
||||
"set_address_name",
|
||||
"set_bookmark",
|
||||
"set_comment",
|
||||
"set_inline_comment",
|
||||
"unset_bookmark",
|
||||
].includes(name)
|
||||
)
|
||||
return true;
|
||||
if (name === "set_addresses_names") return { "0x1000": true };
|
||||
if (["search_procedures", "search_strings"].includes(name)) return {};
|
||||
if (name === "procedure_info")
|
||||
return {
|
||||
name: "main",
|
||||
entrypoint: "0x1000",
|
||||
basicblock_count: 1,
|
||||
length: 4,
|
||||
signature: null,
|
||||
locals: [],
|
||||
};
|
||||
if (name === "resolve_containing_procedure")
|
||||
return {
|
||||
query_address: "0x1000",
|
||||
found: true,
|
||||
procedure: { address: "0x1000", name: "main" },
|
||||
};
|
||||
if (name === "procedure_references")
|
||||
return {
|
||||
procedure: { address: "0x1000", name: "main" },
|
||||
direction: "outgoing",
|
||||
references: {
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
instructions_scanned: 1,
|
||||
instruction_scan_truncated: false,
|
||||
};
|
||||
return name.includes("address") ? "0x1000" : "fixture";
|
||||
};
|
||||
|
||||
describe("official Hopper proxy tools", () => {
|
||||
it("lists exactly the 31 official contracts", async () => {
|
||||
it("lists every official contract", async () => {
|
||||
const client = await connect({
|
||||
callTool: () => Promise.resolve(ok(null)),
|
||||
execute: () => Promise.resolve(ok(null)),
|
||||
});
|
||||
const listed = await client.listTools();
|
||||
const officialNames = new Set(
|
||||
|
|
@ -88,9 +151,9 @@ describe("official Hopper proxy tools", () => {
|
|||
it("executes every handler and projects omitted Python optionals to null", async () => {
|
||||
const invocations: Invocation[] = [];
|
||||
const client = await connect({
|
||||
callTool: (name, arguments_) => {
|
||||
execute: (name, arguments_) => {
|
||||
invocations.push({ name, arguments_ });
|
||||
return Promise.resolve(ok({ name, arguments: arguments_ }));
|
||||
return Promise.resolve(ok(outputFor(name)));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -122,7 +185,7 @@ describe("official Hopper proxy tools", () => {
|
|||
|
||||
it("returns stable safe MCP error content", async () => {
|
||||
const client = await connect({
|
||||
callTool: () => Promise.resolve(err(new HopperRemoteError(-1, "denied"))),
|
||||
execute: () => Promise.resolve(err(new HopperRemoteError(-1, "denied"))),
|
||||
});
|
||||
const result = await client.callTool({
|
||||
name: "list_documents",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const sourceRoot = new URL("../src/", import.meta.url);
|
||||
|
||||
describe("provider-neutral architecture", () => {
|
||||
it("keeps Hopper imports behind its adapter and composition root", async () => {
|
||||
const forbiddenRoots = ["domain", "contracts", "server", "application"];
|
||||
const violations: string[] = [];
|
||||
for (const root of forbiddenRoots) {
|
||||
for (const file of await typescriptFiles(
|
||||
fileURLToPath(new URL(`${root}/`, sourceRoot)),
|
||||
)) {
|
||||
if (root === "application" && file.endsWith("/runtime.ts")) continue;
|
||||
const source = await readFile(file, "utf8");
|
||||
if (/from\s+["'][^"']*\/hopper\//u.test(source)) {
|
||||
violations.push(relative(fileURLToPath(sourceRoot), file));
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const typescriptFiles = async (
|
||||
directory: string,
|
||||
): Promise<readonly string[]> => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const nested = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) return typescriptFiles(path);
|
||||
return entry.isFile() && entry.name.endsWith(".ts") ? [path] : [];
|
||||
}),
|
||||
);
|
||||
return nested.flat();
|
||||
};
|
||||
|
|
@ -22,9 +22,9 @@ describe("localized README product facts", () => {
|
|||
expect(content).toContain('"args": ["-y", "@morluto/rea", "mcp"]');
|
||||
expect(content).toContain("Node.js 22");
|
||||
expect(content).toContain("macOS 12");
|
||||
expect(content).toMatch(/\b42\b/u);
|
||||
expect(content).toMatch(/\b31\b/u);
|
||||
expect(content).toMatch(/\b8\b/u);
|
||||
expect(content).toMatch(/\b46\b/u);
|
||||
expect(content).toMatch(/\b33\b/u);
|
||||
expect(content).toMatch(/\b10\b/u);
|
||||
expect(content).toMatch(/\b3\b/u);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const fixturePath = fileURLToPath(
|
|||
);
|
||||
|
||||
describe("production stdio runtime", () => {
|
||||
it("starts the built entrypoint, lists 42 tools, calls one, and shuts down", async () => {
|
||||
it("starts the built entrypoint, lists 46 tools, calls one, and shuts down", async () => {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [mainPath],
|
||||
|
|
@ -32,7 +32,7 @@ describe("production stdio runtime", () => {
|
|||
try {
|
||||
await client.connect(transport);
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools).toHaveLength(42);
|
||||
expect(tools.tools).toHaveLength(46);
|
||||
const result = await client.callTool({
|
||||
name: "current_document",
|
||||
arguments: {},
|
||||
|
|
@ -78,7 +78,7 @@ describe("production stdio runtime", () => {
|
|||
|
||||
try {
|
||||
await client.connect(transport);
|
||||
expect((await client.listTools()).tools).toHaveLength(42);
|
||||
expect((await client.listTools()).tools).toHaveLength(46);
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
|
|
|
|||
|
|
@ -5,10 +5,8 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BinarySession,
|
||||
type BinaryClient,
|
||||
} from "../src/application/BinarySession.js";
|
||||
import { BinarySession } from "../src/application/BinarySession.js";
|
||||
import type { AnalysisClient } from "../src/application/AnalysisProvider.js";
|
||||
import { ok } from "../src/domain/result.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
|
||||
|
|
@ -44,7 +42,7 @@ describe("target-free MCP lifecycle", () => {
|
|||
});
|
||||
expect(before.isError).toBe(true);
|
||||
expect(text(before)).toContain("NoBinaryOpenError");
|
||||
expect((await mcp.listTools()).tools).toHaveLength(42);
|
||||
expect((await mcp.listTools()).tools).toHaveLength(46);
|
||||
expect(
|
||||
(await mcp.callTool({ name: "open_binary", arguments: { path: first } }))
|
||||
.isError,
|
||||
|
|
@ -64,9 +62,11 @@ describe("target-free MCP lifecycle", () => {
|
|||
});
|
||||
});
|
||||
|
||||
const client = (path: string, closed: string[]): BinaryClient => ({
|
||||
callTool: (name) =>
|
||||
Promise.resolve(ok(name === "health" ? null : { path, name })),
|
||||
const client = (path: string, closed: string[]): AnalysisClient => ({
|
||||
execute: (name) =>
|
||||
Promise.resolve(
|
||||
ok(name === "health" ? null : name === "current_document" ? path : null),
|
||||
),
|
||||
close: () => {
|
||||
closed.push(path);
|
||||
return Promise.resolve();
|
||||
|
|
|
|||
|
|
@ -8,16 +8,16 @@ import {
|
|||
} from "../src/contracts/toolContracts.js";
|
||||
|
||||
describe("tool contract inventory", () => {
|
||||
it("preserves the original 39 contracts and adds three session tools", () => {
|
||||
expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(31);
|
||||
expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(8);
|
||||
it("publishes 43 analysis contracts and three session tools", () => {
|
||||
expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(33);
|
||||
expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(10);
|
||||
expect(SESSION_TOOL_CONTRACTS.map(({ name }) => name)).toEqual([
|
||||
"open_binary",
|
||||
"close_binary",
|
||||
"binary_session",
|
||||
]);
|
||||
expect(TOOL_CONTRACTS).toHaveLength(42);
|
||||
expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(42);
|
||||
expect(TOOL_CONTRACTS).toHaveLength(46);
|
||||
expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(46);
|
||||
});
|
||||
|
||||
it("retains documented enhanced-tool limits at the input boundary", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue