diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index b1110e1d8c..99bf59c93d 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -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; } diff --git a/docs/ghidra-provider.md b/docs/ghidra-provider.md index 3110d662d3..e4d2c6bff7 100644 --- a/docs/ghidra-provider.md +++ b/docs/ghidra-provider.md @@ -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 diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index af2e386399..dbf45235ff 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -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 diff --git a/docs/testing.md b/docs/testing.md index 94e5abde6f..119b7703f2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 diff --git a/docs/user-visible-errors.csv b/docs/user-visible-errors.csv index 3434d4e1bb..b5fb1bf107 100644 --- a/docs/user-visible-errors.csv +++ b/docs/user-visible-errors.csv @@ -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 diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts index 5d1e96f1f3..cb689c448e 100644 --- a/src/domain/analysisErrorCore.ts +++ b/src/domain/analysisErrorCore.ts @@ -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; } } diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index 6fdd2b5b3e..6235c24984 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -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), diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 667c962994..c2982baa7f 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -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", diff --git a/src/ghidra/GhidraFileOffsetFailure.ts b/src/ghidra/GhidraFileOffsetFailure.ts new file mode 100644 index 0000000000..6ba217da57 --- /dev/null +++ b/src/ghidra/GhidraFileOffsetFailure.ts @@ -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 --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> = { + 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, + }, + }, + ], + ); diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index b2765445b5..b4a7b1ca5c 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -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"; diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 915e2e0780..90d069c743 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -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>, ): 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>, ): 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" && diff --git a/src/ghidra/GhidraSessionError.ts b/src/ghidra/GhidraSessionError.ts index 33f3d6e532..1fe87cb1cf 100644 --- a/src/ghidra/GhidraSessionError.ts +++ b/src/ghidra/GhidraSessionError.ts @@ -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", ), diff --git a/tests/boundary/mcp/ghidraFileOffsetMcp.test.ts b/tests/boundary/mcp/ghidraFileOffsetMcp.test.ts new file mode 100644 index 0000000000..5251c07c73 --- /dev/null +++ b/tests/boundary/mcp/ghidraFileOffsetMcp.test.ts @@ -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 = {}) => + 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); diff --git a/tests/conformance/ghidra/ReaFileOffsetSourceProbe.java b/tests/conformance/ghidra/ReaFileOffsetSourceProbe.java index 57da9d28ef..9c3bf4e1cf 100644 --- a/tests/conformance/ghidra/ReaFileOffsetSourceProbe.java +++ b/tests/conformance/ghidra/ReaFileOffsetSourceProbe.java @@ -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); } diff --git a/tests/fixtures/fakeGhidra.mjs b/tests/fixtures/fakeGhidra.mjs index 610024ef4f..99cd7310a8 100644 --- a/tests/fixtures/fakeGhidra.mjs +++ b/tests/fixtures/fakeGhidra.mjs @@ -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,