fix(ghidra): report unbacked file-offset addresses without schema repair (#8308)

* fix(ghidra): report unbacked file-offset addresses without schema repair

address_to_file_offset projected a well-formed address in uninitialized
or unmapped Ghidra memory as generic invalid input: the headline said the
arguments were invalid and recovery pointed at the input schema, while the
real reason survived only in a nested issue.

The bridge now gives each outcome its own code (address_unmapped,
address_uninitialized, file_mapping_absent, file_mapping_ambiguous,
file_offset_unrepresentable) instead of folding them into invalid_request.
The Ghidra projection keeps the bridge reason as the headline, points to
inspect_native_load_image, records the caller's exact address and the
file_mapping outcome, and scopes diagnostics to the request.
AnalysisInputError gains conformsToInputContract so a conforming request
omits recovery.kind=inspect_input_contract. Malformed addresses and other
bridge refusals keep their existing projection.

Fixes #8244

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(ghidra): match fixture block ends to bridge conventions

The fake bridge treated .text's exclusive list_segments end (0x1100) as an
inclusive bound, so address_to_file_offset mapped an address outside the
block. Give the fixture's blocks one owner (start and size) and derive each
reply from the production bridge convention: exclusive list_segments ends,
inclusive byte-search coverage, half-open containment for file offsets.

Add an MCP boundary test that reads the block edges from list_segments and
search_bytes, maps the last file-backed byte and refuses the first address
past each block with its outcome code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-31 08:59:00 -07:00
1 parent 7c9ed94259
commit c287a73276
15 files changed
+688 -22

No files matched your search

+22 -6
View File
@@ -928,17 +928,33 @@ public final class ReaGhidraBridge extends HeadlessScript {
requireKeys(params, Set.of("document", "address"));
requireDocument(params);
Address address = requireAddress(params, "address");
String at = canonicalAddress(address);
// A well-formed address can still lack one exact source offset. Each
// outcome has its own code, so REA reports the reason rather than
// schema repair. Initialization, not the loaded flag, decides whether
// source bytes can exist: file-backed overlays are not loaded.
MemoryBlock block = currentProgram.getMemory().getBlock(address);
if (block == null || !block.isInitialized())
throw new RequestFailure("invalid_request", "Address has no initialized file-backed memory: " + canonicalAddress(address));
if (block == null)
throw new RequestFailure("address_unmapped",
"Address " + at + " is outside every Ghidra memory block");
if (!block.isInitialized())
throw new RequestFailure("address_uninitialized",
"Address " + at + " is in uninitialized Ghidra memory block " + block.getName());
TargetFileOffset mapping = targetFileOffset(block, address);
if (mapping.mappings() != 1)
throw new RequestFailure("invalid_request", "Address has " + mapping.mappings() + " authoritative target-file mappings: " + canonicalAddress(address));
if (mapping.mappings() == 0)
throw new RequestFailure("file_mapping_absent",
"Address " + at + " in initialized Ghidra memory block " + block.getName() +
" has no authoritative mapping to the admitted target file");
if (mapping.mappings() > 1)
throw new RequestFailure("file_mapping_ambiguous",
"Address " + at + " has " + mapping.mappings() +
" authoritative mappings to the admitted target file");
Long fileOffset = mapping.offset();
if (fileOffset == null)
throw new RequestFailure("invalid_request", "Address lacks a safe file offset: " + canonicalAddress(address));
throw new RequestFailure("file_offset_unrepresentable",
"Address " + at + " maps to an admitted target-file offset beyond the exact JSON integer range");
JsonObject result = new JsonObject();
result.addProperty("address", canonicalAddress(address));
result.addProperty("address", at);
result.addProperty("file_offset", fileOffset);
return result;
}
+2 -1
View File
@@ -31,7 +31,8 @@ operation-scoped JVM `OutOfMemoryError` observations retain their separate resou
classification. Automatic kernel or cgroup OOM attribution is not implemented.
After the authenticated startup handshake, rejected inputs (`invalid_request`,
`not_found`, `ambiguous`, or an invalid function name) use
`not_found`, `ambiguous`, an invalid function name, or an address without one
exact file offset) use
`diagnostics.diagnostic_scope: "request"` when their active output checkpoint is
available. They preserve the active request's
`failure_output`, target/profile identity and runtime/log coordinates, while
+12 -1
View File
@@ -161,7 +161,9 @@ for the error code, target details, partial observations, remediation, and the
optional bounded `recovery` next step. `recovery.kind=inspect_input_contract`
names the rejected operation: re-read that operation's advertised input schema
(call `tools/list` if the catalog may be stale), correct the listed fields, and
do not repeat the unchanged call. `recovery.kind=inspect_timeout_state` names
do not repeat the unchanged call. A well-formed request the selected target
cannot answer, such as an address without one exact file offset, omits this
hint; follow its `remediation.action`. `recovery.kind=inspect_timeout_state` names
the operation and the REA server/provider deadline. Inspect partial Evidence
and reported cleanup state, then follow the returned remediation. Change a
request field or REA_* setting only when the returned remediation explicitly
@@ -1119,6 +1121,15 @@ mapping. A successful start-address mapping does not establish that an entire
range is contiguous in the original file, or that its bytes equal analysis
memory after loader fixups. Uninitialized, missing, and ambiguous mappings fail
explicitly; preserve the reported failure rather than guessing an offset.
For Ghidra, such a well-formed address returns `invalid_request` whose message
states the bridge's reason and whose remediation points to
`inspect_native_load_image`. It carries no `inspect_input_contract` recovery.
`details.issues[0].observed` keeps the caller's exact `address` and a
`file_mapping` outcome: `unmapped` (outside every memory block),
`uninitialized`, `absent` (no authoritative target-file source), `ambiguous`,
or `unrepresentable` (beyond the exact JSON integer range). Initialization, not
the loaded flag, decides whether source bytes can exist, so an initialized
file-backed overlay still maps.
For constructor-populated pointer or resource slots, a static zero is not proof
of a later null value. Inspect the slot's `xrefs` and the writer's assembly or
+3 -2
View File
@@ -1545,8 +1545,9 @@ real-provider coverage.
For source-identity boundaries that a normal single-file import cannot create,
`tests/conformance/ghidra/ReaFileOffsetSourceProbe.java` exercises the production
bridge against real Ghidra `FileBytes`. It rejects auxiliary and partial sources,
checks the original target's round trip, and retains ambiguity when duplicate
target bytes have distinct loaded mappings. Injected decompiler failures and
keeps distinct outcome codes for uninitialized and unmapped addresses, checks
the original target's round trip, and retains ambiguity when duplicate target
bytes have distinct loaded mappings. Injected decompiler failures and
cancellation verify that completed source/function resolution facts and backend
failure codes survive; these injections do not establish natural engine failures.
On Windows, after creating the
+1
View File
@@ -119,6 +119,7 @@ Then rerun this installer.",implemented,verified by shell branch inspection
90.5,@modelcontextprotocol/server:tool input validation,Caller sends arguments that violate a declared tool schema,"Canonical invalid_request with the SDK diagnostic plus recovery.kind=inspect_input_contract and a bounded instruction to read the advertised schema or call tools/list",Keeps field/type correction while telling the agent how to recover; diagnostics over 4 KiB stay on the existing retained-error path,Normalize ordinary validation failures and retain oversized diagnostics,implemented,verified by real in-memory and stdio MCP client/server calls with wrong argument types
90.6,incur:unknown CLI command,Caller enters an unknown REA command,"'COMMAND' is not a command for 'rea'. Suggested command: rea --help",Low risk; identifies input and exact discovery action,Keep framework-generated copy,unchanged,verified by real packaged dispatcher invocation
90.7,src/ghidra/GhidraProviderClient.ts:projectPrimarySessionError,One procedure's decompile reaches REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS,"Ghidra decompilation of {entry} exceeded the {N}-second limit set by REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS. Only this procedure's decompilation was stopped; the Ghidra session remains usable.",Low risk; names the setting and keeps the session,Keep current copy,implemented,provider projection test and real-provider lane
90.8,src/ghidra/GhidraFileOffsetFailure.ts:ghidraFileOffsetUnavailable,"address_to_file_offset receives a well-formed address that is unmapped, uninitialized, without an authoritative target-file source, ambiguous or beyond the exact JSON integer range",{bridge reason}. REA establishes no single exact original-file offset for this well-formed address and does not derive or choose one.,Previous copy called the input invalid and directed schema repair for a valid address,"Keep the bridge reason as the headline, point to inspect_native_load_image, omit recovery.kind=inspect_input_contract and record details.issues[0].observed.file_mapping",implemented,provider projection tests; MCP and CLI analysis path through the real Ghidra client with a simulated bridge; real Ghidra not run
91,src/application/binaryDiagnostics/ByteEntropyService.ts (issues); src/domain/analysisErrorPresentation.ts (recovery),Invalid file or window,Expected a regular file.; Selected file could not be found.; Selected byte window starts beyond the end of the file.; Selected byte window extends beyond the end of the file.; File size exceeds exact numeric offset representation.,Invalid selection cannot produce complete entropy Evidence,"Read the declared input schema or CLI usage for inspect_byte_entropy; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.",unchanged,Executed CLI/stdio MCP: path/offset/length refusals and tiny + system-binary controls; shared canonical recovery; service/MCP tests check classification and fields; remaining listed errors source-inspected
92,src/domain/analysisView/byteEntropyView.ts,Non-summary entropy view,Byte entropy Evidence supports the summary view; retrieve parent Evidence for the complete block series.,Unsupported view selection,Use the summary view or retrieve complete parent Evidence,implemented,typed input rejection
93,src/windows/WindowsNativeLoader.ts:load refused,Windows refuses to load the digest-verified native addon (for example Smart App Control or App Control policy),"REA Windows native controls are unavailable: Windows refused to load the verified native/windows/build/rea-windows-x64.node: {OS error} The file matched the package manifest, so restoring or reinstalling it will not help. ...",Previous artifact copy told users to restore or rebuild files that were already intact,"Name the OS refusal, say restoring cannot help, state that Ghidra and JADX need native controls while setup can still register clients, and point to a policy-allowed host or a maintainer-signed build",implemented,simulated loader refusal unit test; real Smart App Control block not reproduced
1 harm_rank location trigger current_copy user_risk proposed_replacement implementation_status verification_result
119
120
121
122
123
124
125
+8
View File
@@ -146,18 +146,26 @@ export class AnalysisInputError extends AnalysisError {
override readonly userMessage: string | undefined;
/** Refusal-specific correction, when the generic input-contract advice cannot fix it. */
readonly remediationAction: string | undefined;
/**
* The refused values satisfy the advertised input contract; the selected
* target lacks the requested fact for them. Re-reading the schema cannot
* correct such a refusal, so it carries no input-contract recovery.
*/
readonly conformsToInputContract: boolean;
constructor(
readonly operation: string,
options?: AnalysisErrorOptions & {
readonly userMessage?: string;
readonly remediationAction?: string;
readonly conformsToInputContract?: boolean;
},
readonly issues: readonly AnalysisInputIssue[] = [],
) {
super(`Invalid analysis input for ${operation}`, options);
this.userMessage = options?.userMessage;
this.remediationAction = options?.remediationAction;
this.conformsToInputContract = options?.conformsToInputContract ?? false;
}
}
@@ -130,6 +130,38 @@ describe("agent recovery metadata", () => {
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
});
it("omits schema recovery when a conforming request lacks a target fact", () => {
const issue = {
path: ["address"],
reason: "invalid_value",
message: "Address 0x2000 is in uninitialized memory block .bss",
observed: { address: "0x2000", file_mapping: "uninitialized" },
} as const;
const projected = projectAnalysisError(
new AnalysisInputError(
"address_to_file_offset",
{
userMessage: "No exact original-file offset exists.",
remediationAction: "Select an address inside a file-backed mapping.",
conformsToInputContract: true,
},
[issue],
),
);
expect(projected).toEqual({
code: "invalid_request",
category: "invalid_input",
message: "No exact original-file offset exists.",
retryable: false,
remediation: {
action: "Select an address inside a file-backed mapping.",
},
details: { operation: "address_to_file_offset", issues: [issue] },
});
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
});
it("separates timeout-state inspection from client deadline recovery", () => {
const projected = projectAnalysisError(
new AnalysisTimeoutError("inspect_binary_layout", 2_500),
+3 -4
View File
@@ -157,10 +157,9 @@ const errorRecovery = (
error: AnalysisError,
): AnalysisErrorRecovery | undefined => {
if (error instanceof AnalysisInputError)
return {
kind: "inspect_input_contract",
operation: error.operation,
};
return error.conformsToInputContract
? undefined
: { kind: "inspect_input_contract", operation: error.operation };
if (error instanceof AnalysisTimeoutError)
return {
kind: "inspect_timeout_state",
+73
View File
@@ -0,0 +1,73 @@
import type { AnalysisErrorOptions } from "../domain/analysisErrorBase.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
/**
* Bridge codes for a well-formed address without one exact original-file
* offset. The bridge owns the classification; consumers read these codes,
* never its message text.
*/
const FILE_OFFSET_OUTCOMES = {
address_unmapped: "unmapped",
address_uninitialized: "uninitialized",
file_mapping_absent: "absent",
file_mapping_ambiguous: "ambiguous",
file_offset_unrepresentable: "unrepresentable",
} as const;
/** Why Ghidra established no single exact offset for a well-formed address. */
export type GhidraFileOffsetOutcome =
(typeof FILE_OFFSET_OUTCOMES)[keyof typeof FILE_OFFSET_OUTCOMES];
/** Classify a bridge code, or undefined for any other remote failure. */
export const ghidraFileOffsetOutcome = (
code: string | undefined,
): GhidraFileOffsetOutcome | undefined =>
code !== undefined && Object.hasOwn(FILE_OFFSET_OUTCOMES, code)
? FILE_OFFSET_OUTCOMES[code as keyof typeof FILE_OFFSET_OUTCOMES]
: undefined;
const LOAD_IMAGE =
"inspect_native_load_image (CLI: `rea inspect-native-load-image <path> --provider ghidra --json`)";
const UNCHANGED = "Do not repeat the unchanged call.";
const SELECT_MAPPED = `Review Ghidra's memory blocks and source-file mappings with ${LOAD_IMAGE}, then select an address inside an initialized mapping of the admitted file. ${UNCHANGED}`;
const REMEDIATION: Readonly<Record<GhidraFileOffsetOutcome, string>> = {
unmapped: SELECT_MAPPED,
uninitialized: SELECT_MAPPED,
absent: SELECT_MAPPED,
ambiguous: `Review the candidate source-file mappings with ${LOAD_IMAGE} and keep each candidate offset distinct; REA does not choose one. ${UNCHANGED}`,
unrepresentable: `Review Ghidra's source-file mappings with ${LOAD_IMAGE}; REA reports offsets only within the exact JSON integer range. ${UNCHANGED}`,
};
/**
* Report the bridge's reason as the headline. The request satisfied its
* contract, so the projection omits schema-repair recovery and names the
* mapping outcome beside the caller's exact address.
*/
export const ghidraFileOffsetUnavailable = (
operation: string,
outcome: GhidraFileOffsetOutcome,
reason: string,
address: string | undefined,
options: AnalysisErrorOptions,
): AnalysisInputError =>
new AnalysisInputError(
operation,
{
...options,
userMessage: `${reason.trim().replace(/\.$/u, "")}. REA establishes no single exact original-file offset for this well-formed address and does not derive or choose one.`,
remediationAction: REMEDIATION[outcome],
conformsToInputContract: true,
},
[
{
path: ["address"],
reason: "invalid_value",
message: reason,
observed: {
...(address === undefined ? {} : { address }),
file_mapping: outcome,
},
},
],
);
+115
View File
@@ -1338,6 +1338,121 @@ describe("Ghidra result projection", () => {
});
});
describe("Ghidra file-offset outcome projection", () => {
const remoteFailure = (remoteCode: string, message: string) =>
provider(installationHost(), () => ({
start: () => Promise.resolve(ok(sessionInfo())),
callTool: () =>
Promise.resolve(
err(
new GhidraSessionError(
"remote",
message,
{ remote_code: remoteCode },
{ remoteCode },
),
),
),
close: () => Promise.resolve(ok(null)),
}));
it.each([
[
"address_unmapped",
"unmapped",
"Address 0x403030 is outside every Ghidra memory block",
],
[
"address_uninitialized",
"uninitialized",
"Address 0x403010 is in uninitialized Ghidra memory block .bss",
],
[
"file_mapping_absent",
"absent",
"Address 0x403010 in initialized Ghidra memory block auxiliary has no authoritative mapping to the admitted target file",
],
[
"file_mapping_ambiguous",
"ambiguous",
"Address 0x403010 has 2 authoritative mappings to the admitted target file",
],
[
"file_offset_unrepresentable",
"unrepresentable",
"Address 0x403010 maps to an admitted target-file offset beyond the exact JSON integer range",
],
] as const)(
"reports %s as a conforming address without an exact offset",
async (remoteCode, outcome, message) => {
const client = await createElfClient(remoteFailure(remoteCode, message));
// A non-canonical spelling the bridge accepts survives verbatim.
const result = await client.execute("address_to_file_offset", {
address: "0X00403010",
});
if (result.ok) throw new Error("Expected a file-offset outcome");
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "invalid_request",
category: "invalid_input",
retryable: false,
details: {
operation: "address_to_file_offset",
issues: [
{
path: ["address"],
reason: "invalid_value",
message,
observed: { address: "0X00403010", file_mapping: outcome },
},
],
diagnostics: { remote_code: remoteCode },
},
});
expect(projected.message.startsWith(`${message}. `)).toBe(true);
expect(projected.message).not.toContain("Analysis input is invalid");
expect(projected).not.toHaveProperty("recovery");
expect(projected.remediation.action).toContain(
"inspect_native_load_image",
);
expect(projected.remediation.action).not.toContain("schema");
expect(projected.remediation.action).not.toContain("tools/list");
await client.close();
},
);
it.each([
["address_to_file_offset", "invalid_request"],
["procedure_pseudo_code", "not_found"],
] as const)(
"keeps schema recovery for an ordinary %s %s refusal",
async (operation, remoteCode) => {
const client = await createElfClient(
remoteFailure(remoteCode, "Fixture bridge rejected the request"),
);
const result = await client.execute(
operation,
operation === "address_to_file_offset"
? { address: "0x403010" }
: { procedure: "missing" },
);
if (result.ok) throw new Error("Expected an input refusal");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
message:
"Analysis input is invalid. Check the arguments and try again.",
recovery: { kind: "inspect_input_contract", operation },
details: {
issues: [
{ path: [], message: "Fixture bridge rejected the request" },
],
},
});
await client.close();
},
);
});
describe("Ghidra decompiler failure projection", () => {
it("reports a procedure decompiler failure without blaming the environment", async () => {
const code = "decompile_failed";
+22
View File
@@ -111,6 +111,10 @@ import {
isGhidraInputFailureCode,
type GhidraSessionError,
} from "./GhidraSessionError.js";
import {
ghidraFileOffsetOutcome,
ghidraFileOffsetUnavailable,
} from "./GhidraFileOffsetFailure.js";
import type { GhidraSessionInfo } from "./GhidraSessionValues.js";
import { ghidraExtensionFailure } from "./extensions/GhidraExtensionFailures.js";
import {
@@ -827,6 +831,7 @@ export const createGhidraProviderClient = (input: {
called.error,
projection,
lostProviderHealth(),
input.value,
),
);
if (operation === "inspect_native_load_image")
@@ -1037,12 +1042,14 @@ const projectSessionError = (
failure: GhidraSessionError,
projection: GhidraProjection,
health?: ProviderOperationHealth,
request?: Readonly<Record<string, JsonValue>>,
): AnalysisError => {
const primary = projectPrimarySessionError(
operation,
failure,
projection,
health,
request,
);
return failure.cleanupFailure === undefined
? primary
@@ -1058,6 +1065,7 @@ const projectPrimarySessionError = (
failure: GhidraSessionError,
{ limits, programName }: GhidraProjection,
health?: ProviderOperationHealth,
request?: Readonly<Record<string, JsonValue>>,
): AnalysisError => {
const startupTimeoutMs = limits.ghidraStartupTimeoutMs;
const context = {
@@ -1101,6 +1109,20 @@ const projectPrimarySessionError = (
physicalCause.reason,
context,
);
// A well-formed address that lacks one exact source offset keeps the
// bridge's reason and the caller's exact spelling, not schema advice.
const fileOffset =
failure.kind === "remote"
? ghidraFileOffsetOutcome(failure.remoteCode)
: undefined;
if (fileOffset !== undefined)
return ghidraFileOffsetUnavailable(
operation,
fileOffset,
failure.message,
typeof request?.address === "string" ? request.address : undefined,
context,
);
if (
operation === "annotate_native_function" &&
failure.kind === "remote" &&
+3 -1
View File
@@ -7,6 +7,7 @@ import type { JsonValue } from "../domain/jsonValue.js";
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
import type { GhidraDiagnosticsScope } from "./GhidraDiagnostics.js";
import { ghidraFileOffsetOutcome } from "./GhidraFileOffsetFailure.js";
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
import { GhidraLaunchSupportError } from "./GhidraJavaLaunch.js";
import {
@@ -214,7 +215,8 @@ export const bindGhidraSessionFailure =
...diagnostics(
kind === "remote" &&
(isGhidraInputFailureCode(options.remoteCode) ||
options.remoteCode === "invalid_function_name")
options.remoteCode === "invalid_function_name" ||
ghidraFileOffsetOutcome(options.remoteCode) !== undefined)
? "request"
: "session",
),
@@ -0,0 +1,311 @@
import { access } from "node:fs/promises";
import { expect, it, vi } from "vitest";
import { z } from "zod";
import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js";
import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js";
import { runDirectAnalysis } from "../../../src/application/DirectAnalysis.js";
import type { DirectAnalysisDependencies } from "../../../src/application/DirectAnalysisDependencies.js";
import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { analysisCliErrorEnvelopeSchema } from "../../../src/contracts/errorSchemas.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { GhidraClient } from "../../../src/ghidra/GhidraClient.js";
import { ghidraProfileInstallationHost } from "../../../src/ghidra/GhidraInstallation.fixture.js";
import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js";
import { silentLogger } from "../../../src/logger.js";
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { GhidraFixtureLauncher } from "../providers/ghidra/ghidraClient.fixture.js";
// Only installation discovery and the Java bridge are simulated. The bridge
// fixture answers with the production address_to_file_offset codes; requests
// cross the real client socket, provider projection and public adapters.
const ghidraSession = (launcher: GhidraFixtureLauncher) => {
const config = parseConfig({ GHIDRA_INSTALL_DIR: "/fixture/ghidra" });
if (!config.ok) throw config.error;
return composeBinarySession(
new AnalysisProviderRegistry([
new GhidraProvider(
config.value,
silentLogger,
{},
ghidraProfileInstallationHost,
(options) =>
new GhidraClient({
...options,
platform: process.platform,
launcher,
}),
),
]),
);
};
const expectReleased = async (launcher: GhidraFixtureLauncher) => {
for (const root of launcher.runtimeRoots)
await expect(access(root)).rejects.toMatchObject({ code: "ENOENT" });
for (const child of launcher.processes)
expect(child.exitCode !== null || child.signalCode !== null).toBe(true);
};
const sessionStatus = z.object({
result: z.object({
analysis_run: z.object({ run_id: z.string() }),
capabilities: z.array(
z.object({
groups: z.array(
z.object({
operations: z.array(z.string()),
available: z.boolean(),
}),
),
}),
),
}),
});
/** Open the process executable through the fixture bridge and public MCP. */
const connectFileOffsetMcp = async () => {
const launcher = new GhidraFixtureLauncher();
const harness = await createApplicationMcpHarness({
session: ghidraSession(launcher),
serverOptions: { logger: silentLogger },
clientIdentity: { name: "ghidra-file-offset", version: "1" },
});
const tool = (name: string, arguments_: Record<string, string> = {}) =>
harness.client.callTool({ name, arguments: arguments_ });
const call = (address: string) => tool("address_to_file_offset", { address });
const mapped = async (address: string) => {
const reply = await call(address);
expect(reply.isError, JSON.stringify(reply)).not.toBe(true);
return parseEvidence(reply.structuredContent).normalized_result;
};
const refused = async (address: string) =>
parseMcpToolError(await call(address)).error;
// A per-address outcome must not mark the operation unavailable.
const runId = async () => {
const status = sessionStatus.parse(
(await tool("binary_session")).structuredContent,
).result;
expect(
status.capabilities
.flatMap(({ groups }) => groups)
.filter(({ operations }) =>
operations.includes("address_to_file_offset"),
),
).toEqual([expect.objectContaining({ available: true })]);
return status.analysis_run.run_id;
};
const bridgeCalls = () =>
launcher.requests.filter((method) => method === "address_to_file_offset");
const close = async () => {
await harness.close();
await expectReleased(launcher);
};
try {
const opened = await tool("open_binary", {
path: process.execPath,
provider_id: "ghidra",
});
expect(opened.isError).not.toBe(true);
} catch (cause) {
await close();
throw cause;
}
return { launcher, tool, mapped, refused, runId, bridgeCalls, close };
};
const BSS_MESSAGE =
"Address 0x2004 is in uninitialized Ghidra memory block .bss";
it("reports file-backed, uninitialized and unmapped addresses through MCP without schema repair", async () => {
const mcp = await connectFileOffsetMcp();
try {
expect(await mcp.mapped("0x1005")).toEqual({
address: "0x1005",
file_offset: 5,
});
const run = await mcp.runId();
// The caller's non-canonical spelling survives beside the canonical reason.
const bss = await mcp.refused("0X2004");
expect(bss).toMatchObject({
code: "invalid_request",
category: "invalid_input",
retryable: false,
details: {
operation: "address_to_file_offset",
issues: [
{
path: ["address"],
reason: "invalid_value",
message: BSS_MESSAGE,
observed: { address: "0X2004", file_mapping: "uninitialized" },
},
],
diagnostics: {
diagnostic_scope: "request",
remote_code: "address_uninitialized",
},
},
});
expect(bss.message.startsWith(`${BSS_MESSAGE}. `)).toBe(true);
expect(bss).not.toHaveProperty("recovery");
expect(bss.remediation.action).toContain("inspect_native_load_image");
expect(bss.remediation.action).not.toContain("tools/list");
expect(bss.details?.diagnostics).not.toHaveProperty("stdout");
const unmapped = await mcp.refused("0x3000");
expect(unmapped).toMatchObject({
code: "invalid_request",
details: {
issues: [
{
message: "Address 0x3000 is outside every Ghidra memory block",
observed: { address: "0x3000", file_mapping: "unmapped" },
},
],
diagnostics: { remote_code: "address_unmapped" },
},
});
expect(unmapped).not.toHaveProperty("recovery");
// Malformed syntax is still an input-contract violation.
expect(await mcp.refused("not-an-address")).toMatchObject({
code: "invalid_request",
message: "Analysis input is invalid. Check the arguments and try again.",
recovery: {
kind: "inspect_input_contract",
operation: "address_to_file_offset",
},
details: {
issues: [{ path: ["address"], reason: "invalid_format" }],
},
});
// Refusals keep the same provider session and operation availability.
expect(await mcp.runId()).toBe(run);
expect(await mcp.mapped("0x1000")).toEqual({
address: "0x1000",
file_offset: 0,
});
expect(mcp.launcher.processes).toHaveLength(1);
// The malformed spelling never reached the bridge.
await vi.waitFor(() => expect(mcp.bridgeCalls()).toHaveLength(4));
} finally {
await mcp.close();
}
}, 30_000);
const segments = z.array(
z.object({ name: z.string(), start: z.string(), end: z.string() }),
);
const skippedCoverage = z.object({
searched: z.object({
skipped_uninitialized: z.array(
z.object({ segment: z.string(), start: z.string(), end: z.string() }),
),
}),
});
const hex = (value: bigint) => `0x${value.toString(16)}`;
it("maps the last byte of a file-backed block and refuses the first address past each block", async () => {
const mcp = await connectFileOffsetMcp();
try {
// Take block edges from the bridge's own replies: list_segments ends are
// exclusive and byte-search coverage ends are inclusive.
const text = segments
.parse(
parseEvidence((await mcp.tool("list_segments")).structuredContent)
.normalized_result,
)
.find(({ name }) => name === ".text");
const bss = skippedCoverage
.parse(
parseEvidence(
(await mcp.tool("search_bytes", { pattern: "48 8b" }))
.structuredContent,
).normalized_result,
)
.searched.skipped_uninitialized.find(({ segment }) => segment === ".bss");
expect(text).toMatchObject({ start: "0x1000", end: "0x1100" });
expect(bss).toMatchObject({ start: "0x2000", end: "0x200f" });
if (text === undefined || bss === undefined)
throw new Error("Fixture omitted its memory blocks");
const lastText = BigInt(text.end) - 1n;
expect(await mcp.mapped(hex(lastText))).toEqual({
address: hex(lastText),
file_offset: Number(lastText - BigInt(text.start)),
});
for (const [value, remoteCode, outcome] of [
[BigInt(text.end), "address_unmapped", "unmapped"],
[BigInt(bss.start), "address_uninitialized", "uninitialized"],
[BigInt(bss.end), "address_uninitialized", "uninitialized"],
[BigInt(bss.end) + 1n, "address_unmapped", "unmapped"],
] as const)
expect(await mcp.refused(hex(value))).toMatchObject({
code: "invalid_request",
details: {
issues: [
{ observed: { address: hex(value), file_mapping: outcome } },
],
diagnostics: { remote_code: remoteCode },
},
});
await vi.waitFor(() => expect(mcp.bridgeCalls()).toHaveLength(5));
} finally {
await mcp.close();
}
}, 30_000);
it("gives the CLI analysis path the same unbacked-address outcome as MCP", async () => {
const launcher = new GhidraFixtureLauncher();
const create = () => ghidraSession(launcher);
const dependencies: DirectAnalysisDependencies = {
readConfiguration: () =>
parseConfig({ GHIDRA_INSTALL_DIR: "/fixture/ghidra" }),
createBinarySession: create,
createNativeDispatchBinarySession: create,
createArtifactBinarySession: create,
createNativeBinarySession: create,
createManagedBinarySession: create,
};
try {
const projected = analysisCliErrorEnvelopeSchema.parse(
projectCliAnalysisResult(
await runDirectAnalysis(
dependencies,
process.execPath,
"address_to_file_offset",
{ address: "0X2004" },
{ providerId: "ghidra" },
),
),
);
expect(projected).toMatchObject({
error: "Analysis failed",
code: "invalid_request",
category: "invalid_input",
message: expect.stringMatching(/^Address 0x2004 is in uninitialized/u),
remediation: {
action: expect.stringContaining("inspect_native_load_image"),
},
details: {
issues: [
{
path: ["address"],
message: BSS_MESSAGE,
observed: { address: "0X2004", file_mapping: "uninitialized" },
},
],
diagnostics: { remote_code: "address_uninitialized" },
},
});
expect(projected).not.toHaveProperty("recovery");
} finally {
await expectReleased(launcher);
}
}, 30_000);
@@ -50,14 +50,23 @@ public final class ReaFileOffsetSourceProbe extends HeadlessScript {
currentProgram.getFunctionManager().createFunction("foreign_only", entry,
new AddressSet(entry), SourceType.USER_DEFINED);
rejected(resolve, bridge, "no loaded source mapping", "file-offset:0x910", new JsonObject());
rejected(reverse, bridge, "0 authoritative target-file mappings", reverseInput("0x140100910"));
rejectedCode(reverse, bridge, "file_mapping_absent",
"has no authoritative mapping to the admitted target file", reverseInput("0x140100910"));
// Well-formed addresses without source bytes keep distinct outcomes.
memory.createUninitializedBlock("rea_uninitialized", toAddr(0x140400000L), 0x100, false);
rejectedCode(reverse, bridge, "address_uninitialized",
"is in uninitialized Ghidra memory block rea_uninitialized", reverseInput("0x140400010"));
rejectedCode(reverse, bridge, "address_unmapped",
"is outside every Ghidra memory block", reverseInput("0x140500000"));
// A source object's declared original-file start does not make its local
// offsets a complete, digest-matched target representation.
var partial = memory.createFileBytes("partial.bin", 0x200, 0x200,
new ByteArrayInputStream(bytes, 0x200, 0x200), monitor);
memory.createInitializedBlock("partial", toAddr(0x140300000L), partial, 0, 0x200, false);
rejected(reverse, bridge, "0 authoritative target-file mappings", reverseInput("0x140300010"));
rejectedCode(reverse, bridge, "file_mapping_absent",
"has no authoritative mapping to the admitted target file", reverseInput("0x140300010"));
var selected = (Function)resolve.invoke(bridge, "file-offset:0x210", new JsonObject());
require(selected.getEntryPoint().equals(toAddr(0x140001010L)), "original target selected despite other sources");
var mapped = (JsonObject)reverse.invoke(bridge, reverseInput("0x140001010"));
@@ -92,7 +101,7 @@ public final class ReaFileOffsetSourceProbe extends HeadlessScript {
new ByteArrayInputStream(bytes), monitor);
memory.createInitializedBlock("duplicate", toAddr(0x140201000L), duplicate, 0x200, 0x200, false);
rejected(resolve, bridge, "multiple loaded mappings", "file-offset:0x210", new JsonObject());
println("REA_FILE_OFFSET_SOURCE_OK foreign/partial rejected; original round trip; duplicate ambiguity; injected backend failure context retained");
println("REA_FILE_OFFSET_SOURCE_OK foreign/partial/uninitialized/unmapped rejected; original round trip; duplicate ambiguity; injected backend failure context retained");
}
private JsonObject reverseInput(String address) {
@@ -112,6 +121,13 @@ public final class ReaFileOffsetSourceProbe extends HeadlessScript {
throw new AssertionError("Expected mapping failure: " + reason);
}
private void rejectedCode(Method method, Object bridge, String code, String reason, Object... arguments) throws Exception {
Throwable failure = rejected(method, bridge, reason, arguments);
var field = failure.getClass().getDeclaredField("code");
field.setAccessible(true);
require(field.get(failure).equals(code), "expected mapping failure code: " + code);
}
private void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
+62 -4
View File
@@ -264,6 +264,12 @@ const handleRequest = (socket, server, request, state) => {
return;
}
}
if (request.method === "address_to_file_offset") {
socket.write(
`${JSON.stringify({ id: request.id, ...fileOffsetReply(request.params.address) })}\n`,
);
return;
}
const inventory =
request.method === "list_strings" && state.mode === "fragmented"
? [stringItem("0x2000", "fixture 雪🦊")]
@@ -479,7 +485,12 @@ const inventoryResultFor = (method, params) => {
],
bytes: continued ? 5 : 10,
skipped_uninitialized: [
{ segment: ".bss", start: "0x2000", end: "0x200f", bytes: 16 },
{
segment: BSS_BLOCK.name,
start: hex(BSS_BLOCK.start),
end: hex(BSS_BLOCK.start + BSS_BLOCK.size - 1n),
bytes: Number(BSS_BLOCK.size),
},
],
skipped_not_loaded: [],
},
@@ -512,6 +523,53 @@ const inventoryResultFor = (method, params) => {
}
};
// One owner for the fixture's memory blocks. Each reply uses the production
// bridge's end convention: list_segments reports an exclusive end, byte-search
// coverage an inclusive one, and address_to_file_offset tests containment.
const TEXT_BLOCK = {
name: ".text",
start: 0x1000n,
size: 0x100n,
fileOffset: 0n,
};
const BSS_BLOCK = {
name: ".bss",
start: 0x2000n,
size: 0x10n,
fileOffset: null,
};
const FIXTURE_BLOCKS = [TEXT_BLOCK, BSS_BLOCK];
const hex = (value) => `0x${value.toString(16)}`;
const fileOffsetReply = (address) => {
const digits = /^(?:0[xX])?([0-9a-fA-F]+)$/u.exec(address)?.[1];
const failure = (code, message) => ({ ok: false, error: { code, message } });
if (digits === undefined)
return failure("invalid_request", `Invalid address: ${address}`);
const value = BigInt(`0x${digits}`);
const at = hex(value);
const block = FIXTURE_BLOCKS.find(
({ start, size }) => value >= start && value < start + size,
);
if (block === undefined)
return failure(
"address_unmapped",
`Address ${at} is outside every Ghidra memory block`,
);
if (block.fileOffset === null)
return failure(
"address_uninitialized",
`Address ${at} is in uninitialized Ghidra memory block ${block.name}`,
);
return {
ok: true,
result: {
address: at,
file_offset: Number(block.fileOffset + value - block.start),
},
};
};
const nameItem = () => ({
address: "0x1000",
value: "fixture_main",
@@ -531,9 +589,9 @@ const procedureItem = () => ({
});
const segmentItem = () => ({
name: ".text",
start: "0x1000",
end: "0x1100",
name: TEXT_BLOCK.name,
start: hex(TEXT_BLOCK.start),
end: hex(TEXT_BLOCK.start + TEXT_BLOCK.size),
readable: true,
writable: false,
executable: true,