fix(contracts)!: require observed provider output metadata

Reject omitted segment bounds, procedure classification and body facts, unresolved-call observations, dossier facets, and Ghidra parameter flow metadata. Providers emit explicit nulls for unsupported facets. Verify those unknowns in the real Hopper workflow.

BREAKING CHANGE: Provider analysis outputs must explicitly supply metadata previously repaired with defaults; unsupported nullable facets must be reported as null.
This commit is contained in:
morluto 2026-10-10 03:11:08 +08:00
parent e3bbb9ac45
commit a4ba8aecd5
29 changed files with 465 additions and 112 deletions

View File

@ -716,6 +716,8 @@ def _analyze_function(document, params):
"referenced_strings": referenced_strings,
"referenced_names": referenced_names,
"basic_blocks": blocks,
"native_api": None,
"native_value_flow": None,
"limitations": [
"Native CallReference classifications are observed where available; detailed reference flags remain unknown.",
"Hopper's public Python API does not expose equivalent external or thunk classification in this dossier.",

View File

@ -58,6 +58,12 @@ async function verifyNavigation(
procedure: address,
});
const info = await call("procedure_info", { procedure: address });
assert.equal(info.classification, null);
assert.equal(info.body.available, false);
assert.match(
info.body.reason,
/public Python API does not expose complete function body ranges/u,
);
assert.ok(info.locals.length > 0, "fixture omitted real stack locals");
for (const local of info.locals) {
assert.equal(typeof local.name, "string");
@ -68,6 +74,18 @@ async function verifyNavigation(
}
const dossier = await call("analyze_function", { procedure: address });
assert.deepEqual(dossier.procedure.locals, info.locals);
assert.equal(dossier.procedure.classification, null);
assert.equal(dossier.procedure.body.available, false);
assert.match(
dossier.procedure.body.reason,
/public Python API does not expose complete function body ranges/u,
);
assert.equal(dossier.native_api, null);
assert.equal(dossier.native_value_flow, null);
assert.ok(
Array.isArray(dossier.limitations) && dossier.limitations.length > 0,
"function dossier omitted Hopper's unsupported-facet limitations",
);
const coordinates = instructions.instructions.map(
(line) => /^0x[0-9a-f]+/u.exec(line)?.[0],
);

View File

@ -11,15 +11,28 @@ export const requireTruthfulMemoryRegions = (segments) => {
...(Array.isArray(segment.sections) ? segment.sections : []),
]);
for (const region of regions) {
if (
typeof region?.name !== "string" ||
region.name.length === 0 ||
typeof region.start !== "string" ||
typeof region.end !== "string"
) {
throw new Error(
"list_segments omitted observed region identity or bounds",
);
}
for (const permission of ["readable", "writable", "executable"]) {
if (![true, false, null].includes(region?.[permission])) {
throw new Error(`list_segments omitted tri-state ${permission}`);
if (region[permission] !== null) {
throw new Error(
`list_segments invented or omitted unknown ${permission}`,
);
}
}
if (
region.provenance !== "hopper-public-python-api" ||
region.permissions?.available !== false ||
typeof region.permissions.reason !== "string"
typeof region.permissions.reason !== "string" ||
region.permissions.reason.length === 0
) {
throw new Error("list_segments omitted permission provenance");
}
@ -141,6 +154,15 @@ export async function verifyRealHopperFixture({
"procedure_references",
);
assert.deepEqual(entryDossier.unresolved_calls, observed.unresolved_calls);
assert.equal(
observed.reference_kinds_available,
false,
"Hopper's partial CallReference observations do not expose detailed reference kinds",
);
assert.ok(
Array.isArray(observed.unresolved_calls),
"procedure_references omitted its observed unresolved CallReference results",
);
for (const call of calls) {
assert.equal(call.call.provider_type, 2);
assert.equal(

View File

@ -62,6 +62,10 @@ export function functionDossier(name) {
thunk_target: null,
provenance: "synthetic-provider",
},
body: {
available: false,
reason: "Synthetic provider does not supply function body ranges.",
},
signature: null,
locals: [],
},
@ -75,6 +79,8 @@ export function functionDossier(name) {
referenced_strings: [],
referenced_names: [],
basic_blocks: [],
native_api: null,
native_value_flow: null,
limitations: [],
};
}

View File

@ -100,11 +100,7 @@ describe("native API inspection", () => {
const value = ghidraFunctionDossier();
if (typeof value !== "object" || value === null || Array.isArray(value))
throw new TypeError("Ghidra dossier fixture is invalid");
const dossier = functionDossierSchema.parse(
Object.fromEntries(
Object.entries(value).filter(([key]) => key !== "native_api"),
),
);
const dossier = functionDossierSchema.parse({ ...value, native_api: null });
expect(projectNativeApiInspection(dossier)).toMatchObject({
boundary: { available: false },

View File

@ -101,4 +101,30 @@ describe("typed native call routes", () => {
error: { _tag: "AnalysisOutputError" },
});
});
it("rejects omitted unresolved-call observations instead of claiming none", async () => {
let calls = 0;
const result = await readNativeCallRoutes(
{
execute: async () => {
calls++;
return ok(
createAnalysisExecution(
{
reference_kinds_available: false,
references: [],
},
provider,
),
);
},
},
"0x1000",
);
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
expect(calls).toBe(1);
});
});

View File

@ -8,7 +8,7 @@ import { err, ok } from "../../domain/result.js";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
const referencesSchema = z.object({
reference_kinds_available: z.boolean().optional(),
reference_kinds_available: z.boolean(),
references: z.array(
z.object({
source_address: z.string(),
@ -17,9 +17,9 @@ const referencesSchema = z.object({
kind: referenceKindSchema,
}),
),
unresolved_calls: z
.array(z.object({ address: z.string(), reason: z.string() }))
.default([]),
unresolved_calls: z.array(
z.object({ address: z.string(), reason: z.string() }),
),
});
/** Prefer typed static call references, preserving computed targets and targetless sites. */
export const readNativeCallRoutes = async (

View File

@ -67,6 +67,11 @@ const analysis: AnalysisOperationPort = {
address: callee ? "0x2000" : "0x1000",
name: callee ? "callee" : "caller",
classification: null,
body: {
available: false,
reason:
"The provider did not report complete function body ranges.",
},
signature: null,
locals: [],
},

View File

@ -7,6 +7,11 @@ const dossier = (text: string) =>
procedure: {
address: "0x1000",
name: "main",
classification: null,
body: {
available: false,
reason: "The fixture omits function-body extent observations.",
},
signature: "int main(void)",
locals: [],
},
@ -20,6 +25,9 @@ const dossier = (text: string) =>
referenced_strings: [],
referenced_names: [],
basic_blocks: [{ start: "0x1000", end: "0x1001", successors: [] }],
native_api: null,
native_value_flow: null,
limitations: ["The fixture omits native API and value-flow observations."],
});
const observe = (digit: string, text: string) =>

View File

@ -165,8 +165,8 @@ export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
z.object({
procedure: procedureIdentitySchema,
direction: z.enum(["incoming", "outgoing"]),
reference_kinds_available: z.boolean().optional(),
unresolved_calls: z.array(unresolvedCallSchema).default([]),
reference_kinds_available: z.boolean(),
unresolved_calls: z.array(unresolvedCallSchema),
references: z.array(referenceEdgeSchema),
}),
),

View File

@ -484,11 +484,8 @@ export const procedureInfoOutput = evidenceResultOf(
length: z.number().min(0),
signature: nullableText,
locals: z.array(localVariableSchema),
classification: procedureClassificationSchema.nullable().default(null),
body: functionBodySchema.default({
available: false,
reason: "The provider did not report complete function body ranges.",
}),
classification: procedureClassificationSchema.nullable(),
body: functionBodySchema,
}),
);

View File

@ -19,6 +19,12 @@ const observe = (
procedure: {
address,
name: `fn_${address.slice(2)}`,
classification: null,
body: {
available: false,
reason:
"Hopper's public Python API does not expose complete function body ranges",
},
signature: null,
locals: [],
},
@ -29,12 +35,21 @@ const observe = (
callees: callees.map((callee) => ({
address: callee,
name: `fn_${callee.slice(2)}`,
classification: null,
body: {
available: false,
reason:
"Hopper's public Python API does not expose complete function body ranges",
},
})),
incoming_references: [],
outgoing_references: [],
referenced_strings: [],
referenced_names: [],
native_api: null,
native_value_flow: null,
basic_blocks: [],
limitations: [],
});
return createEvidence(
{ path: "/tmp/a", sha256: "a".repeat(64), format: "mach-o" },

View File

@ -11,6 +11,16 @@ import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
import { jsonValueSchema } from "./jsonValue.js";
import { compareFunctions } from "./functionComparison.js";
const procedure = (address: string, name: string) => ({
address,
name,
classification: null,
body: {
available: false as const,
reason:
"Hopper's public Python API does not expose complete function body ranges",
},
});
const dossier = (
text: string,
base: "0x1000" | "0x2000",
@ -18,8 +28,7 @@ const dossier = (
) =>
functionDossierSchema.parse({
procedure: {
address: base,
name: "main",
...procedure(base, "main"),
signature: "int main(void)",
locals: [],
},
@ -32,6 +41,8 @@ const dossier = (
outgoing_references: [],
referenced_strings: [],
referenced_names: [],
native_api: null,
native_value_flow: null,
basic_blocks: [
{
start: base,
@ -39,6 +50,7 @@ const dossier = (
successors,
},
],
limitations: [],
});
const dossierWithReference = (
@ -52,8 +64,8 @@ const dossierWithReference = (
{
source_address: base,
target_address: target,
source_procedure: { address: base, name: "main" },
target_procedure: { address: target, name: "helper" },
source_procedure: procedure(base, "main"),
target_procedure: procedure(target, "helper"),
kind:
kind === "unavailable"
? { available: false, reason: "provider has no kind authority" }
@ -111,7 +123,7 @@ describe("function comparison normalized identity", () => {
functionDossierSchema.parse({
...dossier("return 0;", base),
procedure: {
address: base,
...procedure(base, name),
name,
signature: "int helper(void)",
locals: [],
@ -138,7 +150,7 @@ describe("function comparison normalized identity", () => {
functionDossierSchema.parse({
...dossier("return 0;", base),
procedure: {
address: base,
...procedure(base, name),
name,
signature: "int helper(void)",
locals: [],
@ -161,7 +173,7 @@ describe("function comparison normalized identity", () => {
const calling = (base: "0x1000" | "0x2000") =>
functionDossierSchema.parse({
...dossier("return helper();", base),
callees: [{ address: base === "0x1000" ? "0x1010" : "0x2010", name }],
callees: [procedure(base === "0x1000" ? "0x1010" : "0x2010", name)],
});
const result = compareFunctions(
observe("6", calling("0x1000")),
@ -174,11 +186,9 @@ describe("function comparison normalized identity", () => {
});
describe("function collection collation ties", () => {
const composed = { address: "0x2000", name: "caf\u00e9" };
const decomposed = { address: "0x3000", name: "cafe\u0301" };
const calling = (
callees: readonly { readonly address: string; readonly name: string }[],
) =>
const composed = procedure("0x2000", "caf\u00e9");
const decomposed = procedure("0x3000", "cafe\u0301");
const calling = (callees: readonly ReturnType<typeof procedure>[]) =>
functionDossierSchema.parse({
...dossier("return 0;", "0x1000"),
callees,
@ -221,10 +231,7 @@ describe("function collection collation ties", () => {
it("uses canonical code-point ordering for collection digests", () => {
const callees = ["zeta", "Alpha", "alpha", "_helper", "beta"].map(
(name, index) => ({
address: `0x${(0x2000 + index).toString(16)}`,
name,
}),
(name, index) => procedure(`0x${(0x2000 + index).toString(16)}`, name),
);
const canonicalProjection = callees
.map(({ name }) => ({ direction: "out", name }))
@ -247,8 +254,8 @@ describe("function collection collation ties", () => {
});
describe("function comparison ordering controls", () => {
const composed = { address: "0x2000", name: "caf\u00e9" };
const decomposed = { address: "0x3000", name: "cafe\u0301" };
const composed = procedure("0x2000", "caf\u00e9");
const decomposed = procedure("0x3000", "cafe\u0301");
it("preserves duplicate Unicode collections and nullable endpoints", () => {
const controlled = (base: "0x1000" | "0x2000", reverse: boolean) => {
@ -258,7 +265,7 @@ describe("function comparison ordering controls", () => {
source_address: base,
target_address: endpoint,
source_procedure: null,
target_procedure: { address: endpoint, name: "caf\u00e9" },
target_procedure: procedure(endpoint, "caf\u00e9"),
kind: {
available: true,
provenance: "provider-reference-manager",
@ -281,7 +288,7 @@ describe("function comparison ordering controls", () => {
{
source_address: base,
target_address: endpoint,
source_procedure: { address: base, name: "dispatch" },
source_procedure: procedure(base, "dispatch"),
target_procedure: null,
kind: {
available: true,
@ -337,7 +344,7 @@ describe("function comparison ordering controls", () => {
);
const left = controlled("0x1000", false);
const right = controlled("0x2000", true);
const { native_api: _nativeApi, ...rightWithoutNativeApi } = right;
const rightWithoutNativeApi = { ...right, native_api: null };
const comparison = compareFunctions(
makeEvidence("d", left),
@ -522,7 +529,7 @@ describe("function comparison", () => {
functionDossierSchema.parse({
...dossier("return 0;", base),
procedure: {
address: base,
...procedure(base, "sub_1000"),
name: "sub_1000",
signature: "int helper(void)",
locals: [],

View File

@ -184,6 +184,7 @@ export const ghidraFunctionDossier = (includeAssembly = true): JsonValue => {
sequence: 0,
opcode: "COPY",
is_dead: false,
block_membership: "member",
inputs: [
{
kind: "constant",
@ -201,6 +202,8 @@ export const ghidraFunctionDossier = (includeAssembly = true): JsonValue => {
},
],
def_use: [],
parameters: [],
parameter_uses: [],
effects: [],
truncated: false,
omitted_operations_lower_bound: 0,

View File

@ -96,13 +96,27 @@ describe("provider-neutral inclusive function body evidence", () => {
non_contiguous: true,
contains_entry: true,
});
it("represents missing Hopper extent explicitly as unknown", () => {
it("rejects missing provider body metadata", () => {
expect(
procedureIdentitySchema.parse({ address: "0x1000", name: "main" }).body,
).toEqual({
available: false,
reason: "The provider did not report complete function body ranges.",
});
procedureIdentitySchema.safeParse({ address: "0x1000", name: "main" })
.success,
).toBe(false);
});
it("rejects segments when required coordinates or unknown permissions are omitted", () => {
expect(
parseSegments([
{
name: "__TEXT",
start: "0x1",
end: "0x2",
readable: null,
writable: null,
},
]).ok,
).toBe(false);
expect(
parseSegments([{ name: "__TEXT", start: "0x1", end: "0x2" }]).ok,
).toBe(false);
});
it("counts inclusive one-byte ranges without assuming an enclosing continuous body", () => {
expect(
@ -143,6 +157,7 @@ describe("provider-neutral inclusive function body evidence", () => {
procedureIdentitySchema.safeParse({
address: "0x1000",
name: "external",
classification: null,
body: {
...observed(),
ranges: [],
@ -159,6 +174,7 @@ describe("provider-neutral inclusive function body evidence", () => {
procedureIdentitySchema.safeParse({
address: "0x1002",
name: "last",
classification: null,
body: observed(),
}).success,
).toBe(true);
@ -166,6 +182,7 @@ describe("provider-neutral inclusive function body evidence", () => {
procedureIdentitySchema.safeParse({
address: "0x1003",
name: "gap",
classification: null,
body: observed(),
}).success,
).toBe(false);
@ -173,6 +190,7 @@ describe("provider-neutral inclusive function body evidence", () => {
procedureIdentitySchema.safeParse({
address: "other:0x1000",
name: "other",
classification: null,
body: observed(),
}).success,
).toBe(false);

View File

@ -52,12 +52,12 @@ const addressedNamesSchema = z.array(
);
const addressedNameMapSchema = z.record(z.string(), z.string());
const segmentSchema = z.object({
name: z.string().default(""),
start: z.string().default(""),
end: z.string().default(""),
readable: z.boolean().nullable().default(null),
writable: z.boolean().nullable().default(null),
executable: z.boolean().nullable().default(null),
name: z.string(),
start: z.string(),
end: z.string(),
readable: z.boolean().nullable(),
writable: z.boolean().nullable(),
executable: z.boolean().nullable(),
});
const unavailableAnalysisFactSchema = z
.object({ available: z.literal(false), reason: z.string() })
@ -186,12 +186,6 @@ export const functionBodySchema = z.discriminatedUnion("available", [
}),
]);
const unknownFunctionBody = () =>
functionBodySchema.default({
available: false,
reason: "The provider did not report complete function body ranges.",
});
/** Check the declared entry membership against complete, inclusive body ranges. */
export const functionBodyEntryAgrees = (
body: z.infer<typeof functionBodySchema>,
@ -226,8 +220,8 @@ export const procedureIdentitySchema = z
.object({
address: z.string(),
name: z.string(),
classification: procedureClassificationSchema.nullable().default(null),
body: unknownFunctionBody(),
classification: procedureClassificationSchema.nullable(),
body: functionBodySchema,
})
.strict()
.superRefine((identity, context) => {
@ -352,9 +346,9 @@ export const functionDossierSchema = z
})
.strict(),
),
native_api: nativeApiBoundarySchema.nullable().default(null),
native_value_flow: nativeValueFlowSchema.nullable().default(null),
limitations: z.array(z.string()).default([]),
native_api: nativeApiBoundarySchema.nullable(),
native_value_flow: nativeValueFlowSchema.nullable(),
limitations: z.array(z.string()),
})
.strict();

View File

@ -18,9 +18,7 @@ export const nativePcodeOperationSchema = z.strictObject({
sequence: z.number().int().nonnegative(),
opcode: z.string().min(1),
is_dead: z.boolean(),
block_membership: z
.enum(["member", "detached", "unavailable"])
.default("unavailable"),
block_membership: z.enum(["member", "detached", "unavailable"]),
inputs: z.array(nativeVarnodeSchema),
output: nativeVarnodeSchema.nullable(),
});
@ -55,24 +53,20 @@ export const nativeValueFlowSchema = z.discriminatedUnion("available", [
operations: z.array(nativePcodeOperationSchema),
def_use: z.array(nativePcodeDefUseSchema),
effects: z.array(nativePcodeEffectSchema),
parameters: z
.array(
z.strictObject({
ordinal: z.number().int().nonnegative(),
name: z.string(),
data_type: z.string(),
}),
)
.default([]),
parameter_uses: z
.array(
z.strictObject({
ordinal: z.number().int().nonnegative(),
use: z.string(),
input_index: z.number().int().nonnegative(),
}),
)
.default([]),
parameters: z.array(
z.strictObject({
ordinal: z.number().int().nonnegative(),
name: z.string(),
data_type: z.string(),
}),
),
parameter_uses: z.array(
z.strictObject({
ordinal: z.number().int().nonnegative(),
use: z.string(),
input_index: z.number().int().nonnegative(),
}),
),
truncated: z.boolean(),
omitted_operations_lower_bound: z.number().int().nonnegative(),
known_omitted_inputs: z.number().int().nonnegative(),

View File

@ -24,6 +24,12 @@ const dossier = jsonValueSchema.parse(
procedure: {
address: "0x1000",
name: "main",
classification: null,
body: {
available: false,
reason:
"Hopper's public Python API does not expose complete function body ranges",
},
signature: "int main(void)",
locals: [],
},
@ -36,7 +42,10 @@ const dossier = jsonValueSchema.parse(
outgoing_references: [],
referenced_strings: [],
referenced_names: [],
native_api: null,
native_value_flow: null,
basic_blocks: [{ start: "0x1000", end: "0x1001", successors: [] }],
limitations: [],
}),
);

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { JsonValue } from "../domain/jsonValue.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { functionDossierSchema } from "../domain/hopperValues.js";
import {
parseGhidraFunctionResult,
@ -37,6 +38,54 @@ describe("Ghidra function-analysis result values", () => {
},
);
it.each(["block_membership", "parameters", "parameter_uses"])(
"rejects a value-flow result missing %s",
(field) => {
const dossier = ghidraFunctionDossier();
if (
typeof dossier !== "object" ||
dossier === null ||
Array.isArray(dossier)
)
throw new TypeError("Ghidra dossier fixture is invalid");
const flow = dossier.native_value_flow;
if (typeof flow !== "object" || flow === null || Array.isArray(flow))
throw new TypeError("Ghidra p-code fixture is invalid");
if (!Array.isArray(flow.operations))
throw new TypeError("Ghidra operation fixture is invalid");
const incompleteFlow =
field === "block_membership"
? {
...flow,
operations: flow.operations.map((operation, index) =>
index !== 0 ||
typeof operation !== "object" ||
operation === null ||
Array.isArray(operation)
? operation
: Object.fromEntries(
Object.entries(operation).filter(
([key]) => key !== field,
),
),
),
}
: Object.fromEntries(
Object.entries(flow).filter(([key]) => key !== field),
);
expect(
parseGhidraFunctionResult(
"analyze_function",
jsonValueSchema.parse({
...dossier,
native_value_flow: incompleteFlow,
}),
),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
},
);
it("rejects p-code relationships that refer to omitted operation IDs", () => {
const dossier = ghidraFunctionDossier();
if (

View File

@ -426,6 +426,8 @@ export class IdaOperationRunner {
referenced_strings: [],
referenced_names: [],
basic_blocks: [],
native_api: null,
native_value_flow: null,
limitations: [
...IDA_LIMITATIONS,
...this.limitations,

View File

@ -95,6 +95,17 @@ describe("headless IDA producer pagination", () => {
const dossier = functionDossierSchema.parse(result.value.result);
expect(dossier.pseudocode).toBe("first line\nsecond line");
expect(dossier.assembly).toEqual(["0x1000: push rbp", "0x1001: ret"]);
expect(dossier).toMatchObject({
procedure: {
classification: null,
body: {
available: false,
reason: "IDA MCP does not report complete function body ranges.",
},
},
native_api: null,
native_value_flow: null,
});
await client.close();
});
});

View File

@ -35,6 +35,33 @@ const targetObservation = (value: unknown) =>
),
);
const functionDossier = {
procedure: {
address: "0x1",
name: "entry",
classification: null,
body: {
available: false,
reason: "The provider did not report complete function body ranges.",
},
signature: null,
locals: [],
},
pseudocode: "return 0;",
assembly: [],
comments: [],
callers: [],
callees: [],
incoming_references: [],
outgoing_references: [],
referenced_strings: [],
referenced_names: [],
basic_blocks: [],
native_api: null,
native_value_flow: null,
limitations: [],
};
const fixturePort = (): AnalysisOperationPort => ({
// oxlint-disable-next-line complexity -- this fixture exhaustively serves the registered MCP surface.
execute: (name, arguments_) => {
@ -138,7 +165,16 @@ const fixturePort = (): AnalysisOperationPort => ({
);
case "list_segments":
return Promise.resolve(
ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]),
ok([
{
name: "__TEXT",
start: "0x1000",
end: "0x2000",
readable: null,
writable: null,
executable: null,
},
]),
);
case "current_document":
return Promise.resolve(ok("fixture"));
@ -151,26 +187,7 @@ const fixturePort = (): AnalysisOperationPort => ({
case "search_procedures":
return Promise.resolve(ok(inventory({})));
case "analyze_function":
return Promise.resolve(
targetObservation({
procedure: {
address: "0x1",
name: "entry",
signature: null,
locals: [],
},
pseudocode: "return 0;",
assembly: [],
comments: [],
callers: [],
callees: [],
incoming_references: [],
outgoing_references: [],
referenced_strings: [],
referenced_names: [],
basic_blocks: [],
}),
);
return Promise.resolve(targetObservation(functionDossier));
default:
return Promise.resolve(ok(null));
}

View File

@ -46,8 +46,22 @@ describe("enhanced MCP tools", () => {
case "list_segments":
return Promise.resolve(
ok([
{ name: "__TEXT", start: "0x1000", end: "0x1800" },
{ name: "__DATA", start: "0x1800", end: "0x2000" },
{
name: "__TEXT",
start: "0x1000",
end: "0x1800",
readable: null,
writable: null,
executable: null,
},
{
name: "__DATA",
start: "0x1800",
end: "0x2000",
readable: null,
writable: null,
executable: null,
},
]),
);
case "current_document":
@ -113,6 +127,35 @@ describe("enhanced MCP tools", () => {
JSON.stringify(parseMcpToolError(result)),
);
});
it("rejects a segment with omitted coordinates instead of reporting zero length", async () => {
const client = await connect({
execute: (name) =>
Promise.resolve(
name === "list_segments"
? ok([
{
name: "__TEXT",
end: "0x2000",
readable: null,
writable: null,
executable: null,
},
])
: ok(name === "current_document" ? "fixture" : []),
),
});
const result = await client.callTool({
name: "binary_overview",
arguments: {},
});
expect(result.isError).toBe(true);
expect(result.content).toContainEqual({
type: "text",
text: JSON.stringify(parseMcpToolError(result)),
});
});
});
const overviewPort = (
@ -127,7 +170,16 @@ const overviewPort = (
return Promise.resolve(ok([...documents]));
case "list_segments":
return Promise.resolve(
ok([{ name: "text", start: "0x1000", end: "0x2000" }]),
ok([
{
name: "text",
start: "0x1000",
end: "0x2000",
readable: null,
writable: null,
executable: null,
},
]),
);
default:
return Promise.resolve(ok([]));

View File

@ -31,6 +31,38 @@ describe("analyze_function MCP producer validation", () => {
});
});
it.each([
"procedure.classification",
"procedure.body",
"native_api",
"native_value_flow",
"limitations",
])("rejects a complete dossier missing %s", async (field) => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
const value = structuredClone(dossier) as unknown as Record<
string,
unknown
>;
if (field.startsWith("procedure.")) {
const procedure = value.procedure as Record<string, unknown>;
delete procedure[field.slice("procedure.".length)];
} else delete value[field];
const client = await connect({
execute: () => Promise.resolve(observed(value)),
});
const result = await client.callTool({
name: "analyze_function",
arguments: { procedure: "0x1" },
});
expect(result.isError).toBe(true);
expect(result.content).toContainEqual({
type: "text",
text: JSON.stringify(parseMcpToolError(result)),
});
});
it("rejects a malformed collection in an otherwise complete dossier", async () => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
const client = await connect({

View File

@ -129,7 +129,15 @@ const dossierWithCallees = (addresses: readonly string[]) => {
parameters: base.parameters,
result: jsonValueSchema.parse({
...jsonObjectSchema.parse(base.normalized_result),
callees: addresses.map((address) => ({ address, name: "next" })),
callees: addresses.map((address) => ({
address,
name: "next",
classification: null,
body: {
available: false,
reason: "The fixture has no callee body-extent observation.",
},
})),
}),
rawResult: base.raw_result,
confidence: base.confidence,

View File

@ -308,7 +308,16 @@ const makeProvider = (
operation === "health"
? null
: operation === "list_segments"
? [{ name: "__TEXT", start: "0x1000", end: "0x2000" }]
? [
{
name: "__TEXT",
start: "0x1000",
end: "0x2000",
readable: null,
writable: null,
executable: null,
},
]
: operation === "list_documents"
? ["fixture"]
: operation === "list_procedures"

View File

@ -4,6 +4,7 @@ import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { OFFICIAL_TOOL_CONTRACTS } from "../../../../src/contracts/officialToolContracts.js";
import { functionDossierSchema } from "../../../../src/domain/hopperValues.js";
const execute = promisify(execFile);
const bridgePath = new URL(
@ -48,6 +49,7 @@ const probeResultSchema = z.strictObject({
reason: z.string(),
}),
}),
analyze_function: z.unknown(),
procedure_references: z.strictObject({
procedure: z.strictObject({
address: z.literal("0x401000"),
@ -153,6 +155,19 @@ describe("Hopper API facade", () => {
if (!parsed.success) throw new Error(`${name}: ${parsed.error.message}`);
expect(parsed.success, name).toBe(true);
}
const dossier = functionDossierSchema.parse(result.analyze_function);
expect(dossier).toMatchObject({
procedure: {
classification: null,
body: {
available: false,
reason:
"Hopper's public Python API does not expose complete function body ranges",
},
},
native_api: null,
native_value_flow: null,
});
expect(result.provider_faults.map((reply) => reply.error.message)).toEqual([
"TypeError: Hopper bridge operation failed",
"ValueError: Hopper bridge operation failed",

View File

@ -71,6 +71,11 @@ const functionDossier = {
procedure: {
address: "0x1000",
name: "fixture",
classification: null,
body: {
available: false,
reason: "The provider did not report complete function body ranges.",
},
signature: null,
locals: [],
},
@ -171,7 +176,16 @@ const makeProvider = (
trace.calls.push(operation);
const results: Readonly<Record<string, unknown>> = {
health: null,
list_segments: [{ name: "__TEXT", start: "0x1000", end: "0x2000" }],
list_segments: [
{
name: "__TEXT",
start: "0x1000",
end: "0x2000",
readable: null,
writable: null,
executable: null,
},
],
list_documents: ["fixture"],
list_procedures: ["0x1000"],
list_strings: { "0x1000": "fixture" },

View File

@ -57,6 +57,12 @@ class FakeReferenceSegment:
def getReferencesOfAddress(self, address):
return []
def getCommentAtAddress(self, address):
return None
def getInlineCommentAtAddress(self, address):
return None
class FakeProcedure:
def __init__(self, segment):
@ -86,6 +92,12 @@ class FakeProcedure:
def getAllCallees(self):
return []
def getAllCallerProcedures(self):
return []
def getAllCalleeProcedures(self):
return []
class FakeBlock:
def getStartingAddress(self):
@ -93,6 +105,9 @@ class FakeBlock:
def getEndingAddress(self):
return 0x401004
def getSuccessorCount(self):
return 0
def load_bridge(path):
namespace = {
"__file__": path,
@ -184,6 +199,14 @@ def main():
procedure_info = bridge["_dispatch"](
"procedure_info", {"procedure": "0x401000"}
)
bridge["_assembly"] = lambda procedure: "ret"
bridge["_pseudocode"] = lambda document, procedure: None
bridge["_native_calls"] = lambda procedure, direction: ({}, [])
bridge["_string_inventory"] = lambda document, targets=None: {}
bridge["_search_inventory"] = lambda document, kind: []
analyze_function = bridge["_dispatch"](
"analyze_function", {"procedure": "0x401000"}
)
provider_faults = []
for error_type in (TypeError, ValueError, KeyError):
FakeDocumentProvider.current.address_error = error_type("credential=supersecret")
@ -236,6 +259,7 @@ def main():
"containing_procedure": containing_procedure,
"procedure_references": procedure_references,
"procedure_info": procedure_info,
"analyze_function": analyze_function,
"provider_faults": provider_faults,
"malformed_requests": malformed_requests,
"session_document_reused": selected,