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 58c1671b6..71fe3e950 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -83,6 +83,14 @@ names, the native API record, and value-flow summaries. Pseudocode offsets and l code units; use the returned `next_offset` for continuation. Provider limitations and unavailable facts remain explicit; the complete parent Evidence is retained. +`inspect_macho` on a large framework returns megabytes of imports and exports. +Select `detail: "summary"` for slice identity, UUID and per-collection counts, +then page rows with `view: {kind: "macho", facet: "exports", offset: 0, limit: 64}` +and `normalized_result.parent_evidence_id`. Facets are entrypoints, +architectures, build_metadata, load_commands, dependencies, imports, exports, +segments and provenance. `source_exhaustive: false` means unreturned symbols +may exist. + 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 c4169dd73..f3f7892f6 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -147,8 +147,8 @@ after target content was read. ## Retained application Evidence inputs -`analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`, and -`inspect_wasm_artifact` accept `detail`: +`analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`, +`inspect_wasm_artifact`, and `inspect_macho` 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 @@ -218,6 +218,19 @@ attempting oversized delivery, and returns the same summary with an exact `parent_evidence_id` for later export or facet reads. Delivery detail does not reach WABT or change the original source Evidence ID. +Completed `inspect_macho` Evidence supports a summary and +`view: {kind: "macho", facet: "exports", offset: 0, limit: 64}`. Facets are +`entrypoints`, `architectures`, `build_metadata`, `load_commands`, +`dependencies`, `imports`, `exports`, `segments`, and `provenance`; pages return +the original rows. The summary keeps the selected slice, header fields and UUID, +and replaces each collection with its returned count, reported total and +`exhaustive` flag. Each page repeats the paged collection's `source_total` and +`source_exhaustive`; exhausting a non-exhaustive collection does not establish +that unreturned symbols are absent. Large frameworks return megabytes of +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. + `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/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index aba5b9a93..7d023752d 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 and web network Evidence. */ +/** Selected views of completed layout, JavaScript, native, inventory, WASM, Mach-O 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_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, 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. 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_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.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/native/nativeToolContracts.ts b/src/contracts/native/nativeToolContracts.ts index 093add61e..16101368b 100644 --- a/src/contracts/native/nativeToolContracts.ts +++ b/src/contracts/native/nativeToolContracts.ts @@ -11,6 +11,7 @@ import { jsonValueSchema } from "../../domain/jsonValue.js"; import { localPathStringSchema } from "../../domain/localPath.js"; import { toolContractMetadata } from "../toolEffects.js"; import { requireOutputSchema } from "../toolOutputSchemaPrimitives.js"; +import { analysisDetailSchema } from "../analysisDetail.js"; const examples: Readonly>>> = { inspect_macho: {}, @@ -94,8 +95,15 @@ export const NATIVE_TOOL_CONTRACTS = [ ), native( "inspect_macho", - "Inspect Mach-O slices, load commands, imports, exports, dependencies, build metadata, segments, sections, permissions, and exact command provenance without launching Hopper.", - z.object({}), + "Inspect Mach-O slices, load commands, imports, exports, dependencies, build metadata, segments, sections, permissions, and exact command provenance without launching Hopper. Large frameworks return megabytes of symbols; detail summary retains the complete Evidence and returns slice identity with per-collection counts, then page rows with inspect_analysis_view kind macho.", + z.object({ + detail: analysisDetailSchema + .unwrap() + .optional() + .describe( + "Omitted or complete returns the complete inspection Evidence. summary retains that complete Evidence in this session and returns its inspect_analysis_view summary; use normalized_result.parent_evidence_id for macho pages.", + ), + }), ), native( "inspect_signature", diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index e828486ba..174339bdf 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { artifactInventorySummaryViewResultSchema, + machoSummaryViewResultSchema, managedArtifactSummaryViewResultSchema, } from "../domain/analysisView/analysisView.js"; import { evidenceBundleSummarySchema } from "../domain/evidenceRecordSummary.js"; @@ -381,7 +382,9 @@ export const nativeOutputSchemas: Readonly> = { observe_native_ui: evidenceResultOf(nativeUiResultSchema), capture_native_ui_scenario: evidenceResultOf(nativeUiResultSchema), observe_native_calls: evidenceResultOf(nativeCallObservationResultSchema), - inspect_macho: evidenceResultOf(inspectMachoSchema), + inspect_macho: evidenceResultOf( + z.union([inspectMachoSchema, machoSummaryViewResultSchema]), + ), inspect_signature: evidenceResultOf(inspectSignatureSchema), inspect_plist: evidenceResultOf(inspectPlistSchema), list_architectures: evidenceResultOf(listArchitecturesSchema), diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 6cc5ae8a3..7f985f349 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -55,6 +55,13 @@ import { wasmViewItemSchema, wasmSummarySchema, } from "./wasmArtifactView.js"; +import { + machoSummarySchema, + machoViewContextSchema, + machoViewItemSchema, + machoViewRequestSchema, + projectMachoView, +} from "./machoView.js"; import { projectWebNetworkCaptureView, captureViewRequestSchema, @@ -196,6 +203,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ nativeViewSchema, inventoryViewSchema, wasmViewRequestSchema, + machoViewRequestSchema, evmViewRequestSchema, managedViewRequestSchema, androidViewRequestSchema, @@ -315,6 +323,7 @@ const viewResultBase = { "inspect_artifact", "inventory_artifact", "inspect_wasm_artifact", + "inspect_macho", "inspect_evm_interface", "inspect_managed_artifact", "inspect_web_network_capture", @@ -364,6 +373,13 @@ export const wasmArtifactSummaryViewResultSchema = z.strictObject({ summary: wasmSummarySchema, }); +/** Exact slice identity and collection counts for Mach-O's selected delivery. */ +export const machoSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + parent_operation: z.literal("inspect_macho"), + summary: machoSummarySchema, +}); + /** Exact inferred-interface summary for EVM's caller-selected delivery. */ export const evmInterfaceSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, @@ -425,6 +441,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ javascriptSummarySchema, inventorySummarySchema, wasmSummarySchema, + machoSummarySchema, evmSummarySchema, managedSummarySchema, captureSummarySchema, @@ -487,6 +504,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ item: wasmViewItemSchema, ...viewResultBase, }), + z.strictObject({ + kind: z.literal("macho"), + view: machoViewRequestSchema, + item: machoViewItemSchema, + ...viewResultBase, + ...machoViewContextSchema.shape, + parent_operation: z.literal("inspect_macho"), + }), z.strictObject({ kind: z.literal("evm"), view: evmViewRequestSchema, @@ -583,6 +608,7 @@ export type UnsignedAnalysisView = "view_digest" > | Omit, "view_digest"> + | Omit, "view_digest"> | Omit, "view_digest"> | Omit, "view_digest"> | Omit, "view_digest"> @@ -646,7 +672,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, 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, Android package, Dart AOT, or web network capture Evidence.`, ); const parentArtifactPath = (parent: AnalysisViewParent): string => { @@ -673,6 +699,7 @@ const parentArtifactPath = (parent: AnalysisViewParent): string => { /** Named source owners for views that need no extra decoder or accumulator. */ const retainedViewProjectors = new Map([ ["inspect_wasm_artifact", projectWasmArtifactView], + ["inspect_macho", projectMachoView], ["inspect_evm_interface", projectEvmInterfaceView], ["inspect_managed_artifact", projectManagedArtifactView], ["inspect_android_package", projectAndroidPackageView], diff --git a/src/domain/analysisView/machoView.test.ts b/src/domain/analysisView/machoView.test.ts new file mode 100644 index 000000000..2018438be --- /dev/null +++ b/src/domain/analysisView/machoView.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from "vitest"; + +import { + projectAnalysisView, + type AnalysisViewParent, +} from "./analysisView.js"; + +const symbol = (name: string) => ({ + name, + address: null, + weak: null, + reexport: null, + source: "libSystem", +}); +const coverage = (items: T[], exhaustive = true) => ({ + items, + total: exhaustive ? items.length : null, + exhaustive, + limitations: exhaustive ? [] : ["nm output may omit stripped symbols."], +}); +const report = { + format: "mach-o", + selected_architecture: { name: "arm64", container_file_offset: 0 }, + endian: null, + word_size: 64, + file_type: "2", + flags: ["0x00200085"], + uuid: "4C4C44E9-5555-3144-A1E9-D858E24A1CCE", + entrypoints: coverage([{ file_offset: 16384 }]), + architectures: coverage([ + { + name: "arm64", + cpu_type: "16777228", + cpu_subtype: "0", + file_offset: 0, + size: 4096, + alignment: 14, + }, + ]), + build_metadata: coverage([ + { platform: "1", minimum_os: "14.0", sdk: "15.0", tools: [] }, + ]), + load_commands: coverage([{ index: 0, kind: "LC_MAIN", fields: {} }]), + dependencies: coverage([ + { + path: "/usr/lib/libSystem.B.dylib", + kind: "LC_LOAD_DYLIB", + current_version: "1351.0.0", + compatibility_version: "1.0.0", + }, + ]), + imports: coverage( + ["_malloc", "_free", "_open"].map((name) => symbol(name)), + false, + ), + exports: coverage([symbol("__mh_execute_header")]), + segments: coverage([ + { + name: "__TEXT", + vm_address: "0x100000000", + vm_size: 16384, + file_offset: 0, + file_size: 16384, + maximum_permissions: { + read: true, + write: false, + execute: true, + raw: "5", + }, + initial_permissions: { + read: true, + write: false, + execute: true, + raw: "5", + }, + sections: coverage([]), + }, + ]), + provenance: [ + { + tool: "nm", + command: ["/usr/bin/nm", "$ARTIFACT"], + tool_version: null, + version_reason: "fixture", + executable_sha256: "a".repeat(64), + exit: { code: 0, signal: null }, + stdout_bytes: 10, + stderr_bytes: 0, + }, + ], + limitations: ["Imports and exports combine dyld_info and nm."], +}; +const parent = ( + normalizedResult: unknown = report, + subject: AnalysisViewParent["subject"] = { + name: "tool", + digest: { sha256: "b".repeat(64) }, + format: "mach-o", + architecture: "arm64", + local_path: "/bin/tool", + }, +): AnalysisViewParent => ({ + evidenceId: `ev_${"c".repeat(64)}`, + operation: "inspect_macho", + normalizedResult, + subject, + limitations: ["Parent limitation."], +}); + +describe("Mach-O analysis views", () => { + it("summarizes slice identity and collection counts without rows", () => { + const projected = projectAnalysisView(parent(), { kind: "summary" }); + if (!projected.ok) throw projected.error; + expect(projected.value).toMatchObject({ + kind: "summary", + parent_operation: "inspect_macho", + artifact: { path: "/bin/tool", sha256: "b".repeat(64) }, + summary: { + selected_architecture: { name: "arm64", container_file_offset: 0 }, + uuid: report.uuid, + collections: { + imports: { returned: 3, total: null, exhaustive: false }, + exports: { returned: 1, total: 1, exhaustive: true }, + segments: { returned: 1, total: 1, exhaustive: true }, + }, + provenance_count: 1, + limitation_count: 3, + }, + coverage: { status: "complete-within-view", examined: 10, total: 10 }, + limitations: [ + "Imports and exports combine dyld_info and nm.", + "Parent limitation.", + "nm output may omit stripped symbols.", + ], + unknowns: [expect.stringContaining("exhaustive imports;")], + }); + expect(JSON.stringify(projected.value)).not.toContain("_malloc"); + }); + + it("pages original rows with the producer's own coverage", () => { + const first = projectAnalysisView(parent(), { + kind: "macho", + facet: "imports", + offset: 1, + limit: 1, + }); + if (!first.ok) throw first.error; + expect(first.value).toMatchObject({ + kind: "macho", + item: [symbol("_free")], + source_total: null, + source_exhaustive: false, + coverage: { status: "page", examined: 1, total: 3, next_offset: 2 }, + }); + expect(first.value.limitations).toContain( + "nm output may omit stripped symbols.", + ); + // Rows whose keys include an earlier row shape's keys stay whole. + for (const facet of ["architectures", "segments"] as const) { + const page = projectAnalysisView(parent(), { + kind: "macho", + facet, + offset: 0, + limit: 64, + }); + if (!page.ok) throw page.error; + expect(page.value.kind === "macho" && page.value.item).toEqual( + report[facet].items, + ); + } + const provenance = projectAnalysisView(parent(), { + kind: "macho", + facet: "provenance", + offset: 0, + limit: 64, + }); + if (!provenance.ok) throw provenance.error; + expect(provenance.value).toMatchObject({ + item: report.provenance, + source_total: 1, + source_exhaustive: true, + coverage: { exhausted: true, next_offset: null }, + }); + const past = projectAnalysisView(parent(), { + kind: "macho", + facet: "exports", + offset: 5, + limit: 64, + }); + if (!past.ok) throw past.error; + expect(past.value.coverage).toMatchObject({ + status: "empty", + exhausted: true, + }); + }); + + it("keeps a missing subject unknown instead of inventing identity", () => { + const projected = projectAnalysisView(parent(report, null), { + kind: "summary", + }); + if (!projected.ok) throw projected.error; + expect(projected.value.artifact).toEqual({ path: "", sha256: null }); + expect(projected.value.unknowns).toContain( + "The parent has no recorded subject, so the inspected artifact path and SHA-256 are unknown.", + ); + }); + + it("rejects other view kinds and malformed retained results", () => { + const wrongKind = projectAnalysisView(parent(), { + kind: "wasm", + facet: "exports", + offset: 0, + limit: 64, + }); + if (wrongKind.ok) throw new Error("expected a view-kind refusal"); + expect(wrongKind.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: [ + { + path: ["view"], + message: "Mach-O inspection supports summary and macho views.", + }, + ], + }); + const contradictory = projectAnalysisView( + parent(report, { + name: "tool", + digest: { sha256: "b".repeat(64) }, + format: "elf", + architecture: null, + local_path: "/bin/tool", + }), + { kind: "summary" }, + ); + if (contradictory.ok) throw new Error("expected a subject refusal"); + expect(contradictory.error).toMatchObject({ + issues: [{ path: ["source", "subject"] }], + }); + const malformed = projectAnalysisView( + parent({ ...report, imports: { items: "x" } }), + { kind: "summary" }, + ); + expect(malformed.ok).toBe(false); + if (!malformed.ok) expect(malformed.error._tag).toBe("AnalysisInputError"); + }); +}); diff --git a/src/domain/analysisView/machoView.ts b/src/domain/analysisView/machoView.ts new file mode 100644 index 000000000..1a64d23aa --- /dev/null +++ b/src/domain/analysisView/machoView.ts @@ -0,0 +1,247 @@ +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 { + analysisViewInputError, + type AnalysisViewParent, + type AnalysisViewRequest, + type UnsignedAnalysisView, +} from "./analysisView.js"; +import { + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; + +const MACHO_COLLECTIONS = [ + "entrypoints", + "architectures", + "build_metadata", + "load_commands", + "dependencies", + "imports", + "exports", + "segments", +] as const; +type MachoCollection = (typeof MACHO_COLLECTIONS)[number]; + +/** Original inspect_macho rows; offsets count rows in the retained collection. */ +export const machoViewRequestSchema = z.strictObject({ + kind: z.literal("macho"), + facet: z.enum([...MACHO_COLLECTIONS, "provenance"]), + offset: z.number().int().nonnegative().default(0), + limit: z.number().int().positive().default(64), +}); + +const { shape } = inspectMachoSchema; +/** + * Union members are tried in order and plain objects drop unknown keys, so a + * segment or architecture row would otherwise match the entrypoint row and + * lose every field but file_offset. Exact key sets keep each page's rows whole. + */ +const exactRows = ( + rows: z.ZodArray>, +) => z.array(z.strictObject(rows.element.shape)); +export const machoViewItemSchema = z.union([ + exactRows(shape.entrypoints.shape.items), + exactRows(shape.architectures.shape.items), + exactRows(shape.build_metadata.shape.items), + exactRows(shape.load_commands.shape.items), + exactRows(shape.dependencies.shape.items), + // Imports and exports share one row shape. + exactRows(shape.imports.shape.items), + exactRows(shape.segments.shape.items), + exactRows(shape.provenance), +]); + +/** The producer's own coverage of the paged collection, not of this page. */ +export const machoViewContextSchema = z.strictObject({ + source_total: z.number().int().nonnegative().nullable(), + source_exhaustive: z.boolean(), +}); + +const collectionCountSchema = z.strictObject({ + returned: z.number().int().nonnegative(), + total: z.number().int().nonnegative().nullable(), + exhaustive: z.boolean(), +}); + +/** Slice identity and per-collection counts without the collections themselves. */ +export const machoSummarySchema = z.strictObject({ + format: shape.format, + selected_architecture: shape.selected_architecture, + endian: shape.endian, + word_size: shape.word_size, + file_type: shape.file_type, + flags: shape.flags, + uuid: shape.uuid, + collections: z.strictObject( + Object.fromEntries( + MACHO_COLLECTIONS.map((name) => [name, collectionCountSchema]), + ) as Record, + ), + provenance_count: z.number().int().nonnegative(), + limitation_count: z.number().int().nonnegative(), +}); + +type MachoReport = z.output; + +/** Admit a well-formed report whose recorded subject, if any, is a Mach-O. */ +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 }, + ), + ); + if (parent.subject != null && !isMachOFormat(parent.subject.format)) + return err( + analysisViewInputError([ + { + path: ["source", "subject"], + reason: "invalid_value", + message: + "Mach-O parent Evidence subject must identify a thin or universal Mach-O.", + }, + ]), + ); + return ok(parsed.data); +}; + +/** Counts replace rows; every collection caveat stays in the limitations. */ +const summarizeMacho = ( + report: MachoReport, + parentLimitations: readonly string[], +) => { + const limitations = uniqueSorted([ + ...parentLimitations, + ...report.limitations, + ...MACHO_COLLECTIONS.flatMap((name) => report[name].limitations), + ]); + const collections = Object.fromEntries( + MACHO_COLLECTIONS.map((name) => [ + name, + { + returned: report[name].items.length, + total: report[name].total, + exhaustive: report[name].exhaustive, + }, + ]), + ) as Record>; + return { + limitations, + returned: MACHO_COLLECTIONS.reduce( + (sum, name) => sum + report[name].items.length, + 0, + ), + summary: { + format: report.format, + selected_architecture: report.selected_architecture, + endian: report.endian, + word_size: report.word_size, + file_type: report.file_type, + flags: report.flags, + uuid: report.uuid, + collections, + provenance_count: report.provenance.length, + limitation_count: limitations.length, + }, + }; +}; + +/** Project retained inspect_macho Evidence without rerunning native tools. */ +export const projectMachoView = ( + parent: AnalysisViewParent, + view: AnalysisViewRequest, +): Result => { + if (view.kind !== "summary" && view.kind !== "macho") + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: "Mach-O inspection supports summary and macho views.", + }, + ]), + ); + const parsed = parseMachoReport(parent); + if (!parsed.ok) return parsed; + const report = parsed.value; + const incomplete = MACHO_COLLECTIONS.filter( + (name) => !report[name].exhaustive, + ); + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: "inspect_macho" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: + parent.subject == null + ? { path: "", sha256: null } + : { + path: parent.subject.local_path, + sha256: parent.subject.digest.sha256, + }, + unknowns: [ + ...(incomplete.length === 0 + ? [] + : [ + `Native tools did not report exhaustive ${incomplete.join(", ")}; page exhaustion does not establish absence of unreturned rows.`, + ]), + ...(parent.subject == null + ? [ + "The parent has no recorded subject, so the inspected artifact path and SHA-256 are unknown.", + ] + : []), + ], + }; + if (view.kind === "summary") { + const { summary, limitations, returned } = summarizeMacho( + report, + parent.limitations, + ); + return ok({ + kind: "summary", + view, + ...shared, + limitations, + summary, + coverage: completeWithinViewCoverage(returned, returned), + }); + } + const source = + view.facet === "provenance" + ? { + items: report.provenance, + total: report.provenance.length, + exhaustive: true, + limitations: [], + } + : report[view.facet]; + const item = source.items.slice(view.offset, view.offset + view.limit); + return ok({ + kind: "macho", + view, + item, + ...shared, + limitations: uniqueSorted([ + ...parent.limitations, + ...report.limitations, + ...source.limitations, + ]), + source_total: source.total, + source_exhaustive: source.exhaustive, + coverage: pageViewCoverage(view.offset, item.length, source.items.length), + }); +}; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 294984339..777028cf8 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -54,6 +54,7 @@ import { silentLogger } from "../logger.js"; import type { Logger } from "pino"; import { registerApplicationTools } from "./registerApplicationTools.js"; import { registerArtifactTools } from "./registerArtifactTools.js"; +import { registerNativeTools } from "./registerNativeTools.js"; import { registerBrowserScenarioTool } from "./registerBrowserScenarioTool.js"; import { registerBrowserTools } from "./registerBrowserTools.js"; import { registerWebScriptTool } from "./registerWebScriptTool.js"; @@ -107,8 +108,6 @@ import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysis import { AdbDeviceAnalysisService } from "../application/adb/AdbDeviceAnalysisService.js"; import { createAdbDeviceAnalysisProvider } from "../composition/adb.js"; import { registerManagedWorkflowTools } from "./registerManagedWorkflowTools.js"; -import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js"; -import { registerEvidenceTools } from "./registerEvidenceTools.js"; import { registerOfficialTools } from "./registerOfficialTools.js"; import { registerGuidedPrompts } from "./registerPrompts.js"; import { registerSessionTools } from "./registerSessionTools.js"; @@ -725,7 +724,7 @@ const registerBinaryAnalysisTools = ({ recordEvidence, withAdmittedAnalysis, }; - registerEvidenceTools(server, NATIVE_TOOL_CONTRACTS, evidenceOptions); + registerNativeTools(server, { ...evidenceOptions, evidenceById }); registerArtifactTools(server, { ...evidenceOptions, evidenceById }); registerManagedTools(server, { ...evidenceOptions, evidenceById }); if (session !== undefined) diff --git a/src/server/registerArtifactTools.ts b/src/server/registerArtifactTools.ts index cf95fea41..1217664e6 100644 --- a/src/server/registerArtifactTools.ts +++ b/src/server/registerArtifactTools.ts @@ -6,7 +6,10 @@ import { artifactInspectionInputSchema, } from "../contracts/artifactToolContracts.js"; import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; -import { summarizeRetainedAnalysis } from "../application/analysisView/AnalysisViewService.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"; @@ -25,6 +28,10 @@ export const registerArtifactTools = ( readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis; }, ): void => { + const retention = { + recordEvidence: options.recordEvidence, + evidenceById: options.evidenceById, + }; registerEvidenceTools(server, ARTIFACT_TOOL_CONTRACTS, { ...options, parametersForExecution: (operation, parameters) => { @@ -37,12 +44,13 @@ export const registerArtifactTools = ( ? { ...parameters, output_root: createArtifactExtractionDestination() } : 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, { - recordEvidence: options.recordEvidence, - evidenceById: options.evidenceById, - }) + ? summarizeRetainedAnalysis(evidence, retention) : ok(evidence), sourceEvidence: (operation, result) => operation === "inspect_artifact" diff --git a/src/server/registerEvidenceTools.ts b/src/server/registerEvidenceTools.ts index a8efac6de..b3b2358d1 100644 --- a/src/server/registerEvidenceTools.ts +++ b/src/server/registerEvidenceTools.ts @@ -33,6 +33,16 @@ interface EvidenceToolRegistration { operation: Exclude, parameters: Readonly>, ) => Readonly>; + /** Exclude adapter-only selections, such as delivery detail, from Evidence identity. */ + readonly parametersForEvidence?: ( + operation: Exclude, + parameters: Readonly>, + ) => Readonly>; + /** Refuse an unsatisfiable delivery selection before any analysis runs. */ + readonly preflight?: ( + operation: Exclude, + parameters: Readonly>, + ) => Result; readonly projectEvidence?: ( operation: Exclude, parameters: Readonly>, @@ -51,6 +61,12 @@ export const registerEvidenceTools = ( contract.name, toolRegistrationOptions(contract), async (input, context) => { + const ready = options.preflight?.( + contract.name, + jsonObjectSchema.parse(input), + ); + if (ready !== undefined && !ready.ok) + return server.delivery.toCallToolResult(ready, contract); const admitted = await options.withAdmittedAnalysis( contract.name, context.mcpReq.signal, @@ -95,7 +111,9 @@ export const registerEvidenceTools = ( execution.value.provider, { operation: contract.name, - parameters, + parameters: + options.parametersForEvidence?.(contract.name, parameters) ?? + parameters, result: execution.value.result, ...(execution.value.analysisProfile === undefined ? {} diff --git a/src/server/registerNativeTools.ts b/src/server/registerNativeTools.ts new file mode 100644 index 000000000..036389d16 --- /dev/null +++ b/src/server/registerNativeTools.ts @@ -0,0 +1,54 @@ +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"; + +/** 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; +}; + +/** 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; + }, +): void => { + const retention = { + recordEvidence: options.recordEvidence, + evidenceById: options.evidenceById, + }; + registerEvidenceTools(server, NATIVE_TOOL_CONTRACTS, { + ...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/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts b/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts new file mode 100644 index 000000000..e492263fa --- /dev/null +++ b/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts @@ -0,0 +1,216 @@ +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"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { observed } from "../../fixtures/analysisExecution.js"; +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"; + +const evidenceOf = (schema: Schema) => + z.object({ + evidence_id: z.string(), + parameters: z.record(z.string(), z.unknown()), + normalized_result: schema, + }); +const countSchema = z.object({ + returned: z.number(), + total: z.number().nullable(), + exhaustive: z.boolean(), +}); +const summarySchema = z.object({ + kind: z.literal("summary"), + parent_evidence_id: z.string(), + summary: z.object({ + uuid: z.string().nullable(), + collections: z.record(z.string(), countSchema), + provenance_count: z.number(), + }), +}); +const pageSchema = z.object({ + kind: z.literal("macho"), + item: z.array(z.unknown()), + coverage: z.object({ next_offset: z.number().nullable() }), +}); + +describe.skipIf(process.platform !== "darwin")( + "Mach-O summary delivery through MCP", + () => { + mcpTest( + "retains complete inspect_macho Evidence and pages its exact rows", + async ({ mcp, onTestFinished }) => { + const target = join( + await createTestTempDirectory("rea-macho-summary-mcp-"), + "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 complete = await client.callTool({ + name: "inspect_macho", + arguments: {}, + }); + expect(complete.isError, JSON.stringify(complete.content)).not.toBe( + true, + ); + const full = evidenceOf(inspectMachoSchema).parse( + complete.structuredContent, + ); + // Omitted detail keeps the default Evidence parameters unchanged. + expect(full.parameters).toEqual({}); + + const summarized = await client.callTool({ + name: "inspect_macho", + 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 tools or the source Evidence ID. + expect(summary.parent_evidence_id).toBe(full.evidence_id); + const report = full.normalized_result; + expect(summary.summary.uuid).toBe(report.uuid); + expect(summary.summary.provenance_count).toBe(report.provenance.length); + for (const name of ["imports", "exports", "dependencies"] as const) + expect(summary.summary.collections[name]).toEqual({ + returned: report[name].items.length, + total: report[name].total, + exhaustive: report[name].exhaustive, + }); + expect(JSON.stringify(summarized.structuredContent)).not.toContain( + report.imports.items[0]?.name ?? "_", + ); + + // Every facet must reassemble the unparsed complete rows exactly. + const raw = z + .object({ normalized_result: z.record(z.string(), z.unknown()) }) + .parse(complete.structuredContent).normalized_result; + const facets = [ + "entrypoints", + "architectures", + "build_metadata", + "load_commands", + "dependencies", + "imports", + "exports", + "segments", + "provenance", + ] as const; + for (const facet of facets) { + const rows: unknown[] = []; + let offset: number | null = 0; + while (offset !== null) { + const paged = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }, + view: { kind: "macho", facet, offset, limit: 16 }, + }, + }); + expect(paged.isError, JSON.stringify(paged.content)).not.toBe(true); + const page = evidenceOf(pageSchema).parse( + paged.structuredContent, + ).normalized_result; + rows.push(...page.item); + offset = page.coverage.next_offset; + } + expect(rows, facet).toEqual( + facet === "provenance" + ? raw.provenance + : z.object({ items: z.array(z.unknown()) }).parse(raw[facet]) + .items, + ); + } + + const wrongKind = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }, + view: { kind: "wasm", facet: "exports" }, + }, + }); + expect(wrongKind.isError).toBe(true); + expect(JSON.stringify(wrongKind.content)).toContain( + "Mach-O inspection supports summary and macho views.", + ); + }, + ); + }, +); + +mcpTest( + "refuses summary delivery before analysis when the server retains no Evidence", + async ({ mcp, onTestFinished }) => { + const target = join( + await createTestTempDirectory("rea-summary-preflight-"), + "tool", + ); + await writeFile(target, thinMach(0xfeedfacf, 0x0100000c)); + const executed: string[] = []; + const session = createTestBinarySession(() => ({ + execute: (operation) => { + executed.push(operation); + return Promise.resolve(observed(null)); + }, + close: () => Promise.resolve(ok(null)), + })); + onTestFinished(async () => { + await session.close(); + }); + const opened = await session.open(target); + if (!opened.ok) throw opened.error; + const client = await mcp.connect( + createServer({ kind: "fixed", analysis: session }), + ); + const refused = []; + for (const name of ["inspect_macho", "inspect_artifact"]) + refused.push( + await client.callTool({ name, arguments: { detail: "summary" } }), + ); + // Neither analysis ran only to be discarded by the delivery refusal. + expect(executed.filter((operation) => operation !== "health")).toEqual([]); + for (const result of refused) + expect(parseMcpToolError(result)).toMatchObject({ + error: { + code: "capability_unavailable", + message: expect.stringContaining("does not retain Evidence"), + }, + }); + }, +);