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

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