diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 09df6ac1da..84e809ccb6 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -142,6 +142,7 @@ public final class ReaGhidraBridge extends HeadlessScript { "set_addresses_names", "inspect_native_load_image", "read_bytes", + "search_bytes", "address_to_file_offset", "ping", "shutdown", @@ -379,6 +380,7 @@ public final class ReaGhidraBridge extends HeadlessScript { return switch (request.method) { case "inspect_native_load_image" -> inspectNativeLoadImage(request.params); case "read_bytes" -> readMemoryBytes(request.params); + case "search_bytes" -> searchMemoryBytes(request.params, descriptor); case "address_to_file_offset" -> addressToFileOffset(request.params); case "address_name" -> addressName(request.params); case "list_documents" -> listDocuments(request.params); @@ -685,6 +687,160 @@ public final class ReaGhidraBridge extends HeadlessScript { return result; } + private JsonObject searchMemoryBytes(JsonObject params, SessionDescriptor descriptor) throws Exception { + // mask, start, end, and cursor are omitted when absent; JSON encoding drops those keys. + Set keys = params.keySet(); + if (!keys.containsAll(Set.of("document", "pattern", "limit")) || + !Set.of("document", "pattern", "mask", "start", "end", "limit", "cursor").containsAll(keys)) + throw new RequestFailure("invalid_request", "Bridge request fields are invalid"); + requireDocument(params); + // Public input validation owns the grammar; validate again at the socket boundary. + String pattern = requireString(params, "pattern").replaceAll("[\\s\\p{Z}\\uFEFF]", ""); + String mask = params.has("mask") ? requireString(params, "mask") : null; + if (mask != null) mask = mask.replaceAll("[\\s\\p{Z}\\uFEFF]", ""); + if (pattern.isEmpty() || pattern.length() % 2 != 0 || + (mask != null && mask.length() != pattern.length())) + throw new RequestFailure("invalid_request", "Mask length must equal a nonempty byte pattern length"); + byte[] bytes = new byte[pattern.length() / 2]; + byte[] masks = new byte[bytes.length]; + boolean constrained = false; + for (int index = 0; index < bytes.length; index++) { + monitor.checkCancelled(); + String pair = pattern.substring(index * 2, index * 2 + 2); + if ((!pair.equals("??") && !isHexadecimal(pair, 0, 2)) || + (mask != null && !isHexadecimal(mask, index * 2, index * 2 + 2))) + throw new RequestFailure("invalid_request", "Use hexadecimal byte pairs or ?? wildcards"); + int selected = mask == null ? 255 : Integer.parseInt(mask.substring(index * 2, index * 2 + 2), 16); + bytes[index] = (byte) (pair.equals("??") ? 0 : Integer.parseInt(pair, 16)); + masks[index] = (byte) (pair.equals("??") ? 0 : selected); + constrained |= masks[index] != 0; + } + if (!constrained) throw new RequestFailure("invalid_request", "Pattern must constrain at least one bit"); + int limit = requireBoundedInteger(params, "limit", 1, 1000); + Address start = params.has("start") ? requireAddress(params, "start") : null; + Address end = params.has("end") ? requireAddress(params, "end") : null; + if (start != null && end != null && + (!start.getAddressSpace().equals(end.getAddressSpace()) || start.compareTo(end) > 0)) + throw new RequestFailure("invalid_request", "Search bounds must be ordered in one address space"); + JsonArray identity = new JsonArray(); + for (String value : new String[] { descriptor.targetSha256, descriptor.profileDigest, + bytesHex(bytes), bytesHex(masks), start == null ? "" : canonicalAddress(start), + end == null ? "" : canonicalAddress(end), optionalString(params, "document") }) identity.add(value); + String binding = bytesHex(MessageDigest.getInstance("SHA-256").digest(identity.toString().getBytes(StandardCharsets.UTF_8))); + String cursor = params.has("cursor") ? requireString(params, "cursor") : null; + Address resume = null; + if (cursor != null) { + if (!cursor.startsWith(binding + ":")) + throw new RequestFailure("invalid_request", "Byte search cursor does not match the target and query"); + resume = parseReaAddress(cursor.substring(binding.length() + 1)); + MemoryBlock resumedBlock = currentProgram.getMemory().getBlock(resume); + Address bound = start == null ? end : start; + if (resumedBlock == null || !resumedBlock.isInitialized() || + (bound != null && !bound.getAddressSpace().equals(resume.getAddressSpace())) || + (start != null && resume.compareTo(start) < 0) || + (end != null && resume.compareTo(end) > 0)) + throw new RequestFailure("invalid_request", "Byte search cursor lies outside initialized query memory"); + } + List ordered = new ArrayList<>(List.of(currentProgram.getMemory().getBlocks())); + ordered.sort(Comparator.comparing(MemoryBlock::getStart)); + JsonArray matches = new JsonArray(); + JsonArray blocks = new JsonArray(); + JsonArray skipped = new JsonArray(); + JsonArray notLoaded = new JsonArray(); + long covered = 0; + String next = null; + for (MemoryBlock block : ordered) { + monitor.checkCancelled(); + Address low = block.getStart(); + Address high = block.getEnd(); + Address bound = start == null ? end : start; + if (bound != null && !bound.getAddressSpace().equals(low.getAddressSpace())) continue; + // Without bounds, search loaded memory only. Non-loaded file parts + // (such as .symtab) and overlays stay reachable through explicit bounds. + if (bound == null && (!block.isLoaded() || block.isOverlay())) { + notLoaded.add(byteSearchCoverage(block, low, high)); + continue; + } + if (start != null && low.compareTo(start) < 0) low = start; + if (end != null && high.compareTo(end) > 0) high = end; + if (low.compareTo(high) > 0) continue; + if (!block.isInitialized()) { + skipped.add(byteSearchCoverage(block, low, high)); + continue; + } + if (next != null) continue; + if (resume != null && low.compareTo(resume) < 0) low = resume; + if (low.compareTo(high) > 0 || !low.getAddressSpace().equals(high.getAddressSpace())) continue; + Address at = low; + Address examinedEnd = high; + while (at != null && at.compareTo(high) <= 0 && high.subtract(at) >= bytes.length - 1L) { + monitor.checkCancelled(); + // Ghidra's end address bounds candidate starts, not the final matched byte. + Address found = currentProgram.getMemory().findBytes(at, high.subtract(bytes.length - 1L), bytes, masks, true, monitor); + monitor.checkCancelled(); + if (found == null || high.subtract(found) < bytes.length - 1L) break; + if (matches.size() == limit) { + next = binding + ":" + canonicalAddress(found); + examinedEnd = found.addNoWrap(bytes.length - 1L); + break; + } + JsonObject match = new JsonObject(); + match.addProperty("address", canonicalAddress(found)); + match.addProperty("segment", block.getName()); + Long offset = targetFileOffset(block, found).offset(); + match.add("file_offset", offset == null ? JsonNull.INSTANCE : GSON.toJsonTree(offset)); + Function function = currentProgram.getFunctionManager().getFunctionContaining(found); + JsonObject procedure = new JsonObject(); + if (function != null) { + procedure.addProperty("address", canonicalAddress(function.getEntryPoint())); + procedure.addProperty("name", function.getName(true)); + } + match.add("procedure", function == null ? JsonNull.INSTANCE : procedure); + matches.add(match); + at = found.next(); + } + JsonObject coverage = byteSearchCoverage(block, low, examinedEnd); + covered = Math.addExact(covered, coverage.get("bytes").getAsLong()); + blocks.add(coverage); + } + JsonObject searched = new JsonObject(); + searched.add("blocks", blocks); + searched.addProperty("bytes", covered); + searched.add("skipped_uninitialized", skipped); + searched.add("skipped_not_loaded", notLoaded); + JsonObject result = new JsonObject(); + result.add("matches", matches); + result.add("searched", searched); + result.addProperty("complete", next == null); + result.add("next_cursor", next == null ? JsonNull.INSTANCE : GSON.toJsonTree(next)); + return result; + } + + private static JsonObject byteSearchCoverage(MemoryBlock block, Address low, Address high) { + JsonObject result = new JsonObject(); + result.addProperty("segment", block.getName()); + result.addProperty("start", canonicalAddress(low)); + result.addProperty("end", canonicalAddress(high)); + result.addProperty("bytes", high.subtract(low) + 1); + return result; + } + + private record TargetFileOffset(Long offset, int mappings) {} + + private TargetFileOffset targetFileOffset(MemoryBlock block, Address address) throws Exception { + Long offset = null; + int mappings = 0; + for (MemoryBlockSourceInfo source : block.getSourceInfos()) { + if (!source.contains(address) || source.getFileBytes().isEmpty() || + !isTargetSource(source.getFileBytes().get())) continue; + long candidate = source.getFileBytesOffset(address); + if (candidate < 0) continue; + offset = candidate; + mappings++; + } + return new TargetFileOffset(mappings == 1 && offset <= 9007199254740991L ? offset : null, mappings); + } + private JsonObject addressToFileOffset(JsonObject params) throws Exception { requireKeys(params, Set.of("document", "address")); requireDocument(params); @@ -692,18 +848,12 @@ public final class ReaGhidraBridge extends HeadlessScript { MemoryBlock block = currentProgram.getMemory().getBlock(address); if (block == null || !block.isInitialized()) throw new RequestFailure("invalid_request", "Address has no initialized file-backed memory: " + canonicalAddress(address)); - Long fileOffset = null; - int mappings = 0; - for (MemoryBlockSourceInfo source : block.getSourceInfos()) { - if (!source.contains(address) || source.getFileBytes().isEmpty() || - !isTargetSource(source.getFileBytes().get())) continue; - long offset = source.getFileBytesOffset(address); - if (offset < 0) continue; - fileOffset = offset; - mappings++; - } - if (mappings != 1) - throw new RequestFailure("invalid_request", "Address has " + mappings + " authoritative target-file mappings: " + canonicalAddress(address)); + TargetFileOffset mapping = targetFileOffset(block, address); + if (mapping.mappings() != 1) + throw new RequestFailure("invalid_request", "Address has " + mapping.mappings() + " authoritative target-file mappings: " + canonicalAddress(address)); + Long fileOffset = mapping.offset(); + if (fileOffset == null) + throw new RequestFailure("invalid_request", "Address lacks a safe file offset: " + canonicalAddress(address)); JsonObject result = new JsonObject(); result.addProperty("address", canonicalAddress(address)); result.addProperty("file_offset", fileOffset); diff --git a/docs/ghidra-provider.md b/docs/ghidra-provider.md index 0876b8ad51..fd23dcd073 100644 --- a/docs/ghidra-provider.md +++ b/docs/ghidra-provider.md @@ -198,3 +198,32 @@ It accepts the same architecture and DLL flags and verifies the selected PE role in the analysis profile. All lanes require matching native controls; package startup alone establishes only package compatibility. + +## Masked byte search + +`search_bytes` (`rea search-bytes PATH '48 8B ?? ?? 89' --provider ghidra`) +searches initialized analyzed memory, including loader fixups. Supply hexadecimal +byte pairs, with `??` for any byte. An optional equal-length hexadecimal `mask` +selects bits: `pattern: "a0"`, `mask: "f0"` matches bytes from `a0` through `af`. +Wildcards remain unconstrained even with an explicit mask. At least one bit +must be constrained. + +Optional `start` and `end` addresses are inclusive and select one address space. +A match must end at or before `end`, so `end` set inside a match excludes it. +Without bounds, loaded memory blocks are eligible, in Ghidra address order. +Blocks that are not loaded (file parts such as `.symtab`) and overlay blocks are +listed in `searched.skipped_not_loaded` and are not searched; pass `start` or +`end` in their address space to search them. +Overlapping matches are included; matches never cross block boundaries. +Each match reports its address, segment, unique source file offset (or null), +and containing procedure (or null). + +Pages default to 100 matches and accept 1 to 1000. Pass `next_cursor` as `cursor` +with the same target, profile, pattern, mask and bounds to continue. `complete` +means no further matches remain. `searched.blocks` and `searched.bytes` describe +the inclusive memory ranges examined for this page, including lookahead for the +next match. Coverage can overlap between pages; do not sum it as unique memory. +`searched.skipped_uninitialized` lists eligible uninitialized blocks on every +page. These blocks are never searched. Cancellation returns a typed error, +discards the active Ghidra database, and does not return a successful partial page. +Hopper byte search remains unavailable until its implementation is verified. diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 5a2fcbd31d..f963fd4d8e 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -549,3 +549,17 @@ Value projection preserves serialized node kinds, IDs, references and pagination Exact and ambiguous value counts describe emitted root/object value instances; a root and an object may contain the same value. Archive digests identify the original bytes. + +## Locate a byte signature + +Use `search_bytes` after opening a target with Ghidra to locate a known constant +or instruction sequence. For example, `{"pattern":"48 8B ?? ?? 89","limit":100}` +returns ordered addresses with segments, source file offsets and containing +procedures. The CLI equivalent is +`rea search-bytes ./program '48 8B ?? ?? 89' --provider ghidra`. +Use `start`/`end` to constrain the inclusive address window, and continue with +`next_cursor` until `complete` is true. Inspect `searched` for page coverage and +skipped uninitialized blocks. A missing match does not establish absence from +uninitialized memory or across block boundaries. See +[Ghidra byte search](ghidra-provider.md#masked-byte-search) for mask and coverage +semantics. This query never executes or modifies target bytes. diff --git a/docs/user-visible-errors.csv b/docs/user-visible-errors.csv index a9dd0d05db..02237dcdcc 100644 --- a/docs/user-visible-errors.csv +++ b/docs/user-visible-errors.csv @@ -1,4 +1,7 @@ harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implementation_status,verification_result +5,src/domain/native/bytePattern.ts,Unconstrained byte signature,Pattern must constrain at least one bit,Low risk; identifies input correction,Keep current copy,implemented,parser and MCP validation tests +5,src/domain/native/bytePattern.ts,Unequal mask length,Mask length must equal pattern length,Low risk; identifies input correction,Keep current copy,implemented,parser and MCP validation tests +5,bridge/ghidra/ReaGhidraBridge.java:searchMemoryBytes,Cursor reused with another query,Byte search cursor does not match the target and query,Low risk; identifies continuation mismatch,Keep current copy,implemented,real-provider verification required 1,src/domain/errors.ts:AnalysisCapabilityUnavailableError,Requested analysis unavailable,"Provider {providerId} cannot execute {operation}: {reason}",Exposes provider and internal operation/reason,"This analysis is unavailable for the current target. Choose another analysis or target.",implemented,focused assertions passed; full `npm run check` passed 2,src/domain/errors.ts:ProviderAdapterError,Provider adapter fails,"Provider {providerId} adapter failed during {operation}",Exposes provider and implementation boundary,"Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.",implemented,focused assertions passed; full `npm run check` passed 3,src/domain/errors.ts:HopperRemoteError,Remote analysis request fails,"Hopper request failed ({code}): {safeMessage}",Exposes provider name code and upstream text,"Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.",implemented,focused assertions passed; full `npm run check` passed diff --git a/scripts/verify-real-ghidra-raw.mjs b/scripts/verify-real-ghidra-raw.mjs index befba80e09..4341b5cfdc 100644 --- a/scripts/verify-real-ghidra-raw.mjs +++ b/scripts/verify-real-ghidra-raw.mjs @@ -114,6 +114,22 @@ try { length: bytes.length, }); assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex")); + const signature = await call("search_bytes", { + pattern: "1e ?? 2f e1", + limit: 1000, + }); + assert.equal(signature.normalized_result.complete, true); + assert.equal(signature.normalized_result.next_cursor, null); + assert.equal(signature.normalized_result.searched.bytes, bytes.length); + assert.deepEqual( + signature.normalized_result.matches.map((match) => match.address), + [entry], + ); + assert.equal(signature.normalized_result.matches[0].file_offset, 0); + assert.equal( + signature.normalized_result.matches[0].procedure.address, + entry, + ); const procedures = await call("list_procedures"); assert.ok( procedures.normalized_result.some( @@ -211,6 +227,69 @@ try { return_42: true, }); } + const repeatedPath = join(workspace, "signatures.bin"); + const repeatedBytes = Buffer.from("b82a000000c3".repeat(1005), "hex"); + const repeatedSha256 = createHash("sha256") + .update(repeatedBytes) + .digest("hex"); + await writeFile(repeatedPath, repeatedBytes); + await call("open_binary", { + path: repeatedPath, + format: "raw-binary", + architecture: "x86_64", + entry_point: "0x1000", + }); + // Import/analysis is complete before timing the first uncached search. + await call("read_bytes", { address: "0x1000", length: 6 }, repeatedSha256); + const started = performance.now(); + const page = await call( + "search_bytes", + { pattern: "b8 ?? 00 00 00 c3", limit: 1000 }, + repeatedSha256, + ); + const searchElapsedMs = performance.now() - started; + assert.equal(page.normalized_result.matches.length, 1000); + assert.equal(page.normalized_result.complete, false); + const rest = await call( + "search_bytes", + { + pattern: "b8 ?? 00 00 00 c3", + limit: 1000, + cursor: page.normalized_result.next_cursor, + }, + repeatedSha256, + ); + assert.equal(rest.normalized_result.matches.length, 5); + assert.equal( + rest.normalized_result.matches[0].address, + `0x${(0x1000 + 6000).toString(16)}`, + ); + assert.equal(rest.normalized_result.complete, true); + const masked = await call( + "search_bytes", + { pattern: "bf", mask: "f0", start: "0x1000", end: "0x1005" }, + repeatedSha256, + ); + assert.deepEqual( + masked.normalized_result.matches.map((match) => match.address), + ["0x1000"], + ); + const overlapping = await call( + "search_bytes", + { pattern: "00 00", start: "0x1000", end: "0x1005" }, + repeatedSha256, + ); + assert.deepEqual( + overlapping.normalized_result.matches.map((match) => match.address), + ["0x1002", "0x1003"], + ); + const wrongCursor = await client.callTool({ + name: "search_bytes", + arguments: { pattern: "c3", cursor: page.normalized_result.next_cursor }, + }); + assert.equal(wrongCursor.isError, true); + await call("close_binary"); + assert.deepEqual(await readFile(repeatedPath), repeatedBytes); report = { ok: true, mocked: false, @@ -231,6 +310,15 @@ try { load_images_verified: true, original_unchanged: true, x86_targets: x86Targets, + byte_search: { + matches: 1005, + page_size: 1000, + first_search_elapsed_ms: searchElapsedMs, + timing_scope: "MCP request after import; first uncached search", + token_usage: "unmeasured", + process_family_cpu_time: "unmeasured", + peak_process_family_memory: "unmeasured", + }, }; } catch (error) { if (stderr.trim()) process.stderr.write(stderr); diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 0bdb69864c..8a78745988 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -60,6 +60,7 @@ type DirectAnalysisTool = | "annotate_native_function" | "inspect_native_load_image" | "read_bytes" + | "search_bytes" | "address_to_file_offset" | "binary_overview" | "procedure_pseudo_code" diff --git a/src/cli/coreNativeCommands.ts b/src/cli/coreNativeCommands.ts index 016eedbc06..80d07187fd 100644 --- a/src/cli/coreNativeCommands.ts +++ b/src/cli/coreNativeCommands.ts @@ -17,6 +17,46 @@ export const registerCoreNativeCommands = ( runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], ): void => { registerAnnotationCommand(cli, logger, runDirectAnalysis); + cli.command(CLI_COMMANDS.searchBytes, { + description: "Find masked byte patterns in initialized provider memory", + args: z.object({ + path: z.string().describe("Local executable path"), + pattern: z.string().describe("Hexadecimal byte pairs with ?? wildcards"), + }), + options: z.object({ + mask: z.string().optional().describe("Equal-length hexadecimal bit mask"), + start: z.string().optional().describe("Inclusive start address"), + end: z.string().optional().describe("Inclusive end address"), + cursor: z + .string() + .optional() + .describe("Continuation from the same query's next_cursor"), + limit: z + .number() + .int() + .min(1) + .max(1000) + .default(100) + .describe("Maximum matches on this page"), + ...nativeOpenOptionFields, + }), + run: ({ args, options }) => + logCliCommand(logger, CLI_COMMANDS.searchBytes, () => + runDirectAnalysis( + args.path, + "search_bytes", + { + pattern: args.pattern, + limit: options.limit, + ...(options.mask === undefined ? {} : { mask: options.mask }), + ...(options.start === undefined ? {} : { start: options.start }), + ...(options.end === undefined ? {} : { end: options.end }), + ...(options.cursor === undefined ? {} : { cursor: options.cursor }), + }, + directAnalysisOptionsFromCli(logger, options), + ), + ), + }); cli.command(CLI_COMMANDS.inspectNativeLoadImage, { description: "Verify loaded native bytes, source mappings, relocations and entry", diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index d63a9a2bc5..b3f2cb0a6e 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -39,6 +39,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectNativeDataType: "inspect-native-data-type", inspectNativeLoadImage: "inspect-native-load-image", readBytes: "read-bytes", + searchBytes: "search-bytes", addressToFileOffset: "address-to-file-offset", traceNativeValues: "trace-native-values", inspectNativeInstruction: "inspect-native-instruction", diff --git a/src/contracts/hopperMemoryContracts.ts b/src/contracts/hopperMemoryContracts.ts index e4c49788a1..df7e43169d 100644 --- a/src/contracts/hopperMemoryContracts.ts +++ b/src/contracts/hopperMemoryContracts.ts @@ -1,8 +1,27 @@ import { z } from "zod"; import { address, document } from "./toolContractHelpers.js"; +import { + bytePatternFields, + validateBytePattern, +} from "../domain/native/bytePattern.js"; /** Hopper memory and file-mapping contracts. */ export const HOPPER_MEMORY_TOOL_DEFINITIONS = [ + { + name: "search_bytes", + description: + "Find masked byte signatures in initialized analyzed memory. Hex byte pairs and ?? wildcards are supported; an optional equal-length hex mask selects bits. Bounds are inclusive. Matches are ordered by provider address and never span blocks. Coverage describes this page; reuse the same query with next_cursor to continue. Uninitialized blocks are listed separately. Ghidra only; Hopper is unavailable.", + inputSchema: z + .strictObject({ + ...bytePatternFields, + document, + start: address.optional(), + end: address.optional(), + limit: z.number().int().min(1).max(1000).default(100), + cursor: z.string().min(1).optional(), + }) + .superRefine(validateBytePattern), + }, { name: "read_bytes", description: diff --git a/src/contracts/toolContractExamples.ts b/src/contracts/toolContractExamples.ts index 66b0f35184..50a745e755 100644 --- a/src/contracts/toolContractExamples.ts +++ b/src/contracts/toolContractExamples.ts @@ -26,6 +26,7 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly< resolve_native_call_targets: { address: "0x1000" }, read_function_instructions: { procedure: "main" }, read_bytes: { address: "0x1000", length: 16 }, + search_bytes: { pattern: "48 8B ?? ?? 89" }, address_to_file_offset: { address: "0x1000" }, procedure_references: { procedure: "main" }, procedure_pseudo_code: { procedure: "main" }, diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 70a40c6a13..939f123ebf 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -438,6 +438,7 @@ export const TOOL_EFFECTS: Readonly> = { inspect_native_data_type: evidence, resolve_native_call_targets: evidence, read_bytes: evidence, + search_bytes: evidence, address_to_file_offset: evidence, procedure_references: evidence, procedure_pseudo_code: evidence, diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 76294e2db4..1a36c0896f 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { byteSearchResultSchema } from "../domain/native/bytePattern.js"; import { artifactInventorySummaryViewResultSchema, machoSummaryViewResultSchema, @@ -108,6 +109,7 @@ const bookmarkFacetSchema = z.discriminatedUnion("state", [ /** Exact structured-content schemas shared by direct analysis providers. */ export const officialOutputSchemas: Readonly> = { + search_bytes: evidenceResultOf(byteSearchResultSchema), annotate_native_function: evidenceResultOf(nativeFunctionAnnotationsSchema), inspect_native_load_image: evidenceResultOf(nativeLoadImageSchema), inspect_native_data_type: evidenceResultOf(nativeDataTypeSchema), diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index cb19ead3ea..b42a983839 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { addressCoordinate } from "./native/addressCoordinate.js"; import type { JsonValue } from "./jsonValue.js"; @@ -38,16 +39,6 @@ export const addressDistance = (start: string, end: string): number => { ); }; -const addressCoordinate = ( - value: string, -): { readonly space: string; readonly offset: bigint } | null => { - const separator = value.lastIndexOf(":0x"); - const space = separator < 0 ? "default" : value.slice(0, separator); - const offset = separator < 0 ? value : value.slice(separator + 1); - if (!/^0x[0-9a-f]+$/u.test(offset)) return null; - return { space, offset: BigInt(offset) }; -}; - const procedureMapSchema = z.record(z.string(), z.string()); const addressedNamesSchema = z.array( z.object({ address: z.string(), name: z.string() }), @@ -128,9 +119,9 @@ export const functionBodySchema = z.discriminatedUnion("available", [ }) .superRefine((body, context) => { let total = 0n; - const bounds = new Map(); - const closedSpaces = new Set(); - let previousSpace: string | null = null; + const bounds = new Map(); + const closedSpaces = new Set(); + let previousSpace: string | null | undefined; for (const range of body.ranges) { const start = addressCoordinate(range.start); const end = addressCoordinate(range.end); @@ -146,7 +137,7 @@ export const functionBodySchema = z.discriminatedUnion("available", [ }); return; } - if (previousSpace !== null && previousSpace !== start.space) + if (previousSpace !== undefined && previousSpace !== start.space) closedSpaces.add(previousSpace); const previous = bounds.get(start.space); if ( diff --git a/src/domain/native/addressCoordinate.ts b/src/domain/native/addressCoordinate.ts new file mode 100644 index 0000000000..d43b1b4f14 --- /dev/null +++ b/src/domain/native/addressCoordinate.ts @@ -0,0 +1,11 @@ +/** Parse canonical native offsets without discarding their address space. */ +export const addressCoordinate = ( + value: string, +): { readonly space: string | null; readonly offset: bigint } | null => { + const separator = value.lastIndexOf(":0x"); + if (separator === 0) return null; + const space = separator < 0 ? null : value.slice(0, separator); + const offset = separator < 0 ? value : value.slice(separator + 1); + if (!/^0x[0-9a-f]+$/u.test(offset)) return null; + return { space, offset: BigInt(offset) }; +}; diff --git a/src/domain/native/bytePattern.test.ts b/src/domain/native/bytePattern.test.ts new file mode 100644 index 0000000000..cc3b6dd9a4 --- /dev/null +++ b/src/domain/native/bytePattern.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { parseBytePattern } from "./bytePattern.js"; + +describe("masked byte patterns", () => { + it("preserves whole-byte wildcards and combines explicit bit masks", () => { + const parsed = parseBytePattern("48 8b\t??89", "ff f0 ff 0f"); + if (!parsed.ok) throw parsed.error; + expect(parsed.value).toEqual({ + bytes: [0x48, 0x8b, 0, 0x89], + masks: [255, 240, 0, 15], + }); + const exact = parseBytePattern("Aa\u00a000"); + if (!exact.ok) throw exact.error; + expect(exact.value).toEqual({ bytes: [170, 0], masks: [255, 255] }); + }); + + it.each([ + ["", undefined, "pattern"], + ["4", undefined, "pattern"], + ["4?", undefined, "pattern"], + ["gg", undefined, "pattern"], + ["?? ??", undefined, "pattern"], + ["ff", "00", "pattern"], + ["ffff", "ff", "mask"], + ["ff", "??", "mask"], + ])("rejects invalid pattern %s with typed issues", (pattern, mask, field) => { + const parsed = parseBytePattern(pattern, mask); + if (parsed.ok) throw new Error("Expected a rejected pattern"); + expect(parsed.error.issues).toMatchObject([ + { path: [field], reason: "invalid_value" }, + ]); + }); +}); diff --git a/src/domain/native/bytePattern.ts b/src/domain/native/bytePattern.ts new file mode 100644 index 0000000000..add5bb7ef7 --- /dev/null +++ b/src/domain/native/bytePattern.ts @@ -0,0 +1,125 @@ +import { z } from "zod"; +import { addressCoordinate } from "./addressCoordinate.js"; +import { AnalysisInputError } from "../analysisErrorCore.js"; +import { err, ok, type Result } from "../result.js"; + +/** Byte pairs, with whole-byte wildcards; explicit masks can select individual bits. */ +export const bytePatternFields = { + pattern: z + .string() + .regex(/^([0-9a-fA-F]{2}|\?\?)(\s*([0-9a-fA-F]{2}|\?\?))*$/u), + mask: z + .string() + .regex(/^[0-9a-fA-F]{2}(\s*[0-9a-fA-F]{2})*$/u) + .optional(), +}; + +/** Parse once at the input boundary, retaining typed correction issues. */ +export const parseBytePattern = ( + pattern: string, + mask?: string, +): Result<{ bytes: number[]; masks: number[] }, AnalysisInputError> => { + const invalid = (field: string, message: string) => + err( + new AnalysisInputError("search_bytes", undefined, [ + { path: [field], reason: "invalid_value", message }, + ]), + ); + if (!bytePatternFields.pattern.safeParse(pattern).success) + return invalid("pattern", "Use hexadecimal byte pairs or ?? wildcards"); + if (mask !== undefined && !bytePatternFields.mask.safeParse(mask).success) + return invalid("mask", "Use hexadecimal mask byte pairs"); + const pairs = pattern.replace(/\s/gu, "").match(/../gu) ?? []; + const maskPairs = mask?.replace(/\s/gu, "").match(/../gu); + if (maskPairs && maskPairs.length !== pairs.length) + return invalid("mask", "Mask length must equal pattern length"); + const bytes = pairs.map((pair) => + pair === "??" ? 0 : Number.parseInt(pair, 16), + ); + const masks = pairs.map((pair, index) => + pair === "??" ? 0 : Number.parseInt(maskPairs?.[index] ?? "ff", 16), + ); + if (!masks.some((value) => value !== 0)) + return invalid("pattern", "Pattern must constrain at least one bit"); + return ok({ bytes, masks }); +}; + +/** Shared semantic validation for public and provider inputs. */ +export const validateBytePattern = ( + value: { pattern: string; mask?: string | undefined }, + context: z.RefinementCtx, +): void => { + const parsed = parseBytePattern(value.pattern, value.mask); + if (!parsed.ok) + for (const issue of parsed.error.issues) + context.addIssue({ + code: "custom", + path: [...issue.path], + message: issue.message ?? "Invalid byte pattern", + }); +}; + +const coverageBlock = z + .strictObject({ + segment: z.string(), + start: z.string(), + end: z.string(), + bytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + }) + .superRefine((value, context) => { + const start = addressCoordinate(value.start); + const end = addressCoordinate(value.end); + if ( + start === null || + end === null || + start.space !== end.space || + start.offset > end.offset || + end.offset - start.offset + 1n !== BigInt(value.bytes) + ) + context.addIssue({ + code: "custom", + message: + "Byte coverage must equal its inclusive address range in one space", + }); + }); + +/** Inclusive page coverage; matches never span memory blocks. */ +export const byteSearchResultSchema = z + .strictObject({ + matches: z + .array( + z.strictObject({ + address: z.string(), + segment: z.string(), + file_offset: z + .number() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER) + .nullable(), + procedure: z + .strictObject({ address: z.string(), name: z.string() }) + .nullable(), + }), + ) + .max(1000), + searched: z.strictObject({ + blocks: z.array(coverageBlock), + bytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + skipped_uninitialized: z.array(coverageBlock), + skipped_not_loaded: z.array(coverageBlock), + }), + complete: z.boolean(), + next_cursor: z.string().min(1).nullable(), + }) + .superRefine((value, context) => { + if ( + value.complete !== (value.next_cursor === null) || + value.searched.bytes !== + value.searched.blocks.reduce((sum, block) => sum + block.bytes, 0) + ) + context.addIssue({ + code: "custom", + message: "Byte search coverage and continuation disagree", + }); + }); diff --git a/src/ghidra/GhidraInventoryValues.ts b/src/ghidra/GhidraInventoryValues.ts index 86c17a7fbe..85a8b8fbe5 100644 --- a/src/ghidra/GhidraInventoryValues.ts +++ b/src/ghidra/GhidraInventoryValues.ts @@ -1,4 +1,9 @@ import { z } from "zod"; +import { + bytePatternFields, + validateBytePattern, + byteSearchResultSchema, +} from "../domain/native/bytePattern.js"; import { nativeLoadImageObservationSchema } from "../domain/native/nativeLoadImage.js"; import { @@ -20,6 +25,7 @@ import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js" export const GHIDRA_INVENTORY_OPERATIONS = [ "inspect_native_load_image", "read_bytes", + "search_bytes", "address_to_file_offset", "address_name", "list_documents", @@ -72,6 +78,16 @@ const searchInput = { }; const inputSchemas = { + search_bytes: z + .strictObject({ + ...bytePatternFields, + document, + start: explicitAddress.optional(), + end: explicitAddress.optional(), + limit: z.number().int().min(1).max(1000).default(100), + cursor: z.string().min(1).optional(), + }) + .superRefine(validateBytePattern), inspect_native_load_image: z.strictObject({}), read_bytes: z.strictObject({ document, @@ -271,6 +287,24 @@ const containingProcedure = z.discriminatedUnion("found", [ ]); const resultSchemas = { + search_bytes: byteSearchResultSchema.superRefine((value, context) => { + const addresses = [ + ...value.matches.flatMap((match) => [ + match.address, + ...(match.procedure === null ? [] : [match.procedure.address]), + ]), + ...[ + ...value.searched.blocks, + ...value.searched.skipped_uninitialized, + ...value.searched.skipped_not_loaded, + ].flatMap((block) => [block.start, block.end]), + ]; + if (addresses.some((value) => !canonicalAddress.safeParse(value).success)) + context.addIssue({ + code: "custom", + message: "Byte search addresses must be canonical", + }); + }), inspect_native_load_image: nativeLoadImageObservationSchema, read_bytes: z .strictObject({ diff --git a/src/ghidra/GhidraProviderCapabilities.ts b/src/ghidra/GhidraProviderCapabilities.ts index 68c3045cee..0714d1ce04 100644 --- a/src/ghidra/GhidraProviderCapabilities.ts +++ b/src/ghidra/GhidraProviderCapabilities.ts @@ -90,6 +90,11 @@ export const limitationsFor = (operation: string): readonly string[] => { ...common, "Bytes reflect Ghidra's initialized memory, including loader fixups. Reads stop at an unmapped or uninitialized byte and report completeness explicitly.", ]; + case "search_bytes": + return [ + ...common, + "Searches initialized loader-adjusted memory independently per block, in provider address order. Bounds and page coverage ends are inclusive. Uninitialized blocks are listed separately. Overlapping matches are included; patterns never cross block boundaries. A cursor resumes the same target, profile and query. Cancellation discards the active search and returns no successful partial page.", + ]; case "address_to_file_offset": return [ ...common, diff --git a/src/hopper/HopperProviderCapabilities.ts b/src/hopper/HopperProviderCapabilities.ts index 00f89fcdbf..2dbac46590 100644 --- a/src/hopper/HopperProviderCapabilities.ts +++ b/src/hopper/HopperProviderCapabilities.ts @@ -62,12 +62,16 @@ const MUTATING_OPERATIONS = new Set([ /** Source-owned capabilities without acquiring or probing a Hopper session. */ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze( - HOPPER_OPERATIONS.map((operation) => + [...HOPPER_OPERATIONS, "search_bytes" as const].map((operation) => Object.freeze({ provider: HOPPER_PROVIDER_IDENTITY, operation, - available: true, - reason: null, + ...(operation === "search_bytes" + ? { + available: false as const, + reason: "Masked byte search has not been verified for Hopper.", + } + : { available: true as const, reason: null }), effects: Object.freeze({ mutatesArtifact: MUTATING_OPERATIONS.has(operation), launchesProcess: true, diff --git a/tests/boundary/mcp/ghidraInventoryMcp.test.ts b/tests/boundary/mcp/ghidraInventoryMcp.test.ts index 5573c0cbe0..ecd3318165 100644 --- a/tests/boundary/mcp/ghidraInventoryMcp.test.ts +++ b/tests/boundary/mcp/ghidraInventoryMcp.test.ts @@ -5,9 +5,277 @@ import { join } from "node:path"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { nativeAotPeFixture } from "../../fixtures/nativeaotPe.js"; import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; -import { ok } from "../../../src/domain/result.js"; +import { err, ok } from "../../../src/domain/result.js"; import { ghidraFunctionIdentity } from "../../../src/domain/ghidraValues.fixture.js"; -import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js"; +import { + connectGhidraMcp, + ghidraInstallationHost, + sessionEvidence, +} from "./ghidraMcpHarness.js"; +import { spawn } from "node:child_process"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { GhidraClient } from "../../../src/ghidra/GhidraClient.js"; +import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js"; +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { silentLogger } from "../../../src/logger.js"; +import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; +import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { waitForExit } from "../../support/process/processFixture.js"; +import { GhidraSessionError } from "../../../src/ghidra/GhidraSessionError.js"; + +it("retains byte-search coverage and rejects malformed pages before creating Evidence", async () => { + const page = { + matches: [ + { + address: "0x401000", + segment: ".text", + file_offset: null, + procedure: null, + }, + ], + searched: { + blocks: [ + { segment: ".text", start: "0x401000", end: "0x40100f", bytes: 16 }, + ], + bytes: 16, + skipped_uninitialized: [ + { segment: ".bss", start: "0x402000", end: "0x40200f", bytes: 16 }, + ], + skipped_not_loaded: [ + { segment: ".symtab", start: "0x0", end: "0x17f", bytes: 384 }, + ], + }, + complete: false, + next_cursor: "continuation", + }; + let output = jsonValueSchema.parse(page); + const harness = await connectGhidraMcp("byte-search-projection", () => + Promise.resolve(ok(output)), + ); + try { + const first = await harness.mcp.callTool({ + name: "search_bytes", + arguments: { pattern: "48 ??", limit: 1 }, + }); + expect(first.isError).not.toBe(true); + expect( + sessionEvidence(harness.session, first.structuredContent) + .normalized_result, + ).toEqual(page); + output = jsonValueSchema.parse({ + ...page, + matches: [], + complete: true, + next_cursor: null, + }); + const last = await harness.mcp.callTool({ + name: "search_bytes", + arguments: { pattern: "48 ??", cursor: "continuation" }, + }); + expect(last.isError).not.toBe(true); + expect( + sessionEvidence(harness.session, last.structuredContent) + .normalized_result, + ).toEqual(output); + output = jsonValueSchema.parse({ ...page, complete: true }); + const malformed = await harness.mcp.callTool({ + name: "search_bytes", + arguments: { pattern: "49 ??" }, + }); + expect(parseMcpToolError(malformed)).toMatchObject({ + error: { code: "unreadable_output" }, + }); + for (const block of [ + { segment: ".text", start: "0x1000", end: "0x1009", bytes: 1 }, + { segment: ".text", start: "0x1009", end: "0x1000", bytes: 1 }, + { segment: ".text", start: "overlay:0x1000", end: "0x1000", bytes: 1 }, + { segment: ".text", start: "invalid", end: "0x1000", bytes: 1 }, + ]) { + output = jsonValueSchema.parse({ + ...page, + searched: { ...page.searched, blocks: [block], bytes: block.bytes }, + }); + const invalidCoverage = await harness.mcp.callTool({ + name: "search_bytes", + arguments: { pattern: "48 ??" }, + }); + expect(parseMcpToolError(invalidCoverage)).toMatchObject({ + error: { code: "unreadable_output" }, + }); + expect(invalidCoverage.structuredContent).toBeUndefined(); + } + const calls = harness.calls.length; + for (const args of [{ pattern: "??" }, { pattern: "48", mask: "ffff" }]) { + const invalid = await harness.mcp.callTool({ + name: "search_bytes", + arguments: args, + }); + expect(invalid.isError).toBe(true); + expect(JSON.stringify(invalid.content)).toContain( + "Input validation error", + ); + } + expect(harness.calls).toHaveLength(calls); + } finally { + await harness.close(); + } +}); + +it("projects cancelled byte searches as typed failures without successful partial Evidence", async () => { + const harness = await connectGhidraMcp("byte-search-cancelled", () => + Promise.resolve( + err(new GhidraSessionError("cancelled", "Search cancelled")), + ), + ); + try { + const reply = await harness.mcp.callTool({ + name: "search_bytes", + arguments: { pattern: "48 8b" }, + }); + expect(reply.isError).toBe(true); + expect(parseMcpToolError(reply)).toMatchObject({ + error: { code: "cancelled" }, + }); + expect(reply.structuredContent).toBeUndefined(); + } finally { + await harness.close(); + } +}); + +it("searches bytes through MCP and the socket bridge with pagination and skipped coverage", async () => { + const config = parseConfig({ GHIDRA_INSTALL_DIR: "/fixture/ghidra" }); + if (!config.ok) throw config.error; + const provider = new GhidraProvider( + config.value, + silentLogger, + {}, + ghidraInstallationHost(), + (options) => + new GhidraClient({ + ...options, + platform: process.platform, + startupTimeoutMs: 5000, + launcher: { + launch: async (launch) => { + const projectRoot = join(launch.runtimeRoot, "project"); + await mkdir(projectRoot); + const child = spawn( + process.execPath, + [ + fileURLToPath( + new URL("../../fixtures/fakeGhidra.mjs", import.meta.url), + ), + ], + { + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + REA_GHIDRA_FIXTURE_CONFIG: JSON.stringify({ + endpointPath: launch.endpointPath, + token: launch.token, + runId: launch.runId, + providerVersion: launch.providerVersion, + profileDigest: launch.profileDigest, + targetSha256: launch.targetSha256, + transport: launch.transport, + }), + }, + }, + ); + return ok({ + process: child, + ownsProcessLifetime: true as const, + projectRoot, + ghidraLogPath: join(launch.runtimeRoot, "ghidra.log"), + scriptLogPath: join(launch.runtimeRoot, "script.log"), + cleanup: async () => { + const signaled = + child.exitCode === null && child.signalCode === null; + if (signaled) child.kill("SIGTERM"); + return (await waitForExit(child, 2000)) + ? { cleaned: true as const, signaled } + : { cleaned: false as const, reason: "Fixture did not exit" }; + }, + }); + }, + }, + }), + ); + const session = composeBinarySession( + new AnalysisProviderRegistry([provider]), + ); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const client = new Client({ name: "byte-search", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: process.execPath }, + }); + expect(opened.isError).not.toBe(true); + const first = await client.callTool({ + name: "search_bytes", + arguments: { pattern: "48 8b ?? ?? 89", limit: 1 }, + }); + expect(first.isError, JSON.stringify(first)).not.toBe(true); + const firstEvidence = sessionEvidence(session, first.structuredContent); + expect(firstEvidence.normalized_result).toMatchObject({ + matches: [ + { + address: "0x1000", + segment: ".text", + file_offset: 0, + procedure: { name: "fixture_main" }, + }, + ], + searched: { + bytes: 10, + skipped_uninitialized: [{ segment: ".bss", bytes: 16 }], + }, + complete: false, + next_cursor: "fixture-second-page", + }); + const second = await client.callTool({ + name: "search_bytes", + arguments: { + pattern: "48 8b ?? ?? 89", + limit: 1, + cursor: "fixture-second-page", + }, + }); + expect(second.isError).not.toBe(true); + expect( + sessionEvidence(session, second.structuredContent).normalized_result, + ).toMatchObject({ + matches: [{ address: "0x1005", file_offset: 5 }], + complete: true, + next_cursor: null, + }); + const invalid = await client.callTool({ + name: "search_bytes", + arguments: { pattern: "??" }, + }); + expect(invalid.isError).toBe(true); + expect(JSON.stringify(invalid.content)).toContain( + "Pattern must constrain at least one bit", + ); + } finally { + await client.close(); + await server.close(); + const closed = await session.close(); + if (!closed.ok) throw closed.error; + } +}); it("retains missing PDB coverage in empty PE name-search Evidence and snapshot replay", async () => { const root = await createTestTempDirectory("rea-pdb-evidence-"); diff --git a/tests/boundary/mcp/ghidraMcpHarness.ts b/tests/boundary/mcp/ghidraMcpHarness.ts index 2ff9d07906..fea27b037f 100644 --- a/tests/boundary/mcp/ghidraMcpHarness.ts +++ b/tests/boundary/mcp/ghidraMcpHarness.ts @@ -54,7 +54,7 @@ export const connectGhidraMcp = async ( config.value, silentLogger, {}, - installationHost(), + ghidraInstallationHost(), factory, ); const session = composeBinarySession( @@ -100,7 +100,7 @@ export const sessionEvidence = (session: BinarySession, value: unknown) => { return evidence; }; -const installationHost = (): GhidraInstallationHost => ({ +export const ghidraInstallationHost = (): GhidraInstallationHost => ({ platform: "linux", architecture: "x64", readText: () => "application.version=12.1.4\napplication.java.min=21\n", diff --git a/tests/boundary/mcp/officialArguments.test.ts b/tests/boundary/mcp/officialArguments.test.ts index ff0284ac0c..a5fe0f2b11 100644 --- a/tests/boundary/mcp/officialArguments.test.ts +++ b/tests/boundary/mcp/officialArguments.test.ts @@ -43,7 +43,23 @@ describe("official operation arguments", () => { const execute: AnalysisOperationPort["execute"] = (name, parameters) => { calls.push({ name, parameters }); return Promise.resolve( - observed(name === "search_strings" ? [] : "int main() {}"), + observed( + name === "search_bytes" + ? { + matches: [], + searched: { + blocks: [], + bytes: 0, + skipped_uninitialized: [], + skipped_not_loaded: [], + }, + complete: true, + next_cursor: null, + } + : name === "search_strings" + ? [] + : "int main() {}", + ), ); }; const createSession = () => @@ -59,6 +75,7 @@ describe("official operation arguments", () => { const client = await connect({ execute }); for (const [name, mcpParameters, cliParameters] of [ ["procedure_pseudo_code", { procedure: "main" }, { procedure: "main" }], + ["search_bytes", { pattern: "48 ??" }, { pattern: "48 ??", limit: 100 }], [ "search_strings", { pattern: "entry" }, @@ -93,6 +110,11 @@ describe("official operation arguments", () => { ["list_procedures", { document: null }], ["procedure_pseudo_code", { procedure: "main", document: 3 }], ["search_strings", { pattern: "entry", case_sensitive: null }], + ["search_bytes", { pattern: "??" }], + ["search_bytes", { pattern: "48 8B", mask: "ff" }], + ["search_bytes", { pattern: "4?" }], + ["search_bytes", { pattern: "48", limit: 1001 }], + ["search_bytes", { pattern: "48", cursor: null }], ] as const) { const result = await client.callTool({ name, arguments: parameters }); expect(result.isError).toBe(true); diff --git a/tests/boundary/mcp/officialTools.test.ts b/tests/boundary/mcp/officialTools.test.ts index 70fbf8587b..e28639dd98 100644 --- a/tests/boundary/mcp/officialTools.test.ts +++ b/tests/boundary/mcp/officialTools.test.ts @@ -10,6 +10,12 @@ import { err } from "../../../src/domain/result.js"; import { observed as ok } from "../../fixtures/analysisExecution.js"; import type { JsonValue } from "../../../src/domain/jsonValue.js"; import { createServer } from "../../../src/server/createServer.js"; +import { HopperProvider } from "../../../src/hopper/HopperProvider.js"; +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { silentLogger } from "../../../src/logger.js"; +import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; +import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; +import { createBinarySessionTargets } from "../../fixtures/binarySession.js"; interface Invocation { readonly name: string; @@ -18,6 +24,67 @@ interface Invocation { const resources: Array<{ close(): Promise }> = []; +it("returns typed Hopper byte-search unavailability without launching the provider", async () => { + const [target] = await createBinarySessionTargets(); + const config = parseConfig({ HOPPER_LAUNCHER_PATH: process.execPath }); + if (!config.ok) throw config.error; + const provider = new HopperProvider(config.value, silentLogger, {}, "linux"); + const session = composeBinarySession( + new AnalysisProviderRegistry([provider]), + ); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const client = new Client({ + name: "hopper-byte-search-unavailable", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: target }, + }); + expect(opened.isError).not.toBe(true); + const result = await client.callTool({ + name: "search_bytes", + arguments: { pattern: "48 8b" }, + }); + expect(result.isError).toBe(true); + expect(parseMcpToolError(result)).toMatchObject({ + error: { + code: "capability_unavailable", + details: { provider_id: "hopper", operation: "search_bytes" }, + }, + }); + } finally { + await client.close(); + await server.close(); + const closed = await session.close(); + if (!closed.ok) throw closed.error; + } +}); + +it("advertises strict masked-byte arguments and bounded pages", () => { + const contract = OFFICIAL_TOOL_CONTRACTS.find( + ({ name }) => name === "search_bytes", + ); + expect( + contract?.inputSchema.parse({ pattern: "48 8B ?? ?? 89" }), + ).toMatchObject({ limit: 100 }); + expect( + contract?.inputSchema.safeParse({ pattern: "48", ignored: true }).success, + ).toBe(false); + expect( + contract?.inputSchema.safeParse({ pattern: "48", mask: "f0", limit: 1000 }) + .success, + ).toBe(true); +}); + describe("read_bytes contract", () => { it("accepts lengths above the former arbitrary ceiling and rejects zero", () => { const contract = OFFICIAL_TOOL_CONTRACTS.find( diff --git a/tests/fixtures/fakeGhidra.mjs b/tests/fixtures/fakeGhidra.mjs index b08410793b..0a130c744b 100644 --- a/tests/fixtures/fakeGhidra.mjs +++ b/tests/fixtures/fakeGhidra.mjs @@ -121,7 +121,7 @@ const handleRequest = (socket, server, request, state) => { ? [stringItem("0x2000", "fixture 雪🦊")] : request.method === "list_strings" && state.mode === "oversized_result" ? [stringItem("0x2000", "x".repeat(64 * 1024 * 1024 + 1))] - : inventoryResultFor(request.method); + : inventoryResultFor(request.method, request.params); if (inventory !== undefined) { const response = `${JSON.stringify({ id: request.id, ok: true, result: inventory })}\n`; if (state.mode === "fragmented") { @@ -228,6 +228,7 @@ const sessionInfo = ({ "shutdown", "inspect_native_load_image", "read_bytes", + "search_bytes", "address_to_file_offset", "address_name", "list_documents", @@ -273,8 +274,42 @@ const sessionInfo = ({ }, }); -const inventoryResultFor = (method) => { +const inventoryResultFor = (method, params) => { switch (method) { + case "search_bytes": { + const continued = params.cursor === "fixture-second-page"; + const paged = params.limit === 1 && !continued; + const addresses = continued + ? ["0x1005"] + : paged + ? ["0x1000"] + : ["0x1000", "0x1005"]; + return { + matches: addresses.map((address) => ({ + address, + segment: ".text", + file_offset: address === "0x1000" ? 0 : 5, + procedure: { address: "0x1000", name: "fixture_main" }, + })), + searched: { + blocks: [ + { + segment: ".text", + start: continued ? "0x1005" : "0x1000", + end: "0x1009", + bytes: continued ? 5 : 10, + }, + ], + bytes: continued ? 5 : 10, + skipped_uninitialized: [ + { segment: ".bss", start: "0x2000", end: "0x200f", bytes: 16 }, + ], + skipped_not_loaded: [], + }, + complete: !paged, + next_cursor: paged ? "fixture-second-page" : null, + }; + } case "address_name": return "fixture_main"; case "list_documents":