From eb39a07966168ae61685cc8b5cf798c8fc6f9436 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Sun, 1 Nov 2026 08:56:58 -0800 Subject: [PATCH] feat(evidence): read selected complete records without the whole bundle (#8528) * feat(evidence): read selected complete records without the whole bundle get_evidence_bundle could discover a record by exact ID, but reading it required the complete canonical bundle: complete + filters was rejected, and inspect_analysis_view refuses parents without a view, such as annotations, pseudocode sources and native traces. - Add detail: records, a page (offset, limit; default 0, 8) of the complete matching records in canonical bundle order. Records keep their original IDs, subjects, providers and readback; missing_evidence_links names direct links of the returned records that are not retained. - The selection is labelled as historical and not an importable bundle; complete keeps returning the canonical bundle, and offset/limit require detail: records. - Filters keep one owner shared by summary and records delivery, for the MCP session and the CLI's inspect-evidence-bundle. - An oversized record page now advises a smaller limit or one evidence_id; whole-bundle overflow guidance mentions record reads. Fixes #8307 Co-Authored-By: Claude Opus 5.5 * test(evidence): compare record-selection guidance with its owners Assert the record-selection limitations and oversized-page guidance against their owning constants, and check the reported limit facts separately, instead of pinning explanatory phrases. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../skills/reverse-engineer-anything/SKILL.md | 5 +- docs/javascript-route-inventory.md | 5 +- docs/mcp-contracts.md | 22 +- docs/wasm-artifacts.md | 3 +- src/application/EvidenceBundleCommands.ts | 4 +- .../investigation/InspectEvidenceBundle.ts | 42 ++- src/cli/analysisViewCommands.ts | 4 +- src/contracts/sessionToolContracts.ts | 2 +- src/contracts/sessionToolSchemas.ts | 21 +- src/contracts/toolOutputSchemaGroups.ts | 11 +- src/domain/analysisErrorPresentation.ts | 5 +- src/domain/evidenceRecordSummary.ts | 106 ++++++- src/server/ToolResultDelivery.ts | 30 +- tests/boundary/cli/analysisViewCli.test.ts | 18 ++ .../mcp/evidenceRecordSelectionMcp.test.ts | 286 ++++++++++++++++++ 15 files changed, 523 insertions(+), 41 deletions(-) create mode 100644 tests/boundary/mcp/evidenceRecordSelectionMcp.test.ts diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index fd0fcfe3f5..6e2c635705 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -431,7 +431,10 @@ output also streams without a single MCP frame. When the retained parent ID is unknown, use `get_evidence_bundle` with `detail: "summary"` and exact filters for operation, target SHA-256, analysis-profile digest or canonical procedure address. Select its returned -Evidence ID for the next view instead of requesting the complete bundle. +Evidence ID for the next view instead of requesting the complete bundle. When +that record has no supported view (for example an annotation), read it with +`detail: "records"` and an `evidence_id` filter; `missing_evidence_links` +names linked sources this session does not retain. `retention: "complete-record"` means the whole record is stored; native value-flow truncation and unknown metadata remain separate. For an exported portable bundle, use `rea inspect-evidence-bundle` with JSON containing `path`, diff --git a/docs/javascript-route-inventory.md b/docs/javascript-route-inventory.md index bb20b18901..1a4c30a003 100644 --- a/docs/javascript-route-inventory.md +++ b/docs/javascript-route-inventory.md @@ -65,8 +65,9 @@ confidence and basis, and does not prove a backend endpoint or reachability. Pages obey the shared 1 MiB projection budget; MCP transport applies its own wire-size guard. A byte-limited page returns a continuation; an indivisible oversized record or metadata returns a typed resource error. Select fewer -fields or retrieve the complete retained parent through `get_evidence_bundle`, -`export_evidence_bundle`, or the CLI's exported bundle. Full Evidence is never +fields or retrieve the complete retained parent through `get_evidence_bundle` +(`detail: "records"` with its `evidence_id`), `export_evidence_bundle`, or the +CLI's exported bundle. Full Evidence is never silently truncated. Collections are `summary`, `routes`, `occurrences`, `sources`, and `unknowns`. diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 835795c2c9..5ce902f6ab 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -333,7 +333,26 @@ a provider. `retention: "complete-record"` describes stored Evidence, while `native_dossier.value_flow` independently reports truncation, unavailable or unknown metadata. Missing omission counts remain null. Summary output carries every matching record; actual transport admission still applies. Omitted -`detail` preserves complete canonical bundle delivery; filters require summary. +`detail` preserves complete canonical bundle delivery; filters require summary +or records. + +To read a discovered record without transferring the whole bundle, call +`get_evidence_bundle` with `detail: "records"` and the same exact `filters`. +The result (`kind: "evidence-record-selection"`) pages the matching complete +records in canonical bundle (Evidence ID) order with `offset` (default 0) and +`limit` (default 8), and reports `matching_records`, `total_retained_records` +and `next_offset`. Each record is the retained Evidence itself, with an +unchanged ID, subject digest, provider, profile and readback, so it works for +any operation, including records that `inspect_analysis_view` does not support +such as annotations. `missing_evidence_links` lists direct `evidence_links` of +the returned records that this session or bundle does not retain; links are not +traversed. A selection is not an Evidence bundle: it omits manifests and +residual unknowns, cannot be imported, and describes historical observations +rather than current provider or database state. `offset` and `limit` require +`detail: "records"`. If one page exceeds the response budget, select fewer +records or one `evidence_id`; a single record larger than one response still +needs `export_evidence_bundle`, or `inspect_analysis_view` pages where its parent +supports views. Residual-unknown mutations reject missing Evidence or unknown references and unproven verified resolutions with nonretryable `invalid_request` diagnostics. @@ -371,6 +390,7 @@ fit. For a portable exported bundle, the CLI shares this projection: `rea inspect-evidence-bundle '{"path":"/tmp/evidence.json","detail":"summary","filters":{"operation":"analyze_function"}}' --format json`. +`"detail":"records"` with an `evidence_id` filter returns that complete record. The CLI validates and materializes that local bundle under its existing memory budget; summary delivery does not make arbitrary large files cheap to import. diff --git a/docs/wasm-artifacts.md b/docs/wasm-artifacts.md index 571214dd7f..cceda7584f 100644 --- a/docs/wasm-artifacts.md +++ b/docs/wasm-artifacts.md @@ -83,7 +83,8 @@ valid: partial results state whether WABT validation is unknown, rejected the module, or succeeded before a later inspection failure. CLI errors include this partial Evidence inline. MCP errors can return its `retained-evidence` reference in `details.partial_observation`; use -`get_evidence_bundle` with `detail: "complete"` to retrieve the matching record. +`get_evidence_bundle` with `detail: "records"` and that `evidence_id` filter to +retrieve the matching record. Core validation remains authoritative when an optional custom section cannot be interpreted. WABT warning output is preserved in `custom_section_warnings`; diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts index b103089cbf..43b76ceabb 100644 --- a/src/application/EvidenceBundleCommands.ts +++ b/src/application/EvidenceBundleCommands.ts @@ -292,8 +292,10 @@ const projectWrite = ( export const inspectEvidenceBundleCommand = async ( input: { readonly path: string; - readonly detail: "complete" | "summary"; + readonly detail: "complete" | "summary" | "records"; readonly filters?: EvidenceRecordFilters | undefined; + readonly offset?: number | undefined; + readonly limit?: number | undefined; }, signal?: AbortSignal, ) => { diff --git a/src/application/investigation/InspectEvidenceBundle.ts b/src/application/investigation/InspectEvidenceBundle.ts index 755440f727..8831becc2f 100644 --- a/src/application/investigation/InspectEvidenceBundle.ts +++ b/src/application/investigation/InspectEvidenceBundle.ts @@ -2,19 +2,26 @@ import type { EvidenceBundle } from "../../domain/evidenceBundle.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../../domain/result.js"; import { + selectEvidenceRecords, summarizeEvidenceBundle, type EvidenceBundleSummary, type EvidenceRecordFilters, + type EvidenceRecordSelection, } from "../../domain/evidenceRecordSummary.js"; -/** Select complete bundle delivery or discovery metadata without analysis payloads. */ +/** Select complete bundle delivery, discovery metadata, or a page of complete selected records. */ export const inspectEvidenceBundle = ( bundle: EvidenceBundle, options: { - readonly detail: "complete" | "summary"; + readonly detail: "complete" | "summary" | "records"; readonly filters?: EvidenceRecordFilters | undefined; + readonly offset?: number | undefined; + readonly limit?: number | undefined; }, -): Result => { +): Result< + EvidenceBundle | EvidenceBundleSummary | EvidenceRecordSelection, + AnalysisInputError +> => { if (options.detail === "complete" && options.filters !== undefined) return err( new AnalysisInputError("get_evidence_bundle", undefined, [ @@ -22,11 +29,36 @@ export const inspectEvidenceBundle = ( path: ["filters"], reason: "invalid_value", message: - "Record filters require detail: summary; complete returns the canonical bundle.", - expected: "detail: summary", + "Record filters require detail: summary or records; complete returns the canonical bundle.", + expected: "detail: summary or records", }, ]), ); + if (options.detail !== "records") { + const paging = (["offset", "limit"] as const).filter( + (field) => options[field] !== undefined, + ); + if (paging.length > 0) + return err( + new AnalysisInputError( + "get_evidence_bundle", + undefined, + paging.map((field) => ({ + path: [field], + reason: "invalid_value" as const, + message: `${field} pages complete records and requires detail: records.`, + expected: "detail: records", + })), + ), + ); + } + if (options.detail === "records") + return ok( + selectEvidenceRecords(bundle, options.filters, { + offset: options.offset, + limit: options.limit, + }), + ); return ok( options.detail === "summary" ? summarizeEvidenceBundle(bundle, options.filters) diff --git a/src/cli/analysisViewCommands.ts b/src/cli/analysisViewCommands.ts index 7ac18af53f..a6fea44e69 100644 --- a/src/cli/analysisViewCommands.ts +++ b/src/cli/analysisViewCommands.ts @@ -26,12 +26,12 @@ export const registerAnalysisViewCommands = ( ): void => { cli.command(CLI_COMMANDS.inspectEvidenceBundle, { description: - "Inspect complete or selected discovery metadata from a local Evidence bundle", + "Inspect a local Evidence bundle completely, as discovery metadata, or as a page of selected complete records", args: z.object({ inputJson: z .string() .describe( - "JSON or JSON file with absolute path, detail and optional record filters", + "JSON or JSON file with absolute path, detail, optional record filters, and records offset/limit", ), }), run: ({ args }) => diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index 160991addb..87af0a7368 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -173,7 +173,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "get_evidence_bundle", - "Return every retained Evidence record and residual unknown as a complete canonical bundle by default. detail=summary discovers retained records without copying or transferring their payloads; optionally filter by exact Evidence ID, operation, target SHA-256, analysis-profile digest or canonical native procedure address. Metadata distinguishes complete record retention from native value-flow truncation or unavailable/unknown coverage. Use a discovered evidence_id with inspect_analysis_view without restarting a provider. Filters apply only to summary delivery.", + "Return every retained Evidence record and residual unknown as a complete canonical bundle by default. detail=summary discovers retained records without copying or transferring their payloads; optionally filter by exact Evidence ID, operation, target SHA-256, analysis-profile digest or canonical native procedure address. Metadata distinguishes complete record retention from native value-flow truncation or unavailable/unknown coverage. detail=records returns a page (offset, limit) of the matching complete records with unchanged IDs and their missing direct evidence_links; it is not an importable bundle. Use a discovered evidence_id with inspect_analysis_view without restarting a provider. Filters apply to summary and records delivery.", getEvidenceBundleInputSchema, ), { diff --git a/src/contracts/sessionToolSchemas.ts b/src/contracts/sessionToolSchemas.ts index 5f0926517c..a8a0f31ada 100644 --- a/src/contracts/sessionToolSchemas.ts +++ b/src/contracts/sessionToolSchemas.ts @@ -4,13 +4,28 @@ import { absoluteLocalPathStringSchema } from "../domain/localPath.js"; import { bundleComparisonInputSchema } from "../domain/bundleComparison.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; -import { evidenceRecordFiltersSchema } from "../domain/evidenceRecordSummary.js"; +import { + EVIDENCE_RECORD_SELECTION_LIMIT, + evidenceRecordFiltersSchema, +} from "../domain/evidenceRecordSummary.js"; import { residualUnknownSchema } from "../domain/residualUnknown.js"; -/** Complete retained Evidence, or selected discovery metadata without payloads. */ +/** Complete retained Evidence, selected discovery metadata, or a page of selected complete records. */ export const getEvidenceBundleInputSchema = z.strictObject({ - detail: z.enum(["complete", "summary"]).default("complete"), + detail: z.enum(["complete", "summary", "records"]).default("complete"), filters: evidenceRecordFiltersSchema.optional(), + offset: z + .int() + .nonnegative() + .optional() + .describe("records only: first matching record to return; default 0."), + limit: z + .int() + .positive() + .optional() + .describe( + `records only: maximum complete records to return; default ${String(EVIDENCE_RECORD_SELECTION_LIMIT)}.`, + ), }); /** Optional document selection for volatile navigation context. */ diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index b54e5dffdf..88434a744f 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -42,7 +42,10 @@ import { } from "../domain/investigationLifecycle.js"; import { jsonRecordSchema } from "../domain/jsonRecord.js"; import { byteSearchResultSchema } from "../domain/native/bytePattern.js"; -import { evidenceBundleSummarySchema } from "../domain/evidenceRecordSummary.js"; +import { + evidenceBundleSummarySchema, + evidenceRecordSelectionSchema, +} from "../domain/evidenceRecordSummary.js"; import { liveBinaryTargetSummaryVariants } from "../domain/liveBinaryTargetSummary.js"; import { nativeFunctionAnnotationsSchema } from "../domain/native/nativeFunctionAnnotations.js"; import { nativeLoadImageSchema } from "../domain/native/nativeLoadImage.js"; @@ -574,7 +577,11 @@ export const sessionOutputSchemas = { ), export_evidence_bundle: lifecycleResultOf(evidenceBundleExportReceiptSchema), get_evidence_bundle: lifecycleResultOf( - z.union([evidenceBundleSchema, evidenceBundleSummarySchema]), + z.union([ + evidenceBundleSchema, + evidenceBundleSummarySchema, + evidenceRecordSelectionSchema, + ]), ), get_navigation_context: lifecycleResultOf( z.object({ diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index aee472551c..0959da3c64 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -48,7 +48,10 @@ export const MCP_TRANSPORT_EVIDENCE_EXPORT = "Export complete retained session evidence through export_evidence_bundle to a caller-selected path, or use complete CLI JSON output. The connection remains usable."; /** Next step when one MCP frame cannot carry a complete retained session bundle. */ -export const MCP_TRANSPORT_EVIDENCE_DISCOVERY = `Discover retained records with get_evidence_bundle and detail: summary, then inspect compatible records through inspect_analysis_view using an exact Evidence reference. ${MCP_TRANSPORT_EVIDENCE_EXPORT}`; +export const MCP_TRANSPORT_EVIDENCE_DISCOVERY = `Discover retained records with get_evidence_bundle and detail: summary, then read selected complete records with detail: records and an exact evidence_id, or inspect compatible records through inspect_analysis_view using an exact Evidence reference. ${MCP_TRANSPORT_EVIDENCE_EXPORT}`; + +/** Next step when one MCP frame cannot carry a selected page of complete records. */ +export const MCP_TRANSPORT_EVIDENCE_RECORDS = `Request fewer records with get_evidence_bundle detail: records, a smaller limit or an exact evidence_id filter; inspect compatible records in pages through inspect_analysis_view. A single record larger than one response needs export. ${MCP_TRANSPORT_EVIDENCE_EXPORT}`; export const analysisErrorRemediationAction = ( error: AnalysisError, diff --git a/src/domain/evidenceRecordSummary.ts b/src/domain/evidenceRecordSummary.ts index 47653431f8..01c4ed2278 100644 --- a/src/domain/evidenceRecordSummary.ts +++ b/src/domain/evidenceRecordSummary.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { uniqueSorted } from "./canonicalOrdering.js"; import { digestSchema, prefixedDigestSchema } from "./digests.js"; import { evidenceSchema, type Evidence } from "./evidence.js"; import type { EvidenceBundle } from "./evidenceBundle.js"; @@ -107,15 +108,24 @@ const nativeSummary = ( }; }; -/** Discover retained Evidence without reading or copying its complete payloads. */ -export const summarizeEvidenceBundle = ( - bundle: EvidenceBundle, - filters: EvidenceRecordFilters = {}, -): EvidenceBundleSummary => { - const selectedFilters: Record = {}; +/** Caller-selected filters with omitted fields removed, echoed by every selection. */ +const selectedFilters = ( + filters: EvidenceRecordFilters, +): Record => { + const selected: Record = {}; for (const [field, value] of Object.entries(filters)) - if (value !== undefined) selectedFilters[field] = value; - const records: EvidenceBundleSummary["records"] = []; + if (value !== undefined) selected[field] = value; + return selected; +}; + +/** One owner for exact filter semantics, in canonical bundle (Evidence ID) order. */ +function* matchingEvidenceRecords( + bundle: EvidenceBundle, + filters: EvidenceRecordFilters, +): Generator<{ + readonly evidence: Evidence; + readonly native: z.infer; +}> { for (const evidence of bundle.records) { if ( (filters.evidence_id !== undefined && @@ -135,6 +145,17 @@ export const summarizeEvidenceBundle = ( filters.procedure_address !== native.procedure_address) ) continue; + yield { evidence, native }; + } +} + +/** Discover retained Evidence without reading or copying its complete payloads. */ +export const summarizeEvidenceBundle = ( + bundle: EvidenceBundle, + filters: EvidenceRecordFilters = {}, +): EvidenceBundleSummary => { + const records: EvidenceBundleSummary["records"] = []; + for (const { evidence, native } of matchingEvidenceRecords(bundle, filters)) records.push({ evidence_id: evidence.evidence_id, subject: evidence.subject, @@ -147,13 +168,78 @@ export const summarizeEvidenceBundle = ( limitation_count: evidence.limitations.length, native_dossier: native, }); - } return { kind: "evidence-bundle-summary", - filters: selectedFilters, + filters: selectedFilters(filters), total_retained_records: bundle.records.length, matching_records: records.length, unknown_revisions: bundle.unknowns.length, records, }; }; + +/** Default page size for complete record selection; each record may be large. */ +export const EVIDENCE_RECORD_SELECTION_LIMIT = 8; + +/** Labels every record selection as historical and as distinct from an importable bundle. */ +export const EVIDENCE_RECORD_SELECTION_LIMITATIONS = [ + "Selected records are retained historical observations; they do not establish current live provider or database state.", + "A record selection is not an Evidence bundle: it omits manifests and residual unknowns and cannot be imported. Use export_evidence_bundle for a portable bundle.", +] as const; + +/** Complete selected records with their retained identities and direct-link closure. */ +export const evidenceRecordSelectionSchema = z.strictObject({ + kind: z.literal("evidence-record-selection"), + filters: z.record(z.string(), z.string()), + total_retained_records: countSchema, + matching_records: countSchema, + offset: countSchema, + limit: z.int().positive(), + next_offset: countSchema.nullable(), + records: z.array(evidenceSchema), + missing_evidence_links: z + .array(prefixedDigestSchema("ev")) + .describe( + "Direct evidence_links of the returned records that this session or bundle does not retain; links are not traversed.", + ), + limitations: z.array(z.string()), +}); +export type EvidenceRecordSelection = z.infer< + typeof evidenceRecordSelectionSchema +>; + +/** Select one page of complete authenticated records without copying the rest of the ledger. */ +export const selectEvidenceRecords = ( + bundle: EvidenceBundle, + filters: EvidenceRecordFilters = {}, + page: { + readonly offset?: number | undefined; + readonly limit?: number | undefined; + } = {}, +): EvidenceRecordSelection => { + const offset = page.offset ?? 0; + const limit = page.limit ?? EVIDENCE_RECORD_SELECTION_LIMIT; + const records: Evidence[] = []; + let matching = 0; + for (const { evidence } of matchingEvidenceRecords(bundle, filters)) { + if (matching >= offset && records.length < limit) records.push(evidence); + matching += 1; + } + const retained = new Set(bundle.records.map(({ evidence_id: id }) => id)); + const missing = records.flatMap(({ evidence_links: links }) => + links.filter((link) => !retained.has(link)), + ); + const end = offset + records.length; + return { + kind: "evidence-record-selection", + filters: selectedFilters(filters), + total_retained_records: bundle.records.length, + matching_records: matching, + offset, + limit, + next_offset: end < matching ? end : null, + records, + missing_evidence_links: uniqueSorted(missing), + limitations: [...EVIDENCE_RECORD_SELECTION_LIMITATIONS], + }; +}; diff --git a/src/server/ToolResultDelivery.ts b/src/server/ToolResultDelivery.ts index d92c620d87..aa2ef61568 100644 --- a/src/server/ToolResultDelivery.ts +++ b/src/server/ToolResultDelivery.ts @@ -16,6 +16,7 @@ import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js" import { MCP_TRANSPORT_EVIDENCE_DISCOVERY, MCP_TRANSPORT_EVIDENCE_EXPORT, + MCP_TRANSPORT_EVIDENCE_RECORDS, } from "../domain/analysisErrorPresentation.js"; import { nodeStringCeilingRecovery, @@ -61,10 +62,10 @@ export const mcpResponseOverflowProjection = (input: { }; /** - * A summary projection has already used `detail: "summary"`. - * Export still writes the retained bundle outside one MCP frame. + * The bundle projection a get_evidence_bundle result already selected. + * Each projection owns a different next step when it exceeds one MCP frame. */ -const evidenceBundleSummary = (value: JsonValue): boolean => { +const evidenceBundleProjection = (value: JsonValue): JsonValue | undefined => { const payload = typeof value === "object" && value !== null && @@ -72,12 +73,21 @@ const evidenceBundleSummary = (value: JsonValue): boolean => { "result" in value ? value.result : value; - return ( - typeof payload === "object" && + return typeof payload === "object" && payload !== null && - !Array.isArray(payload) && - payload.kind === "evidence-bundle-summary" - ); + !Array.isArray(payload) + ? payload.kind + : undefined; +}; + +const evidenceBundleOverflowStep = (value: JsonValue): string => { + const projection = evidenceBundleProjection(value); + // A summary already used detail: summary; export writes outside one frame. + if (projection === "evidence-bundle-summary") + return MCP_TRANSPORT_EVIDENCE_EXPORT; + return projection === "evidence-record-selection" + ? MCP_TRANSPORT_EVIDENCE_RECORDS + : MCP_TRANSPORT_EVIDENCE_DISCOVERY; }; /** Immutable encoding and recovery policy shared by one MCP server. */ @@ -253,9 +263,7 @@ export class ToolResultDelivery { : contract.name === "list_unknowns" ? "Call list_unknowns on this same connection with the same filters and offset, using a smaller limit, then follow next_offset. If a single unknown cannot fit, export the session with export_evidence_bundle to recover it." : contract.name === "get_evidence_bundle" - ? evidenceBundleSummary(value) - ? MCP_TRANSPORT_EVIDENCE_EXPORT - : MCP_TRANSPORT_EVIDENCE_DISCOVERY + ? evidenceBundleOverflowStep(value) : "Use complete CLI JSON output to consume the analysis."; const projected = mcpResponseOverflowProjection({ completed: true, diff --git a/tests/boundary/cli/analysisViewCli.test.ts b/tests/boundary/cli/analysisViewCli.test.ts index 5b462c3b67..2f1d42a2a0 100644 --- a/tests/boundary/cli/analysisViewCli.test.ts +++ b/tests/boundary/cli/analysisViewCli.test.ts @@ -622,6 +622,24 @@ cliTest( const expected = inspectEvidenceBundle(bundle, options); if (!expected.ok) throw expected.error; expect(result.json).toEqual(expected.value); + // The same shared owner selects the complete record from the portable file. + const records = await cli.run({ + arguments: [ + "inspect-evidence-bundle", + JSON.stringify({ path, ...options, detail: "records", limit: 1 }), + "--json", + ], + environment: { HOME: root, XDG_CONFIG_HOME: root, XDG_CACHE_HOME: root }, + }); + expect(records.exitCode).toBe(0); + expect(records.json).toMatchObject({ + kind: "evidence-record-selection", + matching_records: 1, + limit: 1, + next_offset: null, + records: [parent], + missing_evidence_links: [], + }); const complete = await cli.run({ arguments: [ "inspect-evidence-bundle", diff --git a/tests/boundary/mcp/evidenceRecordSelectionMcp.test.ts b/tests/boundary/mcp/evidenceRecordSelectionMcp.test.ts new file mode 100644 index 0000000000..86d267d16d --- /dev/null +++ b/tests/boundary/mcp/evidenceRecordSelectionMcp.test.ts @@ -0,0 +1,286 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterAll, expect, it } from "vitest"; +import { z } from "zod"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { MCP_TRANSPORT_EVIDENCE_RECORDS } from "../../../src/domain/analysisErrorPresentation.js"; +import { + EVIDENCE_RECORD_SELECTION_LIMIT, + EVIDENCE_RECORD_SELECTION_LIMITATIONS, + evidenceRecordSelectionSchema, +} from "../../../src/domain/evidenceRecordSummary.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.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"; + +const schemaValidation = createMcpSchemaValidation(); +afterAll(() => schemaValidation.clear()); + +const GHIDRA = { id: "ghidra", name: "Ghidra", version: "12.1.4" }; +const target = (letter: string) => ({ + path: `/fixtures/${letter}.macho`, + format: "mach-o" as const, + sha256: letter.repeat(64), +}); + +// Synthetic records with the shapes the issue observed; no provider is run. +const annotation = (letter: string, name: string) => + createEvidence(target(letter), GHIDRA, { + operation: "annotate_native_function", + parameters: { procedure: "0x100000430", name }, + result: { procedure: { address: "0x100000430", name }, comment: "audit" }, + }); +const annotatedA = annotation("a", "audit_entry"); +const annotatedB = annotation("b", "other_entry"); +const pseudocode = createEvidence(target("a"), GHIDRA, { + operation: "procedure_pseudo_code", + parameters: { procedure: "0x100000430" }, + result: "int audit_entry(void) { return 0; }", +}); +const unretainedSource = createEvidence(target("a"), GHIDRA, { + operation: "xrefs", + parameters: { address: "0x100000430" }, + result: [], +}); +const trace = createEvidence(target("a"), GHIDRA, { + operation: "trace_feature", + parameters: { feature: "audit" }, + result: { references: 1 }, + evidenceLinks: [pseudocode.evidence_id, unretainedSource.evidence_id], +}); +const LEDGER = [annotatedA, pseudocode, annotatedB, trace]; +type Record_ = (typeof LEDGER)[number]; +/** Canonical bundle order: exact Evidence ID code-point order. */ +const canonical = (records: readonly Record_[]) => + [...records].sort((left, right) => + left.evidence_id < right.evidence_id ? -1 : 1, + ); + +const connect = async ( + options: { + readonly profile?: "full" | "compact"; + readonly maximumResponseBytes?: number; + readonly records?: readonly ReturnType[]; + } = {}, +) => { + const session = createTestBinarySession(() => { + throw new Error("Record selection must not open a provider"); + }); + const { client } = await createApplicationMcpHarness({ + session, + clientIdentity: { name: "evidence-record-selection", version: "1" }, + schemaValidator: schemaValidation, + serverOptions: { + environment: { + ...process.env, + REA_MCP_OUTPUT_SCHEMA_PROFILE: options.profile ?? "full", + }, + ...(options.maximumResponseBytes === undefined + ? {} + : { delivery: createToolResultDelivery(options.maximumResponseBytes) }), + }, + }); + for (const record of options.records ?? LEDGER) + expect(session.recordEvidence(record).ok).toBe(true); + const reply = (args: Record) => + client.callTool({ name: "get_evidence_bundle", arguments: args }); + const select = async (args: Record) => { + const result = await reply({ detail: "records", ...args }); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + return { + result, + selection: evidenceRecordSelectionSchema.parse( + z.object({ result: z.unknown() }).parse(result.structuredContent) + .result, + ), + }; + }; + return { client, session, reply, select }; +}; + +it.each(["full", "compact"] as const)( + "returns selected complete records with retained identities (%s)", + async (profile) => { + const { client, select } = await connect({ profile }); + const exact = await select({ + filters: { evidence_id: annotatedA.evidence_id }, + }); + expect(exact.selection).toMatchObject({ + kind: "evidence-record-selection", + filters: { evidence_id: annotatedA.evidence_id }, + total_retained_records: LEDGER.length, + matching_records: 1, + offset: 0, + limit: EVIDENCE_RECORD_SELECTION_LIMIT, + next_offset: null, + missing_evidence_links: [], + }); + // The readback is the retained record itself, authenticated under its original ID. + expect(exact.selection.records).toEqual([annotatedA]); + expect(parseEvidence(exact.selection.records[0]).evidence_id).toBe( + annotatedA.evidence_id, + ); + // Historical and non-bundle labels come from their owning constant. + expect(exact.selection.limitations).toEqual([ + ...EVIDENCE_RECORD_SELECTION_LIMITATIONS, + ]); + + const { tools } = await client.listTools(); + const tool = tools.find(({ name }) => name === "get_evidence_bundle")!; + const ajv = schemaValidation.assertions; + expect(ajv.validateSchema(tool.inputSchema)).toBe(true); + expect( + ajv.validate(tool.inputSchema, { + detail: "records", + filters: { operation: "trace_feature" }, + offset: 0, + limit: 1, + }), + ).toBe(true); + const outputSchema: Record = tool.outputSchema!; + expect(ajv.validate(outputSchema, exact.result.structuredContent)).toBe( + true, + ); + + // Pages follow canonical bundle order across distinct targets. + const annotations = canonical([annotatedA, annotatedB]); + const first = await select({ + filters: { operation: "annotate_native_function" }, + limit: 1, + }); + expect(first.selection).toMatchObject({ + matching_records: 2, + next_offset: 1, + records: [annotations[0]], + }); + const second = await select({ + filters: { operation: "annotate_native_function" }, + offset: 1, + limit: 1, + }); + expect(second.selection).toMatchObject({ + next_offset: null, + records: [annotations[1]], + }); + expect( + (await select({ filters: { target_sha256: "a".repeat(64) } })).selection + .records, + ).toEqual(canonical([annotatedA, pseudocode, trace])); + expect( + (await select({ filters: { operation: "absent" } })).selection, + ).toMatchObject({ matching_records: 0, records: [], next_offset: null }); + expect((await select({ offset: LEDGER.length })).selection).toMatchObject({ + matching_records: LEDGER.length, + records: [], + }); + }, +); + +it("reports only the selected records' unretained direct links", async () => { + const { session, select } = await connect(); + const selected = await select({ + filters: { evidence_id: trace.evidence_id }, + }); + expect(selected.selection.records).toEqual([trace]); + expect(selected.selection.missing_evidence_links).toEqual([ + unretainedSource.evidence_id, + ]); + // Unselected records never contribute closure facts. + expect( + (await select({ filters: { operation: "annotate_native_function" } })) + .selection.missing_evidence_links, + ).toEqual([]); + expect(session.recordEvidence(unretainedSource).ok).toBe(true); + expect( + (await select({ filters: { evidence_id: trace.evidence_id } })).selection, + ).toMatchObject({ records: [trace], missing_evidence_links: [] }); +}); + +it("selects the same records after importing an exported bundle", async () => { + const exporter = await connect(); + const root = await createTestTempDirectory("rea-record-selection-"); + const path = join(root, "evidence.json"); + await writeFile( + path, + JSON.stringify(exporter.session.exportEvidenceBundle()), + ); + const importer = await connect({ records: [] }); + const imported = await importer.client.callTool({ + name: "import_evidence_bundle", + arguments: { path }, + }); + expect(imported.isError, JSON.stringify(imported)).not.toBe(true); + expect( + ( + await importer.select({ + filters: { evidence_id: annotatedB.evidence_id }, + }) + ).selection.records, + ).toEqual([annotatedB]); +}); + +it("keeps canonical bundle and summary contracts distinct from record pages", async () => { + const { reply } = await connect(); + for (const [args, path] of [ + [ + { detail: "complete", filters: { operation: "trace_feature" } }, + "filters", + ], + [{ detail: "summary", limit: 1 }, "limit"], + [{ detail: "complete", offset: 0 }, "offset"], + [{ limit: 1 }, "limit"], + ] as const) + expect(parseMcpToolError(await reply(args))).toMatchObject({ + error: { + code: "invalid_request", + details: { issues: [expect.objectContaining({ path: [path] })] }, + }, + }); + expect( + parseMcpToolError(await reply({ detail: "records", limit: 0 })), + ).toMatchObject({ error: { code: "invalid_request" } }); + const summary = await reply({ + detail: "summary", + filters: { evidence_id: trace.evidence_id }, + }); + expect(summary.structuredContent).toMatchObject({ + result: { kind: "evidence-bundle-summary", matching_records: 1 }, + }); +}); + +it("guides an oversized record page toward a smaller selection", async () => { + const large = createEvidence(target("c"), GHIDRA, { + operation: "procedure_pseudo_code", + parameters: { procedure: "0x1000" }, + result: "x".repeat(32 * 1024), + }); + const { reply, select } = await connect({ + maximumResponseBytes: 16 * 1024, + records: [...LEDGER, large], + }); + const failed = parseMcpToolError(await reply({ detail: "records" })).error; + expect(failed.code).toBe("resource_constraint"); + // Guidance comes from its owning value; the limit facts are checked separately. + expect(failed.remediation?.action).toContain(MCP_TRANSPORT_EVIDENCE_RECORDS); + const limits = z + .object({ + details: z.object({ + reported_limits: z.object({ + result_budget_bytes: z.number(), + response_bytes_at_least: z.number(), + }), + }), + }) + .parse(failed).details.reported_limits; + expect(limits.response_bytes_at_least).toBeGreaterThan( + limits.result_budget_bytes, + ); + // An exact selection that excludes the large record still fits. + expect( + (await select({ filters: { evidence_id: annotatedA.evidence_id } })) + .selection.records, + ).toEqual([annotatedA]); +});