fix(ghidra): preserve function references and snapshot boundaries
Collect references from the complete owned function body and exact entry, including instruction-interior destinations and embedded-data sources. Bind the corrected semantics to the analysis profile so legacy snapshots cannot replay incomplete results. Preserve snapshot mismatch reasons and recovery advice through CLI and MCP without disturbing live sessions. Return exact entry candidates for ambiguous function names. Expand the real-provider regression workflow and replace redundant parser assertions with malformed-output coverage through the pinned MCP client. Verified with Ghidra 12.1.4 on macOS arm64: full host-native lane, real CLI/MCP boundary probes, 30 focused tests, and typecheck/lint.
This commit is contained in:
parent
017297c40c
commit
84a17d5519
|
|
@ -52,6 +52,8 @@ import ghidra.app.decompiler.ClangCaseToken;
|
|||
import ghidra.app.decompiler.ClangNode;
|
||||
import ghidra.framework.Application;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.address.AddressIterator;
|
||||
import ghidra.program.model.address.AddressSet;
|
||||
import ghidra.program.model.address.AddressSpace;
|
||||
import ghidra.program.model.address.AddressRange;
|
||||
import ghidra.program.model.address.AddressRangeIterator;
|
||||
|
|
@ -723,8 +725,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
if (!direction.equals("incoming") && !direction.equals("outgoing")) {
|
||||
throw new RequestFailure("invalid_request", "direction must be incoming or outgoing");
|
||||
}
|
||||
InstructionScan scan = scanInstructions(function, Integer.MAX_VALUE);
|
||||
List<Reference> references = collectReferences(scan.instructions, direction);
|
||||
List<Reference> references = collectReferences(function, direction);
|
||||
JsonArray edges = new JsonArray();
|
||||
for (Reference reference : references) {
|
||||
edges.add(referenceEdge(reference));
|
||||
|
|
@ -735,7 +736,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
result.add("references", edges);
|
||||
result.addProperty("reference_kinds_available", true);
|
||||
JsonArray unresolvedCalls = new JsonArray();
|
||||
if (direction.equals("outgoing")) for (Instruction instruction : scan.instructions) {
|
||||
if (direction.equals("outgoing")) for (Instruction instruction : scanInstructions(function, Integer.MAX_VALUE).instructions) {
|
||||
if (!instruction.getFlowType().isCall()) continue;
|
||||
boolean resolved = false;
|
||||
for (Reference reference : instruction.getReferencesFrom()) if (reference.getReferenceType().isCall()) resolved = true;
|
||||
|
|
@ -1092,11 +1093,11 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
if (pseudocode == null) {
|
||||
pseudocode = "";
|
||||
}
|
||||
List<Reference> incomingReferences = collectReferences(scan.instructions, "incoming")
|
||||
List<Reference> incomingReferences = collectReferences(function, "incoming")
|
||||
.stream()
|
||||
.filter(reference -> !function.getBody().contains(reference.getFromAddress()))
|
||||
.toList();
|
||||
List<Reference> outgoingReferences = collectReferences(scan.instructions, "outgoing");
|
||||
List<Reference> outgoingReferences = collectReferences(function, "outgoing");
|
||||
JsonArray incoming = referenceEdges(incomingReferences);
|
||||
JsonArray outgoing = referenceEdges(outgoingReferences);
|
||||
JsonArray comments = comments(scan.instructions);
|
||||
|
|
@ -1615,7 +1616,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
}
|
||||
}
|
||||
if (matches.size() > 1)
|
||||
throw new RequestFailure("ambiguous", "Ghidra procedure name is ambiguous: " + value);
|
||||
throw new RequestFailure("ambiguous", "Ghidra procedure name is ambiguous: " + value +
|
||||
"; select an exact entry address from " + matches.stream()
|
||||
.map(function -> canonicalAddress(function.getEntryPoint())).toList());
|
||||
if (matches.size() == 1) return matches.get(0);
|
||||
}
|
||||
// Retain legacy bare-hex address input only when no symbol matches.
|
||||
|
|
@ -2550,21 +2553,32 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
}
|
||||
|
||||
private List<Reference> collectReferences(
|
||||
List<Instruction> instructions,
|
||||
Function function,
|
||||
String direction) throws Exception {
|
||||
// References can target instruction interiors or originate in embedded
|
||||
// data. Use actual body ownership, including an external's exact entry.
|
||||
AddressSet scope = new AddressSet(function.getBody());
|
||||
scope.add(function.getEntryPoint());
|
||||
boolean outgoing = direction.equals("outgoing");
|
||||
AddressIterator addresses = outgoing
|
||||
? currentProgram.getReferenceManager().getReferenceSourceIterator(scope, true)
|
||||
: currentProgram.getReferenceManager().getReferenceDestinationIterator(scope, true);
|
||||
TreeMap<String, Reference> observed = new TreeMap<>();
|
||||
for (Instruction instruction : instructions) {
|
||||
while (addresses.hasNext()) {
|
||||
monitor.checkCancelled();
|
||||
if (direction.equals("outgoing")) {
|
||||
Address address = addresses.next();
|
||||
if (outgoing) {
|
||||
for (Reference reference : currentProgram.getReferenceManager()
|
||||
.getReferencesFrom(instruction.getAddress())) {
|
||||
.getReferencesFrom(address)) {
|
||||
monitor.checkCancelled();
|
||||
addReference(observed, reference);
|
||||
}
|
||||
}
|
||||
else {
|
||||
ReferenceIterator iterator = currentProgram.getReferenceManager()
|
||||
.getReferencesTo(instruction.getAddress());
|
||||
.getReferencesTo(address);
|
||||
while (iterator.hasNext()) {
|
||||
monitor.checkCancelled();
|
||||
addReference(observed, iterator.next());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,16 @@ an explicit `0x` or address-space prefix selects an address; otherwise an exact
|
|||
database symbol name takes precedence over a bare hexadecimal address. Thus a
|
||||
function renamed to `dead` remains selectable by name. Use the names returned
|
||||
by the inventory, including their namespaces and any platform symbol prefix.
|
||||
Overloads can share a fully qualified name; ambiguity errors return every
|
||||
matching entry address so the caller can select the intended function directly.
|
||||
|
||||
Ghidra function references cover the complete function-body AddressSet and its
|
||||
exact entry, including references into instruction interiors and references
|
||||
from embedded data. Addresses inside an enclosing span but outside the owned
|
||||
body are excluded. `procedure_references` returns internal and external edges;
|
||||
`analyze_function` omits incoming edges whose sources belong to the same function.
|
||||
The reference collector's semantics participate in the analysis profile, so
|
||||
older snapshots cannot replay the former incomplete results under this profile.
|
||||
|
||||
```bash
|
||||
rea inspect-native-instruction <binary> <address> --provider ghidra
|
||||
|
|
|
|||
|
|
@ -274,8 +274,13 @@ path exercises private Unix socket allocation and cleanup, including cancellatio
|
|||
after a real headless process launches. Native annotation probes reject NUL and
|
||||
unpaired Unicode surrogates without partial edits or a broken bridge, preserve
|
||||
supported Unicode and control text, and check lossless malformed-text diagnostics.
|
||||
Memory-to-file mapping is checked against original artifact bytes. Malformed
|
||||
annotation readback, memory completeness, and inventory data remain separate
|
||||
Memory-to-file mapping is checked against original artifact bytes.
|
||||
The fixture also stores a pointer one byte past a function entry; exact
|
||||
xrefs, raw procedure references, and CLI/MCP dossiers must retain that data edge.
|
||||
A valid legacy snapshot reconstructs the former omitted edge under its older
|
||||
profile; CLI and MCP must reject that binding with a mismatch reason and
|
||||
recovery advice. The rejected open must preserve the active live session.
|
||||
Malformed annotation readback, memory completeness, and inventory data remain separate
|
||||
SDK/provider integration cases; success from a real
|
||||
provider cannot establish rejection of a contradictory provider response.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { parseBinaryTarget } from "../../dist/application/BinaryTargetResolver.js";
|
||||
import { createAnalysisProfile } from "../../dist/domain/analysisProfile.js";
|
||||
import {
|
||||
createAnalysisSnapshotEntry,
|
||||
parseAnalysisSnapshot,
|
||||
serializeAnalysisSnapshot,
|
||||
snapshotBinding,
|
||||
snapshotTarget,
|
||||
} from "../../dist/domain/analysisSnapshot.js";
|
||||
import { createEvidence } from "../../dist/domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../../dist/domain/evidenceBundle.js";
|
||||
import { requireMcpResult } from "./mcp-verifier-results.mjs";
|
||||
|
||||
/** Reject a valid older snapshot whose instruction-only collector omitted a real edge. */
|
||||
export async function verifyLegacyGhidraReferenceSnapshot(
|
||||
client,
|
||||
{ target, procedure, omittedEdge, entrypoint, env },
|
||||
) {
|
||||
const bundle = requireMcpResult(
|
||||
await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
}),
|
||||
"get_evidence_bundle",
|
||||
);
|
||||
const observed = bundle.records.find(
|
||||
(record) =>
|
||||
record.operation === "analyze_function" &&
|
||||
record.parameters.procedure === procedure,
|
||||
);
|
||||
assert.ok(observed);
|
||||
const { function_references: coverage, ...legacyParameters } =
|
||||
observed.analysis_profile.parameters;
|
||||
assert.equal(
|
||||
typeof coverage,
|
||||
"string",
|
||||
"Reference semantics must participate in snapshot identity",
|
||||
);
|
||||
const legacyProfile = createAnalysisProfile(
|
||||
observed.provider,
|
||||
legacyParameters,
|
||||
);
|
||||
assert.notEqual(legacyProfile.digest, observed.analysis_profile.digest);
|
||||
const parsed = await parseBinaryTarget(
|
||||
target.path,
|
||||
process.cwd(),
|
||||
process.arch,
|
||||
);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
const nativeTarget = parsed.value;
|
||||
const result = structuredClone(observed.normalized_result);
|
||||
// Reconstruct the observed pre-fix omission; all other data
|
||||
// comes from the real provider and the file has a fully valid Evidence binding.
|
||||
result.incoming_references = result.incoming_references.filter(
|
||||
(edge) =>
|
||||
edge.source_address !== omittedEdge.source_address ||
|
||||
edge.target_address !== omittedEdge.target_address,
|
||||
);
|
||||
assert.equal(
|
||||
result.incoming_references.length,
|
||||
observed.normalized_result.incoming_references.length - 1,
|
||||
);
|
||||
const evidence = createEvidence(nativeTarget, observed.provider, {
|
||||
operation: observed.operation,
|
||||
parameters: observed.parameters,
|
||||
result,
|
||||
rawResult: result,
|
||||
analysisProfile: legacyProfile,
|
||||
limitations: observed.limitations,
|
||||
locations: observed.locations,
|
||||
});
|
||||
const binding = snapshotBinding(legacyProfile);
|
||||
const identity = snapshotTarget(nativeTarget);
|
||||
const snapshot = parseAnalysisSnapshot({
|
||||
target: identity,
|
||||
binding,
|
||||
entries: [
|
||||
createAnalysisSnapshotEntry({
|
||||
target: identity,
|
||||
binding,
|
||||
operation: observed.operation,
|
||||
parameters: observed.parameters,
|
||||
execution: {
|
||||
result,
|
||||
rawResult: result,
|
||||
provider: observed.provider,
|
||||
analysisProfile: legacyProfile,
|
||||
limitations: observed.limitations,
|
||||
locations: observed.locations,
|
||||
subject: nativeTarget,
|
||||
},
|
||||
}),
|
||||
],
|
||||
evidence_bundle: createEvidenceBundle([evidence]),
|
||||
});
|
||||
assert.equal(
|
||||
snapshot.entries.length,
|
||||
1,
|
||||
"The legacy query must remain valid and replayable under its own profile",
|
||||
);
|
||||
const path = join(env.TMPDIR, `legacy-reference-${randomUUID()}.json`);
|
||||
await writeFile(path, serializeAnalysisSnapshot(snapshot), { flag: "wx" });
|
||||
try {
|
||||
const session = requireMcpResult(
|
||||
await client.callTool({ name: "binary_session", arguments: {} }),
|
||||
"binary_session",
|
||||
);
|
||||
const reply = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: {
|
||||
path: target.path,
|
||||
provider_id: "ghidra",
|
||||
snapshot_path: path,
|
||||
},
|
||||
});
|
||||
assert.equal(reply.isError, true);
|
||||
const mcpRejection = reply.structuredContent.error;
|
||||
assert.equal(mcpRejection.code, "evidence_integrity_mismatch");
|
||||
assert.match(mcpRejection.details.reason, /profile_mismatch/u);
|
||||
assert.match(
|
||||
mcpRejection.remediation.action,
|
||||
/without this snapshot.*fresh snapshot/u,
|
||||
);
|
||||
assert.deepEqual(
|
||||
requireMcpResult(
|
||||
await client.callTool({ name: "binary_session", arguments: {} }),
|
||||
"binary_session",
|
||||
),
|
||||
session,
|
||||
"A rejected snapshot must preserve the selected session",
|
||||
);
|
||||
const retained = requireMcpResult(
|
||||
await client.callTool({
|
||||
name: "analyze_function",
|
||||
arguments: { procedure },
|
||||
}),
|
||||
"analyze_function",
|
||||
);
|
||||
assert.ok(
|
||||
retained.incoming_references.some(
|
||||
(edge) =>
|
||||
edge.source_address === omittedEdge.source_address &&
|
||||
edge.target_address === omittedEdge.target_address,
|
||||
),
|
||||
"A rejected legacy snapshot must not replace complete live analysis",
|
||||
);
|
||||
await assert.rejects(
|
||||
promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"function",
|
||||
target.path,
|
||||
procedure,
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--snapshot",
|
||||
path,
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 72 * 1024 * 1024 },
|
||||
),
|
||||
(error) => {
|
||||
assert.equal(error.code, 1);
|
||||
const rejection = JSON.parse(error.stdout);
|
||||
assert.equal(rejection.code, "evidence_integrity_mismatch");
|
||||
assert.equal(rejection.details.reason, mcpRejection.details.reason);
|
||||
assert.equal(
|
||||
rejection.remediation.action,
|
||||
mcpRejection.remediation.action,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await rm(path);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { promisify } from "node:util";
|
|||
import Ajv from "ajv/dist/2020.js";
|
||||
import addFormats from "ajv-formats";
|
||||
import { mcpTextValue, requireMcpResult } from "./mcp-verifier-results.mjs";
|
||||
import { verifyLegacyGhidraReferenceSnapshot } from "./ghidra-reference-snapshot-e2e.mjs";
|
||||
|
||||
/** Probe real Ghidra location, annotation and error contracts through public adapters. */
|
||||
export async function verifyGhidraBoundaries(
|
||||
|
|
@ -56,6 +57,7 @@ export async function verifyGhidraBoundaries(
|
|||
assert.match(JSON.stringify(error.details.issues), diagnostic);
|
||||
}
|
||||
rejectedCalls++;
|
||||
return reply.structuredContent?.error;
|
||||
};
|
||||
const cli = async (command, value, flags = []) => {
|
||||
const { stdout } = await promisify(execFile)(
|
||||
|
|
@ -93,6 +95,61 @@ export async function verifyGhidraBoundaries(
|
|||
assert.equal((await stat(socketRoot)).mode & 0o777, 0o700);
|
||||
const address = original.procedure.address;
|
||||
const name = original.procedure.name;
|
||||
const names = await call("list_names");
|
||||
const leaf = names.find((item) =>
|
||||
item.value.endsWith("rea_ghidra_inventory_leaf"),
|
||||
);
|
||||
const pointer = names.find((item) =>
|
||||
item.value.endsWith("rea_ghidra_interior_pointer"),
|
||||
);
|
||||
assert.ok(
|
||||
leaf && pointer,
|
||||
"Source fixture lacks the interior function pointer",
|
||||
);
|
||||
const interiorTarget = `0x${(BigInt(leaf.address) + 1n).toString(16)}`;
|
||||
assert.ok(
|
||||
(await call("xrefs", { address: interiorTarget })).includes(
|
||||
pointer.address,
|
||||
),
|
||||
);
|
||||
const incoming = await call("procedure_references", {
|
||||
procedure: leaf.address,
|
||||
direction: "incoming",
|
||||
});
|
||||
const interiorEdge = incoming.references.find(
|
||||
(edge) =>
|
||||
edge.source_address === pointer.address &&
|
||||
edge.target_address === interiorTarget,
|
||||
);
|
||||
assert.ok(
|
||||
interiorEdge,
|
||||
"Procedure references omitted an observed interior-body edge",
|
||||
);
|
||||
assert.equal(interiorEdge.target_procedure?.address, leaf.address);
|
||||
assert.equal(interiorEdge.source_procedure, null);
|
||||
assert.equal(interiorEdge.kind.data, true);
|
||||
const leafDossier = await call("analyze_function", {
|
||||
procedure: leaf.address,
|
||||
});
|
||||
assert.ok(
|
||||
leafDossier.incoming_references.some(
|
||||
(edge) =>
|
||||
edge.source_address === pointer.address &&
|
||||
edge.target_address === interiorTarget,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await cli("function", leaf.value)).incoming_references,
|
||||
leafDossier.incoming_references,
|
||||
"CLI and MCP must retain the same interior-body references",
|
||||
);
|
||||
await verifyLegacyGhidraReferenceSnapshot(client, {
|
||||
target,
|
||||
procedure: leaf.address,
|
||||
omittedEdge: interiorEdge,
|
||||
entrypoint,
|
||||
env,
|
||||
});
|
||||
const baseline = await call("inspect_native_instruction", { address });
|
||||
assert.equal(baseline.status, "decoded");
|
||||
const bytes = await call("read_bytes", { address, length: baseline.length });
|
||||
|
|
@ -442,12 +499,37 @@ export async function verifyGhidraBoundaries(
|
|||
);
|
||||
assert.equal(await call("address_name", { address }), renamed);
|
||||
}
|
||||
for (const selected of [address, indirectProcedure.address]) {
|
||||
await call("annotate_native_function", {
|
||||
procedure: selected,
|
||||
name: "rea_ambiguous",
|
||||
});
|
||||
}
|
||||
const ambiguous = await invalid(
|
||||
"procedure_address",
|
||||
{ procedure: "rea_ambiguous" },
|
||||
/ambiguous.*select an exact entry address/u,
|
||||
);
|
||||
for (const selected of [address, indirectProcedure.address]) {
|
||||
assert.ok(
|
||||
JSON.stringify(ambiguous.details.issues).includes(selected),
|
||||
"Ambiguity diagnostics must retain every matching entry address",
|
||||
);
|
||||
assert.equal(
|
||||
await call("procedure_address", { procedure: selected }),
|
||||
selected,
|
||||
);
|
||||
}
|
||||
await call("close_binary");
|
||||
await assert.rejects(access(socketRoot), { code: "ENOENT" });
|
||||
await assert.rejects(access(runtimeRoot), { code: "ENOENT" });
|
||||
await call("open_binary", { path: target.path, provider_id: "ghidra" });
|
||||
assert.equal(await call("procedure_address", { procedure: name }), address);
|
||||
assert.equal(await call("address_name", { address }), name);
|
||||
assert.equal(
|
||||
await call("procedure_address", { procedure: indirectProcedure.value }),
|
||||
indirectProcedure.address,
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await call("analyze_function", { procedure: address })).comments,
|
||||
original.comments,
|
||||
|
|
@ -468,6 +550,9 @@ export async function verifyGhidraBoundaries(
|
|||
mutation_rollback: true,
|
||||
annotation_native_text_validation: true,
|
||||
lossless_unicode_transport: true,
|
||||
complete_function_body_references: true,
|
||||
ambiguity_candidates_inline: true,
|
||||
legacy_reference_snapshot_rejected: true,
|
||||
source_immutable: true,
|
||||
reopen_discards_edits: true,
|
||||
long_tmpdir_private_socket_cleanup: true,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
|||
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import type { EvidenceBundle } from "../../domain/evidenceBundle.js";
|
||||
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import {
|
||||
EvidenceIntegrityError,
|
||||
AnalysisSnapshotMismatchError,
|
||||
} from "../../domain/evidenceErrors.js";
|
||||
import { NoBinaryOpenError } from "../../domain/configurationErrors.js";
|
||||
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
|
|
@ -189,7 +192,7 @@ export class AnalysisSnapshotCache {
|
|||
!snapshotMatchesProfile(validated.binding, active.profile))
|
||||
)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
new AnalysisSnapshotMismatchError(
|
||||
"Analysis snapshot profile_mismatch: target, provider, or analysis profile does not match the active binary",
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import {
|
|||
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
|
||||
import { parseBinaryTarget } from "../BinaryTargetResolver.js";
|
||||
import { type BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import {
|
||||
EvidenceIntegrityError,
|
||||
AnalysisSnapshotMismatchError,
|
||||
} from "../../domain/evidenceErrors.js";
|
||||
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { err, type Result } from "../../domain/result.js";
|
||||
import type { SessionProviderRoute } from "./SessionProviderRouter.js";
|
||||
|
|
@ -135,11 +138,11 @@ const validateSnapshot = ({
|
|||
snapshot !== undefined &&
|
||||
(profile === null || !snapshotMatchesBinding(snapshot, target, profile))
|
||||
)
|
||||
return new EvidenceIntegrityError(
|
||||
return new AnalysisSnapshotMismatchError(
|
||||
"Analysis snapshot profile_mismatch: target, provider, or analysis profile does not match the requested binary",
|
||||
);
|
||||
if (current === undefined && !stagedSnapshotMatches(target, profile))
|
||||
return new EvidenceIntegrityError(
|
||||
return new AnalysisSnapshotMismatchError(
|
||||
"Analysis snapshot profile_mismatch: staged target, provider, or analysis profile does not match",
|
||||
);
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import type { Evidence } from "../../domain/evidence.js";
|
|||
import type { EvidenceBundle } from "../../domain/evidenceBundle.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { evidenceBundleForTarget } from "../../domain/evidenceBundle.js";
|
||||
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import {
|
||||
EvidenceIntegrityError,
|
||||
AnalysisSnapshotMismatchError,
|
||||
} from "../../domain/evidenceErrors.js";
|
||||
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { type UnknownRegistryError } from "../../domain/unknownRegistryError.js";
|
||||
import type {
|
||||
|
|
@ -129,7 +132,7 @@ export abstract class BinarySessionRecords {
|
|||
const active = this.activeAnalysisBinding();
|
||||
if (active?.profile === null)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
new AnalysisSnapshotMismatchError(
|
||||
"Analysis snapshot profile_mismatch: the active target has no concrete analysis profile",
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
EvidenceFileError,
|
||||
EvidenceIntegrityError,
|
||||
EvidenceReferenceError,
|
||||
AnalysisSnapshotMismatchError,
|
||||
} from "./evidenceErrors.js";
|
||||
import {
|
||||
HopperRemoteError,
|
||||
|
|
@ -35,6 +36,8 @@ import { type AnalysisErrorProjection } from "./analysisErrorProjection.js";
|
|||
export const analysisErrorRemediationAction = (
|
||||
error: AnalysisError,
|
||||
): string => {
|
||||
if (error instanceof AnalysisSnapshotMismatchError)
|
||||
return "Run analysis without this snapshot, then save a fresh snapshot using the intended artifact, provider, and analysis profile.";
|
||||
if (error instanceof AnalysisUnsupportedTargetError)
|
||||
return "Select a target supported by this operation or choose an operation supporting the reported target format.";
|
||||
if (error instanceof AnalysisResourceConstraintError)
|
||||
|
|
@ -151,6 +154,7 @@ const STATIC_ERROR_CATEGORIES: Readonly<
|
|||
};
|
||||
|
||||
export const analysisErrorUserMessage = (error: AnalysisError): string => {
|
||||
if (error instanceof AnalysisSnapshotMismatchError) return error.message;
|
||||
if (error instanceof AnalysisUnsupportedTargetError) return error.message;
|
||||
if (error instanceof AnalysisResourceConstraintError) return error.reason;
|
||||
if (error instanceof AnalysisAccessDeniedError)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ import {
|
|||
import { ArtifactOperationError } from "./artifactOperationError.js";
|
||||
import { BinaryTargetError } from "./configurationErrors.js";
|
||||
import { BrowserObservationError } from "./browserObservationError.js";
|
||||
import { EvidenceFileError, EvidenceReferenceError } from "./evidenceErrors.js";
|
||||
import {
|
||||
EvidenceFileError,
|
||||
EvidenceIntegrityError,
|
||||
EvidenceReferenceError,
|
||||
} from "./evidenceErrors.js";
|
||||
import {
|
||||
HopperCancelledError,
|
||||
HopperProcessError,
|
||||
|
|
@ -264,6 +268,7 @@ const requestErrorDetails = (
|
|||
expected: error.expected,
|
||||
actual: error.actual,
|
||||
};
|
||||
if (error instanceof EvidenceIntegrityError) return { reason: error.message };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ export class EvidenceIntegrityError extends AnalysisError {
|
|||
}
|
||||
}
|
||||
|
||||
/** A valid snapshot belongs to a different target, provider, or analysis profile. */
|
||||
export class AnalysisSnapshotMismatchError extends EvidenceIntegrityError {}
|
||||
|
||||
/** A session Evidence reference is missing or has the wrong semantic identity. */
|
||||
export class EvidenceReferenceError extends EvidenceIntegrityError {
|
||||
constructor(
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export const resolveGhidraAnalysisProfile = (
|
|||
annotation_policy: "atomic-function-entry-metadata-v1",
|
||||
load_image_observations: "source-mappings-entry-context-v2",
|
||||
function_body_evidence: "complete-inclusive-ranges-v1",
|
||||
function_references: "complete-body-and-entry-reference-manager-v2",
|
||||
location_resolution: "explicit-address-exact-name-first-v2",
|
||||
process_launch:
|
||||
installation.platform === "darwin"
|
||||
|
|
|
|||
|
|
@ -16,104 +16,6 @@ import {
|
|||
} from "../domain/ghidraValues.fixture.js";
|
||||
|
||||
describe("Ghidra function-analysis result values", () => {
|
||||
it("parses provider-classified function facts and reference kinds", () => {
|
||||
expect(
|
||||
parseGhidraFunctionResult("read_function_instructions", {
|
||||
procedure: ghidraFunctionIdentity(),
|
||||
instructions: ["0x401000: push rbp"],
|
||||
limitations: ["Ghidra-specific instruction text."],
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(
|
||||
parseGhidraFunctionResult("read_function_instructions", {
|
||||
procedure: {
|
||||
...ghidraFunctionIdentity(),
|
||||
address: "0X401000",
|
||||
},
|
||||
instructions: ["0x401000: push rbp"],
|
||||
limitations: ["Ghidra-specific instruction text."],
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
expect(
|
||||
parseGhidraFunctionResult("procedure_info", {
|
||||
name: "fixture_main",
|
||||
entrypoint: "0x401000",
|
||||
basicblock_count: 1,
|
||||
length: 6,
|
||||
signature: "int fixture_main(void)",
|
||||
locals: [
|
||||
{
|
||||
description: "int local @ Stack[-0x4]:4",
|
||||
provenance: "ghidra-function-database",
|
||||
},
|
||||
],
|
||||
classification: ghidraFunctionClassification(),
|
||||
body: ghidraFunctionBody(),
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(
|
||||
parseGhidraFunctionResult("procedure_references", {
|
||||
procedure: ghidraFunctionIdentity(),
|
||||
direction: "outgoing",
|
||||
references: [ghidraReferenceEdge()],
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
references: [
|
||||
{
|
||||
kind: {
|
||||
available: true,
|
||||
provenance: "ghidra-reference-manager",
|
||||
data: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
parseGhidraFunctionResult("analyze_function", ghidraFunctionDossier()),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
native_api: {
|
||||
available: true,
|
||||
provenance: "ghidra-high-function",
|
||||
return_type: {
|
||||
data_type: "int",
|
||||
confidence: "medium",
|
||||
},
|
||||
jump_tables: [
|
||||
{
|
||||
dispatch_address: "0x401010",
|
||||
data_sources: [{ address: "0x403000" }],
|
||||
mappings: [
|
||||
{
|
||||
target_address: "0x401020",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
pseudocode: {
|
||||
classification: "decompiler-generated-non-source",
|
||||
compilable: false,
|
||||
},
|
||||
},
|
||||
native_value_flow: {
|
||||
available: true,
|
||||
provenance: "ghidra-high-pcode",
|
||||
operations: [
|
||||
expect.objectContaining({ id: "0x401000#0", opcode: "COPY" }),
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects p-code relationships that refer to omitted operation IDs", () => {
|
||||
const dossier = ghidraFunctionDossier();
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -125,12 +125,14 @@ export const limitationsFor = (operation: string): readonly string[] => {
|
|||
case "procedure_references":
|
||||
return [
|
||||
...common,
|
||||
"References cover the complete Ghidra function-body AddressSet and exact entry, including instruction-interior destinations and embedded-data sources; an enclosing address span does not establish function ownership.",
|
||||
"Reference kinds are direct Ghidra ReferenceManager observations; unresolved computed flows without a target are absent and remain unknown.",
|
||||
"Synthetic Ghidra entry-point references without actionable memory sources are omitted.",
|
||||
];
|
||||
case "analyze_function":
|
||||
return [
|
||||
...common,
|
||||
"References cover the complete Ghidra function-body AddressSet and exact entry. Dossier incoming edges omit sources owned by that function; procedure_references also retains those internal edges.",
|
||||
"The dossier combines Ghidra FunctionManager, Listing, ReferenceManager, BasicBlockModel, and decompiler observations; provider-specific pseudocode and assembly are not cross-provider text invariants.",
|
||||
"Resolved reference metadata identifies computed, indirect, external, call, jump, and data edges; unresolved targetless flows remain unknown, and function classifications distinguish thunks and externals.",
|
||||
"Synthetic Ghidra entry-point references without actionable memory sources are omitted.",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { expect, it } from "vitest";
|
||||
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { ghidraFunctionIdentity } from "../../../src/domain/ghidraValues.fixture.js";
|
||||
import { connectGhidraMcp } from "./ghidraMcpHarness.js";
|
||||
|
||||
it("rejects contradictory provider memory and inventory data before emitting MCP Evidence", async () => {
|
||||
it("rejects contradictory provider output before emitting MCP Evidence", async () => {
|
||||
const bytes = {
|
||||
address: "0x401000",
|
||||
requested_bytes: 4,
|
||||
|
|
@ -57,6 +58,15 @@ it("rejects contradictory provider memory and inventory data before emitting MCP
|
|||
arguments: { pattern: "needle" },
|
||||
output: [{ address: "0x401000" }],
|
||||
},
|
||||
{
|
||||
name: "read_function_instructions",
|
||||
arguments: { procedure: "fixture_main" },
|
||||
output: {
|
||||
procedure: { ...ghidraFunctionIdentity(), address: "0X401000" },
|
||||
instructions: ["0x401000: push rbp"],
|
||||
limitations: ["Ghidra-specific instruction text."],
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const probe of cases) {
|
||||
output = jsonValueSchema.parse(probe.output);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ __attribute__((noinline, used)) int rea_ghidra_inventory_leaf(int value) {
|
|||
return value + rea_ghidra_inventory_global;
|
||||
}
|
||||
|
||||
// A reference into the function body can land inside an instruction.
|
||||
__attribute__((used)) const unsigned char *volatile rea_ghidra_interior_pointer =
|
||||
(const unsigned char *)(void *)rea_ghidra_inventory_leaf + 1;
|
||||
|
||||
__attribute__((noinline, used)) int rea_ghidra_inventory_branch(int value) {
|
||||
if (value > 10) {
|
||||
return rea_ghidra_inventory_leaf(value);
|
||||
|
|
|
|||
Loading…
Reference in New Issue