mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 06:46:10 +00:00
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:
1 parent
4d2aff2ec6
commit
ebea0f009f
8 files changed
+243
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user