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:
parent
e3bbb9ac45
commit
a4ba8aecd5
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([]));
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue