From bfaa5a6c2c0ddc9e6fcf16cd11ad06f24db174a2 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:09:30 +0800 Subject: [PATCH] fix(native): distinguish batch selectors from resolved entries --- docs/native-investigation.md | 12 ++ scripts/lib/real-ghidra-batch-identity.mjs | 41 ++++++ scripts/lib/real-ghidra-boundaries.mjs | 3 + src/application/EnhancedTools.ts | 46 ++++++- src/application/InvestigationProviders.ts | 4 +- src/application/WorkflowEvidence.ts | 34 ++++- src/contracts/enhancedToolContracts.ts | 2 +- src/contracts/toolOutputSchemaGroups.ts | 27 +++- .../enhancedTools.callPathsAndBatches.test.ts | 125 +++++++++++++++++- 9 files changed, 281 insertions(+), 13 deletions(-) create mode 100644 scripts/lib/real-ghidra-batch-identity.mjs diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 902831169..9bf30c19a 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -189,6 +189,18 @@ Call resolution reports direct, resolved indirect, ambiguous, unresolved and non-call outcomes from static call references. It does not establish runtime execution or classify Objective-C/Swift/vtable/closure mechanisms from names. +`batch_decompile` keeps each caller-selected symbol or address in `items[].address`. +The separate `procedure` field reports the canonical entry and the provider's +observed entry label. An interior address therefore remains visible without +being mistaken for the function entry. Identity failures are reported as +`procedure.status: "unknown"`; label failures leave a null name and `name_error`. +Neither failure discards usable pseudocode, and names are not parsed from it. +Resolved entries also appear in the composed Evidence locations. New batch +Evidence commits the revised workflow profile; retained historical Evidence +remains valid. This tool +has no dedicated CLI command; the existing `decompile` command remains the +single-procedure CLI operation. + Type inspection selects one exact database pathname or typed data address. Struct/union fields, enums, pointers, arrays, typedefs, size, alignment, packing and bitfields retain database authority. Child types use exact IDs for further diff --git a/scripts/lib/real-ghidra-batch-identity.mjs b/scripts/lib/real-ghidra-batch-identity.mjs new file mode 100644 index 000000000..a8d2935f6 --- /dev/null +++ b/scripts/lib/real-ghidra-batch-identity.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; + +/** Verify batch selector/entry separation against real source-owned Ghidra functions. */ +export async function verifyGhidraBatchIdentity(call, names) { + const leaf = names.find((item) => + item.value.endsWith("rea_ghidra_inventory_leaf"), + ); + assert.ok(leaf, "Source fixture lacks a leaf procedure"); + const listing = await call("read_function_instructions", { + procedure: leaf.address, + }); + const interior = listing.instructions[1]?.split(": ")[0]; + assert.ok(interior, "Source fixture lacks an interior instruction"); + const name = await call("address_name", { address: leaf.address }); + const selectors = [leaf.address, interior, leaf.value, "0x0"]; + const result = await call("batch_decompile", { addresses: selectors }); + assert.equal(result.total, 4); + assert.equal(result.succeeded, 3); + assert.equal(result.failed, 1); + for (let index = 0; index < 3; index++) { + const item = result.items[index]; + assert.equal(item.address, selectors[index]); + assert.equal(item.status, "ok"); + assert.deepEqual(item.procedure, { + status: "resolved", + address: leaf.address, + name, + }); + assert.equal(item.pseudocode, result.items[0].pseudocode); + } + assert.equal(result.items[3].status, "error"); + assert.equal(result.items[3].procedure.status, "unknown"); + assert.ok(result.items[3].procedure.error.code); + return { + entry: leaf.address, + interior, + selectors_preserved: true, + identical_resolved_code: true, + partial_failure_retained: true, + }; +} diff --git a/scripts/lib/real-ghidra-boundaries.mjs b/scripts/lib/real-ghidra-boundaries.mjs index 47e90e60e..4e8c1609b 100644 --- a/scripts/lib/real-ghidra-boundaries.mjs +++ b/scripts/lib/real-ghidra-boundaries.mjs @@ -15,6 +15,7 @@ import { verifyGhidraTargetAdmission } from "./real-ghidra-target-admission.mjs" import { verifyGhidraLargeResults } from "./real-ghidra-large-results.mjs"; import { verifyGhidraEntryAliases } from "./real-ghidra-entry-aliases.mjs"; import { verifyGhidraNamespaceAnnotations } from "./real-ghidra-namespace-annotations.mjs"; +import { verifyGhidraBatchIdentity } from "./real-ghidra-batch-identity.mjs"; /** Probe real Ghidra location, annotation and error contracts through public adapters. */ export async function verifyGhidraBoundaries( @@ -157,6 +158,7 @@ export async function verifyGhidraBoundaries( /Annotations require a local function entry/u, ); const names = await call("list_names"); + const batchIdentity = await verifyGhidraBatchIdentity(call, names); const leaf = names.find((item) => item.value.endsWith("rea_ghidra_inventory_leaf"), ); @@ -766,6 +768,7 @@ export async function verifyGhidraBoundaries( missing_and_nonregular_source_rejected: true, imported_source_identity_retained: true, equivalent_instruction_address_spellings: true, + batch_procedure_identity: batchIdentity, qualified_annotation_name_roundtrip: true, imported_entry_alias_selection: true, oversized_result_retention_and_complete_export: true, diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index a27d0e566..a41aca239 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -254,20 +254,33 @@ export class EnhancedTools { ): EnhancedResult { const items = await Promise.all( addresses.map(async (address) => { + const resolved = await resolveProcedureAddress( + this.#call.bind(this), + address, + signal, + ); + const procedure = resolved.ok + ? await this.#batchProcedureIdentity(resolved.value, signal) + : { + status: "unknown" as const, + error: projectAnalysisError(resolved.error), + }; const result = await this.#call( "procedure_pseudo_code", - { procedure: address }, + { procedure: resolved.ok ? resolved.value : address }, signal, ); if (!result.ok) return { address, + procedure, status: "error" as const, error: projectAnalysisError(result.error), }; if (typeof result.value !== "string" || result.value.length === 0) return { address, + procedure, status: "error" as const, error: projectAnalysisError( new AnalysisOutputError( @@ -276,7 +289,12 @@ export class EnhancedTools { ), ), }; - return { address, status: "ok" as const, pseudocode: result.value }; + return { + address, + procedure, + status: "ok" as const, + pseudocode: result.value, + }; }), ); const succeeded = items.filter(({ status }) => status === "ok").length; @@ -288,6 +306,30 @@ export class EnhancedTools { }); } + async #batchProcedureIdentity(address: string, signal?: AbortSignal) { + const observed = await this.#call("address_name", { address }, signal); + const named = + observed.ok && + (typeof observed.value === "string" || observed.value === null); + return { + status: "resolved" as const, + address, + name: named ? observed.value : null, + ...(named + ? {} + : { + name_error: projectAnalysisError( + observed.ok + ? new AnalysisOutputError( + "address_name", + "provider returned an invalid entry label", + ) + : observed.error, + ), + }), + }; + } + async #callGraph( input: { readonly address: string; diff --git a/src/application/InvestigationProviders.ts b/src/application/InvestigationProviders.ts index 20354c27b..03d705252 100644 --- a/src/application/InvestigationProviders.ts +++ b/src/application/InvestigationProviders.ts @@ -18,7 +18,9 @@ export const workflowAnalysisProfile = ( ): AnalysisProfileCommitment => createAnalysisProfile(REA_WORKFLOW_PROVIDER, { upstream_analysis_profile: upstream, - ...(operation === "binary_overview" ? { workflow_revision: 2 } : {}), + ...(operation === "binary_overview" || operation === "batch_decompile" + ? { workflow_revision: 2 } + : {}), }); /** Provider identity for deterministic artifact inventories. */ diff --git a/src/application/WorkflowEvidence.ts b/src/application/WorkflowEvidence.ts index b9ccfe783..94c81a1d6 100644 --- a/src/application/WorkflowEvidence.ts +++ b/src/application/WorkflowEvidence.ts @@ -10,7 +10,11 @@ import { committedProviderSchema, } from "../domain/analysisProfile.js"; import type { BinaryTarget } from "../domain/binaryTarget.js"; -import { createEvidence, type Evidence } from "../domain/evidence.js"; +import { + createEvidence, + type Evidence, + type EvidenceLocation, +} from "../domain/evidence.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { UnknownRegistryPort } from "./investigation/InvestigationRecordPort.js"; import { UnknownRegistryError } from "../domain/unknownRegistryError.js"; @@ -37,8 +41,36 @@ export const createWorkflowEvidence = (input: { }), confidence: "derived", limitations: ["Derived by an REA composed workflow."], + ...(input.operation === "batch_decompile" + ? { locations: batchProcedureLocations(input.result) } + : {}), }); +const batchProcedureLocations = (result: JsonValue): EvidenceLocation[] => { + if ( + result === null || + typeof result !== "object" || + Array.isArray(result) || + !Array.isArray(result.items) + ) + return []; + const addresses = new Set(); + for (const item of result.items) { + if (item === null || typeof item !== "object" || Array.isArray(item)) + continue; + const procedure = item.procedure; + if ( + procedure !== null && + typeof procedure === "object" && + !Array.isArray(procedure) && + procedure.status === "resolved" && + typeof procedure.address === "string" + ) + addresses.add(procedure.address); + } + return [...addresses].map((address) => ({ kind: "address", address })); +}; + /** Convert workflow Evidence into its exact replay binding, when it has a profile. */ export const workflowSnapshotRecord = ( evidence: Evidence, diff --git a/src/contracts/enhancedToolContracts.ts b/src/contracts/enhancedToolContracts.ts index 18b8d3af0..02cc55aca 100644 --- a/src/contracts/enhancedToolContracts.ts +++ b/src/contracts/enhancedToolContracts.ts @@ -44,7 +44,7 @@ export const ENHANCED_TOOL_CONTRACTS = [ ), enhanced( "batch_decompile", - "Decompile each explicit procedure symbol or address concurrently. Returns ordered per-item success or error results and aggregate counts.", + "Decompile each explicit procedure symbol or address concurrently. Preserves each input selector in address and reports the observed canonical entry and entry label in procedure; identity or label failures remain explicit without discarding usable pseudocode. Returns ordered per-item success or error results and aggregate counts.", enhancedInputSchemas.batch_decompile, ), enhanced( diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index adb4af43e..7498f02eb 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -245,6 +245,23 @@ const callPathTraceOutput = resultOf( }), ); +/** Identity facts attached to one requested batch selector. */ +const batchProcedureIdentity = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("resolved"), + address: z.string().min(1).describe("Observed canonical procedure entry."), + name: z + .string() + .nullable() + .describe("Provider's entry label, or null when unavailable."), + name_error: analysisErrorProjectionSchema.optional(), + }), + z.object({ + status: z.literal("unknown"), + error: analysisErrorProjectionSchema, + }), +]); + /** Exact structured-content schemas for composed analysis workflows. */ export const enhancedOutputSchemas: Readonly> = { inspect_native_dispatch_metadata: resultOf( @@ -259,12 +276,18 @@ export const enhancedOutputSchemas: Readonly> = { items: z.array( z.discriminatedUnion("status", [ z.object({ - address: z.string(), + address: z + .string() + .describe("Original caller-selected symbol or address."), + procedure: batchProcedureIdentity, status: z.literal("ok"), pseudocode: z.string().min(1), }), z.object({ - address: z.string(), + address: z + .string() + .describe("Original caller-selected symbol or address."), + procedure: batchProcedureIdentity, status: z.literal("error"), error: analysisErrorProjectionSchema, }), diff --git a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts index 083ac1ee6..4a44d00e3 100644 --- a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts +++ b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts @@ -2,7 +2,10 @@ import { afterEach, describe, expect, it } from "vitest"; import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js"; import { EnhancedTools } from "../../../src/application/EnhancedTools.js"; -import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js"; +import { + AnalysisCapabilityUnavailableError, + AnalysisOutputError, +} from "../../../src/domain/analysisErrorCore.js"; import { err } from "../../../src/domain/result.js"; import { closeEnhancedToolResources, connect } from "./enhancedToolsHarness.js"; @@ -18,6 +21,10 @@ describe("enhanced MCP tools", () => { ); const analysis: AnalysisOperationPort = { execute: async (name, arguments_) => { + if (name === "procedure_address") + return ok(String(arguments_.procedure)); + if (name === "address_name") + return ok(`name for ${String(arguments_.address)}`); if (name !== "procedure_pseudo_code") throw new Error(`Unexpected operation: ${name}`); await new Promise((resolve) => setImmediate(resolve)); @@ -36,6 +43,11 @@ describe("enhanced MCP tools", () => { result: { items: addresses.map((address) => ({ address, + procedure: { + status: "resolved", + address, + name: `name for ${address}`, + }, status: "ok", pseudocode: `pseudo for ${address}`, })), @@ -48,10 +60,16 @@ describe("enhanced MCP tools", () => { it("returns ordered typed batch failures and zero counts for empty input", async () => { const tools = new EnhancedTools({ - execute: (_name, arguments_) => - arguments_.procedure === "0x2" - ? Promise.resolve(err(new AnalysisOutputError("decompile", "failed"))) - : Promise.resolve(ok("pseudo")), + execute: (name, arguments_) => + name === "procedure_address" + ? Promise.resolve(ok(String(arguments_.procedure))) + : name === "address_name" + ? Promise.resolve(ok(null)) + : arguments_.procedure === "0x2" + ? Promise.resolve( + err(new AnalysisOutputError("decompile", "failed")), + ) + : Promise.resolve(ok("pseudo")), }); const result = await tools.execute("batch_decompile", { @@ -63,9 +81,15 @@ describe("enhanced MCP tools", () => { ok: true, value: { items: [ - { address: "0x1", status: "ok", pseudocode: "pseudo" }, + { + address: "0x1", + procedure: { status: "resolved", address: "0x1", name: null }, + status: "ok", + pseudocode: "pseudo", + }, { address: "0x2", + procedure: { status: "resolved", address: "0x2", name: null }, status: "error", error: { code: "unreadable_output", @@ -92,3 +116,92 @@ describe("enhanced MCP tools", () => { }); }); }); + +describe("batch procedure identity", () => { + it("keeps selectors separate from canonical entries and preserves partial identity failures", async () => { + const client = await connect({ + execute: async (operation, input) => { + if (operation === "procedure_address") { + if (input.procedure === "unknown") + return err( + new AnalysisCapabilityUnavailableError( + "fixture", + operation, + "identity unavailable", + ), + ); + if (input.procedure === "malformed") return ok({ address: "0x1000" }); + return ok( + input.procedure === "external" ? "EXTERNAL:00000001" : "0x1000", + ); + } + if (operation === "address_name") { + return input.address === "EXTERNAL:00000001" + ? err(new AnalysisOutputError(operation, "label lookup failed")) + : ok("observed_name"); + } + return input.procedure === "missing" + ? err(new AnalysisOutputError(operation, "no containing procedure")) + : ok( + `pseudocode label is deliberately unrelated: ${String(input.procedure)}`, + ); + }, + }); + const selectors = [ + "0x1000", + "0x1004", + "alias", + "external", + "unknown", + "malformed", + ]; + const result = await client.callTool({ + name: "batch_decompile", + arguments: { addresses: selectors }, + }); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + result: { + items: [ + ...selectors.slice(0, 3).map((address) => ({ + address, + procedure: { + status: "resolved", + address: "0x1000", + name: "observed_name", + }, + pseudocode: "pseudocode label is deliberately unrelated: 0x1000", + })), + { + address: "external", + procedure: { + status: "resolved", + address: "EXTERNAL:00000001", + name: null, + name_error: { code: "unreadable_output" }, + }, + }, + { + address: "unknown", + status: "ok", + procedure: { + status: "unknown", + error: { code: "capability_unavailable" }, + }, + }, + { + address: "malformed", + status: "ok", + procedure: { + status: "unknown", + error: { code: "unreadable_output" }, + }, + }, + ], + total: 6, + succeeded: 6, + failed: 0, + }, + }); + }); +});