fix(dotnet): keep a decoded member unknown beside an undecoded counterpart

A field that changed from a decodable to an undecoded signature was
reported as removed on the decoded side and unknown on the other. The
undecoded member with the same declared type and name may be that field
with a changed signature, so its decoded counterpart is now unknown too,
with a counterpart-signature-not-decoded limitation.

The compare_managed_members description now names the exact declared
type, name, and raw signature tier and the undecoded handling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822 2026-10-07 07:45:16 +00:00
parent d52af3b321
commit 76b70203d9
8 changed files with 116 additions and 38 deletions

View File

@ -284,7 +284,13 @@ Cross-version matching first prefers exact CIL/signature identity. When that
does not match, it pairs an exact declared type, method name, and raw signature
before trying structural body shape. The exact-signature key uses names only
as part of that full tuple; names alone never select a pair. Duplicate tuples
remain ambiguous rather than being paired by token order.
remain ambiguous rather than being paired by token order. Fields follow the
same exact tiers. The raw signature is exact whether or not REA decoded it, so
undecoded signatures pair by that tuple too, but they never enter structural
rounds. An unmatched member is therefore reported as `unknown`, not added or
removed, when its own signature was not decoded or when the other side has an
unmatched member with the same declared type and name whose signature was not
decoded.
For a matched method, an unavailable or partial body makes body-shape facets
unknown while preserving observed signature differences. Structural identity

View File

@ -663,9 +663,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "676a0ae5ff16933e3a9b2db0e5a95fcecf57ac926d18ce8b7a31909e040a9972",
"tools_sha256": "4d67b3d567f690604e29dae3c76d4bdfc7d9a332745302dd27af328642118c66",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
"combined_sha256": "c32a78528d109b2c25582a74c02d25ccb779cb511f6e2477017a2e8805c07bac",
"combined_sha256": "ca2f454e044d0095736974fa078383989ab848e420fcebb6127d2c6869c47f1c",
"providers_sha256": "67251a90abf28e1cd9020cf68f579bf2460922d90dc113798a391039279db009"
}
}

View File

@ -1,5 +1,5 @@
{
"manifest_id": "ecm_df760a39c0bf1144d883fe0b80e22cc93364d23289b9a9a14d0efff72a1031bb",
"manifest_id": "ecm_4e3e927be6545a5328c9e1c523da2504d22f1fae7b3d71ddc6c20e0c6faf34c3",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "e6e0645e84a6514c7d7d3e42d5b0a424bfb0110951eff41471f901de39dbec17"
"sha256": "7f689acab3357db8f73aeaf4030e58a1b0c488dfe5a5c3238ce3bb5bcb119bd6"
}
],
"claims": [

View File

@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
metadata:
version: "26"
tool_count: 130
catalog_digest: "c32a78528d109b2c25582a74c02d25ccb779cb511f6e2477017a2e8805c07bac"
catalog_digest: "ca2f454e044d0095736974fa078383989ab848e420fcebb6127d2c6869c47f1c"
---
# REA

View File

@ -126,7 +126,7 @@ export const MANAGED_WORKFLOW_TOOL_CONTRACTS = [
name: "compare_managed_members",
...toolContractMetadata("compare_managed_members"),
description:
"Compare two authenticated inspect_managed_members Evidence records using unique-only decoded-CIL/signature and structural method-shape tiers. Names are reported as observations but are not used as a matching basis; metadata tokens remain build-local coordinates bound to each artifact SHA-256 and MVID, and the tuple digest does not itself remap them.",
"Compare two authenticated inspect_managed_members Evidence records using unique-only tiers: exact CIL/signature, exact declared type, name, and raw signature (decoded or not), and decoded structural method shape. Names alone are never a matching basis; a member whose signature was not decoded, or whose same-named counterpart's was not, stays unknown rather than added or removed. Metadata tokens remain build-local coordinates bound to each artifact SHA-256 and MVID, and the tuple digest does not itself remap them.",
kind: "application",
inputSchema: compareManagedMembersReferenceInputSchema,
outputSchema: comparisonOutputSchema,

View File

@ -28,6 +28,12 @@ interface ComparisonItemContext {
readonly rightComplete: boolean;
}
/** Declared type and name of each unmatched member, per side, whose signature was not decoded. */
interface UndecodedOneSidedNames {
readonly left: ReadonlySet<string>;
readonly right: ReadonlySet<string>;
}
const methodDimensions = (
left: Method,
right: Method,
@ -113,13 +119,15 @@ const unmatchedComparison = (): UnmatchedComparison => ({
const methodOnlyItem = (
member: OneSided<Method>,
context: ComparisonItemContext,
undecoded: UndecodedOneSidedNames,
): MethodItem => {
if (member.left !== undefined) {
const limitations = absenceLimitations(
member.left,
context.rightComplete,
"The right method inventory is incomplete, so absence was not observed.",
);
const limitations = absenceLimitations(member.left, {
oppositeComplete: context.rightComplete,
oppositeUndecodedNames: undecoded.right,
opposite: "right",
inventory: "method",
});
return {
item_id: `mmc_method_${sha256({ token: member.left.token, side: "left" })}`,
status: limitations.length === 0 ? "removed" : "unknown",
@ -132,11 +140,12 @@ const methodOnlyItem = (
};
}
const limitations = absenceLimitations(
member.right,
context.leftComplete,
"The left method inventory is incomplete, so absence was not observed.",
);
const limitations = absenceLimitations(member.right, {
oppositeComplete: context.leftComplete,
oppositeUndecodedNames: undecoded.left,
opposite: "left",
inventory: "method",
});
return {
item_id: `mmc_method_${sha256({ token: member.right.token, side: "right" })}`,
status: limitations.length === 0 ? "added" : "unknown",
@ -151,34 +160,66 @@ const methodOnlyItem = (
/**
* Why a one-sided member's absence from the other inventory was not
* observed: that inventory is incomplete, or the member's own signature was
* not decoded, so no shape round could seek a renamed counterpart.
* observed: that inventory is incomplete, the member's own signature was not
* decoded, so no shape round could seek a changed counterpart, or the other
* inventory has an unmatched same-named member whose signature was not
* decoded, which may be this member with a changed signature.
*/
const absenceLimitations = (
member: Method | Field,
oppositeComplete: boolean,
incomplete: string,
opposite: {
readonly oppositeComplete: boolean;
readonly oppositeUndecodedNames: ReadonlySet<string>;
readonly opposite: "left" | "right";
readonly inventory: "method" | "field";
},
): string[] => [
...(oppositeComplete
...(opposite.oppositeComplete
? []
: [`unknown-within-incomplete-metadata: ${incomplete}`]),
: [
`unknown-within-incomplete-metadata: The ${opposite.opposite} ${opposite.inventory} inventory is incomplete, so absence was not observed.`,
]),
...(member.signature.parse_status === "decoded"
? []
: [
`signature-not-decoded: The member's ${member.signature.parse_status} signature was compared only by exact declared type, name, and raw bytes, so a changed or renamed counterpart could not be sought.`,
]),
...(member.signature.parse_status === "decoded" &&
opposite.oppositeUndecodedNames.has(declaredName(member))
? [
`counterpart-signature-not-decoded: The ${opposite.opposite} ${opposite.inventory} inventory has an unmatched member with the same declared type and name whose signature was not decoded, so it may be this member with a changed signature.`,
]
: []),
];
const declaredName = (member: Method | Field): string =>
JSON.stringify([member.declaring_type, member.name]);
const undecodedOneSidedNames = (members: {
readonly leftOnly: readonly { readonly item: Method | Field }[];
readonly rightOnly: readonly { readonly item: Method | Field }[];
}): UndecodedOneSidedNames => {
const names = (side: readonly { readonly item: Method | Field }[]) =>
new Set(
side
.filter(({ item }) => item.signature.parse_status !== "decoded")
.map(({ item }) => declaredName(item)),
);
return { left: names(members.leftOnly), right: names(members.rightOnly) };
};
const fieldOnlyItem = (
member: OneSided<Field>,
context: ComparisonItemContext,
undecoded: UndecodedOneSidedNames,
): FieldItem => {
if (member.left !== undefined) {
const limitations = absenceLimitations(
member.left,
context.rightComplete,
"The right field inventory is incomplete, so absence was not observed.",
);
const limitations = absenceLimitations(member.left, {
oppositeComplete: context.rightComplete,
oppositeUndecodedNames: undecoded.right,
opposite: "right",
inventory: "field",
});
return {
item_id: `mmc_field_${sha256({ token: member.left.token, side: "left" })}`,
status: limitations.length === 0 ? "removed" : "unknown",
@ -190,11 +231,12 @@ const fieldOnlyItem = (
};
}
const limitations = absenceLimitations(
member.right,
context.leftComplete,
"The left field inventory is incomplete, so absence was not observed.",
);
const limitations = absenceLimitations(member.right, {
oppositeComplete: context.leftComplete,
oppositeUndecodedNames: undecoded.left,
opposite: "left",
inventory: "field",
});
return {
item_id: `mmc_field_${sha256({ token: member.right.token, side: "right" })}`,
status: limitations.length === 0 ? "added" : "unknown",
@ -273,10 +315,11 @@ export const buildMethodItems = (
],
});
}
const undecoded = undecodedOneSidedNames(matches);
for (const item of matches.leftOnly)
items.push(methodOnlyItem({ left: item.item }, context));
items.push(methodOnlyItem({ left: item.item }, context, undecoded));
for (const item of matches.rightOnly)
items.push(methodOnlyItem({ right: item.item }, context));
items.push(methodOnlyItem({ right: item.item }, context, undecoded));
return items;
};
@ -330,9 +373,10 @@ export const buildFieldItems = (
"Multiple managed fields share the same signature; REA did not guess a token remap from names.",
],
});
const undecoded = undecodedOneSidedNames(matches);
for (const item of matches.leftOnly)
items.push(fieldOnlyItem({ left: item.item }, context));
items.push(fieldOnlyItem({ left: item.item }, context, undecoded));
for (const item of matches.rightOnly)
items.push(fieldOnlyItem({ right: item.item }, context));
items.push(fieldOnlyItem({ right: item.item }, context, undecoded));
return items;
};

File diff suppressed because one or more lines are too long

View File

@ -307,6 +307,34 @@ describe("managed member comparison of undecoded signatures", () => {
limitations: [expect.stringContaining("signature-not-decoded")],
});
});
it("keeps a decoded member unknown beside a same-named undecoded counterpart", () => {
const left = inspect(buildManagedPeFixture(), "/tmp/decoded-before.dll");
const right = inspect(
buildManagedPeFixture({
methodSignature: Buffer.from([0x00, 0x00, 0x7f]),
fieldSignature: Buffer.from([0x06, 0x15]),
}),
"/tmp/undecoded-after.dll",
);
expect(left.result.methods[0]?.signature.parse_status).toBe("decoded");
expect(left.result.fields[0]?.signature.parse_status).toBe("decoded");
expect(right.result.coverage.state).toBe("complete");
const result = compareManagedMembers(left, right);
expect(result.summary).toMatchObject({ added: 0, removed: 0, unknown: 4 });
for (const item of [...result.methods, ...result.fields])
expect(item).toMatchObject({
status: "unknown",
match: { status: "unmatched" },
limitations: [
expect.stringMatching(
item.left === null
? /^signature-not-decoded:/
: /^counterpart-signature-not-decoded: The right (method|field) inventory/,
),
],
});
});
});
const inspect = (bytes: Buffer, path: string) => {