fix(native): distinguish batch selectors from resolved entries
This commit is contained in:
parent
6cc817e8bd
commit
bfaa5a6c2c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue