fix(snapshot): retain one-shot Ghidra pseudocode results (#8089)

* fix(snapshot): retain one-shot Ghidra pseudocode results

* test(snapshot): cover evidence-only pseudocode migration

---------

Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com>
This commit is contained in:
hhangandhhang authored and GitHub committed 2026-11-04 19:50:39 +08:00
1 parent 4d2aff2ec6
commit ebea0f009f
8 files changed
+243 -6

No files matched your search

+5
View File
@@ -637,6 +637,11 @@ rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/pat
```
An exact CLI cache hit is normally read before starting a provider process.
`decompile --provider ghidra --snapshot ...` also retains its single selected
Program's pseudocode result, even without an explicit document selector. Old
snapshots that contain only the Evidence need one new query to add that replay
entry. This does not retain every body from `decompile-all` or make a persistent
session's implicit document replayable.
On Windows, Ghidra first admits the selected source through its native snapshot
owner, so replay can start that provider.
In MCP, `open_binary` accepts `snapshot_path` to import a snapshot atomically
+2
View File
@@ -30,6 +30,8 @@ import {
export interface ExecutionOptions {
/** Ephemeral state shared only by one composed workflow. */
readonly workScope?: AnalysisWorkScope;
/** A single CLI answer bound to its isolated target, not a persistent default document. */
readonly snapshotContext?: "one-shot-target";
readonly signal?: AbortSignal;
readonly progress?: ProgressReporter;
/**
+8 -1
View File
@@ -484,6 +484,7 @@ const runAnalysis = async (
evidenceParameters,
signal,
evidenceProfile,
saveSnapshot: snapshotPath !== undefined,
});
if (!executed.ok) return executed;
const { cachePolicy } = executed.value;
@@ -594,6 +595,7 @@ const executeAnalysisTool = async (input: {
readonly evidenceParameters: Readonly<Record<string, JsonValue>>;
readonly signal: AbortSignal;
readonly evidenceProfile: AnalysisProfileCommitment | undefined;
readonly saveSnapshot: boolean;
}): Promise<
Result<
{
@@ -658,7 +660,12 @@ const executeAnalysisTool = async (input: {
: { cachePolicy: result.value.cachePolicy }),
});
}
let result = await session.execute(tool, input.arguments, { signal });
let result = await session.execute(tool, input.arguments, {
signal,
...(input.saveSnapshot && tool === "procedure_pseudo_code"
? { snapshotContext: "one-shot-target" as const }
: {}),
});
if (
tool === "xrefs" &&
typeof input.arguments.address === "string" &&
@@ -777,7 +777,9 @@ describe("analysis snapshot cache policy", () => {
);
},
);
});
describe("document-scoped snapshot admission", () => {
it("retains caller observations without a document while refusing to replay them", () => {
const descriptor: CapabilityDescriptor = {
operation: "procedure_callers",
@@ -829,6 +831,35 @@ describe("analysis snapshot cache policy", () => {
document: "fixture",
}),
).toBe(true);
const ghidra = {
...pseudocode,
provider: { ...pseudocode.provider, id: "ghidra" },
};
expect(
isSnapshotCacheable("procedure_pseudo_code", ghidra, parameters),
).toBe(false);
expect(
isSnapshotCacheable(
"procedure_pseudo_code",
ghidra,
parameters,
"one-shot-target",
),
).toBe(true);
for (const refused of [
pseudocode,
{ ...ghidra, cachePolicy: "live" as const },
{ ...ghidra, effects: { ...ghidra.effects, mutatesArtifact: true } },
{ ...ghidra, effects: { ...ghidra.effects, changesPermissions: true } },
])
expect(
isSnapshotCacheable(
"procedure_pseudo_code",
refused,
parameters,
"one-shot-target",
),
).toBe(false);
});
});
@@ -36,6 +36,7 @@ import type {
AnalysisExecution,
AnalysisOperation,
CapabilityDescriptor,
ExecutionOptions,
} from "../AnalysisProvider.js";
import { OFFICIAL_TOOL_CONTRACTS } from "../../contracts/officialToolContracts.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
@@ -94,6 +95,16 @@ const replayDocumentSelected = (
!DOCUMENT_SCOPED_OPERATIONS.has(operation) ||
typeof parameters.document === "string";
/** Ghidra's private Program belongs to this one-shot target; GUI defaults do not. */
const isOneShotPseudocode = (
operation: AnalysisOperation,
descriptor: CapabilityDescriptor | undefined,
context: ExecutionOptions["snapshotContext"],
): boolean =>
context === "one-shot-target" &&
operation === "procedure_pseudo_code" &&
descriptor?.provider.id === "ghidra";
/**
* Caller and callee walks omit `document`. Their observations are still
* Evidence. Other document-scoped reads, including `procedure_pseudo_code`
@@ -112,12 +123,14 @@ export const isSourceEvidenceRetainable = (
operation: AnalysisOperation,
descriptor: CapabilityDescriptor | undefined,
parameters: Readonly<Record<string, JsonValue>>,
context?: ExecutionOptions["snapshotContext"],
): descriptor is CapabilityDescriptor =>
!STATEFUL_OPERATIONS.has(operation) &&
!RESOURCE_SCOPED_OPERATIONS.has(operation) &&
(!DOCUMENT_SCOPED_OPERATIONS.has(operation) ||
typeof parameters.document === "string" ||
DOCUMENTLESS_SOURCE_EVIDENCE_OPERATIONS.has(operation)) &&
DOCUMENTLESS_SOURCE_EVIDENCE_OPERATIONS.has(operation) ||
isOneShotPseudocode(operation, descriptor, context)) &&
(!CURSOR_DEFAULT_OPERATIONS.has(operation) ||
typeof parameters.address === "string") &&
descriptor?.effects.mutatesArtifact === false &&
@@ -130,9 +143,11 @@ export const isSnapshotCacheable = (
operation: AnalysisOperation,
descriptor: CapabilityDescriptor | undefined,
parameters: Readonly<Record<string, JsonValue>>,
context?: ExecutionOptions["snapshotContext"],
): descriptor is CapabilityDescriptor =>
isSourceEvidenceRetainable(operation, descriptor, parameters) &&
replayDocumentSelected(operation, parameters) &&
isSourceEvidenceRetainable(operation, descriptor, parameters, context) &&
(replayDocumentSelected(operation, parameters) ||
isOneShotPseudocode(operation, descriptor, context)) &&
descriptor.cachePolicy !== "live";
/** Windows Ghidra must admit the selected source through its native snapshot owner. */
+7 -1
View File
@@ -1418,6 +1418,7 @@ export class BinarySession
active: this.#active,
operation: name,
parameters: arguments_,
snapshotContext: options?.snapshotContext,
nativeSourceAdmitted: this.#active?.nativeSourceAdmitted === true,
unboundOperationError: (operation, route) =>
this.#providerRouter.unboundOperationError(operation, route),
@@ -1479,7 +1480,12 @@ export class BinarySession
}
if (
execution.ok &&
isSourceEvidenceRetainable(name, capability, arguments_) &&
isSourceEvidenceRetainable(
name,
capability,
arguments_,
options?.snapshotContext,
) &&
profile !== undefined
) {
const evidence = createAnalysisExecutionEvidence(
@@ -15,6 +15,7 @@ import type {
AnalysisExecution,
AnalysisOperation,
CapabilityDescriptor,
ExecutionOptions,
} from "../AnalysisProvider.js";
import {
isSnapshotCacheable,
@@ -41,6 +42,7 @@ interface PrepareSessionExecutionInput {
readonly active: ActiveExecutionBinding | undefined;
readonly operation: AnalysisOperation;
readonly parameters: Readonly<Record<string, JsonValue>>;
readonly snapshotContext?: ExecutionOptions["snapshotContext"];
readonly nativeSourceAdmitted: boolean;
readonly unboundOperationError: (
operation: AnalysisOperation,
@@ -94,7 +96,12 @@ export const prepareSessionExecution = (
: undefined;
const cacheable =
profile !== undefined &&
isSnapshotCacheable(operation, capability, parameters);
isSnapshotCacheable(
operation,
capability,
parameters,
input.snapshotContext,
);
const cached =
cacheable &&
(!requiresNativeSourceAdmissionBeforeReplay(capability.provider.id) ||
@@ -289,6 +289,170 @@ const createSnapshotReplayFixture = async (
};
};
describe("one-shot Ghidra pseudocode snapshots", () => {
it("keeps persistent documentless reads out of the provider cache", async () => {
const fixture = await createSnapshotReplayFixture("rea-session-code-");
const session = createTestBinarySession(
fixture.createProvider({
profile: createAnalysisProfile(
{ ...GHIDRA_PROVIDER_IDENTITY, version: "12.1.4" },
{ fixture: true },
),
supportedOperations: ["procedure_pseudo_code"],
}),
);
try {
expect((await session.open(fixture.path)).ok).toBe(true);
for (let index = 0; index < 2; index++)
expect(
(
await session.execute("procedure_pseudo_code", {
procedure: "fixture",
})
).ok,
).toBe(true);
const snapshot = session.exportAnalysisSnapshot();
if (!snapshot.ok) throw snapshot.error;
expect(snapshot.value.entries).toEqual([]);
expect(snapshot.value.evidence_bundle.records).toEqual([]);
expect(
fixture.calls.filter((name) => name === "procedure_pseudo_code"),
).toHaveLength(2);
} finally {
expect((await session.close()).ok).toBe(true);
}
});
it.skipIf(process.platform === "win32")(
"replays the exact selected-target answer without opening another provider",
async () => {
const fixture = await createSnapshotReplayFixture("rea-one-shot-code-");
const profile = createAnalysisProfile(
{ ...GHIDRA_PROVIDER_IDENTITY, version: "12.1.4" },
{ fixture: true },
);
const dependencies = fixture.dependencies(
fixture.createProvider({
profile,
supportedOperations: ["procedure_pseudo_code"],
}),
);
const options = { snapshotPath: fixture.snapshotPath };
const parameters = { procedure: "fixture" };
const first = await runDirectAnalysis(
dependencies,
fixture.path,
"procedure_pseudo_code",
parameters,
options,
);
expect(first.ok).toBe(true);
const second = await runDirectAnalysis(
dependencies,
fixture.path,
"procedure_pseudo_code",
parameters,
options,
);
expect(second).toEqual(first);
expect(fixture.starts).toHaveLength(1);
const saved = await readAnalysisSnapshot(fixture.snapshotPath);
if (!saved.ok) throw saved.error;
expect(saved.value.entries).toMatchObject([
{ operation: "procedure_pseudo_code", parameters },
]);
},
);
it("does not make another provider's implicit document replayable", async () => {
const fixture = await createSnapshotReplayFixture("rea-implicit-document-");
const dependencies = fixture.dependencies(
fixture.createProvider({
supportedOperations: ["procedure_pseudo_code"],
}),
);
for (let index = 0; index < 2; index++) {
const result = await runDirectAnalysis(
dependencies,
fixture.path,
"procedure_pseudo_code",
{ procedure: "fixture" },
{ snapshotPath: fixture.snapshotPath },
);
expect(result.ok).toBe(true);
}
expect(fixture.starts).toHaveLength(2);
const saved = await readAnalysisSnapshot(fixture.snapshotPath);
if (!saved.ok) throw saved.error;
expect(saved.value.entries).toEqual([]);
});
});
describe("legacy one-shot Ghidra pseudocode snapshots", () => {
it.skipIf(process.platform === "win32")(
"restores a missing replay entry without replacing the original Evidence bundle",
async () => {
const fixture = await createSnapshotReplayFixture("rea-legacy-code-");
const dependencies = fixture.dependencies(
fixture.createProvider({
profile: createAnalysisProfile(
{ ...GHIDRA_PROVIDER_IDENTITY, version: "12.1.4" },
{ fixture: true },
),
supportedOperations: ["procedure_pseudo_code"],
}),
);
const parameters = { procedure: "fixture" };
const query = () =>
runDirectAnalysis(
dependencies,
fixture.path,
"procedure_pseudo_code",
parameters,
{ snapshotPath: fixture.snapshotPath },
);
const first = await query();
if (!first.ok) throw new Error(JSON.stringify(first.error));
const saved = await readAnalysisSnapshot(fixture.snapshotPath);
if (!saved.ok) throw saved.error;
const written = await withArtifactResourceScope((resources) =>
writeAnalysisSnapshot(
{ ...saved.value, entries: [] },
fixture.snapshotPath,
true,
resources,
),
);
if (!written.ok) throw written.error;
const legacy = await readAnalysisSnapshot(fixture.snapshotPath);
if (!legacy.ok) throw legacy.error;
expect(legacy.value.entries).toEqual([]);
expect(legacy.value.evidence_bundle).toEqual(saved.value.evidence_bundle);
const upgraded = await query();
expect(upgraded).toEqual(first);
expect(fixture.starts).toHaveLength(2);
const restored = await readAnalysisSnapshot(fixture.snapshotPath);
if (!restored.ok) throw restored.error;
expect(restored.value.entries).toMatchObject([
{ operation: "procedure_pseudo_code", parameters },
]);
expect(restored.value.evidence_bundle).toEqual(
saved.value.evidence_bundle,
);
const replay = await query();
expect(replay).toEqual(first);
if (!replay.ok) throw new Error(JSON.stringify(replay.error));
expect(replay.value.evidence_id).toBe(first.value.evidence_id);
expect(fixture.starts).toHaveLength(2);
expect(
fixture.calls.filter((name) => name === "procedure_pseudo_code"),
).toHaveLength(2);
},
);
});
const withAlternateWorkflowProfile = (
snapshot: AnalysisSnapshot,
path: string,