diff --git a/scripts/verify-real-ghidra-dos.mjs b/scripts/verify-real-ghidra-dos.mjs index 6743e518dc..7fd094af05 100644 --- a/scripts/verify-real-ghidra-dos.mjs +++ b/scripts/verify-real-ghidra-dos.mjs @@ -498,7 +498,7 @@ function hexBytes(bytes) { function assertProfile(profile) { assert.equal( profile.parameters.load_image_evidence, - "independent-mz-mapping-relocations-v1", + "independent-mz-mapping-relocations-v2", ); assert.equal( profile.parameters.function_body_evidence, diff --git a/src/domain/native/nativeAddress.ts b/src/domain/native/nativeAddress.ts index 969ce49cb7..59c014d3c8 100644 --- a/src/domain/native/nativeAddress.ts +++ b/src/domain/native/nativeAddress.ts @@ -1,5 +1,10 @@ import { z } from "zod"; +/** Observed hexadecimal coordinate; preserve padding and encoded space spelling. */ +export const nativeAddressObservationSchema = z + .string() + .regex(/^(?:(?:[A-Za-z0-9._~\x2d]|%[0-9A-F]{2})+:)?0x[0-9a-f]+$/u); + /** Canonical hexadecimal coordinate, retaining its optional address space. */ export const nativeAddressSchema = z .string() diff --git a/src/domain/native/nativeLoadImage.ts b/src/domain/native/nativeLoadImage.ts index 5eb22f2895..b76a2d250a 100644 --- a/src/domain/native/nativeLoadImage.ts +++ b/src/domain/native/nativeLoadImage.ts @@ -2,11 +2,11 @@ import { z } from "zod"; import { nativeMetadataRecoverySummarySchema } from "./nativeMetadataRecovery.js"; import { jsonValueSchema } from "../jsonValue.js"; import { binaryArchitectureSchema } from "../rawBinary.js"; +import { nativeAddressObservationSchema } from "./nativeAddress.js"; const integer = z.int().min(0).max(Number.MAX_SAFE_INTEGER); const digest = z.string().regex(/^[a-f0-9]{64}$/u); const hexBytes = z.string().regex(/^(?:[a-f0-9]{2})*$/u); -const address = z.string().min(1); /** Measured provider image, without exporting the complete executable bytes. */ export const nativeLoadImageObservationSchema = z.strictObject({ @@ -14,7 +14,7 @@ export const nativeLoadImageObservationSchema = z.strictObject({ executable_format: z.string().min(1), language_id: z.string().min(1), compiler_spec_id: z.string().min(1), - image_base: address, + image_base: nativeAddressObservationSchema, default_address_space: z.string().min(1), source_files: z.array( z.strictObject({ @@ -27,8 +27,8 @@ export const nativeLoadImageObservationSchema = z.strictObject({ mappings: z.array( z.strictObject({ block: z.string(), - start: address, - end: address, + start: nativeAddressObservationSchema, + end: nativeAddressObservationSchema, address_space: z.string().min(1), initialized: z.boolean(), loaded: z.boolean(), @@ -41,7 +41,7 @@ export const nativeLoadImageObservationSchema = z.strictObject({ ), relocations: z.array( z.strictObject({ - address, + address: nativeAddressObservationSchema, segment: integer.max(0xffff).nullable(), offset: integer.max(0xffff).nullable(), status: z.string().min(1), @@ -53,10 +53,10 @@ export const nativeLoadImageObservationSchema = z.strictObject({ memory_bytes_hex: hexBytes.nullable(), }), ), - entry_points: z.array(address), + entry_points: z.array(nativeAddressObservationSchema), entry_context: z.array( z.strictObject({ - address, + address: nativeAddressObservationSchema, registers: z.array( z.strictObject({ name: z.string().min(1), @@ -77,7 +77,7 @@ export const nativeLoadImageCheckSchema = z.strictObject({ expected: jsonValueSchema, observed: jsonValueSchema, file_offset: integer.nullable(), - address: address.nullable(), + address: nativeAddressObservationSchema.nullable(), }); /** Read-only native image verification; verified applies only to declared coverage. */ @@ -99,7 +99,7 @@ export const nativeLoadImageSchema = z.union([ entry: z.strictObject({ relative_segment: integer.max(0xffff), offset: integer.max(0xffff), - linear_address: address, + linear_address: nativeAddressObservationSchema, }), observations: nativeLoadImageObservationSchema, checks: z.array(nativeLoadImageCheckSchema), @@ -112,10 +112,10 @@ export const nativeLoadImageSchema = z.union([ architecture: binaryArchitectureSchema, language_id: z.string().min(1), compiler_spec_id: z.string().min(1), - base_address: address, + base_address: nativeAddressObservationSchema, file_bytes: integer.min(1), entry: z.strictObject({ - address: address, + address: nativeAddressObservationSchema, file_offset: z.literal(0), }), observations: nativeLoadImageObservationSchema, diff --git a/src/ghidra/GhidraFunctionValues.ts b/src/ghidra/GhidraFunctionValues.ts index c8ae3147ff..7c2fd71db5 100644 --- a/src/ghidra/GhidraFunctionValues.ts +++ b/src/ghidra/GhidraFunctionValues.ts @@ -1,3 +1,4 @@ +import { nativeAddressObservationSchema } from "../domain/native/nativeAddress.js"; import { procedureInfoSchema } from "../domain/native/procedureInfo.js"; import { z } from "zod"; import { @@ -38,7 +39,6 @@ import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js" import { ghidraIdentifierSchema, ghidraInputAddressSchema, - ghidraCanonicalAddressSchema, ghidraFunctionClassificationSchema as classification, ghidraFunctionBodySchema as ghidraFunctionBody, ghidraProcedureIdentitySchema as procedureIdentity, @@ -146,8 +146,8 @@ const referenceKind = availableReferenceKindSchema.safeExtend({ }); const referenceEdge = z .object({ - source_address: ghidraCanonicalAddressSchema, - target_address: ghidraCanonicalAddressSchema, + source_address: nativeAddressObservationSchema, + target_address: nativeAddressObservationSchema, source_procedure: procedureIdentity.nullable(), target_procedure: procedureIdentity.nullable(), kind: referenceKind, @@ -161,7 +161,7 @@ const procedureReferences = z reference_kinds_available: z.boolean(), unresolved_calls: z.array( z.strictObject({ - address: ghidraCanonicalAddressSchema, + address: nativeAddressObservationSchema, reason: z.string(), }), ), @@ -170,7 +170,7 @@ const procedureReferences = z const procedureInfo = procedureInfoSchema .safeExtend({ name: z.string().min(1), - entrypoint: ghidraCanonicalAddressSchema, + entrypoint: nativeAddressObservationSchema, length: z.int().min(0), locals: z.array(localVariable), classification, @@ -207,7 +207,7 @@ const ghidraNativeApiBoundary = nativeApiBoundarySchema.superRefine( ]); if ( addresses.some( - (address) => !ghidraCanonicalAddressSchema.safeParse(address).success, + (address) => !nativeAddressObservationSchema.safeParse(address).success, ) ) context.addIssue({ @@ -233,7 +233,7 @@ const ghidraNativeValueFlow = nativeValueFlowSchema.superRefine( ]); if ( addresses.some( - (address) => !ghidraCanonicalAddressSchema.safeParse(address).success, + (address) => !nativeAddressObservationSchema.safeParse(address).success, ) ) context.addIssue({ @@ -322,7 +322,7 @@ const ghidraFunctionDossier = functionDossierSchema ]; if ( addresses.some( - (address) => !ghidraCanonicalAddressSchema.safeParse(address).success, + (address) => !nativeAddressObservationSchema.safeParse(address).success, ) ) context.addIssue({ @@ -382,8 +382,8 @@ const resultSchemas = { }), analyze_function: ghidraFunctionDossier, procedure_assembly: z.string(), - procedure_callees: z.array(ghidraCanonicalAddressSchema), - procedure_callers: z.array(ghidraCanonicalAddressSchema), + procedure_callees: z.array(nativeAddressObservationSchema), + procedure_callers: z.array(nativeAddressObservationSchema), procedure_info: procedureInfo, procedure_pseudo_code: z.string().nullable(), read_function_instructions: ghidraFunctionInstructionWindow, @@ -391,7 +391,7 @@ const resultSchemas = { inspect_native_data_type: nativeDataTypeSchema, resolve_native_call_targets: nativeCallTargetsSchema, procedure_references: procedureReferences, - xrefs: z.array(ghidraCanonicalAddressSchema), + xrefs: z.array(nativeAddressObservationSchema), } satisfies Readonly>; const functionBoundaryObservationOperations: ReadonlySet = @@ -414,8 +414,8 @@ const functionObservationEnvelope = z.strictObject({ selection: z .strictObject({ file_offset: z.int().min(0).max(Number.MAX_SAFE_INTEGER), - mapped_address: ghidraCanonicalAddressSchema, - procedure_address: ghidraCanonicalAddressSchema, + mapped_address: nativeAddressObservationSchema, + procedure_address: nativeAddressObservationSchema, }) .exactOptional(), }); diff --git a/src/ghidra/GhidraInventoryValues.ts b/src/ghidra/GhidraInventoryValues.ts index 49d6cd70c6..2e7f16aafc 100644 --- a/src/ghidra/GhidraInventoryValues.ts +++ b/src/ghidra/GhidraInventoryValues.ts @@ -1,3 +1,4 @@ +import { nativeAddressObservationSchema } from "../domain/native/nativeAddress.js"; import { nativeByteReadSchema } from "../domain/native/nativeByteRead.js"; import { z } from "zod"; import { @@ -147,10 +148,6 @@ export const parseGhidraInventoryInput = ( return ok(parsed.data); }; -export const ghidraCanonicalAddressSchema = z - .string() - .regex(/^(?:0x[0-9a-f]+|(?:[A-Za-z0-9._~-]|%[0-9A-F]{2})+:0x[0-9a-f]+)$/u); -const canonicalAddress = ghidraCanonicalAddressSchema; const symbolFacts = z .object({ primary: z.boolean(), @@ -164,7 +161,7 @@ const procedureFacts = z .object({ external: z.boolean(), thunk: z.boolean(), - thunk_target: canonicalAddress.nullable(), + thunk_target: nativeAddressObservationSchema.nullable(), }) .strict(); /** Ghidra FunctionManager classification, independent of body ownership inference. */ @@ -179,8 +176,8 @@ export const ghidraFunctionBodySchema = functionBodySchema.superRefine( body.provenance !== "ghidra-function-body-address-set" || body.ranges.some( ({ start, end }) => - !ghidraCanonicalAddressSchema.safeParse(start).success || - !ghidraCanonicalAddressSchema.safeParse(end).success, + !nativeAddressObservationSchema.safeParse(start).success || + !nativeAddressObservationSchema.safeParse(end).success, ) ) context.addIssue({ @@ -194,7 +191,7 @@ export const ghidraFunctionBodySchema = functionBodySchema.superRefine( /** Strict provider identity shared by inventory and function-analysis results. */ export const ghidraProcedureIdentitySchema = z .object({ - address: ghidraCanonicalAddressSchema, + address: nativeAddressObservationSchema, name: z.string().min(1), classification: ghidraFunctionClassificationSchema, body: ghidraFunctionBodySchema, @@ -221,7 +218,7 @@ const stringFacts = z }) .strict(); const baseItem = { - address: canonicalAddress, + address: nativeAddressObservationSchema, value: z.string(), }; const symbolItem = z.object({ ...baseItem, symbol: symbolFacts }).strict(); @@ -240,15 +237,15 @@ const availablePermissions = z const memoryRegion = z .object({ name: z.string(), - start: canonicalAddress, - end: canonicalAddress, + start: nativeAddressObservationSchema, + end: nativeAddressObservationSchema, readable: z.boolean(), writable: z.boolean(), executable: z.boolean(), permissions: availablePermissions, provenance: z.literal("ghidra-memory-block"), address_space: z.string().min(1), - image_base: canonicalAddress, + image_base: nativeAddressObservationSchema, initialized: z.boolean(), overlay: z.boolean(), }) @@ -259,7 +256,7 @@ const segment = memoryRegion const containingProcedure = z.discriminatedUnion("found", [ z .object({ - query_address: canonicalAddress, + query_address: nativeAddressObservationSchema, found: z.literal(true), procedure: ghidraProcedureIdentitySchema, }) @@ -285,7 +282,7 @@ const containingProcedure = z.discriminatedUnion("found", [ }), z .object({ - query_address: canonicalAddress, + query_address: nativeAddressObservationSchema, found: z.literal(false), procedure: z.null(), reason: z.enum(["outside_segments", "not_in_procedure"]), @@ -314,7 +311,11 @@ const resultSchemas = { ...value.searched.skipped_not_loaded, ].flatMap((block) => [block.start, block.end]), ]; - if (addresses.some((value) => !canonicalAddress.safeParse(value).success)) + if ( + addresses.some( + (value) => !nativeAddressObservationSchema.safeParse(value).success, + ) + ) context.addIssue({ code: "custom", message: "Byte search addresses must be canonical", @@ -322,10 +323,10 @@ const resultSchemas = { }), inspect_native_load_image: nativeLoadImageObservationSchema, read_bytes: nativeByteReadSchema - .safeExtend({ address: canonicalAddress }) + .safeExtend({ address: nativeAddressObservationSchema }) .strict(), address_to_file_offset: z.strictObject({ - address: canonicalAddress, + address: nativeAddressObservationSchema, file_offset: z.int().min(0).max(Number.MAX_SAFE_INTEGER), }), address_name: z.string().nullable(), @@ -334,7 +335,7 @@ const resultSchemas = { list_procedures: z.array(procedureItem), list_segments: z.array(segment), list_strings: z.array(stringItem), - procedure_address: canonicalAddress, + procedure_address: nativeAddressObservationSchema, resolve_containing_procedure: containingProcedure, search_procedures: z.array(searchItem), search_strings: z.array(searchItem), diff --git a/src/ghidra/GhidraLoadImageValues.test.ts b/src/ghidra/GhidraLoadImageValues.test.ts index 4bd285893d..249fc49421 100644 --- a/src/ghidra/GhidraLoadImageValues.test.ts +++ b/src/ghidra/GhidraLoadImageValues.test.ts @@ -4,8 +4,13 @@ import { compareDosFixture, fixtureDosLoadImage, } from "./GhidraLoadImage.fixture.js"; -import { nativeLoadImageObservationSchema } from "../domain/native/nativeLoadImage.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import { + nativeLoadImageObservationSchema, + nativeLoadImageSchema, +} from "../domain/native/nativeLoadImage.js"; import type { NativeLoadImageObservation } from "../domain/native/nativeLoadImage.js"; +import { parseGhidraInventoryResult } from "./GhidraInventoryValues.js"; import { prepareGhidraDosLoadImage } from "./GhidraLoadImageValues.js"; it("does not interpret an ambiguous Windows-header candidate as a DOS image", () => { @@ -229,6 +234,103 @@ const damage: ReadonlyArray< }, ], ]; + +const malformedCoordinates: ReadonlyArray< + readonly [string, (observation: NativeLoadImageObservation) => void] +> = [ + ["image base", (value) => (value.image_base = "not-an-address")], + [ + "mapping start", + (value) => { + const mapping = value.mappings[0]; + if (mapping) mapping.start = "not-an-address"; + }, + ], + [ + "mapping end", + (value) => { + const mapping = value.mappings[0]; + if (mapping) mapping.end = "not-an-address"; + }, + ], + [ + "relocation address", + (value) => { + const relocation = value.relocations[0]; + if (relocation) relocation.address = "not-an-address"; + }, + ], + ["entry point", (value) => (value.entry_points[0] = "not-an-address")], + [ + "entry context address", + (value) => { + value.entry_context.push({ address: "not-an-address", registers: [] }); + }, + ], +]; + +it.each(malformedCoordinates)( + "rejects a malformed nonempty %s at both native load-image boundaries", + (_name, mutate) => { + const observation = structuredClone(fixtureDosLoadImage().observation); + mutate(observation); + expect({ + shared: nativeLoadImageObservationSchema.safeParse(observation).success, + provider: parseGhidraInventoryResult( + "inspect_native_load_image", + jsonValueSchema.parse(observation), + ).ok, + }).toEqual({ shared: false, provider: false }); + }, +); + +it("preserves zero-padded, qualified, and large observation addresses", () => { + const observation = structuredClone(fixtureDosLoadImage().observation); + observation.image_base = "0x0000"; + const mapping = observation.mappings[0]; + const relocation = observation.relocations[0]; + if (!mapping || !relocation) throw new Error("Incomplete load-image fixture"); + mapping.start = "ram%2Fbank:0x0000000000000001"; + mapping.end = "ram%2Fbank:0x10000000000000001"; + relocation.address = "0x10000000000000009"; + observation.entry_points[0] = "ram:0x10000000000000001"; + const parsed = parseGhidraInventoryResult( + "inspect_native_load_image", + jsonValueSchema.parse(observation), + ); + expect(parsed.ok).toBe(true); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.value).toEqual(observation); +}); + +it("rejects malformed coordinates in completed load-image checks and entries", () => { + const fixture = fixtureDosLoadImage(); + const compared = compareDosFixture( + fixture.bytes, + fixture.sha256, + fixture.observation, + ); + if (!compared.ok || compared.value.status === "unsupported") + throw new Error("Expected a completed DOS load-image result"); + const malformedResults = [ + { + ...compared.value, + entry: { ...compared.value.entry, linear_address: "not-an-address" }, + }, + { + ...compared.value, + checks: compared.value.checks.map((check, index) => + index === 0 ? { ...check, address: "not-an-address" } : check, + ), + }, + ]; + expect( + malformedResults.map( + (result) => nativeLoadImageSchema.safeParse(result).success, + ), + ).toEqual([false, false]); +}); + describe("independent DOS MZ import verification", () => { it("verifies every initialized source byte while retaining overlay and BSS scope", () => { const fixture = fixtureDosLoadImage(); diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index d0574c5c3c..10d01e990f 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -1478,10 +1478,13 @@ describe("Ghidra measured raw load-image projection", () => { "mapping-mismatch", "changed-snapshot", "cancelled", + "malformed-coordinate", ] as const)( "streams raw snapshot identity and preserves %s outcomes", async (state) => { const fixture = fixtureRawBinaryLoadImage(); + if (state === "malformed-coordinate") + fixture.observation.image_base = "not-an-address"; if (state === "mapping-mismatch") { const mapping = fixture.observation.mappings[0]; if (mapping === undefined) @@ -1542,7 +1545,9 @@ describe("Ghidra measured raw load-image projection", () => { {}, { signal: controller.signal }, ); - expect(signals).toEqual([controller.signal]); + expect(signals).toEqual( + state === "malformed-coordinate" ? [] : [controller.signal], + ); if (state === "verified" || state === "mapping-mismatch") { if (!result.ok) throw result.error; expect(result.value.result).toMatchObject({ @@ -1567,7 +1572,9 @@ describe("Ghidra measured raw load-image projection", () => { _tag: state === "cancelled" ? "AnalysisCancelledError" - : "ProviderAdapterError", + : state === "malformed-coordinate" + ? "AnalysisOutputError" + : "ProviderAdapterError", }, }); } diff --git a/tests/boundary/mcp/outputSchemaSemanticConstraints.test.ts b/tests/boundary/mcp/outputSchemaSemanticConstraints.test.ts index 811f93cfed..b8e9bc0016 100644 --- a/tests/boundary/mcp/outputSchemaSemanticConstraints.test.ts +++ b/tests/boundary/mcp/outputSchemaSemanticConstraints.test.ts @@ -14,6 +14,10 @@ import { managedNativeVerificationResultSchema, } from "../../../src/domain/managed/managedNativeVerificationSchemas.js"; import { nativeUiResultSchema } from "../../../src/domain/native/nativeUiObservation.js"; +import { nativeLoadImageSchema } from "../../../src/domain/native/nativeLoadImage.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import type { JsonValue } from "../../../src/domain/jsonValue.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js"; import { verifyManagedNativeBoundaries } from "../../../src/domain/managed/managedNativeVerification.js"; @@ -186,3 +190,80 @@ it("advertises semantic output invariants and keeps producers aligned with Zod", await Promise.allSettled([client.close(), server.close()]); } }); + +mcpTest( + "advertises and enforces the observed load-image coordinate grammar", + async ({ mcp }) => { + const contract = toolContract("inspect_native_load_image"); + const server = new McpServer({ + name: "load-image-coordinates", + version: "1", + }); + const image = { + status: "unsupported", + reason: "No format-specific comparison was performed", + observations: { + executable_format: "ELF", + language_id: "AARCH64:LE:64:v8A", + compiler_spec_id: "default", + image_base: "ram%2Fbank:0x0000", + default_address_space: "ram", + source_files: [], + mappings: [], + relocations: [], + entry_points: ["0x10000000000000001"], + entry_context: [], + }, + limitations: [], + }; + const evidence = (result: JsonValue) => + createEvidence( + undefined, + { id: "ghidra", name: "Controlled bridge result", version: "12.1.4" }, + { operation: contract.name, parameters: {}, result }, + ); + let reply = evidence(image); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async () => ({ + content: [], + structuredContent: reply, + }), + ); + const client = await mcp.connect(server); + const advertised = (await client.listTools()).tools.find( + ({ name }) => name === contract.name, + ); + if (advertised?.outputSchema === undefined) + throw new Error("Load-image output schema missing"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const outputSchema = z + .record(z.string(), z.unknown()) + .parse(advertised.outputSchema); + expect(ajv.validateSchema(outputSchema)).toBe(true); + const validate = ajv.compile(outputSchema); + expect(nativeLoadImageSchema.parse(image)).toEqual(image); + expect(validate(reply)).toBe(true); + const accepted = await client.callTool({ + name: contract.name, + arguments: {}, + }); + expect(accepted.isError).not.toBe(true); + expect(accepted.structuredContent).toEqual(reply); + + const malformed = { + ...image, + observations: { ...image.observations, image_base: "not-an-address" }, + }; + reply = evidence(malformed); + expect(nativeLoadImageSchema.safeParse(malformed).success).toBe(false); + expect(validate(reply)).toBe(false); + const rejected = await client.callTool({ + name: contract.name, + arguments: {}, + }); + expect(rejected.isError).toBe(true); + expect(rejected.structuredContent).toBeUndefined(); + }, +);