rea/scripts/verify-real-hopper.mjs

372 lines
11 KiB
JavaScript

import { execFile } from "node:child_process";
import { readdir, realpath } from "node:fs/promises";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
const execFileAsync = promisify(execFile);
const timeout = 180_000;
const sessionsBefore = new Set(
(await readdir("/tmp")).filter((name) => name.startsWith("rea-")),
);
const textValue = (result) => {
const text = result.content?.find((item) => item.type === "text")?.text;
if (typeof text !== "string") throw new Error("Tool result omitted text");
return text;
};
const jsonValue = (result) => JSON.parse(textValue(result));
const requireSuccessfulTool = (result, operation) => {
if (result.isError === true) {
throw new Error(`${operation} failed: ${textValue(result)}`);
}
const value = jsonValue(result);
if (value === null || typeof value !== "object" || !("result" in value)) {
throw new Error(`${operation} omitted its evidence result`);
}
return value.result;
};
const firstProcedureAddress = (result) => {
const items = result.structuredContent?.result?.items;
if (!Array.isArray(items) || items.length === 0) {
throw new Error("The verifier target contains no analyzed procedure");
}
const address = items[0]?.address;
if (typeof address !== "string" || !/^0x[0-9a-f]+$/iu.test(address)) {
throw new Error("list_procedures returned an invalid procedure address");
}
return address;
};
const requirePseudocode = (value, operation) => {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${operation} returned empty pseudocode`);
}
if (value === "No output" || value.startsWith("Error:")) {
throw new Error(`${operation} returned an embedded failure: ${value}`);
}
return value;
};
const requireFunctionDossier = (value, expectedAddress) => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error("analyze_function returned no function dossier");
}
if (value.procedure?.address !== expectedAddress) {
throw new Error("analyze_function returned the wrong procedure");
}
if (
typeof value.procedure?.name !== "string" ||
value.procedure.name.trim().length === 0
) {
throw new Error("analyze_function omitted the procedure name");
}
requirePseudocode(value.pseudocode?.text, "analyze_function");
for (const field of [
"comments",
"callers",
"callees",
"incoming_references",
"outgoing_references",
"referenced_strings",
"referenced_names",
"basic_blocks",
]) {
const collection = value[field];
if (
collection === null ||
typeof collection !== "object" ||
!Array.isArray(collection.items) ||
!Number.isInteger(collection.total)
) {
throw new Error(`analyze_function returned an invalid ${field} result`);
}
}
for (const field of ["callers", "callees"]) {
for (const identity of value[field].items) {
if (
typeof identity?.address !== "string" ||
typeof identity?.name !== "string"
) {
throw new Error(`analyze_function returned an untyped ${field} item`);
}
}
}
for (const block of value.basic_blocks.items) {
if (!Array.isArray(block?.successors)) {
throw new Error("analyze_function omitted CFG successor evidence");
}
}
if (
typeof value.instruction_scan?.scanned !== "number" ||
typeof value.instruction_scan?.truncated !== "boolean"
) {
throw new Error("analyze_function omitted instruction scan limitations");
}
return value;
};
const requireSafeDiagnostics = (chunks) => {
const lines = chunks
.join("")
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
for (const line of lines) {
let diagnostic;
try {
diagnostic = JSON.parse(line);
} catch {
throw new Error(`The MCP runtime emitted malformed stderr: ${line}`);
}
if (
diagnostic === null ||
typeof diagnostic !== "object" ||
diagnostic.application !== "rea" ||
typeof diagnostic.level !== "number" ||
diagnostic.level >= 40
) {
throw new Error(`The MCP runtime emitted unsafe stderr: ${line}`);
}
}
return lines.length;
};
const targetPath = process.env.HOPPER_TARGET_PATH;
const secondTargetPath = process.env.HOPPER_SECOND_TARGET_PATH;
if (!targetPath || !secondTargetPath)
throw new Error(
"HOPPER_TARGET_PATH and HOPPER_SECOND_TARGET_PATH are required and must name distinct binaries",
);
const [targetA, targetB] = await Promise.all([
realpath(targetPath),
realpath(secondTargetPath),
]);
if (targetA === targetB)
throw new Error("Real-Hopper verification requires two distinct targets");
const serverEnvironment = { ...process.env };
delete serverEnvironment.HOPPER_TARGET_PATH;
delete serverEnvironment.HOPPER_SECOND_TARGET_PATH;
const transport = new StdioClientTransport({
command: process.execPath,
args: ["dist/main.js"],
cwd: process.cwd(),
env: serverEnvironment,
stderr: "pipe",
});
let stderrBytes = 0;
const stderrChunks = [];
transport.stderr?.on("data", (chunk) => {
stderrBytes += chunk.length;
if (stderrBytes <= 16_384) stderrChunks.push(chunk.toString("utf8"));
});
const client = new Client({ name: "real-hopper-verifier", version: "1.0.0" });
let summary;
try {
await client.connect(transport);
const listed = await client.listTools();
const expectedNames = TOOL_CONTRACTS.map(({ name }) => name).sort();
const actualNames = listed.tools.map(({ name }) => name).sort();
if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) {
throw new Error("The real server did not expose the intended 50 tools");
}
const options = { timeout };
const initialSession = await client.callTool(
{ name: "binary_session", arguments: {} },
options,
);
if (jsonValue(initialSession).open !== false)
throw new Error("The verifier did not start without a target");
const opened = await client.callTool(
{ name: "open_binary", arguments: { path: targetA } },
options,
);
if (opened.isError === true) throw new Error(textValue(opened));
const documents = await client.callTool(
{ name: "list_documents", arguments: {} },
options,
);
const overview = await client.callTool(
{ name: "binary_overview", arguments: {} },
options,
);
const segments = await client.callTool(
{ name: "list_segments", arguments: {} },
options,
);
const procedures = await client.callTool(
{ name: "list_procedures", arguments: {} },
options,
);
requireSuccessfulTool(procedures, "list_procedures");
const firstAddress = firstProcedureAddress(procedures);
const containment = requireSuccessfulTool(
await client.callTool(
{
name: "resolve_containing_procedure",
arguments: { address: firstAddress },
},
options,
),
"resolve_containing_procedure",
);
if (
containment?.found !== true ||
containment.procedure?.address !== firstAddress
) {
throw new Error(
"resolve_containing_procedure returned the wrong procedure",
);
}
const references = requireSuccessfulTool(
await client.callTool(
{
name: "procedure_references",
arguments: {
procedure: firstAddress,
direction: "outgoing",
limit: 10,
max_instructions: 100,
},
},
options,
),
"procedure_references",
);
if (!Array.isArray(references?.references?.items)) {
throw new Error("procedure_references returned an invalid bounded result");
}
const bounded = await client.callTool(
{ name: "batch_decompile", arguments: { addresses: [firstAddress] } },
options,
);
const boundedResult = requireSuccessfulTool(bounded, "batch_decompile");
if (
boundedResult === null ||
typeof boundedResult !== "object" ||
Array.isArray(boundedResult)
) {
throw new Error("batch_decompile returned an invalid result");
}
const boundedPseudocode = requirePseudocode(
boundedResult[firstAddress],
"batch_decompile",
);
const dossier = requireFunctionDossier(
requireSuccessfulTool(
await client.callTool(
{
name: "analyze_function",
arguments: { procedure: firstAddress },
},
options,
),
"analyze_function",
),
firstAddress,
);
const switched = await client.callTool(
{ name: "open_binary", arguments: { path: targetB } },
options,
);
if (switched.isError === true) throw new Error(textValue(switched));
const secondSession = jsonValue(
await client.callTool({ name: "binary_session", arguments: {} }, options),
);
if (secondSession.path !== targetB)
throw new Error("The real session did not switch to target B");
const secondOverview = await client.callTool(
{ name: "binary_overview", arguments: {} },
options,
);
if (secondOverview.isError === true)
throw new Error(textValue(secondOverview));
const processList = await execFileAsync("ps", ["-ax", "-o", "command="]);
const bundledMcpRunning = processList.stdout
.split("\n")
.some((line) => line.trim().endsWith("/HopperMCPServer"));
if (bundledMcpRunning) {
throw new Error(
"Hopper's bundled MCP server was running during verification",
);
}
const diagnosticCount = requireSafeDiagnostics(stderrChunks);
const closed = await client.callTool(
{ name: "close_binary", arguments: {} },
options,
);
if (closed.isError === true) throw new Error(textValue(closed));
summary = {
toolCount: actualNames.length,
documentCount: jsonValue(documents).length,
overview: jsonValue(overview),
segmentCount: jsonValue(segments).length,
boundedTool: "batch_decompile",
boundedInputCount: 1,
boundedResultKeys: Object.keys(boundedResult).length,
boundedPseudocodeChars: boundedPseudocode.length,
analyzedProcedure: dossier.procedure,
analyzedPseudocodeChars: dossier.pseudocode.text.length,
bundledMcpRunning,
stderrBytes,
diagnosticCount,
dynamicSession: true,
targets: [targetA, targetB],
switched: true,
secondOverview: jsonValue(secondOverview),
};
} finally {
const keepAlive = setInterval(() => undefined, 100);
try {
await client.close();
await transport.close();
} finally {
clearInterval(keepAlive);
}
}
await new Promise((resolve) => setTimeout(resolve, 500));
const sessionsAfter = (await readdir("/tmp")).filter(
(name) => name.startsWith("rea-") && !sessionsBefore.has(name),
);
if (sessionsAfter.length > 0) {
throw new Error("The MCP runtime leaked a bridge session directory");
}
const afterCloseProcesses = await execFileAsync("ps", [
"-ax",
"-o",
"command=",
]);
if (
afterCloseProcesses.stdout
.split("\n")
.some((line) => line.includes("node dist/main.js"))
) {
throw new Error(
"The TypeScript MCP server remained alive after client close",
);
}
if (summary === undefined)
throw new Error("Real-Hopper verification did not produce a summary");
await new Promise((resolve, reject) => {
process.stdout.write(
`${JSON.stringify({ ...summary, cleanShutdown: true }, null, 2)}\n`,
(cause) => {
if (cause) reject(cause);
else resolve();
},
);
});