test: add critical provider workflow coverage

This commit is contained in:
morluto 2026-07-25 15:12:51 +00:00
parent 521139d4a5
commit 4716fa2ece
10 changed files with 363 additions and 98 deletions

View File

@ -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,

View File

@ -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) => {

View File

@ -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 ||

View File

@ -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,

View File

@ -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;

View File

@ -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],

View File

@ -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) => {

View File

@ -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; }

View File

@ -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.",

View File

@ -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",
),
],
]);