test: add critical provider workflow coverage
This commit is contained in:
parent
521139d4a5
commit
4716fa2ece
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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<GhidraOperation, GhidraResultBuilder>([
|
|||
["xrefs", () => ["0x401001"]],
|
||||
[
|
||||
"analyze_function",
|
||||
(input, _limit) => ghidraFunctionDossier(input.include_assembly === true),
|
||||
(input, _limit) =>
|
||||
ghidraFunctionDossier(
|
||||
input.include_assembly === true,
|
||||
input.procedure === "fixture_truncated",
|
||||
),
|
||||
],
|
||||
]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue