diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 960e46094..789b7a89e 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -169,6 +169,14 @@ repeat parsing. Returned-row exhaustion does not prove complete metadata or absence under partial/unavailable coverage; preserve build-local tokens and their artifact/MVID scope. +`inspect_managed_members` always returns the assembly-wide `member_refs`, even +for one selected type. For questions about a selected type, select +`detail: "summary"` with `type_names`; it returns the selection (with unmatched +names), coverage and per-collection counts. Page rows with +`view: {kind: "managed-members", collection: "call_edges", offset: 0, limit: 64}` +over `types`, `exported_types`, `fields`, `methods`, `member_refs`, `call_edges`, +`field_accesses` or `user_strings`. + ### .NET NativeAOT NativeAOT binaries contain native machine code, so ordinary CIL decompilers diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 05782b4a2..f2d211d4e 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -81,6 +81,40 @@ including the existing `inspect-analysis-view` CLI. Session record lifetime and export/import semantics remain unchanged. A server without Evidence retention rejects selected summary before analysis; complete delivery remains available. +### Member summary and retained member rows + +Complete `inspect_managed_members` delivery always includes the assembly-wide +`member_refs` and `exported_types`, which `type_names` does not narrow, so even +one selected or unmatched type can return megabytes. Select +`{"path":"/absolute/path/Assembly.dll","type_names":["Namespace.Type"],"detail":"summary"}` +to keep the complete source in the session and receive identity, metadata +status, the selection with its unmatched names, coverage/issues, the separate +user-string coverage, limitations and per-collection counts. Then page original +rows with view kind `managed-members`: + +```json +{ + "source": { + "kind": "retained-evidence", + "evidence_id": "" + }, + "view": { + "kind": "managed-members", + "collection": "call_edges", + "offset": 0, + "limit": 64 + } +} +``` + +Collections are `types`, `exported_types`, `fields`, `methods`, `member_refs`, +`call_edges`, `field_accesses` and `user_strings` (the literals). Each page names +its collection, retains original rows, order, tokens, signatures and body facts, +and carries the source selection and coverage; `user_strings` pages carry the +literal pool's own coverage. Older Evidence that never examined exported types +or user strings reports a null count and refuses those pages rather than +returning an empty one. + ## Shipped scope The canonical parser admits PE/CLI bytes and their metadata/CIL without loading diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index 7d023752d..a98b08612 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -16,7 +16,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, inspect_evm_interface, inspect_managed_artifact, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, inventory, WASM, Mach-O, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, inventory, WASM, Mach-O, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/managed/managedToolContracts.ts b/src/contracts/managed/managedToolContracts.ts index 2c0088b9b..ead714fb7 100644 --- a/src/contracts/managed/managedToolContracts.ts +++ b/src/contracts/managed/managedToolContracts.ts @@ -21,13 +21,13 @@ export const managedTargetInputSchema = z.strictObject({ .optional(), }); -/** Delivery selection belongs only to artifact inspection, not member/native queries. */ +/** Delivery selection belongs to artifact and member inspection, not native queries. */ export const managedArtifactInputSchema = managedTargetInputSchema.extend({ detail: analysisDetailSchema.unwrap().optional(), }); /** Member inspection may select declaring types before members are decoded. */ -export const managedMembersInputSchema = managedTargetInputSchema.extend({ +export const managedMembersInputSchema = managedArtifactInputSchema.extend({ type_names: z .array( z @@ -87,7 +87,7 @@ 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, field-access anchors, and the #US string literals that ldstr loads, without loading or executing target code. Select type_names to decode only the members of those exact declaring types and the literals their bodies load, 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.", + 'Inspect PE/CLI metadata members, signatures, raw CIL hashes, decoded-instruction-tuple hashes, separately reported exception regions, call edges, field-access anchors, and the #US string literals that ldstr loads, without loading or executing target code. Select type_names to decode only the members of those exact declaring types and the literals their bodies load, 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. Omitted or complete detail returns every collection, including the assembly-wide member_refs and exported_types, which type_names does not narrow. Select detail: "summary" for identity, metadata status, selection with unmatched names, coverage/issues, limitations and per-collection counts; it retains complete Evidence and returns a linked inspect_analysis_view summary. Page original rows with view kind managed-members, normalized_result.parent_evidence_id and collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings.', kind: "managed-provider", inputSchema: managedMembersInputSchema, outputSchema: memberOutputSchema, @@ -103,6 +103,14 @@ export const MANAGED_TOOL_CONTRACTS = [ type_names: ["System.Threading.Monitor"], }, }, + { + title: "Summarize selected managed types and retain their members", + input: { + path: "/tmp/mscorlib.dll", + type_names: ["System.Threading.Monitor"], + detail: "summary", + }, + }, ], }, { diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 1a36c0896..430d12d58 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -4,6 +4,7 @@ import { artifactInventorySummaryViewResultSchema, machoSummaryViewResultSchema, managedArtifactSummaryViewResultSchema, + managedMemberSummaryViewResultSchema, } from "../domain/analysisView/analysisView.js"; import { evidenceBundleSummarySchema } from "../domain/evidenceRecordSummary.js"; import { BINARY_ARCHITECTURES } from "../domain/binaryTargetTypes.js"; @@ -418,7 +419,12 @@ export const managedOutputSchemas: Readonly> = { managedArtifactSummaryViewResultSchema, ]), ), - inspect_managed_members: evidenceResultOf(managedMemberInspectionSchema), + inspect_managed_members: evidenceResultOf( + z.union([ + managedMemberInspectionSchema, + managedMemberSummaryViewResultSchema, + ]), + ), inspect_managed_native_boundaries: evidenceResultOf( managedNativeBoundaryInspectionSchema, ), diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index b08001e35..d676d491c 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -49,6 +49,13 @@ import { managedSummarySchema, managedViewContextSchema, } from "./managedArtifactView.js"; +import { + projectManagedMemberView, + managedMembersViewRequestSchema, + managedMembersViewPageSchema, + managedMemberSummarySchema, + managedMembersViewContextSchema, +} from "./managedMemberView.js"; import { projectWasmArtifactView, wasmViewRequestSchema, @@ -208,6 +215,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ machoViewRequestSchema, evmViewRequestSchema, managedViewRequestSchema, + managedMembersViewRequestSchema, androidViewRequestSchema, apiSurfaceViewSchema, captureViewRequestSchema, @@ -328,6 +336,7 @@ const viewResultBase = { "inspect_macho", "inspect_evm_interface", "inspect_managed_artifact", + "inspect_managed_members", "inspect_web_network_capture", "inspect_android_package", "inspect_pcap_capture", @@ -396,6 +405,13 @@ export const managedArtifactSummaryViewResultSchema = z.strictObject({ summary: managedSummarySchema, }); +/** Exact member selection, coverage and counts for the selected delivery. */ +export const managedMemberSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + parent_operation: z.literal("inspect_managed_members"), + summary: managedMemberSummarySchema, +}); + /** Exact historical capture summary, with no body or member-inventory payload. */ export const webNetworkCaptureSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, @@ -446,6 +462,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ machoSummarySchema, evmSummarySchema, managedSummarySchema, + managedMemberSummarySchema, captureSummarySchema, androidPackageSummarySchema, pcapSummarySchema, @@ -529,6 +546,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ ...managedViewContextSchema.shape, parent_operation: z.literal("inspect_managed_artifact"), }), + z.strictObject({ + kind: z.literal("managed-members"), + view: managedMembersViewRequestSchema, + page: managedMembersViewPageSchema, + ...viewResultBase, + ...managedMembersViewContextSchema.shape, + parent_operation: z.literal("inspect_managed_members"), + }), z.strictObject({ kind: z.literal("android"), view: androidViewRequestSchema, @@ -622,6 +647,10 @@ export type UnsignedAnalysisView = Extract, "view_digest" > + | Omit< + Extract, + "view_digest" + > | Omit< Extract, "view_digest" @@ -674,7 +703,7 @@ const unsupportedParent = ( new AnalysisUnsupportedTargetError( INSPECT_ANALYSIS_VIEW_OPERATION, parentArtifactPath(parent), - `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, EVM, managed artifact, Android package, Dart AOT, or web network capture Evidence.`, + `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, EVM, managed artifact or member, Android package, Dart AOT, or web network capture Evidence.`, ); const parentArtifactPath = (parent: AnalysisViewParent): string => { @@ -704,6 +733,7 @@ const retainedViewProjectors = new Map([ ["inspect_macho", projectMachoView], ["inspect_evm_interface", projectEvmInterfaceView], ["inspect_managed_artifact", projectManagedArtifactView], + ["inspect_managed_members", projectManagedMemberView], ["inspect_android_package", projectAndroidPackageView], ["inspect_dart_aot", projectDartAotView], ["inspect_artifact", projectArtifactInventoryView], diff --git a/src/domain/analysisView/managedArtifactView.ts b/src/domain/analysisView/managedArtifactView.ts index 49d16fa80..eef59eb9e 100644 --- a/src/domain/analysisView/managedArtifactView.ts +++ b/src/domain/analysisView/managedArtifactView.ts @@ -51,6 +51,24 @@ export const managedViewContextSchema = z.strictObject({ source_coverage: managedArtifactInspectionSchema.shape.coverage, }); +/** Token scope and identity limits shared by every managed metadata view. */ +export const managedViewIdentityUnknowns = ( + parent: AnalysisViewParent, + mvid: string | null, +): string[] => [ + "Metadata tokens are build-local coordinates bound to the recorded artifact SHA-256 and module MVID; this view does not load or execute the assembly.", + ...(parent.subject == null + ? [ + "The parent has no recorded subject; its normalized artifact identity cannot be cross-checked against a subject digest.", + ] + : []), + ...(mvid === null + ? [ + "The module MVID is unknown; tokens cannot establish a cross-observation module match.", + ] + : []), +]; + /** Project authenticated metadata without reading a path or invoking a provider. */ export const projectManagedArtifactView = ( parent: AnalysisViewParent, @@ -96,17 +114,7 @@ export const projectManagedArtifactView = ( limitations: uniqueSorted([...parent.limitations, ...report.limitations]), unknowns: [ "Collection counts and pages cover producer-returned rows; metadata completeness remains the original metadata status and coverage, not an absence claim about unreturned rows.", - "Metadata tokens are build-local coordinates bound to the recorded artifact SHA-256 and module MVID; this view does not load or execute the assembly.", - ...(parent.subject == null - ? [ - "The parent has no recorded subject; its normalized artifact identity cannot be cross-checked against a subject digest.", - ] - : []), - ...(identity_scope.requires_mvid === null - ? [ - "The module MVID is unknown; tokens cannot establish a cross-observation module match.", - ] - : []), + ...managedViewIdentityUnknowns(parent, identity_scope.requires_mvid), ], }; if (view.kind === "summary") { diff --git a/src/domain/analysisView/managedMemberView.ts b/src/domain/analysisView/managedMemberView.ts new file mode 100644 index 000000000..399dd59a1 --- /dev/null +++ b/src/domain/analysisView/managedMemberView.ts @@ -0,0 +1,270 @@ +import { z } from "zod"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { uniqueSorted } from "../canonicalOrdering.js"; +import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; +import { + managedMemberInspectionSchema, + type ManagedMemberInspection, +} from "../managed/managedArtifact.js"; +import { err, ok, type Result } from "../result.js"; +import { + analysisViewInputError, + type AnalysisViewParent, + type AnalysisViewRequest, + type UnsignedAnalysisView, +} from "./analysisView.js"; +import { + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; +import { managedViewIdentityUnknowns } from "./managedArtifactView.js"; + +const member = managedMemberInspectionSchema.shape; + +/** Original member rows; offsets count entries in the returned parent collection. */ +export const managedMembersViewRequestSchema = z.strictObject({ + kind: z.literal("managed-members"), + collection: z.enum([ + "types", + "exported_types", + "fields", + "methods", + "member_refs", + "call_edges", + "field_accesses", + "user_strings", + ]), + offset: z.number().int().nonnegative().default(0), + limit: z.number().int().positive().default(64), +}); + +const rowsOf = ( + collection: C, + rows: S, +) => z.strictObject({ collection: z.literal(collection), rows }); + +/** + * Rows of one collection, keyed by that collection: several row schemas accept + * each other's rows after stripping keys, so no union may pick one by order. + */ +export const managedMembersViewPageSchema = z.discriminatedUnion("collection", [ + rowsOf("types", member.types), + rowsOf("exported_types", member.exported_types.unwrap().unwrap()), + rowsOf("fields", member.fields), + rowsOf("methods", member.methods), + rowsOf("member_refs", member.member_refs), + rowsOf("call_edges", member.call_edges), + rowsOf("field_accesses", member.field_accesses), + rowsOf("user_strings", member.user_strings.unwrap().unwrap().shape.literals), +]); + +type ManagedMembersViewPage = z.infer; + +const nullableCount = z.number().int().nonnegative().nullable(); + +/** Keep identity, selection and coverage; member collections remain retained. */ +export const managedMemberSummarySchema = z.strictObject({ + artifact: member.artifact, + module: member.module, + metadata: member.metadata, + identity_scope: member.identity_scope, + selection: member.selection.unwrap(), + coverage: member.coverage, + user_strings: z + .strictObject({ + state: member.user_strings.unwrap().unwrap().shape.state, + issues: member.user_strings.unwrap().unwrap().shape.issues, + }) + .nullable(), + limitations: member.limitations, + reported_counts: z.strictObject({ + types: z.number().int().nonnegative(), + exported_types: nullableCount, + fields: z.number().int().nonnegative(), + methods: z.number().int().nonnegative(), + member_refs: z.number().int().nonnegative(), + call_edges: z.number().int().nonnegative(), + field_accesses: z.number().int().nonnegative(), + user_strings: nullableCount, + }), +}); + +export const managedMembersViewContextSchema = z.strictObject({ + identity_scope: member.identity_scope, + selection: member.selection.unwrap(), + source_metadata_status: member.metadata.shape.status, + source_coverage: member.coverage, +}); + +type ManagedMembersViewRequest = z.infer< + typeof managedMembersViewRequestSchema +>; +type MemberCollection = ManagedMembersViewRequest["collection"]; + +const slice = ( + collection: C, + rows: readonly T[] | null | undefined, + { offset, limit }: { readonly offset: number; readonly limit: number }, +) => + rows == null + ? null + : { + page: { collection, rows: rows.slice(offset, offset + limit) }, + total: rows.length, + }; + +/** One page of a collection, or null when the parent never examined it. */ +const collectionPage = ( + report: ManagedMemberInspection, + view: ManagedMembersViewRequest, +): { readonly page: ManagedMembersViewPage; readonly total: number } | null => { + const { collection } = view; + switch (collection) { + case "types": + return slice(collection, report.types, view); + case "exported_types": + return slice(collection, report.exported_types, view); + case "fields": + return slice(collection, report.fields, view); + case "methods": + return slice(collection, report.methods, view); + case "member_refs": + return slice(collection, report.member_refs, view); + case "call_edges": + return slice(collection, report.call_edges, view); + case "field_accesses": + return slice(collection, report.field_accesses, view); + case "user_strings": + return slice(collection, report.user_strings?.literals, view); + } +}; + +/** Everything but the member collections, which are replaced by their counts. */ +const summarizeMembers = ( + report: ManagedMemberInspection, +): z.infer => { + const { + types, + exported_types, + fields, + methods, + member_refs, + call_edges, + field_accesses, + user_strings, + ...identity + } = report; + return { + ...identity, + user_strings: + user_strings === null + ? null + : { state: user_strings.state, issues: user_strings.issues }, + reported_counts: { + types: types.length, + exported_types: exported_types?.length ?? null, + fields: fields.length, + methods: methods.length, + member_refs: member_refs.length, + call_edges: call_edges.length, + field_accesses: field_accesses.length, + user_strings: user_strings?.literals.length ?? null, + }, + }; +}; + +/** Project authenticated member Evidence without reading a path or invoking a provider. */ +export const projectManagedMemberView = ( + parent: AnalysisViewParent, + view: AnalysisViewRequest, +): Result => { + if (view.kind !== "summary" && view.kind !== "managed-members") + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "Managed member inspection supports summary and managed-members views.", + }, + ]), + ); + const parsed = managedMemberInspectionSchema.safeParse( + parent.normalizedResult, + ); + if (!parsed.success) + return err( + analysisInputErrorFromIssues( + "inspect_analysis_view", + parsed.error.issues.map((issue) => ({ + ...issue, + path: ["source", "normalized_result", ...issue.path], + })), + { source: { normalized_result: parent.normalizedResult } }, + { cause: parsed.error }, + ), + ); + const report = parsed.data; + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: "inspect_managed_members" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: { path: report.artifact.path, sha256: report.artifact.sha256 }, + limitations: uniqueSorted([...parent.limitations, ...report.limitations]), + unknowns: [ + "Collection counts and pages cover producer-returned rows; member completeness remains the original coverage, not an absence claim about unreturned rows.", + ...(report.selection === null + ? [] + : [ + "Exported types and member references cover the whole assembly; the other collections cover only the selected declaring types.", + ]), + ...managedViewIdentityUnknowns( + parent, + report.identity_scope.requires_mvid, + ), + ], + }; + if (view.kind === "summary") { + const summary = summarizeMembers(report); + const total = Object.values(summary.reported_counts).reduce( + (sum, count) => sum + (count ?? 0), + 0, + ); + return ok({ + kind: "summary", + view, + ...shared, + summary, + coverage: completeWithinViewCoverage(total, total), + }); + } + const selected = collectionPage(report, view); + if (selected === null) + return err( + analysisViewInputError([ + { + path: ["view", "collection"], + reason: "invalid_value", + message: `The parent Evidence did not examine ${view.collection}; repeat inspect_managed_members with the current runtime to record it.`, + }, + ]), + ); + const { page, total } = selected; + return ok({ + kind: "managed-members", + view, + page, + ...shared, + identity_scope: report.identity_scope, + selection: report.selection, + source_metadata_status: report.metadata.status, + source_coverage: + view.collection === "user_strings" && report.user_strings !== null + ? { + state: report.user_strings.state, + issues: report.user_strings.issues, + } + : report.coverage, + coverage: pageViewCoverage(view.offset, page.rows.length, total), + }); +}; diff --git a/src/server/registerManagedTools.ts b/src/server/registerManagedTools.ts index 5ff513d67..e8acf3156 100644 --- a/src/server/registerManagedTools.ts +++ b/src/server/registerManagedTools.ts @@ -16,6 +16,15 @@ import { registerEvidenceTools } from "./registerEvidenceTools.js"; import { runManagedProviderExecution } from "../composition/directAnalysis.js"; import { isManagedToolName } from "../contracts/managed/managedToolContracts.js"; +/** Operations whose caller may select a retained summary instead of complete delivery. */ +const summarizesRetainedEvidence = ( + operation: string, + parameters: Readonly>, +): boolean => + (operation === "inspect_managed_artifact" || + operation === "inspect_managed_members") && + parameters.detail === "summary"; + /** Register execution-free managed PE/CLI inspection. */ export const registerManagedTools = ( server: EvidenceMcpServer, @@ -34,11 +43,8 @@ export const registerManagedTools = ( execute: async (operation, parameters, executionOptions) => { if (!isManagedToolName(operation)) return analysis.execute(operation, parameters, executionOptions); - const { detail: _detail, ...artifactParameters } = parameters; - if ( - operation === "inspect_managed_artifact" && - parameters.detail === "summary" - ) { + const { detail: _detail, ...providerParameters } = parameters; + if (summarizesRetainedEvidence(operation, parameters)) { const ready = requireAnalysisViewRetention(operation, { recordEvidence: options.recordEvidence, evidenceById: options.evidenceById, @@ -69,9 +75,7 @@ export const registerManagedTools = ( path, operation, executionOptions?.signal, - operation === "inspect_managed_artifact" - ? artifactParameters - : parameters, + providerParameters, ); if (execution.ok && typeof requestedPath === "string") selectedManagedPath = requestedPath; @@ -82,8 +86,7 @@ export const registerManagedTools = ( ...options, analysisForAdmission, projectEvidence: (operation, parameters, evidence) => - operation === "inspect_managed_artifact" && - parameters.detail === "summary" + summarizesRetainedEvidence(operation, parameters) ? summarizeRetainedAnalysis(evidence, { recordEvidence: options.recordEvidence, evidenceById: options.evidenceById, diff --git a/tests/boundary/mcp/managedArtifactSummaryMcp.test.ts b/tests/boundary/mcp/managedArtifactSummaryMcp.test.ts index 8e941e877..7bb38abe8 100644 --- a/tests/boundary/mcp/managedArtifactSummaryMcp.test.ts +++ b/tests/boundary/mcp/managedArtifactSummaryMcp.test.ts @@ -49,16 +49,13 @@ it("returns a first-call overview and pages the unchanged source after its file const ajv = new Ajv2020({ strict: false, validateFormats: false }); expect(ajv.validateSchema(definition.inputSchema)).toBe(true); expect(ajv.validateSchema(outputSchema)).toBe(true); - for (const name of [ - "inspect_managed_members", - "inspect_managed_native_boundaries", - ]) - expect( - ajv.validate(catalog.find((x) => x.name === name)!.inputSchema, { - path, - detail: "summary", - }), - ).toBe(false); + expect( + ajv.validate( + catalog.find((x) => x.name === "inspect_managed_native_boundaries")! + .inputSchema, + { path, detail: "summary" }, + ), + ).toBe(false); const reply = await client.callTool({ name: "inspect_managed_artifact", arguments: { path, detail: "summary" }, @@ -239,11 +236,15 @@ it("refuses selected delivery before file access when the server cannot retain E const [a, b] = InMemoryTransport.createLinkedPair(); await server.connect(b); await client.connect(a); - const rejected = await client.callTool({ - name: "inspect_managed_artifact", - arguments: { path: join(directory, "absent.dll"), detail: "summary" }, - }); - expect(parseMcpToolError(rejected).error.code).toBe("capability_unavailable"); + for (const name of ["inspect_managed_artifact", "inspect_managed_members"]) { + const rejected = await client.callTool({ + name, + arguments: { path: join(directory, "absent.dll"), detail: "summary" }, + }); + expect(parseMcpToolError(rejected).error.code).toBe( + "capability_unavailable", + ); + } const complete = await client.callTool({ name: "inspect_managed_artifact", arguments: { path }, diff --git a/tests/boundary/mcp/managedMemberSummaryMcp.test.ts b/tests/boundary/mcp/managedMemberSummaryMcp.test.ts new file mode 100644 index 000000000..83afc5d0c --- /dev/null +++ b/tests/boundary/mcp/managedMemberSummaryMcp.test.ts @@ -0,0 +1,200 @@ +import { writeFile, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect, it, onTestFinished } from "vitest"; +import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; +import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; +import { inspectAnalysisView } from "../../../src/application/analysisView/AnalysisViewService.js"; +import { ManagedStaticProvider } from "../../../src/dotnet/ManagedStaticProvider.js"; +import { buildManagedPeFixture } from "../../../src/dotnet/ManagedPe.fixture.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { + analysisViewResultSchema, + managedMemberSummaryViewResultSchema, +} from "../../../src/domain/analysisView/analysisView.js"; +import { managedMemberInspectionSchema } from "../../../src/domain/managed/managedArtifact.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const COLLECTIONS = [ + "types", + "exported_types", + "fields", + "methods", + "member_refs", + "call_edges", + "field_accesses", + "user_strings", +] as const; + +const createHarness = async () => { + const session = composeBinarySession(new AnalysisProviderRegistry([]), [ + new ManagedStaticProvider(), + ]); + const harness = await createApplicationMcpHarness({ session }); + onTestFinished(harness.close); + return harness; +}; + +it("summarizes selected members and pages every original collection after the file disappears", async () => { + const directory = await createTestTempDirectory("rea-member-summary-"); + const path = join(directory, "fixture.dll"); + await writeFile(path, buildManagedPeFixture({ methodRowCount: 2 })); + const { client, session } = await createHarness(); + const definition = (await client.listTools()).tools.find( + (x) => x.name === "inspect_managed_members", + ); + if (definition?.outputSchema === undefined) + throw new Error("Managed member schemas are absent"); + const outputSchema: Record = definition.outputSchema; + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + expect(ajv.validateSchema(definition.inputSchema)).toBe(true); + expect(ajv.validateSchema(outputSchema)).toBe(true); + const type_names = ["Fixture.Program", "Missing.Type"]; + expect( + ajv.validate(definition.inputSchema, { + path, + type_names, + detail: "summary", + }), + ).toBe(true); + const reply = await client.callTool({ + name: "inspect_managed_members", + arguments: { path, type_names, detail: "summary" }, + }); + expect(reply.isError).not.toBe(true); + expect( + ajv.validate(outputSchema, reply.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const receipt = parseEvidence(reply.structuredContent); + const view = managedMemberSummaryViewResultSchema.parse( + receipt.normalized_result, + ); + const parent = session.evidenceById(view.parent_evidence_id); + if (parent === undefined) + throw new Error("Complete member source was not retained"); + expect(parent.operation).toBe("inspect_managed_members"); + expect(receipt.evidence_links).toEqual([parent.evidence_id]); + const report = managedMemberInspectionSchema.parse(parent.normalized_result); + expect(view.summary.selection).toEqual({ + type_names, + unmatched_type_names: ["Missing.Type"], + }); + expect(view.summary.coverage).toEqual(report.coverage); + expect(view.summary.identity_scope).toEqual(report.identity_scope); + expect(view.summary).not.toHaveProperty("methods"); + expect(report.methods.length).toBeGreaterThan(0); + expect(report.member_refs.length).toBeGreaterThan(0); + for (const detail of [undefined, "complete"]) { + const complete = parseEvidence( + ( + await client.callTool({ + name: "inspect_managed_members", + arguments: { path, type_names, ...(detail ? { detail } : {}) }, + }) + ).structuredContent, + ); + expect(complete.operation).toBe("inspect_managed_members"); + expect(complete.normalized_result).toEqual(parent.normalized_result); + } + const original = JSON.stringify(parent); + await unlink(path); + for (const collection of COLLECTIONS) { + const rows = + collection === "user_strings" + ? report.user_strings?.literals + : report[collection]; + if (rows == null) throw new Error(`${collection} was not examined`); + expect(view.summary.reported_counts[collection]).toBe(rows.length); + const paged: unknown[] = []; + for (let offset: number | null = 0; offset !== null;) { + const result = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }, + view: { kind: "managed-members", collection, offset, limit: 1 }, + }, + }); + expect(result.isError).not.toBe(true); + const page = analysisViewResultSchema.parse( + parseEvidence(result.structuredContent).normalized_result, + ); + if (page.kind !== "managed-members") + throw new Error("Unexpected page kind"); + expect(page.page.collection).toBe(collection); + expect(page.selection).toEqual(report.selection); + expect(page.coverage.total).toBe(rows.length); + paged.push(...page.page.rows); + offset = page.coverage.next_offset; + } + // Method rows carry body facts that a looser row schema would strip. + expect(paged).toEqual(rows); + } + expect(JSON.stringify(session.evidenceById(parent.evidence_id))).toBe( + original, + ); + const portable = inspectAnalysisView({ + source: { kind: "inline", evidence: parent }, + view: { kind: "summary" }, + }); + if (!portable.ok) throw portable.error; + expect(portable.value.normalized_result).toEqual(view); +}, 30000); + +it("keeps collections that historical member Evidence never examined unknown", async () => { + const directory = await createTestTempDirectory("rea-member-historical-"); + const path = join(directory, "fixture.dll"); + await writeFile(path, buildManagedPeFixture()); + const { client } = await createHarness(); + const parent = parseEvidence( + ( + await client.callTool({ + name: "inspect_managed_members", + arguments: { path }, + }) + ).structuredContent, + ); + const { + exported_types: _exported, + user_strings: _strings, + selection: _selection, + ...recorded + } = managedMemberInspectionSchema.parse(parent.normalized_result); + const historical = createEvidence(undefined, parent.provider, { + operation: parent.operation, + parameters: parent.parameters, + result: recorded, + limitations: parent.limitations, + authority: parent.authority, + }); + const summary = inspectAnalysisView({ + source: { kind: "inline", evidence: historical }, + view: { kind: "summary" }, + }); + if (!summary.ok) throw summary.error; + const view = managedMemberSummaryViewResultSchema.parse( + summary.value.normalized_result, + ); + expect(view.summary.reported_counts.exported_types).toBeNull(); + expect(view.summary.reported_counts.user_strings).toBeNull(); + expect(view.summary.user_strings).toBeNull(); + expect(view.summary.selection).toBeNull(); + expect(view.unknowns.join(" ")).toContain("no recorded subject"); + for (const collection of ["exported_types", "user_strings"] as const) + expect( + inspectAnalysisView({ + source: { kind: "inline", evidence: historical }, + view: { kind: "managed-members", collection, offset: 0, limit: 1 }, + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); + expect( + inspectAnalysisView({ + source: { kind: "inline", evidence: historical }, + view: { kind: "managed", collection: "attributes", offset: 0, limit: 1 }, + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); +}, 30000);