From 893d2e2d0550271297c452b418f80bdf8ae21514 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:59:53 +0800 Subject: [PATCH 1/5] feat(mcp): add typed bounded analysis tools --- bridge/hopper_bridge.py | 91 +++++++++++++++++++- src/application/EnhancedTools.ts | 81 ++++++++++++------ src/contracts/enhancedInputs.ts | 13 ++- src/contracts/toolContracts.ts | 94 +++++++++++++++++++-- src/domain/hopperValues.ts | 47 ++++++++++- src/server/registerEnhancedTools.ts | 8 +- src/server/registerOfficialTools.ts | 4 +- src/server/registerSessionTools.ts | 31 ++++--- src/server/toolResult.ts | 31 ++++++- tests/contractSnapshot.test.ts | 40 ++++----- tests/enhancedTools.test.ts | 76 ++++++++++++++--- tests/integration/serverIntegration.test.ts | 60 ++++++++++--- tests/officialTools.test.ts | 17 +++- tests/runtime.test.ts | 6 +- tests/sessionMcp.test.ts | 2 +- tests/toolContracts.test.ts | 8 +- 16 files changed, 509 insertions(+), 100 deletions(-) diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py index 0faf5fa04..932737921 100644 --- a/bridge/hopper_bridge.py +++ b/bridge/hopper_bridge.py @@ -102,7 +102,28 @@ 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 _assembly(procedure, limit=None): """Render bounded assembly while guarding against malformed instruction cycles.""" lines = [] segment = procedure.getSegment() @@ -118,6 +139,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 +148,57 @@ 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": []}) + 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} + def unavailable(reason): + return {"available": False, "reason": reason} + 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 +217,9 @@ def _dispatch(method, params): document = _document(params.get("document")) + if method == "analyze_function": + return _analyze_function(document, params) + if method == "current_address": return _hex(document.getCurrentAddress()) if method == "current_procedure": @@ -178,18 +255,24 @@ def _dispatch(method, params): result.append({"name": segment.getName(), "start": _hex(start), "end": _hex(start + segment.getLength()), "writable": False, "executable": False, "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) diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 7e2d4b6c0..a1d9e61ff 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -4,9 +4,8 @@ import { enhancedInputSchemas } from "../contracts/enhancedInputs.js"; import { HopperProtocolError, type HopperError } from "../domain/errors.js"; import { parseDocuments, + parseAddressedPage, parseListCount, - parseNames, - parseProcedures, parseRelatedAddresses, parseSegments, } from "../domain/hopperValues.js"; @@ -70,31 +69,35 @@ 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); + } } } 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 +190,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,39 +214,68 @@ 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 #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); + } + offset = page.value.nextOffset; + } + } + #call( name: string, arguments_: Readonly>, @@ -278,3 +308,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)); diff --git a/src/contracts/enhancedInputs.ts b/src/contracts/enhancedInputs.ts index 4a60a1844..bfac29843 100644 --- a/src/contracts/enhancedInputs.ts +++ b/src/contracts/enhancedInputs.ts @@ -18,7 +18,17 @@ 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), + }), } as const; export type EnhancedToolName = keyof typeof enhancedInputSchemas; @@ -33,4 +43,5 @@ export const enhancedToolNameSchema = z.enum([ "analyze_swift_types", "find_xrefs_to_name", "binary_overview", + "analyze_function", ]); diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index a606eb419..471299745 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -11,6 +11,33 @@ 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), +}; +const jsonScalar = z.union([z.string(), z.number(), z.boolean(), z.null()]); +const jsonOutput = z.object({ + result: z.union([ + jsonScalar, + z.array(z.unknown()), + z.record(z.string(), z.unknown()), + ]), +}); +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(), +}); + +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,25 +48,67 @@ 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: ["list_procedures", "list_names", "list_strings"].includes(name) + ? pageOutput + : jsonOutput, + 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: jsonOutput, + 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: jsonOutput, + annotations: annotations(name, "session"), +}); /** Bridge operations exposed without additional application composition. */ export const OFFICIAL_TOOL_CONTRACTS = [ @@ -79,14 +148,18 @@ export const OFFICIAL_TOOL_CONTRACTS = [ official( "list_names", "List names", - z.object({ document, address: optionalAddress }), + z.object({ document, address: optionalAddress, ...pagination }), + ), + official( + "list_procedures", + "List procedures", + z.object({ document, ...pagination }), ), - 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 }), + z.object({ document, address: optionalAddress, ...pagination }), ), official( "next_address", @@ -219,9 +292,14 @@ export const ENHANCED_TOOL_CONTRACTS = [ ), enhanced( "binary_overview", - "Summarize the loaded binary", + "First call after opening an app: summarize the loaded binary", enhancedInputSchemas.binary_overview, ), + enhanced( + "analyze_function", + "Preferred bounded function analysis; combines metadata, pseudocode, assembly, references, calls, strings, names, and blocks", + enhancedInputSchemas.analyze_function, + ), ] as const satisfies readonly ToolContract[]; /** Target lifecycle tools available only on the long-lived MCP adapter. */ @@ -237,7 +315,7 @@ export const SESSION_TOOL_CONTRACTS = [ /** * 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 43 tools unless a deliberate contract change updates * snapshots, package verification, and real-Hopper verification together. */ export const TOOL_CONTRACTS = [ diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index adc28b017..358619a40 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -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 => { + 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 => { + 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 => { 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 => { 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); diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts index ab4263950..c19d649f5 100644 --- a/src/server/registerEnhancedTools.ts +++ b/src/server/registerEnhancedTools.ts @@ -32,12 +32,18 @@ const registerEnhancedTool = ( const name = enhancedToolNameSchema.parse(contract.name); server.registerTool( name, - { description: contract.description, inputSchema: contract.inputSchema }, + { + description: contract.description, + inputSchema: contract.inputSchema, + outputSchema: contract.outputSchema, + annotations: contract.annotations, + }, async (input, context) => toCallToolResult( await logToolExecution(logger, name, () => services.execute(name, input, context.mcpReq.signal), ), + contract, ), ); }; diff --git a/src/server/registerOfficialTools.ts b/src/server/registerOfficialTools.ts index 42b011ebe..127a9fb71 100644 --- a/src/server/registerOfficialTools.ts +++ b/src/server/registerOfficialTools.ts @@ -32,6 +32,8 @@ const registerOfficialTool = ( { description: contract.description, inputSchema: contract.inputSchema, + outputSchema: contract.outputSchema, + annotations: contract.annotations, }, async (input, context) => { const arguments_ = projectOfficialArguments(contract, input); @@ -40,7 +42,7 @@ const registerOfficialTool = ( signal: context.mcpReq.signal, }), ); - return toCallToolResult(result); + return toCallToolResult(result, contract); }, ); }; diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index 50c361906..b0f19d4a4 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -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,19 @@ 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], + }, }, - }) - : toCallToolResult(opened); + openContract, + ) + : toCallToolResult(opened, openContract); }, ); server.registerTool( @@ -43,12 +48,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 +64,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), ); }; diff --git a/src/server/toolResult.ts b/src/server/toolResult.ts index 331f61c41..9316ad812 100644 --- a/src/server/toolResult.ts +++ b/src/server/toolResult.ts @@ -3,6 +3,7 @@ import type { CallToolResult } from "@modelcontextprotocol/server"; import type { HopperError } from "../domain/errors.js"; import type { Result } from "../domain/result.js"; import type { JsonValue } from "../hopper/protocol.js"; +import type { ToolContract } from "../contracts/toolContracts.js"; /** * Translate an application result into MCP text content. @@ -11,9 +12,10 @@ import type { JsonValue } from "../hopper/protocol.js"; */ export const toCallToolResult = ( result: Result, + contract: ToolContract, ): CallToolResult => result.ok - ? { content: [{ type: "text", text: formatValue(result.value) }] } + ? successResult(result.value, contract) : { content: [ { @@ -24,6 +26,33 @@ export const toCallToolResult = ( isError: true, }; +const successResult = ( + value: JsonValue, + contract: ToolContract, +): CallToolResult => { + const candidate = ["list_procedures", "list_names", "list_strings"].includes( + contract.name, + ) + ? value + : { result: value }; + const parsed = contract.outputSchema.safeParse(candidate); + if (!parsed.success) { + return { + content: [ + { + type: "text", + text: "HopperProtocolError: Hopper returned output that 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; diff --git a/tests/contractSnapshot.test.ts b/tests/contractSnapshot.test.ts index 59cdbe570..9be89c70f 100644 --- a/tests/contractSnapshot.test.ts +++ b/tests/contractSnapshot.test.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; import { z } from "zod"; @@ -8,27 +7,28 @@ import { SESSION_TOOL_CONTRACTS, } from "../src/contracts/toolContracts.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(40); + 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", () => { diff --git a/tests/enhancedTools.test.ts b/tests/enhancedTools.test.ts index 560f5d59b..4a65c500f 100644 --- a/tests/enhancedTools.test.ts +++ b/tests/enhancedTools.test.ts @@ -17,18 +17,29 @@ const PROCEDURES = { "0x6": "prefix_TtOther", }; +const page = (values: Readonly>) => ({ + 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 = (): HopperToolPort => ({ callTool: (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; @@ -63,7 +74,7 @@ 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" }))); default: return Promise.resolve(ok(null)); } @@ -90,6 +101,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 +124,10 @@ const jsonResult = (result: CallToolResult): JsonValue => { }; describe("enhanced MCP tools", () => { - it("lists the complete 31 plus 8 surface", async () => { + it("lists the complete 31 plus 9 surface", async () => { const client = await connect(); const listed = await client.listTools(); - expect(listed.tools).toHaveLength(39); + expect(listed.tools).toHaveLength(40); expect( listed.tools .map(({ name }) => name) @@ -114,7 +138,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 nine tools through production registration", async () => { const client = await connect(); const calls = [ ["swift_classes", { pattern: "Fixture" }], @@ -125,6 +149,7 @@ describe("enhanced MCP tools", () => { ["analyze_swift_types", {}], ["find_xrefs_to_name", { name: "entry" }], ["binary_overview", {}], + ["analyze_function", { procedure: "0x1" }], ] as const; const results = []; for (const [name, arguments_] of calls) { @@ -152,6 +177,7 @@ describe("enhanced MCP tools", () => { procedure_count: 6, string_count: 1, }); + expect(results[8]).toBeNull(); }); it("bounds batch concurrency at the parsed maximum of 20", async () => { @@ -181,6 +207,36 @@ describe("enhanced MCP tools", () => { expect(maximum).toBe(20); }); + it("follows procedure pagination for whole-binary workflows", async () => { + const offsets: number[] = []; + const client = await connect({ + callTool: (_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("returns a typed tool error for malformed Hopper boundary values", async () => { const client = await connect({ callTool: () => Promise.resolve(ok(["not", "a", "procedure", "map"])), diff --git a/tests/integration/serverIntegration.test.ts b/tests/integration/serverIntegration.test.ts index 9d3bd7c66..93af14644 100644 --- a/tests/integration/serverIntegration.test.ts +++ b/tests/integration/serverIntegration.test.ts @@ -39,6 +39,15 @@ const text = (result: CallToolResult): string => { return content.text; }; +const structured = (result: CallToolResult): Record => { + 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({ @@ -46,7 +55,17 @@ describe("full MCP integration with multi-tool sequences", () => { 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,23 +84,31 @@ 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({ + 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)).toEqual({ + result: "pseudo for 0x1000", + }); const xrefResult = await client.callTool({ name: "xrefs", arguments: {}, }); - expect(JSON.parse(text(xrefResult))).toEqual(["0x1000"]); + expect(structured(xrefResult)).toEqual({ result: ["0x1000"] }); }); - it("preserves the complete 42-tool inventory with a session", async () => { + it("preserves the complete 43-tool inventory with a session", async () => { const session = new BinarySession( (_path) => ({ @@ -104,7 +131,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(43); const names = listed.tools.map((t) => t.name); expect(names).toContain("open_binary"); expect(names).toContain("close_binary"); @@ -113,12 +140,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 40-tool inventory without a session", async () => { const client = await connect({ callTool: () => Promise.resolve(ok(null)), }); const listed = await client.listTools(); - expect(listed.tools).toHaveLength(39); + expect(listed.tools).toHaveLength(40); const names = listed.tools.map((t) => t.name); expect(names).toContain("binary_overview"); expect(names).toContain("batch_decompile"); @@ -145,7 +172,20 @@ describe("full MCP integration with multi-tool sequences", () => { const client = await connect({ callTool: (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, + } + : { handled: name }, + ), + ); }, }); diff --git a/tests/officialTools.test.ts b/tests/officialTools.test.ts index 9afe4f249..3d4a4dfbb 100644 --- a/tests/officialTools.test.ts +++ b/tests/officialTools.test.ts @@ -68,6 +68,15 @@ const connect = async (hopper: HopperToolPort) => { return client; }; +const page: JsonValue = { + items: [], + offset: 0, + limit: 100, + total: 0, + next_offset: null, + has_more: false, +}; + describe("official Hopper proxy tools", () => { it("lists exactly the 31 official contracts", async () => { const client = await connect({ @@ -90,7 +99,13 @@ describe("official Hopper proxy tools", () => { const client = await connect({ callTool: (name, arguments_) => { invocations.push({ name, arguments_ }); - return Promise.resolve(ok({ name, arguments: arguments_ })); + return Promise.resolve( + ok( + ["list_procedures", "list_names", "list_strings"].includes(name) + ? page + : { name, arguments: arguments_ }, + ), + ); }, }); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index 342b30636..b90c8a531 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -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 43 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(43); 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(43); } finally { await client.close(); await transport.close(); diff --git a/tests/sessionMcp.test.ts b/tests/sessionMcp.test.ts index d84413a7c..0c2ac7faa 100644 --- a/tests/sessionMcp.test.ts +++ b/tests/sessionMcp.test.ts @@ -44,7 +44,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(43); expect( (await mcp.callTool({ name: "open_binary", arguments: { path: first } })) .isError, diff --git a/tests/toolContracts.test.ts b/tests/toolContracts.test.ts index d7bc2448b..c9a2ef6c4 100644 --- a/tests/toolContracts.test.ts +++ b/tests/toolContracts.test.ts @@ -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", () => { + it("publishes 40 analysis contracts and three session tools", () => { expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(31); - expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(8); + expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(9); 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(43); + expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(43); }); it("retains documented enhanced-tool limits at the input boundary", () => { From 1f235cb52f61c08b65ecb674cac649c34005bc66 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:00:03 +0800 Subject: [PATCH 2/5] docs: document the 43-tool workflow --- README.md | 16 ++++++++-------- README_ar.md | 8 ++++---- README_ja.md | 16 ++++++++-------- README_ko.md | 16 ++++++++-------- README_zh.md | 16 ++++++++-------- scripts/rea.mjs | 2 +- scripts/verify-package.mjs | 6 +++--- scripts/verify-real-hopper.mjs | 2 +- tests/readmeConsistency.test.ts | 4 ++-- 9 files changed, 43 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index eb3fc7cff..35f2d58d4 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ [![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea) [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml) -[![42 MCP tools](https://img.shields.io/badge/MCP_tools-42-5c4ee5?style=flat-square)](#42-tools-for-deeper-investigation) +[![43 MCP tools](https://img.shields.io/badge/MCP_tools-43-5c4ee5?style=flat-square)](#43-tools-for-deeper-investigation) [![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](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) · [43 tools](#43-tools-for-deeper-investigation) · [How it works](#how-it-works) · [FAQ](#faq)
@@ -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 +## 43 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 | 31 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations | +| Composed analysis | 9 | `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 diff --git a/README_ar.md b/README_ar.md index 8f416a082..b1d4e1e0c 100644 --- a/README_ar.md +++ b/README_ar.md @@ -10,11 +10,11 @@ [![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea) [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml) -[![42 MCP tools](https://img.shields.io/badge/MCP_tools-42-5c4ee5?style=flat-square)](#منصة-من-42-أداة) +[![43 MCP tools](https://img.shields.io/badge/MCP_tools-43-5c4ee5?style=flat-square)](#منصة-من-43-أداة) [![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) -[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [42 أداة](#منصة-من-42-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة) +[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [43 أداة](#منصة-من-43-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
@@ -145,12 +145,12 @@ npx -y @morluto/rea doctor افحص التحقق من الإدخال، والتشفير، والصلاحيات، والتخزين، ومسارات الأخطاء من دون رفع الملف التنفيذي إلى خدمة بعيدة. -## منصة من 42 أداة +## منصة من 43 أداة | عائلة الأدوات | العدد | أمثلة | | --------------------- | ----: | ----------------------------------------------------------------------------------- | | فحص الملفات التنفيذية | 31 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات | -| التحليل المركب | 8 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع | +| التحليل المركب | 9 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع | | جلسة الملف التنفيذي | 3 | `open_binary` و`binary_session` و`close_binary` | ## استخدام REA مع وكلاء برمجة آخرين diff --git a/README_ja.md b/README_ja.md index 5e25816b1..1a16531a2 100644 --- a/README_ja.md +++ b/README_ja.md @@ -10,11 +10,11 @@ [![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea) [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml) -[![42 MCP tools](https://img.shields.io/badge/MCP_tools-42-5c4ee5?style=flat-square)](#42-ツールのワークベンチ) +[![43 MCP tools](https://img.shields.io/badge/MCP_tools-43-5c4ee5?style=flat-square)](#43-ツールのワークベンチ) [![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) -[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [42 ツール](#42-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq) +[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [43 ツール](#43-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
@@ -135,13 +135,13 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ - Swift / Objective-C のメタデータを解析する。 - Hopper に名前、コメント、ブックマークを残し、人間とエージェントの調査を共有する。 -## 42 ツールのワークベンチ +## 43 ツールのワークベンチ -| ツール群 | 数 | 例 | -| ------------------ | --: | ----------------------------------------------------------------------------------------------- | -| バイナリ調査 | 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` | +| ツール群 | 数 | 例 | +| ------------------ | --: | ------------------------------------------------------------------------------------------------------------------- | +| バイナリ調査 | 31 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 | +| 合成解析 | 9 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 | +| バイナリセッション | 3 | `open_binary`, `binary_session`, `close_binary` | ## 他のコーディングエージェントで使う diff --git a/README_ko.md b/README_ko.md index 9d4273475..c542d2a2e 100644 --- a/README_ko.md +++ b/README_ko.md @@ -10,11 +10,11 @@ [![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea) [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml) -[![42 MCP tools](https://img.shields.io/badge/MCP_tools-42-5c4ee5?style=flat-square)](#42개-도구로-구성된-워크벤치) +[![43 MCP tools](https://img.shields.io/badge/MCP_tools-43-5c4ee5?style=flat-square)](#43개-도구로-구성된-워크벤치) [![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) -[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [42개 도구](#42개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq) +[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [43개 도구](#43개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
@@ -135,13 +135,13 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이 - Swift 및 Objective-C 메타데이터를 분석합니다. - Hopper에 이름, 주석, 북마크를 남겨 사람과 에이전트의 분석을 연결합니다. -## 42개 도구로 구성된 워크벤치 +## 43개 도구로 구성된 워크벤치 -| 도구 그룹 | 수 | 예시 | -| ------------- | --: | ------------------------------------------------------------------------------------------------ | -| 바이너리 검사 | 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` | +| 도구 그룹 | 수 | 예시 | +| ------------- | --: | -------------------------------------------------------------------------------------------------------------------- | +| 바이너리 검사 | 31 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 | +| 합성 분석 | 9 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 | +| 바이너리 세션 | 3 | `open_binary`, `binary_session`, `close_binary` | ## 다른 코딩 에이전트에서 사용하기 diff --git a/README_zh.md b/README_zh.md index d0f3dc6a8..ac9526240 100644 --- a/README_zh.md +++ b/README_zh.md @@ -10,11 +10,11 @@ [![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea) [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml) -[![42 MCP tools](https://img.shields.io/badge/MCP_tools-42-5c4ee5?style=flat-square)](#42-个工具组成的工作台) +[![43 MCP tools](https://img.shields.io/badge/MCP_tools-43-5c4ee5?style=flat-square)](#43-个工具组成的工作台) [![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) -[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [42 个工具](#42-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题) +[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [43 个工具](#43-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
@@ -135,13 +135,13 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常 - 分析 Swift 和 Objective-C 元数据。 - 在 Hopper 中留下名称、注释与书签,使人与智能体的分析互相增强。 -## 42 个工具组成的工作台 +## 43 个工具组成的工作台 -| 工具类别 | 数量 | 示例 | -| ---------- | ---: | ------------------------------------------------------------------------------------------------ | -| 二进制检查 | 31 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 | -| 组合分析 | 8 | `binary_overview`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 | -| 二进制会话 | 3 | `open_binary`, `binary_session`, `close_binary` | +| 工具类别 | 数量 | 示例 | +| ---------- | ---: | -------------------------------------------------------------------------------------------------------------------- | +| 二进制检查 | 31 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 | +| 组合分析 | 9 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 | +| 二进制会话 | 3 | `open_binary`, `binary_session`, `close_binary` | ## 与其他编程智能体一起使用 diff --git a/scripts/rea.mjs b/scripts/rea.mjs index e7498d5d2..27044697f 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -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 43-tool stdio server. const args = process.argv.slice(2); const isMcpMode = args[0] === "--mcp" || (args.length === 1 && args[0] === "mcp"); diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index c873ff6ac..a941f5614 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -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 !== 43) + throw new Error("packaged MCP did not expose 43 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: 43, targetFree: true })}\n`, ); } finally { if (tarball) await rm(join(root, tarball), { force: true }); diff --git a/scripts/verify-real-hopper.mjs b/scripts/verify-real-hopper.mjs index dc14b8227..d2d9053f3 100644 --- a/scripts/verify-real-hopper.mjs +++ b/scripts/verify-real-hopper.mjs @@ -61,7 +61,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 43 tools"); } const options = { timeout }; diff --git a/tests/readmeConsistency.test.ts b/tests/readmeConsistency.test.ts index 39b59076c..e4485fddc 100644 --- a/tests/readmeConsistency.test.ts +++ b/tests/readmeConsistency.test.ts @@ -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(/\b43\b/u); expect(content).toMatch(/\b31\b/u); - expect(content).toMatch(/\b8\b/u); + expect(content).toMatch(/\b9\b/u); expect(content).toMatch(/\b3\b/u); }, ); From 4f18e26ee58f616de788e07639f5b2f91d37ef11 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:32:13 +0800 Subject: [PATCH 3/5] feat: evolve REA analysis platform --- README.md | 10 +- README_ar.md | 10 +- README_ja.md | 10 +- README_ko.md | 10 +- README_zh.md | 10 +- bridge/hopper_bridge.py | 119 +++++++- scripts/rea.mjs | 2 +- scripts/verify-package.mjs | 6 +- scripts/verify-real-hopper.mjs | 173 +++++++++++- src/application/AnalysisProvider.ts | 37 +++ src/application/BinarySession.ts | 68 +++-- src/application/DirectAnalysis.ts | 11 +- src/application/EnhancedTools.ts | 184 ++++++++++++- src/application/HopperToolPort.ts | 17 -- src/application/runtime.ts | 25 +- src/contracts/enhancedInputs.ts | 7 + src/contracts/toolContracts.ts | 173 +++++++----- src/contracts/toolOutputSchemas.ts | 287 ++++++++++++++++++++ src/domain/binaryTarget.ts | 24 +- src/domain/errors.ts | 18 +- src/domain/evidence.ts | 62 +++++ src/domain/hopperValues.ts | 2 +- src/domain/jsonValue.ts | 6 + src/domain/symbolAnalysis.ts | 2 +- src/hopper/HopperProvider.ts | 67 +++++ src/hopper/protocol.ts | 4 +- src/server/createServer.ts | 18 +- src/server/registerEnhancedTools.ts | 51 +++- src/server/registerOfficialTools.ts | 43 ++- src/server/registerSessionTools.ts | 6 +- src/server/toolResult.ts | 14 +- tests/binarySession.test.ts | 61 +++-- tests/binaryTarget.test.ts | 5 +- tests/bridgeTruthfulness.test.ts | 32 +++ tests/contractSnapshot.test.ts | 33 ++- tests/createServer.test.ts | 8 +- tests/enhancedTools.test.ts | 254 ++++++++++++++++- tests/evidence.test.ts | 56 ++++ tests/fixtures/fakeHopper.mjs | 28 ++ tests/hopperClient.test.ts | 2 +- tests/integration/serverIntegration.test.ts | 64 +++-- tests/officialTools.test.ts | 78 +++++- tests/providerArchitecture.test.ts | 40 +++ tests/readmeConsistency.test.ts | 6 +- tests/runtime.test.ts | 6 +- tests/sessionMcp.test.ts | 16 +- tests/toolContracts.test.ts | 10 +- 47 files changed, 1841 insertions(+), 334 deletions(-) create mode 100644 src/application/AnalysisProvider.ts delete mode 100644 src/application/HopperToolPort.ts create mode 100644 src/contracts/toolOutputSchemas.ts create mode 100644 src/domain/evidence.ts create mode 100644 src/domain/jsonValue.ts create mode 100644 src/hopper/HopperProvider.ts create mode 100644 tests/bridgeTruthfulness.test.ts create mode 100644 tests/evidence.test.ts create mode 100644 tests/providerArchitecture.test.ts diff --git a/README.md b/README.md index 35f2d58d4..55d702634 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ [![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea) [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml) -[![43 MCP tools](https://img.shields.io/badge/MCP_tools-43-5c4ee5?style=flat-square)](#43-tools-for-deeper-investigation) +[![46 MCP tools](https://img.shields.io/badge/MCP_tools-46-5c4ee5?style=flat-square)](#46-tools-for-deeper-investigation) [![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) -[Quick start](#quick-start) · [See the workflow](#one-prompt-a-full-investigation) · [From app to feature](#from-app-to-feature) · [43 tools](#43-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)
@@ -147,12 +147,12 @@ 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. -## 43 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 | 9 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift and ObjC discovery | +| 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 diff --git a/README_ar.md b/README_ar.md index b1d4e1e0c..5d756a756 100644 --- a/README_ar.md +++ b/README_ar.md @@ -10,11 +10,11 @@ [![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea) [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml) -[![43 MCP tools](https://img.shields.io/badge/MCP_tools-43-5c4ee5?style=flat-square)](#منصة-من-43-أداة) +[![46 MCP tools](https://img.shields.io/badge/MCP_tools-46-5c4ee5?style=flat-square)](#منصة-من-46-أداة) [![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) -[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [43 أداة](#منصة-من-43-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة) +[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [46 أداة](#منصة-من-46-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
@@ -145,12 +145,12 @@ npx -y @morluto/rea doctor افحص التحقق من الإدخال، والتشفير، والصلاحيات، والتخزين، ومسارات الأخطاء من دون رفع الملف التنفيذي إلى خدمة بعيدة. -## منصة من 43 أداة +## منصة من 46 أداة | عائلة الأدوات | العدد | أمثلة | | --------------------- | ----: | ----------------------------------------------------------------------------------- | -| فحص الملفات التنفيذية | 31 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات | -| التحليل المركب | 9 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع | +| فحص الملفات التنفيذية | 33 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات | +| التحليل المركب | 10 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع | | جلسة الملف التنفيذي | 3 | `open_binary` و`binary_session` و`close_binary` | ## استخدام REA مع وكلاء برمجة آخرين diff --git a/README_ja.md b/README_ja.md index 1a16531a2..8dc17f379 100644 --- a/README_ja.md +++ b/README_ja.md @@ -10,11 +10,11 @@ [![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea) [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml) -[![43 MCP tools](https://img.shields.io/badge/MCP_tools-43-5c4ee5?style=flat-square)](#43-ツールのワークベンチ) +[![46 MCP tools](https://img.shields.io/badge/MCP_tools-46-5c4ee5?style=flat-square)](#46-ツールのワークベンチ) [![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) -[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [43 ツール](#43-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq) +[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [46 ツール](#46-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
@@ -135,12 +135,12 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ - Swift / Objective-C のメタデータを解析する。 - Hopper に名前、コメント、ブックマークを残し、人間とエージェントの調査を共有する。 -## 43 ツールのワークベンチ +## 46 ツールのワークベンチ | ツール群 | 数 | 例 | | ------------------ | --: | ------------------------------------------------------------------------------------------------------------------- | -| バイナリ調査 | 31 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 | -| 合成解析 | 9 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 | +| バイナリ調査 | 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` | ## 他のコーディングエージェントで使う diff --git a/README_ko.md b/README_ko.md index c542d2a2e..4eaeae68a 100644 --- a/README_ko.md +++ b/README_ko.md @@ -10,11 +10,11 @@ [![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea) [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml) -[![43 MCP tools](https://img.shields.io/badge/MCP_tools-43-5c4ee5?style=flat-square)](#43개-도구로-구성된-워크벤치) +[![46 MCP tools](https://img.shields.io/badge/MCP_tools-46-5c4ee5?style=flat-square)](#46개-도구로-구성된-워크벤치) [![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) -[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [43개 도구](#43개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq) +[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [46개 도구](#46개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
@@ -135,12 +135,12 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이 - Swift 및 Objective-C 메타데이터를 분석합니다. - Hopper에 이름, 주석, 북마크를 남겨 사람과 에이전트의 분석을 연결합니다. -## 43개 도구로 구성된 워크벤치 +## 46개 도구로 구성된 워크벤치 | 도구 그룹 | 수 | 예시 | | ------------- | --: | -------------------------------------------------------------------------------------------------------------------- | -| 바이너리 검사 | 31 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 | -| 합성 분석 | 9 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 | +| 바이너리 검사 | 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` | ## 다른 코딩 에이전트에서 사용하기 diff --git a/README_zh.md b/README_zh.md index ac9526240..c6af61880 100644 --- a/README_zh.md +++ b/README_zh.md @@ -10,11 +10,11 @@ [![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea) [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml) -[![43 MCP tools](https://img.shields.io/badge/MCP_tools-43-5c4ee5?style=flat-square)](#43-个工具组成的工作台) +[![46 MCP tools](https://img.shields.io/badge/MCP_tools-46-5c4ee5?style=flat-square)](#46-个工具组成的工作台) [![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) -[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [43 个工具](#43-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题) +[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [46 个工具](#46-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
@@ -135,12 +135,12 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常 - 分析 Swift 和 Objective-C 元数据。 - 在 Hopper 中留下名称、注释与书签,使人与智能体的分析互相增强。 -## 43 个工具组成的工作台 +## 46 个工具组成的工作台 | 工具类别 | 数量 | 示例 | | ---------- | ---: | -------------------------------------------------------------------------------------------------------------------- | -| 二进制检查 | 31 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 | -| 组合分析 | 9 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 | +| 二进制检查 | 33 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 | +| 组合分析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 | | 二进制会话 | 3 | `open_binary`, `binary_session`, `close_binary` | ## 与其他编程智能体一起使用 diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py index 932737921..dbb28def2 100644 --- a/bridge/hopper_bridge.py +++ b/bridge/hopper_bridge.py @@ -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(): @@ -123,6 +203,11 @@ def _page(values, offset, limit): } +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 = [] @@ -163,7 +248,13 @@ def _analyze_function(document, params): blocks = [] all_blocks = list(procedure.basicBlockIterator()) for block in all_blocks[:limit]: - blocks.append({"start": _hex(block.getStartingAddress()), "end": _hex(block.getEndingAddress()), "successors": []}) + 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 @@ -172,8 +263,6 @@ def _analyze_function(document, params): 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} - def unavailable(reason): - return {"available": False, "reason": reason} entry = procedure.getEntryPoint() segment = procedure.getSegment() comments = [] @@ -193,8 +282,8 @@ def _analyze_function(document, params): "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"), + "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), } @@ -219,6 +308,14 @@ def _dispatch(method, params): 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()) @@ -252,7 +349,17 @@ 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 _page(_procedure_map(document), params.get("offset", 0), params.get("limit", 100)) diff --git a/scripts/rea.mjs b/scripts/rea.mjs index 27044697f..52cf67333 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -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 43-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"); diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index a941f5614..b20e11604 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -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 !== 43) - throw new Error("packaged MCP did not expose 43 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: 43, 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 }); diff --git a/scripts/verify-real-hopper.mjs b/scripts/verify-real-hopper.mjs index d2d9053f3..e7cbb5678 100644 --- a/scripts/verify-real-hopper.mjs +++ b/scripts/verify-real-hopper.mjs @@ -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 43 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, diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts new file mode 100644 index 000000000..2db6bfec1 --- /dev/null +++ b/src/application/AnalysisProvider.ts @@ -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"; + +export interface ExecutionOptions { + readonly signal?: AbortSignal; +} + +/** Provider-neutral application capability for one analysis operation. */ +export interface AnalysisOperationPort { + execute( + operation: string, + parameters: Readonly>, + options?: ExecutionOptions, + ): Promise>; +} + +/** Provider session bound to exactly one parsed artifact. */ +export interface AnalysisClient extends AnalysisOperationPort { + close(): Promise; +} + +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; +} diff --git a/src/application/BinarySession.ts b/src/application/BinarySession.ts index 37601f4a7..53a1688b6 100644 --- a/src/application/BinarySession.ts +++ b/src/application/BinarySession.ts @@ -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; +/** 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>; + close(): Promise>; + 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 = Promise.resolve(); readonly #calls = new Set>(); - 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> { + ): Promise> { 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> { + close(): Promise> { 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>, options?: { readonly signal?: AbortSignal }, - ): Promise> { + ): Promise> { 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 { 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(); } diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 6f06e7932..e280b0d4b 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -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(); diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index a1d9e61ff..81201cbe2 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -1,7 +1,7 @@ -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, @@ -16,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>; +type EnhancedResult = Promise>; +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. @@ -26,7 +32,7 @@ type EnhancedResult = Promise>; * 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( @@ -81,6 +87,12 @@ export class EnhancedTools { ? 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); + } } } @@ -253,6 +265,155 @@ export class EnhancedTools { }); } + 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(); + 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(); + 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); } @@ -272,6 +433,13 @@ export class EnhancedTools { 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; } } @@ -280,8 +448,8 @@ export class EnhancedTools { name: string, arguments_: Readonly>, signal?: AbortSignal, - ): Promise> { - return this.hopper.callTool( + ): Promise> { + return this.analysis.execute( name, arguments_, signal === undefined ? {} : { signal }, @@ -292,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, }), ), diff --git a/src/application/HopperToolPort.ts b/src/application/HopperToolPort.ts deleted file mode 100644 index be9ebac12..000000000 --- a/src/application/HopperToolPort.ts +++ /dev/null @@ -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>, - options?: { readonly signal?: AbortSignal }, - ): Promise>; -} diff --git a/src/application/runtime.ts b/src/application/runtime.ts index bed304462..c49580b17 100644 --- a/src/application/runtime.ts +++ b/src/application/runtime.ts @@ -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)); }; diff --git a/src/contracts/enhancedInputs.ts b/src/contracts/enhancedInputs.ts index bfac29843..8238ae7ea 100644 --- a/src/contracts/enhancedInputs.ts +++ b/src/contracts/enhancedInputs.ts @@ -29,6 +29,12 @@ export const enhancedInputSchemas = { 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; @@ -44,4 +50,5 @@ export const enhancedToolNameSchema = z.enum([ "find_xrefs_to_name", "binary_overview", "analyze_function", + "trace_feature", ]); diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 471299745..51708bc00 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -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"); @@ -15,22 +20,6 @@ const pagination = { offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(500).default(100), }; -const jsonScalar = z.union([z.string(), z.number(), z.boolean(), z.null()]); -const jsonOutput = z.object({ - result: z.union([ - jsonScalar, - z.array(z.unknown()), - z.record(z.string(), z.unknown()), - ]), -}); -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(), -}); export interface ToolAnnotations { readonly readOnlyHint: boolean; @@ -78,9 +67,7 @@ const official = ( description, kind: "official-proxy", inputSchema, - outputSchema: ["list_procedures", "list_names", "list_strings"].includes(name) - ? pageOutput - : jsonOutput, + outputSchema: requireOutputSchema(officialOutputSchemas, name), annotations: annotations(name, "official-proxy"), }); @@ -93,7 +80,7 @@ const enhanced = ( description, kind: "enhanced", inputSchema, - outputSchema: jsonOutput, + outputSchema: requireOutputSchema(enhancedOutputSchemas, name), annotations: annotations(name, "enhanced"), }); @@ -106,149 +93,192 @@ const session = ( description, kind: "session", inputSchema, - outputSchema: jsonOutput, + outputSchema: requireOutputSchema(sessionOutputSchemas, name), annotations: annotations(name, "session"), }); +const requireOutputSchema = ( + schemas: Readonly>, + 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", + "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", - "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 segments", z.object({ document })), + 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_strings", - "List strings", + "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[]; @@ -257,65 +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", - "First call after opening an app: 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 function analysis; combines metadata, pseudocode, assembly, references, calls, strings, names, and blocks", + "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 43 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 = [ diff --git a/src/contracts/toolOutputSchemas.ts b/src/contracts/toolOutputSchemas.ts new file mode 100644 index 000000000..2f97380e6 --- /dev/null +++ b/src/contracts/toolOutputSchemas.ts @@ -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> = { + 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> = { + 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> = { + 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(), + }), + ]), + ), +}; diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 306fd4a3b..749193fd4 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -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; 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 => { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +}; + const resolveAppBundle = async ( path: string, ): Promise> => { diff --git a/src/domain/errors.ts b/src/domain/errors.ts index b7dd9514b..04924a470 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -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, diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts new file mode 100644 index 000000000..6c456bcf8 --- /dev/null +++ b/src/domain/evidence.ts @@ -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; + +export interface EvidenceObservation { + readonly operation: string; + readonly parameters: Readonly>; + 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 ?? [])], + }); diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index 358619a40..5f26c034f 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -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"; diff --git a/src/domain/jsonValue.ts b/src/domain/jsonValue.ts new file mode 100644 index 000000000..5abc86806 --- /dev/null +++ b/src/domain/jsonValue.ts @@ -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; diff --git a/src/domain/symbolAnalysis.ts b/src/domain/symbolAnalysis.ts index c47aebb0b..1b4ba0508 100644 --- a/src/domain/symbolAnalysis.ts +++ b/src/domain/symbolAnalysis.ts @@ -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; diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts new file mode 100644 index 000000000..65208fc7a --- /dev/null +++ b/src/hopper/HopperProvider.ts @@ -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(), + }; + } +} diff --git a/src/hopper/protocol.ts b/src/hopper/protocol.ts index eb1852e2a..4b2ce11e2 100644 --- a/src/hopper/protocol.ts +++ b/src/hopper/protocol.ts @@ -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; +export { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; const responseSchema = z.union([ z.object({ diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 9e4a08f31..cbcbafbe1 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -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; }; diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts index c19d649f5..af1cb133f 100644 --- a/src/server/registerEnhancedTools.ts +++ b/src/server/registerEnhancedTools.ts @@ -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,7 +32,10 @@ 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( @@ -38,12 +46,33 @@ const registerEnhancedTool = ( outputSchema: contract.outputSchema, annotations: contract.annotations, }, - async (input, context) => - toCallToolResult( - await logToolExecution(logger, name, () => - services.execute(name, input, context.mcpReq.signal), - ), + 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 = { ...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, - ), + ); + }, ); }; diff --git a/src/server/registerOfficialTools.ts b/src/server/registerOfficialTools.ts index 127a9fb71..17d28cd83 100644 --- a/src/server/registerOfficialTools.ts +++ b/src/server/registerOfficialTools.ts @@ -1,31 +1,37 @@ 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, @@ -37,12 +43,27 @@ const registerOfficialTool = ( }, 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, contract); }, ); }; diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index b0f19d4a4..611772af9 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -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; @@ -36,6 +36,8 @@ export const registerSessionTools = ( format: opened.value.format, kind: opened.value.kind, loaderArgs: [...opened.value.loaderArgs], + sha256: opened.value.sha256, + architecture: opened.value.architecture ?? null, }, }, openContract, diff --git a/src/server/toolResult.ts b/src/server/toolResult.ts index 9316ad812..8a36f7071 100644 --- a/src/server/toolResult.ts +++ b/src/server/toolResult.ts @@ -1,8 +1,8 @@ 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"; /** @@ -11,7 +11,7 @@ import type { ToolContract } from "../contracts/toolContracts.js"; * output, and other potentially sensitive details stay private. */ export const toCallToolResult = ( - result: Result, + result: Result, contract: ToolContract, ): CallToolResult => result.ok @@ -30,18 +30,14 @@ const successResult = ( value: JsonValue, contract: ToolContract, ): CallToolResult => { - const candidate = ["list_procedures", "list_names", "list_strings"].includes( - contract.name, - ) - ? value - : { result: value }; + const candidate = contract.kind === "session" ? { result: value } : value; const parsed = contract.outputSchema.safeParse(candidate); if (!parsed.success) { return { content: [ { type: "text", - text: "HopperProtocolError: Hopper returned output that does not match the tool contract", + text: "HopperProtocolError: Analysis output does not match the tool contract", }, ], isError: true, diff --git a/tests/binarySession.test.ts b/tests/binarySession.test.ts index e96eb3cf1..c149cfa3b 100644 --- a/tests/binarySession.test.ts +++ b/tests/binarySession.test.ts @@ -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>>, readonly failHealth = false, readonly pendingCall?: Promise>>, ) {} - callTool(name: string) { + execute(name: string) { if (name === "health") return this.failHealth ? Promise.resolve(err(new HopperStartError())) diff --git a/tests/binaryTarget.test.ts b/tests/binaryTarget.test.ts index 9ff7ece05..baaa4a07a 100644 --- a/tests/binaryTarget.test.ts +++ b/tests/binaryTarget.test.ts @@ -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); }); diff --git a/tests/bridgeTruthfulness.test.ts b/tests/bridgeTruthfulness.test.ts new file mode 100644 index 000000000..2e5a6cf37 --- /dev/null +++ b/tests/bridgeTruthfulness.test.ts @@ -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", + ); + }); +}); diff --git a/tests/contractSnapshot.test.ts b/tests/contractSnapshot.test.ts index 9be89c70f..eaadfcfc7 100644 --- a/tests/contractSnapshot.test.ts +++ b/tests/contractSnapshot.test.ts @@ -6,11 +6,16 @@ import { OFFICIAL_TOOL_CONTRACTS, SESSION_TOOL_CONTRACTS, } from "../src/contracts/toolContracts.js"; +import { + enhancedOutputSchemas, + officialOutputSchemas, + sessionOutputSchemas, +} from "../src/contracts/toolOutputSchemas.js"; 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(40); + expect(contracts).toHaveLength(43); for (const contract of contracts) { const inputSchema = z.toJSONSchema(contract.inputSchema, { target: "draft-07", @@ -40,4 +45,30 @@ describe("tool contract surface", () => { { 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); + } + }); }); diff --git a/tests/createServer.test.ts b/tests/createServer.test.ts index 47c3da51f..2aacfc3c3 100644 --- a/tests/createServer.test.ts +++ b/tests/createServer.test.ts @@ -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(); diff --git a/tests/enhancedTools.test.ts b/tests/enhancedTools.test.ts index 4a65c500f..003b3cbf3 100644 --- a/tests/enhancedTools.test.ts +++ b/tests/enhancedTools.test.ts @@ -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 = { @@ -26,8 +26,8 @@ const page = (values: Readonly>) => ({ has_more: false, }); -const fixturePort = (): HopperToolPort => ({ - callTool: (name, arguments_) => { +const fixturePort = (): AnalysisOperationPort => ({ + execute: (name, arguments_) => { switch (name) { case "list_procedures": return Promise.resolve(ok(page(PROCEDURES))); @@ -67,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" }]), @@ -75,12 +87,52 @@ const fixturePort = (): HopperToolPort => ({ return Promise.resolve(ok(["fixture"])); case "list_strings": 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 }> = []; afterEach(async () => { @@ -89,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(); @@ -124,10 +176,10 @@ const jsonResult = (result: CallToolResult): JsonValue => { }; describe("enhanced MCP tools", () => { - it("lists the complete 31 plus 9 surface", async () => { + it("lists the complete 33 plus 10 surface", async () => { const client = await connect(); const listed = await client.listTools(); - expect(listed.tools).toHaveLength(40); + expect(listed.tools).toHaveLength(43); expect( listed.tools .map(({ name }) => name) @@ -138,7 +190,7 @@ describe("enhanced MCP tools", () => { ).toEqual(ENHANCED_TOOL_CONTRACTS.map(({ name }) => name).sort()); }); - it("executes all nine tools through production registration", async () => { + it("executes all ten tools through production registration", async () => { const client = await connect(); const calls = [ ["swift_classes", { pattern: "Fixture" }], @@ -150,6 +202,7 @@ describe("enhanced MCP tools", () => { ["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) { @@ -177,14 +230,26 @@ describe("enhanced MCP tools", () => { procedure_count: 6, string_count: 1, }); - expect(results[8]).toBeNull(); + 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); @@ -193,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: { @@ -210,7 +275,7 @@ describe("enhanced MCP tools", () => { it("follows procedure pagination for whole-binary workflows", async () => { const offsets: number[] = []; const client = await connect({ - callTool: (_name, arguments_) => { + execute: (_name, arguments_) => { const offset = arguments_.offset; offsets.push(typeof offset === "number" ? offset : 0); return Promise.resolve( @@ -237,9 +302,168 @@ describe("enhanced MCP tools", () => { 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", diff --git a/tests/evidence.test.ts b/tests/evidence.test.ts new file mode 100644 index 000000000..336be8baa --- /dev/null +++ b/tests/evidence.test.ts @@ -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.", + ], + }); + }); +}); diff --git a/tests/fixtures/fakeHopper.mjs b/tests/fixtures/fakeHopper.mjs index 64cfa4121..84f63216a 100644 --- a/tests/fixtures/fakeHopper.mjs +++ b/tests/fixtures/fakeHopper.mjs @@ -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( diff --git a/tests/hopperClient.test.ts b/tests/hopperClient.test.ts index a83c061fe..2793b94b2 100644 --- a/tests/hopperClient.test.ts +++ b/tests/hopperClient.test.ts @@ -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); }); diff --git a/tests/integration/serverIntegration.test.ts b/tests/integration/serverIntegration.test.ts index 93af14644..d3bb017b0 100644 --- a/tests/integration/serverIntegration.test.ts +++ b/tests/integration/serverIntegration.test.ts @@ -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", @@ -51,7 +51,7 @@ const structured = (result: CallToolResult): Record => { 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( @@ -85,19 +85,26 @@ describe("full MCP integration with multi-tool sequences", () => { }); expect(listResult.isError).not.toBe(true); expect(structured(listResult)).toMatchObject({ - items: [ - { address: "0x1000", value: "main" }, - { address: "0x2000", value: "helper" }, - ], - total: 2, - has_more: false, + 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(structured(decompileResult)).toEqual({ + expect(structured(decompileResult)).toMatchObject({ + operation: "procedure_pseudo_code", + parameters: { document: null, procedure: "0x1000" }, result: "pseudo for 0x1000", }); @@ -105,19 +112,22 @@ describe("full MCP integration with multi-tool sequences", () => { name: "xrefs", arguments: {}, }); - expect(structured(xrefResult)).toEqual({ result: ["0x1000"] }); + expect(structured(xrefResult)).toMatchObject({ + operation: "xrefs", + result: ["0x1000"], + }); }); - it("preserves the complete 43-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({ @@ -131,7 +141,7 @@ describe("full MCP integration with multi-tool sequences", () => { await client.connect(clientTransport); const listed = await client.listTools(); - expect(listed.tools).toHaveLength(43); + expect(listed.tools).toHaveLength(46); const names = listed.tools.map((t) => t.name); expect(names).toContain("open_binary"); expect(names).toContain("close_binary"); @@ -140,12 +150,12 @@ describe("full MCP integration with multi-tool sequences", () => { expect(names).toContain("batch_decompile"); }); - it("preserves the 40-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(40); + expect(listed.tools).toHaveLength(43); const names = listed.tools.map((t) => t.name); expect(names).toContain("binary_overview"); expect(names).toContain("batch_decompile"); @@ -154,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"))), }); @@ -170,7 +180,7 @@ 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( @@ -183,7 +193,7 @@ describe("full MCP integration with multi-tool sequences", () => { next_offset: null, has_more: false, } - : { handled: name }, + : [], ), ); }, diff --git a/tests/officialTools.test.ts b/tests/officialTools.test.ts index 3d4a4dfbb..c5d137a4d 100644 --- a/tests/officialTools.test.ts +++ b/tests/officialTools.test.ts @@ -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(); @@ -77,10 +79,62 @@ const page: JsonValue = { 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( @@ -97,15 +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( - ["list_procedures", "list_names", "list_strings"].includes(name) - ? page - : { name, arguments: arguments_ }, - ), - ); + return Promise.resolve(ok(outputFor(name))); }, }); @@ -137,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", diff --git a/tests/providerArchitecture.test.ts b/tests/providerArchitecture.test.ts new file mode 100644 index 000000000..11b0468e2 --- /dev/null +++ b/tests/providerArchitecture.test.ts @@ -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 => { + 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(); +}; diff --git a/tests/readmeConsistency.test.ts b/tests/readmeConsistency.test.ts index e4485fddc..deef52771 100644 --- a/tests/readmeConsistency.test.ts +++ b/tests/readmeConsistency.test.ts @@ -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(/\b43\b/u); - expect(content).toMatch(/\b31\b/u); - expect(content).toMatch(/\b9\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); }, ); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index b90c8a531..cab5653e1 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -10,7 +10,7 @@ const fixturePath = fileURLToPath( ); describe("production stdio runtime", () => { - it("starts the built entrypoint, lists 43 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(43); + 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(43); + expect((await client.listTools()).tools).toHaveLength(46); } finally { await client.close(); await transport.close(); diff --git a/tests/sessionMcp.test.ts b/tests/sessionMcp.test.ts index 0c2ac7faa..552b6bb9c 100644 --- a/tests/sessionMcp.test.ts +++ b/tests/sessionMcp.test.ts @@ -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(43); + 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(); diff --git a/tests/toolContracts.test.ts b/tests/toolContracts.test.ts index c9a2ef6c4..5af7630f4 100644 --- a/tests/toolContracts.test.ts +++ b/tests/toolContracts.test.ts @@ -8,16 +8,16 @@ import { } from "../src/contracts/toolContracts.js"; describe("tool contract inventory", () => { - it("publishes 40 analysis contracts and three session tools", () => { - expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(31); - expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(9); + 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(43); - expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(43); + 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", () => { From d648bcd2e41ec0b45bc02a69dee0d7206fc64f8f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:32:29 +0800 Subject: [PATCH 4/5] test: add source-built Hopper conformance fixtures --- package.json | 2 + scripts/build-conformance-fixtures.mjs | 223 ++++++++++++++++++++++++ scripts/lib/conformance-fixtures.d.mts | 6 + scripts/lib/conformance-fixtures.mjs | 30 ++++ scripts/verify-conformance-fixtures.mjs | 68 ++++++++ tests/conformance/README.md | 16 ++ tests/conformance/c/fixture.c | 19 ++ tests/conformance/napi/fixture.c | 17 ++ tests/conformance/objc/fixture.m | 21 +++ tests/conformance/swift/fixture.swift | 11 ++ tests/conformance/versions/v1.c | 9 + tests/conformance/versions/v2.c | 10 ++ tests/conformanceFixtures.test.ts | 33 ++++ 13 files changed, 465 insertions(+) create mode 100644 scripts/build-conformance-fixtures.mjs create mode 100644 scripts/lib/conformance-fixtures.d.mts create mode 100644 scripts/lib/conformance-fixtures.mjs create mode 100644 scripts/verify-conformance-fixtures.mjs create mode 100644 tests/conformance/README.md create mode 100644 tests/conformance/c/fixture.c create mode 100644 tests/conformance/napi/fixture.c create mode 100644 tests/conformance/objc/fixture.m create mode 100644 tests/conformance/swift/fixture.swift create mode 100644 tests/conformance/versions/v1.c create mode 100644 tests/conformance/versions/v2.c create mode 100644 tests/conformanceFixtures.test.ts diff --git a/package.json b/package.json index 466e87615..187b0c8c6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build-conformance-fixtures.mjs b/scripts/build-conformance-fixtures.mjs new file mode 100644 index 000000000..11b4f0889 --- /dev/null +++ b/scripts/build-conformance-fixtures.mjs @@ -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, + }); +} diff --git a/scripts/lib/conformance-fixtures.d.mts b/scripts/lib/conformance-fixtures.d.mts new file mode 100644 index 000000000..949e300ce --- /dev/null +++ b/scripts/lib/conformance-fixtures.d.mts @@ -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; diff --git a/scripts/lib/conformance-fixtures.mjs b/scripts/lib/conformance-fixtures.mjs new file mode 100644 index 000000000..64952dadb --- /dev/null +++ b/scripts/lib/conformance-fixtures.mjs @@ -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 ", + ...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); +}; diff --git a/scripts/verify-conformance-fixtures.mjs b/scripts/verify-conformance-fixtures.mjs new file mode 100644 index 000000000..7cf09addc --- /dev/null +++ b/scripts/verify-conformance-fixtures.mjs @@ -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}`, + ); +} diff --git a/tests/conformance/README.md b/tests/conformance/README.md new file mode 100644 index 000000000..88d64f28a --- /dev/null +++ b/tests/conformance/README.md @@ -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. diff --git a/tests/conformance/c/fixture.c b/tests/conformance/c/fixture.c new file mode 100644 index 000000000..38cfc2df3 --- /dev/null +++ b/tests/conformance/c/fixture.c @@ -0,0 +1,19 @@ +#include + +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; } diff --git a/tests/conformance/napi/fixture.c b/tests/conformance/napi/fixture.c new file mode 100644 index 000000000..c78c9f7f4 --- /dev/null +++ b/tests/conformance/napi/fixture.c @@ -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; +} diff --git a/tests/conformance/objc/fixture.m b/tests/conformance/objc/fixture.m new file mode 100644 index 000000000..bc9f5ea2d --- /dev/null +++ b/tests/conformance/objc/fixture.m @@ -0,0 +1,21 @@ +#import +@protocol REAWidgetDelegate +- (void)widgetDidFinish; +@end +@interface REAWidget : NSObject +@property(nonatomic, weak) id 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; + } +} diff --git a/tests/conformance/swift/fixture.swift b/tests/conformance/swift/fixture.swift new file mode 100644 index 000000000..f7d9e0a9c --- /dev/null +++ b/tests/conformance/swift/fixture.swift @@ -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) diff --git a/tests/conformance/versions/v1.c b/tests/conformance/versions/v1.c new file mode 100644 index 000000000..f9d2162bd --- /dev/null +++ b/tests/conformance/versions/v1.c @@ -0,0 +1,9 @@ +#include +__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; } diff --git a/tests/conformance/versions/v2.c b/tests/conformance/versions/v2.c new file mode 100644 index 000000000..8d5828652 --- /dev/null +++ b/tests/conformance/versions/v2.c @@ -0,0 +1,10 @@ +#include +__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; } diff --git a/tests/conformanceFixtures.test.ts b/tests/conformanceFixtures.test.ts new file mode 100644 index 000000000..6b02b9ec0 --- /dev/null +++ b/tests/conformanceFixtures.test.ts @@ -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")); + }); +}); From 167a824f9131a60d07fca3e99301eecc98d5d5e9 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:35:58 +0800 Subject: [PATCH 5/5] fix: keep execution options internal --- src/application/AnalysisProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index 2db6bfec1..6e00ea2c0 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -3,7 +3,7 @@ import type { AnalysisError } from "../domain/errors.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Result } from "../domain/result.js"; -export interface ExecutionOptions { +interface ExecutionOptions { readonly signal?: AbortSignal; }