From a4ba8aecd507637b6261026afc17f79516840d50 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:11:08 +0800 Subject: [PATCH] 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. --- bridge/hopper_bridge.py | 2 + scripts/lib/real-hopper-navigation.mjs | 18 ++++++ scripts/lib/real-hopper-semantic.mjs | 28 ++++++++- scripts/verify/managed/config.mjs | 6 ++ .../native/NativeApiInspection.test.ts | 6 +- .../native/NativeCallRoutes.test.ts | 26 ++++++++ src/application/native/NativeCallRoutes.ts | 8 +-- .../native/NativeValueTrace.test.ts | 5 ++ src/contracts/functionComparisonExample.ts | 8 +++ src/contracts/toolOutputSchemaGroups.ts | 4 +- src/contracts/toolOutputSchemaPrimitives.ts | 7 +-- src/domain/callPath.test.ts | 15 +++++ src/domain/functionComparison.test.ts | 51 +++++++++------- src/domain/ghidraValues.fixture.ts | 3 + src/domain/hopperValues.test.ts | 30 ++++++++-- src/domain/hopperValues.ts | 28 ++++----- src/domain/native/nativeValueFlow.ts | 36 +++++------ ...uctionVerification.functionSources.test.ts | 9 +++ src/ghidra/GhidraFunctionValues.test.ts | 49 +++++++++++++++ src/ida/IdaOperationRunner.ts | 2 + src/ida/IdaSessionClient.test.ts | 11 ++++ ....01-lists-the-complete-target-open.test.ts | 59 ++++++++++++------- ...4-uses-complete-inventories-inline.test.ts | 58 +++++++++++++++++- ...ts-incomplete-function-dossiers-at.test.ts | 32 ++++++++++ .../mcp/investigationAnalysisMcp.test.ts | 10 +++- .../mcp/workflowSnapshotReplayMcp.test.ts | 11 +++- .../hopper/hopperBridgeFacade.test.ts | 15 +++++ .../directAnalysisSnapshotReplay.test.ts | 16 ++++- tests/fixtures/hopperBridgeFacadeProbe.py | 24 ++++++++ 29 files changed, 465 insertions(+), 112 deletions(-) diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py index ab7b84ee0..440085637 100644 --- a/bridge/hopper_bridge.py +++ b/bridge/hopper_bridge.py @@ -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.", diff --git a/scripts/lib/real-hopper-navigation.mjs b/scripts/lib/real-hopper-navigation.mjs index 3ee47a7a5..c4096d869 100644 --- a/scripts/lib/real-hopper-navigation.mjs +++ b/scripts/lib/real-hopper-navigation.mjs @@ -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], ); diff --git a/scripts/lib/real-hopper-semantic.mjs b/scripts/lib/real-hopper-semantic.mjs index 412900d7c..000b806d0 100644 --- a/scripts/lib/real-hopper-semantic.mjs +++ b/scripts/lib/real-hopper-semantic.mjs @@ -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( diff --git a/scripts/verify/managed/config.mjs b/scripts/verify/managed/config.mjs index 352f0443d..f22b15d18 100644 --- a/scripts/verify/managed/config.mjs +++ b/scripts/verify/managed/config.mjs @@ -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: [], }; } diff --git a/src/application/native/NativeApiInspection.test.ts b/src/application/native/NativeApiInspection.test.ts index db0c61a19..d3bd88b7b 100644 --- a/src/application/native/NativeApiInspection.test.ts +++ b/src/application/native/NativeApiInspection.test.ts @@ -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 }, diff --git a/src/application/native/NativeCallRoutes.test.ts b/src/application/native/NativeCallRoutes.test.ts index baac1d16a..e54a55dc5 100644 --- a/src/application/native/NativeCallRoutes.test.ts +++ b/src/application/native/NativeCallRoutes.test.ts @@ -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); + }); }); diff --git a/src/application/native/NativeCallRoutes.ts b/src/application/native/NativeCallRoutes.ts index adb286195..bb819d742 100644 --- a/src/application/native/NativeCallRoutes.ts +++ b/src/application/native/NativeCallRoutes.ts @@ -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 ( diff --git a/src/application/native/NativeValueTrace.test.ts b/src/application/native/NativeValueTrace.test.ts index 7f9be52d9..0d2b19c77 100644 --- a/src/application/native/NativeValueTrace.test.ts +++ b/src/application/native/NativeValueTrace.test.ts @@ -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: [], }, diff --git a/src/contracts/functionComparisonExample.ts b/src/contracts/functionComparisonExample.ts index 5229dc6cd..2ba8581d9 100644 --- a/src/contracts/functionComparisonExample.ts +++ b/src/contracts/functionComparisonExample.ts @@ -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) => diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 6f38137e6..b381662d0 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -165,8 +165,8 @@ export const officialOutputSchemas: Readonly> = { 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), }), ), diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index 2efb498b2..224245c37 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -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, }), ); diff --git a/src/domain/callPath.test.ts b/src/domain/callPath.test.ts index 45c48ac3c..00d9e97d4 100644 --- a/src/domain/callPath.test.ts +++ b/src/domain/callPath.test.ts @@ -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" }, diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index 665635c6b..ebc59a6ef 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -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[]) => 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: [], diff --git a/src/domain/ghidraValues.fixture.ts b/src/domain/ghidraValues.fixture.ts index 1efd378b3..d0c6d183c 100644 --- a/src/domain/ghidraValues.fixture.ts +++ b/src/domain/ghidraValues.fixture.ts @@ -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, diff --git a/src/domain/hopperValues.test.ts b/src/domain/hopperValues.test.ts index 2affa2630..c5931cd35 100644 --- a/src/domain/hopperValues.test.ts +++ b/src/domain/hopperValues.test.ts @@ -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); diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index 366640038..3cbb79c8c 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -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, @@ -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(); diff --git a/src/domain/native/nativeValueFlow.ts b/src/domain/native/nativeValueFlow.ts index 30f2c6219..a4e2a0a4a 100644 --- a/src/domain/native/nativeValueFlow.ts +++ b/src/domain/native/nativeValueFlow.ts @@ -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(), diff --git a/src/domain/reconstructionVerification.functionSources.test.ts b/src/domain/reconstructionVerification.functionSources.test.ts index 8816a5d48..4c4c04f04 100644 --- a/src/domain/reconstructionVerification.functionSources.test.ts +++ b/src/domain/reconstructionVerification.functionSources.test.ts @@ -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: [], }), ); diff --git a/src/ghidra/GhidraFunctionValues.test.ts b/src/ghidra/GhidraFunctionValues.test.ts index 90f8715d2..6b92d2deb 100644 --- a/src/ghidra/GhidraFunctionValues.test.ts +++ b/src/ghidra/GhidraFunctionValues.test.ts @@ -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 ( diff --git a/src/ida/IdaOperationRunner.ts b/src/ida/IdaOperationRunner.ts index 8420ff8e9..e775eb864 100644 --- a/src/ida/IdaOperationRunner.ts +++ b/src/ida/IdaOperationRunner.ts @@ -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, diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index cf75e5ddc..2b6783c1c 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -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(); }); }); diff --git a/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts b/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts index 7662c8234..0da86dfc5 100644 --- a/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts +++ b/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts @@ -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)); } diff --git a/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts b/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts index 79002f40e..a8c9bf464 100644 --- a/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts +++ b/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts @@ -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([])); diff --git a/tests/boundary/mcp/enhancedTools.06-rejects-incomplete-function-dossiers-at.test.ts b/tests/boundary/mcp/enhancedTools.06-rejects-incomplete-function-dossiers-at.test.ts index 212e32824..449432e26 100644 --- a/tests/boundary/mcp/enhancedTools.06-rejects-incomplete-function-dossiers-at.test.ts +++ b/tests/boundary/mcp/enhancedTools.06-rejects-incomplete-function-dossiers-at.test.ts @@ -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; + 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({ diff --git a/tests/boundary/mcp/investigationAnalysisMcp.test.ts b/tests/boundary/mcp/investigationAnalysisMcp.test.ts index 752f81bc3..a1259e667 100644 --- a/tests/boundary/mcp/investigationAnalysisMcp.test.ts +++ b/tests/boundary/mcp/investigationAnalysisMcp.test.ts @@ -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, diff --git a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts index 1282aef36..832b55e40 100644 --- a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts +++ b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts @@ -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" diff --git a/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts b/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts index 871ee140f..515172eab 100644 --- a/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts +++ b/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts @@ -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", diff --git a/tests/composition/directAnalysisSnapshotReplay.test.ts b/tests/composition/directAnalysisSnapshotReplay.test.ts index da6d3a11b..7da4ec8cf 100644 --- a/tests/composition/directAnalysisSnapshotReplay.test.ts +++ b/tests/composition/directAnalysisSnapshotReplay.test.ts @@ -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> = { 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" }, diff --git a/tests/fixtures/hopperBridgeFacadeProbe.py b/tests/fixtures/hopperBridgeFacadeProbe.py index 195292136..fa1cacefa 100644 --- a/tests/fixtures/hopperBridgeFacadeProbe.py +++ b/tests/fixtures/hopperBridgeFacadeProbe.py @@ -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,