From 865c35e25a0123c3c3a24d63f98d7c585e609091 Mon Sep 17 00:00:00 2001 From: Hifza Zafar <152292315+hifzabuildsai@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:17:27 +0500 Subject: [PATCH] fix(ghidra): disclose missing PDB symbol coverage (#2275) * fix(ghidra): disclose missing PDB symbol coverage * style: format reviewed source changes * fix(ghidra): bound PDB handshake and retain coverage uncertainty --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: morluto <17176810+morluto@users.noreply.github.com> --- bridge/ghidra/ReaGhidraBridge.java | 40 +++++++ docs/ghidra-provider.md | 20 ++++ src/ghidra/GhidraAnalysisProfile.ts | 3 + src/ghidra/GhidraProvider.behavior.test.ts | 107 ++++++++++++++++++ src/ghidra/GhidraProviderClient.ts | 26 ++++- src/ghidra/GhidraSessionValues.test.ts | 43 +++++++ src/ghidra/GhidraSessionValues.ts | 71 ++++++++++++ tests/boundary/mcp/ghidraInventoryMcp.test.ts | 76 +++++++++++++ tests/boundary/mcp/ghidraMcpHarness.ts | 12 +- 9 files changed, 391 insertions(+), 7 deletions(-) diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index c180c5866..7c90d2e4a 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -56,6 +56,8 @@ import ghidra.app.decompiler.DecompiledFunction; import ghidra.app.decompiler.ClangCaseToken; import ghidra.app.decompiler.ClangNode; import ghidra.framework.Application; +import ghidra.app.util.bin.format.pdb.PdbParserConstants; +import ghidra.framework.options.Options; import ghidra.program.model.address.Address; import ghidra.program.model.address.AddressIterator; import ghidra.program.model.address.AddressSet; @@ -110,6 +112,8 @@ import ghidra.program.util.SymbolicPropogator; import ghidra.program.util.VarnodeContext; public final class ReaGhidraBridge extends HeadlessScript { + // At most 24 KiB after worst-case JSON escaping (six bytes per UTF-16 unit). + private static final int PDB_ATTRIBUTE_UTF16_BUDGET = 4096; private static final int MAX_HIGH_PCODE_OPS = 3000; private static final int MAX_HIGH_PCODE_INPUTS_PER_OP = 64; private static final int MAX_HIGH_PCODE_DEF_USE_EDGES = 12000; @@ -454,6 +458,7 @@ public final class ReaGhidraBridge extends HeadlessScript { .toArray(String[]::new) )); result.add("target", target); + result.add("pdb", pdbObservation()); JsonArray extensions = analysisExtensions.reports(); if (extensions.size() > 0) result.add("analysis_extensions", extensions); // Written by ReaGhidraApplySeeds before auto-analysis; REA checks it @@ -469,6 +474,41 @@ public final class ReaGhidraBridge extends HeadlessScript { return result; } + /** Read loader/analyzer-owned facts without searching for or applying companions. */ + private JsonObject pdbObservation() { + Options info = currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO); + JsonObject pdb = new JsonObject(); + String[] names = {"file", "guid", "age", "signature", "version"}; + String[] attributes = { + PdbParserConstants.PDB_FILE, PdbParserConstants.PDB_GUID, + PdbParserConstants.PDB_AGE, PdbParserConstants.PDB_SIGNATURE, + PdbParserConstants.PDB_VERSION + }; + int remaining = PDB_ATTRIBUTE_UTF16_BUDGET; + JsonArray omitted = new JsonArray(); + for (int index = 0; index < names.length; index++) { + String value = info.getString(attributes[index], null); + if (value != null && value.length() > remaining) { + omitted.add(names[index]); + pdb.add(names[index], JsonNull.INSTANCE); + } else { + pdb.addProperty(names[index], value); + if (value != null) remaining -= value.length(); + } + } + if (omitted.size() > 0) { + JsonObject limit = new JsonObject(); + limit.addProperty("utf16_budget", PDB_ATTRIBUTE_UTF16_BUDGET); + limit.add("omitted_attributes", omitted); + pdb.add("resource_limit", limit); + } + if (info.contains(PdbParserConstants.PDB_LOADED)) + pdb.addProperty("loaded", info.getBoolean(PdbParserConstants.PDB_LOADED, false)); + else + pdb.add("loaded", JsonNull.INSTANCE); + return pdb; + } + private static String bytesHex(byte[] bytes) { return java.util.HexFormat.of().formatHex(bytes); } diff --git a/docs/ghidra-provider.md b/docs/ghidra-provider.md index 5b83ee152..0876b8ad5 100644 --- a/docs/ghidra-provider.md +++ b/docs/ghidra-provider.md @@ -41,6 +41,26 @@ does not prove text is absent. Both list and search Evidence carry this limitation. When an address is known, use `read_bytes` and `xrefs` to inspect bytes and references. +## PE debug-symbol coverage + +The post-analysis handshake reports Ghidra's PDB Program Information attributes: +the referenced file, GUID, hexadecimal age, legacy signature, version, and +`PDB Loaded` state. Absent attributes remain null. PE Evidence carries a +limitation when a PDB reference is present but Ghidra reports it as not loaded, +or its load state is unknown. A bridge without this report leaves PDB coverage +unknown. A reported loaded state is a Ghidra observation, not an independent +PDB identity or complete symbol-coverage attestation, and retains an inline +coverage caveat. Handshake PDB strings share a 4096 UTF-16-unit serialization +budget (at most 24 KiB after JSON escaping); whole attributes that do not fit +are omitted with an explicit resource limitation, rather than truncated. + +Original names and types may be missing even when auto-analysis completes. +An empty name search does not establish that a function is absent; use +`list_procedures` and address-based analysis to inspect recovered functions. +REA imports a private executable copy and does not admit a caller-selected +PDB companion. This reporting does not search symbol servers, copy a sibling +PDB, or change analyzer settings. + ## Loaded addresses and file offsets `rea analyze --provider ghidra --json` and MCP `binary_overview` report diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 66be760bf..d4bd5a433 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -206,6 +206,9 @@ export const resolveGhidraAnalysisProfile = async ( ...(target.availableArchitectures ?? []), ].sort(), import_mode: "ephemeral-source-immutable", + ...(target.format === "pe" + ? { pdb_observations: "program-info-v1" } + : {}), annotation_policy: "atomic-function-entry-metadata-v1", load_image_observations: "source-mappings-entry-context-v2", function_body_evidence: "complete-inclusive-ranges-v1", diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index c9084905b..ccdfd96f7 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -1541,6 +1541,113 @@ const sessionInfo = () => ({ }, }); +describe("Ghidra PDB coverage", () => { + it.each([ + { name: "missing bridge report", pdb: undefined, warns: true }, + { + name: "report without a PDB reference", + pdb: { + file: null, + guid: null, + age: null, + signature: null, + version: null, + loaded: null, + }, + warns: false, + }, + ])("distinguishes $name in direct PE searches", async ({ pdb, warns }) => { + const ghidra = provider(installationHost(), () => ({ + start: () => + Promise.resolve( + ok(pdb === undefined ? sessionInfo() : { ...sessionInfo(), pdb }), + ), + callTool: () => Promise.resolve(ok([])), + close: () => Promise.resolve(ok(null)), + })); + const target = executableTarget("pe", "x86_64"); + const resolved = await ghidra.resolveAnalysisProfile(target); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("Expected a PE profile"); + const client = ghidra.createClient(target, resolved.value.profile); + try { + const searched = await client.execute("search_procedures", { + pattern: "add_numbers", + }); + if (!searched.ok) throw searched.error; + expect(searched.value.result).toEqual([]); + const warnings = searched.value.limitations.filter((limitation) => + limitation.includes("PDB"), + ); + expect(warnings).toEqual( + warns + ? [ + "Ghidra did not report PDB attributes; debug-symbol coverage is unknown.", + ] + : [], + ); + } finally { + await client.close(); + } + }); + const pdb = { + file: "C:\\build\\fixture.pdb", + guid: "01234567-89AB-CDEF-0123-456789ABCDEF", + age: "a", + signature: null, + version: "RSDS", + loaded: false as boolean | null, + }; + it.each([false, null, true])( + "carries PDB load state %s into direct search and health Evidence", + async (loaded) => { + let starts = 0; + const ghidra = provider(installationHost(), () => ({ + start: () => { + starts += 1; + return Promise.resolve( + ok({ ...sessionInfo(), pdb: { ...pdb, loaded } }), + ); + }, + callTool: () => Promise.resolve(ok([])), + close: () => Promise.resolve(ok(null)), + })); + const target = executableTarget("pe", "x86_64"); + const resolved = await ghidra.resolveAnalysisProfile(target); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("Expected a PE profile"); + const client = ghidra.createClient(target, resolved.value.profile); + // No preceding health call: a name search must establish symbol coverage itself. + const searched = await client.execute("search_procedures", { + pattern: "add_numbers", + }); + expect(starts).toBe(1); + if (!searched.ok) throw searched.error; + expect(searched.value.result).toEqual([]); + const health = await client.execute("health", {}); + if (!health.ok) throw health.error; + expect(health.value.result).toMatchObject({ pdb: { ...pdb, loaded } }); + for (const observation of [searched.value, health.value]) { + const limitations = observation.limitations.join("\n"); + if (loaded === true) { + expect(limitations).toContain("loaded PDB"); + expect(limitations).toContain("not an independent PDB identity"); + expect(limitations).toContain("empty name search does not prove"); + } else { + expect(limitations).toContain(pdb.file); + expect(limitations).toContain(pdb.guid); + expect(limitations).toContain("age (hex) a"); + expect(limitations).toContain("list_procedures"); + expect(limitations).toContain( + loaded === null ? "unknown" : "not loaded", + ); + } + } + await client.close(); + }, + ); +}); + const executableTarget = ( format: "mach-o" | "elf" | "pe", architecture: Extract["architecture"], diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index cff45f78b..25838453e 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -88,7 +88,10 @@ import { relationshipCoverageFor, } from "./GhidraProviderCapabilities.js"; import type { GhidraSessionError } from "./GhidraSessionError.js"; -import type { GhidraSessionInfo } from "./GhidraSessionValues.js"; +import { + ghidraPdbLimitations, + type GhidraSessionInfo, +} from "./GhidraSessionValues.js"; import { ghidraExtensionFailure } from "./extensions/GhidraExtensionFailures.js"; import { ghidraExtensionSchema, @@ -526,6 +529,7 @@ export const createGhidraProviderClient = (input: { let extensionFailure: AnalysisError | undefined; // Replaced by the reported outcome once the session handshake is checked. const seedLimitations: string[] = [...ghidraSeedLimitations(seeds)]; + const pdbLimitations: string[] = []; // Replaced by the reported storage outcome once the handshake is checked. const softFloatLimitations: string[] = [ ...ghidraMipsSoftFloatLimitations(softFloat), @@ -646,10 +650,16 @@ export const createGhidraProviderClient = (input: { ...(context === undefined ? {} : { runId: context.runId }), logger: logger.child({ layer: "ghidra-bridge" }), }); - const checkExtensions = async ( + const checkSessionObservations = async ( operation: AnalysisOperation, info: GhidraSessionInfo, ): Promise => { + if (target.format === "pe") + pdbLimitations.splice( + 0, + pdbLimitations.length, + ...ghidraPdbLimitations(info.pdb), + ); const softFloatReason = ghidraMipsSoftFloatFailure( softFloat, info.mips_soft_float_storage, @@ -701,6 +711,7 @@ export const createGhidraProviderClient = (input: { ...softFloatLimitations, ...ghidraExtensionLimitations(extensions), ...seedLimitations, + ...pdbLimitations, ...(releaseLimitation === undefined ? [] : [releaseLimitation]), ]; const nativeAotOwner = @@ -772,7 +783,7 @@ export const createGhidraProviderClient = (input: { config.ghidraStartupTimeoutMs, ), ); - const failed = await checkExtensions(operation, started.value); + const failed = await checkSessionObservations(operation, started.value); if (failed !== undefined) return err(failed); return ok( createAnalysisExecution(started.value, committedProfile.provider, { @@ -785,7 +796,12 @@ export const createGhidraProviderClient = (input: { ? parseGhidraFunctionInput(operation, parameters) : parseGhidraInventoryInput(operation, parameters); if (!input.ok) return input; - if (extensions.length > 0 || seeds !== undefined || softFloat) { + if ( + target.format === "pe" || + extensions.length > 0 || + seeds !== undefined || + softFloat + ) { const started = await client.start(options?.signal, options?.progress); if (!started.ok) return err( @@ -795,7 +811,7 @@ export const createGhidraProviderClient = (input: { config.ghidraStartupTimeoutMs, ), ); - const failed = await checkExtensions(operation, started.value); + const failed = await checkSessionObservations(operation, started.value); if (failed !== undefined) return err(failed); } const called = await client.callTool(operation, input.value, { diff --git a/src/ghidra/GhidraSessionValues.test.ts b/src/ghidra/GhidraSessionValues.test.ts index a0430f35e..a68ed1615 100644 --- a/src/ghidra/GhidraSessionValues.test.ts +++ b/src/ghidra/GhidraSessionValues.test.ts @@ -52,6 +52,49 @@ describe("Ghidra DOS import commitment", () => { }); }); +describe("Ghidra PDB observations", () => { + const pdb = { + file: "C:\\build\\fixture.pdb", + guid: "01234567-89AB-CDEF-0123-456789ABCDEF", + age: "a", + signature: null, + version: "RSDS", + loaded: false, + }; + it("retains the referenced identity and observed load state", () => { + const parsed = parseGhidraSessionInfo({ ...session(), pdb }, expected); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.pdb).toEqual(pdb); + }); + it("preserves missing attributes as unknown", () => { + const unknown = Object.fromEntries( + Object.keys(pdb).map((key) => [key, null]), + ); + const parsed = parseGhidraSessionInfo( + { ...session(), pdb: unknown }, + expected, + ); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.pdb).toEqual(unknown); + }); + it.each([ + { ...pdb, loaded: "false" }, + { ...pdb, age: 1 }, + { + guid: pdb.guid, + age: pdb.age, + signature: null, + version: "RSDS", + loaded: false, + }, + { ...pdb, matched: true }, + ])("rejects malformed PDB reports", (report) => { + expect( + parseGhidraSessionInfo({ ...session(), pdb: report }, expected).ok, + ).toBe(false); + }); +}); + describe("Ghidra MIPS soft-float storage report", () => { const valid = { policy: "o32-soft-float-prototype-v1", diff --git a/src/ghidra/GhidraSessionValues.ts b/src/ghidra/GhidraSessionValues.ts index 0ee2a6bf1..e370892f3 100644 --- a/src/ghidra/GhidraSessionValues.ts +++ b/src/ghidra/GhidraSessionValues.ts @@ -25,6 +25,13 @@ export const GHIDRA_MUTATING_OPERATIONS: ReadonlySet = new Set([ ...GHIDRA_NAMING_OPERATIONS, ]); +// Shared wire budget: Java bounds the aggregate before serializing the handshake. +const PDB_ATTRIBUTE_UTF16_BUDGET = 4096; +const pdbAttributeSchema = z + .string() + .max(PDB_ATTRIBUTE_UTF16_BUDGET) + .nullable(); + const capabilitySchema = z.enum(GHIDRA_SESSION_CAPABILITIES); const sessionInfoSchema = z @@ -41,6 +48,39 @@ const sessionInfoSchema = z read_only: z.boolean(), analysis_complete: z.boolean(), analysis_timed_out: z.boolean(), + /** Program Information attributes; absent load state remains unknown. */ + pdb: z + .strictObject({ + file: pdbAttributeSchema, + guid: pdbAttributeSchema, + /** Ghidra stores CodeView age as a hexadecimal string. */ + age: pdbAttributeSchema, + signature: pdbAttributeSchema, + version: pdbAttributeSchema, + loaded: z.boolean().nullable(), + resource_limit: z + .strictObject({ + utf16_budget: z.literal(PDB_ATTRIBUTE_UTF16_BUDGET), + omitted_attributes: z + .array(z.enum(["file", "guid", "age", "signature", "version"])) + .min(1) + .max(5), + }) + .optional(), + }) + .refine( + (pdb) => + [ + pdb.file, + pdb.guid, + pdb.age, + pdb.signature, + pdb.version, + ].reduce((sum, value) => sum + (value?.length ?? 0), 0) <= + PDB_ATTRIBUTE_UTF16_BUDGET, + "PDB attributes exceed the aggregate wire budget", + ) + .optional(), analysis_extensions: z.array(ghidraExtensionResultSchema).optional(), analysis_seeds: ghidraSeedReportSchema.optional(), mips_soft_float_storage: z @@ -68,6 +108,37 @@ const sessionInfoSchema = z /** Authenticated metadata produced after Ghidra finishes auto-analysis. */ export type GhidraSessionInfo = z.infer; +/** Describe missing PDB authority without claiming all names are generated. */ +export const ghidraPdbLimitations = ( + pdb: GhidraSessionInfo["pdb"], +): readonly string[] => { + if (pdb === undefined) + return [ + "Ghidra did not report PDB attributes; debug-symbol coverage is unknown.", + ]; + const limitations: string[] = []; + if (pdb.resource_limit !== undefined) + limitations.push( + `PDB attributes ${pdb.resource_limit.omitted_attributes.join(", ")} were omitted because the handshake's ${pdb.resource_limit.utf16_budget}-UTF-16-unit PDB attribute budget was exhausted. Omitted values remain unknown; they were not truncated or reported as absent.`, + ); + if (pdb.loaded === true) + return [ + ...limitations, + "Ghidra reports a loaded PDB; this is not an independent PDB identity or complete symbol/type-coverage attestation. Original names and types may still be missing; an empty name search does not prove a procedure is absent. Use list_procedures and address-based analysis to inspect recovered functions.", + ]; + if ( + [pdb.file, pdb.guid, pdb.age, pdb.signature, pdb.version].every( + (value) => value === null, + ) + ) + return limitations; + const state = pdb.loaded === false ? "not loaded" : "load state unknown"; + return [ + ...limitations, + `The target references PDB ${pdb.file ?? "(file unknown)"} (GUID ${pdb.guid ?? "unknown"}, age (hex) ${pdb.age ?? "unknown"}, signature ${pdb.signature ?? "unknown"}, version ${pdb.version ?? "unknown"}); Ghidra reports ${state}. Original names and types may be missing; an empty name search does not prove a procedure is absent. Use list_procedures and address-based analysis to inspect recovered functions. REA does not admit a caller-selected PDB companion.`, + ]; +}; + /** Require exact run, provider, and profile identity from the Java bridge. */ export const parseGhidraSessionInfo = ( value: JsonValue, diff --git a/tests/boundary/mcp/ghidraInventoryMcp.test.ts b/tests/boundary/mcp/ghidraInventoryMcp.test.ts index b24dac153..5573c0cbe 100644 --- a/tests/boundary/mcp/ghidraInventoryMcp.test.ts +++ b/tests/boundary/mcp/ghidraInventoryMcp.test.ts @@ -1,10 +1,86 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { expect, it } from "vitest"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { nativeAotPeFixture } from "../../fixtures/nativeaotPe.js"; import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; import { ok } from "../../../src/domain/result.js"; import { ghidraFunctionIdentity } from "../../../src/domain/ghidraValues.fixture.js"; import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js"; +it("retains missing PDB coverage in empty PE name-search Evidence and snapshot replay", async () => { + const root = await createTestTempDirectory("rea-pdb-evidence-"); + const targetPath = join(root, "fixture.exe"); + await writeFile(targetPath, nativeAotPeFixture()); + // Synthetic post-analysis facts at the real provider boundary; no engine is simulated as verified. + const pdb = { + file: "C:\\build\\fixture.pdb", + guid: "01234567-89AB-CDEF-0123-456789ABCDEF", + age: "a", + signature: null, + version: "RSDS", + loaded: false, + }; + const harness = await connectGhidraMcp( + "ghidra-pdb-coverage", + (operation) => + Promise.resolve( + ok( + operation === "procedure_pseudo_code" + ? { + value: "int FUN_140001000(void) { return 0; }", + limitations: [], + } + : [], + ), + ), + { + targetPath, + pdb, + }, + ); + try { + const reply = await harness.mcp.callTool({ + name: "search_procedures", + arguments: { pattern: "add_numbers" }, + }); + expect(reply.isError).not.toBe(true); + const evidence = sessionEvidence(harness.session, reply.structuredContent); + expect(evidence.normalized_result).toEqual([]); + expect(evidence.analysis_profile?.parameters.pdb_observations).toBe( + "program-info-v1", + ); + const limitation = evidence.limitations.find((value) => + value.includes(pdb.file), + ); + expect(limitation).toContain(pdb.guid); + expect(limitation).toContain("age (hex) a"); + expect(limitation).toContain("not loaded"); + const decompiled = await harness.mcp.callTool({ + name: "procedure_pseudo_code", + arguments: { procedure: "0x140001000", document: "fixture" }, + }); + expect(decompiled.isError).not.toBe(true); + const functionEvidence = sessionEvidence( + harness.session, + decompiled.structuredContent, + ); + expect(functionEvidence.limitations).toContain(limitation); + const replay = await harness.session.execute( + "procedure_pseudo_code", + functionEvidence.parameters, + ); + if (!replay.ok) throw replay.error; + expect(replay.value.limitations).toContain(limitation); + expect(replay.value.limitations).toContainEqual( + expect.stringContaining("Loaded from a local REA analysis snapshot"), + ); + } finally { + await harness.close(); + } +}); + it("retains defined-string coverage in empty and matching search Evidence", async () => { let output = jsonValueSchema.parse([]); const harness = await connectGhidraMcp("ghidra-string-coverage", () => diff --git a/tests/boundary/mcp/ghidraMcpHarness.ts b/tests/boundary/mcp/ghidraMcpHarness.ts index a9b2248dc..2ff9d0790 100644 --- a/tests/boundary/mcp/ghidraMcpHarness.ts +++ b/tests/boundary/mcp/ghidraMcpHarness.ts @@ -12,6 +12,7 @@ import type { GhidraOperation } from "../../../src/ghidra/GhidraClient.js"; import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js"; import type { GhidraProviderClientFactory } from "../../../src/ghidra/GhidraProviderClient.js"; import { GHIDRA_SESSION_CAPABILITIES } from "../../../src/ghidra/GhidraSessionValues.js"; +import type { GhidraSessionInfo } from "../../../src/ghidra/GhidraSessionValues.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; import { @@ -26,12 +27,19 @@ const INSTALL = "/opt/ghidra_12.1.4_PUBLIC"; export const connectGhidraMcp = async ( name: string, execute?: ReturnType["callTool"], + observations: { + readonly targetPath?: string; + readonly pdb?: GhidraSessionInfo["pdb"]; + } = {}, ) => { const calls: GhidraOperation[] = []; const factory: GhidraProviderClientFactory = (options) => ({ start: () => Promise.resolve( - ok(sessionInfo(options.profileDigest, options.targetSha256)), + ok({ + ...sessionInfo(options.profileDigest, options.targetSha256), + ...(observations.pdb === undefined ? {} : { pdb: observations.pdb }), + }), ), callTool: (operation, input, options) => { calls.push(operation); @@ -68,7 +76,7 @@ export const connectGhidraMcp = async ( await mcp.connect(clientTransport); const opened = await mcp.callTool({ name: "open_binary", - arguments: { path: process.execPath }, + arguments: { path: observations.targetPath ?? process.execPath }, }); if (opened.isError === true) throw new Error(