mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 11:13:21 +00:00
* refactor: prune internal seams and preserve configuration ownership * fix(setup): keep YAML aliases and inherited registration settings on refresh - Reuse an unchanged YAML collection only while every alias in it still resolves after the edit; otherwise rebuild it so the alias becomes its value. A sibling anchor replaced or moved in the same refresh no longer leaves an unresolved alias in Hermes or Goose output. - Keep a connection selector whose existing value already chooses stdio (for example an inherited Hermes transport: stdio) instead of superseding it; remote selectors are still replaced. Explicit stdio selectors are therefore no longer rewritten (Kimi expectation updated). - When the whole Hermes registration is inherited through a merge, override it with that merge and write only the changed fields. * fix(setup): edit registration fields whose YAML key is an alias A key written as an alias to a string scalar (for example *field with field: &field env) was treated as a source-only key, so any change to its value was refused in Hermes and Goose. Resolve such keys to their string, keep the aliased spelling while its anchor survives the edit, and write the plain key when the same edit rebuilds the anchor. * test(process): bound cleanup-boundary waits and always release gates Both cancellation tests awaited a bare started promise and released their gates only on success, so a missed boundary waited for the outer deadline and could leave cleanup parked. Use awaitAcknowledgement and release and join the cleanup in finally. * fix(setup): repair only aliases an edit leaves unresolved Replace the pre-edit alias survival guess, which expanded aliases to sibling anchors that survive unchanged, with an exact repair: record what each alias in the replaced value resolves to, write the edit, then replace only aliases left without a preceding anchor by that value. - Keep an aliased key's source position, so its anchor stays ahead of later aliases. - Treat an aliased key as its string only when no other key in the map resolves to the same string; an ambiguous one stays guarded. - Override an inherited Hermes registration only when the requested entry keeps every inherited field, so an explicit replacement can drop remote selectors. * fix(setup): repair aliases whose binding an edit changes, keeping comments An alias whose own anchor was removed could resolve to an earlier anchor with the same name and silently change value. Record the node each alias resolved to and repair any alias that no longer resolves to that node. Carry the alias's comments and spacing onto the repaired value. * test(setup): split the Hermes anchor and merge refresh cases * fix(setup): refuse removing an overridden key its merge would restore The merge-retention check skipped keys the mapping also overrides, so dropping such a key (for example a remote url during an explicit stdio replacement) let the retained merge restore it. Check removal before skipping overridden keys, so the edit is refused with the existing message instead of producing a registration that cannot be applied.
932 lines
31 KiB
TypeScript
932 lines
31 KiB
TypeScript
import { writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|
import type { CallToolResult } from "@modelcontextprotocol/server";
|
|
import { afterAll, expect, it, onTestFinished, vi } from "vitest";
|
|
import { z } from "zod";
|
|
import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "../../../src/contracts/analysisViewToolContracts.js";
|
|
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisViewResult.js";
|
|
import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js";
|
|
import {
|
|
createEvidence,
|
|
parseEvidence,
|
|
type Evidence,
|
|
} from "../../../src/domain/evidence.js";
|
|
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
|
import { jsonObjectSchema } from "../../../src/domain/jsonValue.js";
|
|
import { batchDecompileResultSchema } from "../../../src/domain/native/batchDecompile.js";
|
|
import { err, ok } from "../../../src/domain/result.js";
|
|
import { GhidraSessionError } from "../../../src/ghidra/GhidraSessionError.js";
|
|
import { createServer } from "../../../src/server/createServer.js";
|
|
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
|
|
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
|
|
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
|
import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js";
|
|
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import {
|
|
closeEnhancedToolResources,
|
|
connect as connectFixedAnalysis,
|
|
} from "./enhancedToolsHarness.js";
|
|
import { connectGhidraMcp, ghidraFixtureResult } from "./ghidraMcpHarness.js";
|
|
|
|
const MAIN_BODY = "int main(void) { return 0; }";
|
|
const EMOJI_BODY = `int emoji(void) { return "${"\u{1F600}".repeat(4)}"; }`;
|
|
const NO_BODY_REASON = "Thunk has no decompilable body.";
|
|
|
|
/** Selectors exercising a duplicate, an interior address, no body and no identity. */
|
|
const SELECTORS = ["main", "0x401005", "main", "emoji", "bodyless", "missing"];
|
|
const ENTRIES: Readonly<Record<string, string>> = {
|
|
main: "0x401000",
|
|
"0x401005": "0x401000",
|
|
emoji: "0x402000",
|
|
bodyless: "0x403000",
|
|
unlabeled: "0x404000",
|
|
"label-failure": "0x405000",
|
|
};
|
|
const LABELS: Readonly<Record<string, string>> = {
|
|
"0x401000": "main",
|
|
"0x402000": "emoji",
|
|
"0x403000": "bodyless",
|
|
};
|
|
const BODIES: Readonly<Record<string, string>> = {
|
|
"0x401000": MAIN_BODY,
|
|
"0x402000": EMOJI_BODY,
|
|
};
|
|
|
|
const provider = (calls: { count: number }, bodies: typeof BODIES) =>
|
|
((operation, input) => {
|
|
calls.count++;
|
|
if (operation === "procedure_address") {
|
|
const selector = String(input.procedure);
|
|
const entry = ENTRIES[selector];
|
|
return Promise.resolve(
|
|
entry === undefined
|
|
? err(
|
|
new GhidraSessionError(
|
|
"remote",
|
|
`no function: ${selector}`,
|
|
{},
|
|
{ remoteCode: "not_found" },
|
|
),
|
|
)
|
|
: ok(entry),
|
|
);
|
|
}
|
|
if (operation === "address_name")
|
|
return Promise.resolve(
|
|
input.address === "0x405000"
|
|
? err(new GhidraSessionError("remote", "label lookup failed"))
|
|
: ok(LABELS[String(input.address)] ?? null),
|
|
);
|
|
if (operation === "procedure_pseudo_code") {
|
|
const body = bodies[String(input.procedure)];
|
|
return Promise.resolve(
|
|
ok(
|
|
body === undefined
|
|
? { value: null, limitations: [NO_BODY_REASON] }
|
|
: { value: body, limitations: [] },
|
|
),
|
|
);
|
|
}
|
|
return Promise.resolve(ok(ghidraFixtureResult(operation, input)));
|
|
}) satisfies NonNullable<Parameters<typeof connectGhidraMcp>[1]>;
|
|
|
|
const [inspectAnalysisViewContract] = ANALYSIS_VIEW_TOOL_CONTRACTS.filter(
|
|
({ name }) => name === "inspect_analysis_view",
|
|
);
|
|
if (inspectAnalysisViewContract === undefined)
|
|
throw new Error("Missing inspect_analysis_view contract");
|
|
|
|
const schemaValidation = createMcpSchemaValidation();
|
|
afterAll(() => schemaValidation.clear());
|
|
|
|
/** Replies these tools deliver must validate against what they advertise. */
|
|
const ADVERTISED_OUTPUTS = ["batch_decompile", "inspect_analysis_view"];
|
|
|
|
const connect = async (
|
|
profile: "full" | "compact",
|
|
options: {
|
|
readonly budget?: number;
|
|
readonly bodies?: typeof BODIES;
|
|
readonly execute?: Parameters<typeof connectGhidraMcp>[1];
|
|
} = {},
|
|
) => {
|
|
const calls = { count: 0 };
|
|
const harness = await connectGhidraMcp(
|
|
`batch-decompile-views-${profile}`,
|
|
options.execute ?? provider(calls, options.bodies ?? BODIES),
|
|
{
|
|
environment: {
|
|
REA_MCP_INPUT_SCHEMA_PROFILE: profile,
|
|
REA_MCP_OUTPUT_SCHEMA_PROFILE: profile,
|
|
},
|
|
schemaValidator: schemaValidation,
|
|
...(options.budget === undefined
|
|
? {}
|
|
: { delivery: createToolResultDelivery(options.budget) }),
|
|
},
|
|
);
|
|
onTestFinished(() => harness.close());
|
|
const { tools } = await harness.mcp.listTools();
|
|
const advertisedTools = new Map(
|
|
ADVERTISED_OUTPUTS.map((name) => {
|
|
const advertised = tools.find((tool) => tool.name === name);
|
|
if (advertised?.outputSchema === undefined)
|
|
throw new Error(`${name} advertises no output schema`);
|
|
return [name, advertised] as const;
|
|
}),
|
|
);
|
|
const reply = async (name: string, args: Record<string, unknown>) => {
|
|
const toolDefinition = advertisedTools.get(name);
|
|
return z
|
|
.custom<CallToolResult>()
|
|
.parse(
|
|
await harness.mcp.callTool(
|
|
{ name, arguments: args },
|
|
toolDefinition === undefined ? {} : { toolDefinition },
|
|
),
|
|
);
|
|
};
|
|
const call = async (name: string, args: Record<string, unknown>) => {
|
|
const result = await reply(name, args);
|
|
expect(result.isError, JSON.stringify(result)).not.toBe(true);
|
|
return { evidence: parseEvidence(result.structuredContent), result };
|
|
};
|
|
return { ...harness, calls, reply, call };
|
|
};
|
|
|
|
type Harness = Awaited<ReturnType<typeof connect>>;
|
|
|
|
const viewOf = (evidence: Evidence) =>
|
|
z.looseObject({}).parse(evidence.normalized_result);
|
|
|
|
const retained = (evidenceId: string) => ({
|
|
kind: "retained-evidence",
|
|
evidence_id: evidenceId,
|
|
});
|
|
|
|
const view = async (
|
|
{ call }: Harness,
|
|
parentId: string,
|
|
selected: Record<string, unknown>,
|
|
) =>
|
|
viewOf(
|
|
(
|
|
await call("inspect_analysis_view", {
|
|
source: retained(parentId),
|
|
view: { kind: "batch-decompile", ...selected },
|
|
})
|
|
).evidence,
|
|
);
|
|
|
|
const rowIndices = (page: Record<string, unknown>) =>
|
|
z
|
|
.array(z.looseObject({ index: z.number() }))
|
|
.parse(page.items)
|
|
.map(({ index }) => index);
|
|
|
|
/** Read every text page of one retained body in order. */
|
|
const readBody = async (
|
|
harness: Harness,
|
|
parentId: string,
|
|
index: number,
|
|
limit?: number,
|
|
) => {
|
|
let text = "";
|
|
let pages = 0;
|
|
const nextOffsets: (number | null)[] = [];
|
|
let offset: number | null = 0;
|
|
while (offset !== null) {
|
|
const page = await view(harness, parentId, {
|
|
collection: "pseudocode",
|
|
index,
|
|
offset,
|
|
...(limit === undefined ? {} : { limit }),
|
|
});
|
|
text += z.looseObject({ text: z.string() }).parse(page.item).text;
|
|
offset = z
|
|
.looseObject({ next_offset: z.number().nullable() })
|
|
.parse(page.coverage).next_offset;
|
|
nextOffsets.push(offset);
|
|
pages++;
|
|
}
|
|
return { text, pages, nextOffsets };
|
|
};
|
|
|
|
/** A typed refusal whose issue names the rejected selection. */
|
|
const invalidIssue = (message: string) => ({
|
|
code: "invalid_request",
|
|
details: {
|
|
issues: expect.arrayContaining([
|
|
expect.objectContaining({ message: expect.stringContaining(message) }),
|
|
]),
|
|
},
|
|
});
|
|
|
|
it.each(["full", "compact"] as const)(
|
|
"selects retained batch rows and bodies from a summary without repeating analysis (%s)",
|
|
async (profile) => {
|
|
const harness = await connect(profile);
|
|
const reply = await harness.call("batch_decompile", {
|
|
addresses: SELECTORS,
|
|
detail: "summary",
|
|
});
|
|
const summary = viewOf(reply.evidence);
|
|
const parentId = z.string().parse(summary.parent_evidence_id);
|
|
const parent = parseEvidence(harness.session.evidenceById(parentId));
|
|
|
|
// The selection never reaches the record, so the parent is the same analysis.
|
|
expect(parent.parameters).toEqual({ addresses: SELECTORS });
|
|
expect(
|
|
batchDecompileResultSchema.parse(parent.normalized_result),
|
|
).toMatchObject({ total: 6, succeeded: 4, failed: 2 });
|
|
expect(JSON.stringify(reply.result)).not.toContain("int main");
|
|
expect(reply.evidence.evidence_links).toEqual([parentId]);
|
|
expect(reply.evidence.limitations).toEqual(
|
|
expect.arrayContaining(parent.limitations),
|
|
);
|
|
expect(summary).toMatchObject({
|
|
kind: "summary",
|
|
parent_operation: "batch_decompile",
|
|
summary: {
|
|
batch: { total: 6, succeeded: 4, failed: 2 },
|
|
source_provider: parent.provider,
|
|
source_analysis_profile_digest: parent.analysis_profile?.digest,
|
|
decompiling_provider: { id: "ghidra", version: "12.1.4" },
|
|
procedures: {
|
|
distinct_resolved: 3,
|
|
unknown_identity: 1,
|
|
label_unavailable: 0,
|
|
},
|
|
pseudocode: {
|
|
code_units: MAIN_BODY.length * 3 + EMOJI_BODY.length,
|
|
largest: { index: 3, code_units: EMOJI_BODY.length },
|
|
},
|
|
},
|
|
});
|
|
expect(summary.unknowns).toEqual([
|
|
expect.stringContaining("1 items have unknown procedure identity"),
|
|
expect.stringContaining("Evidence limitations aggregate"),
|
|
]);
|
|
const callsAfterBatch = harness.calls.count;
|
|
|
|
// The same summary is reachable later through the retained parent.
|
|
expect(
|
|
viewOf(
|
|
(
|
|
await harness.call("inspect_analysis_view", {
|
|
source: retained(parentId),
|
|
view: { kind: "summary" },
|
|
})
|
|
).evidence,
|
|
).summary,
|
|
).toEqual(summary.summary);
|
|
|
|
// The default page is an index without bodies.
|
|
const index = await view(harness, parentId, { collection: "items" });
|
|
expect(JSON.stringify(index.items)).not.toContain("int main");
|
|
expect(index).toMatchObject({
|
|
batch: { total: 6, succeeded: 4, failed: 2 },
|
|
coverage: { total: 6, exhausted: true },
|
|
});
|
|
expect(index.items).toMatchObject([
|
|
{
|
|
index: 0,
|
|
address: "main",
|
|
procedure: { status: "resolved", address: "0x401000", name: "main" },
|
|
status: "ok",
|
|
pseudocode_code_units: MAIN_BODY.length,
|
|
},
|
|
{ index: 1, address: "0x401005" },
|
|
{ index: 2, address: "main" },
|
|
{ index: 3, address: "emoji" },
|
|
{
|
|
index: 4,
|
|
status: "unavailable",
|
|
reason: "no_pseudocode",
|
|
limitations: expect.arrayContaining([NO_BODY_REASON]),
|
|
},
|
|
{ index: 5, procedure: { status: "unknown" } },
|
|
]);
|
|
|
|
// Filters are exact, compose, and keep original indices.
|
|
for (const [where, indices] of [
|
|
[{ addresses: ["main"] }, [0, 2]],
|
|
[{ procedure_addresses: ["0x401000"] }, [0, 1, 2]],
|
|
// A selector never matches as an observed entry, including an unknown one.
|
|
[{ procedure_addresses: ["missing"] }, []],
|
|
[{ statuses: ["unavailable"] }, [4, 5]],
|
|
[{ statuses: ["error"] }, []],
|
|
[{ indices: [5, 3, 3] }, [3, 5]],
|
|
[{ statuses: ["ok"], procedure_addresses: ["0x402000"] }, [3]],
|
|
[{ indices: [] }, []],
|
|
[{ addresses: [] }, []],
|
|
[{ procedure_addresses: [] }, []],
|
|
[{ statuses: [] }, []],
|
|
] as const)
|
|
expect(
|
|
rowIndices(
|
|
await view(harness, parentId, { collection: "items", where }),
|
|
),
|
|
JSON.stringify(where),
|
|
).toEqual(indices);
|
|
|
|
// Coverage counts matches rather than the whole batch.
|
|
const paged = await view(harness, parentId, {
|
|
collection: "items",
|
|
where: { statuses: ["ok"] },
|
|
offset: 1,
|
|
limit: 2,
|
|
});
|
|
expect(rowIndices(paged)).toEqual([1, 2]);
|
|
expect(paged.coverage).toMatchObject({
|
|
status: "page",
|
|
examined: 2,
|
|
total: 4,
|
|
next_offset: 3,
|
|
exhausted: false,
|
|
});
|
|
|
|
// Bodies are opt-in, and only successful items have one.
|
|
const selected = await view(harness, parentId, {
|
|
collection: "items",
|
|
where: { indices: [3, 4] },
|
|
include_pseudocode: true,
|
|
});
|
|
expect(selected.items).toMatchObject([
|
|
{ index: 3, pseudocode: EMOJI_BODY },
|
|
{ index: 4, status: "unavailable" },
|
|
]);
|
|
expect(JSON.stringify(selected.items)).not.toContain('"pseudocode":null');
|
|
|
|
expect(harness.calls.count).toBe(callsAfterBatch);
|
|
expect(harness.session.evidenceById(parentId)).toEqual(parent);
|
|
},
|
|
);
|
|
|
|
it("counts both forms of an unavailable entry label", async () => {
|
|
const harness = await connect("full");
|
|
const parent = (
|
|
await harness.call("batch_decompile", {
|
|
addresses: ["main", "unlabeled", "label-failure"],
|
|
})
|
|
).evidence.evidence_id;
|
|
const summary = viewOf(
|
|
(
|
|
await harness.call("inspect_analysis_view", {
|
|
source: retained(parent),
|
|
view: { kind: "summary" },
|
|
})
|
|
).evidence,
|
|
);
|
|
expect(summary.summary).toMatchObject({
|
|
procedures: {
|
|
distinct_resolved: 3,
|
|
unknown_identity: 0,
|
|
label_unavailable: 2,
|
|
},
|
|
});
|
|
const { items } = await view(harness, parent, { collection: "items" });
|
|
expect(items).toMatchObject([
|
|
{ procedure: { name: "main" } },
|
|
{ procedure: { name: null } },
|
|
{ procedure: { name: null, name_error: { code: expect.any(String) } } },
|
|
]);
|
|
expect(JSON.stringify(z.array(z.unknown()).parse(items)[1])).not.toContain(
|
|
"name_error",
|
|
);
|
|
});
|
|
|
|
it("pages one body exactly without splitting a Unicode character", async () => {
|
|
const harness = await connect("full");
|
|
const { evidence } = await harness.call("batch_decompile", {
|
|
addresses: SELECTORS,
|
|
});
|
|
const parent = evidence.evidence_id;
|
|
// The complete delivery still carries every body by default.
|
|
expect(JSON.stringify(evidence.normalized_result)).toContain("int main");
|
|
|
|
// A limit ending inside a surrogate pair stops before it, so pages rebuild the text.
|
|
const { text, pages, nextOffsets } = await readBody(harness, parent, 3, 31);
|
|
expect(nextOffsets[0]).toBe(30);
|
|
expect(text).toBe(EMOJI_BODY);
|
|
expect(pages).toBeGreaterThan(1);
|
|
expect(
|
|
await view(harness, parent, { collection: "pseudocode", index: 3 }),
|
|
).toMatchObject({
|
|
item: {
|
|
index: 3,
|
|
address: "emoji",
|
|
procedure: { status: "resolved", address: "0x402000" },
|
|
pseudocode_code_units: EMOJI_BODY.length,
|
|
unit: "utf16-code-units",
|
|
},
|
|
coverage: { total: EMOJI_BODY.length, exhausted: true },
|
|
});
|
|
|
|
const refused = async (selected: Record<string, unknown>) =>
|
|
parseMcpToolError(
|
|
await harness.reply("inspect_analysis_view", {
|
|
source: retained(parent),
|
|
view: {
|
|
kind: "batch-decompile",
|
|
collection: "pseudocode",
|
|
...selected,
|
|
},
|
|
}),
|
|
).error;
|
|
expect(await refused({ index: 4 })).toMatchObject(
|
|
invalidIssue("Item 4 has status unavailable"),
|
|
);
|
|
expect(await refused({ index: 6 })).toMatchObject(
|
|
invalidIssue("outside this batch of 6"),
|
|
);
|
|
expect(
|
|
await refused({ index: 3, offset: EMOJI_BODY.indexOf("\u{1F600}") + 1 }),
|
|
).toMatchObject(invalidIssue("splits a UTF-16 surrogate pair"));
|
|
});
|
|
|
|
it("recovers oversized batches and pages through smaller selections of the parent", async () => {
|
|
const budget = 64 * 1024;
|
|
const large = `int main(void) { /*${"x".repeat(budget * 2)}*/ }`;
|
|
const harness = await connect("full", {
|
|
budget,
|
|
bodies: { "0x401000": large, "0x402000": large },
|
|
});
|
|
const constraint = (result: CallToolResult) => {
|
|
const { error } = parseMcpToolError(result);
|
|
expect(error.code).toBe("resource_constraint");
|
|
const limits = z
|
|
.object({ details: z.object({ reported_limits: jsonObjectSchema }) })
|
|
.parse(error).details.reported_limits;
|
|
return z
|
|
.object({
|
|
name: z.literal("inspect_analysis_view"),
|
|
arguments: inspectAnalysisViewContract.inputSchema,
|
|
})
|
|
.parse(limits.continuation);
|
|
};
|
|
|
|
// The complete batch names a body-free first page of its retained parent.
|
|
const first = constraint(
|
|
await harness.reply("batch_decompile", {
|
|
addresses: ["main", "emoji", "bodyless"],
|
|
}),
|
|
);
|
|
const source = first.arguments.source;
|
|
if (source.kind !== "retained-evidence") throw new Error("Expected parent");
|
|
expect(first.arguments.view).toEqual({
|
|
kind: "batch-decompile",
|
|
collection: "items",
|
|
where: {},
|
|
include_pseudocode: false,
|
|
offset: 0,
|
|
limit: 64,
|
|
});
|
|
const rows = await harness.call(first.name, first.arguments);
|
|
expect(rowIndices(viewOf(rows.evidence))).toEqual([0, 1, 2]);
|
|
expect(viewOf(rows.evidence).items).toMatchObject([
|
|
{ status: "ok" },
|
|
{ status: "ok" },
|
|
{
|
|
status: "unavailable",
|
|
reason: "no_pseudocode",
|
|
limitations: expect.arrayContaining([NO_BODY_REASON]),
|
|
},
|
|
]);
|
|
expect(Buffer.byteLength(JSON.stringify(rows.result))).toBeLessThan(budget);
|
|
|
|
// Several bodies that cannot fit continue with fewer rows from the same parent.
|
|
const withBodies = {
|
|
kind: "batch-decompile",
|
|
collection: "items",
|
|
include_pseudocode: true,
|
|
};
|
|
const fewer = constraint(
|
|
await harness.reply("inspect_analysis_view", {
|
|
source,
|
|
view: withBodies,
|
|
}),
|
|
);
|
|
expect(fewer.arguments).toMatchObject({
|
|
source,
|
|
view: { ...withBodies, limit: 1 },
|
|
});
|
|
|
|
// One body that cannot fit continues with its text pages rather than an export.
|
|
const text = constraint(await harness.reply(fewer.name, fewer.arguments));
|
|
expect(text.arguments).toMatchObject({
|
|
source,
|
|
view: { kind: "batch-decompile", collection: "pseudocode", index: 0 },
|
|
});
|
|
const oversizedPage = constraint(
|
|
await harness.reply("inspect_analysis_view", {
|
|
source,
|
|
view: { ...text.arguments.view, limit: budget * 2 },
|
|
}),
|
|
);
|
|
expect(oversizedPage.arguments).toMatchObject({
|
|
source,
|
|
view: { collection: "pseudocode", index: 0, limit: budget },
|
|
});
|
|
expect((await readBody(harness, source.evidence_id, 0)).text).toBe(large);
|
|
});
|
|
|
|
it("reads the same rows from a bundle and an Evidence file without a provider", async () => {
|
|
const harness = await connect("full");
|
|
const parent = (
|
|
await harness.call("batch_decompile", { addresses: SELECTORS })
|
|
).evidence;
|
|
const selected = {
|
|
kind: "batch-decompile",
|
|
collection: "items",
|
|
where: { addresses: ["emoji"] },
|
|
include_pseudocode: true,
|
|
};
|
|
const { items } = viewOf(
|
|
(
|
|
await harness.call("inspect_analysis_view", {
|
|
source: retained(parent.evidence_id),
|
|
view: selected,
|
|
})
|
|
).evidence,
|
|
);
|
|
expect(items).toMatchObject([{ index: 3, pseudocode: EMOJI_BODY }]);
|
|
|
|
const root = await createTestTempDirectory("rea-batch-decompile-view-");
|
|
const bundlePath = join(root, "bundle.json");
|
|
const filePath = join(root, "batch.json");
|
|
await writeFile(bundlePath, JSON.stringify(createEvidenceBundle([parent])));
|
|
await writeFile(filePath, JSON.stringify(parent));
|
|
const { client, close } = await createApplicationMcpHarness({
|
|
session: createTestBinarySession(() => {
|
|
throw new Error("Retained batch views must not open a provider");
|
|
}),
|
|
clientIdentity: { name: "batch-decompile-offline", version: "1.0.0" },
|
|
schemaValidator: schemaValidation,
|
|
});
|
|
onTestFinished(close);
|
|
const imported = await client.callTool({
|
|
name: "import_evidence_bundle",
|
|
arguments: { path: bundlePath },
|
|
});
|
|
expect(imported.isError).not.toBe(true);
|
|
for (const source of [
|
|
retained(parent.evidence_id),
|
|
{ kind: "evidence-file", path: filePath },
|
|
]) {
|
|
const reply = await client.callTool({
|
|
name: "inspect_analysis_view",
|
|
arguments: { source, view: selected },
|
|
});
|
|
expect(viewOf(parseEvidence(reply.structuredContent)).items).toEqual(items);
|
|
}
|
|
});
|
|
|
|
it("reads historical batch Evidence with the prior null-pseudocode error", async () => {
|
|
const harness = await connect("full");
|
|
const savedItem = {
|
|
address: "bodyless",
|
|
procedure: { status: "resolved", address: "0x403000", name: "bodyless" },
|
|
status: "error",
|
|
error: {
|
|
code: "capability_unavailable",
|
|
category: "unsupported_provider",
|
|
message:
|
|
"The provider returned no pseudocode for this function; this item's limitations hold that observation's caveats, including any reason the provider reported.",
|
|
retryable: false,
|
|
remediation: {
|
|
action:
|
|
"Read this item's limitations for the provider's reason before retrying; another provider may decompile this entry.",
|
|
},
|
|
details: {
|
|
provider_id: "fixture",
|
|
operation: "procedure_pseudo_code",
|
|
reason: "provider returned null pseudocode",
|
|
},
|
|
},
|
|
limitations: [NO_BODY_REASON],
|
|
} as const;
|
|
const historical = createEvidence(
|
|
undefined,
|
|
{ id: "fixture", name: "Fixture", version: "1" },
|
|
{
|
|
operation: "batch_decompile",
|
|
parameters: { addresses: ["bodyless"] },
|
|
result: {
|
|
items: [savedItem],
|
|
total: 1,
|
|
succeeded: 0,
|
|
failed: 1,
|
|
},
|
|
confidence: "derived",
|
|
},
|
|
);
|
|
const result = await harness.call("inspect_analysis_view", {
|
|
source: { kind: "inline", evidence: historical },
|
|
view: {
|
|
kind: "batch-decompile",
|
|
collection: "items",
|
|
where: { statuses: ["error"] },
|
|
},
|
|
});
|
|
expect(viewOf(result.evidence).items).toEqual([{ index: 0, ...savedItem }]);
|
|
});
|
|
|
|
it("keeps the caller's own source when a page cannot fit, without retaining its parent", async () => {
|
|
const budget = 64 * 1024;
|
|
const body = `int f(void) { /*${"x".repeat(budget * 2)}*/ }`;
|
|
const row = {
|
|
procedure: { status: "resolved", address: "0x1", name: null },
|
|
status: "ok",
|
|
pseudocode: body,
|
|
};
|
|
const parent = createEvidence(
|
|
undefined,
|
|
{ id: "fixture", name: "Fixture", version: "1" },
|
|
{
|
|
operation: "batch_decompile",
|
|
parameters: {},
|
|
result: {
|
|
items: [
|
|
{ address: "a", ...row },
|
|
{ address: "b", ...row },
|
|
],
|
|
total: 2,
|
|
succeeded: 2,
|
|
failed: 0,
|
|
},
|
|
predicateType: "rea.analysis",
|
|
confidence: "derived",
|
|
authority: "shipped-artifact",
|
|
},
|
|
);
|
|
const root = await createTestTempDirectory("rea-batch-decompile-source-");
|
|
const path = join(root, "batch.json");
|
|
await writeFile(path, JSON.stringify(parent));
|
|
const session = createTestBinarySession(() => {
|
|
throw new Error("Retained batch views must not open a provider");
|
|
});
|
|
const { client, close } = await createApplicationMcpHarness({
|
|
session,
|
|
clientIdentity: { name: "batch-decompile-source", version: "1.0.0" },
|
|
schemaValidator: schemaValidation,
|
|
serverOptions: { delivery: createToolResultDelivery(budget) },
|
|
});
|
|
onTestFinished(close);
|
|
const inspect = (source: unknown, selected: Record<string, unknown>) =>
|
|
client.callTool({
|
|
name: "inspect_analysis_view",
|
|
arguments: { source, view: { kind: "batch-decompile", ...selected } },
|
|
});
|
|
const continuationOf = (result: Parameters<typeof parseMcpToolError>[0]) =>
|
|
z
|
|
.object({ details: z.object({ reported_limits: jsonObjectSchema }) })
|
|
.parse(parseMcpToolError(result).error).details.reported_limits
|
|
.continuation;
|
|
const withBodies = { collection: "items", include_pseudocode: true };
|
|
|
|
// A file source continues from that file: its parent was never retained.
|
|
const file = { kind: "evidence-file", path };
|
|
expect(continuationOf(await inspect(file, withBodies))).toMatchObject({
|
|
arguments: { source: file, view: { limit: 1 } },
|
|
});
|
|
const oneRow = continuationOf(
|
|
await inspect(file, { ...withBodies, limit: 1 }),
|
|
);
|
|
expect(oneRow).toMatchObject({
|
|
arguments: {
|
|
source: file,
|
|
view: { collection: "pseudocode", index: 0 },
|
|
},
|
|
});
|
|
const followed = await client.callTool(
|
|
z.object({ name: z.string(), arguments: jsonObjectSchema }).parse(oneRow),
|
|
);
|
|
expect(followed.isError, JSON.stringify(followed)).not.toBe(true);
|
|
expect(session.hasEvidence(parent.evidence_id)).toBe(false);
|
|
|
|
// An inline record cannot be repeated within the reply it overflowed.
|
|
const inline = { kind: "inline", evidence: parent };
|
|
const overflowed = await inspect(inline, withBodies);
|
|
expect(parseMcpToolError(overflowed).error.code).toBe("resource_constraint");
|
|
expect(continuationOf(overflowed)).toBeUndefined();
|
|
|
|
// A server that retains nothing still reads the file, and can name it again.
|
|
const unretained = createServer(
|
|
{
|
|
kind: "fixed",
|
|
analysis: {
|
|
execute: () => {
|
|
throw new Error("Retained batch views must not call a provider");
|
|
},
|
|
},
|
|
},
|
|
{ delivery: createToolResultDelivery(budget) },
|
|
);
|
|
const fixedClient = new Client({
|
|
name: "batch-unretained",
|
|
version: "1.0.0",
|
|
});
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
onTestFinished(async () => {
|
|
await fixedClient.close();
|
|
await unretained.close();
|
|
});
|
|
await unretained.connect(serverTransport);
|
|
await fixedClient.connect(clientTransport);
|
|
const noRecorder = continuationOf(
|
|
await fixedClient.callTool({
|
|
name: "inspect_analysis_view",
|
|
arguments: {
|
|
source: file,
|
|
view: { kind: "batch-decompile", ...withBodies },
|
|
},
|
|
}),
|
|
);
|
|
expect(noRecorder).toMatchObject({
|
|
arguments: { source: file, view: { limit: 1 } },
|
|
});
|
|
const followedUnretained = await fixedClient.callTool(
|
|
z
|
|
.object({ name: z.string(), arguments: jsonObjectSchema })
|
|
.parse(
|
|
continuationOf(
|
|
await fixedClient.callTool(
|
|
z
|
|
.object({ name: z.string(), arguments: jsonObjectSchema })
|
|
.parse(noRecorder),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
expect(
|
|
followedUnretained.isError,
|
|
JSON.stringify(followedUnretained),
|
|
).not.toBe(true);
|
|
});
|
|
|
|
it("delivers no summary without retention, on a ledger failure or after cancellation", async () => {
|
|
onTestFinished(closeEnhancedToolResources);
|
|
let calls = 0;
|
|
const unretained = await connectFixedAnalysis({
|
|
execute: () => {
|
|
calls++;
|
|
throw new Error("Selected delivery must fail before analysis");
|
|
},
|
|
});
|
|
expect(
|
|
parseMcpToolError(
|
|
await unretained.callTool({
|
|
name: "batch_decompile",
|
|
arguments: { addresses: ["0x1"], detail: "summary" },
|
|
}),
|
|
).error,
|
|
).toMatchObject({ code: "capability_unavailable" });
|
|
expect(calls).toBe(0);
|
|
|
|
const ledger = await connect("full");
|
|
vi.spyOn(ledger.session, "recordEvidence").mockReturnValue(
|
|
err(new AnalysisOutputError("record_evidence", "ledger refused the batch")),
|
|
);
|
|
const refused = await ledger.reply("batch_decompile", {
|
|
addresses: ["main"],
|
|
detail: "summary",
|
|
});
|
|
expect(parseMcpToolError(refused).error.code).toBe("unreadable_output");
|
|
|
|
const cancelled = await connect("full", {
|
|
execute: (operation, input) =>
|
|
Promise.resolve(
|
|
operation === "procedure_pseudo_code"
|
|
? err(new GhidraSessionError("cancelled", "Decompile cancelled"))
|
|
: ok(ghidraFixtureResult(operation, input)),
|
|
),
|
|
});
|
|
const stopped = await cancelled.reply("batch_decompile", {
|
|
addresses: ["main"],
|
|
detail: "summary",
|
|
});
|
|
expect(parseMcpToolError(stopped).error.code).toBe("cancelled");
|
|
const records = await cancelled.reply("get_evidence_bundle", {
|
|
detail: "summary",
|
|
filters: { operation: "batch_decompile" },
|
|
});
|
|
expect(records.structuredContent).toMatchObject({ result: { records: [] } });
|
|
});
|
|
|
|
it("binds views to their parent and each page to its own collection", async () => {
|
|
const harness = await connect("full");
|
|
const summary = viewOf(
|
|
(
|
|
await harness.call("batch_decompile", {
|
|
addresses: [],
|
|
detail: "summary",
|
|
})
|
|
).evidence,
|
|
);
|
|
expect(summary.summary).toMatchObject({
|
|
batch: { total: 0, succeeded: 0, failed: 0 },
|
|
procedures: { distinct_resolved: 0, unknown_identity: 0 },
|
|
pseudocode: { code_units: 0, largest: null },
|
|
});
|
|
expect(
|
|
await view(harness, z.string().parse(summary.parent_evidence_id), {
|
|
collection: "items",
|
|
}),
|
|
).toMatchObject({
|
|
items: [],
|
|
coverage: { status: "empty", exhausted: true },
|
|
});
|
|
|
|
const parent = (
|
|
await harness.call("batch_decompile", { addresses: ["main"] })
|
|
).evidence.evidence_id;
|
|
const items = await view(harness, parent, { collection: "items" });
|
|
const body = await view(harness, parent, {
|
|
collection: "pseudocode",
|
|
index: 0,
|
|
});
|
|
expect(analysisViewResultSchema.safeParse(items).success).toBe(true);
|
|
expect(analysisViewResultSchema.safeParse(body).success).toBe(true);
|
|
expect(
|
|
analysisViewResultSchema.safeParse({ ...items, view: body.view }).success,
|
|
).toBe(false);
|
|
const { item: _item, ...withoutItem } = body;
|
|
expect(
|
|
analysisViewResultSchema.safeParse({ ...withoutItem, items: items.items })
|
|
.success,
|
|
).toBe(false);
|
|
|
|
const inspect = async (source: unknown, selected: unknown) =>
|
|
harness.reply("inspect_analysis_view", { source, view: selected });
|
|
expect(
|
|
parseMcpToolError(
|
|
await inspect(retained(parent), { kind: "native-inventory" }),
|
|
).error,
|
|
).toMatchObject(invalidIssue("batch_decompile Evidence supports"));
|
|
const inventory = (await harness.call("list_procedures", {})).evidence;
|
|
expect(
|
|
parseMcpToolError(
|
|
await inspect(retained(inventory.evidence_id), {
|
|
kind: "batch-decompile",
|
|
collection: "items",
|
|
}),
|
|
).error.code,
|
|
).toBe("invalid_request");
|
|
|
|
// Inline parents authenticate their predicate and result before selection.
|
|
const inline = (
|
|
predicateType: string,
|
|
result: Evidence["normalized_result"],
|
|
) =>
|
|
inspect(
|
|
{
|
|
kind: "inline",
|
|
evidence: createEvidence(
|
|
undefined,
|
|
{ id: "fixture", name: "Fixture", version: "1" },
|
|
{
|
|
operation: "batch_decompile",
|
|
parameters: {},
|
|
result,
|
|
predicateType,
|
|
confidence: "derived",
|
|
authority: "shipped-artifact",
|
|
},
|
|
),
|
|
},
|
|
{ kind: "batch-decompile", collection: "items" },
|
|
);
|
|
const valid = {
|
|
items: [
|
|
{
|
|
address: "main",
|
|
procedure: { status: "resolved", address: "0x1", name: null },
|
|
status: "ok",
|
|
pseudocode: "x",
|
|
},
|
|
],
|
|
total: 1,
|
|
succeeded: 1,
|
|
failed: 0,
|
|
};
|
|
// A parent without a target stays explicit rather than inventing identity.
|
|
const accepted = await inline("rea.analysis", valid);
|
|
expect(viewOf(parseEvidence(accepted.structuredContent))).toMatchObject({
|
|
artifact: null,
|
|
decompiling_provider: null,
|
|
source_analysis_profile_digest: null,
|
|
});
|
|
for (const rejected of [
|
|
await inline("rea.other", valid),
|
|
await inline("rea.analysis", {
|
|
...valid,
|
|
items: [{ address: "main", status: "ok" }],
|
|
}),
|
|
])
|
|
expect(parseMcpToolError(rejected).error.code).toBe("invalid_request");
|
|
});
|