* 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>
284 lines
9.5 KiB
TypeScript
284 lines
9.5 KiB
TypeScript
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js";
|
|
import { OFFICIAL_TOOL_CONTRACTS } from "../../../src/contracts/officialToolContracts.js";
|
|
import { HopperRemoteError } from "../../../src/domain/hopperErrors.js";
|
|
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;
|
|
readonly arguments_: Readonly<Record<string, JsonValue>>;
|
|
}
|
|
|
|
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(
|
|
({ name }) => name === "read_bytes",
|
|
);
|
|
expect(contract).toBeDefined();
|
|
expect(
|
|
contract?.inputSchema.safeParse({ address: "0x1000", length: 4097 })
|
|
.success,
|
|
).toBe(true);
|
|
expect(
|
|
contract?.inputSchema.safeParse({ address: "0x1000", length: 0 }).success,
|
|
).toBe(false);
|
|
expect(
|
|
contract?.outputSchema.shape.normalized_result.safeParse({
|
|
address: "0x1000",
|
|
requested_bytes: 4097,
|
|
returned_bytes: 4097,
|
|
bytes_hex: "ff".repeat(4097),
|
|
complete: true,
|
|
}).success,
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("list_strings contract", () => {
|
|
it("describes optional provider string metadata inline", () => {
|
|
const contract = OFFICIAL_TOOL_CONTRACTS.find(
|
|
({ name }) => name === "list_strings",
|
|
);
|
|
expect(
|
|
contract?.outputSchema.shape.normalized_result.safeParse([
|
|
{
|
|
address: "0x1000",
|
|
value: "coffee",
|
|
string: {
|
|
encoding: "UTF-8",
|
|
termination: "present_or_not_required",
|
|
byte_length: 6,
|
|
},
|
|
},
|
|
{ address: "0x1008", value: "beans" },
|
|
]).success,
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
resources.splice(0).map(async (resource) => resource.close()),
|
|
);
|
|
});
|
|
|
|
const connect = async (analysis: AnalysisOperationPort) => {
|
|
const server = createServer({ kind: "fixed", analysis });
|
|
const client = new Client({ name: "contract-test", version: "1.0.0" });
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
resources.push(client, server);
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
return client;
|
|
};
|
|
|
|
describe("official tool input contracts", () => {
|
|
it("rejects misspelled top-level inputs before dispatch and keeps record keys open", async () => {
|
|
const invocations: Invocation[] = [];
|
|
const client = await connect({
|
|
execute: (name, arguments_) => {
|
|
invocations.push({ name, arguments_ });
|
|
return Promise.resolve(ok(null));
|
|
},
|
|
});
|
|
const { tools } = await client.listTools();
|
|
const advertisedByName = new Map(tools.map((tool) => [tool.name, tool]));
|
|
for (const contract of OFFICIAL_TOOL_CONTRACTS) {
|
|
expect(advertisedByName.get(contract.name)?.inputSchema).toHaveProperty(
|
|
"additionalProperties",
|
|
false,
|
|
);
|
|
}
|
|
const advertised = tools.find(({ name }) => name === "address_name");
|
|
const recordTool = tools.find(({ name }) => name === "set_addresses_names");
|
|
if (advertised === undefined || recordTool === undefined)
|
|
throw new Error("Official tools were not advertised");
|
|
|
|
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
|
const validateAddressName = ajv.compile(advertised.inputSchema);
|
|
const validateRecordInput = ajv.compile(recordTool.inputSchema);
|
|
const typoInput = { adress: "0x1234" };
|
|
expect(advertised.inputSchema).toHaveProperty(
|
|
"additionalProperties",
|
|
false,
|
|
);
|
|
expect(validateAddressName(typoInput)).toBe(false);
|
|
expect(
|
|
OFFICIAL_TOOL_CONTRACTS.find(
|
|
({ name }) => name === "address_name",
|
|
)?.inputSchema.safeParse(typoInput).success,
|
|
).toBe(false);
|
|
expect(validateRecordInput({ names: { "0x1000": "entry" } })).toBe(true);
|
|
|
|
const result = await client.callTool({
|
|
name: "address_name",
|
|
arguments: typoInput,
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(invocations).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("official Hopper proxy tools", () => {
|
|
it("returns stable safe MCP error content", async () => {
|
|
const client = await connect({
|
|
execute: () => Promise.resolve(err(new HopperRemoteError(-1, "denied"))),
|
|
});
|
|
const result = await client.callTool({
|
|
name: "list_documents",
|
|
arguments: {},
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content[0]).toEqual({
|
|
type: "text",
|
|
text: JSON.stringify(parseMcpToolError(result)),
|
|
});
|
|
});
|
|
|
|
it("binds list_procedures without adding omitted document arguments", async () => {
|
|
const invocations: Invocation[] = [];
|
|
const client = await connect({
|
|
execute: (name, arguments_) => {
|
|
invocations.push({ name, arguments_ });
|
|
return Promise.resolve(
|
|
ok([
|
|
{ address: "0x1", value: "procedure" },
|
|
{ address: "0x2", value: "procedure" },
|
|
]),
|
|
);
|
|
},
|
|
});
|
|
|
|
const result = await client.callTool({
|
|
name: "list_procedures",
|
|
arguments: {},
|
|
});
|
|
|
|
expect(result.structuredContent).toMatchObject({
|
|
normalized_result: [
|
|
{ address: "0x1", value: "procedure" },
|
|
{ address: "0x2", value: "procedure" },
|
|
],
|
|
});
|
|
expect(invocations).toHaveLength(1);
|
|
expect(invocations[0]?.name).toBe("list_procedures");
|
|
expect(invocations[0]?.arguments_).toEqual({});
|
|
expect(
|
|
(await client.listTools()).tools.find(
|
|
({ name }) => name === "list_procedures",
|
|
)?.inputSchema,
|
|
).not.toHaveProperty("properties.offset");
|
|
});
|
|
|
|
it("returns every search match inline in one provider call", async () => {
|
|
const invocations: Invocation[] = [];
|
|
const client = await connect({
|
|
execute: (name, arguments_) => {
|
|
invocations.push({ name, arguments_ });
|
|
return Promise.resolve(
|
|
ok([
|
|
{ address: "0x1", value: "coffee" },
|
|
{ address: "0x2", value: "coffee" },
|
|
]),
|
|
);
|
|
},
|
|
});
|
|
|
|
const result = await client.callTool({
|
|
name: "search_strings",
|
|
arguments: { pattern: "coffee" },
|
|
});
|
|
|
|
expect(result.structuredContent).toMatchObject({
|
|
normalized_result: [
|
|
{ address: "0x1", value: "coffee" },
|
|
{ address: "0x2", value: "coffee" },
|
|
],
|
|
});
|
|
expect(invocations).toHaveLength(1);
|
|
expect(invocations[0]?.arguments_).toMatchObject({ pattern: "coffee" });
|
|
expect(
|
|
(await client.listTools()).tools.find(
|
|
({ name }) => name === "search_strings",
|
|
)?.inputSchema,
|
|
).not.toHaveProperty("properties.offset");
|
|
});
|
|
});
|