feat(managed): select declaring types in inspect_managed_members

Select exact declaring types before managed signatures and CIL bodies are decoded, preserving caller choices in CLI/MCP Evidence. Keep unmatched names and selected coverage explicit; comparisons and graphs retain unknowns outside their observed scope. Reuse existing metadata ownership/accounting and advertise unique selections through the actual SDK Schema.

Validated with 229 focused tests across 21 files, 11 managed conformance controls, fullstatic/docs checks and real Linux CLI and fresh MCP inspection of two unchanged DLLs. All 118 selected CIL hashes matched original PE-mapped bytes; result/caller-argument parity and unmatched controls passed. Full artifact/native-boundary metadata is complete; whole-assembly members retain the unchanged 64 MiB resource constraint. No target code executes.

Refs #1878

Co-authored-by: skared <190618464+skoodster@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
skoodster
2026-10-11 19:31:37 -05:00
committed by GitHub
parent f28f338283
commit a149d183ef
21 changed files with 645 additions and 90 deletions
+28
View File
@@ -58,6 +58,34 @@ reports `resource-limit` with unavailable or partial coverage; omitted facts do
not become empty observations. Ambiguous PE section mappings are rejected
rather than interpreted according to section order.
### Selected member inspection
When whole-assembly member decoding reaches the representation budget, select
known declaring types before their signatures and CIL bodies are decoded:
```bash
rea inspect-managed-members /absolute/path/Assembly.dll --type Example.Parser
```
MCP uses `inspect_managed_members` with
`{"path":"/absolute/path/Assembly.dll","type_names":["Example.Parser"]}`.
Repeat `--type` or supply multiple unique `type_names` for several types.
Names match the reported `full_name` exactly. Top-level names are namespace
qualified; nested types currently report bare names, which can select several
types with that name.
The result preserves the requested names in `selection.type_names` and reports
unmatched names separately. If metadata names are unreadable, limitations retain
that uncertainty. With no selection, `selection` is null and whole-assembly
inspection keeps its existing resource policy. Selection restricts types,
fields, methods, call edges and field accesses; member references remain an
assembly-wide inventory, so output size is not guaranteed to be small.
Coverage describes the selected members. Comparisons leave members outside the
opposite observation's selection unknown, and application graphs report their
selected scope as partial. Neither makes an absence claim about the whole
assembly. Older member Evidence without `selection` remains readable.
## Analysis objective
REA's managed-code track is intended to answer five different questions without
+8 -3
View File
@@ -128,7 +128,7 @@ export const runProviderAnalysis = async (
]
): Promise<JsonValue> =>
isManagedToolName(tool)
? runManagedProviderAnalysis(dependencies, path, tool, signal)
? runManagedProviderAnalysis(dependencies, path, tool, arguments_, signal)
: withProcessCancellation(signal, (operationSignal) =>
runAnalysis(dependencies, path, tool, arguments_, {
logger,
@@ -143,6 +143,7 @@ export const runManagedProviderExecution = async (
path: string,
tool: ManagedToolName,
signal?: AbortSignal,
parameters: Readonly<Record<string, JsonValue>> = {},
): Promise<Result<AnalysisExecution, AnalysisError>> =>
withProcessCancellation(signal, async (operationSignal) => {
const session = await dependencies.createManagedBinarySession();
@@ -151,7 +152,9 @@ export const runManagedProviderExecution = async (
async () => {
const opened = await session.open(path, { signal: operationSignal });
if (!opened.ok) return opened;
return await session.execute(tool, {}, { signal: operationSignal });
return await session.execute(tool, parameters, {
signal: operationSignal,
});
},
managedAnalysisCleanupFailure,
);
@@ -161,6 +164,7 @@ const runManagedProviderAnalysis = async (
dependencies: DirectAnalysisDependencies,
path: string,
tool: ManagedToolName,
arguments_: Readonly<Record<string, JsonValue>>,
signal?: AbortSignal,
): Promise<JsonValue> => {
const execution = await runManagedProviderExecution(
@@ -168,12 +172,13 @@ const runManagedProviderAnalysis = async (
path,
tool,
signal,
arguments_,
);
if (!execution.ok) return cliError(execution.error);
const value = execution.value;
return createEvidence(value.subject ?? undefined, value.provider, {
operation: tool,
parameters: {},
parameters: arguments_,
result: value.result,
rawResult: value.rawResult,
limitations: value.limitations,
+15 -2
View File
@@ -54,9 +54,22 @@ const registerInspectManagedMembers = (
args: z.object({
path: z.string().describe("Managed PE executable or assembly path"),
}),
run: ({ args }) =>
options: z.object({
type: z
.array(z.string())
.optional()
.describe(
"Inspect only members declared by this exact type full name, such as System.Threading.Monitor; a nested type is named by its bare name, which selects every nested type of that name (repeatable)",
),
}),
run: ({ args, options }) =>
logCliCommand(logger, "inspect-managed-members", () =>
runProviderAnalysis(args.path, "inspect_managed_members", {}, logger),
runProviderAnalysis(
args.path,
"inspect_managed_members",
options.type === undefined ? {} : { type_names: options.type },
logger,
),
),
});
};
+31 -2
View File
@@ -20,6 +20,28 @@ export const managedTargetInputSchema = z.strictObject({
.optional(),
});
/** Member inspection may select declaring types before members are decoded. */
export const managedMembersInputSchema = managedTargetInputSchema.extend({
type_names: z
.array(
z
.string()
.min(1)
.describe(
"Exact declaring-type full_name: namespace-qualified for top-level types (System.Threading.Monitor); nested types report their bare name, so it selects every nested type of that name",
),
)
.min(1)
.refine((names) => new Set(names).size === names.length, {
message: "type_names must not repeat a name",
})
.meta({ uniqueItems: true })
.describe(
"Inspect only the fields, methods, call edges, and field accesses declared by these exact type full names; omit to inspect every type. Unmatched names are reported in selection.unmatched_type_names.",
)
.optional(),
});
const outputSchema = requireOutputSchema(
managedOutputSchemas,
"inspect_managed_artifact",
@@ -54,15 +76,22 @@ export const MANAGED_TOOL_CONTRACTS = [
name: "inspect_managed_members",
...toolContractMetadata("inspect_managed_members"),
description:
"Inspect PE/CLI metadata members, signatures, raw CIL hashes, decoded-instruction-tuple hashes, separately reported exception regions, call edges, and field-access anchors without loading or executing target code. Metadata tokens are reported as build-local coordinates bound to the artifact SHA-256 and MVID; the tuple hash does not resolve them or fully commit control flow.",
"Inspect PE/CLI metadata members, signatures, raw CIL hashes, decoded-instruction-tuple hashes, separately reported exception regions, call edges, and field-access anchors without loading or executing target code. Select type_names to decode only the members of those exact declaring types, which keeps large assemblies such as a base class library within the representation budget. Metadata tokens are reported as build-local coordinates bound to the artifact SHA-256 and MVID; the tuple hash does not resolve them or fully commit control flow.",
kind: "managed-provider",
inputSchema: managedTargetInputSchema,
inputSchema: managedMembersInputSchema,
outputSchema: memberOutputSchema,
examples: [
{
title: "Example inspect managed members request",
input: {},
},
{
title: "Example inspect selected managed types request",
input: {
path: "/tmp/mscorlib.dll",
type_names: ["System.Threading.Monitor"],
},
},
],
},
{
@@ -53,6 +53,7 @@ const emptyManagedMembers = (
member_refs: [],
call_edges: [],
field_accesses: [],
selection: null,
coverage: { state: "complete", issues: [] },
limitations: [],
});
@@ -335,6 +335,11 @@ const projectionLimitations = (
...(omissions.partialInput
? ["At least one supplied managed Evidence record has partial coverage."]
: []),
...(omissions.selectedTypes === null
? []
: [
`Managed member Evidence selected only these declaring types: ${omissions.selectedTypes.join(", ")}; type, method, and field nodes of other types are omitted, not absent.`,
]),
...(omissions.unscopedTokenRelationships
? [
"Managed boundary tokens were not linked to member nodes because matching module MVID scope was not established.",
@@ -19,8 +19,15 @@ export interface ManagedGraphCoverageSources {
export interface ManagedGraphProjectionOmissions {
readonly partialInput: boolean;
readonly unscopedTokenRelationships: boolean;
/** Declaring types the member Evidence selected; null when it inspected every type. */
readonly selectedTypes: readonly string[] | null;
}
const omitsAny = (omissions: ManagedGraphProjectionOmissions): boolean =>
omissions.partialInput ||
omissions.unscopedTokenRelationships ||
omissions.selectedTypes !== null;
const completeCoverage = (): ApplicationGraphEvidence["coverage"] => ({
status: "complete",
truncated: false,
@@ -61,13 +68,14 @@ export const assessManagedGraphOmissions = (
sources.boundaries?.coverage.state,
sources.artifact?.coverage.state,
].some((state) => state !== undefined && state !== "complete"),
selectedTypes: sources.members?.selection?.type_names ?? null,
});
/** Derive graph coverage solely from the coverage of supplied source evidence. */
export const managedGraphEvidenceCoverage = (
omissions: ManagedGraphProjectionOmissions,
): ApplicationGraphEvidence["coverage"] =>
omissions.partialInput || omissions.unscopedTokenRelationships
omitsAny(omissions)
? {
status: "partial",
truncated: false,
@@ -80,8 +88,7 @@ export const managedGraphEvidenceCoverage = (
export const managedGraphResultCoverage = (
omissions: ManagedGraphProjectionOmissions,
) => ({
status:
omissions.partialInput || omissions.unscopedTokenRelationships
? ("partial" as const)
: ("complete-within-inputs" as const),
status: omitsAny(omissions)
? ("partial" as const)
: ("complete-within-inputs" as const),
});
+8
View File
@@ -440,6 +440,14 @@ export const managedMemberInspectionSchema = z.object({
member_refs: z.array(managedMemberReferenceSchema),
call_edges: z.array(managedCallEdgeSchema),
field_accesses: z.array(managedFieldAccessSchema),
/** Caller-selected declaring types; null when every type was inspected. */
selection: z
.object({
type_names: z.array(z.string().min(1)).min(1),
unmatched_type_names: z.array(z.string().min(1)),
})
.nullable()
.default(null),
coverage: z.object({
state: z.enum(["complete", "partial", "unavailable"]),
issues: z.array(managedParseIssueSchema),
@@ -213,6 +213,11 @@ export interface ManagedMemberComparisonSide {
readonly result: ManagedMemberInspection;
}
const selectedTypes = (
result: ManagedMemberInspection,
): ReadonlySet<string> | null =>
result.selection === null ? null : new Set(result.selection.type_names);
/** Compare managed members with constrained exact and structural identities. */
export const compareManagedMembers = (
left: ManagedMemberComparisonSide,
@@ -224,6 +229,8 @@ export const compareManagedMembers = (
rightEvidenceId: right.evidenceId,
leftComplete: left.result.metadata.status === "complete",
rightComplete: right.result.metadata.status === "complete",
leftSelection: selectedTypes(left.result),
rightSelection: selectedTypes(right.result),
};
const methodItems = buildMethodItems(methodMatches, itemContext);
const fieldItems = buildFieldItems(fieldMatches, itemContext);
@@ -16,7 +16,9 @@ export const buildComparisonCoverage = ({
const leftStatus = sideCoverageStatus(left);
const rightStatus = sideCoverageStatus(right);
const unknownInput =
left.coverage.state !== "complete" || right.coverage.state !== "complete";
left.coverage.state !== "complete" ||
right.coverage.state !== "complete" ||
!sameSelection(left, right);
return {
status:
!unknownInput && leftStatus === "complete" && rightStatus === "complete"
@@ -27,6 +29,20 @@ export const buildComparisonCoverage = ({
};
};
/** Whether both observations inspected the same declaring types. */
const sameSelection = (
left: ManagedMemberInspection,
right: ManagedMemberInspection,
): boolean => {
if (left.selection === null || right.selection === null)
return left.selection === right.selection;
const rightNames = new Set(right.selection.type_names);
return (
rightNames.size === new Set(left.selection.type_names).size &&
left.selection.type_names.every((name) => rightNames.has(name))
);
};
const sideCoverageStatus = (
result: ManagedMemberInspection,
): ManagedMemberComparisonResult["coverage"]["left_status"] =>
@@ -96,6 +112,14 @@ export const comparisonLimitations = (
];
if (left.coverage.state !== "complete" || right.coverage.state !== "complete")
limitations.push("At least one managed member observation is partial.");
if (!sameSelection(left, right))
limitations.push(
"The observations select different declaring types; a member outside the other side's selection is unknown, not added or removed.",
);
else if (left.selection !== null)
limitations.push(
`Both observations cover only the selected declaring types (${left.selection.type_names.join(", ")}); member totals and statuses describe those types, not the whole assembly.`,
);
if (left.exported_types === null || right.exported_types === null)
limitations.push(
"At least one managed member observation predates ExportedType inspection; its exported and forwarded types are unknown.",
@@ -26,8 +26,21 @@ interface ComparisonItemContext {
readonly rightEvidenceId: string;
readonly leftComplete: boolean;
readonly rightComplete: boolean;
/** Declaring types each side selected; null when it inspected every type. */
readonly leftSelection: ReadonlySet<string> | null;
readonly rightSelection: ReadonlySet<string> | null;
}
/** Whether an inventory observed every member of this member's declaring type. */
const inventoryCovers = (
complete: boolean,
selection: ReadonlySet<string> | null,
member: Method | Field,
): boolean =>
complete &&
(selection === null ||
(member.declaring_type !== null && selection.has(member.declaring_type)));
/** Declared type and name of each unpaired member, per side, whose signature was not decoded. */
interface UndecodedOneSidedNames {
readonly left: ReadonlySet<string>;
@@ -123,7 +136,11 @@ const methodOnlyItem = (
): MethodItem => {
if (member.left !== undefined) {
const limitations = absenceLimitations(member.left, {
oppositeComplete: context.rightComplete,
oppositeComplete: inventoryCovers(
context.rightComplete,
context.rightSelection,
member.left,
),
oppositeUndecodedNames: undecoded.right,
opposite: "right",
inventory: "method",
@@ -141,7 +158,11 @@ const methodOnlyItem = (
}
const limitations = absenceLimitations(member.right, {
oppositeComplete: context.leftComplete,
oppositeComplete: inventoryCovers(
context.leftComplete,
context.leftSelection,
member.right,
),
oppositeUndecodedNames: undecoded.left,
opposite: "left",
inventory: "method",
@@ -233,7 +254,11 @@ const fieldOnlyItem = (
): FieldItem => {
if (member.left !== undefined) {
const limitations = absenceLimitations(member.left, {
oppositeComplete: context.rightComplete,
oppositeComplete: inventoryCovers(
context.rightComplete,
context.rightSelection,
member.left,
),
oppositeUndecodedNames: undecoded.right,
opposite: "right",
inventory: "field",
@@ -250,7 +275,11 @@ const fieldOnlyItem = (
}
const limitations = absenceLimitations(member.right, {
oppositeComplete: context.leftComplete,
oppositeComplete: inventoryCovers(
context.leftComplete,
context.leftSelection,
member.right,
),
oppositeUndecodedNames: undecoded.left,
opposite: "left",
inventory: "field",
@@ -250,22 +250,74 @@ const groupBy = <Item>(
return grouped;
};
/** Whether a side's inventory observed members of this declaring type. */
const observedBy =
(side: ManagedMemberComparisonSide) =>
({ declaring_type }: { readonly declaring_type: string | null }): boolean =>
side.result.selection === null ||
(declaring_type !== null &&
side.result.selection.type_names.includes(declaring_type));
/**
* Pair only members whose declaring type both sides observed. A member outside
* the other side's selection stays one-sided, so a type selection never yields
* a remap to a member of a type the other side did not inspect.
*/
const matchObserved = <Item extends { readonly declaring_type: string | null }>(
left: readonly Item[],
right: readonly Item[],
sides: {
readonly inLeft: (item: Item) => boolean;
readonly inRight: (item: Item) => boolean;
},
match: (
left: readonly Keyed<Item>[],
right: readonly Keyed<Item>[],
) => ReturnType<typeof matchByKeys<Item>>,
key: (item: Item) => Keyed<Item>,
): ReturnType<typeof matchByKeys<Item>> => {
const matched = match(
left.filter(sides.inRight).map(key),
right.filter(sides.inLeft).map(key),
);
return {
...matched,
leftOnly: [
...matched.leftOnly,
...left.filter((item) => !sides.inRight(item)).map(key),
],
rightOnly: [
...matched.rightOnly,
...right.filter((item) => !sides.inLeft(item)).map(key),
],
};
};
export const keyMembers = (
left: ManagedMemberComparisonSide,
right: ManagedMemberComparisonSide,
): {
readonly methodMatches: ReturnType<typeof matchMethods>;
readonly fieldMatches: ReturnType<typeof matchFields>;
} => ({
methodMatches: matchMethods(
left.result.methods.map(keyMethod),
right.result.methods.map(keyMethod),
),
fieldMatches: matchFields(
left.result.fields.map(keyField),
right.result.fields.map(keyField),
),
});
} => {
const sides = { inLeft: observedBy(left), inRight: observedBy(right) };
return {
methodMatches: matchObserved(
left.result.methods,
right.result.methods,
sides,
matchMethods,
keyMethod,
),
fieldMatches: matchObserved(
left.result.fields,
right.result.fields,
sides,
matchFields,
keyField,
),
};
};
export const parseManagedMemberEvidence = (
evidence: unknown,
+47 -33
View File
@@ -177,40 +177,54 @@ describe("managed artifact coded indexes", () => {
});
});
it("reports null for the required CustomAttribute constructor coded index", () => {
const bytes = buildManagedPeFixture({ customAttributeTypeRaw: 0 });
const result = inspectManagedArtifactBytes(
bytes,
managedPeFixtureTarget(bytes),
);
const text = Buffer.from(".NETCoreApp,Version=v8.0", "utf8");
const attributeValue = Buffer.concat([
Buffer.from([1, 0, text.length]),
text,
Buffer.alloc(2),
]);
it.each([
[
0,
"CustomAttribute constructor coded index 0x0 is null, but Type must reference a MethodDef or MemberRef row",
],
[
19,
"coded index 0x13 is invalid: coded index selects row 2 in table 10, which has 1 rows",
],
] as const)(
"preserves readable attribute facts for constructor index %s",
(rawType, detail) => {
const bytes = buildManagedPeFixture({ customAttributeTypeRaw: rawType });
const result = inspectManagedArtifactBytes(
bytes,
managedPeFixtureTarget(bytes),
);
const text = Buffer.from(".NETCoreApp,Version=v8.0", "utf8");
const attributeValue = Buffer.concat([
Buffer.from([1, 0, text.length]),
text,
Buffer.alloc(2),
]);
expect(result.attributes).toMatchObject([
{
parent_token: "0x20000001",
constructor_token: null,
type_name: null,
value_length: attributeValue.length,
value_sha256: createHash("sha256").update(attributeValue).digest("hex"),
token: "0x0c000001",
},
]);
expect(result.coverage).toMatchObject({
state: "partial",
issues: [
expect.objectContaining({
code: "invalid-row",
detail:
"CustomAttribute constructor coded index 0x0 is null, but Type must reference a MethodDef or MemberRef row",
}),
],
});
});
expect(result.attributes).toMatchObject([
{
parent_token: "0x20000001",
constructor_token: null,
type_name: null,
value_length: attributeValue.length,
value_sha256: createHash("sha256")
.update(attributeValue)
.digest("hex"),
token: "0x0c000001",
},
]);
expect(result.coverage).toMatchObject({
state: "partial",
issues: [
expect.objectContaining({
code: "invalid-row",
scope: "metadata.CustomAttribute:0x0c000001",
detail: expect.stringContaining(detail),
}),
],
});
},
);
it("reports coded-index column offsets after widened string and coded-index columns", () => {
const references = Array.from(
+65
View File
@@ -859,3 +859,68 @@ it.each([
}
},
);
describe("managed member type selection", () => {
const bytes = buildManagedPeFixture();
const target = managedPeFixtureTarget(bytes);
const everything = inspectManagedMembersBytes(bytes, target);
it("reports no selection when every type is inspected", () => {
expect(everything.selection).toBeNull();
});
it("decodes the selected type's members and reports unmatched names", () => {
const result = inspectManagedMembersBytes(bytes, target, {
typeNames: ["Fixture.Program", "Missing.Type"],
});
expect(result.selection).toEqual({
type_names: ["Fixture.Program", "Missing.Type"],
unmatched_type_names: ["Missing.Type"],
});
expect(result.types).toEqual(everything.types);
expect(result.fields).toEqual(everything.fields);
expect(result.methods).toEqual(everything.methods);
expect(result.call_edges).toEqual(everything.call_edges);
expect(result.field_accesses).toEqual(everything.field_accesses);
expect(result.coverage).toEqual(everything.coverage);
expect(result.limitations).toContain(
"Types, fields, methods, call edges, and field accesses cover only the selected declaring types; coverage describes that selection. Edge targets outside it keep their names but are not described.",
);
});
it("does not treat a name as absent when a TypeDef name is unreadable", () => {
const corrupt = Buffer.from(bytes);
const row = everything.types[0]?.row_offset ?? 0;
// Point the TypeDef Name past the #Strings heap.
corrupt.writeUInt16LE(0xffff, row + 4);
const result = inspectManagedMembersBytes(
corrupt,
managedPeFixtureTarget(corrupt),
{ typeNames: ["Fixture.Program"] },
);
expect(result.selection?.unmatched_type_names).toEqual(["Fixture.Program"]);
expect(result.coverage.state).toBe("partial");
expect(result.limitations).toContain(
"1 TypeDef name(s) could not be read; a name in selection.unmatched_type_names may belong to one of them, so its absence is not observed.",
);
});
it("omits members of unselected types without dropping member references", () => {
const result = inspectManagedMembersBytes(bytes, target, {
typeNames: ["Missing.Type"],
});
expect(result.selection).toEqual({
type_names: ["Missing.Type"],
unmatched_type_names: ["Missing.Type"],
});
expect(result.types).toEqual([]);
expect(result.fields).toEqual([]);
expect(result.methods).toEqual([]);
expect(result.call_edges).toEqual([]);
expect(result.field_accesses).toEqual([]);
expect(result.member_refs).toEqual(everything.member_refs);
});
});
+70 -6
View File
@@ -22,19 +22,25 @@ import {
ManagedReaderFailure,
uniqueManagedParseIssues,
} from "./ManagedReaderFailure.js";
import { parseTypes, typeRanges } from "./ManagedMemberInspectorCore.js";
import {
parseTypes,
typeRanges,
type TypeRange,
} from "./ManagedMemberInspectorCore.js";
import {
edges,
parseExportedTypes,
parseFields,
parseMemberRefs,
parseMethods,
type SelectedTypeTokens,
} from "./ManagedMemberRows.js";
const unavailable = (
target: BinaryTarget,
bytes: Buffer,
issue: ManagedParseIssue | null,
typeNames: readonly string[] | undefined,
): ManagedMemberInspection =>
managedMemberInspectionSchema.parse(
admitManagedProjection({
@@ -67,6 +73,11 @@ const unavailable = (
member_refs: [],
call_edges: [],
field_accesses: [],
// Nothing was examined, so no requested name is known to be unmatched.
selection:
typeNames === undefined
? null
: { type_names: [...typeNames], unmatched_type_names: [] },
coverage: {
state: "unavailable",
issues: issue === null ? [] : [issue],
@@ -105,23 +116,61 @@ const readMemberInventory = (
return { layout, inventory };
};
/** Inspect metadata members and method bodies without loading target code. */
const selectTypes = (
ranges: readonly TypeRange[],
typeNames: readonly string[] | undefined,
): {
readonly tokens: SelectedTypeTokens;
readonly selection: ManagedMemberInspection["selection"];
readonly unreadableNames: number;
} => {
if (typeNames === undefined)
return { tokens: null, selection: null, unreadableNames: 0 };
const requested = new Set(typeNames);
const tokens = new Set<string>();
const matched = new Set<string>();
for (const range of ranges) {
if (range.fullName === null || !requested.has(range.fullName)) continue;
tokens.add(range.token);
matched.add(range.fullName);
}
return {
tokens,
unreadableNames: ranges.filter(({ fullName }) => fullName === null).length,
selection: {
type_names: [...typeNames],
unmatched_type_names: typeNames.filter((name) => !matched.has(name)),
},
};
};
/**
* Inspect metadata members and method bodies without loading target code.
* Selected type names restrict full decoding to members of those exact types.
*/
export const inspectManagedMembersBytes = (
bytes: Buffer,
target: BinaryTarget,
{ typeNames }: { readonly typeNames?: readonly string[] } = {},
): ManagedMemberInspection => {
const pe = readManagedPeLayout(bytes);
if (pe.cli === null) return unavailable(target, bytes, pe.cliIssue);
if (pe.cli === null)
return unavailable(target, bytes, pe.cliIssue, typeNames);
const issues: ManagedParseIssue[] = [];
try {
const { layout, inventory } = readMemberInventory(bytes, pe, issues);
issues.push(...inventory.issues);
const ranges = typeRanges(bytes, layout, issues);
const {
tokens: selected,
selection,
unreadableNames,
} = selectTypes(ranges, typeNames);
const parsedTypes = parseTypes(bytes, layout, ranges);
issues.push(...parsedTypes.issues);
const exportedTypes = parseExportedTypes(bytes, layout);
issues.push(...exportedTypes.issues);
const fields = parseFields(bytes, layout, ranges);
const fields = parseFields(bytes, layout, ranges, selected);
const memberRefs = parseMemberRefs(bytes, layout);
issues.push(...memberRefs.issues);
const methods = parseMethods({
@@ -129,6 +178,7 @@ export const inspectManagedMembersBytes = (
layout,
pe,
ranges,
selected,
});
issues.push(...fields.issues, ...methods.issues);
const coverageIssues = uniqueManagedParseIssues(issues);
@@ -168,13 +218,17 @@ export const inspectManagedMembersBytes = (
requires_artifact_sha256: target.sha256,
requires_mvid: inventory.module?.mvid ?? null,
},
types: parsedTypes.types,
types:
selected === null
? parsedTypes.types
: parsedTypes.types.filter((type) => selected.has(type.token)),
exported_types: exportedTypes.exportedTypes,
fields: fields.fields,
methods: methods.methods,
member_refs: memberRefs.refs,
call_edges: related.callEdges,
field_accesses: related.fieldAccesses,
selection,
coverage: {
state:
issues.length === 0 && !incompleteMembers
@@ -187,6 +241,16 @@ export const inspectManagedMembersBytes = (
"CIL instruction anchors are decoded from file-backed method bodies only; no target assembly is loaded or executed.",
"Exported types are reported from ExportedType rows as declared; forwarded types are not resolved in their implementing assemblies.",
"Member signatures decode ECMA-335 primitive, class, valuetype, generic instantiation, generic variable, pointer, byref, single-dimension and general array, function pointer, custom modifier, and typed reference forms; class and valuetype operands keep their encoded TypeDefOrRefOrSpec values, and unsupported forms such as vararg sentinels retain raw signature hashes.",
...(selection === null
? []
: [
"Types, fields, methods, call edges, and field accesses cover only the selected declaring types; coverage describes that selection. Edge targets outside it keep their names but are not described.",
]),
...(unreadableNames === 0
? []
: [
`${String(unreadableNames)} TypeDef name(s) could not be read; a name in selection.unmatched_type_names may belong to one of them, so its absence is not observed.`,
]),
],
},
managedDecodeBudget(layout),
@@ -194,7 +258,7 @@ export const inspectManagedMembersBytes = (
);
} catch (cause: unknown) {
if (cause instanceof ManagedReaderFailure)
return unavailable(target, bytes, cause.issue);
return unavailable(target, bytes, cause.issue, typeNames);
throw cause;
}
};
+35 -18
View File
@@ -32,10 +32,23 @@ import { readManagedValue } from "./ManagedReaderFailure.js";
import { methodBody } from "./ManagedMethodBodyReader.js";
import { memberDeclaringTypeRowLookup } from "./ManagedMemberOwnership.js";
/**
* Declaring-type tokens whose members are decoded in full; null selects every
* type. Unselected members keep only their name so edges stay labelled.
*/
export type SelectedTypeTokens = ReadonlySet<string> | null;
const isSelected = (
selected: SelectedTypeTokens,
declared: { readonly token: string } | undefined,
): boolean =>
selected === null || (declared !== undefined && selected.has(declared.token));
export const parseFields = (
bytes: Buffer,
layout: ManagedMetadataLayout,
ranges: readonly TypeRange[],
selected: SelectedTypeTokens = null,
): {
readonly fields: readonly ManagedField[];
readonly core: ReadonlyMap<string, FieldCore>;
@@ -59,18 +72,20 @@ export const parseFields = (
cursor.readIndex(layout.stringIndexSize),
layout.strings.size,
);
const sig = readMetadataBlob(
bytes,
layout,
cursor.readIndex(layout.blobIndexSize),
layout.blob.size,
);
const owner =
ownerRow === undefined
? 0
: (readManagedValue(() => ownerRow(row), issues) ?? 0);
const declared = ranges[owner - 1];
const token = metadataToken(4, row);
core.set(token, { token, name });
if (!isSelected(selected, declared)) return;
const sig = readMetadataBlob(
bytes,
layout,
cursor.readIndex(layout.blobIndexSize),
layout.blob.size,
);
fields.push({
token,
row_offset: cursor.start,
@@ -80,7 +95,6 @@ export const parseFields = (
flags,
signature: budgetedSignature(sig, layout),
});
core.set(token, { token, name });
}, issues);
}
return { fields, core, issues };
@@ -227,6 +241,7 @@ interface ParseMethodsInput {
readonly layout: ManagedMetadataLayout;
readonly pe: ManagedPeLayout;
readonly ranges: readonly TypeRange[];
readonly selected?: SelectedTypeTokens;
}
export const parseMethods = ({
@@ -234,6 +249,7 @@ export const parseMethods = ({
layout,
pe,
ranges,
selected = null,
}: ParseMethodsInput): {
readonly methods: readonly ManagedMethod[];
readonly core: ReadonlyMap<string, MethodCore>;
@@ -259,6 +275,18 @@ export const parseMethods = ({
cursor.readIndex(layout.stringIndexSize),
layout.strings.size,
);
const owner =
ownerRow === undefined
? 0
: (readManagedValue(() => ownerRow(row), issues) ?? 0);
const declared = ranges[owner - 1];
const token = metadataToken(6, row);
core.set(token, {
token,
name,
declaringType: declared?.fullName ?? null,
});
if (!isSelected(selected, declared)) return;
const sig = readMetadataBlob(
bytes,
layout,
@@ -266,12 +294,6 @@ export const parseMethods = ({
layout.blob.size,
);
cursor.readIndex(layout.tableIndexSize(8));
const owner =
ownerRow === undefined
? 0
: (readManagedValue(() => ownerRow(row), issues) ?? 0);
const declared = ranges[owner - 1];
const token = metadataToken(6, row);
methods.push({
token,
row_offset: cursor.start,
@@ -290,11 +312,6 @@ export const parseMethods = ({
managedDecodeBudget(layout),
),
});
core.set(token, {
token,
name,
declaringType: declared?.fullName ?? null,
});
}, issues);
}
return { methods, core, issues };
+11 -4
View File
@@ -13,6 +13,7 @@ import { managedStaticCapabilities } from "./ManagedStaticProviderMetadata.js";
import { MANAGED_STATIC_PROVIDER } from "../application/InvestigationProviders.js";
import {
MANAGED_TOOL_CONTRACTS,
managedMembersInputSchema,
managedTargetInputSchema,
type ManagedToolName,
} from "../contracts/managed/managedToolContracts.js";
@@ -235,14 +236,20 @@ const inspectManagedOperation = (
| ManagedArtifactInspection
| ManagedMemberInspection
| ManagedNativeBoundaryInspection => {
if (operation === "inspect_managed_members") {
const { type_names: typeNames } =
managedMembersInputSchema.parse(parameters);
return inspectManagedMembersBytes(
bytes,
target,
typeNames === undefined ? {} : { typeNames },
);
}
managedTargetInputSchema.parse(parameters);
if (operation === "inspect_managed_artifact") {
return inspectManagedArtifactBytes(bytes, target);
}
if (operation === "inspect_managed_native_boundaries") {
return inspectManagedNativeBoundariesBytes(bytes, target);
}
return inspectManagedMembersBytes(bytes, target);
return inspectManagedNativeBoundariesBytes(bytes, target);
};
const managedLocations = (
+1
View File
@@ -52,6 +52,7 @@ export const registerManagedTools = (
path,
operation,
executionOptions?.signal,
parameters,
);
if (execution.ok && typeof requestedPath === "string")
selectedManagedPath = requestedPath;
+53
View File
@@ -113,6 +113,7 @@ it("runs every managed static inspection independently of an active native targe
operation: "inspect_managed_members",
subject: { local_path: otherPath, format: "pe" },
});
await verifyManagedTypeSelection(client, path, otherPath);
const boundaries = structured(
await client.callTool({
name: "inspect_managed_native_boundaries",
@@ -445,6 +446,58 @@ const verifyImport = async (
});
};
/** Selected type names reach the provider and unmatched names stay visible. */
const verifyManagedTypeSelection = async (
client: Client,
path: string,
otherPath: string,
): Promise<void> => {
const advertised = (await client.listTools()).tools.find(
(tool) => tool.name === "inspect_managed_members",
);
if (advertised === undefined) throw new Error("missing managed member tool");
const ajv = new Ajv2020({ strict: false, validateFormats: false });
expect(ajv.validateSchema(advertised.inputSchema)).toBe(true);
const validate = ajv.compile(advertised.inputSchema);
expect(
validate({ path, type_names: ["Fixture.Program", "Missing.Type"] }),
).toBe(true);
expect(
validate({ path, type_names: ["Fixture.Program", "Fixture.Program"] }),
).toBe(false);
const selected = structured(
await client.callTool({
name: "inspect_managed_members",
arguments: { path, type_names: ["Fixture.Program", "Missing.Type"] },
}),
);
expect(selected).toMatchObject({
parameters: { path, type_names: ["Fixture.Program", "Missing.Type"] },
normalized_result: {
types: [expect.objectContaining({ full_name: "Fixture.Program" })],
methods: [expect.objectContaining({ declaring_type: "Fixture.Program" })],
selection: {
type_names: ["Fixture.Program", "Missing.Type"],
unmatched_type_names: ["Missing.Type"],
},
},
});
const repeatedSelection = await client.callTool({
name: "inspect_managed_members",
arguments: { path, type_names: ["Fixture.Program", "Fixture.Program"] },
});
expect(repeatedSelection.isError).toBe(true);
const reselectedOtherTarget = structured(
await client.callTool({
name: "inspect_managed_artifact",
arguments: { path: otherPath },
}),
);
expect(reselectedOtherTarget).toMatchObject({
subject: { local_path: otherPath, format: "pe" },
});
};
const structured = (result: CallToolResult): Record<string, unknown> => {
if (result.isError === true) return parseMcpToolError(result);
if (
@@ -459,9 +459,83 @@ describe("managed member comparison of ambiguous undecoded signatures", () => {
});
});
const inspect = (bytes: Buffer, path: string) => {
describe("managed member comparison of selected types", () => {
it("keeps members outside the other side's selection unknown", () => {
const left = inspect(buildManagedPeFixture(), "/tmp/left-all.dll");
const right = inspect(buildManagedPeFixture(), "/tmp/right-selected.dll", [
"Missing.Type",
]);
const result = compareManagedMembers(
{ evidenceId: left.evidenceId, result: left.result },
{ evidenceId: right.evidenceId, result: right.result },
);
expect(result.summary).toMatchObject({ added: 0, removed: 0, unknown: 2 });
expect(result.methods[0]).toMatchObject({
status: "unknown",
left: { token: "0x06000001" },
right: null,
});
expect(result.coverage.status).toBe("partial");
expect(result.limitations).toContain(
"The observations select different declaring types; a member outside the other side's selection is unknown, not added or removed.",
);
});
it("does not remap members across types neither side observed", () => {
const left = inspect(buildManagedPeFixture(), "/tmp/left-a.dll", [
"Fixture.Program",
]);
const right = inspect(
buildManagedPeFixture({
mvid: Buffer.from("00112233445566778899aabbccddeefe", "hex"),
typeName: "A",
typeNamespace: "X",
}),
"/tmp/right-b.dll",
["X.A"],
);
const result = compareManagedMembers(
{ evidenceId: left.evidenceId, result: left.result },
{ evidenceId: right.evidenceId, result: right.result },
);
expect(result.matching.exact_il_signature).toBe(0);
expect(result.summary).toMatchObject({
unchanged: 0,
added: 0,
removed: 0,
unknown: 4,
});
});
it("compares two identical selections within those types", () => {
const left = inspect(buildManagedPeFixture(), "/tmp/left-sel.dll", [
"Fixture.Program",
]);
const right = inspect(buildManagedPeFixture(), "/tmp/right-sel.dll", [
"Fixture.Program",
]);
const result = compareManagedMembers(
{ evidenceId: left.evidenceId, result: left.result },
{ evidenceId: right.evidenceId, result: right.result },
);
expect(result.methods[0]).toMatchObject({ status: "unchanged" });
expect(result.coverage.status).toBe("complete-within-inputs");
expect(result.limitations).toContain(
"Both observations cover only the selected declaring types (Fixture.Program); member totals and statuses describe those types, not the whole assembly.",
);
});
});
const inspect = (bytes: Buffer, path: string, typeNames?: string[]) => {
const target = managedPeFixtureTarget(bytes, path);
const result = inspectManagedMembersBytes(bytes, target);
const result = inspectManagedMembersBytes(
bytes,
target,
typeNames === undefined ? {} : { typeNames },
);
return {
result,
evidenceId: `ev_${hash(Buffer.from(path))}`,
@@ -629,3 +629,55 @@ describe("managed application graph coverage", () => {
});
});
});
describe("managed application graph type selection", () => {
const project = (result: Parameters<typeof createEvidence>[2]["result"]) => {
const bytes = buildManagedPeFixture();
const binary = managedPeFixtureTarget(bytes, "/fixture/ManagedInterop.exe");
const projection = projectManagedApplicationGraphEvidence({
managed_members: createEvidence(binary, MANAGED_STATIC_PROVIDER, {
operation: "inspect_managed_members",
parameters: {},
result,
rawResult: null,
limitations: [],
}),
});
if (!projection.ok) throw new Error("projection failed");
return managedApplicationGraphResultSchema.parse(
parseEvidence(projection.value).normalized_result,
);
};
it("marks a graph from selected member inspection partial", () => {
const bytes = buildManagedPeFixture();
const binary = managedPeFixtureTarget(bytes, "/fixture/ManagedInterop.exe");
const selected = inspectManagedMembersBytes(bytes, binary, {
typeNames: ["Fixture.Program"],
});
expect(selected.coverage.state).toBe("complete");
const result = project(selected);
expect(result.coverage).toMatchObject({ status: "partial" });
expect(result.limitations).toContain(
"Managed member Evidence selected only these declaring types: Fixture.Program; type, method, and field nodes of other types are omitted, not absent.",
);
expect(result.limitations).not.toContain(
"At least one supplied managed Evidence record has partial coverage.",
);
});
it("accepts member Evidence recorded before type selection existed", () => {
const bytes = buildManagedPeFixture();
const binary = managedPeFixtureTarget(bytes, "/fixture/ManagedInterop.exe");
const { selection: _selection, ...legacy } = inspectManagedMembersBytes(
bytes,
binary,
);
expect(managedMemberInspectionSchema.parse(legacy).selection).toBeNull();
expect(project(legacy).coverage).toMatchObject({
status: "complete-within-inputs",
});
});
});