diff --git a/scripts/lib/real-hopper-semantic.mjs b/scripts/lib/real-hopper-semantic.mjs index 9a2ae0c7e..4feba2678 100644 --- a/scripts/lib/real-hopper-semantic.mjs +++ b/scripts/lib/real-hopper-semantic.mjs @@ -1,5 +1,60 @@ import { requireFunctionDossierOracle } from "../../dist/application/RealHopperAssertions.js"; +/** Require truthful tri-state permissions from real Hopper memory regions. */ +export 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"); + } + } +}; + +/** Require correlated start and terminal progress from the real bridge. */ +export function requireBridgeProgress(updates) { + const messages = updates.map(({ message }) => message); + if ( + !messages.includes("Hopper bridge started request") || + !messages.includes("Hopper bridge completed request") + ) + throw new Error( + `The MCP client did not observe correlated Hopper bridge progress: ${JSON.stringify(updates)}`, + ); +} + +/** Require one sanitized invalid-request diagnostic from the real bridge. */ +export function requireBridgeDiagnostic(chunks) { + const diagnostics = chunks + .join("") + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line)); + if ( + !diagnostics.some( + (diagnostic) => + diagnostic.type === "bridge-diagnostic" && + diagnostic.category === "invalid_request" && + diagnostic.message === "Invalid Hopper bridge request", + ) + ) + throw new Error("The MCP runtime omitted the correlated Hopper diagnostic"); +} + /** Verify named positive semantics from the source-owned C fixture. */ export async function verifyRealHopperFixture({ client, diff --git a/scripts/verify-package-artifact.mjs b/scripts/verify-package-artifact.mjs index dff36f994..5acdf596c 100644 --- a/scripts/verify-package-artifact.mjs +++ b/scripts/verify-package-artifact.mjs @@ -1,11 +1,4 @@ -import { - access, - mkdir, - readFile, - readdir, - rm, - writeFile, -} from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { isDeepStrictEqual } from "node:util"; import { join } from "node:path"; import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; @@ -142,24 +135,6 @@ const verifyPackagedArtifactExtraction = async ({ (await readFile(join(outputRoot, "app/main.js"), "utf8")) !== "main();" ) throw new Error("packaged artifact extraction CLI failed"); - if ( - (await readdir(workspace)).some((path) => - path.startsWith(".artifact-extraction.rea-"), - ) - ) - throw new Error("packaged artifact extraction left staging paths"); - await rm(outputRoot, { recursive: true }); - if (await pathExists(outputRoot)) - throw new Error("packaged artifact extraction cleanup failed"); -}; - -const pathExists = async (path) => { - try { - await access(path); - return true; - } catch { - return false; - } }; const assertRoutedApplicationAnalysis = (analysis) => { diff --git a/scripts/verify-real-ghidra-assertions.mjs b/scripts/verify-real-ghidra-assertions.mjs index 3c6fd0cd7..7a72ecc37 100644 --- a/scripts/verify-real-ghidra-assertions.mjs +++ b/scripts/verify-real-ghidra-assertions.mjs @@ -67,6 +67,29 @@ export function assertDossier( assertDossierMultiBlock(dossier, requireMultiBlock); } +export function assertDenseSwitchDossier(dossier, address) { + assertDossier(dossier, { + address, + requireAssembly: true, + requireMultiBlock: true, + }); + const truncated = dossier.native_api.jump_tables.find( + ({ mappings_truncated: mappingsTruncated }) => mappingsTruncated, + ); + if ( + truncated === undefined || + truncated.mappings.length !== 32 || + truncated.mappings.some( + ({ target_address: target, evidence }) => + !/^0x[0-9a-f]+$/u.test(target) || + !evidence.some(({ kind }) => kind === "jump-table"), + ) + ) + throw new Error( + `Ghidra dense jump-table truncation drifted: ${JSON.stringify(dossier.native_api.jump_tables)}`, + ); +} + function assertNativeApiBoundary(observed, requireJumpTable) { if ( observed?.available !== true || diff --git a/scripts/verify-real-ghidra-function.mjs b/scripts/verify-real-ghidra-function.mjs index cb719e97e..d0f50a93a 100644 --- a/scripts/verify-real-ghidra-function.mjs +++ b/scripts/verify-real-ghidra-function.mjs @@ -8,6 +8,7 @@ import { } from "../dist/ghidra/GhidraInventoryValues.js"; import { + assertDenseSwitchDossier, assertDossier, assertLocalProcedureInfo, assertProviderText, @@ -59,6 +60,10 @@ export async function verifyDebugFunctionOperations( procedures, "rea_ghidra_inventory_switch", ); + const denseSwitchProcedure = requireProcedure( + procedures, + "rea_ghidra_inventory_dense_switch", + ); const main = requireProcedure(procedures, "main"); const entryString = strings.find( (item) => item.value === "REA_GHIDRA_INVENTORY_ENTRY", @@ -111,6 +116,14 @@ export async function verifyDebugFunctionOperations( const branchDossier = await analyzeBranchDossier(client, branch); const indirectDossier = await analyzeIndirectDossier(client, indirect); const switchDossier = await analyzeSwitchDossier(client, switchProcedure); + const denseSwitchDossier = await functionCall(client, "analyze_function", { + procedure: denseSwitchProcedure.address, + include_assembly: true, + limit: 500, + max_pseudocode_chars: 100_000, + max_instructions: 5_000, + }); + assertDenseSwitchDossier(denseSwitchDossier, denseSwitchProcedure.address); assertIndirectDossier(indirectDossier, leaf.address); const { cancellation, timeout } = await verifyRequestCancellationAndTimeout( @@ -124,6 +137,7 @@ export async function verifyDebugFunctionOperations( branch: dossierSummary(branchDossier), indirect: dossierSummary(indirectDossier), switch: dossierSummary(switchDossier), + dense_switch: dossierSummary(denseSwitchDossier), instruction_window: { returned: instructionWindow.instructions.returned, truncated: instructionWindow.instructions.truncated, diff --git a/scripts/verify-real-ghidra.mjs b/scripts/verify-real-ghidra.mjs index fd8638144..b8c4aca52 100644 --- a/scripts/verify-real-ghidra.mjs +++ b/scripts/verify-real-ghidra.mjs @@ -127,6 +127,7 @@ try { architecture: "x86_64", }); assertDebugFixture(debug); + const nativeApiCli = await verifyNativeApiCli(debugPath); const stripped = await verifyTarget(strippedPath, "stripped", { format: "elf", architecture: "x86_64", @@ -166,6 +167,7 @@ try { summary(machObject), ], malformed_target: "rejected-before-provider-start", + native_api_cli: nativeApiCli, custom_target: custom === null ? null : summary(custom), cleanup: "complete", })}\n`, @@ -174,6 +176,52 @@ try { await rm(fixtureRoot, { recursive: true, force: true }); } +async function verifyNativeApiCli(targetPath) { + const { stdout } = await exec( + process.execPath, + [ + "scripts/rea.mjs", + "inspect-native-api", + targetPath, + "rea_ghidra_inventory_dense_switch", + "--provider", + "ghidra", + "--json", + ], + { + cwd: process.cwd(), + env: process.env, + maxBuffer: 2 * 1024 * 1024, + }, + ); + const evidence = JSON.parse(stdout); + const boundary = evidence.normalized_result?.boundary; + const truncated = boundary?.jump_tables?.find( + ({ mappings_truncated: mappingsTruncated }) => mappingsTruncated === true, + ); + const omittedQuestion = + "Which additional data sources or targets were omitted"; + if ( + evidence.operation !== "inspect_native_api" || + evidence.provider?.id !== "rea-workflow" || + evidence.analysis_profile?.provider?.id !== "rea-workflow" || + boundary?.available !== true || + truncated?.mappings?.length !== 32 || + !evidence.normalized_result?.residual_unknowns?.some((question) => + question.includes(omittedQuestion), + ) + ) + throw new Error( + `The shipped inspect-native-api CLI did not preserve dense jump-table truncation: ${stdout}`, + ); + return { + operation: evidence.operation, + provider: evidence.provider, + mappings_returned: truncated.mappings.length, + residual_unknowns: evidence.normalized_result.residual_unknowns.length, + }; +} + async function verifyTarget(targetPath, variant, expectedTarget = null) { const parsedTarget = await parseBinaryTarget(targetPath); if (!parsedTarget.ok) throw parsedTarget.error; diff --git a/scripts/verify-real-hopper.mjs b/scripts/verify-real-hopper.mjs index 847ce498b..c58c8cfc1 100644 --- a/scripts/verify-real-hopper.mjs +++ b/scripts/verify-real-hopper.mjs @@ -16,8 +16,11 @@ import { HOPPER_PROVIDER_IDENTITY } from "../dist/hopper/HopperProvider.js"; import { REA_WORKFLOW_PROVIDER } from "../dist/application/InvestigationProviders.js"; import { loadRealHopperFixtureTargets } from "./lib/real-hopper-fixture.mjs"; import { + requireBridgeDiagnostic, + requireBridgeProgress, requireCurrentDocument, requireSafeDiagnostics, + requireTruthfulMemoryRegions, verifyRealHopperFixture, } from "./lib/real-hopper-semantic.mjs"; import { @@ -99,30 +102,6 @@ const requireOverview = (value, operation) => { 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"]) { @@ -312,7 +291,11 @@ try { ); } - const options = { timeout }; + const progressUpdates = []; + const options = { + timeout, + onprogress: (update) => progressUpdates.push(update), + }; const fullSessionStatus = () => client.callTool( { name: "binary_session", arguments: { detail: "full" } }, @@ -343,6 +326,16 @@ try { { name: "list_documents", arguments: {} }, options, ); + requireBridgeProgress(progressUpdates); + const rejectedProcedure = await client.callTool( + { + name: "procedure_info", + arguments: { procedure: "__rea_missing_procedure__" }, + }, + options, + ); + if (rejectedProcedure.isError !== true) + throw new Error("The real Hopper bridge accepted an unknown procedure"); const overview = await client.callTool( { name: "binary_overview", arguments: {} }, options, @@ -435,6 +428,7 @@ try { ); } const diagnosticCount = requireSafeDiagnostics(stderrChunks); + requireBridgeDiagnostic(stderrChunks); const closed = await client.callTool( { name: "close_binary", arguments: {} }, @@ -460,6 +454,7 @@ try { bundledMcpRunning, stderrBytes, diagnosticCount, + bridgeProgressUpdates: progressUpdates.length, dynamicSession: true, providerBinding, targets: [targetA, targetB, largeTarget], diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index a7f641cae..6205796d4 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -233,6 +233,8 @@ export class HopperProvider implements AnalysisProviderCandidate { }), ...(context === undefined ? {} : { runId: context.runId }), logger: this.logger.child({ layer: "bridge" }), + onDiagnostic: (diagnostic) => + this.logger.info(diagnostic, "Hopper bridge reported a diagnostic"), }); return { execute: async (operation, parameters, options) => { diff --git a/tests/conformance/ghidra/inventory.c b/tests/conformance/ghidra/inventory.c index 4cac9de09..343e66f4b 100644 --- a/tests/conformance/ghidra/inventory.c +++ b/tests/conformance/ghidra/inventory.c @@ -43,10 +43,58 @@ __attribute__((noinline, used)) int rea_ghidra_inventory_switch( } } +__attribute__((noinline, used)) int rea_ghidra_inventory_dense_switch( + unsigned int selector, int value) { + switch (selector) { + case 0: return rea_ghidra_inventory_leaf(value + 0); + case 1: return rea_ghidra_inventory_leaf(value + 1); + case 2: return rea_ghidra_inventory_leaf(value + 2); + case 3: return rea_ghidra_inventory_leaf(value + 3); + case 4: return rea_ghidra_inventory_leaf(value + 4); + case 5: return rea_ghidra_inventory_leaf(value + 5); + case 6: return rea_ghidra_inventory_leaf(value + 6); + case 7: return rea_ghidra_inventory_leaf(value + 7); + case 8: return rea_ghidra_inventory_leaf(value + 8); + case 9: return rea_ghidra_inventory_leaf(value + 9); + case 10: return rea_ghidra_inventory_leaf(value + 10); + case 11: return rea_ghidra_inventory_leaf(value + 11); + case 12: return rea_ghidra_inventory_leaf(value + 12); + case 13: return rea_ghidra_inventory_leaf(value + 13); + case 14: return rea_ghidra_inventory_leaf(value + 14); + case 15: return rea_ghidra_inventory_leaf(value + 15); + case 16: return rea_ghidra_inventory_leaf(value + 16); + case 17: return rea_ghidra_inventory_leaf(value + 17); + case 18: return rea_ghidra_inventory_leaf(value + 18); + case 19: return rea_ghidra_inventory_leaf(value + 19); + case 20: return rea_ghidra_inventory_leaf(value + 20); + case 21: return rea_ghidra_inventory_leaf(value + 21); + case 22: return rea_ghidra_inventory_leaf(value + 22); + case 23: return rea_ghidra_inventory_leaf(value + 23); + case 24: return rea_ghidra_inventory_leaf(value + 24); + case 25: return rea_ghidra_inventory_leaf(value + 25); + case 26: return rea_ghidra_inventory_leaf(value + 26); + case 27: return rea_ghidra_inventory_leaf(value + 27); + case 28: return rea_ghidra_inventory_leaf(value + 28); + case 29: return rea_ghidra_inventory_leaf(value + 29); + case 30: return rea_ghidra_inventory_leaf(value + 30); + case 31: return rea_ghidra_inventory_leaf(value + 31); + case 32: return rea_ghidra_inventory_leaf(value + 32); + case 33: return rea_ghidra_inventory_leaf(value + 33); + case 34: return rea_ghidra_inventory_leaf(value + 34); + case 35: return rea_ghidra_inventory_leaf(value + 35); + case 36: return rea_ghidra_inventory_leaf(value + 36); + case 37: return rea_ghidra_inventory_leaf(value + 37); + case 38: return rea_ghidra_inventory_leaf(value + 38); + case 39: return rea_ghidra_inventory_leaf(value + 39); + default: return -1; + } +} + __attribute__((noinline, used)) int rea_ghidra_inventory_entry(void) { puts("REA_GHIDRA_INVENTORY_ENTRY"); return rea_ghidra_inventory_branch(35) + - rea_ghidra_inventory_indirect(rea_ghidra_inventory_leaf, 0); + rea_ghidra_inventory_indirect(rea_ghidra_inventory_leaf, 0) + + rea_ghidra_inventory_dense_switch(100, 0) + 1; } int main(void) { return rea_ghidra_inventory_entry() == 49 ? 0 : 1; } diff --git a/tests/fixtures/ghidraFunction.ts b/tests/fixtures/ghidraFunction.ts index 67efa5969..73ed36753 100644 --- a/tests/fixtures/ghidraFunction.ts +++ b/tests/fixtures/ghidraFunction.ts @@ -48,7 +48,7 @@ export const ghidraBounded = (items: readonly JsonValue[]) => ({ next_offset: null, }); -export const ghidraNativeApiBoundary = () => ({ +export const ghidraNativeApiBoundary = (mappingsTruncated = false) => ({ available: true as const, provenance: "ghidra-high-function", signature_source: "analysis", @@ -115,7 +115,7 @@ export const ghidraNativeApiBoundary = () => ({ ], }, ], - mappings_truncated: false, + mappings_truncated: mappingsTruncated, limitations: [], }, ], @@ -136,7 +136,10 @@ export const ghidraNativeApiBoundary = () => ({ ], }); -export const ghidraFunctionDossier = (includeAssembly = true): JsonValue => { +export const ghidraFunctionDossier = ( + includeAssembly = true, + mappingsTruncated = false, +): JsonValue => { const pseudocode = "int fixture_main(void) { return 42; }"; return { procedure: { @@ -171,7 +174,7 @@ export const ghidraFunctionDossier = (includeAssembly = true): JsonValue => { { start: "0x401000", end: "0x401006", successors: [] }, ]), instruction_scan: { scanned: 2, truncated: false }, - native_api: ghidraNativeApiBoundary(), + native_api: ghidraNativeApiBoundary(mappingsTruncated), limitations: [ "Unresolved computed or indirect flows without target addresses are not represented as reference edges.", "Thunk and external classifications are Ghidra FunctionManager observations; they do not resolve targetless calls.", diff --git a/tests/ghidraMcp.test.ts b/tests/ghidraMcp.test.ts index fc2d389ae..441b2cb34 100644 --- a/tests/ghidraMcp.test.ts +++ b/tests/ghidraMcp.test.ts @@ -29,46 +29,9 @@ const INSTALL = "/opt/ghidra_12.1.2_PUBLIC"; describe("Ghidra MCP and shared CLI composition", () => { it("preserves provider evidence, composed parity, and capability routing", async () => { - const calls: GhidraOperation[] = []; - const factory: GhidraProviderClientFactory = (options) => ({ - start: () => - Promise.resolve( - ok(sessionInfo(options.profileDigest, options.targetSha256)), - ), - callTool: (operation, input) => { - calls.push(operation); - return Promise.resolve(ok(resultFor(operation, input))); - }, - close: () => Promise.resolve(), - }); - const config = parseConfig({ GHIDRA_INSTALL_DIR: INSTALL }); - if (!config.ok) throw config.error; - const provider = new GhidraProvider( - config.value, - silentLogger, - installationHost(), - factory, - ); - const session = new BinarySession( - SessionProviderRouter.selectable( - new AnalysisProviderRegistry([provider]), - [], - ), - ); - const server = createServer(session, session, { logger: silentLogger }); - const mcp = new Client({ name: "ghidra-parity", version: "1.0.0" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - + const harness = await connectGhidraMcp("ghidra-parity"); + const { calls, mcp, session } = harness; try { - await server.connect(serverTransport); - await mcp.connect(clientTransport); - const opened = await mcp.callTool({ - name: "open_binary", - arguments: { path: process.execPath }, - }); - expect(opened.isError).not.toBe(true); - const listed = sessionEvidence( session, ( @@ -269,12 +232,147 @@ describe("Ghidra MCP and shared CLI composition", () => { }, }); } finally { - await Promise.allSettled([mcp.close(), server.close()]); - await session.close(); + await harness.close(); + } + }, 10_000); + + it("records approved native API truncation once and links its evidence", async () => { + const harness = await connectGhidraMcp("ghidra-native-api"); + const { mcp, session } = harness; + try { + const unapprovedInspection = sessionEvidence( + session, + ( + await mcp.callTool({ + name: "inspect_native_api", + arguments: { procedure: "fixture_truncated" }, + }) + ).structuredContent, + ); + expect(unapprovedInspection.normalized_result).toMatchObject({ + boundary: { + available: true, + jump_tables: [{ mappings_truncated: true }], + }, + residual_unknowns: [ + expect.stringContaining("additional data sources or targets"), + ], + }); + expect( + ( + await mcp.callTool({ + name: "list_unknowns", + arguments: {}, + }) + ).structuredContent, + ).toMatchObject({ result: [] }); + + const approvedInspection = sessionEvidence( + session, + ( + await mcp.callTool({ + name: "inspect_native_api", + arguments: { + procedure: "fixture_truncated", + unknown_registry_approved: true, + }, + }) + ).structuredContent, + ); + await mcp.callTool({ + name: "inspect_native_api", + arguments: { + procedure: "fixture_truncated", + unknown_registry_approved: true, + }, + }); + expect( + ( + await mcp.callTool({ + name: "list_unknowns", + arguments: {}, + }) + ).structuredContent, + ).toMatchObject({ + result: [ + { + status: "open", + domain: "native-api", + question: expect.stringContaining( + "additional data sources or targets", + ), + supporting_evidence_ids: [approvedInspection.evidence_id], + recommended_probes: [ + { + operation: "inspect_native_api", + rationale: expect.stringContaining("ABI probe"), + }, + ], + }, + ], + }); + } finally { + await harness.close(); } }, 10_000); }); +const connectGhidraMcp = async (name: string) => { + const calls: GhidraOperation[] = []; + const factory: GhidraProviderClientFactory = (options) => ({ + start: () => + Promise.resolve( + ok(sessionInfo(options.profileDigest, options.targetSha256)), + ), + callTool: (operation, input) => { + calls.push(operation); + return Promise.resolve(ok(resultFor(operation, input))); + }, + close: () => Promise.resolve(), + }); + const config = parseConfig({ GHIDRA_INSTALL_DIR: INSTALL }); + if (!config.ok) throw config.error; + const provider = new GhidraProvider( + config.value, + silentLogger, + installationHost(), + factory, + ); + const session = new BinarySession( + SessionProviderRouter.selectable( + new AnalysisProviderRegistry([provider]), + [], + ), + ); + const server = createServer(session, session, { logger: silentLogger }); + const mcp = new Client({ name, version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const close = async () => { + await Promise.allSettled([mcp.close(), server.close()]); + await session.close(); + }; + try { + await server.connect(serverTransport); + await mcp.connect(clientTransport); + const opened = await mcp.callTool({ + name: "open_binary", + arguments: { path: process.execPath }, + }); + if (opened.isError === true) + throw new Error("The Ghidra MCP harness could not open its target"); + } catch (cause: unknown) { + await close(); + throw cause; + } + return { + calls, + mcp, + session, + close, + }; +}; + const sessionEvidence = (session: BinarySession, value: unknown) => { if ( typeof value !== "object" || @@ -453,7 +551,11 @@ const resultBuilders = new Map([ ["xrefs", () => ["0x401001"]], [ "analyze_function", - (input, _limit) => ghidraFunctionDossier(input.include_assembly === true), + (input, _limit) => + ghidraFunctionDossier( + input.include_assembly === true, + input.procedure === "fixture_truncated", + ), ], ]);