diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 48e168a5a6..743c194e08 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -2,7 +2,7 @@ name: reverse-engineer-anything description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA, and build agent environments (AgentEnv bundles) of web applications from a live instance or a capture. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis. metadata: - version: "56" + version: "57" --- # REA diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 3063aed555..aa46651d4f 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -614,8 +614,12 @@ Use `annotate_native_function` for one function name and/or entry comments, with For `compare_functions`, obtain `analyze_function` Evidence before and after the edit while the MCP session is open; annotation Evidence is not a comparison input. Export the needed Evidence before close if comparison will happen later. -A rename can be reported as a function mismatch even for the same entry in one -Program; inspect the selected entries, profiles and changed dimensions. +Authenticated same-artifact coordinates preserve renamed entries as +`matched / same-entry`, with the canonical `entry_address` inline; name and +exact-text changes remain separate dimensions. This requires compatible +architecture, provider-owned mapping and selected-image commitments. Equal +numeric addresses alone cannot establish correspondence; inspect retained +source Evidence and coverage when the match remains ambiguous. ## Offline PE resource inventory diff --git a/docs/native-investigation.md b/docs/native-investigation.md index ff6e69de88..b9933284f9 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -1123,9 +1123,13 @@ itself is not an `analyze_function` input. If comparing after close, export the needed Evidence before closing and import it into the later session. A one-shot CLI annotation returns its edited dossier before its temporary session is discarded; a later CLI invocation with the same fresh-import settings starts from -the original executable. The current name-based comparison can report a rename -as a function mismatch even for the same entry in one Program; inspect the -selected entries, profiles and changed dimensions before interpreting identity. +the original executable. Comparison preserves correspondence across renames +when authenticated artifact identity, architecture and provider-owned mapping +and slice commitments establish the same canonical entry. The result reports +`matched`, `method: "same-entry"` and `entry_address`; name and exact-text changes +remain separate dimensions. Equal numeric addresses without applicable coordinate +commitments do not establish same-entry correspondence. Inspect the retained +source Evidence and coverage when correspondence remains ambiguous. `set_address_name` and `set_addresses_names` give Ghidra sessions the same compact naming results as Hopper: a local function entry renames that function, and any other mapped address, such as a global, receives a new or renamed primary diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index 4389c59e6d..86d0722f0c 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -115,7 +115,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "compare_functions", - "Compare two explicit analyze_function Evidence records or exact retained session references across identity, exact provider text, calls, references, strings, and address-normalized CFG topology. Missing or provider-incompatible facets remain truncated or unknown; every conclusion cites both Evidence sets.", + "Compare two explicit analyze_function Evidence records or exact retained session references across identity, exact provider text, calls, references, strings, and address-normalized CFG topology. Authenticated same-artifact entry coordinates establish correspondence independently of renamed labels; metadata and text changes remain separate. Missing or provider-incompatible facets remain truncated or unknown; every conclusion cites both Evidence sets.", functionComparisonInputSchema, ), session( diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index 216c55fb0e..cdb2f9f8df 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -169,6 +169,57 @@ const rejectsMalformedFacetAvailability = (): void => { ).toBe(false); }; +it.each(["imported", null] as const)( + "preserves same-entry correspondence across a rename from symbol origin %s", + (origin) => { + const before = dossier("int evaluate(void) { return 0; }", "0x1000"); + before.procedure.name = "evaluate"; + before.procedure.symbol = + origin === null + ? null + : { ...procedure("0x1000", "evaluate").symbol, source: origin }; + const after = functionDossierSchema.parse({ + ...before, + procedure: { + ...before.procedure, + name: "renamed_evaluate", + symbol: { + ...procedure("0x1000", "evaluate").symbol, + source: "user_defined", + }, + }, + pseudocode: "int renamed_evaluate(void) { return 0; }", + }); + const left = observe("a", before); + const right = observe("a", after); + const result = compareFunctions( + functionSnapshotFixture(left), + functionSnapshotFixture(right), + ); + expect(result.function_match).toMatchObject({ + status: "matched", + method: "same-entry", + left_name: "evaluate", + right_name: "renamed_evaluate", + }); + expect(result.status).toBe("changed"); + for (const name of ["identity", "pseudocode"]) + expect( + result.dimensions.find(({ dimension }) => dimension === name), + ).toMatchObject({ status: "changed" }); + expect( + result.dimensions.find(({ dimension }) => dimension === "assembly"), + ).toMatchObject({ status: "unchanged" }); + expect( + result.dimensions.every( + ({ evidence_links }) => + evidence_links.includes(left.evidence_id) && + evidence_links.includes(right.evidence_id), + ), + ).toBe(true); + }, +); + const keepsGeneratedNamesAmbiguous = (): void => { const named = (base: "0x1000" | "0x2000", name: string) => functionDossierSchema.parse({ @@ -1641,3 +1692,20 @@ describe("function comparison unnamed reference endpoints", () => { ).toMatchObject({ status: "changed" }); }); }); + +it("does not equate matching numeric entries from different providers", () => { + const before = dossier("return 0;", "0x1000"); + before.procedure.name = "evaluate"; + const after = functionDossierSchema.parse({ + ...before, + procedure: { ...before.procedure, name: "renamed_evaluate" }, + }); + const result = compareFunctions( + functionSnapshotFixture(observe("a", before, "provider-a")), + functionSnapshotFixture(observe("a", after, "provider-b")), + ); + expect(result.function_match).toMatchObject({ + status: "ambiguous", + method: "explicit", + }); +}); diff --git a/src/domain/functionComparison.ts b/src/domain/functionComparison.ts index 9710fbe7d2..3cdaf9c299 100644 --- a/src/domain/functionComparison.ts +++ b/src/domain/functionComparison.ts @@ -1,6 +1,9 @@ import { canonicalJson } from "./comparisonSemantics.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; -import type { FunctionSnapshot } from "./functionDossierEvidence.js"; +import { + functionSnapshotCoordinateContext, + type FunctionSnapshot, +} from "./functionDossierEvidence.js"; import { functionMatch } from "./functionComparisonNormalization.js"; import { functionComparisonResultSchema, @@ -18,8 +21,14 @@ export const compareFunctions = ( const providersDiffer = canonicalJson(left.evidence.provider, "Function comparison") !== canonicalJson(right.evidence.provider, "Function comparison"); - const dimensions = compareDimensions(left, right, { links, providersDiffer }); - const match = functionMatch(left, right); + const coordinateContext = functionSnapshotCoordinateContext(left, right); + const match = functionMatch(left, right, coordinateContext); + const dimensions = compareDimensions(left, right, { + links, + providersDiffer, + sameEntry: match.method === "same-entry", + coordinateContext, + }); const changes = dimensions.filter(({ status }) => status !== "unchanged"); return functionComparisonResultSchema.parse({ status: overallStatus(dimensions, match.status), diff --git a/src/domain/functionComparisonDimensions.ts b/src/domain/functionComparisonDimensions.ts index 09ce541c95..0db87e5bc4 100644 --- a/src/domain/functionComparisonDimensions.ts +++ b/src/domain/functionComparisonDimensions.ts @@ -6,7 +6,10 @@ import { } from "./functionRelationshipComparison.js"; import { diffLines } from "diff"; -import type { FunctionSnapshot } from "./functionDossierEvidence.js"; +import type { + FunctionSnapshot, + FunctionSnapshotCoordinateContext, +} from "./functionDossierEvidence.js"; import { commentProjection, identityProjection, @@ -30,14 +33,21 @@ import { type ComparisonContext = Pick< DimensionResultInput, "links" | "providersDiffer" ->; +> & { + readonly sameEntry: boolean; + readonly coordinateContext: FunctionSnapshotCoordinateContext; +}; export const compareDimensions = ( left: FunctionSnapshot, right: FunctionSnapshot, context: ComparisonContext, ): FunctionDimension[] => { - const relationships = compareFunctionRelationships(left, right); + const relationships = compareFunctionRelationships( + left, + right, + context.coordinateContext, + ); return [ compareIdentity(left, right, context), compareText( @@ -544,14 +554,15 @@ const compareIdentity = ( context: ComparisonContext, ): FunctionDimension => { if ( - !hasStableNativeSymbol( + !context.sameEntry && + (!hasStableNativeSymbol( left.dossier.procedure.name, left.dossier.procedure.symbol, ) || - !hasStableNativeSymbol( - right.dossier.procedure.name, - right.dossier.procedure.symbol, - ) + !hasStableNativeSymbol( + right.dossier.procedure.name, + right.dossier.procedure.symbol, + )) ) return unresolvedDimension({ dimension: "identity", diff --git a/src/domain/functionComparisonNormalization.ts b/src/domain/functionComparisonNormalization.ts index d80b6895d9..177be5d0bd 100644 --- a/src/domain/functionComparisonNormalization.ts +++ b/src/domain/functionComparisonNormalization.ts @@ -1,9 +1,15 @@ import { hasStableNativeSymbol } from "./native/nativeSymbol.js"; import { canonicalJson } from "./comparisonSemantics.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; -import { nativeAddressCoordinateInputSchema } from "./native/nativeAddress.js"; +import { + nativeAddressCoordinateInputSchema, + nativeAddressInputSchema, +} from "./native/nativeAddress.js"; -import type { FunctionSnapshot } from "./functionDossierEvidence.js"; +import type { + FunctionSnapshotCoordinateContext, + FunctionSnapshot, +} from "./functionDossierEvidence.js"; import type { FunctionComparisonResult } from "./functionComparisonSchemas.js"; export const identityProjection = (snapshot: FunctionSnapshot) => ({ @@ -17,9 +23,39 @@ export const identityProjection = (snapshot: FunctionSnapshot) => ({ export const functionMatch = ( left: FunctionSnapshot, right: FunctionSnapshot, + coordinateContext: FunctionSnapshotCoordinateContext, ): FunctionComparisonResult["function_match"] => { const leftName = left.dossier.procedure.name; const rightName = right.dossier.procedure.name; + if (coordinateContext === "common") { + const leftEntry = nativeAddressInputSchema.safeParse( + left.dossier.procedure.address, + ); + const rightEntry = nativeAddressInputSchema.safeParse( + right.dossier.procedure.address, + ); + if (leftEntry.success && rightEntry.success) + return leftEntry.data === rightEntry.data + ? { + status: "matched", + method: "same-entry", + entry_address: leftEntry.data, + left_name: leftName, + right_name: rightName, + } + : { + status: "mismatched", + method: "explicit", + left_name: leftName, + right_name: rightName, + }; + return { + status: "ambiguous", + method: "explicit", + left_name: leftName, + right_name: rightName, + }; + } const stableNames = hasStableNativeSymbol(leftName, left.dossier.procedure.symbol) && hasStableNativeSymbol(rightName, right.dossier.procedure.symbol); @@ -31,7 +67,10 @@ export const functionMatch = ( right_name: rightName, }; return { - status: stableNames ? "mismatched" : "ambiguous", + status: + stableNames && coordinateContext === "cross-artifact" + ? "mismatched" + : "ambiguous", method: "explicit", left_name: leftName, right_name: rightName, diff --git a/src/domain/functionComparisonSchemas.ts b/src/domain/functionComparisonSchemas.ts index d10c8b5318..bbcf325a87 100644 --- a/src/domain/functionComparisonSchemas.ts +++ b/src/domain/functionComparisonSchemas.ts @@ -3,6 +3,7 @@ import { overallStatus, summarize } from "./functionComparisonResults.js"; import { canonicalJson } from "./comparisonSemantics.js"; import { evidenceInputSchema } from "./evidenceReference.js"; import { digestSchema, prefixedDigestSchema } from "./../domain/digests.js"; +import { nativeAddressSchema } from "./native/nativeAddress.js"; const comparisonStatusSchema = z.enum(["unchanged", "changed", "unknown"]); export const functionComparisonDimensionNameSchema = z.enum([ @@ -71,6 +72,13 @@ const functionMatchSchema = z.union([ left_name: z.string(), right_name: z.string(), }), + z.object({ + status: z.literal("matched"), + method: z.literal("same-entry"), + entry_address: nativeAddressSchema, + left_name: z.string(), + right_name: z.string(), + }), z.object({ status: z.enum(["mismatched", "ambiguous"]), method: z.literal("explicit"), diff --git a/src/domain/functionDossierEvidence.ts b/src/domain/functionDossierEvidence.ts index 7757f3bf75..ee3ccd92d6 100644 --- a/src/domain/functionDossierEvidence.ts +++ b/src/domain/functionDossierEvidence.ts @@ -10,6 +10,7 @@ import { type FunctionDossier, } from "./native/functionDossier.js"; import { assessFunctionCallCoverage } from "./functionCallCoverage.js"; +import { functionCoordinateContextsEqual } from "./native/functionCoordinateContext.js"; /** Complete function observation parsed from one analyze_function Evidence record. */ export interface FunctionSnapshot { @@ -22,6 +23,29 @@ export interface FunctionSnapshot { readonly callCoverageDiagnostics: readonly string[]; } +/** Artifact identity and provider-owned mapping jointly establish coordinate applicability. */ +export type FunctionSnapshotCoordinateContext = + | "common" + | "cross-artifact" + | "unknown"; + +export const functionSnapshotCoordinateContext = ( + left: FunctionSnapshot, + right: FunctionSnapshot, +): FunctionSnapshotCoordinateContext => { + if (left.subject.digest.sha256 !== right.subject.digest.sha256) + return "cross-artifact"; + return left.subject.format === right.subject.format && + left.subject.architecture !== null && + left.subject.architecture === right.subject.architecture && + functionCoordinateContextsEqual( + left.evidence.analysis_profile, + right.evidence.analysis_profile, + ) + ? "common" + : "unknown"; +}; + /** Parse one complete analyze_function Evidence record for comparison workflows. */ export const parseFunctionEvidence = ( input: unknown, diff --git a/src/domain/functionRelationshipComparison.ts b/src/domain/functionRelationshipComparison.ts index 4483344bc1..adb66ad7c5 100644 --- a/src/domain/functionRelationshipComparison.ts +++ b/src/domain/functionRelationshipComparison.ts @@ -1,6 +1,8 @@ -import { functionCoordinateContextsEqual } from "./native/functionCoordinateContext.js"; import { canonicalJson } from "./comparisonSemantics.js"; -import type { FunctionSnapshot } from "./functionDossierEvidence.js"; +import type { + FunctionSnapshotCoordinateContext, + FunctionSnapshot, +} from "./functionDossierEvidence.js"; import { sorted } from "./functionComparisonNormalization.js"; import { hasStableNativeSymbol, @@ -225,24 +227,11 @@ const constraints = ( export const compareFunctionRelationships = ( left: FunctionSnapshot, right: FunctionSnapshot, + coordinateContext: FunctionSnapshotCoordinateContext, ): { readonly calls: FunctionRelationshipComparison; readonly references: FunctionRelationshipComparison; } => { - // Provider coordinate commitments own loader/slice semantics. Missing context is not equality. - const sameArtifact = - left.subject.digest.sha256 === right.subject.digest.sha256; - const coordinateContext = !sameArtifact - ? "cross-artifact" - : left.subject.format === right.subject.format && - left.subject.architecture !== null && - left.subject.architecture === right.subject.architecture && - functionCoordinateContextsEqual( - left.evidence.analysis_profile, - right.evidence.analysis_profile, - ) - ? "common" - : "unknown"; const ambiguousNames = ambiguousProcedureNames([left, right]); const compare = (dimension: "calls" | "references") => compareRelationshipFacts( diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts index 8b7319ed95..8ffd151689 100644 --- a/src/generatedPackageMetadata.ts +++ b/src/generatedPackageMetadata.ts @@ -6,5 +6,5 @@ export const PACKAGE_METADATA = { serverSdkVersion: "2.3.1", clientSdkVersion: "2.3.1", coreSdkVersion: "2.3.1", - skillVersion: "56", + skillVersion: "57", } as const; diff --git a/tests/boundary/mcp/functionComparisonReferenceFactsMcp.test.ts b/tests/boundary/mcp/functionComparisonReferenceFactsMcp.test.ts index 86788ce923..028f7e5c2b 100644 --- a/tests/boundary/mcp/functionComparisonReferenceFactsMcp.test.ts +++ b/tests/boundary/mcp/functionComparisonReferenceFactsMcp.test.ts @@ -1,6 +1,11 @@ import type { NativeSymbolMetadata } from "../../../src/domain/native/nativeSymbol.js"; -import type { Client } from "@modelcontextprotocol/client"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { McpServer } from "@modelcontextprotocol/server"; import { expect, it, onTestFinished } from "vitest"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { z } from "zod"; +import { SESSION_TOOL_CONTRACTS } from "../../../src/contracts/sessionToolContracts.js"; +import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js"; import { createFunctionCoordinateContext } from "../../../src/domain/native/functionCoordinateContext.js"; import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; @@ -976,3 +981,202 @@ it.each([null, "default", "imported"] as const)( ); }, ); + +it("distinguishes same-entry renames from different entries through retained MCP Evidence", async () => { + const harness = await createHarness(); + const left = evidence("before-rename", "a", [], { + procedureAddress: "0x1000", + procedureName: "evaluate", + }); + const right = evidence("after-rename", "a", [], { + procedureAddress: "0x01000", + procedureName: "renamed_evaluate", + procedureSymbol: { + ...procedure("0x1000", "evaluate").symbol!, + source: "user_defined", + }, + }); + const first = await compare(harness.client, left, right); + expect(first.result.function_match).toMatchObject({ + status: "matched", + method: "same-entry", + entry_address: "0x1000", + left_name: "evaluate", + right_name: "renamed_evaluate", + }); + expect( + first.result.dimensions.find(({ dimension }) => dimension === "identity"), + ).toMatchObject({ status: "changed" }); + const repeated = await harness.client.callTool({ + name: "compare_functions", + arguments: { + left: { kind: "retained-evidence", evidence_id: left.evidence_id }, + right: { kind: "retained-evidence", evidence_id: right.evidence_id }, + }, + }); + expect(repeated.isError).not.toBe(true); + expect(parseEvidence(repeated.structuredContent).normalized_result).toEqual( + first.result, + ); + const contract = SESSION_TOOL_CONTRACTS.find( + ({ name }) => name === "compare_functions", + ); + if (contract === undefined) + throw new Error("compare_functions contract is missing"); + const schemaServer = new McpServer({ + name: "comparison-schema", + version: "1", + }); + const schemaClient = new Client({ name: "comparison-schema", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + onTestFinished(async () => { + await schemaClient.close(); + await schemaServer.close(); + }); + schemaServer.registerTool( + contract.name, + toolRegistrationOptions(contract), + async () => ({ + content: [], + structuredContent: repeated.structuredContent, + }), + ); + await schemaServer.connect(serverTransport); + await schemaClient.connect(clientTransport); + const advertised = (await schemaClient.listTools()).tools.find( + ({ name }) => name === "compare_functions", + ); + if (advertised?.outputSchema === undefined) + throw new Error("compare_functions output schema is missing"); + const outputSchema = z + .record(z.string(), z.unknown()) + .parse(advertised.outputSchema); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + expect(ajv.validateSchema(outputSchema)).toBe(true); + const validate = ajv.compile(outputSchema); + expect( + validate(repeated.structuredContent), + JSON.stringify(validate.errors), + ).toBe(true); + const sdkResult = await schemaClient.callTool({ + name: contract.name, + arguments: { + left: { kind: "retained-evidence", evidence_id: left.evidence_id }, + right: { kind: "retained-evidence", evidence_id: right.evidence_id }, + }, + }); + expect(sdkResult.structuredContent).toEqual(repeated.structuredContent); + expect( + validate({ + ...first.evidence, + normalized_result: { + ...first.result, + function_match: { + status: "matched", + method: "same-entry", + left_name: "evaluate", + right_name: "renamed_evaluate", + }, + }, + }), + ).toBe(false); + const different = evidence("distinct-entry", "a", [], { + procedureAddress: "0x2000", + procedureName: "evaluate", + }); + expect( + (await compare(harness.client, left, different)).result.function_match, + ).toMatchObject({ status: "mismatched", method: "explicit" }); +}); + +it.each([ + { name: "missing profile", profile: null }, + { + name: "missing coordinate commitment", + profile: createAnalysisProfile(provider, {}), + }, + { + name: "malformed coordinate commitment", + profile: createAnalysisProfile(provider, { + function_coordinate_context: { + kind: "native-function-coordinate-context-v1", + mapping_sha256: "invalid", + selected_image: null, + }, + }), + }, + { + name: "different loader mapping", + profile: createAnalysisProfile(provider, { + function_coordinate_context: createFunctionCoordinateContext({ + ...mapping, + entry_point: "0x2000", + }), + }), + }, + { name: "unknown architecture", architecture: null }, + { name: "different architecture", architecture: "x86_64" as const }, + { name: "different format", format: "mach-o-universal" as const }, + { + name: "unestablished entry coordinate", + procedureAddress: "unresolved-entry", + }, +])( + "keeps rename correspondence unknown with $name", + async ({ name, ...options }) => { + const harness = await createHarness(); + const left = evidence("known-entry", "a", [], { + procedureAddress: "0x1000", + procedureName: "evaluate", + }); + const right = evidence(name, "a", [], { + procedureAddress: "0x1000", + procedureName: "renamed_evaluate", + ...options, + }); + expect( + (await compare(harness.client, left, right)).result.function_match, + ).toMatchObject({ status: "ambiguous", method: "explicit" }); + }, +); + +it.each([4096, 8192])( + "binds same-entry correspondence to selected image offset %i", + async (rightOffset) => { + const harness = await createHarness(); + const source = (offset: number, name: string) => + evidence(name, "a", [], { + procedureAddress: "0x1000", + procedureName: name, + format: "mach-o-universal", + profile: createAnalysisProfile(provider, { + function_coordinate_context: createFunctionCoordinateContext( + mapping, + { + kind: "macho-selected-image-v1", + container: "fat", + offset, + byte_length: 1024, + cpu_type: 0x0100000c, + cpu_subtype: 0, + container_table_sha256: "a".repeat(64), + }, + ), + }), + }); + expect( + ( + await compare( + harness.client, + source(4096, "before"), + source(rightOffset, "renamed"), + ) + ).result.function_match, + ).toMatchObject( + rightOffset === 4096 + ? { status: "matched", method: "same-entry", entry_address: "0x1000" } + : { status: "ambiguous", method: "explicit" }, + ); + }, +);