perf(native): add opt-in Mach-O summary delivery and retained pages (#2634)
* perf(native): add opt-in Mach-O summary delivery and retained pages inspect_macho returns every import and export inline. For VS Code's Electron Framework one MCP call carries 2.5 MB of text (about 828k o200k tokens), and the same again as structuredContent; the MCP response budget admits it. Agents asking for slice identity, UUID or dependency counts paid that cost on every first call. Add the established opt-in detail selection: omitted or complete keeps the existing complete result and Evidence identity, while summary retains the complete Evidence and returns slice identity with per-collection returned/total/exhaustive counts (956 tokens). inspect_analysis_view kind macho pages the original rows of any collection or the command provenance, repeating the producer's own total and exhaustive flag so page exhaustion is never read as absence. Delivery detail never reaches the native tools or the source Evidence ID: registerEvidenceTools gains a parametersForEvidence hook, so the summary's parent is the same Evidence as a complete call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(mcp): refuse unretainable summary delivery before analysis A server without Evidence retention (a fixed analysis source) ran the complete inspect_macho or inspect_artifact analysis for detail summary and only then refused it in summarizeRetainedAnalysis, paying the very cost summary delivery exists to avoid. registerEvidenceTools gains a preflight hook; both registrations check retention there, as the WASM, EVM, managed, pcap and capture handlers already do before dispatch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(native): keep whole rows in Mach-O architecture and segment pages The page item union listed plain-object row arrays, and zod returns the first union member that parses while dropping unknown keys. Entrypoint rows declare only file_offset, so every architecture and segment row (which also carry a numeric file_offset) matched it and lost its name, CPU fields, permissions and sections. Rows now use exact key sets, and imports/exports share one member. The MCP test pages every facet of a real /bin/ls and compares it with the unparsed complete rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e802441bce
commit
bf34d0d2fc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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<Record<string, Readonly<Record<string, unknown>>>> = {
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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<Record<string, z.ZodObject>> = {
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -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<Extract<AnalysisViewResult, { readonly kind: "wasm" }>, "view_digest">
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "macho" }>, "view_digest">
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "evm" }>, "view_digest">
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "qlog" }>, "view_digest">
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "pcap" }>, "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<string, typeof projectWasmArtifactView>([
|
||||
["inspect_wasm_artifact", projectWasmArtifactView],
|
||||
["inspect_macho", projectMachoView],
|
||||
["inspect_evm_interface", projectEvmInterfaceView],
|
||||
["inspect_managed_artifact", projectManagedArtifactView],
|
||||
["inspect_android_package", projectAndroidPackageView],
|
||||
|
|
|
|||
|
|
@ -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 = <T>(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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = <Shape extends z.ZodRawShape>(
|
||||
rows: z.ZodArray<z.ZodObject<Shape>>,
|
||||
) => 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<MachoCollection, typeof collectionCountSchema>,
|
||||
),
|
||||
provenance_count: z.number().int().nonnegative(),
|
||||
limitation_count: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
type MachoReport = z.output<typeof inspectMachoSchema>;
|
||||
|
||||
/** Admit a well-formed report whose recorded subject, if any, is a Mach-O. */
|
||||
const parseMachoReport = (
|
||||
parent: AnalysisViewParent,
|
||||
): Result<MachoReport, AnalysisError> => {
|
||||
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<MachoCollection, z.output<typeof collectionCountSchema>>;
|
||||
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<UnsignedAnalysisView, AnalysisError> => {
|
||||
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),
|
||||
});
|
||||
};
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -33,6 +33,16 @@ interface EvidenceToolRegistration {
|
|||
operation: Exclude<AnalysisOperation, "health">,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
) => Readonly<Record<string, JsonValue>>;
|
||||
/** Exclude adapter-only selections, such as delivery detail, from Evidence identity. */
|
||||
readonly parametersForEvidence?: (
|
||||
operation: Exclude<AnalysisOperation, "health">,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
) => Readonly<Record<string, JsonValue>>;
|
||||
/** Refuse an unsatisfiable delivery selection before any analysis runs. */
|
||||
readonly preflight?: (
|
||||
operation: Exclude<AnalysisOperation, "health">,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
) => Result<unknown, AnalysisError>;
|
||||
readonly projectEvidence?: (
|
||||
operation: Exclude<AnalysisOperation, "health">,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
|
|
@ -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
|
||||
? {}
|
||||
|
|
|
|||
|
|
@ -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<Record<string, JsonValue>>,
|
||||
): Readonly<Record<string, JsonValue>> => {
|
||||
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),
|
||||
});
|
||||
};
|
||||
|
|
@ -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 extends z.ZodType>(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"),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
Loading…
Reference in New Issue