feat(native): select retained batch decompile results through analysis views (#8629)

* feat(native): select retained batch decompile results through analysis views

A completed batch_decompile could only be read whole. When it did not fit the
MCP response budget, or an agent wanted one function, inspect_analysis_view
refused the parent as unsupported and the only recovery was exporting the
session.

- Own the batch result schema in the domain, replacing the contract's copy and
  the workflow's hand-written item types.
- Add summary and batch-decompile views: body-free rows keyed by original index
  with exact filters and opt-in bodies, and UTF-16 pages of one body.
- Report the decompiling provider from the upstream analysis profile, since the
  Evidence provider is the composing workflow.
- Keep batch limitations visibly aggregated rather than attributing them to one
  successful item.

Fixes #8498

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(native): count every unavailable entry label in a batch summary

label_unavailable only counted failed lookups, so a batch whose provider
answered null for every label reported zero. The identity contract defines a
null name as an unavailable label in both cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(native): require advertised batch schemas through the shared validator

A local validator map dropped any tool without an output schema, so removing an
advertised schema would have left these contract tests green. Require both named
schemas and validate through createMcpSchemaValidation, as docs/testing.md asks.

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:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-11-01 23:58:54 -08:00
1 parent 0e6943c9da
commit dec8d2d588
14 files changed
+1199 -73

No files matched your search

@@ -73,6 +73,13 @@ Parent Evidence ID plus source index preserves same-address
alternatives. Follow `coverage.next_offset` only as far as the question needs;
exhausting retained rows does not establish complete program discovery.
Read a retained `batch_decompile` through its Evidence ID with
`view: {kind: "batch-decompile", collection: "items"}` for body-free rows and
exact `where.indices`, `addresses`, `procedure_addresses` or `statuses`. Add
`include_pseudocode: true` for several chosen bodies, or use
`collection: "pseudocode"` with one row's `index` to page a large body. Batch
limitations aggregate every item, so do not attribute them to one successful row.
Use `record_unknown` to track unresolved questions in the user's investigation
and name the authority or environment still required. Supply supporting and
contradicting evidence IDs. It updates the session registry directly.
+32
View File
@@ -620,6 +620,38 @@ never upgrades `trace_coverage`, and graph connectivity does not prove runtime
reachability. The CLI keeps complete trace JSON; save it and use
`inspect-analysis-view` with an `evidence-file` source.
### Selected batch decompile results
`batch_decompile` returns every pseudocode body. `inspect_analysis_view` selects
from that completed record, retained in the session, saved as an Evidence file or
imported from a portable bundle, and never repeats decompilation.
`view: {"kind":"summary"}` reports the producer counts, `source_provider` (the
composing workflow), `source_analysis_profile_digest`, `decompiling_provider`,
procedure identity counts, total and largest `pseudocode` size, and
`limitation_count`.
A `batch-decompile` view selects one `collection` and repeats the batch counts
and provider identities:
- `items`: rows in retained order with their original `index`, so duplicate
selectors stay distinct. A row keeps the selector in `address`, its `procedure`
identity and `status`, plus `pseudocode_code_units` or its exact `error` and
`limitations`. `where.indices`, `where.addresses`, `where.procedure_addresses`
and `where.statuses` filter exactly and combine; an item with unknown identity
never matches `procedure_addresses`. Omitted filters select all, and empty
arrays select none. `offset` and `limit` default to 0 and 64.
`include_pseudocode: true` adds each selected body.
- `pseudocode`: one `index`'s text in UTF-16 code-unit pages. `offset` and `limit`
default to 0 and 16,384, and `coverage` measures that body. An offset inside a
surrogate pair is refused, and a limit ending inside one stops before it. An
error item has no text and is refused with a pointer to `items`.
Page `coverage` counts the selection only: exhausting it establishes nothing
about functions outside the batch. Evidence `limitations` aggregate every item's
provider observations and are not attributed to one successful item; only error
items keep their own. The CLI has no batch command; save the Evidence and use
`inspect-analysis-view` with an `evidence-file` source or `--evidence-bundle`.
Completed `inspect_artifact` and atomic `inventory_artifact` Evidence also
support summary and inventory views. A summary reports the manifest's expected
counts separately from available record counts, observed entry kinds, inventory
+4
View File
@@ -201,6 +201,10 @@ remains valid. This tool
has no dedicated CLI command; the existing `decompile` command remains the
single-procedure CLI operation.
`inspect_analysis_view` reads a summary, rows or one body from the retained
record, an Evidence file or a portable bundle without repeating decompilation:
see [selected batch results](mcp-contracts.md#selected-batch-decompile-results).
Each batch schedules one provider read at a time, but has no shared input-count
or aggregate retained-output allowance. It retains every requested row and full
pseudocode body through Evidence construction. The MCP response budget checks
+4 -22
View File
@@ -1,6 +1,10 @@
import { permitsCapabilityFallback } from "./CapabilityFallback.js";
import { withOverviewInventoryCoverage } from "../domain/native/inventoryCoverage.js";
import type { NativeCallGraph } from "../domain/native/nativeCallGraph.js";
import type {
BatchDecompileItem,
BatchProcedureIdentity,
} from "../domain/native/batchDecompile.js";
import { nativeProcedureInputSchema } from "../domain/native/nativeProcedureInput.js";
import {
cleanupStoppedTraversal,
@@ -76,18 +80,6 @@ import {
import { traceLiteralFeature } from "./EnhancedLiteralTracing.js";
import { projectNativeApiInspection } from "./native/NativeApiInspection.js";
type BatchProcedureIdentity =
| {
readonly status: "resolved";
readonly address: string;
readonly name: string | null;
readonly name_error?: ReturnType<typeof projectAnalysisError>;
}
| {
readonly status: "unknown";
readonly error: ReturnType<typeof projectAnalysisError>;
};
type BatchResolution = {
readonly address: string;
readonly result: Result<string, AnalysisError>;
@@ -99,16 +91,6 @@ type BatchReadyItem = {
readonly providerProcedure: string;
};
type BatchDecompileItem = {
readonly address: string;
readonly procedure: BatchProcedureIdentity;
readonly status: "ok" | "error";
readonly pseudocode?: string;
readonly error?: ReturnType<typeof projectAnalysisError>;
/** Caveats of this item's own observation when it supplied no pseudocode. */
readonly limitations?: string[];
};
/**
* Guidance for a batch item whose provider returned null pseudocode, by
* whether that observation reported caveats for the item to carry.
+1 -1
View File
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_analysis_view"),
kind: "application",
description:
'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. 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. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. 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. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the fLine truncated
'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. 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. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. 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. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the fLine truncated
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
+2 -50
View File
@@ -22,6 +22,7 @@ import { byteEntropySchema } from "../domain/native/byteEntropy.js";
import { nativeFunctionMatchingSchema } from "../domain/native/functionMatching.js";
import { nativeByteReadSchema } from "../domain/native/nativeByteRead.js";
import { analysisDetailOutputSchemaOf } from "./analysisDetail.js";
import { batchDecompileResultSchema } from "../domain/native/batchDecompile.js";
import {
functionDossierSchema,
functionInstructionWindowSchema,
@@ -313,23 +314,6 @@ const callPathTraceOutput = evidenceResultOf(
}),
);
/** Identity facts attached to one requested batch selector. */
const batchProcedureIdentity = z.discriminatedUnion("status", [
z.object({
status: z.literal("resolved"),
address: z.string().min(1).describe("Observed canonical procedure entry."),
name: z
.string()
.nullable()
.describe("Provider's entry label, or null when unavailable."),
name_error: analysisErrorProjectionSchema.optional(),
}),
z.object({
status: z.literal("unknown"),
error: analysisErrorProjectionSchema,
}),
]);
/** Exact structured-content schemas for composed analysis workflows. */
export const enhancedOutputSchemas = {
inspect_native_dispatch_metadata: evidenceResultOf(
@@ -340,39 +324,7 @@ export const enhancedOutputSchemas = {
get_objc_classes: objcClassDiscoveryOutput,
get_objc_protocols: symbolDiscoveryOutput("protocols"),
decompile_all: evidenceResultOf(decompileDirectoryManifestSchema),
batch_decompile: evidenceResultOf(
z.object({
items: z.array(
z.discriminatedUnion("status", [
z.object({
address: z
.string()
.describe("Original caller-selected symbol or address."),
procedure: batchProcedureIdentity,
status: z.literal("ok"),
pseudocode: z.string().min(1),
}),
z.object({
address: z
.string()
.describe("Original caller-selected symbol or address."),
procedure: batchProcedureIdentity,
status: z.literal("error"),
error: analysisErrorProjectionSchema,
limitations: z
.array(z.string())
.optional()
.describe(
"Caveats of this item's own provider observation when it supplied no pseudocode, including any reason the provider reported.",
),
}),
]),
),
total: z.int().min(0),
succeeded: z.int().min(0),
failed: z.int().min(0),
}),
),
batch_decompile: evidenceResultOf(batchDecompileResultSchema),
get_call_graph: analysisDetailOutputSchemaOf(
nativeObservationSourceSchemas.get_call_graph,
nativeObservationSummaryViewResultSchemas.get_call_graph,
+3
View File
@@ -18,6 +18,7 @@ import { projectJavaScriptRoutesView } from "./javascriptRoutesView.js";
import { projectWebBundleView } from "./webBundleView.js";
import { projectSourceToBundleView } from "./sourceToBundleView.js";
import { projectFeatureTraceView } from "./featureTraceView.js";
import { projectBatchDecompileView } from "./batchDecompileView.js";
import { projectGpuCodeObjectsView } from "./gpuCodeObjectsView.js";
import { projectModelArtifactView } from "./modelArtifactView.js";
import { projectCommandObservationView } from "./commandObservationView.js";
@@ -333,6 +334,7 @@ const retainedViewProjectors = new Map<string, RetainedViewProjector>([
retain("match_native_functions", projectNativeFunctionMatchingView),
retain("compare_source_to_bundle", projectSourceToBundleView),
retain("trace_application_feature", projectFeatureTraceView),
retain("batch_decompile", projectBatchDecompileView),
retain("inspect_artifact", projectArtifactInventoryView),
retain("inventory_artifact", projectArtifactInventoryView),
retain("analyze_web_bundle", projectWebBundleView),
@@ -432,6 +434,7 @@ export const projectAnalysisView = (
parent.operation === "match_native_functions"
? NATIVE_FUNCTION_MATCHING_PREDICATE
: parent.operation === "analyze_function" ||
parent.operation === "batch_decompile" ||
isNativeObservationOperation(parent.operation) ||
isNativeInventoryOperation(parent.operation)
? ANALYSIS_EVIDENCE_PREDICATE
@@ -11,6 +11,7 @@ import { javaScriptRoutesViewRequestSchema } from "./javascriptRoutesView.js";
import { webBundleViewRequestSchema } from "./webBundleView.js";
import { sourceToBundleViewRequestSchema } from "./sourceToBundleView.js";
import { featureTraceViewRequestSchema } from "./featureTraceView.js";
import { batchDecompileViewRequestSchema } from "./batchDecompileView.js";
import { gpuCodeObjectsViewRequestSchema } from "./gpuCodeObjectsView.js";
import { modelArtifactViewRequestSchema } from "../model/modelArtifactQuery.js";
import { rustBinaryViewRequestSchema } from "../rust/rustBinaryQuery.js";
@@ -68,6 +69,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
nativeFunctionMatchingViewRequestSchema,
sourceToBundleViewRequestSchema,
featureTraceViewRequestSchema,
batchDecompileViewRequestSchema,
summaryViewSchema,
goBinaryViewRequestSchema,
rustBinaryViewRequestSchema,
@@ -68,6 +68,11 @@ import {
featureTraceViewCollections,
featureTraceViewContextSchema,
} from "./featureTraceView.js";
import {
batchDecompileSummarySchema,
batchDecompileViewCollections,
batchDecompileViewContextSchema,
} from "./batchDecompileView.js";
import {
gpuCodeObjectsViewRequestSchema,
gpuCodeObjectsSummarySchema,
@@ -987,6 +992,39 @@ export const featureTraceViewResultSchema = z
]),
);
/** Counts and identities of a retained batch without any pseudocode body. */
const batchDecompileSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
artifact: artifactSchema.nullable(),
parent_operation: z.literal("batch_decompile"),
summary: batchDecompileSummarySchema,
});
const batchDecompilePage = {
...viewResultBase,
...batchDecompileViewContextSchema.shape,
kind: z.literal("batch-decompile"),
artifact: artifactSchema.nullable(),
parent_operation: z.literal("batch_decompile"),
};
// The kind tag keeps dispatch while each collection keeps exactly its own shape.
export const batchDecompileViewResultSchema = z
.object({ kind: z.literal("batch-decompile") })
.passthrough()
.pipe(
z.union([
z.strictObject({
...batchDecompilePage,
view: batchDecompileViewCollections.items.request,
items: batchDecompileViewCollections.items.rows,
}),
z.strictObject({
...batchDecompilePage,
view: batchDecompileViewCollections.pseudocode.request,
item: batchDecompileViewCollections.pseudocode.item,
}),
]),
);
/** Bind the existing nested facet selection to its producer-owned rows. */
const machoPage = <Facet extends keyof typeof machoViewRows>(facet: Facet) =>
z.strictObject({
@@ -1026,6 +1064,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
webBundlePageViewResultSchema,
sourceToBundlePageViewResultSchema,
featureTraceViewResultSchema,
batchDecompileViewResultSchema,
z.strictObject({
...viewResultBase,
...nativeFunctionMatchingContextSchema.shape,
@@ -1116,6 +1155,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
nativeFunctionSummaryViewResultSchema,
sourceToBundleSummaryViewResultSchema,
featureTraceSummaryViewResultSchema,
batchDecompileSummaryViewResultSchema,
webBundleSummaryViewResultSchema,
]),
z.strictObject({
@@ -0,0 +1,396 @@
import { z } from "zod";
import {
committedProviderSchema,
type CommittedProviderIdentity,
} from "../analysisProfile.js";
import { digestSchema } from "../digests.js";
import { providerSchema } from "../evidence.js";
import {
batchDecompileResultSchema,
batchItemLimitationsSchema,
batchProcedureIdentitySchema,
type BatchDecompileItem,
type BatchDecompileResult,
} from "../native/batchDecompile.js";
import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js";
import { err, ok } from "../result.js";
import {
boundedViewMessages,
completeWithinViewCoverage,
pageViewCoverage,
} from "./analysisViewCoverage.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import {
createAnalysisViewResultReader,
type AnalysisViewParent,
} from "./analysisViewParent.js";
import {
ownedView,
type AdmittedView,
type SummaryAnalysisView,
} from "./analysisViewShape.js";
import {
analysisTextPage,
analysisTextPageItemSchema,
} from "./analysisViewText.js";
import { selectViewRows } from "./selectViewRows.js";
const OPERATION = "batch_decompile" as const;
const offset = z.int().nonnegative().default(0);
const itemsRequestSchema = z.strictObject({
kind: z.literal("batch-decompile"),
collection: z.literal("items"),
offset,
limit: z.int().positive().default(64),
where: z
.strictObject({
indices: z
.array(z.int().nonnegative())
.optional()
.describe(
"Exact zero-based positions in the original batch, which keep duplicate selectors distinct. Omitted selects all; [] selects none.",
),
addresses: z
.array(z.string())
.optional()
.describe(
"Exact caller-selected symbols or addresses as submitted. Omitted selects all; [] selects none.",
),
procedure_addresses: z
.array(z.string())
.optional()
.describe(
"Exact observed canonical entries. An item with unknown procedure identity never matches. Omitted selects all; [] selects none.",
),
statuses: z
.array(z.enum(["ok", "error"]))
.optional()
.describe("Item outcomes. Omitted selects all; [] selects none."),
})
.default({}),
include_pseudocode: z
.boolean()
.default(false)
.describe(
"Opt into each selected successful item's complete pseudocode. Otherwise rows report pseudocode_code_units only.",
),
});
const pseudocodeRequestSchema = z.strictObject({
kind: z.literal("batch-decompile"),
collection: z.literal("pseudocode"),
index: z.int().nonnegative().describe("Exact item position from a row."),
offset: offset.describe("UTF-16 code-unit offset into this item's text."),
limit: z
.int()
.positive()
.default(16_384)
.describe(
"UTF-16 code units in this page. A page size only: coverage reports the complete body and the next offset.",
),
});
/** Select rows of a completed batch or page one retained pseudocode body. */
export const batchDecompileViewRequestSchema = z.discriminatedUnion(
"collection",
[itemsRequestSchema, pseudocodeRequestSchema],
);
type BatchDecompileViewRequest = z.output<
typeof batchDecompileViewRequestSchema
>;
const itemIdentity = {
index: z.int().nonnegative().describe("Position in the original batch."),
address: z.string().describe("Original caller-selected symbol or address."),
procedure: batchProcedureIdentitySchema,
};
const batchDecompileRowSchema = z.discriminatedUnion("status", [
z.strictObject({
...itemIdentity,
status: z.literal("ok"),
pseudocode_code_units: z.int().positive(),
pseudocode: z.string().min(1).exactOptional(),
}),
z.strictObject({
...itemIdentity,
status: z.literal("error"),
error: analysisErrorProjectionSchema,
limitations: batchItemLimitationsSchema,
}),
]);
/**
* Producer counts and identities repeated with every view, so one selected row
* stays traceable to its batch and to the provider that decompiled it.
*/
export const batchDecompileViewContextSchema = z.strictObject({
batch: z.strictObject({
total: z.int().nonnegative(),
succeeded: z.int().nonnegative(),
failed: z.int().nonnegative(),
}),
source_provider: providerSchema.nullable(),
source_analysis_profile_digest: digestSchema.nullable(),
decompiling_provider: committedProviderSchema
.nullable()
.describe(
"Provider committed by the batch's upstream analysis profile, or null when the parent records none.",
),
});
/** Summary context adds the facts an analyst needs to choose what to read. */
export const batchDecompileSummarySchema =
batchDecompileViewContextSchema.extend({
procedures: z.strictObject({
distinct_resolved: z.int().nonnegative(),
unknown_identity: z.int().nonnegative(),
label_unavailable: z
.int()
.nonnegative()
.describe(
"Resolved items whose entry label is null, whether the provider had none or its lookup failed.",
),
}),
pseudocode: z.strictObject({
code_units: z.int().nonnegative(),
largest: z
.strictObject({
index: z.int().nonnegative(),
code_units: z.int().positive(),
})
.nullable(),
}),
limitation_count: z.int().nonnegative(),
});
/** Each collection's request bound to its own rows, so a reply cannot mix them. */
export const batchDecompileViewCollections = {
items: {
request: itemsRequestSchema,
rows: z.array(batchDecompileRowSchema),
},
pseudocode: {
request: pseudocodeRequestSchema,
item: z.strictObject({
...itemIdentity,
...analysisTextPageItemSchema.shape,
pseudocode_code_units: z.int().positive(),
}),
},
} as const;
const checkedBatch = createAnalysisViewResultReader(batchDecompileResultSchema);
const upstreamProfileSchema = z.object({
upstream_analysis_profile: z.object({ provider: committedProviderSchema }),
});
/** A composed batch's Evidence provider is the workflow; its profile names the decompiler. */
const decompilingProvider = (
parent: AnalysisViewParent,
): CommittedProviderIdentity | null => {
const parsed = upstreamProfileSchema.safeParse(
parent.analysisProfile?.parameters,
);
return parsed.success ? parsed.data.upstream_analysis_profile.provider : null;
};
const batchContext = (
parent: AnalysisViewParent,
{ total, succeeded, failed }: BatchDecompileResult,
) => ({
batch: { total, succeeded, failed },
source_provider: parent.provider ?? null,
source_analysis_profile_digest: parent.analysisProfile?.digest ?? null,
decompiling_provider: decompilingProvider(parent),
});
const summarizeBatch = (items: readonly BatchDecompileItem[]) => {
const resolved = new Set<string>();
let unknownIdentity = 0;
let labelUnavailable = 0;
let codeUnits = 0;
let largest: { index: number; code_units: number } | null = null;
for (const [index, item] of items.entries()) {
if (item.procedure.status === "unknown") unknownIdentity++;
else {
resolved.add(item.procedure.address);
// Null covers an answer without a label and a failed label lookup alike.
if (item.procedure.name === null) labelUnavailable++;
}
if (item.status !== "ok") continue;
codeUnits += item.pseudocode.length;
if (largest === null || item.pseudocode.length > largest.code_units)
largest = { index, code_units: item.pseudocode.length };
}
return {
procedures: {
distinct_resolved: resolved.size,
unknown_identity: unknownIdentity,
label_unavailable: labelUnavailable,
},
pseudocode: { code_units: codeUnits, largest },
};
};
const batchUnknowns = (items: readonly BatchDecompileItem[]): string[] => {
const unknownIdentity = items.filter(
({ procedure }) => procedure.status === "unknown",
).length;
return [
...(unknownIdentity === 0
? []
: [
`${String(unknownIdentity)} items have unknown procedure identity; their address is the caller's selector, not an observed entry.`,
]),
"Evidence limitations aggregate every item's provider observations and are not attributed to one successful item. A batch covers only its selected procedures, and exhausting a page establishes nothing outside it.",
];
};
const selected = <Value>(values: readonly Value[] | undefined) =>
values === undefined ? undefined : new Set(values);
const itemRow = (
item: BatchDecompileItem,
index: number,
includePseudocode: boolean,
): z.output<typeof batchDecompileRowSchema> => {
const identity = {
index,
address: item.address,
procedure: item.procedure,
};
if (item.status === "ok")
return {
...identity,
status: "ok",
pseudocode_code_units: item.pseudocode.length,
...(includePseudocode ? { pseudocode: item.pseudocode } : {}),
};
return {
...identity,
status: "error",
error: item.error,
...(item.limitations === undefined
? {}
: { limitations: item.limitations }),
};
};
const pseudocodeRefusal = (message: string) =>
err(
analysisViewInputError([
{ path: ["view", "index"], reason: "invalid_value", message },
]),
);
/** Select retained batch facts without repeating decompilation or changing its record. */
export const projectBatchDecompileView = (
parent: AnalysisViewParent,
admitted: AdmittedView,
) => {
const view = ownedView<SummaryAnalysisView | BatchDecompileViewRequest>(
admitted,
"summary",
"batch-decompile",
);
if (view === undefined)
return err(
analysisViewInputError([
{
path: ["view"],
reason: "invalid_value",
message:
"batch_decompile Evidence supports summary and batch-decompile views of items or pseudocode.",
},
]),
);
const parsed = checkedBatch(parent);
if (!parsed.ok) return parsed;
const batch = parsed.value;
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: OPERATION,
parent_digest: parent.evidenceId.slice(3),
artifact: parent.artifact ?? null,
limitations: boundedViewMessages(
parent.limitations,
parent.evidenceId,
"limitations",
),
unknowns: boundedViewMessages(
[],
parent.evidenceId,
"unknowns",
batchUnknowns(batch.items),
),
};
const context = batchContext(parent, batch);
if (view.kind === "summary")
return ok({
kind: "summary" as const,
view,
...shared,
summary: {
...context,
...summarizeBatch(batch.items),
limitation_count: new Set(parent.limitations).size,
},
coverage: completeWithinViewCoverage(1, 1),
});
if (view.collection === "items") {
const indices = selected(view.where.indices);
const addresses = selected(view.where.addresses);
const entries = selected(view.where.procedure_addresses);
const statuses = selected<string>(view.where.statuses);
const { rows, total } = selectViewRows(
batch.items,
view,
(item, index) =>
(indices === undefined || indices.has(index)) &&
(addresses === undefined || addresses.has(item.address)) &&
(statuses === undefined || statuses.has(item.status)) &&
(entries === undefined ||
(item.procedure.status === "resolved" &&
entries.has(item.procedure.address))),
(item, index) => itemRow(item, index, view.include_pseudocode),
);
return ok({
kind: "batch-decompile" as const,
view,
...shared,
...context,
items: rows,
coverage: pageViewCoverage(view.offset, rows.length, total),
});
}
const item = batch.items[view.index];
if (item === undefined)
return pseudocodeRefusal(
`Item ${String(view.index)} is outside this batch of ${String(batch.items.length)}.`,
);
if (item.status !== "ok")
return pseudocodeRefusal(
`Item ${String(view.index)} has status error and no pseudocode; read its error and limitations through the items collection.`,
);
const page = analysisTextPage(item.pseudocode, view.offset, view.limit);
if (!page.ok) return page;
return ok({
kind: "batch-decompile" as const,
view,
...shared,
...context,
item: {
index: view.index,
address: item.address,
procedure: item.procedure,
pseudocode_code_units: item.pseudocode.length,
...page.value.item,
},
coverage: page.value.coverage,
});
};
+62
View File
@@ -0,0 +1,62 @@
import { z } from "zod";
import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js";
/** Identity facts attached to one requested batch selector. */
export const batchProcedureIdentitySchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("resolved"),
address: z.string().min(1).describe("Observed canonical procedure entry."),
name: z
.string()
.nullable()
.describe("Provider's entry label, or null when unavailable."),
name_error: analysisErrorProjectionSchema.exactOptional(),
}),
z.object({
status: z.literal("unknown"),
error: analysisErrorProjectionSchema,
}),
]);
const batchSelectorSchema = z
.string()
.describe("Original caller-selected symbol or address.");
/** Caveats only this item's own provider observation can explain. */
export const batchItemLimitationsSchema = z
.array(z.string())
.exactOptional()
.describe(
"Caveats of this item's own provider observation when it supplied no pseudocode, including any reason the provider reported.",
);
/** One ordered batch outcome; array position keeps duplicate selectors distinct. */
export const batchDecompileItemSchema = z.discriminatedUnion("status", [
z.object({
address: batchSelectorSchema,
procedure: batchProcedureIdentitySchema,
status: z.literal("ok"),
pseudocode: z.string().min(1),
}),
z.object({
address: batchSelectorSchema,
procedure: batchProcedureIdentitySchema,
status: z.literal("error"),
error: analysisErrorProjectionSchema,
limitations: batchItemLimitationsSchema,
}),
]);
/** Provider-independent result owned by the shared batch decompilation workflow. */
export const batchDecompileResultSchema = z.object({
items: z.array(batchDecompileItemSchema),
total: z.int().min(0),
succeeded: z.int().min(0),
failed: z.int().min(0),
});
export type BatchProcedureIdentity = z.output<
typeof batchProcedureIdentitySchema
>;
export type BatchDecompileItem = z.output<typeof batchDecompileItemSchema>;
export type BatchDecompileResult = z.output<typeof batchDecompileResultSchema>;
@@ -46,6 +46,72 @@ import {
} from "../../../src/domain/evidenceBundle.js";
import { inspectEvidenceBundle } from "../../../src/application/investigation/InspectEvidenceBundle.js";
cliTest(
"reads selected batch rows and one body from a saved bundle without a provider",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-batch-view-cli-");
const path = join(root, "bundle.json");
const body = "int main(void) { return 0; }";
const parent = createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "batch_decompile",
parameters: { addresses: ["main", "main"] },
result: {
items: ["main", "main"].map((address) => ({
address,
procedure: { status: "resolved", address: "0x1", name: "main" },
status: "ok",
pseudocode: body,
})),
total: 2,
succeeded: 2,
failed: 0,
},
confidence: "derived",
authority: "shipped-artifact",
},
);
await writeFile(path, JSON.stringify(createEvidenceBundle([parent])));
const inspect = async (view: JsonValue) => {
const result = await cli.run({
arguments: [
"inspect-analysis-view",
JSON.stringify({
source: {
kind: "retained-evidence",
evidence_id: parent.evidence_id,
},
view,
}),
"--evidence-bundle",
path,
"--json",
],
});
expect(result.exitCode).toBe(0);
return parseEvidence(result.json).normalized_result;
};
expect(
await inspect({ kind: "batch-decompile", collection: "items" }),
).toMatchObject({
batch: { total: 2 },
items: [
{ index: 0, pseudocode_code_units: body.length },
{ index: 1, pseudocode_code_units: body.length },
],
});
expect(
await inspect({
kind: "batch-decompile",
collection: "pseudocode",
index: 1,
}),
).toMatchObject({ item: { index: 1, text: body } });
},
);
cliTest(
"authenticates an exact portable parent for non-managed views and rejects ambiguous or altered sources",
async ({ cli }) => {
@@ -0,0 +1,573 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { afterAll, expect, it, onTestFinished } from "vitest";
import { z } from "zod";
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
import {
createEvidence,
parseEvidence,
type Evidence,
} from "../../../src/domain/evidence.js";
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
import { batchDecompileResultSchema } from "../../../src/domain/native/batchDecompile.js";
import { err, ok } from "../../../src/domain/result.js";
import { GhidraSessionError } from "../../../src/ghidra/GhidraSessionError.js";
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { connectGhidraMcp, ghidraFixtureResult } from "./ghidraMcpHarness.js";
const MAIN_BODY = "int main(void) { return 0; }";
const EMOJI_BODY = `int emoji(void) { return "${"\u{1F600}".repeat(4)}"; }`;
const NO_BODY_REASON = "Thunk has no decompilable body.";
/** Selectors exercising a duplicate, an interior address, no body and no identity. */
const SELECTORS = ["main", "0x401005", "main", "emoji", "bodyless", "missing"];
const ENTRIES: Readonly<Record<string, string>> = {
main: "0x401000",
"0x401005": "0x401000",
emoji: "0x402000",
bodyless: "0x403000",
unlabeled: "0x404000",
"label-failure": "0x405000",
};
const LABELS: Readonly<Record<string, string>> = {
"0x401000": "main",
"0x402000": "emoji",
"0x403000": "bodyless",
};
const BODIES: Readonly<Record<string, string>> = {
"0x401000": MAIN_BODY,
"0x402000": EMOJI_BODY,
};
const provider = (calls: { count: number }, bodies: typeof BODIES) =>
((operation, input) => {
calls.count++;
if (operation === "procedure_address") {
const selector = String(input.procedure);
const entry = ENTRIES[selector];
return Promise.resolve(
entry === undefined
? err(
new GhidraSessionError(
"remote",
`no function: ${selector}`,
{},
{ remoteCode: "not_found" },
),
)
: ok(entry),
);
}
if (operation === "address_name")
return Promise.resolve(
input.address === "0x405000"
? err(new GhidraSessionError("remote", "label lookup failed"))
: ok(LABELS[String(input.address)] ?? null),
);
if (operation === "procedure_pseudo_code") {
const body = bodies[String(input.procedure)];
return Promise.resolve(
ok(
body === undefined
? { value: null, limitations: [NO_BODY_REASON] }
: { value: body, limitations: [] },
),
);
}
return Promise.resolve(ok(ghidraFixtureResult(operation, input)));
}) satisfies NonNullable<Parameters<typeof connectGhidraMcp>[1]>;
const schemaValidation = createMcpSchemaValidation();
afterAll(() => schemaValidation.clear());
/** Replies these tools deliver must validate against what they advertise. */
const ADVERTISED_OUTPUTS = ["batch_decompile", "inspect_analysis_view"];
const connect = async (profile: "full" | "compact") => {
const calls = { count: 0 };
const harness = await connectGhidraMcp(
`batch-decompile-views-${profile}`,
provider(calls, BODIES),
{
environment: {
REA_MCP_INPUT_SCHEMA_PROFILE: profile,
REA_MCP_OUTPUT_SCHEMA_PROFILE: profile,
},
schemaValidator: schemaValidation,
},
);
onTestFinished(() => harness.close());
const { tools } = await harness.mcp.listTools();
const outputSchemas = new Map(
ADVERTISED_OUTPUTS.map((name) => {
const advertised = tools.find((tool) => tool.name === name)?.outputSchema;
if (advertised === undefined)
throw new Error(`${name} advertises no output schema`);
const schema: Record<string, unknown> = advertised;
return [name, schema] as const;
}),
);
const reply = async (name: string, args: Record<string, unknown>) =>
z
.custom<CallToolResult>()
.parse(await harness.mcp.callTool({ name, arguments: args }));
const call = async (name: string, args: Record<string, unknown>) => {
const result = await reply(name, args);
expect(result.isError, JSON.stringify(result)).not.toBe(true);
const schema = outputSchemas.get(name);
if (schema !== undefined)
expect(
schemaValidation.assertions.validate(schema, result.structuredContent),
JSON.stringify(schemaValidation.assertions.errors),
).toBe(true);
return { evidence: parseEvidence(result.structuredContent), result };
};
return { ...harness, calls, reply, call };
};
type Harness = Awaited<ReturnType<typeof connect>>;
const viewOf = (evidence: Evidence) =>
z.looseObject({}).parse(evidence.normalized_result);
const retained = (evidenceId: string) => ({
kind: "retained-evidence",
evidence_id: evidenceId,
});
const view = async (
{ call }: Harness,
parentId: string,
selected: Record<string, unknown>,
) =>
viewOf(
(
await call("inspect_analysis_view", {
source: retained(parentId),
view: { kind: "batch-decompile", ...selected },
})
).evidence,
);
const rowIndices = (page: Record<string, unknown>) =>
z
.array(z.looseObject({ index: z.number() }))
.parse(page.items)
.map(({ index }) => index);
/** Read every text page of one retained body in order. */
const readBody = async (
harness: Harness,
parentId: string,
index: number,
limit?: number,
) => {
let text = "";
let pages = 0;
let offset: number | null = 0;
while (offset !== null) {
const page = await view(harness, parentId, {
collection: "pseudocode",
index,
offset,
...(limit === undefined ? {} : { limit }),
});
text += z.looseObject({ text: z.string() }).parse(page.item).text;
offset = z
.looseObject({ next_offset: z.number().nullable() })
.parse(page.coverage).next_offset;
pages++;
}
return { text, pages };
};
/** A typed refusal whose issue names the rejected selection. */
const invalidIssue = (message: string) => ({
code: "invalid_request",
details: {
issues: expect.arrayContaining([
expect.objectContaining({ message: expect.stringContaining(message) }),
]),
},
});
it.each(["full", "compact"] as const)(
"selects retained batch rows and bodies without repeating analysis (%s)",
async (profile) => {
const harness = await connect(profile);
const parent = (
await harness.call("batch_decompile", { addresses: SELECTORS })
).evidence;
const parentId = parent.evidence_id;
const reply = await harness.call("inspect_analysis_view", {
source: retained(parentId),
view: { kind: "summary" },
});
const summary = viewOf(reply.evidence);
expect(parent.parameters).toEqual({ addresses: SELECTORS });
expect(
batchDecompileResultSchema.parse(parent.normalized_result),
).toMatchObject({ total: 6, succeeded: 4, failed: 2 });
expect(JSON.stringify(reply.result)).not.toContain("int main");
expect(reply.evidence.evidence_links).toEqual([parentId]);
expect(reply.evidence.limitations).toEqual(
expect.arrayContaining(parent.limitations),
);
expect(summary).toMatchObject({
kind: "summary",
parent_operation: "batch_decompile",
summary: {
batch: { total: 6, succeeded: 4, failed: 2 },
source_provider: parent.provider,
source_analysis_profile_digest: parent.analysis_profile?.digest,
decompiling_provider: { id: "ghidra", version: "12.1.4" },
procedures: {
distinct_resolved: 3,
unknown_identity: 1,
label_unavailable: 0,
},
pseudocode: {
code_units: MAIN_BODY.length * 3 + EMOJI_BODY.length,
largest: { index: 3, code_units: EMOJI_BODY.length },
},
},
});
expect(summary.unknowns).toEqual(
expect.arrayContaining([
expect.stringContaining("1 items have unknown procedure identity"),
expect.stringContaining("not attributed to one successful item"),
]),
);
const callsAfterBatch = harness.calls.count;
// The default page is an index without bodies.
const index = await view(harness, parentId, { collection: "items" });
expect(JSON.stringify(index.items)).not.toContain("int main");
expect(index).toMatchObject({
batch: { total: 6, succeeded: 4, failed: 2 },
coverage: { total: 6, exhausted: true },
});
expect(index.items).toMatchObject([
{
index: 0,
address: "main",
procedure: { status: "resolved", address: "0x401000", name: "main" },
status: "ok",
pseudocode_code_units: MAIN_BODY.length,
},
{ index: 1, address: "0x401005" },
{ index: 2, address: "main" },
{ index: 3, address: "emoji" },
{
index: 4,
status: "error",
error: { code: "capability_unavailable" },
limitations: expect.arrayContaining([NO_BODY_REASON]),
},
{ index: 5, procedure: { status: "unknown" } },
]);
// Filters are exact, compose, and keep original indices.
for (const [where, indices] of [
[{ addresses: ["main"] }, [0, 2]],
[{ procedure_addresses: ["0x401000"] }, [0, 1, 2]],
// A selector never matches as an observed entry, including an unknown one.
[{ procedure_addresses: ["missing"] }, []],
[{ statuses: ["error"] }, [4, 5]],
[{ indices: [5, 3, 3] }, [3, 5]],
[{ statuses: ["ok"], procedure_addresses: ["0x402000"] }, [3]],
[{ indices: [] }, []],
[{ addresses: [] }, []],
[{ procedure_addresses: [] }, []],
[{ statuses: [] }, []],
] as const)
expect(
rowIndices(
await view(harness, parentId, { collection: "items", where }),
),
JSON.stringify(where),
).toEqual(indices);
// Coverage counts matches rather than the whole batch.
const paged = await view(harness, parentId, {
collection: "items",
where: { statuses: ["ok"] },
offset: 1,
limit: 2,
});
expect(rowIndices(paged)).toEqual([1, 2]);
expect(paged.coverage).toMatchObject({
status: "page",
examined: 2,
total: 4,
next_offset: 3,
exhausted: false,
});
// Bodies are opt-in, and only successful items have one.
const selected = await view(harness, parentId, {
collection: "items",
where: { indices: [3, 4] },
include_pseudocode: true,
});
expect(selected.items).toMatchObject([
{ index: 3, pseudocode: EMOJI_BODY },
{ index: 4, status: "error" },
]);
expect(JSON.stringify(selected.items)).not.toContain('"pseudocode":null');
expect(harness.calls.count).toBe(callsAfterBatch);
expect(harness.session.evidenceById(parentId)).toEqual(parent);
},
);
it("counts both forms of an unavailable entry label", async () => {
const harness = await connect("full");
const parent = (
await harness.call("batch_decompile", {
addresses: ["main", "unlabeled", "label-failure"],
})
).evidence.evidence_id;
const summary = viewOf(
(
await harness.call("inspect_analysis_view", {
source: retained(parent),
view: { kind: "summary" },
})
).evidence,
);
expect(summary.summary).toMatchObject({
procedures: {
distinct_resolved: 3,
unknown_identity: 0,
label_unavailable: 2,
},
});
const { items } = await view(harness, parent, { collection: "items" });
expect(items).toMatchObject([
{ procedure: { name: "main" } },
{ procedure: { name: null } },
{ procedure: { name: null, name_error: { code: expect.any(String) } } },
]);
expect(JSON.stringify(z.array(z.unknown()).parse(items)[1])).not.toContain(
"name_error",
);
});
it("pages one body exactly without splitting a Unicode character", async () => {
const harness = await connect("full");
const { evidence } = await harness.call("batch_decompile", {
addresses: SELECTORS,
});
const parent = evidence.evidence_id;
// The complete delivery still carries every body by default.
expect(JSON.stringify(evidence.normalized_result)).toContain("int main");
// A limit ending inside a surrogate pair stops before it, so pages rebuild the text.
const { text, pages } = await readBody(harness, parent, 3, 30);
expect(text).toBe(EMOJI_BODY);
expect(pages).toBeGreaterThan(1);
expect(
await view(harness, parent, { collection: "pseudocode", index: 3 }),
).toMatchObject({
item: {
index: 3,
address: "emoji",
procedure: { status: "resolved", address: "0x402000" },
pseudocode_code_units: EMOJI_BODY.length,
unit: "utf16-code-units",
},
coverage: { total: EMOJI_BODY.length, exhausted: true },
});
const refused = async (selected: Record<string, unknown>) =>
parseMcpToolError(
await harness.reply("inspect_analysis_view", {
source: retained(parent),
view: {
kind: "batch-decompile",
collection: "pseudocode",
...selected,
},
}),
).error;
expect(await refused({ index: 4 })).toMatchObject(
invalidIssue("Item 4 has status error"),
);
expect(await refused({ index: 6 })).toMatchObject(
invalidIssue("outside this batch of 6"),
);
expect(
await refused({ index: 3, offset: EMOJI_BODY.indexOf("\u{1F600}") + 1 }),
).toMatchObject(invalidIssue("splits a UTF-16 surrogate pair"));
});
it("reads the same rows from a bundle and an Evidence file without a provider", async () => {
const harness = await connect("full");
const parent = (
await harness.call("batch_decompile", { addresses: SELECTORS })
).evidence;
const selected = {
kind: "batch-decompile",
collection: "items",
where: { addresses: ["emoji"] },
include_pseudocode: true,
};
const { items } = viewOf(
(
await harness.call("inspect_analysis_view", {
source: retained(parent.evidence_id),
view: selected,
})
).evidence,
);
expect(items).toMatchObject([{ index: 3, pseudocode: EMOJI_BODY }]);
const root = await createTestTempDirectory("rea-batch-decompile-view-");
const bundlePath = join(root, "bundle.json");
const filePath = join(root, "batch.json");
await writeFile(bundlePath, JSON.stringify(createEvidenceBundle([parent])));
await writeFile(filePath, JSON.stringify(parent));
const { client, close } = await createApplicationMcpHarness({
session: createTestBinarySession(() => {
throw new Error("Retained batch views must not open a provider");
}),
clientIdentity: { name: "batch-decompile-offline", version: "1.0.0" },
schemaValidator: schemaValidation,
});
onTestFinished(close);
const imported = await client.callTool({
name: "import_evidence_bundle",
arguments: { path: bundlePath },
});
expect(imported.isError).not.toBe(true);
for (const source of [
retained(parent.evidence_id),
{ kind: "evidence-file", path: filePath },
]) {
const reply = await client.callTool({
name: "inspect_analysis_view",
arguments: { source, view: selected },
});
expect(viewOf(parseEvidence(reply.structuredContent)).items).toEqual(items);
}
});
it("binds views to their parent and each page to its own collection", async () => {
const harness = await connect("full");
const emptyParent = (await harness.call("batch_decompile", { addresses: [] }))
.evidence.evidence_id;
const summary = viewOf(
(
await harness.call("inspect_analysis_view", {
source: retained(emptyParent),
view: { kind: "summary" },
})
).evidence,
);
expect(summary.summary).toMatchObject({
batch: { total: 0, succeeded: 0, failed: 0 },
procedures: { distinct_resolved: 0, unknown_identity: 0 },
pseudocode: { code_units: 0, largest: null },
});
expect(
await view(harness, emptyParent, { collection: "items" }),
).toMatchObject({
items: [],
coverage: { status: "empty", exhausted: true },
});
const parent = (
await harness.call("batch_decompile", { addresses: ["main"] })
).evidence.evidence_id;
const items = await view(harness, parent, { collection: "items" });
const body = await view(harness, parent, {
collection: "pseudocode",
index: 0,
});
expect(analysisViewResultSchema.safeParse(items).success).toBe(true);
expect(analysisViewResultSchema.safeParse(body).success).toBe(true);
expect(
analysisViewResultSchema.safeParse({ ...items, view: body.view }).success,
).toBe(false);
const { item: _item, ...withoutItem } = body;
expect(
analysisViewResultSchema.safeParse({ ...withoutItem, items: items.items })
.success,
).toBe(false);
const inspect = async (source: unknown, selected: unknown) =>
harness.reply("inspect_analysis_view", { source, view: selected });
expect(
parseMcpToolError(
await inspect(retained(parent), { kind: "native-inventory" }),
).error,
).toMatchObject(invalidIssue("batch_decompile Evidence supports"));
const inventory = (await harness.call("list_procedures", {})).evidence;
expect(
parseMcpToolError(
await inspect(retained(inventory.evidence_id), {
kind: "batch-decompile",
collection: "items",
}),
).error.code,
).toBe("invalid_request");
// Inline parents authenticate their predicate and result before selection.
const inline = (
predicateType: string,
result: Evidence["normalized_result"],
) =>
inspect(
{
kind: "inline",
evidence: createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "batch_decompile",
parameters: {},
result,
predicateType,
confidence: "derived",
authority: "shipped-artifact",
},
),
},
{ kind: "batch-decompile", collection: "items" },
);
const valid = {
items: [
{
address: "main",
procedure: { status: "resolved", address: "0x1", name: null },
status: "ok",
pseudocode: "x",
},
],
total: 1,
succeeded: 1,
failed: 0,
};
// A parent without a target stays explicit rather than inventing identity.
const accepted = await inline("rea.analysis", valid);
expect(viewOf(parseEvidence(accepted.structuredContent))).toMatchObject({
artifact: null,
decompiling_provider: null,
source_analysis_profile_digest: null,
});
for (const rejected of [
await inline("rea.other", valid),
await inline("rea.analysis", {
...valid,
items: [{ address: "main", status: "ok" }],
}),
])
expect(parseMcpToolError(rejected).error.code).toBe("invalid_request");
});
+7
View File
@@ -3,6 +3,7 @@ import type { GhidraJavaObservation } from "../../../src/ghidra/GhidraJavaProbe.
import { expect } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
import type { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js";
import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js";
import type { BinarySession } from "../../../src/application/binary/BinarySession.js";
import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js";
@@ -33,6 +34,8 @@ export const connectGhidraMcp = async (
readonly sessionTarget?: GhidraSessionInfo["target"];
readonly attestDosTargetSnapshot?: ReturnType<GhidraProviderClientFactory>["attestDosTargetSnapshot"];
readonly close?: ReturnType<GhidraProviderClientFactory>["close"];
readonly environment?: Readonly<Record<string, string>>;
readonly schemaValidator?: ReturnType<typeof createMcpSchemaValidation>;
} = {},
) => {
const calls: GhidraOperation[] = [];
@@ -72,11 +75,15 @@ export const connectGhidraMcp = async (
const { client: mcp, close } = await createApplicationMcpHarness({
session,
clientIdentity: { name, version: "1.0.0" },
...(fixture.schemaValidator === undefined
? {}
: { schemaValidator: fixture.schemaValidator }),
serverOptions: {
logger: silentLogger,
environment: {
...process.env,
REA_MCP_MAX_INPUT_ELEMENTS: process.env.REA_MCP_MAX_INPUT_ELEMENTS,
...fixture.environment,
},
},
});