diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 9badaabc25..dffef765f3 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -37,6 +37,7 @@ import { inspectNativeDispatch } from "./native/NativeDispatchMetadataInspection import { traceNativeValues } from "./native/NativeValueTrace.js"; import { traceNativeUiAction } from "./native/NativeUiActionTrace.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; +import { addressedValueSchema } from "../domain/native/nativeInventory.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { @@ -676,11 +677,15 @@ export class EnhancedTools { ) { const result = await this.#call(tool, {}, signal); if (!result.ok) return result; - const parsed = z - .array(z.object({ address: z.string(), value: z.string() })) - .safeParse(result.value); + const parsed = z.array(addressedValueSchema).safeParse(result.value); return parsed.success - ? ok(parsed.data.map(({ address, value: name }) => ({ address, name }))) + ? ok( + parsed.data.map(({ address, value: name, objc_class_label }) => ({ + address, + name, + ...(objc_class_label === undefined ? {} : { objc_class_label }), + })), + ) : err( new AnalysisOutputError( tool, diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index e35338fd90..30fe47c4fc 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -13,6 +13,7 @@ import { AnalysisOutputError } from "./analysisErrorCore.js"; export interface AddressedName { readonly address: string; readonly name: string; + readonly objc_class_label?: "hopper_legacy_class_object"; } export interface SegmentSummary { diff --git a/src/domain/native/nativeInventory.ts b/src/domain/native/nativeInventory.ts index eee10b55b3..af16f03210 100644 --- a/src/domain/native/nativeInventory.ts +++ b/src/domain/native/nativeInventory.ts @@ -14,6 +14,7 @@ export const addressedValueSchema = z.object({ source: z.enum(["default", "analysis", "ai", "imported", "user_defined"]), }) .exactOptional(), + objc_class_label: z.literal("hopper_legacy_class_object").exactOptional(), procedure: z .object({ external: z.boolean(), diff --git a/src/domain/symbolAnalysis.objcClasses.test.ts b/src/domain/symbolAnalysis.objcClasses.test.ts new file mode 100644 index 0000000000..d019ae4118 --- /dev/null +++ b/src/domain/symbolAnalysis.objcClasses.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { discoverObjcClasses } from "./symbolAnalysis.js"; + +describe("Objective-C class label discovery", () => { + it("does not reinterpret a provider-specific label without an owner tag", () => { + expect( + discoverObjcClasses( + [{ address: "0x1", name: "__objc_class_HuntRoot_class" }], + "Hunt", + ), + ).toEqual({ count: 0, classes: [] }); + }); + + it("uses provider-tagged stripped class labels and filters their exact text", () => { + const labels = [ + "__objc_class_HuntRoot_class", + "__objc_class_HuntSub_class", + "__objc_class_HuntWorker_class", + "__objc_class_HuntModel_class", + "__objc_class_HuntLegacy_class", + "__objc_class_Hunt_class_class", + "prefix__objc_class_HuntForeign_class", + "__objc_class__class", + "__objc_class_HuntMissingSuffix", + "__objc_class_HuntExtra_class_suffix", + ]; + const names = labels.map((name, index) => ({ + address: `0x${(index + 1).toString(16)}`, + name, + ...(index < 6 + ? { objc_class_label: "hopper_legacy_class_object" as const } + : {}), + })); + + expect(discoverObjcClasses(names, "Hunt")).toEqual({ + count: 6, + classes: names.slice(0, 6).map(({ address, name }) => ({ + address, + name, + })), + }); + expect(discoverObjcClasses(names, "__objc_class_HuntRoot_class")).toEqual({ + count: 1, + classes: [{ address: "0x1", name: "__objc_class_HuntRoot_class" }], + }); + }); +}); diff --git a/src/domain/symbolAnalysis.ts b/src/domain/symbolAnalysis.ts index 3f0841df45..792c09771e 100644 --- a/src/domain/symbolAnalysis.ts +++ b/src/domain/symbolAnalysis.ts @@ -26,8 +26,9 @@ export const discoverObjcClasses = ( const classes = uniqueByName( names .filter( - ({ name }) => + ({ name, objc_class_label }) => name.includes(OBJC_CLASS_MARKER) || + objc_class_label === "hopper_legacy_class_object" || (name.startsWith(LEGACY_OBJC_CLASS_PREFIX) && name.length > LEGACY_OBJC_CLASS_PREFIX.length && !/^l_OBJC_CLASS_NAME_(?:\.\d+)?$/u.test(name)), diff --git a/src/hopper/HopperNameInventory.test.ts b/src/hopper/HopperNameInventory.test.ts new file mode 100644 index 0000000000..0d129f1587 --- /dev/null +++ b/src/hopper/HopperNameInventory.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from "vitest"; +import { parseConfig } from "../config/parseConfig.js"; +import { ok } from "../domain/result.js"; +import { silentLogger } from "../logger.js"; +import { HopperClient } from "./HopperClient.js"; +import { HopperProvider } from "./HopperProvider.js"; + +import { projectHopperNameInventory } from "./HopperNameInventory.js"; + +describe("Hopper name inventory labels", () => { + it("tags stripped class-object labels without rewriting source values", () => { + const value = [ + { + address: "0x1", + value: "__objc_class_HuntRoot_class", + provider_metadata: { section: "__DATA,__objc_classrefs" }, + }, + { + address: "0x2", + value: "__objc_class_Hunt_class_class", + provider_metadata: { section: "__DATA,__objc_classrefs" }, + }, + { address: "0x3", value: "_OBJC_CLASS_$_Unchanged" }, + { address: "0x4", value: "prefix__objc_class_Foreign_class" }, + { address: "0x5", value: "__objc_class__class" }, + { address: "0x6", value: "__objc_class_MissingSuffix" }, + { address: "0x7", value: "__objc_class_Extra_class_suffix" }, + ]; + + expect(projectHopperNameInventory(value)).toEqual({ + changed: true, + result: [ + { + address: "0x1", + value: "__objc_class_HuntRoot_class", + provider_metadata: { section: "__DATA,__objc_classrefs" }, + objc_class_label: "hopper_legacy_class_object", + }, + { + address: "0x2", + value: "__objc_class_Hunt_class_class", + provider_metadata: { section: "__DATA,__objc_classrefs" }, + objc_class_label: "hopper_legacy_class_object", + }, + value[2], + value[3], + value[4], + value[5], + value[6], + ], + }); + }); + + it("replaces provider-supplied label tags with the Hopper-owned classification", () => { + const value = [ + { + address: "0x1", + value: "__objc_class_HuntRoot_class", + objc_class_label: "untrusted_other_label", + provider_metadata: { ordinal: 3 }, + }, + { + address: "0x2", + value: "ordinary_name", + objc_class_label: "hopper_legacy_class_object", + provider_metadata: { ordinal: 4 }, + }, + ]; + + expect(projectHopperNameInventory(value)).toEqual({ + changed: true, + result: [ + { + address: "0x1", + value: "__objc_class_HuntRoot_class", + objc_class_label: "hopper_legacy_class_object", + provider_metadata: { ordinal: 3 }, + }, + { + address: "0x2", + value: "ordinary_name", + provider_metadata: { ordinal: 4 }, + }, + ], + }); + }); + + it("leaves malformed provider inventories untouched for the consumer to reject", () => { + const value = [{ address: 1, value: "__objc_class_HuntRoot_class" }]; + expect(projectHopperNameInventory(value)).toEqual({ + result: value, + changed: false, + }); + }); + + it("preserves source rows and the exact raw reply through the provider boundary", async () => { + const config = parseConfig({}); + if (!config.ok) throw config.error; + const source = [ + { + address: "0x1", + value: "__objc_class_HuntRoot_class", + symbol: { primary: false, source: "imported" }, + provider_metadata: { section: "__DATA,__objc_classrefs" }, + }, + { + address: "0x2", + value: "ordinary_name", + provider_metadata: { ordinal: 2 }, + objc_class_label: "hopper_legacy_class_object", + }, + ]; + const callTool = vi + .spyOn(HopperClient.prototype, "callTool") + .mockResolvedValue(ok(source)); + const provider = new HopperProvider( + config.value, + silentLogger, + {}, + "linux", + ); + const client = provider.createClient({ + path: "/tmp/fixture.hop", + sha256: "a".repeat(64), + kind: "database", + format: "analysis-database", + }); + + const result = await client.execute("list_names", {}); + + expect(callTool).toHaveBeenCalledOnce(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.result).toEqual([ + { + ...source[0], + objc_class_label: "hopper_legacy_class_object", + }, + { + address: "0x2", + value: "ordinary_name", + provider_metadata: { ordinal: 2 }, + }, + ]); + expect(result.value.rawResult).toEqual(source); + } + await client.close(); + callTool.mockRestore(); + }); +}); diff --git a/src/hopper/HopperNameInventory.ts b/src/hopper/HopperNameInventory.ts new file mode 100644 index 0000000000..b478a6d639 --- /dev/null +++ b/src/hopper/HopperNameInventory.ts @@ -0,0 +1,47 @@ +import type { JsonValue } from "../domain/jsonValue.js"; +import { z } from "zod"; + +const LEGACY_OBJC_CLASS_LABEL_PREFIX = "__objc_class_"; +const LEGACY_OBJC_CLASS_LABEL_SUFFIX = "_class"; +const addressedNameRowsSchema = z.array( + z.object({ address: z.string(), value: z.string() }), +); + +const isLegacyObjcClassObjectLabel = (value: string): boolean => + value.startsWith(LEGACY_OBJC_CLASS_LABEL_PREFIX) && + value.endsWith(LEGACY_OBJC_CLASS_LABEL_SUFFIX) && + value.length > + LEGACY_OBJC_CLASS_LABEL_PREFIX.length + + LEGACY_OBJC_CLASS_LABEL_SUFFIX.length; + +/** Tag Hopper's stripped class-object labels while retaining their exact text. */ +export const projectHopperNameInventory = ( + value: JsonValue, +): { readonly result: JsonValue; readonly changed: boolean } => { + const parsed = addressedNameRowsSchema.safeParse(value); + if (!parsed.success) return { result: value, changed: false }; + + let changed = false; + if (!Array.isArray(value)) return { result: value, changed: false }; + const result = value.map((entry, index) => { + if (entry === null || Array.isArray(entry) || typeof entry !== "object") + return entry; + const parsedEntry = parsed.data[index]; + if (parsedEntry === undefined) return entry; + const { objc_class_label: _untrustedLabel, ...sourceFields } = entry; + const ownsLegacyLabel = isLegacyObjcClassObjectLabel(parsedEntry.value); + if (ownsLegacyLabel) { + changed = true; + return { + ...sourceFields, + objc_class_label: "hopper_legacy_class_object" as const, + }; + } + if (_untrustedLabel !== undefined) { + changed = true; + return sourceFields; + } + return entry; + }); + return changed ? { result, changed } : { result: value, changed: false }; +}; diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index 48d3432f6a..6108944405 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -33,6 +33,7 @@ import { import { mapHopperFileOffset } from "./HopperFileOffset.js"; import { HopperRegexSearch } from "./HopperRegexSearch.js"; import { HopperClient } from "./HopperClient.js"; +import { projectHopperNameInventory } from "./HopperNameInventory.js"; import { hopperMachOImageSchema } from "./HopperMachOImage.js"; import { @@ -274,7 +275,7 @@ export class HopperProvider implements AnalysisProviderCandidate { rawResult: JsonValue | null = null, ) => createAnalysisExecution(normalized, executionProvider, { - // Only the file-offset mapping differs from Hopper's reply. + // Retain the source reply when an adapter-owned projection changes it. rawResult, ...(profile === undefined ? {} : { analysisProfile: profile }), ...(operation === "procedure_callers" || @@ -316,15 +317,18 @@ export class HopperProvider implements AnalysisProviderCandidate { operation === "address_to_file_offset" ? await mapHopperFileOffset(target, result.value, options?.signal) : result; - return mapped.ok - ? ok( - projectExecution( - operation, - mapped.value, - mapped === result ? null : result.value, - ), - ) - : mapped; + if (!mapped.ok) return mapped; + const inventory = + operation === "list_names" + ? projectHopperNameInventory(mapped.value) + : { result: mapped.value, changed: false }; + return ok( + projectExecution( + operation, + inventory.result, + inventory.changed || mapped !== result ? result.value : null, + ), + ); }, executeIfReady: async (operation, parameters, options) => { if (operation !== "list_documents" && operation !== "list_procedures") 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 b400d9a4fd..35240f1158 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 @@ -99,6 +99,57 @@ it("discovers declarations without counting duplicates or compiler bookkeeping", }); }); +it("discovers stripped Hopper class labels through MCP with literal filtering", async () => { + const names = [ + "__objc_class_HuntRoot_class", + "__objc_class_HuntSub_class", + "__objc_class_HuntWorker_class", + "__objc_class_HuntModel_class", + "__objc_class_HuntLegacy_class", + "prefix__objc_class_HuntForeign_class", + "__objc_class__class", + "__objc_class_HuntMissingSuffix", + ]; + const client = await connect({ + execute: (name) => { + expect(name).toBe("list_names"); + return Promise.resolve( + ok( + names.map((value, index) => ({ + address: `0x${index + 1}`, + value, + ...(index < 5 + ? { objc_class_label: "hopper_legacy_class_object" } + : {}), + })), + ), + ); + }, + }); + + const all = jsonResult( + await client.callTool({ name: "get_objc_classes", arguments: {} }), + ); + expect(all).toMatchObject({ + count: 5, + classes: names.slice(0, 5).map((name, index) => ({ + address: `0x${index + 1}`, + name, + })), + }); + + const filtered = jsonResult( + await client.callTool({ + name: "get_objc_classes", + arguments: { pattern: "__objc_class_HuntRoot_class" }, + }), + ); + expect(filtered).toMatchObject({ + count: 1, + classes: [{ address: "0x1", name: "__objc_class_HuntRoot_class" }], + }); +}); + const completeOverviewPort: AnalysisOperationPort = { execute: (name) => { switch (name) {