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:
morluto committed 2026-10-09 02:05:31 +08:00
1 parent 34cc9259c1
commit 2f71375efc
23 files changed
+140 -146

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+9 -2
View File
@@ -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
+5 -12
View File
@@ -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",
),
);
-1
View File
@@ -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 -7
View File
@@ -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 = [
+4 -9
View File
@@ -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(
+3 -3
View File
@@ -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"],
-1
View File
@@ -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" },
+6 -1
View File
@@ -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(
-1
View File
@@ -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",
-1
View File
@@ -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,
+6 -3
View File
@@ -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),
+8 -1
View File
@@ -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"
+5
View File
@@ -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"
+5
View File
@@ -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,
+5
View File
@@ -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.",
-1
View File
@@ -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",