diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index ad642b7a51..18360ec3d9 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -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 diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index d5300ff5e3..839a758f31 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -128,7 +128,7 @@ export const runProviderAnalysis = async ( ] ): Promise => 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> = {}, ): Promise> => 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>, signal?: AbortSignal, ): Promise => { 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, diff --git a/src/cli/managedCommands.ts b/src/cli/managedCommands.ts index fdc32634e2..7dc74ae3e4 100644 --- a/src/cli/managedCommands.ts +++ b/src/cli/managedCommands.ts @@ -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, + ), ), }); }; diff --git a/src/contracts/managed/managedToolContracts.ts b/src/contracts/managed/managedToolContracts.ts index 2ff8d36bb8..ddcc7a6e3c 100644 --- a/src/contracts/managed/managedToolContracts.ts +++ b/src/contracts/managed/managedToolContracts.ts @@ -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"], + }, + }, ], }, { diff --git a/src/contracts/managed/managedWorkflowExamples.ts b/src/contracts/managed/managedWorkflowExamples.ts index ff81122d0e..85297d671d 100644 --- a/src/contracts/managed/managedWorkflowExamples.ts +++ b/src/contracts/managed/managedWorkflowExamples.ts @@ -53,6 +53,7 @@ const emptyManagedMembers = ( member_refs: [], call_edges: [], field_accesses: [], + selection: null, coverage: { state: "complete", issues: [] }, limitations: [], }); diff --git a/src/domain/managed/managedApplicationGraph.ts b/src/domain/managed/managedApplicationGraph.ts index 3e4ffea377..4ae40acd9b 100644 --- a/src/domain/managed/managedApplicationGraph.ts +++ b/src/domain/managed/managedApplicationGraph.ts @@ -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.", diff --git a/src/domain/managed/managedApplicationGraphCoverage.ts b/src/domain/managed/managedApplicationGraphCoverage.ts index dc15a64623..0e842ba80d 100644 --- a/src/domain/managed/managedApplicationGraphCoverage.ts +++ b/src/domain/managed/managedApplicationGraphCoverage.ts @@ -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), }); diff --git a/src/domain/managed/managedArtifact.ts b/src/domain/managed/managedArtifact.ts index 66a3b17614..297b60a4eb 100644 --- a/src/domain/managed/managedArtifact.ts +++ b/src/domain/managed/managedArtifact.ts @@ -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), diff --git a/src/domain/managed/managedMemberComparison.ts b/src/domain/managed/managedMemberComparison.ts index 5197c1755f..77e382dc70 100644 --- a/src/domain/managed/managedMemberComparison.ts +++ b/src/domain/managed/managedMemberComparison.ts @@ -213,6 +213,11 @@ export interface ManagedMemberComparisonSide { readonly result: ManagedMemberInspection; } +const selectedTypes = ( + result: ManagedMemberInspection, +): ReadonlySet | 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); diff --git a/src/domain/managed/managedMemberComparisonCoverage.ts b/src/domain/managed/managedMemberComparisonCoverage.ts index 9d3daafe0b..7f28cc88ea 100644 --- a/src/domain/managed/managedMemberComparisonCoverage.ts +++ b/src/domain/managed/managedMemberComparisonCoverage.ts @@ -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.", diff --git a/src/domain/managed/managedMemberComparisonItems.ts b/src/domain/managed/managedMemberComparisonItems.ts index 5cd9aba76a..23b4d600bd 100644 --- a/src/domain/managed/managedMemberComparisonItems.ts +++ b/src/domain/managed/managedMemberComparisonItems.ts @@ -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 | null; + readonly rightSelection: ReadonlySet | null; } +/** Whether an inventory observed every member of this member's declaring type. */ +const inventoryCovers = ( + complete: boolean, + selection: ReadonlySet | 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; @@ -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", diff --git a/src/domain/managed/managedMemberComparisonMatch.ts b/src/domain/managed/managedMemberComparisonMatch.ts index 2c9e0cf04d..f8e2b1d430 100644 --- a/src/domain/managed/managedMemberComparisonMatch.ts +++ b/src/domain/managed/managedMemberComparisonMatch.ts @@ -250,22 +250,74 @@ const groupBy = ( 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 = ( + left: readonly Item[], + right: readonly Item[], + sides: { + readonly inLeft: (item: Item) => boolean; + readonly inRight: (item: Item) => boolean; + }, + match: ( + left: readonly Keyed[], + right: readonly Keyed[], + ) => ReturnType>, + key: (item: Item) => Keyed, +): ReturnType> => { + 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; readonly fieldMatches: ReturnType; -} => ({ - 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, diff --git a/src/dotnet/ManagedArtifactInspector.test.ts b/src/dotnet/ManagedArtifactInspector.test.ts index a4a900b267..3fc74337e8 100644 --- a/src/dotnet/ManagedArtifactInspector.test.ts +++ b/src/dotnet/ManagedArtifactInspector.test.ts @@ -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( diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index 0b342332e1..81d64f4a17 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -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); + }); +}); diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index 6c27ac8024..24b97f9ebe 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -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(); + const matched = new Set(); + 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; } }; diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index 559201f13f..f4a63db5a7 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -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 | 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; @@ -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; @@ -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 }; diff --git a/src/dotnet/ManagedStaticProvider.ts b/src/dotnet/ManagedStaticProvider.ts index 116f93382a..e3827c3460 100644 --- a/src/dotnet/ManagedStaticProvider.ts +++ b/src/dotnet/ManagedStaticProvider.ts @@ -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 = ( diff --git a/src/server/registerManagedTools.ts b/src/server/registerManagedTools.ts index f54f2981f5..8d1125c523 100644 --- a/src/server/registerManagedTools.ts +++ b/src/server/registerManagedTools.ts @@ -52,6 +52,7 @@ export const registerManagedTools = ( path, operation, executionOptions?.signal, + parameters, ); if (execution.ok && typeof requestedPath === "string") selectedManagedPath = requestedPath; diff --git a/tests/boundary/mcp/managedMcp.test.ts b/tests/boundary/mcp/managedMcp.test.ts index dc662eafe4..d2e414a55e 100644 --- a/tests/boundary/mcp/managedMcp.test.ts +++ b/tests/boundary/mcp/managedMcp.test.ts @@ -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 => { + 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 => { if (result.isError === true) return parseMcpToolError(result); if ( diff --git a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts index 7849cfb827..7f44088118 100644 --- a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts @@ -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))}`, diff --git a/tests/composition/analysis-sessions/managedApplicationGraph.test.ts b/tests/composition/analysis-sessions/managedApplicationGraph.test.ts index 915545a69e..cd60028d4f 100644 --- a/tests/composition/analysis-sessions/managedApplicationGraph.test.ts +++ b/tests/composition/analysis-sessions/managedApplicationGraph.test.ts @@ -629,3 +629,55 @@ describe("managed application graph coverage", () => { }); }); }); + +describe("managed application graph type selection", () => { + const project = (result: Parameters[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", + }); + }); +});