feat(native): search analyzed memory for masked byte patterns (#2341)

* feat(native): search analyzed memory for masked byte patterns

The diff adds search_bytes to the CLI, the shared MCP contract, Ghidra
inventory, and the headless bridge, and advertises the same operation on
Hopper as unavailable until that provider is verified. Patterns are
hexadecimal byte pairs with optional ?? wildcards and an optional
equal-length bit mask; the TypeScript boundary and the bridge both
require a nonempty pattern, equal lengths, and at least one constrained
bit. Ghidra walks initialized blocks in address order within inclusive
bounds, keeps overlapping matches inside a single block, and returns
each hit with its segment, unique source file offset, and containing
procedure, continuing through a cursor bound to the target, profile,
pattern, mask, and bounds. Each page reports the inclusive ranges
examined and the skipped uninitialized blocks; cancellation returns a
typed error and discards the active Ghidra database.
address_to_file_offset now uses the same unique-offset helper, which
rejects a mapping that is missing, ambiguous, or larger than a safe
integer.

* fix(native): limit unbounded byte search to loaded memory

Without start or end, search_bytes walked every Ghidra block, including
non-loaded file parts such as .symtab and overlay blocks, which the docs
call out of scope. Unbounded searches now skip those blocks and list them
in searched.skipped_not_loaded; explicit bounds in their address space
still search them. Document that a match must end at or before end.

* fix(native): preserve implicit address spaces in range consumers

* fix(native): reject present malformed byte search state

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Matt Van Horn
2026-10-13 08:41:45 -07:00
committed by GitHub
parent 70611713e1
commit 2a78763e20
24 changed files with 980 additions and 36 deletions
+162 -12
View File
@@ -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<String> 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<MemoryBlock> 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);
+29
View File
@@ -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.
+14
View File
@@ -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.
+3
View File
@@ -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
1 harm_rank location trigger current_copy user_risk proposed_replacement implementation_status verification_result
2 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
3 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
4 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
5 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
6 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
7 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
+88
View File
@@ -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);
+1
View File
@@ -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"
+40
View File
@@ -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",
+1
View File
@@ -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",
+19
View File
@@ -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:
+1
View File
@@ -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" },
+1
View File
@@ -438,6 +438,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
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,
+2
View File
@@ -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<Record<string, z.ZodObject>> = {
search_bytes: evidenceResultOf(byteSearchResultSchema),
annotate_native_function: evidenceResultOf(nativeFunctionAnnotationsSchema),
inspect_native_load_image: evidenceResultOf(nativeLoadImageSchema),
inspect_native_data_type: evidenceResultOf(nativeDataTypeSchema),
+5 -14
View File
@@ -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<string, { start: bigint; end: bigint }>();
const closedSpaces = new Set<string>();
let previousSpace: string | null = null;
const bounds = new Map<string | null, { start: bigint; end: bigint }>();
const closedSpaces = new Set<string | null>();
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 (
+11
View File
@@ -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) };
};
+33
View File
@@ -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" },
]);
});
});
+125
View File
@@ -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",
});
});
+34
View File
@@ -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({
+5
View File
@@ -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,
+7 -3
View File
@@ -62,12 +62,16 @@ const MUTATING_OPERATIONS = new Set<string>([
/** 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,
+270 -2
View File
@@ -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-");
+2 -2
View File
@@ -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",
+23 -1
View File
@@ -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);
+67
View File
@@ -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<void> }> = [];
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(
+37 -2
View File
@@ -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":