diff --git a/src/application/binary/BinaryTargetSummary.ts b/src/application/binary/BinaryTargetSummary.ts index a0a4f9e7bd..018c0af679 100644 --- a/src/application/binary/BinaryTargetSummary.ts +++ b/src/application/binary/BinaryTargetSummary.ts @@ -1,13 +1,16 @@ import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; +import { liveBinaryTargetSummarySchema } from "../../domain/liveBinaryTargetSummary.js"; + /** Preserve the admitted target interpretation in public lifecycle results. */ -export const binaryTargetSummary = (target: BinaryTarget) => ({ - path: target.path, - format: target.format, - kind: target.kind, - sha256: target.sha256, - architecture: target.architecture ?? null, - ...(target.format === "raw-binary" || target.format === "gamecube-dol" - ? { entry_point: target.entryPoint } - : {}), -}); +export const binaryTargetSummary = (target: BinaryTarget) => + liveBinaryTargetSummarySchema.parse({ + path: target.path, + format: target.format, + kind: target.kind, + sha256: target.sha256, + architecture: target.architecture ?? null, + ...(target.format === "raw-binary" || target.format === "gamecube-dol" + ? { entry_point: target.entryPoint } + : {}), + }); diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 9816a89ab5..63907f51a5 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -38,14 +38,7 @@ import { z } from "zod"; import { jsonRecordSchema } from "../domain/jsonRecord.js"; import { byteSearchResultSchema } from "../domain/native/bytePattern.js"; import { evidenceBundleSummarySchema } from "../domain/evidenceRecordSummary.js"; -import { - BINARY_ARCHITECTURES, - RAW_ONLY_ARCHITECTURES, -} from "../domain/binaryTargetTypes.js"; -import { - binaryArchitectureSchema, - rawBinaryEntryPointSchema, -} from "../domain/rawBinary.js"; +import { liveBinaryTargetSummaryVariants } from "../domain/liveBinaryTargetSummary.js"; import { nativeFunctionAnnotationsSchema } from "../domain/native/nativeFunctionAnnotations.js"; import { nativeLoadImageSchema } from "../domain/native/nativeLoadImage.js"; import { nativeUiResultSchema } from "../domain/native/nativeUiObservation.js"; @@ -109,8 +102,6 @@ import { sessionProviderFacts, sessionStatusDetailShapes, symbolDiscoveryOutput, - targetFormatSchema, - targetKindSchema, } from "./toolOutputSchemaPrimitives.js"; import { processCaptureSchema } from "../domain/process/processCapture.js"; @@ -512,52 +503,10 @@ export const managedWorkflowOutputSchemas = { ), } as const satisfies Readonly>; -const lifecycleTargetFields = { - path: z.string(), - kind: targetKindSchema, - sha256: z.string().regex(/^[a-f0-9]{64}$/u), -}; -const detectedLifecycleTargetSchema = z.object({ - ...lifecycleTargetFields, - format: targetFormatSchema.exclude(["raw-binary", "gamecube-dol"]), - architecture: z - .enum(BINARY_ARCHITECTURES) - .exclude([...RAW_ONLY_ARCHITECTURES]) - .nullable(), -}); -const rawLifecycleTargetSchema = z.object({ - ...lifecycleTargetFields, - format: z.literal("raw-binary"), - kind: z.literal("executable"), - architecture: binaryArchitectureSchema, - entry_point: rawBinaryEntryPointSchema.describe( - "Canonical caller-selected address where raw file offset 0 is mapped and execution is seeded.", - ), -}); -const dolLifecycleTargetSchema = z.object({ - ...lifecycleTargetFields, - format: z.literal("gamecube-dol"), - kind: z.literal("executable"), - architecture: z.literal("powerpc"), - entry_point: rawBinaryEntryPointSchema.describe( - "Canonical entry address taken from the caller-selected DOL header.", - ), -}); - -/** - * Every open-target variant. open_binary and binary_session share this list so - * a target that open_binary returns is always valid in binary_session status. - */ -const openLifecycleTargetVariants = [ - detectedLifecycleTargetSchema, - rawLifecycleTargetSchema, - dolLifecycleTargetSchema, -] as const; - /** Exact structured-content schemas for target lifecycle operations. */ export const sessionOutputSchemas = { open_binary: lifecycleResultOf( - z.discriminatedUnion("format", [...openLifecycleTargetVariants]), + z.discriminatedUnion("format", [...liveBinaryTargetSummaryVariants]), ), open_bundle_resources: lifecycleResultOf(bundleResourceRootSchema), close_binary: lifecycleResultOf( @@ -595,7 +544,7 @@ export const sessionOutputSchemas = { resource_root: bundleResourceRootSchema, ...detail, }), - ...openLifecycleTargetVariants.map((variant) => + ...liveBinaryTargetSummaryVariants.map((variant) => sessionProviderFacts.extend({ open: z.literal(true), ...variant.shape, diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index f66d02beab..1b1e42c73b 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -19,7 +19,6 @@ import { PROVIDER_RETRY_ACTIONS, } from "../domain/providerOperationHealth.js"; import { analysisErrorProjectionSchema } from "./errorSchemas.js"; -import { ZIP_PACKAGE_FORMATS } from "../domain/zipPackageFormat.js"; import { objcClassImportIdentitySchema } from "../domain/native/objcSwiftMetadata.js"; import { BINARY_SESSION_SUMMARY_OMITTED_FIELDS } from "./sessionStatusContract.js"; @@ -47,34 +46,6 @@ export const requireOutputSchema = < return schema; }; -export const targetFormatSchema = z.enum([ - "hopper", - "analysis-database", - "mach-o", - "mach-o-universal", - "elf", - "pe", - "dos-mz", - "dos-com", - "raw-binary", - "gamecube-dol", - ...ZIP_PACKAGE_FORMATS, - "asar", - "godot-pck", - "dmg", - "pkg", - "plist", - "javascript", - "source-map", -]); - -export const targetKindSchema = z.enum([ - "executable", - "database", - "archive", - "artifact", -]); - const providerCapabilityFacts = { operation: z.string(), effects: z.object({ diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 35bd9faf72..acf7e8ca1e 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -4,8 +4,6 @@ import type { HeaderArchitecture, MipsElfMetadata, PeCodeViewRecord, - RiscvElfMetadata, - PowerPcElfMetadata, UniversalMachOSlice, } from "./binaryTargetTypes.js"; import { err, ok, type Result } from "./result.js"; @@ -48,13 +46,13 @@ export type ExecutableMetadata = readonly format: "elf"; readonly architecture: "riscv"; readonly availableArchitectures: readonly "riscv"[]; - readonly riscv: RiscvElfMetadata; + readonly riscv: ElfFamilyMetadata; } | { readonly format: "elf"; readonly architecture: "powerpc"; readonly availableArchitectures: readonly "powerpc"[]; - readonly powerpc: PowerPcElfMetadata; + readonly powerpc: ElfFamilyMetadata; } | { readonly format: "elf"; diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index d7fb7fb940..7763b23618 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -110,15 +110,6 @@ export interface MipsGnuAttributes { readonly msa: number | null; } -/** - * ELF class and flags retain the width and ABI declarations used by this lane. - * ISA extensions in `.riscv.attributes` are not inspected by this header parser. - */ -export type RiscvElfMetadata = ElfFamilyMetadata; - -/** Raw PowerPC declarations; family identity does not select a processor variant. */ -export type PowerPcElfMetadata = ElfFamilyMetadata; - /** Byte range of the selected slice inside its universal Mach-O container. */ export interface UniversalMachOSlice { readonly offset: number; @@ -167,7 +158,8 @@ export type BinaryTarget = readonly format: "elf"; readonly architecture: "riscv"; readonly availableArchitectures: readonly "riscv"[]; - readonly riscv: RiscvElfMetadata; + /** ISA extensions in `.riscv.attributes` are not inspected by the header parser. */ + readonly riscv: ElfFamilyMetadata; readonly executableRole?: never; readonly managed?: never; }) @@ -176,7 +168,8 @@ export type BinaryTarget = readonly format: "elf"; readonly architecture: "powerpc"; readonly availableArchitectures: readonly "powerpc"[]; - readonly powerpc: PowerPcElfMetadata; + /** Family identity does not select a processor variant. */ + readonly powerpc: ElfFamilyMetadata; readonly executableRole?: never; readonly managed?: never; }) diff --git a/src/domain/liveBinaryTargetSummary.ts b/src/domain/liveBinaryTargetSummary.ts new file mode 100644 index 0000000000..633154baf9 --- /dev/null +++ b/src/domain/liveBinaryTargetSummary.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +import { + BINARY_ARCHITECTURES, + ELF_METADATA_ARCHITECTURES, + RAW_ONLY_ARCHITECTURES, +} from "./binaryTargetTypes.js"; +import { digestSchema } from "./digests.js"; +import { + binaryArchitectureSchema, + rawBinaryEntryPointSchema, +} from "./rawBinary.js"; +import { ZIP_PACKAGE_FORMATS } from "./zipPackageFormat.js"; + +const architectureSchema = z.enum(BINARY_ARCHITECTURES); +const headerArchitectureSchema = architectureSchema.exclude([ + ...ELF_METADATA_ARCHITECTURES, + ...RAW_ONLY_ARCHITECTURES, + "hexagon", + "arm64_32", +]); +const identity = { path: z.string(), sha256: digestSchema }; +const executable = { ...identity, kind: z.literal("executable") }; + +/** Live target classifications; historical Evidence and snapshots retain their own admission. */ +export const liveBinaryTargetSummaryVariants = [ + z.object({ + ...executable, + format: z.literal("elf"), + architecture: architectureSchema.exclude([ + ...RAW_ONLY_ARCHITECTURES, + "arm64_32", + ]), + }), + z.object({ + ...executable, + format: z.literal("mach-o"), + architecture: z.enum([...headerArchitectureSchema.options, "arm64_32"]), + }), + z.object({ + ...executable, + format: z.enum(["mach-o-universal", "pe", "dos-mz", "dos-com"]), + architecture: headerArchitectureSchema, + }), + z.object({ + ...executable, + format: z.literal("raw-binary"), + architecture: binaryArchitectureSchema, + entry_point: rawBinaryEntryPointSchema.describe( + "Canonical caller-selected address where raw file offset 0 is mapped and execution is seeded.", + ), + }), + z.object({ + ...executable, + format: z.literal("gamecube-dol"), + architecture: z.literal("powerpc"), + entry_point: rawBinaryEntryPointSchema.describe( + "Canonical entry address taken from the caller-selected DOL header.", + ), + }), + z.object({ + ...identity, + kind: z.literal("database"), + format: z.literal("analysis-database"), + architecture: z.null(), + }), + z.object({ + ...identity, + kind: z.literal("archive"), + format: z.enum([...ZIP_PACKAGE_FORMATS, "asar", "godot-pck", "dmg", "pkg"]), + architecture: z.null(), + }), + z.object({ + ...identity, + kind: z.literal("artifact"), + format: z.enum(["plist", "javascript", "source-map"]), + architecture: z.null(), + }), +] as const; + +/** Exact wire projection of the currently admitted canonical target variants. */ +export const liveBinaryTargetSummarySchema = z.discriminatedUnion( + "format", + liveBinaryTargetSummaryVariants, +); diff --git a/src/ghidra/GhidraPowerPcProfile.test.ts b/src/ghidra/GhidraPowerPcProfile.test.ts index 72fb0ac545..01f9e00779 100644 --- a/src/ghidra/GhidraPowerPcProfile.test.ts +++ b/src/ghidra/GhidraPowerPcProfile.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { parseConfig } from "../config/parseConfig.js"; import type { BinaryTarget, - PowerPcElfMetadata, + ElfFamilyMetadata, } from "../domain/binaryTargetTypes.js"; import { silentLogger } from "../logger.js"; import { HopperProvider } from "../hopper/HopperProvider.js"; @@ -38,7 +38,7 @@ const configFor = (environment: NodeJS.ProcessEnv) => { return parsed.value; }; const config = configFor({}); -const target = (change: Partial = {}): BinaryTarget => ({ +const target = (change: Partial = {}): BinaryTarget => ({ path: "/fixture.elf", sha256: "a".repeat(64), kind: "executable", @@ -143,7 +143,7 @@ describe("bounded Ghidra PowerPC admission", () => { [{ type: 1 }, "ET_EXEC"], [{ type: 3 }, "ET_EXEC"], [{ flags: 0x80000000 }, "EF_PPC_EMB"], - ] satisfies [Partial, string][])( + ] satisfies [Partial, string][])( "refuses an unverified PowerPC interpretation %j", async (change, reason) => { const ghidra = new GhidraProvider(config, silentLogger, {}, host); diff --git a/src/ghidra/GhidraRiscvProfile.test.ts b/src/ghidra/GhidraRiscvProfile.test.ts index 4ae4c7e59d..31f7ed04ed 100644 --- a/src/ghidra/GhidraRiscvProfile.test.ts +++ b/src/ghidra/GhidraRiscvProfile.test.ts @@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { parseConfig } from "../config/parseConfig.js"; import type { BinaryTarget, - RiscvElfMetadata, + ElfFamilyMetadata, } from "../domain/binaryTargetTypes.js"; import { silentLogger } from "../logger.js"; import { HopperProvider } from "../hopper/HopperProvider.js"; @@ -60,7 +60,7 @@ const configOf = ( if (!parsed.ok) throw parsed.error; return parsed.value; }; -const target = (change: Partial = {}): BinaryTarget => ({ +const target = (change: Partial = {}): BinaryTarget => ({ path: "/fixture.elf", sha256: "a".repeat(64), kind: "executable", @@ -170,7 +170,7 @@ const registerRiscvAdmissionAndCapabilityCases = () => { [{ flags: 0x7 }, "quad-float"], [{ flags: 0x8000_0005 }, "(0x80000000)"], [{ flags: 0x24 }, "bits outside the verified lane"], - ] satisfies [Partial, string][])( + ] satisfies [Partial, string][])( "refuses an unverified RISC-V interpretation %j", async (change, reason) => { const provider = ghidra(); diff --git a/tests/boundary/mcp/liveBinaryTargetSummary.test.ts b/tests/boundary/mcp/liveBinaryTargetSummary.test.ts new file mode 100644 index 0000000000..dc593ac3ec --- /dev/null +++ b/tests/boundary/mcp/liveBinaryTargetSummary.test.ts @@ -0,0 +1,262 @@ +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv"; +import { expect, it } from "vitest"; + +import type { BinaryTarget } from "../../../src/domain/binaryTargetTypes.js"; +import { ZIP_PACKAGE_FORMATS } from "../../../src/domain/zipPackageFormat.js"; +import { binaryTargetSummary } from "../../../src/application/binary/BinaryTargetSummary.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { toolOutputSchemaWithMetadata } from "../../../src/contracts/toolSchemaMetadata.js"; +import { sessionOutputSchemas } from "../../../src/contracts/toolOutputSchemaGroups.js"; + +const identity = { path: "/fixture/target", sha256: "a".repeat(64) }; +const executable = { ...identity, kind: "executable" as const }; +const elf = { + elfClass: 32 as const, + byteOrder: "little" as const, + type: 2, + flags: 0, +}; +const targets: readonly BinaryTarget[] = [ + ...(["mach-o", "elf", "dos-mz", "dos-com"] as const).map((format) => ({ + ...executable, + format, + architecture: "x86" as const, + availableArchitectures: ["x86" as const], + })), + { + ...executable, + format: "elf", + architecture: "mips", + availableArchitectures: ["mips"], + mips: elf, + }, + { + ...executable, + format: "elf", + architecture: "riscv", + availableArchitectures: ["riscv"], + riscv: elf, + }, + { + ...executable, + format: "elf", + architecture: "powerpc", + availableArchitectures: ["powerpc"], + powerpc: elf, + }, + { + ...executable, + format: "elf", + architecture: "hexagon", + availableArchitectures: ["hexagon"], + }, + { + ...executable, + format: "mach-o", + architecture: "arm64_32", + availableArchitectures: ["arm64_32"], + }, + { + ...executable, + format: "mach-o-universal", + architecture: "arm64", + availableArchitectures: ["arm64"], + universalSlice: { offset: 32, size: 32 }, + }, + { + ...executable, + format: "pe", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + managed: false, + executableRole: "application", + }, + ...(["x86", "x86_64", "arm", "arm64", "6502", "m68k"] as const).map( + (architecture) => ({ + ...executable, + format: "raw-binary" as const, + architecture, + availableArchitectures: [architecture], + entryPoint: "0x1000", + entryPointSource: "0x00001000", + }), + ), + { + ...executable, + format: "gamecube-dol", + architecture: "powerpc", + availableArchitectures: ["powerpc"], + byteOrder: "big", + bits: 32, + entryPoint: "0x80003100", + entryPointSource: "dol-header", + dol: { + sections: [], + bss: { address: 0, size: 0 }, + bssGaps: [], + bssOverlaps: [], + entryPoint: 0x80003100, + }, + }, + { ...identity, kind: "database", format: "analysis-database" }, + ...([...ZIP_PACKAGE_FORMATS, "asar", "godot-pck", "dmg", "pkg"] as const).map( + (format) => ({ ...identity, kind: "archive" as const, format }), + ), + ...(["plist", "javascript", "source-map"] as const).map((format) => ({ + ...identity, + kind: "artifact" as const, + format, + })), +]; +const impossible = [ + { kind: "database", format: "elf", architecture: "x86" }, + { kind: "executable", format: "zip", architecture: "arm64" }, + { kind: "archive", format: "zip", architecture: "arm64" }, + { kind: "executable", format: "pe", architecture: null }, + { kind: "executable", format: "mach-o", architecture: "hexagon" }, + { kind: "executable", format: "elf", architecture: "arm64_32" }, + { kind: "executable", format: "pe", architecture: "mips" }, + { kind: "database", format: "hopper", architecture: null }, + { + kind: "executable", + format: "raw-binary", + architecture: "riscv", + entry_point: "0x1000", + }, + { kind: "executable", format: "gamecube-dol", architecture: "powerpc" }, + { + kind: "executable", + format: "gamecube-dol", + architecture: "arm", + entry_point: "0x1000", + }, +]; + +it.each(["draft-2020-12", "draft-07"] as const)( + "preserves canonical live summaries and rejects impossible classifications through %s clients", + (target) => { + const advertised = toolOutputSchemaWithMetadata( + toolContract("open_binary"), + )["~standard"].jsonSchema.output({ target }); + const validate = new AjvJsonSchemaValidator().getValidator(advertised); + for (const target of targets) { + const summary = binaryTargetSummary(target); + expect(summary).toEqual({ + ...identity, + kind: target.kind, + format: target.format, + architecture: target.architecture ?? null, + ...(target.format === "raw-binary" || target.format === "gamecube-dol" + ? { entry_point: target.entryPoint } + : {}), + }); + expect( + sessionOutputSchemas.open_binary.safeParse({ result: summary }).success, + ).toBe(true); + expect(validate({ result: summary }).valid).toBe(true); + } + for (const classification of impossible) { + const result = { ...identity, ...classification }; + expect( + sessionOutputSchemas.open_binary.safeParse({ result }).success, + ).toBe(false); + expect(validate({ result }).valid).toBe(false); + } + }, +); + +const sessionFacts = { + open: true, + cleanup_pending: false, + capabilities: [], + analysis_run: null, + analysis_activity: { status: "not_observed", providers: [] }, + provider_operation_health: null, + analysis_provider_binding: null, + analysis_provider_candidates: [], + client_features: { + elicitation_form: false, + elicitation_url: false, + roots: false, + sampling: false, + }, + server_identity: { + package: { + name: "rea", + version: "fixture", + root_path: "/fixture", + build_commit: null, + }, + server: { + name: "rea", + version: "fixture", + started_at: "2026-01-01T00:00:00Z", + command_path: null, + }, + sdk: { server: "fixture", client_test: "fixture", core: "fixture" }, + negotiated_protocol_version: null, + client: null, + skill: { name: "rea", expected_version: "fixture" }, + catalog: { + counts: { cli_commands: 0, mcp_tools: 0, mcp_prompts: 0 }, + digests: { + tools_sha256: identity.sha256, + prompts_sha256: identity.sha256, + combined_sha256: identity.sha256, + }, + }, + protocol_features: { + progress: false, + cancellation: false, + elicitation: false, + }, + alignment: { state: "unknown", reasons: [], remediation: null }, + }, + detail: "complete", + tool_availability: { + client_requirements: { + required: [], + optional: [], + missing_required: [], + missing_optional: [], + }, + groups: [], + }, +}; + +it.each(["draft-2020-12", "draft-07"] as const)( + "shares live classification admission with binary_session in %s", + (target) => { + const advertised = toolOutputSchemaWithMetadata( + toolContract("binary_session"), + )["~standard"].jsonSchema.output({ target }); + const validate = new AjvJsonSchemaValidator().getValidator(advertised); + // One representative per distinct projection shape; the complete target matrix is above. + for (const format of [ + "elf", + "raw-binary", + "gamecube-dol", + "zip", + ] as const) { + const selected = targets.find((candidate) => candidate.format === format); + if (selected === undefined) + throw new Error(`Missing canonical fixture: ${format}`); + const value = { + result: { ...sessionFacts, ...binaryTargetSummary(selected) }, + }; + expect(sessionOutputSchemas.binary_session.safeParse(value).success).toBe( + true, + ); + expect(validate(value).valid).toBe(true); + } + for (const classification of impossible.slice(0, 4)) { + const value = { + result: { ...sessionFacts, ...identity, ...classification }, + }; + expect(sessionOutputSchemas.binary_session.safeParse(value).success).toBe( + false, + ); + expect(validate(value).valid).toBe(false); + } + }, +); diff --git a/tests/conformance/ghidra/ppc-checks.d.mts b/tests/conformance/ghidra/ppc-checks.d.mts index 38609de454..ef27c3ddd0 100644 --- a/tests/conformance/ghidra/ppc-checks.d.mts +++ b/tests/conformance/ghidra/ppc-checks.d.mts @@ -1,7 +1,7 @@ -import type { PowerPcElfMetadata } from "../../../src/domain/binaryTargetTypes.js"; +import type { ElfFamilyMetadata } from "../../../src/domain/binaryTargetTypes.js"; export interface PowerPcReadelfFacts { - powerpc: PowerPcElfMetadata; + powerpc: ElfFamilyMetadata; symbols: Record< | "rea_ppc_entry" | "rea_ppc_leaf"