diff --git a/README_ar.md b/README_ar.md index 2cfccff05e..79f5e8e2c2 100644 --- a/README_ar.md +++ b/README_ar.md @@ -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 دون اتصال | diff --git a/README_ja.md b/README_ja.md index 213ceeb3a6..5db65e77e7 100644 --- a/README_ja.md +++ b/README_ja.md @@ -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 インターフェース推定 | diff --git a/README_ko.md b/README_ko.md index f8a13d3e41..5df5089cac 100644 --- a/README_ko.md +++ b/README_ko.md @@ -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 인터페이스 추론 | diff --git a/README_zh.md b/README_zh.md index bc3cd5e545..b1b1e4cac1 100644 --- a/README_zh.md +++ b/README_zh.md @@ -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 接口推断 | diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py index b290f697c7..166f530a18 100644 --- a/bridge/hopper_bridge.py +++ b/bridge/hopper_bridge.py @@ -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} diff --git a/docs/installation.md b/docs/installation.md index bc21b585b1..0c04129aaa 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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 diff --git a/src/application/AnalysisContextQueries.ts b/src/application/AnalysisContextQueries.ts index 30fa4c6486..b82ef410a6 100644 --- a/src/application/AnalysisContextQueries.ts +++ b/src/application/AnalysisContextQueries.ts @@ -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"; diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index edb39da10c..0c80fa4df3 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -35,7 +35,6 @@ const STATEFUL_OPERATIONS: ReadonlySet = new Set([ "current_document", "goto_address", "list_documents", - "set_current_document", ]); const CURSOR_DEFAULT_OPERATIONS: ReadonlySet = new Set([ @@ -49,10 +48,7 @@ const CURSOR_DEFAULT_OPERATIONS: ReadonlySet = new Set([ const DOCUMENT_SCOPED_OPERATIONS: ReadonlySet = 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", ), ); diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 61a85bb25a..2cf700e580 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -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", diff --git a/src/contracts/hopperMemoryContracts.ts b/src/contracts/hopperMemoryContracts.ts index 3d54cf52b2..ddebc3e819 100644 --- a/src/contracts/hopperMemoryContracts.ts +++ b/src/contracts/hopperMemoryContracts.ts @@ -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 = [ diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index 6b69c80533..6a33008338 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -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( diff --git a/src/contracts/promptContracts.ts b/src/contracts/promptContracts.ts index 477be2d667..534515a7db 100644 --- a/src/contracts/promptContracts.ts +++ b/src/contracts/promptContracts.ts @@ -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"], diff --git a/src/contracts/toolContractExamples.ts b/src/contracts/toolContractExamples.ts index 2135a85432..0d7e531136 100644 --- a/src/contracts/toolContractExamples.ts +++ b/src/contracts/toolContractExamples.ts @@ -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" }, diff --git a/src/contracts/toolContractHelpers.ts b/src/contracts/toolContractHelpers.ts index 76a72118e5..7167c05e5d 100644 --- a/src/contracts/toolContractHelpers.ts +++ b/src/contracts/toolContractHelpers.ts @@ -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( diff --git a/src/contracts/toolContracts.test.ts b/src/contracts/toolContracts.test.ts index f0086cff4b..2c4431eba1 100644 --- a/src/contracts/toolContracts.test.ts +++ b/src/contracts/toolContracts.test.ts @@ -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", diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 558f4551fa..dbe2b3bc9a 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -161,7 +161,6 @@ export const TOOL_EFFECTS: Readonly> = { 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, diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 3fd1575ed9..13a94b210e 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -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> = { 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> = { 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), diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 5fc042d4a0..7505410223 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -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" diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 0f63da0bb1..59d61f9326 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -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" diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index 2add2a1d13..0b852d993e 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -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, diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index a8de08e8a7..a07f9994b3 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -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.", diff --git a/src/hopper/HopperProviderCapabilities.ts b/src/hopper/HopperProviderCapabilities.ts index d42f2c7a99..105bec8703 100644 --- a/src/hopper/HopperProviderCapabilities.ts +++ b/src/hopper/HopperProviderCapabilities.ts @@ -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", diff --git a/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts b/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts index 3ac0234738..5544ea53f9 100644 --- a/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts +++ b/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts @@ -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",