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 3e185a8a2..8aab2e44f 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -91,6 +91,11 @@ architectures, build_metadata, load_commands, dependencies, imports, exports, segments and provenance. `source_exhaustive: false` means unreturned symbols may exist. +`decode_interface_builder` graphs are often several megabytes and can exceed the +MCP response budget. Select `detail: "summary"` for every decoded document and +node/edge counts, then page `view: {kind: "interface-builder", collection: +"edges", document: "", offset: 0, limit: 64}`. + To see which functions or Objective-C methods a Mach-O actually calls in one run, use `observe_native_calls` with explicit breakpoints and a bounded `duration_ms`/`max_events`. It launches a new process under LLDB, so the target diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 876a30f6d..b8452922e 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -162,7 +162,8 @@ after target content was read. ## Retained application Evidence inputs `analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`, -`inspect_wasm_artifact`, and `inspect_macho` accept `detail`: +`inspect_wasm_artifact`, `inspect_macho`, and `decode_interface_builder` accept +`detail`: `complete` (default) returns the complete analysis Evidence; `summary` records that complete Evidence in the session and returns the derived `inspect_analysis_view` summary Evidence instead. Its @@ -245,6 +246,17 @@ symbols (Electron Framework: 2.5 MB of imports and exports, 3.3 KB as a summary), so select `detail: "summary"` on the first call. Delivery detail never reaches the native tools or changes the source Evidence ID. +Completed `decode_interface_builder` Evidence supports a summary and +`view: {kind: "interface-builder", collection: "nodes", offset: 0, limit: 64}`. +Collections are `documents`, `nodes`, `edges`, and `coverage`; for nodes and +edges an optional `document` keeps rows whose evidence names that exact decoded +`relative_path`, and an undecoded path is refused. The summary keeps every +decoded document, the provider, node-kind, edge-relation and coverage-status +counts, and decoder truncation. Each page repeats `source_truncated`. A complete +app graph often exceeds the default MCP response budget (XLD.app: 6.7 MB), so +select `detail: "summary"` on the first call. Delivery detail never reaches the +decoder or changes the source Evidence ID. + `trace_application_feature`, `trace_javascript_semantics`, `compare_application_versions`, `compare_source_to_bundle`, and `compare_javascript_export_shapes` accept complete inline application Evidence diff --git a/docs/native-investigation.md b/docs/native-investigation.md index f963fd4d8..f0f4d6483 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -319,7 +319,16 @@ complete observation. digest are preserved; key interpretation follows the pinned plist decoder. - `decode_interface_builder` / `rea decode-interface-builder ` reads compiled storyboard and nib resources into objects, outlets, actions, - connections and controller/class names. `verify:interface-builder` compiles + connections and controller/class names. A full app graph is often several + megabytes (XLD.app: 6.7 MB, about 2M tokens, which also exceeds the default + MCP response budget), so MCP callers should pass `detail: "summary"`. The + summary keeps every decoded document and node/edge/coverage counts and + retains the complete Evidence; page rows with `inspect_analysis_view` + `view: {kind: "interface-builder", collection: "nodes", document: + "Contents/Resources/Base.lproj/MainMenu.nib", offset: 0, limit: 64}`. + `document` selects rows whose evidence names that exact + `documents[].relative_path` (nodes and edges only); `source_truncated` keeps + decoder truncation visible on every page. `verify:interface-builder` compiles a source-owned AppKit XIB with `ibtool`. Storyboards require an installed iOS platform; unsupported archive forms remain explicit. XML plist archives accept UTF-8 and BOM-marked UTF-16 in either byte order. diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index 7d023752d..189757295 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -9,14 +9,14 @@ import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; const RETAINED_EXAMPLE_ID = `ev_${"a".repeat(64)}`; -/** Selected views of completed layout, JavaScript, native, inventory, WASM, Mach-O and web network Evidence. */ +/** Selected views of completed layout, JavaScript, native, inventory, WASM, Mach-O, Interface Builder and web network Evidence. */ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ { name: "inspect_analysis_view", ...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, decode_interface_builder, 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, Interface Builder, 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. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. 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.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts index 287b41214..3d63488a4 100644 --- a/src/contracts/artifactToolContracts.ts +++ b/src/contracts/artifactToolContracts.ts @@ -89,11 +89,17 @@ export const ARTIFACT_TOOL_CONTRACTS = [ ), artifact( "decode_interface_builder", - "Decode compiled storyboard and nib archives inside the active Apple app bundle, including keyed property lists and NIBArchive object tables, into a bounded object, hierarchy, and connection graph. This is static archive parsing; unrecognized archive classes and unresolved code handlers remain explicit unknowns.", + "Decode compiled storyboard and nib archives inside the active Apple app bundle, including keyed property lists and NIBArchive object tables, into a bounded object, hierarchy, and connection graph. This is static archive parsing; unrecognized archive classes and unresolved code handlers remain explicit unknowns. A full app graph is often several megabytes; detail summary retains it and returns documents with node, edge and coverage counts, then page rows, optionally one document's, with inspect_analysis_view kind interface-builder.", z.strictObject({ max_documents: z.number().int().min(1).max(64).default(64), max_objects: z.number().int().min(1).max(20_000).default(20_000), max_connections: z.number().int().min(1).max(40_000).default(40_000), + detail: analysisDetailSchema + .unwrap() + .optional() + .describe( + "Omitted or complete returns the complete decoded graph Evidence. summary retains that complete Evidence in this session and returns its inspect_analysis_view summary; use normalized_result.parent_evidence_id for interface-builder pages.", + ), }), ), artifact( diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 1a36c0896..20d5797d7 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { byteSearchResultSchema } from "../domain/native/bytePattern.js"; import { artifactInventorySummaryViewResultSchema, + interfaceBuilderSummaryViewResultSchema, machoSummaryViewResultSchema, managedArtifactSummaryViewResultSchema, } from "../domain/analysisView/analysisView.js"; @@ -404,7 +405,12 @@ export const artifactOutputSchemas: Readonly> = { ]), ), extract_artifact: evidenceResultOf(artifactExtractionResultSchema), - decode_interface_builder: evidenceResultOf(interfaceBuilderAnalysisSchema), + decode_interface_builder: evidenceResultOf( + z.union([ + interfaceBuilderAnalysisSchema, + interfaceBuilderSummaryViewResultSchema, + ]), + ), inspect_keyed_archive: evidenceResultOf(keyedArchiveResultSchema), inspect_asset_catalog: evidenceResultOf(appleAssetCatalogResultSchema), trace_dylib_resolution: evidenceResultOf(dylibResolutionResultSchema), diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index b08001e35..c15d15a12 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -62,6 +62,13 @@ import { machoViewRequestSchema, projectMachoView, } from "./machoView.js"; +import { + interfaceBuilderSummarySchema, + interfaceBuilderViewContextSchema, + interfaceBuilderViewItemsSchema, + interfaceBuilderViewRequestSchema, + projectInterfaceBuilderView, +} from "./interfaceBuilderView.js"; import { projectWebNetworkCaptureView, captureViewRequestSchema, @@ -206,6 +213,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ inventoryViewSchema, wasmViewRequestSchema, machoViewRequestSchema, + interfaceBuilderViewRequestSchema, evmViewRequestSchema, managedViewRequestSchema, androidViewRequestSchema, @@ -326,6 +334,7 @@ const viewResultBase = { "inventory_artifact", "inspect_wasm_artifact", "inspect_macho", + "decode_interface_builder", "inspect_evm_interface", "inspect_managed_artifact", "inspect_web_network_capture", @@ -382,6 +391,13 @@ export const machoSummaryViewResultSchema = z.strictObject({ summary: machoSummarySchema, }); +/** Decoded documents and graph counts for Interface Builder's selected delivery. */ +export const interfaceBuilderSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + parent_operation: z.literal("decode_interface_builder"), + summary: interfaceBuilderSummarySchema, +}); + /** Exact inferred-interface summary for EVM's caller-selected delivery. */ export const evmInterfaceSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, @@ -444,6 +460,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ inventorySummarySchema, wasmSummarySchema, machoSummarySchema, + interfaceBuilderSummarySchema, evmSummarySchema, managedSummarySchema, captureSummarySchema, @@ -514,6 +531,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ ...machoViewContextSchema.shape, parent_operation: z.literal("inspect_macho"), }), + z.strictObject({ + kind: z.literal("interface-builder"), + view: interfaceBuilderViewRequestSchema, + items: interfaceBuilderViewItemsSchema, + ...viewResultBase, + ...interfaceBuilderViewContextSchema.shape, + parent_operation: z.literal("decode_interface_builder"), + }), z.strictObject({ kind: z.literal("evm"), view: evmViewRequestSchema, @@ -611,6 +636,10 @@ export type UnsignedAnalysisView = > | Omit, "view_digest"> | Omit, "view_digest"> + | Omit< + Extract, + "view_digest" + > | Omit, "view_digest"> | Omit, "view_digest"> | Omit, "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, Interface Builder, EVM, managed artifact, Android package, Dart AOT, or web network capture Evidence.`, ); const parentArtifactPath = (parent: AnalysisViewParent): string => { @@ -702,6 +731,7 @@ const parentArtifactPath = (parent: AnalysisViewParent): string => { const retainedViewProjectors = new Map([ ["inspect_wasm_artifact", projectWasmArtifactView], ["inspect_macho", projectMachoView], + ["decode_interface_builder", projectInterfaceBuilderView], ["inspect_evm_interface", projectEvmInterfaceView], ["inspect_managed_artifact", projectManagedArtifactView], ["inspect_android_package", projectAndroidPackageView], diff --git a/src/domain/analysisView/analysisViewParent.ts b/src/domain/analysisView/analysisViewParent.ts new file mode 100644 index 000000000..0a2b57180 --- /dev/null +++ b/src/domain/analysisView/analysisViewParent.ts @@ -0,0 +1,25 @@ +import type { z } from "zod"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; +import { err, ok, type Result } from "../result.js"; +import type { AnalysisViewParent } from "./analysisView.js"; + +/** Parse a parent's result with its owning schema; issues point into the view source. */ +export const parseAnalysisViewResult = ( + schema: Schema, + parent: AnalysisViewParent, +): Result, AnalysisError> => { + const parsed = schema.safeParse(parent.normalizedResult); + if (parsed.success) return ok(parsed.data); + 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 }, + ), + ); +}; diff --git a/src/domain/analysisView/androidPackageView.ts b/src/domain/analysisView/androidPackageView.ts index caf948c10..519153db1 100644 --- a/src/domain/analysisView/androidPackageView.ts +++ b/src/domain/analysisView/androidPackageView.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { androidResultSchemas } from "../android/androidAnalysis.js"; -import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; import { err, ok, type Result } from "../result.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; import { analysisViewInputError, type AnalysisViewParent, @@ -53,19 +53,8 @@ export const projectAndroidPackageView = ( }, ]), ); - const parsed = packageSchema.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 parsed = parseAnalysisViewResult(packageSchema, parent); + if (!parsed.ok) return parsed; // Engine artifact_sha256 identifies the JAR, never the APK. A missing // authenticated APK subject cannot be repaired using the engine identity. if (parent.subject == null) @@ -79,7 +68,7 @@ export const projectAndroidPackageView = ( }, ]), ); - const report = parsed.data; + const report = parsed.value; const shared = { parent_evidence_id: parent.evidenceId, parent_operation: "inspect_android_package" as const, diff --git a/src/domain/analysisView/dartAotView.ts b/src/domain/analysisView/dartAotView.ts index 4d66aa190..76242973c 100644 --- a/src/domain/analysisView/dartAotView.ts +++ b/src/domain/analysisView/dartAotView.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { flutterResultSchemas } from "../flutter/flutterBuildAnalysis.js"; -import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; import { err, ok, type Result } from "../result.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; import { analysisViewInputError, type AnalysisViewParent, @@ -59,20 +59,9 @@ export const projectDartAotView = ( }, ]), ); - const parsed = aotSchema.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 parsed = parseAnalysisViewResult(aotSchema, parent); + if (!parsed.ok) return parsed; + const report = parsed.value; const { package_uris, dart_uris, class_like_tokens, ...pool } = report.string_pool; const string_pool = { diff --git a/src/domain/analysisView/evmInterfaceView.ts b/src/domain/analysisView/evmInterfaceView.ts index d9f3994d4..f931f21c7 100644 --- a/src/domain/analysisView/evmInterfaceView.ts +++ b/src/domain/analysisView/evmInterfaceView.ts @@ -2,8 +2,8 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { uniqueSorted } from "../canonicalOrdering.js"; import { evmInterfaceSchema } from "../evm/evmInterface.js"; -import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; import { err, ok, type Result } from "../result.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; import { analysisViewInputError, type AnalysisViewParent, @@ -60,20 +60,9 @@ export const projectEvmInterfaceView = ( }, ]), ); - const parsed = evmInterfaceSchema.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 parsed = parseAnalysisViewResult(evmInterfaceSchema, parent); + if (!parsed.ok) return parsed; + const report = parsed.value; if ( parent.subject == null || parent.subject.format !== "file" || diff --git a/src/domain/analysisView/interfaceBuilderView.test.ts b/src/domain/analysisView/interfaceBuilderView.test.ts new file mode 100644 index 000000000..97d2f375b --- /dev/null +++ b/src/domain/analysisView/interfaceBuilderView.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; + +import { + projectAnalysisView, + type AnalysisViewParent, +} from "./analysisView.js"; + +const target = "a".repeat(64); +const evidence = (artifact_path: string) => [ + { + kind: "interface_builder_resource" as const, + description: "compiled document", + location: { address: null, file_offset: null }, + artifact_path, + artifact_sha256: "b".repeat(64), + }, +]; +const node = (id: string, kind: string, document: string) => ({ + id, + kind, + name: id, + location: null, + attributes: {}, + evidence: evidence(document), +}); +const main = "Contents/Resources/Main.nib"; +const second = "Contents/Resources/Second.nib"; +const document = (relative_path: string) => ({ + relative_path, + archive_sha256: "b".repeat(64), + document_kind: "nib" as const, + object_count: 2, + connection_count: 1, + hierarchy_complete: true, +}); +const report = (truncated = false) => ({ + target_sha256: target, + documents: [document(main), document(second)], + graph: { + target_sha256: target, + provider: { id: "rea-ib", version: "1", tool_version: "1" }, + nodes: [ + node("main:root", "scene", main), + node("main:button", "control", main), + node("second:root", "scene", second), + ], + edges: [ + { + id: "main:edge", + from: "main:root", + to: "main:button", + relation: "contains", + resolution: "observed", + evidence: evidence(main), + limitations: [], + }, + ], + coverage: [ + { + facet: `objects:${main}`, + status: "complete", + reason: null, + examined: 2, + omitted: 0, + }, + ], + truncated, + }, + limitations: ["Recognized archive fields only."], +}); +const parent = ( + normalizedResult: unknown = report(), + sha256 = target, +): AnalysisViewParent => ({ + evidenceId: `ev_${"c".repeat(64)}`, + operation: "decode_interface_builder", + normalizedResult, + subject: { + name: "Fixture", + digest: { sha256 }, + format: "mach-o", + architecture: "arm64", + local_path: "/Applications/Fixture.app/Contents/MacOS/Fixture", + }, + limitations: [], +}); + +describe("Interface Builder analysis views", () => { + it("summarizes documents and graph counts without rows", () => { + const projected = projectAnalysisView(parent(), { kind: "summary" }); + if (!projected.ok) throw projected.error; + expect(projected.value).toMatchObject({ + kind: "summary", + parent_operation: "decode_interface_builder", + artifact: { sha256: target }, + summary: { + documents: report().documents, + available_counts: { nodes: 3, edges: 1, coverage: 1 }, + node_kinds: { control: 1, scene: 2 }, + edge_relations: { contains: 1 }, + coverage_statuses: { complete: 1 }, + truncated: false, + }, + coverage: { examined: 7, total: 7 }, + }); + expect(JSON.stringify(projected.value)).not.toContain("main:button"); + }); + + it("pages one document's nodes and keeps decoder truncation visible", () => { + const projected = projectAnalysisView(parent(), { + kind: "interface-builder", + collection: "nodes", + document: main, + offset: 1, + limit: 5, + }); + if (!projected.ok) throw projected.error; + expect(projected.value).toMatchObject({ + kind: "interface-builder", + items: [node("main:button", "control", main)], + source_truncated: false, + coverage: { status: "page", examined: 1, total: 2, exhausted: true }, + }); + const truncated = projectAnalysisView(parent(report(true)), { + kind: "interface-builder", + collection: "coverage", + offset: 0, + limit: 64, + }); + if (!truncated.ok) throw truncated.error; + expect(truncated.value).toMatchObject({ + items: report().graph.coverage, + source_truncated: true, + unknowns: expect.arrayContaining([ + expect.stringContaining("stopped at a caller-selected bound"), + ]), + }); + }); + + it("refuses unknown documents, other views and mismatched targets", () => { + const unknown = projectAnalysisView(parent(), { + kind: "interface-builder", + collection: "edges", + document: "Contents/Resources/Missing.nib", + offset: 0, + limit: 64, + }); + if (unknown.ok) throw new Error("expected an unknown-document refusal"); + expect(unknown.error).toMatchObject({ + issues: [{ path: ["view", "document"] }], + }); + const wrongKind = projectAnalysisView(parent(), { + kind: "macho", + facet: "imports", + offset: 0, + limit: 64, + }); + if (wrongKind.ok) throw new Error("expected a view-kind refusal"); + expect(wrongKind.error).toMatchObject({ issues: [{ path: ["view"] }] }); + const mismatched = projectAnalysisView(parent(report(), "d".repeat(64)), { + kind: "summary", + }); + if (mismatched.ok) throw new Error("expected a subject refusal"); + expect(mismatched.error).toMatchObject({ + issues: [{ path: ["source", "subject"] }], + }); + }); +}); diff --git a/src/domain/analysisView/interfaceBuilderView.ts b/src/domain/analysisView/interfaceBuilderView.ts new file mode 100644 index 000000000..80f2c6179 --- /dev/null +++ b/src/domain/analysisView/interfaceBuilderView.ts @@ -0,0 +1,248 @@ +import { z } from "zod"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { uniqueSorted } from "../canonicalOrdering.js"; +import { + interfaceBuilderAnalysisSchema, + interfaceBuilderDocumentSchema, +} from "../apple/interfaceBuilderGraph.js"; +import { + nativeInvestigationCoverageSchema, + nativeInvestigationEdgeSchema, + nativeInvestigationNodeSchema, +} from "../native/nativeInvestigationGraph.js"; +import { err, ok, type Result } from "../result.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; +import { + analysisViewInputError, + type AnalysisViewParent, + type AnalysisViewRequest, + type UnsignedAnalysisView, +} from "./analysisView.js"; +import { + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; + +const page = { + kind: z.literal("interface-builder"), + offset: z.number().int().nonnegative().default(0), + limit: z.number().int().positive().default(64), +} as const; + +/** Original decoded rows; graph rows can be narrowed to one decoded document. */ +export const interfaceBuilderViewRequestSchema = z.discriminatedUnion( + "collection", + [ + z.strictObject({ + ...page, + collection: z.enum(["nodes", "edges"]), + document: z + .string() + .min(1) + .optional() + .describe( + "Exact documents[].relative_path; selects rows whose evidence names that archive.", + ), + }), + z.strictObject({ + ...page, + collection: z.enum(["documents", "coverage"]), + }), + ], +); + +export const interfaceBuilderViewItemsSchema = z.union([ + z.array(interfaceBuilderDocumentSchema), + z.array(nativeInvestigationNodeSchema), + z.array(nativeInvestigationEdgeSchema), + z.array(nativeInvestigationCoverageSchema), +]); + +/** The decoder's own truncation, independent of page exhaustion. */ +export const interfaceBuilderViewContextSchema = z.strictObject({ + source_truncated: z.boolean(), +}); + +const countsSchema = z.record(z.string(), z.number().int().positive()); + +/** Every decoded document with graph counts; rows and evidence stay retained. */ +export const interfaceBuilderSummarySchema = z.strictObject({ + target_sha256: interfaceBuilderAnalysisSchema.shape.target_sha256, + provider: interfaceBuilderAnalysisSchema.shape.graph.shape.provider, + documents: interfaceBuilderAnalysisSchema.shape.documents, + available_counts: z.strictObject({ + nodes: z.number().int().nonnegative(), + edges: z.number().int().nonnegative(), + coverage: z.number().int().nonnegative(), + }), + node_kinds: countsSchema, + edge_relations: countsSchema, + coverage_statuses: countsSchema, + truncated: z.boolean(), + limitation_count: z.number().int().nonnegative(), +}); + +const tally = (values: readonly string[]): Record => { + const counts = new Map(); + for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1); + return Object.fromEntries( + [...counts].sort(([left], [right]) => (left < right ? -1 : 1)), + ); +}; + +type InterfaceBuilderReport = z.output; + +/** Admit a well-formed report bound to its recorded target, if one exists. */ +const parseInterfaceBuilderReport = ( + parent: AnalysisViewParent, +): Result => { + const parsed = parseAnalysisViewResult( + interfaceBuilderAnalysisSchema, + parent, + ); + if (!parsed.ok) return parsed; + const report = parsed.value; + if ( + report.graph.target_sha256 !== report.target_sha256 || + (parent.subject != null && + parent.subject.digest.sha256 !== report.target_sha256) + ) + return err( + analysisViewInputError([ + { + path: ["source", "subject"], + reason: "invalid_value", + message: + "Interface Builder parent Evidence subject and graph must identify the report's target SHA-256.", + }, + ]), + ); + return ok(report); +}; + +/** Every decoded document with graph tallies; rows stay in the retained parent. */ +const summarizeInterfaceBuilder = ( + report: InterfaceBuilderReport, + limitationCount: number, +) => { + const { graph } = report; + const total = + report.documents.length + + graph.nodes.length + + graph.edges.length + + graph.coverage.length; + return { + summary: { + target_sha256: report.target_sha256, + provider: graph.provider, + documents: report.documents, + available_counts: { + nodes: graph.nodes.length, + edges: graph.edges.length, + coverage: graph.coverage.length, + }, + node_kinds: tally(graph.nodes.map((node) => node.kind)), + edge_relations: tally(graph.edges.map((edge) => edge.relation)), + coverage_statuses: tally(graph.coverage.map((row) => row.status)), + truncated: graph.truncated, + limitation_count: limitationCount, + }, + coverage: completeWithinViewCoverage(total, total), + }; +}; + +/** Project retained decode_interface_builder Evidence without reopening archives. */ +export const projectInterfaceBuilderView = ( + parent: AnalysisViewParent, + view: AnalysisViewRequest, +): Result => { + if (view.kind !== "summary" && view.kind !== "interface-builder") + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "Interface Builder decoding supports summary and interface-builder views.", + }, + ]), + ); + const parsed = parseInterfaceBuilderReport(parent); + if (!parsed.ok) return parsed; + const report = parsed.value; + const { graph } = report; + const limitations = uniqueSorted([ + ...parent.limitations, + ...report.limitations, + ]); + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: "decode_interface_builder" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: { + path: parent.subject?.local_path ?? "", + sha256: report.target_sha256, + }, + limitations, + unknowns: [ + "Rows describe recognized archive objects and connections only; unrecognized classes, fields and runtime-created UI remain unknown.", + ...(graph.truncated + ? [ + "The decoder stopped at a caller-selected bound; page exhaustion does not establish absence of undecoded objects or connections.", + ] + : []), + ], + }; + if (view.kind === "summary") + return ok({ + kind: "summary", + view, + ...shared, + ...summarizeInterfaceBuilder(report, limitations.length), + }); + const document = + view.collection === "nodes" || view.collection === "edges" + ? view.document + : undefined; + if ( + document !== undefined && + !report.documents.some((row) => row.relative_path === document) + ) + return err( + analysisViewInputError([ + { + path: ["view", "document"], + reason: "invalid_value", + message: + "Select an exact documents[].relative_path from this decoded report.", + }, + ]), + ); + const inDocument = < + Row extends { readonly evidence: readonly { artifact_path: unknown }[] }, + >( + candidates: readonly Row[], + ): readonly Row[] => + document === undefined + ? candidates + : candidates.filter((row) => + row.evidence.some((evidence) => evidence.artifact_path === document), + ); + const rows = + view.collection === "documents" + ? report.documents + : view.collection === "coverage" + ? graph.coverage + : view.collection === "nodes" + ? inDocument(graph.nodes) + : inDocument(graph.edges); + const items = rows.slice(view.offset, view.offset + view.limit); + return ok({ + kind: "interface-builder", + view, + items, + ...shared, + source_truncated: graph.truncated, + coverage: pageViewCoverage(view.offset, items.length, rows.length), + }); +}; diff --git a/src/domain/analysisView/machoView.ts b/src/domain/analysisView/machoView.ts index 1a64d23aa..fe39d6326 100644 --- a/src/domain/analysisView/machoView.ts +++ b/src/domain/analysisView/machoView.ts @@ -2,9 +2,9 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { uniqueSorted } from "../canonicalOrdering.js"; import { isMachOFormat } from "../binaryTargetTypes.js"; -import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; import { inspectMachoSchema } from "../native/nativeInspection.js"; import { err, ok, type Result } from "../result.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; import { analysisViewInputError, type AnalysisViewParent, @@ -93,19 +93,8 @@ type MachoReport = z.output; const parseMachoReport = ( parent: AnalysisViewParent, ): Result => { - const parsed = inspectMachoSchema.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 parsed = parseAnalysisViewResult(inspectMachoSchema, parent); + if (!parsed.ok) return parsed; if (parent.subject != null && !isMachOFormat(parent.subject.format)) return err( analysisViewInputError([ @@ -117,7 +106,7 @@ const parseMachoReport = ( }, ]), ); - return ok(parsed.data); + return ok(parsed.value); }; /** Counts replace rows; every collection caveat stays in the limitations. */ diff --git a/src/domain/analysisView/managedArtifactView.ts b/src/domain/analysisView/managedArtifactView.ts index 49d16fa80..3dd0033d7 100644 --- a/src/domain/analysisView/managedArtifactView.ts +++ b/src/domain/analysisView/managedArtifactView.ts @@ -1,12 +1,12 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { uniqueSorted } from "../canonicalOrdering.js"; -import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; import { managedArtifactInspectionSchema, managedMemberInspectionSchema, } from "../managed/managedArtifact.js"; import { err, ok, type Result } from "../result.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; import { analysisViewInputError, type AnalysisViewParent, @@ -67,22 +67,12 @@ export const projectManagedArtifactView = ( }, ]), ); - const parsed = managedArtifactInspectionSchema.safeParse( - parent.normalizedResult, + const parsed = parseAnalysisViewResult( + managedArtifactInspectionSchema, + parent, ); - 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; + if (!parsed.ok) return parsed; + const report = parsed.value; const identity_scope = { token_identity: "build-local" as const, requires_artifact_sha256: report.artifact.sha256, diff --git a/src/domain/analysisView/wasmArtifactView.ts b/src/domain/analysisView/wasmArtifactView.ts index dc3036b26..52a97632a 100644 --- a/src/domain/analysisView/wasmArtifactView.ts +++ b/src/domain/analysisView/wasmArtifactView.ts @@ -2,11 +2,11 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { uniqueSorted } from "../canonicalOrdering.js"; import { err, ok, type Result } from "../result.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; import { wasmArtifactSchema, wasmArtifactMatchesSubject, } from "../wasm/wasmArtifact.js"; -import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; import { analysisViewInputError, type AnalysisViewParent, @@ -81,20 +81,9 @@ export const projectWasmArtifactView = ( }, ]), ); - const parsed = wasmArtifactSchema.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 parsed = parseAnalysisViewResult(wasmArtifactSchema, parent); + if (!parsed.ok) return parsed; + const report = parsed.value; if ( !wasmArtifactMatchesSubject( report.artifact, diff --git a/src/domain/analysisView/webNetworkCaptureView.ts b/src/domain/analysisView/webNetworkCaptureView.ts index b335e9241..1adf0c288 100644 --- a/src/domain/analysisView/webNetworkCaptureView.ts +++ b/src/domain/analysisView/webNetworkCaptureView.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; -import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; import type { JsonValue } from "../jsonValue.js"; import { err, ok, type Result } from "../result.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; import { webNetworkCaptureRecordSchema, webNetworkCaptureBinaryDescriptorSchema, @@ -118,20 +118,9 @@ const preview = (record: WebNetworkCaptureRecord) => { const parseCaptureParent = ( parent: AnalysisViewParent, ): Result => { - const parsed = webNetworkCaptureSchema.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 parsed = parseAnalysisViewResult(webNetworkCaptureSchema, parent); + if (!parsed.ok) return parsed; + const report = parsed.value; if ( report.artifact.sha256 !== null && (parent.subject == null || diff --git a/src/server/registerArtifactTools.ts b/src/server/registerArtifactTools.ts index 1217664e6..d2df1da9e 100644 --- a/src/server/registerArtifactTools.ts +++ b/src/server/registerArtifactTools.ts @@ -1,39 +1,36 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; -import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; import { ARTIFACT_TOOL_CONTRACTS, artifactInspectionInputSchema, } from "../contracts/artifactToolContracts.js"; -import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; -import { - requireAnalysisViewRetention, - summarizeRetainedAnalysis, -} from "../application/analysisView/AnalysisViewService.js"; import { createArtifactExtractionDestination } from "../application/artifacts/ArtifactExtractionDestination.js"; -import { ok } from "../domain/result.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; -import type { Logger } from "pino"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; import { artifactInspectionResultSchema } from "../domain/artifactInspection.js"; +import { + retainedSummaryDelivery, + withoutDeliveryDetail, + type EvidenceToolFamilyOptions, +} from "./retainedSummaryDelivery.js"; + +/** Operations whose MCP delivery may be a retained summary view. */ +const SUMMARY_DELIVERY: ReadonlySet = new Set([ + "inspect_artifact", + "decode_interface_builder", +]); +/** inspect_artifact keeps an explicit detail in its Evidence parameters. */ +const decodeWithoutDelivery = withoutDeliveryDetail( + new Set(["decode_interface_builder"]), +); /** Register deterministic artifact inventory and safe extraction operations. */ export const registerArtifactTools = ( server: EvidenceMcpServer, - options: { - readonly logger: Logger; - readonly activeTarget: (() => BinaryTarget | undefined) | undefined; - readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined; - readonly evidenceById?: EvidenceLookup | undefined; - readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis; - }, + options: EvidenceToolFamilyOptions, ): void => { - const retention = { - recordEvidence: options.recordEvidence, - evidenceById: options.evidenceById, - }; registerEvidenceTools(server, ARTIFACT_TOOL_CONTRACTS, { ...options, + ...retainedSummaryDelivery(SUMMARY_DELIVERY, options), parametersForExecution: (operation, parameters) => { if (operation === "inspect_artifact") { const { detail: _detail, ...execution } = @@ -42,16 +39,9 @@ export const registerArtifactTools = ( } return operation === "extract_artifact" ? { ...parameters, output_root: createArtifactExtractionDestination() } - : parameters; + : decodeWithoutDelivery(operation, parameters); }, - preflight: (operation, parameters) => - operation === "inspect_artifact" && parameters.detail === "summary" - ? requireAnalysisViewRetention(operation, retention) - : ok(null), - projectEvidence: (operation, parameters, evidence) => - operation === "inspect_artifact" && parameters.detail === "summary" - ? summarizeRetainedAnalysis(evidence, retention) - : ok(evidence), + parametersForEvidence: decodeWithoutDelivery, sourceEvidence: (operation, result) => operation === "inspect_artifact" ? artifactInspectionResultSchema diff --git a/src/server/registerManagedTools.ts b/src/server/registerManagedTools.ts index 5ff513d67..2f00d16ce 100644 --- a/src/server/registerManagedTools.ts +++ b/src/server/registerManagedTools.ts @@ -1,6 +1,4 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; -import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; -import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; import { requireAnalysisViewRetention, summarizeRetainedAnalysis, @@ -8,24 +6,17 @@ import { import type { AnalysisOperationPort } from "../application/AnalysisProvider.js"; import { MANAGED_TOOL_CONTRACTS } from "../contracts/managed/managedToolContracts.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { err, ok } from "../domain/result.js"; -import type { Logger } from "pino"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; +import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js"; import { runManagedProviderExecution } from "../composition/directAnalysis.js"; import { isManagedToolName } from "../contracts/managed/managedToolContracts.js"; /** Register execution-free managed PE/CLI inspection. */ export const registerManagedTools = ( server: EvidenceMcpServer, - options: { - readonly logger: Logger; - readonly activeTarget: (() => BinaryTarget | undefined) | undefined; - readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined; - readonly evidenceById?: EvidenceLookup | undefined; - readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis; - }, + options: EvidenceToolFamilyOptions, ): void => { let selectedManagedPath: string | undefined; const analysisForAdmission = ( diff --git a/src/server/registerNativeTools.ts b/src/server/registerNativeTools.ts index 036389d16..62668daa1 100644 --- a/src/server/registerNativeTools.ts +++ b/src/server/registerNativeTools.ts @@ -1,54 +1,25 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; -import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js"; -import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; -import { - requireAnalysisViewRetention, - summarizeRetainedAnalysis, -} from "../application/analysisView/AnalysisViewService.js"; -import type { JsonValue } from "../domain/jsonValue.js"; -import { ok } from "../domain/result.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; -import type { Logger } from "pino"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; +import { + retainedSummaryDelivery, + withoutDeliveryDetail, + type EvidenceToolFamilyOptions, +} from "./retainedSummaryDelivery.js"; -/** Delivery detail selects a response shape; it never reaches the tools or Evidence identity. */ -const withoutDelivery = ( - operation: string, - parameters: Readonly>, -): Readonly> => { - if (operation !== "inspect_macho") return parameters; - const { detail: _detail, ...selected } = parameters; - return selected; -}; +const SUMMARY_DELIVERY: ReadonlySet = new Set(["inspect_macho"]); /** Register macOS native contracts; Mach-O inspection can deliver a retained summary. */ export const registerNativeTools = ( server: EvidenceMcpServer, - options: { - readonly logger: Logger; - readonly activeTarget: (() => BinaryTarget | undefined) | undefined; - readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined; - readonly evidenceById?: EvidenceLookup | undefined; - readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis; - }, + options: EvidenceToolFamilyOptions, ): void => { - const retention = { - recordEvidence: options.recordEvidence, - evidenceById: options.evidenceById, - }; + const withoutDelivery = withoutDeliveryDetail(SUMMARY_DELIVERY); registerEvidenceTools(server, NATIVE_TOOL_CONTRACTS, { ...options, + ...retainedSummaryDelivery(SUMMARY_DELIVERY, options), parametersForExecution: withoutDelivery, parametersForEvidence: withoutDelivery, - preflight: (operation, parameters) => - operation === "inspect_macho" && parameters.detail === "summary" - ? requireAnalysisViewRetention(operation, retention) - : ok(null), - projectEvidence: (operation, parameters, evidence) => - operation === "inspect_macho" && parameters.detail === "summary" - ? summarizeRetainedAnalysis(evidence, retention) - : ok(evidence), }); }; diff --git a/src/server/retainedSummaryDelivery.ts b/src/server/retainedSummaryDelivery.ts new file mode 100644 index 000000000..0bc703a7c --- /dev/null +++ b/src/server/retainedSummaryDelivery.ts @@ -0,0 +1,62 @@ +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; +import { + requireAnalysisViewRetention, + summarizeRetainedAnalysis, +} from "../application/analysisView/AnalysisViewService.js"; +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import type { Evidence } from "../domain/evidence.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { ok } from "../domain/result.js"; +import type { Logger } from "pino"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; + +/** Registration inputs shared by provider-backed Evidence tool families. */ +export interface EvidenceToolFamilyOptions { + readonly logger: Logger; + readonly activeTarget: (() => BinaryTarget | undefined) | undefined; + readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined; + readonly evidenceById?: EvidenceLookup | undefined; + readonly withAdmittedAnalysis: WithAdmittedAnalysis; +} + +type Parameters = Readonly>; + +/** + * Summary delivery for the named operations: refuse it before analysis when + * this server retains no Evidence, then return the retained summary view. + */ +export const retainedSummaryDelivery = ( + operations: ReadonlySet, + options: Pick, +) => { + const retention = { + recordEvidence: options.recordEvidence, + evidenceById: options.evidenceById, + }; + const selected = (operation: string, parameters: Parameters): boolean => + operations.has(operation) && parameters.detail === "summary"; + return { + preflight: (operation: string, parameters: Parameters) => + selected(operation, parameters) + ? requireAnalysisViewRetention(operation, retention) + : ok(null), + projectEvidence: ( + operation: string, + parameters: Parameters, + evidence: Evidence, + ) => + selected(operation, parameters) + ? summarizeRetainedAnalysis(evidence, retention) + : ok(evidence), + }; +}; + +/** Delivery detail selects a response shape; it never reaches tools or Evidence identity. */ +export const withoutDeliveryDetail = + (operations: ReadonlySet) => + (operation: string, parameters: Parameters): Parameters => { + if (!operations.has(operation)) return parameters; + const { detail: _detail, ...selected } = parameters; + return selected; + }; diff --git a/tests/boundary/mcp/interfaceBuilderSummaryMcp.test.ts b/tests/boundary/mcp/interfaceBuilderSummaryMcp.test.ts new file mode 100644 index 000000000..2e59336af --- /dev/null +++ b/tests/boundary/mcp/interfaceBuilderSummaryMcp.test.ts @@ -0,0 +1,151 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; +import { z } from "zod"; + +import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js"; +import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; +import { openProductionTarget } from "../../support/mcp/productionSession.js"; + +const evidenceOf = (schema: Schema) => + z.object({ + evidence_id: z.string(), + parameters: z.record(z.string(), z.unknown()), + normalized_result: schema, + }); +const summarySchema = z.object({ + kind: z.literal("summary"), + parent_evidence_id: z.string(), + summary: z.object({ + documents: z.array(z.unknown()), + available_counts: z.object({ + nodes: z.number(), + edges: z.number(), + coverage: z.number(), + }), + }), +}); +const pageSchema = z.object({ + kind: z.literal("interface-builder"), + items: z.array(z.unknown()), + coverage: z.object({ next_offset: z.number().nullable() }), +}); + +/** A two-document app; each nib is a real rooted UIKit outlet archive. */ +const fixtureApp = async (): Promise => { + const app = join( + await createTestTempDirectory("rea-ib-summary-mcp-"), + "Fixture.app", + ); + const contents = join(app, "Contents"); + const resources = join(contents, "Resources"); + await mkdir(resources, { recursive: true }); + await mkdir(join(contents, "MacOS")); + await writeFile( + join(contents, "MacOS", "Fixture"), + thinMach(0xfeedfacf, 0x0100000c), + ); + await writeFile( + join(contents, "Info.plist"), + "CFBundleExecutableFixture", + ); + const nib = await readFile( + join(process.cwd(), "tests/fixtures/interface-builder/UIKitOutlet.xml"), + ); + await writeFile(join(resources, "Main.nib"), nib); + await writeFile(join(resources, "Second.nib"), nib); + return app; +}; + +mcpTest( + "retains complete Interface Builder Evidence and pages one document's rows", + async ({ mcp, onTestFinished }) => { + const app = await fixtureApp(); + const client = await openProductionTarget(mcp, onTestFinished, app); + + const complete = await client.callTool({ + name: "decode_interface_builder", + arguments: {}, + }); + expect(complete.isError, JSON.stringify(complete.content)).not.toBe(true); + const full = evidenceOf(interfaceBuilderAnalysisSchema).parse( + complete.structuredContent, + ); + expect(full.parameters).not.toHaveProperty("detail"); + const report = full.normalized_result; + expect(report.documents).toHaveLength(2); + + const summarized = await client.callTool({ + name: "decode_interface_builder", + arguments: { detail: "summary" }, + }); + expect(summarized.isError, JSON.stringify(summarized.content)).not.toBe( + true, + ); + const summary = evidenceOf(summarySchema).parse( + summarized.structuredContent, + ).normalized_result; + // Delivery detail never reaches the decoder or the source Evidence ID. + expect(summary.parent_evidence_id).toBe(full.evidence_id); + expect(summary.summary.documents).toEqual(report.documents); + expect(summary.summary.available_counts).toEqual({ + nodes: report.graph.nodes.length, + edges: report.graph.edges.length, + coverage: report.graph.coverage.length, + }); + + const source = { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }; + const readAll = async (view: Record) => { + const rows: unknown[] = []; + let offset: number | null = 0; + while (offset !== null) { + const paged = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source, + view: { kind: "interface-builder", ...view, offset, limit: 2 }, + }, + }); + expect(paged.isError, JSON.stringify(paged.content)).not.toBe(true); + const page = evidenceOf(pageSchema).parse( + paged.structuredContent, + ).normalized_result; + rows.push(...page.items); + offset = page.coverage.next_offset; + } + return rows; + }; + expect(await readAll({ collection: "edges" })).toEqual(report.graph.edges); + const main = "Contents/Resources/Main.nib"; + const mainNodes = report.graph.nodes.filter(({ evidence }) => + evidence.some(({ artifact_path }) => artifact_path === main), + ); + expect(mainNodes.length).toBeGreaterThan(0); + expect(mainNodes.length).toBeLessThan(report.graph.nodes.length); + expect(await readAll({ collection: "nodes", document: main })).toEqual( + mainNodes, + ); + + const unknownDocument = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source, + view: { + kind: "interface-builder", + collection: "nodes", + document: "Contents/Resources/Missing.nib", + }, + }, + }); + expect(parseMcpToolError(unknownDocument)).toMatchObject({ + error: { code: "invalid_request" }, + }); + }, +); diff --git a/tests/boundary/mcp/keyedArchiveNumberMetadataMcp.test.ts b/tests/boundary/mcp/keyedArchiveNumberMetadataMcp.test.ts index 4a40a3064..485c058dd 100644 --- a/tests/boundary/mcp/keyedArchiveNumberMetadataMcp.test.ts +++ b/tests/boundary/mcp/keyedArchiveNumberMetadataMcp.test.ts @@ -1,40 +1,24 @@ -import { silentLogger } from "../../../src/logger.js"; import { expect } from "vitest"; import { z } from "zod"; -import { createBinarySession } from "../../../src/composition/binary.js"; -import { parseConfig } from "../../../src/config/parseConfig.js"; import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js"; -import { createServer } from "../../../src/server/createServer.js"; import { archiveNumberMetadataCases, expectKeyedArchiveNumberMetadata, keyedArchiveNumberMetadataFixture, } from "../../fixtures/keyedArchiveNumberMetadata.js"; import { mcpTest } from "../../support/mcp/mcpFixture.js"; +import { openProductionTarget } from "../../support/mcp/productionSession.js"; mcpTest.for(archiveNumberMetadataCases)( "MCP preserves numeric meaning for $name", async (item, { mcp, onTestFinished }) => { const fixture = await keyedArchiveNumberMetadataFixture(item); - const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" }); - if (!configured.ok) throw configured.error; - const session = createBinarySession( - configured.value, - silentLogger, - process.env, + const client = await openProductionTarget( + mcp, + onTestFinished, + fixture.path, ); - onTestFinished(async () => { - await session.close(); - }); - const client = await mcp.connect( - createServer({ kind: "session", session }), - ); - const opened = await client.callTool({ - name: "open_binary", - arguments: { path: fixture.path }, - }); - expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true); const result = await client.callTool({ name: "inspect_keyed_archive", arguments: {}, diff --git a/tests/boundary/mcp/keyedArchivePrototypeKeyMcp.test.ts b/tests/boundary/mcp/keyedArchivePrototypeKeyMcp.test.ts index b7803f0bd..17d536003 100644 --- a/tests/boundary/mcp/keyedArchivePrototypeKeyMcp.test.ts +++ b/tests/boundary/mcp/keyedArchivePrototypeKeyMcp.test.ts @@ -1,39 +1,23 @@ import { expect } from "vitest"; import { z } from "zod"; -import { createBinarySession } from "../../../src/composition/binary.js"; -import { parseConfig } from "../../../src/config/parseConfig.js"; import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js"; -import { silentLogger } from "../../../src/logger.js"; -import { createServer } from "../../../src/server/createServer.js"; import { expectKeyedArchivePrototypeKeys, keyedArchivePrototypeKeyFixture, } from "../../fixtures/keyedArchivePrototypeKeys.js"; import { mcpTest } from "../../support/mcp/mcpFixture.js"; +import { openProductionTarget } from "../../support/mcp/productionSession.js"; mcpTest( "MCP retains ordinary evidence with formatted XML prototype keys", async ({ mcp, onTestFinished }) => { const fixture = await keyedArchivePrototypeKeyFixture(); - const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" }); - if (!configured.ok) throw configured.error; - const session = createBinarySession( - configured.value, - silentLogger, - process.env, + const client = await openProductionTarget( + mcp, + onTestFinished, + fixture.path, ); - onTestFinished(async () => { - await session.close(); - }); - const client = await mcp.connect( - createServer({ kind: "session", session }), - ); - const opened = await client.callTool({ - name: "open_binary", - arguments: { path: fixture.path }, - }); - expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true); const result = await client.callTool({ name: "inspect_keyed_archive", arguments: {}, diff --git a/tests/boundary/mcp/nativeDemangleMcp.test.ts b/tests/boundary/mcp/nativeDemangleMcp.test.ts index 71f7732a5..8e66a27fa 100644 --- a/tests/boundary/mcp/nativeDemangleMcp.test.ts +++ b/tests/boundary/mcp/nativeDemangleMcp.test.ts @@ -1,15 +1,12 @@ -import { silentLogger } from "../../../src/logger.js"; import { copyFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect } from "vitest"; import { z } from "zod"; -import { createBinarySession } from "../../../src/composition/binary.js"; -import { parseConfig } from "../../../src/config/parseConfig.js"; -import { createServer } from "../../../src/server/createServer.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { mcpTest } from "../../support/mcp/mcpFixture.js"; +import { openProductionTarget } from "../../support/mcp/productionSession.js"; const demangledSchema = z.object({ normalized_result: z.object({ @@ -30,24 +27,7 @@ describe.skipIf(process.platform !== "darwin")( "tool", ); await copyFile("/usr/bin/true", target); - const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" }); - if (!configured.ok) throw configured.error; - const session = createBinarySession( - configured.value, - silentLogger, - process.env, - ); - onTestFinished(async () => { - await session.close(); - }); - const client = await mcp.connect( - createServer({ kind: "session", session }), - ); - const opened = await client.callTool({ - name: "open_binary", - arguments: { path: target }, - }); - expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true); + const client = await openProductionTarget(mcp, onTestFinished, target); const called = await client.callTool({ name: "demangle_swift", diff --git a/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts b/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts index e492263fa..1a2f29902 100644 --- a/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts +++ b/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts @@ -1,12 +1,9 @@ -import { silentLogger } from "../../../src/logger.js"; import { copyFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect } from "vitest"; import { z } from "zod"; -import { createBinarySession } from "../../../src/composition/binary.js"; -import { parseConfig } from "../../../src/config/parseConfig.js"; import { inspectMachoSchema } from "../../../src/domain/native/nativeInspection.js"; import { createServer } from "../../../src/server/createServer.js"; import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; @@ -16,6 +13,7 @@ import { ok } from "../../../src/domain/result.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { mcpTest } from "../../support/mcp/mcpFixture.js"; +import { openProductionTarget } from "../../support/mcp/productionSession.js"; const evidenceOf = (schema: Schema) => z.object({ @@ -54,24 +52,7 @@ describe.skipIf(process.platform !== "darwin")( "ls", ); await copyFile("/bin/ls", target); - const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" }); - if (!configured.ok) throw configured.error; - const session = createBinarySession( - configured.value, - silentLogger, - process.env, - ); - onTestFinished(async () => { - await session.close(); - }); - const client = await mcp.connect( - createServer({ kind: "session", session }), - ); - const opened = await client.callTool({ - name: "open_binary", - arguments: { path: target }, - }); - expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true); + const client = await openProductionTarget(mcp, onTestFinished, target); const complete = await client.callTool({ name: "inspect_macho", diff --git a/tests/support/mcp/productionSession.ts b/tests/support/mcp/productionSession.ts new file mode 100644 index 000000000..38ce87e09 --- /dev/null +++ b/tests/support/mcp/productionSession.ts @@ -0,0 +1,33 @@ +import type { Client } from "@modelcontextprotocol/client"; +import { expect, type OnTestFinishedHandler } from "vitest"; + +import { createBinarySession } from "../../../src/composition/binary.js"; +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { silentLogger } from "../../../src/logger.js"; +import { createServer } from "../../../src/server/createServer.js"; +import type { TestMcp } from "./mcpFixture.js"; + +/** Open one real target in a production session served over MCP. */ +export const openProductionTarget = async ( + mcp: TestMcp, + onTestFinished: (handler: OnTestFinishedHandler) => void, + path: string, +): Promise => { + const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" }); + if (!configured.ok) throw configured.error; + const session = createBinarySession( + configured.value, + silentLogger, + process.env, + ); + onTestFinished(async () => { + await session.close(); + }); + const client = await mcp.connect(createServer({ kind: "session", session })); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path }, + }); + expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true); + return client; +};