import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; 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"; import { firstProcedureAddress, requireDistinctTargetHashes, requireAddressArray, requireFunctionDossier, requirePseudocode, } from "../dist/application/RealHopperAssertions.js"; const execFileAsync = promisify(execFile); const timeout = 180_000; const parseServerArgs = (encoded) => { const parsed = JSON.parse(encoded); if ( !Array.isArray(parsed) || parsed.some((value) => typeof value !== "string") ) throw new Error("REA_VERIFY_SERVER_ARGS_JSON must encode string arguments"); return parsed; }; 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" || !("normalized_result" in value) ) { throw new Error(`${operation} omitted its evidence result`); } return value.normalized_result; }; const requireOverview = (value, operation) => { if ( value === null || typeof value !== "object" || typeof value.document !== "string" || value.document.length === 0 || !Number.isInteger(value.procedure_count) || value.procedure_count < 1 || !Number.isInteger(value.segment_count) || value.segment_count < 1 || !Array.isArray(value.segments) ) { throw new Error(`${operation} returned no analyzed binary overview`); } return value; }; const requireTruthfulMemoryRegions = (segments) => { if (!Array.isArray(segments) || segments.length === 0) { throw new Error("list_segments returned no real memory regions"); } const regions = segments.flatMap((segment) => [ segment, ...(Array.isArray(segment.sections) ? segment.sections : []), ]); for (const region of regions) { for (const permission of ["readable", "writable", "executable"]) { if (![true, false, null].includes(region?.[permission])) { throw new Error(`list_segments omitted tri-state ${permission}`); } } if ( region.provenance !== "hopper-public-python-api" || region.permissions?.available !== false || typeof region.permissions.reason !== "string" ) { throw new Error("list_segments omitted permission provenance"); } } }; const verifyRelationships = async (client, options, procedure) => { const related = {}; for (const operation of ["procedure_callers", "procedure_callees"]) { related[operation] = requireAddressArray( requireSuccessfulTool( await client.callTool( { name: operation, arguments: { procedure } }, options, ), operation, ), operation, ); } related.xrefs = requireAddressArray( requireSuccessfulTool( await client.callTool( { name: "xrefs", arguments: { address: procedure } }, options, ), "xrefs", ), "xrefs", ); return related; }; const verifyCurrentTarget = async (client, options) => { const procedures = requireSuccessfulTool( await client.callTool({ name: "list_procedures", arguments: {} }, options), "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 relationships = await verifyRelationships( client, options, firstAddress, ); const boundedResult = requireSuccessfulTool( await client.callTool( { name: "batch_decompile", arguments: { addresses: [firstAddress] }, }, options, ), "batch_decompile", ); if ( boundedResult === null || typeof boundedResult !== "object" || Array.isArray(boundedResult) || boundedResult.total !== 1 || boundedResult.succeeded !== 1 || boundedResult.failed !== 0 || !Array.isArray(boundedResult.items) || boundedResult.items.length !== 1 || boundedResult.items[0]?.address !== firstAddress || boundedResult.items[0]?.status !== "ok" ) { throw new Error("batch_decompile returned an invalid result"); } const boundedPseudocode = requirePseudocode( boundedResult.items[0].pseudocode, "batch_decompile", ); const dossier = requireFunctionDossier( requireSuccessfulTool( await client.callTool( { name: "analyze_function", arguments: { procedure: firstAddress }, }, options, ), "analyze_function", ), firstAddress, ); return { procedure: dossier.procedure, procedureCount: procedures.total, boundedPseudocodeChars: boundedPseudocode.length, analyzedPseudocodeChars: [...dossier.pseudocode.text].length, callerCount: relationships.procedure_callers.length, calleeCount: relationships.procedure_callees.length, xrefCount: relationships.xrefs.length, outgoingReferenceCount: references.references.items.length, }; }; 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 [targetHashA, targetHashB] = await Promise.all([ sha256File(targetA), sha256File(targetB), ]); requireDistinctTargetHashes(targetHashA, targetHashB); const serverEnvironment = { ...process.env }; delete serverEnvironment.HOPPER_TARGET_PATH; delete serverEnvironment.HOPPER_SECOND_TARGET_PATH; delete serverEnvironment.REA_VERIFY_SERVER_COMMAND; delete serverEnvironment.REA_VERIFY_SERVER_ARGS_JSON; const serverCommand = process.env.REA_VERIFY_SERVER_COMMAND ?? process.execPath; const serverArgs = parseServerArgs( process.env.REA_VERIFY_SERVER_ARGS_JSON ?? '["dist/main.js"]', ); const transport = new StdioClientTransport({ command: serverCommand, args: serverArgs, 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 78 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, ); requireTruthfulMemoryRegions( requireSuccessfulTool(segments, "list_segments"), ); const firstDocuments = requireSuccessfulTool(documents, "list_documents"); if (!Array.isArray(firstDocuments) || firstDocuments.length === 0) throw new Error("list_documents returned no real Hopper document"); const firstOverview = requireOverview( requireSuccessfulTool(overview, "binary_overview"), "binary_overview", ); const firstAnalysis = await verifyCurrentTarget(client, options); 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, ); const verifiedSecondOverview = requireOverview( requireSuccessfulTool(secondOverview, "binary_overview"), "binary_overview after target switch", ); const secondAnalysis = await verifyCurrentTarget(client, options); 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)); const closedSession = jsonValue( await client.callTool({ name: "binary_session", arguments: {} }, options), ); if (closedSession.open !== false) throw new Error("The real session remained open after close_binary"); summary = { toolCount: actualNames.length, documentCount: firstDocuments.length, overview: firstOverview, segmentCount: firstOverview.segment_count, analyses: [firstAnalysis, secondAnalysis], bundledMcpRunning, stderrBytes, diagnosticCount, dynamicSession: true, targets: [targetA, targetB], targetHashes: [targetHashA, targetHashB], switched: true, secondOverview: verifiedSecondOverview, }; } 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(); }, ); }); async function sha256File(path) { const hash = createHash("sha256"); for await (const chunk of createReadStream(path)) hash.update(chunk); return hash.digest("hex"); }