fix(native): distinguish batch selectors from resolved entries

This commit is contained in:
N0zoM1z0 2026-10-09 19:09:30 +08:00
parent 6cc817e8bd
commit bfaa5a6c2c
9 changed files with 281 additions and 13 deletions

View File

@ -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

View File

@ -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,
};
}

View File

@ -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,

View File

@ -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;

View File

@ -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. */

View File

@ -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<string>();
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,

View File

@ -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(

View File

@ -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<Record<string, z.ZodObject>> = {
inspect_native_dispatch_metadata: resultOf(
@ -259,12 +276,18 @@ export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
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,
}),

View File

@ -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,
},
});
});
});