mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(hopper)!: bind analysis to the active target and correct boundary contracts
Preserve native document identity, nullable bookmark labels, address diagnostics, segment-end byte prefixes, and verified file mappings. Validate batch rename addresses before mutation. BREAKING CHANGE: remove set_current_document. Use open_binary to switch targets; document arguments are scoped to the active target.
This commit is contained in:
1 parent
34cc9259c1
commit
2f71375efc
23 files changed
+140
-146
No files matched your search
+1
-1
@@ -215,7 +215,7 @@ rea setup
|
||||
|
||||
| عائلة الأدوات | العدد | الاستخدام |
|
||||
| ---------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| الفحص الأصلي | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والاستدعاءات والمراجع والتعليقات وقراءة البايتات ومواضع الملفات |
|
||||
| الفحص الأصلي | 40 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والاستدعاءات والمراجع والتعليقات وقراءة البايتات ومواضع الملفات |
|
||||
| سير عمل التحقيق | 14 | نظرات عامة على التطبيقات وملفات الدوال وواجهات API الأصلية والتوزيع وفك الترجمة الدفعي وتتبع الميزات ومسارات الاستدعاء ومخططاته واكتشاف Swift وObjective-C |
|
||||
| أدوات الملفات الثنائية | 10 | بيانات Mach-O والتوقيعات وplist والمعماريات وأسماء Swift ومراقبة الاستدعاءات عبر LLDB على macOS؛ تخطيط ELF وأدلة الحماية الثابتة وسجلات الأعطال على Linux |
|
||||
| مخطط المخرجات | 7 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple وتحليل تحميل Mach-O dylib والاستخراج واستدلال واجهات EVM دون اتصال |
|
||||
|
||||
+1
-1
@@ -238,7 +238,7 @@ TypeScript と SQLite を使って私のプロジェクト向けに実装して
|
||||
|
||||
| ツール分類 | 数 | 用途 |
|
||||
| -------------------- | --: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット |
|
||||
| ネイティブ検査 | 40 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット |
|
||||
| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 |
|
||||
| ネイティブバイナリ | 10 | macOS の Mach-O メタデータ、署名、plist、アーキテクチャ、Swift シンボル、LLDB による呼び出し観測;Linux の ELF レイアウト、静的な保護機構の証拠と記録済みクラッシュ |
|
||||
| 成果物グラフ | 7 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、Mach-O dylib のロード解決、抽出、オフライン EVM インターフェース推定 |
|
||||
|
||||
+1
-1
@@ -205,7 +205,7 @@ REA는 1~5단계의 바이너리 분석을 처리합니다. 6단계는 에이전
|
||||
|
||||
| 도구 분류 | 수 | 용도 |
|
||||
| ----------------- | --: | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 네이티브 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조, 주석, 바이트 읽기, 파일 오프셋 |
|
||||
| 네이티브 검사 | 40 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조, 주석, 바이트 읽기, 파일 오프셋 |
|
||||
| 조사 워크플로 | 14 | 앱 개요, 함수 기록서, 네이티브 API와 디스패치, 일괄 디컴파일, 기능 추적, 호출 경로, 호출 그래프, Swift와 Objective-C 탐색 |
|
||||
| 네이티브 바이너리 | 10 | macOS의 Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 심볼과 LLDB 호출 관찰; Linux의 ELF 레이아웃, 정적 보호 증거와 기록된 크래시 |
|
||||
| 아티팩트 그래프 | 7 | 디렉터리와 패키지 목록, 컴파일된 Interface Builder 파일, Apple 애셋 카탈로그, Mach-O dylib 로드 해석, 추출, 오프라인 EVM 인터페이스 추론 |
|
||||
|
||||
+1
-1
@@ -204,7 +204,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
||||
|
||||
| 工具类别 | 数量 | 用途 |
|
||||
| ----------------- | ---: | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| 原生检查 | 41 | 函数、伪代码、汇编、字符串、符号、调用、引用、注释、字节读取与文件偏移 |
|
||||
| 原生检查 | 40 | 函数、伪代码、汇编、字符串、符号、调用、引用、注释、字节读取与文件偏移 |
|
||||
| 调查工作流 | 14 | 应用概览、函数档案、原生 API 与分发、批量反编译、功能追踪、调用路径与调用图、Swift 与 Objective-C 发现 |
|
||||
| 原生二进制工具 | 10 | macOS 的 Mach-O 元数据、签名、plist、架构、Swift 符号与 LLDB 调用观察;Linux 的 ELF 布局、静态保护证据与历史崩溃记录 |
|
||||
| 产物图 | 7 | 目录与软件包清单、编译后的 Interface Builder 文件、Apple 资源目录、Mach-O dylib 加载解析、提取与离线 EVM 接口推断 |
|
||||
|
||||
+62
-47
@@ -13,7 +13,6 @@ import socket
|
||||
from typing import Any, Optional, Protocol, Sequence
|
||||
|
||||
BAD_ADDRESSES = (-1, 0xFFFFFFFFFFFFFFFF, None)
|
||||
_selected_document = None
|
||||
_rea_session_document = None
|
||||
_search_inventory_cache = {}
|
||||
_pseudocode_cache = {}
|
||||
@@ -89,9 +88,11 @@ def _session_document():
|
||||
global _rea_session_document
|
||||
documents = _api().documents()
|
||||
if _rea_session_document is not None:
|
||||
# Hopper recreates Python wrappers; equality compares native handles.
|
||||
# Retain our bound wrapper so cache identity stays stable across calls.
|
||||
return (
|
||||
_rea_session_document
|
||||
if any(document is _rea_session_document for document in documents)
|
||||
if any(document == _rea_session_document for document in documents)
|
||||
else None
|
||||
)
|
||||
return _bind_session_document(documents)
|
||||
@@ -174,46 +175,46 @@ def _json_safe(value):
|
||||
|
||||
|
||||
def _document(name=None):
|
||||
"""Resolve an explicit or session-selected document without changing Hopper UI."""
|
||||
global _selected_document
|
||||
api = _api()
|
||||
documents = api.documents()
|
||||
if name is not None:
|
||||
matches = [candidate for candidate in documents if candidate.getDocumentName() == name]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
current = api.current_document()
|
||||
if current is not None and current.getDocumentName() == name:
|
||||
return current
|
||||
raise InvalidRequestError("Hopper document name is ambiguous")
|
||||
raise InvalidRequestError("Unknown Hopper document")
|
||||
if _selected_document is not None:
|
||||
for candidate in documents:
|
||||
if candidate.getDocumentName() == _selected_document:
|
||||
return candidate
|
||||
"""Keep observations and mutations bound to the active REA target."""
|
||||
documents = _api().documents()
|
||||
session_document = _session_document()
|
||||
if session_document is not None:
|
||||
return session_document
|
||||
current = api.current_document()
|
||||
if current is None:
|
||||
raise CapabilityUnavailableError("No Hopper document is loaded")
|
||||
return current
|
||||
if session_document is None:
|
||||
raise CapabilityUnavailableError("The active REA target's Hopper document is no longer open; reopen the target with open_binary")
|
||||
if name is not None:
|
||||
# A document name is display metadata; scope it to the bound native
|
||||
# handle even when another open target happens to have the same name.
|
||||
if session_document.getDocumentName() == name:
|
||||
return session_document
|
||||
if any(candidate.getDocumentName() == name for candidate in documents):
|
||||
raise InvalidRequestError("Hopper document belongs to another target; use open_binary to select that target")
|
||||
raise InvalidRequestError("Unknown Hopper document")
|
||||
return session_document
|
||||
|
||||
|
||||
def _address(document, value=None):
|
||||
"""Resolve hexadecimal addresses first, then fall back to Hopper symbol names."""
|
||||
"""Keep explicit hexadecimal coordinates distinct from symbol names."""
|
||||
if value is None:
|
||||
return document.getCurrentAddress()
|
||||
if not isinstance(value, str):
|
||||
raise InvalidRequestError("Address must be a string")
|
||||
try:
|
||||
return int(value, 16)
|
||||
except ValueError:
|
||||
result = document.getAddressForName(value)
|
||||
if result in BAD_ADDRESSES:
|
||||
raise InvalidRequestError("Unknown Hopper address or name")
|
||||
return result
|
||||
if value.lower().startswith("0x"):
|
||||
try:
|
||||
address = int(value, 16)
|
||||
except ValueError as error:
|
||||
raise InvalidRequestError("Invalid hexadecimal Hopper address: %s" % value) from error
|
||||
else:
|
||||
# Names such as `add` and `face` are valid hexadecimal strings too.
|
||||
# Prefer an observed symbol, retaining bare-hex compatibility only
|
||||
# when Hopper has no symbol with the supplied name.
|
||||
address = document.getAddressForName(value)
|
||||
if address in BAD_ADDRESSES:
|
||||
try:
|
||||
address = int(value, 16)
|
||||
except ValueError as error:
|
||||
raise InvalidRequestError("Unknown Hopper address or name: %s" % value) from error
|
||||
if not 0 <= address < 0xFFFFFFFFFFFFFFFF:
|
||||
raise InvalidRequestError("Address is outside Hopper's usable unsigned 64-bit range: %s" % value)
|
||||
return address
|
||||
|
||||
|
||||
def _segment(document, address):
|
||||
@@ -588,7 +589,6 @@ def _analyze_function(document, params):
|
||||
|
||||
def _dispatch(method, params):
|
||||
"""Dispatch only the closed operation set implemented by REA's public tools."""
|
||||
global _selected_document
|
||||
if method == "health":
|
||||
return {"name": "REA Hopper bridge", "version": "1.0.0", "run_id": REA_RUN_ID}
|
||||
if method in ("shutdown", "shutdown_document"):
|
||||
@@ -622,14 +622,9 @@ def _dispatch(method, params):
|
||||
"document_closed": document_closed,
|
||||
}
|
||||
if method == "list_documents":
|
||||
return [document.getDocumentName() for document in Document.getAllDocuments()]
|
||||
return [document.getDocumentName() for document in _api().documents()]
|
||||
if method == "current_document":
|
||||
return _document().getDocumentName()
|
||||
if method == "set_current_document":
|
||||
document = _document(params.get("document"))
|
||||
_selected_document = document.getDocumentName()
|
||||
return _selected_document
|
||||
|
||||
document = _document(params.get("document"))
|
||||
if method in EXHAUSTIVE_ANALYSIS_METHODS:
|
||||
_api().require_analysis_complete(document, method)
|
||||
@@ -650,10 +645,17 @@ def _dispatch(method, params):
|
||||
"Hopper's public Python API does not expose readBytes"
|
||||
)
|
||||
address = _address(document, params.get("address"))
|
||||
value = reader(address, length)
|
||||
segment = _segment(document, address)
|
||||
# Document.readBytes delegates to one Segment and returns False when
|
||||
# the requested range crosses its end. Preserve the readable prefix.
|
||||
available = segment.getStartingAddress() + segment.getLength() - address
|
||||
value = reader(address, min(length, available))
|
||||
if value is False or value is None:
|
||||
raise InvalidRequestError("Hopper could not read the requested byte range at %s" % _hex(address))
|
||||
if not isinstance(value, (bytes, bytearray)):
|
||||
raise CapabilityUnavailableError(
|
||||
"Hopper's public Python API returned an unsupported byte representation"
|
||||
"Hopper readBytes returned %s for the requested %s-byte range at %s"
|
||||
% (type(value).__name__, length, _hex(address))
|
||||
)
|
||||
data = bytes(value)
|
||||
return {
|
||||
@@ -678,6 +680,15 @@ def _dispatch(method, params):
|
||||
or offset < 0
|
||||
):
|
||||
raise InvalidRequestError("Address has no authoritative file-offset mapping")
|
||||
inverse = getattr(document, "getAddressFromFileOffset", None)
|
||||
if not callable(inverse):
|
||||
raise CapabilityUnavailableError(
|
||||
"Hopper's public Python API cannot verify the file-offset mapping by reverse lookup"
|
||||
)
|
||||
# Synthetic memory can report offset zero, which belongs to the actual
|
||||
# file header. An integer alone does not prove source-file provenance.
|
||||
if inverse(offset) != address:
|
||||
raise InvalidRequestError("Address has no authoritative file-offset mapping: reverse lookup disagrees at %s" % _hex(address))
|
||||
return {"address": _hex(address), "file_offset": offset}
|
||||
if method == "resolve_containing_procedure":
|
||||
address = _address(document, params.get("address"))
|
||||
@@ -797,8 +808,11 @@ def _dispatch(method, params):
|
||||
_invalidate_pseudocode(document)
|
||||
return result
|
||||
if method == "set_addresses_names":
|
||||
# Validate all destinations before applying any annotation. A malformed
|
||||
# later address must not discard the result of an earlier mutation.
|
||||
names = [(key, _address(document, key), value) for key, value in params["names"].items()]
|
||||
try:
|
||||
result = {key: document.setNameAtAddress(_address(document, key), value) for key, value in params["names"].items()}
|
||||
result = {key: document.setNameAtAddress(address, value) for key, address, value in names}
|
||||
finally:
|
||||
_invalidate_search_inventory(document)
|
||||
_invalidate_pseudocode(document)
|
||||
@@ -810,7 +824,8 @@ def _dispatch(method, params):
|
||||
getter = segment.getCommentAtAddress if method == "set_comment" else segment.getInlineCommentAtAddress
|
||||
setter(address, params["comment"])
|
||||
_invalidate_pseudocode(document)
|
||||
return getter(address) == params["comment"]
|
||||
observed = getter(address)
|
||||
return observed == params["comment"] or (params["comment"] == "" and observed is None)
|
||||
if method == "list_bookmarks":
|
||||
return [{"address": _hex(item), "name": document.getBookmarkName(item)} for item in document.getBookmarks()]
|
||||
if method == "set_bookmark":
|
||||
@@ -913,14 +928,14 @@ def _diagnostic_type(error):
|
||||
|
||||
|
||||
def _safe_diagnostic(error):
|
||||
"""Project one exception without retaining provider or credential text."""
|
||||
"""Preserve bridge validation reasons while excluding transport credentials."""
|
||||
diagnostic_type = _diagnostic_type(error)
|
||||
if diagnostic_type == "capability_unavailable":
|
||||
message = str(error)
|
||||
elif diagnostic_type == "authorization":
|
||||
message = "Invalid bridge capability"
|
||||
elif diagnostic_type == "invalid_request":
|
||||
message = "Invalid Hopper bridge request"
|
||||
message = str(error)
|
||||
else:
|
||||
message = "%s: Hopper bridge operation failed" % type(error).__name__
|
||||
return {"code": -32000, "message": message, "type": diagnostic_type}
|
||||
|
||||
@@ -314,8 +314,15 @@ Hopper serializes analysis requests. Cancelling a wait can leave provider work
|
||||
running, which the session reports. Successful decompilation is cached until
|
||||
a relevant rename or comment changes it.
|
||||
|
||||
Closing a session shuts down REA's bridge and removes its temporary socket
|
||||
directory while preserving the Hopper application. A `cleanup_incomplete`
|
||||
Analysis and annotation calls stay bound to the active target's native Hopper
|
||||
document, even when GUI focus changes or other documents have the same display
|
||||
name. Use `open_binary` to change targets. Byte reads stop at a segment boundary
|
||||
and return the readable prefix with `complete: false`. File-offset mapping checks
|
||||
the reverse lookup; synthetic external-symbol memory has no original file offset.
|
||||
|
||||
Closing or switching a target closes its bound Hopper document, shuts down REA's
|
||||
bridge and removes its temporary socket directory while preserving the Hopper
|
||||
application and unrelated documents. A `cleanup_incomplete`
|
||||
result identifies resources whose cleanup could not be verified.
|
||||
|
||||
### Hopper in CI
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import { type AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { analysisBookmarkSchema } from "../domain/hopperValues.js";
|
||||
import { resolveAnalysisDocument } from "./AnalysisDocument.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
@@ -188,25 +189,17 @@ const matchingBookmarks = (value: JsonValue, address: string): Facet => {
|
||||
remediation:
|
||||
"Retry list_bookmarks directly and report the provider output shape if it remains malformed.",
|
||||
};
|
||||
if (!value.every(isBookmark))
|
||||
const parsed = analysisBookmarkSchema.array().safeParse(value);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
state: "unavailable",
|
||||
reason:
|
||||
"list_bookmarks returned malformed entries; expected address and name strings.",
|
||||
"list_bookmarks returned malformed entries; expected an address string and a string or null name.",
|
||||
remediation:
|
||||
"Retry list_bookmarks directly and report the provider output shape if it remains malformed.",
|
||||
};
|
||||
return {
|
||||
state: "available",
|
||||
value: value.filter((bookmark) => bookmark.address === address),
|
||||
value: parsed.data.filter((bookmark) => bookmark.address === address),
|
||||
};
|
||||
};
|
||||
|
||||
const isBookmark = (
|
||||
value: JsonValue,
|
||||
): value is { readonly address: string; readonly name: string } =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
typeof value.address === "string" &&
|
||||
typeof value.name === "string";
|
||||
@@ -35,7 +35,6 @@ const STATEFUL_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
|
||||
"current_document",
|
||||
"goto_address",
|
||||
"list_documents",
|
||||
"set_current_document",
|
||||
]);
|
||||
|
||||
const CURSOR_DEFAULT_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
|
||||
@@ -49,10 +48,7 @@ const CURSOR_DEFAULT_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
|
||||
|
||||
const DOCUMENT_SCOPED_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name).filter(
|
||||
(name) =>
|
||||
name !== "current_document" &&
|
||||
name !== "list_documents" &&
|
||||
name !== "set_current_document",
|
||||
(name) => name !== "current_document" && name !== "list_documents",
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -143,7 +143,6 @@ export const MCP_TOOLS_WITHOUT_DEDICATED_CLI = Object.freeze([
|
||||
"set_addresses_names",
|
||||
"set_bookmark",
|
||||
"set_comment",
|
||||
"set_current_document",
|
||||
"set_inline_comment",
|
||||
"unset_bookmark",
|
||||
"get_objc_classes",
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const document = z.string().optional().describe("The document name");
|
||||
const address = z
|
||||
.string()
|
||||
.describe(
|
||||
"A provider-normalized address; default memory uses 0x-prefixed hexadecimal",
|
||||
);
|
||||
import { address, document } from "./toolContractHelpers.js";
|
||||
|
||||
/** Hopper memory and file-mapping contracts. */
|
||||
export const HOPPER_MEMORY_TOOL_DEFINITIONS = [
|
||||
|
||||
@@ -101,7 +101,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
),
|
||||
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.",
|
||||
"List every bookmark in the selected Hopper document as address and name pairs; unnamed bookmarks retain a null name. Use bookmarks as analyst-authored navigation aids; this does not discover code references.",
|
||||
z.object({ document }),
|
||||
),
|
||||
official(
|
||||
@@ -208,7 +208,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
),
|
||||
official(
|
||||
"set_addresses_names",
|
||||
"Assign analyst names to multiple addresses in one call and return per-address success booleans. This mutates analysis metadata; verify failures individually.",
|
||||
"Assign analyst names to multiple addresses in one call and return per-address success booleans. Hopper validates every address before applying names. This mutates analysis metadata; verify failures individually.",
|
||||
z.object({ names: z.record(z.string(), z.string()), document }),
|
||||
),
|
||||
official(
|
||||
@@ -218,17 +218,12 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
),
|
||||
official(
|
||||
"set_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.",
|
||||
"Write a regular analysis comment at a hexadecimal address and return whether readback matched. An empty comment clears it. 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 an already-open Hopper document by exact document name. This changes subsequent default-document routing; list_documents can supply names when needed, while explicit document inputs keep calls reproducible.",
|
||||
z.object({ document: z.string() }),
|
||||
),
|
||||
official(
|
||||
"set_inline_comment",
|
||||
"Write an inline instruction comment at a hexadecimal address and return whether readback matched. This mutates analysis metadata.",
|
||||
"Write an inline instruction comment at a hexadecimal address and return whether readback matched. An empty comment clears it; Hopper may flatten newlines, producing false on exact readback. This mutates analysis metadata.",
|
||||
z.object({ address, comment: z.string(), document }),
|
||||
),
|
||||
official(
|
||||
|
||||
@@ -104,7 +104,7 @@ export const PROMPT_CONTRACTS = [
|
||||
target_path: optional(
|
||||
"Absolute target path; omit when the intended target is already open",
|
||||
),
|
||||
document: optional("Open Hopper document to inspect", "document"),
|
||||
document: optional("Active target's provider document name", "document"),
|
||||
procedure: optional(
|
||||
"Known procedure name or address to prioritize",
|
||||
"procedure",
|
||||
@@ -121,9 +121,9 @@ export const PROMPT_CONTRACTS = [
|
||||
"For a JavaScript/Electron application directory, call analyze_javascript_application with input_path=target_path directly; no open_binary call is needed. For a file or macOS app bundle, open target_path only when the required target is not active.",
|
||||
},
|
||||
{
|
||||
tools: ["list_documents", "set_current_document"],
|
||||
tools: ["current_document"],
|
||||
instruction:
|
||||
"Select a document only when needed for the requested analysis.",
|
||||
"Analysis stays bound to the active target's provider document. Use open_binary to switch targets; document names never select another artifact.",
|
||||
},
|
||||
{
|
||||
tools: ["search_strings", "search_procedures", "procedure_address"],
|
||||
|
||||
@@ -36,7 +36,6 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly<
|
||||
set_addresses_names: { names: { "0x1000": "entry" } },
|
||||
set_bookmark: { address: "0x1000" },
|
||||
set_comment: { address: "0x1000", comment: "validated entry point" },
|
||||
set_current_document: { document: "fixture" },
|
||||
set_inline_comment: { address: "0x1000", comment: "calls parser" },
|
||||
unset_bookmark: { address: "0x1000" },
|
||||
get_call_graph: { address: "0x1000" },
|
||||
|
||||
@@ -4,7 +4,12 @@ import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { TOOL_EXAMPLE_OVERRIDES } from "./toolContractExamples.js";
|
||||
import type { ToolExample } from "./toolContractTypes.js";
|
||||
|
||||
export const document = z.string().optional().describe("The document name");
|
||||
export const document = z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"The active target's provider document name; use open_binary to switch targets",
|
||||
);
|
||||
export const address = z
|
||||
.string()
|
||||
.describe(
|
||||
|
||||
@@ -163,7 +163,6 @@ const PUBLISHED_TOOL_NAME_FLOOR = [
|
||||
"set_addresses_names",
|
||||
"set_bookmark",
|
||||
"set_comment",
|
||||
"set_current_document",
|
||||
"set_inline_comment",
|
||||
"trace_application_feature",
|
||||
"trace_call_path",
|
||||
|
||||
@@ -161,7 +161,6 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
set_addresses_names: effects({ mutatesTarget: true, mutatesSession: true }),
|
||||
set_bookmark: effects({ mutatesTarget: true, mutatesSession: true }),
|
||||
set_comment: effects({ mutatesTarget: true, mutatesSession: true }),
|
||||
set_current_document: effects({ mutatesSession: true, changesUiState: true }),
|
||||
set_inline_comment: effects({ mutatesTarget: true, mutatesSession: true }),
|
||||
unset_bookmark: effects({
|
||||
mutatesTarget: true,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
processCaptureSchema,
|
||||
} from "../domain/process/processCapture.js";
|
||||
import {
|
||||
analysisBookmarkSchema,
|
||||
functionInstructionWindowSchema,
|
||||
referenceKindSchema,
|
||||
} from "../domain/hopperValues.js";
|
||||
@@ -82,7 +83,10 @@ const contextFacetSchema = z.discriminatedUnion("state", [
|
||||
}),
|
||||
]);
|
||||
const bookmarkFacetSchema = z.discriminatedUnion("state", [
|
||||
z.object({ state: z.literal("available"), value: z.array(addressedEntry) }),
|
||||
z.object({
|
||||
state: z.literal("available"),
|
||||
value: z.array(analysisBookmarkSchema),
|
||||
}),
|
||||
z.object({
|
||||
state: z.literal("unavailable"),
|
||||
reason: z.string(),
|
||||
@@ -116,7 +120,7 @@ export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
current_document: resultOf(z.string()),
|
||||
goto_address: resultOf(z.string()),
|
||||
inline_comment: resultOf(nullableText),
|
||||
list_bookmarks: resultOf(z.array(addressedEntry)),
|
||||
list_bookmarks: resultOf(z.array(analysisBookmarkSchema)),
|
||||
list_documents: resultOf(z.array(z.string())),
|
||||
list_names: resultOf(z.array(addressedValue)),
|
||||
list_procedures: resultOf(z.array(addressedValue)),
|
||||
@@ -176,7 +180,6 @@ export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
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),
|
||||
|
||||
@@ -56,7 +56,9 @@ export const analysisErrorRemediationAction = (
|
||||
? "Check the Hopper launcher and target details, then retry opening the target."
|
||||
: `Use the active REA session ${error.ownerRunId} or close it before opening this target again.`;
|
||||
if (error instanceof HopperRemoteError)
|
||||
return "Review the Hopper diagnostic details; correct the request or retry if the failure was transient.";
|
||||
return error.diagnosticType === "invalid_request"
|
||||
? "Correct the reported address, document, or arguments and retry."
|
||||
: "Review the Hopper diagnostic details; correct the request or retry if the failure was transient.";
|
||||
if (error instanceof EvidenceReferenceError)
|
||||
return "Use an exact Evidence reference retained by this session, supply the complete inline Evidence, re-run its producer, or import its Evidence bundle. close_binary clears retained records.";
|
||||
if (
|
||||
@@ -87,6 +89,11 @@ export const analysisErrorCategory = (
|
||||
return error.userCategory ?? "execution_failure";
|
||||
if (error instanceof BrowserObservationError)
|
||||
return browserErrorCategory(error.reason);
|
||||
if (
|
||||
error instanceof HopperRemoteError &&
|
||||
error.diagnosticType === "invalid_request"
|
||||
)
|
||||
return "invalid_input";
|
||||
if (
|
||||
error instanceof HopperRemoteError &&
|
||||
error.diagnosticType === "authorization"
|
||||
|
||||
@@ -82,6 +82,11 @@ export const projectAnalysisError = (
|
||||
const underlyingErrorCode = (
|
||||
error: AnalysisError,
|
||||
): AnalysisErrorProjection["code"] => {
|
||||
if (
|
||||
error instanceof HopperRemoteError &&
|
||||
error.diagnosticType === "invalid_request"
|
||||
)
|
||||
return "invalid_request";
|
||||
if (error instanceof ProviderSelectionError)
|
||||
return error.reason === "provider_unavailable"
|
||||
? "provider_unavailable"
|
||||
|
||||
@@ -62,6 +62,11 @@ const segmentSchema = z.object({
|
||||
const unavailableAnalysisFactSchema = z
|
||||
.object({ available: z.literal(false), reason: z.string() })
|
||||
.strict();
|
||||
/** An analyzed bookmark retains a missing provider label as null. */
|
||||
export const analysisBookmarkSchema = z.strictObject({
|
||||
address: z.string(),
|
||||
name: z.string().nullable(),
|
||||
});
|
||||
/** Complete observed function-body ranges; every range endpoint is inclusive. */
|
||||
export const functionBodySchema = z.discriminatedUnion("available", [
|
||||
unavailableAnalysisFactSchema,
|
||||
|
||||
@@ -201,6 +201,11 @@ export class HopperProvider implements AnalysisProviderCandidate {
|
||||
...(profile === undefined ? {} : { analysisProfile: profile }),
|
||||
limitations: [
|
||||
"Results depend on Hopper's completed static analysis.",
|
||||
...(operation === "read_bytes"
|
||||
? [
|
||||
"Hopper byte reads stop at the containing segment's exclusive end; a readable prefix is returned with complete=false when the requested range crosses it.",
|
||||
]
|
||||
: []),
|
||||
...(operation === "procedure_references"
|
||||
? [
|
||||
"Hopper's public Python API does not expose flow classification for calls without resolved targets; unresolved_calls is empty and its coverage is unknown.",
|
||||
|
||||
@@ -45,7 +45,6 @@ export const HOPPER_OPERATIONS = Object.freeze([
|
||||
"set_addresses_names",
|
||||
"set_bookmark",
|
||||
"set_comment",
|
||||
"set_current_document",
|
||||
"set_inline_comment",
|
||||
"unset_bookmark",
|
||||
"xrefs",
|
||||
|
||||
@@ -130,7 +130,7 @@ const probeResultSchema = z.strictObject({
|
||||
id: z.literal(0),
|
||||
error: z.strictObject({
|
||||
code: z.literal(-32000),
|
||||
message: z.literal("Invalid Hopper bridge request"),
|
||||
message: z.literal("Invalid bridge request id"),
|
||||
type: z.literal("invalid_request"),
|
||||
}),
|
||||
}),
|
||||
@@ -167,12 +167,25 @@ describe("Hopper API facade", () => {
|
||||
"KeyError: Hopper bridge operation failed",
|
||||
]);
|
||||
expect(result.malformed_requests).toEqual([
|
||||
...[0, 0, 0, 0, 0, 2, 3, 0, 4, 5, 6, 7].map((id) => ({
|
||||
...[
|
||||
[0, "Invalid bridge request JSON"],
|
||||
[0, "Invalid bridge request JSON"],
|
||||
[0, "Invalid bridge request shape"],
|
||||
[0, "Invalid bridge request shape"],
|
||||
[0, "Invalid bridge request shape"],
|
||||
[2, "Invalid bridge method or parameters"],
|
||||
[3, "Invalid bridge method or parameters"],
|
||||
[0, "Invalid bridge request id"],
|
||||
[4, "Address must be a string"],
|
||||
[5, "Byte-read length must be an integer"],
|
||||
[6, "case_sensitive must be a boolean"],
|
||||
[7, "Unknown bridge method"],
|
||||
].map(([id, message]) => ({
|
||||
id,
|
||||
error: {
|
||||
code: -32000,
|
||||
type: "invalid_request",
|
||||
message: "Invalid Hopper bridge request",
|
||||
message,
|
||||
},
|
||||
})),
|
||||
{
|
||||
@@ -207,50 +220,6 @@ describe("Hopper API facade", () => {
|
||||
{ id: 5, result: [] },
|
||||
{ id: 6, result: [] },
|
||||
]);
|
||||
expect(result.procedure_references).toEqual({
|
||||
procedure: {
|
||||
address: "0x401000",
|
||||
name: "fixture-procedure",
|
||||
classification: null,
|
||||
body: {
|
||||
available: false,
|
||||
reason:
|
||||
"Hopper's public Python API does not expose complete function body ranges",
|
||||
},
|
||||
},
|
||||
direction: "outgoing",
|
||||
reference_kinds_available: false,
|
||||
unresolved_calls: [],
|
||||
references: [],
|
||||
});
|
||||
expect(result.containing_procedure).toEqual({
|
||||
query_address: "0x401000",
|
||||
found: true,
|
||||
procedure: {
|
||||
address: "0x401000",
|
||||
name: "fixture-procedure",
|
||||
classification: null,
|
||||
body: {
|
||||
available: false,
|
||||
reason:
|
||||
"Hopper's public Python API does not expose complete function body ranges",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.procedure_info).toEqual({
|
||||
name: "fixture-procedure",
|
||||
entrypoint: "0x401000",
|
||||
basicblock_count: 1,
|
||||
length: 4,
|
||||
signature: "int fixture-procedure()",
|
||||
locals: [],
|
||||
classification: null,
|
||||
body: {
|
||||
available: false,
|
||||
reason:
|
||||
"Hopper's public Python API does not expose complete function body ranges",
|
||||
},
|
||||
});
|
||||
expect(stdout).not.toContain("supersecret");
|
||||
expect(result.analysis_guard.message).toContain(
|
||||
"requires completed Hopper background analysis",
|
||||
|
||||
Reference in new issue
Block a user