diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index ce4c7b7510..a08d611f80 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -390,9 +390,26 @@ that revision. If a list response overflows, retry on the same connection with the same filters and offset and a smaller limit; export if one head still cannot fit. +To find missing sources before a downstream analysis refuses them, read +`missing_direct_links` from the summary, or call `get_evidence_bundle` with +`detail: "links"`. The result (`kind: "evidence-link-inspection"`) transfers no +record payload. `filters` select the starting records, while membership is +checked against every record in the session or file, so a filtered-out source is +`retained`, not missing. Rows (`evidence_id`, `linked_evidence_id`, `status`) are +in canonical order, default to `link_status: "missing"` and page with `offset` and +`limit` (default 64); `links` counts every inspected link regardless of that +selection. `traversal: "direct"` (the default) reads the selected records' own +links and sets `retained_closure` to null, because it makes no closure claim. +`traversal: "retained-closure"` also follows retained links, as composed workflows +such as `build_call_path` do, and reports `complete` or `incomplete`; anything +beyond a missing link remains unknown. Missing means absent from this session or +file, not nonexistent, and neither status describes analysis coverage. Importing +a missing source changes this membership without changing any Evidence ID. + 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. +`"detail":"records"` with an `evidence_id` filter returns that complete record, +and `"detail":"links"` reads link membership. 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/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts index d88e555dd5..26bf80e437 100644 --- a/src/application/EvidenceBundleCommands.ts +++ b/src/application/EvidenceBundleCommands.ts @@ -297,10 +297,12 @@ const projectWrite = ( export const inspectEvidenceBundleCommand = async ( input: { readonly path: string; - readonly detail: "complete" | "summary" | "records"; + readonly detail: "complete" | "summary" | "records" | "links"; readonly filters?: EvidenceRecordFilters | undefined; readonly offset?: number | undefined; readonly limit?: number | undefined; + readonly traversal?: "direct" | "retained-closure" | undefined; + readonly link_status?: "missing" | "retained" | "all" | undefined; }, signal?: AbortSignal, ) => { diff --git a/src/application/investigation/InspectEvidenceBundle.ts b/src/application/investigation/InspectEvidenceBundle.ts index 8831becc2f..1a186bb02c 100644 --- a/src/application/investigation/InspectEvidenceBundle.ts +++ b/src/application/investigation/InspectEvidenceBundle.ts @@ -2,24 +2,41 @@ import type { EvidenceBundle } from "../../domain/evidenceBundle.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../../domain/result.js"; import { + inspectEvidenceLinks, selectEvidenceRecords, summarizeEvidenceBundle, type EvidenceBundleSummary, + type EvidenceLinkInspection, type EvidenceRecordFilters, type EvidenceRecordSelection, } from "../../domain/evidenceRecordSummary.js"; -/** Select complete bundle delivery, discovery metadata, or a page of complete selected records. */ +type InspectionDetail = "complete" | "summary" | "records" | "links"; + +/** Fields owned by particular deliveries, refused elsewhere rather than ignored. */ +const DELIVERY_FIELDS = [ + ["offset", ["records", "links"]], + ["limit", ["records", "links"]], + ["traversal", ["links"]], + ["link_status", ["links"]], +] as const; + +/** Select complete delivery, discovery metadata, complete records, or link membership. */ export const inspectEvidenceBundle = ( bundle: EvidenceBundle, options: { - readonly detail: "complete" | "summary" | "records"; + readonly detail: InspectionDetail; readonly filters?: EvidenceRecordFilters | undefined; readonly offset?: number | undefined; readonly limit?: number | undefined; + readonly traversal?: "direct" | "retained-closure" | undefined; + readonly link_status?: "missing" | "retained" | "all" | undefined; }, ): Result< - EvidenceBundle | EvidenceBundleSummary | EvidenceRecordSelection, + | EvidenceBundle + | EvidenceBundleSummary + | EvidenceRecordSelection + | EvidenceLinkInspection, AnalysisInputError > => { if (options.detail === "complete" && options.filters !== undefined) @@ -29,29 +46,34 @@ export const inspectEvidenceBundle = ( path: ["filters"], reason: "invalid_value", message: - "Record filters require detail: summary or records; complete returns the canonical bundle.", - expected: "detail: summary or records", + "Record filters require detail: summary, records or links; complete returns the canonical bundle.", + expected: "detail: summary, records or links", }, ]), ); - 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) => ({ + const misplaced = DELIVERY_FIELDS.filter( + ([field, details]) => + options[field] !== undefined && + !(details as readonly InspectionDetail[]).includes(options.detail), + ); + if (misplaced.length > 0) + return err( + new AnalysisInputError( + "get_evidence_bundle", + undefined, + misplaced.map(([field, details]) => { + const expected = details + .map((detail) => `detail: ${detail}`) + .join(" or "); + return { path: [field], reason: "invalid_value" as const, - message: `${field} pages complete records and requires detail: records.`, - expected: "detail: records", - })), - ), - ); - } + message: `${field} requires ${expected}.`, + expected, + }; + }), + ), + ); if (options.detail === "records") return ok( selectEvidenceRecords(bundle, options.filters, { @@ -59,6 +81,15 @@ export const inspectEvidenceBundle = ( limit: options.limit, }), ); + if (options.detail === "links") + return ok( + inspectEvidenceLinks(bundle, options.filters, { + traversal: options.traversal, + linkStatus: options.link_status, + 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 a6fea44e69..427ab6709b 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 a local Evidence bundle completely, as discovery metadata, or as a page of selected complete records", + "Inspect a local Evidence bundle completely, as discovery metadata, as a page of selected complete records, or as direct-link and retained-closure membership.", args: z.object({ inputJson: z .string() .describe( - "JSON or JSON file with absolute path, detail, optional record filters, and records offset/limit", + "JSON or JSON file with absolute path, detail, optional record filters, records or links offset/limit, and links traversal/link_status", ), }), run: ({ args }) => diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index 87af0a7368..44d5655bf6 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. 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.", + "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. detail=links reads which evidence_links of the selected records this session or bundle retains, without their payloads: traversal retained-closure also follows retained links, the walk composed workflows require, and rows default to missing links. Use a discovered evidence_id with inspect_analysis_view without restarting a provider. Filters apply to summary, records and links delivery.", getEvidenceBundleInputSchema, ), { diff --git a/src/contracts/sessionToolSchemas.ts b/src/contracts/sessionToolSchemas.ts index a8a0f31ada..25c344d790 100644 --- a/src/contracts/sessionToolSchemas.ts +++ b/src/contracts/sessionToolSchemas.ts @@ -5,26 +5,41 @@ import { bundleComparisonInputSchema } from "../domain/bundleComparison.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; import { + EVIDENCE_LINK_INSPECTION_LIMIT, EVIDENCE_RECORD_SELECTION_LIMIT, + evidenceLinkStatusFilterSchema, + evidenceLinkTraversalSchema, evidenceRecordFiltersSchema, } from "../domain/evidenceRecordSummary.js"; import { residualUnknownSchema } from "../domain/residualUnknown.js"; -/** Complete retained Evidence, selected discovery metadata, or a page of selected complete records. */ +/** Complete retained Evidence, discovery metadata, a page of complete records, or link membership. */ export const getEvidenceBundleInputSchema = z.strictObject({ - detail: z.enum(["complete", "summary", "records"]).default("complete"), + detail: z + .enum(["complete", "summary", "records", "links"]) + .default("complete"), filters: evidenceRecordFiltersSchema.optional(), offset: z .int() .nonnegative() .optional() - .describe("records only: first matching record to return; default 0."), + .describe("records or links: first matching row to return; default 0."), limit: z .int() .positive() .optional() .describe( - `records only: maximum complete records to return; default ${String(EVIDENCE_RECORD_SELECTION_LIMIT)}.`, + `records: maximum complete records, default ${String(EVIDENCE_RECORD_SELECTION_LIMIT)}. links: maximum link rows, default ${String(EVIDENCE_LINK_INSPECTION_LIMIT)}.`, + ), + traversal: evidenceLinkTraversalSchema + .optional() + .describe( + "links only: direct (default) reads the selected records' own links; retained-closure also follows retained links.", + ), + link_status: evidenceLinkStatusFilterSchema + .optional() + .describe( + "links only: rows to return, default missing. Counts always cover every inspected link.", ), }); diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 669eb0a0e8..c418be82fd 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -46,6 +46,7 @@ import { jsonRecordSchema } from "../domain/jsonRecord.js"; import { byteSearchResultSchema } from "../domain/native/bytePattern.js"; import { evidenceBundleSummarySchema, + evidenceLinkInspectionSchema, evidenceRecordSelectionSchema, } from "../domain/evidenceRecordSummary.js"; import { liveBinaryTargetSummaryVariants } from "../domain/liveBinaryTargetSummary.js"; @@ -537,6 +538,7 @@ export const sessionOutputSchemas = { evidenceBundleSchema, evidenceBundleSummarySchema, evidenceRecordSelectionSchema, + evidenceLinkInspectionSchema, ]), ), get_navigation_context: lifecycleResultOf( diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 76a4e0ff40..2285303699 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -56,6 +56,10 @@ export const MCP_TRANSPORT_EVIDENCE_DISCOVERY = `Discover retained records with /** 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}`; +/** Next step when one MCP frame cannot carry a selected page of link rows. */ +export const MCP_TRANSPORT_EVIDENCE_LINKS = + "Request fewer rows with get_evidence_bundle detail: links, a smaller limit or an exact evidence_id filter, then follow next_offset."; + /** Name the next step that is true for how long the caller keeps REA's runtime and session. */ export const analysisErrorRemediationAction = ( error: AnalysisError, diff --git a/src/domain/evidenceRecordSummary.ts b/src/domain/evidenceRecordSummary.ts index 01c4ed2278..373d028fdd 100644 --- a/src/domain/evidenceRecordSummary.ts +++ b/src/domain/evidenceRecordSummary.ts @@ -53,6 +53,12 @@ export const evidenceBundleSummarySchema = z.strictObject({ total_retained_records: countSchema, matching_records: countSchema, unknown_revisions: countSchema, + direct_links: countSchema.describe( + "Distinct evidence_links of each matching record; detail links names them.", + ), + missing_direct_links: countSchema.describe( + "Those direct links whose records this session or bundle does not retain. Zero establishes no transitive closure or analysis coverage.", + ), records: z.array( z.strictObject({ evidence_id: evidenceSchema.shape.evidence_id, @@ -149,13 +155,24 @@ function* matchingEvidenceRecords( } } +/** A record's distinct direct links; the Evidence schema does not deduplicate them. */ +const directLinksOf = ({ evidence_links: links }: Evidence): string[] => + uniqueSorted(links); + /** 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)) + const retained = new Set(bundle.records.map(({ evidence_id: id }) => id)); + let directLinks = 0; + let missingDirectLinks = 0; + for (const { evidence, native } of matchingEvidenceRecords(bundle, filters)) { + for (const link of directLinksOf(evidence)) { + directLinks += 1; + if (!retained.has(link)) missingDirectLinks += 1; + } records.push({ evidence_id: evidence.evidence_id, subject: evidence.subject, @@ -168,12 +185,15 @@ export const summarizeEvidenceBundle = ( limitation_count: evidence.limitations.length, native_dossier: native, }); + } return { kind: "evidence-bundle-summary", filters: selectedFilters(filters), total_retained_records: bundle.records.length, matching_records: records.length, unknown_revisions: bundle.unknowns.length, + direct_links: directLinks, + missing_direct_links: missingDirectLinks, records, }; }; @@ -243,3 +263,142 @@ export const selectEvidenceRecords = ( limitations: [...EVIDENCE_RECORD_SELECTION_LIMITATIONS], }; }; + +/** How far link inspection reads from the selected records. */ +export const evidenceLinkTraversalSchema = z.enum([ + "direct", + "retained-closure", +]); +/** Which inspected links a page returns; counts always cover all of them. */ +export const evidenceLinkStatusFilterSchema = z.enum([ + "missing", + "retained", + "all", +]); + +/** Default page size for link rows, which carry two identifiers each. */ +export const EVIDENCE_LINK_INSPECTION_LIMIT = 64; + +/** Bounds what link membership establishes, in every inspection. */ +export const EVIDENCE_LINK_INSPECTION_LIMITATIONS = [ + "Link status is membership in this session or bundle: missing means absent here, not nonexistent, and neither status describes analysis coverage or a record's meaning.", +] as const; + +/** Direct links, or the retained closure, of selected records without their payloads. */ +export const evidenceLinkInspectionSchema = z.strictObject({ + kind: z.literal("evidence-link-inspection"), + filters: z.record(z.string(), z.string()), + traversal: evidenceLinkTraversalSchema, + link_status: evidenceLinkStatusFilterSchema, + total_retained_records: countSchema, + matching_records: countSchema, + examined_records: countSchema.describe( + "Retained records whose evidence_links were read: the matching records, plus those reached through retained links when traversal is retained-closure.", + ), + links: z + .strictObject({ + total: countSchema, + retained: countSchema, + missing: countSchema, + }) + .describe("Every link read from the examined records, before link_status."), + retained_closure: z + .enum(["complete", "incomplete"]) + .nullable() + .describe( + "With retained-closure traversal, incomplete means a link could not be followed, so what lies beyond it remains unknown. Null for direct traversal or an empty selection, which make no closure claim.", + ), + offset: countSchema, + limit: z.int().positive(), + matching_links: countSchema, + next_offset: countSchema.nullable(), + rows: z.array( + z.strictObject({ + evidence_id: prefixedDigestSchema("ev"), + linked_evidence_id: prefixedDigestSchema("ev"), + status: z.enum(["retained", "missing"]), + }), + ), + limitations: z.array(z.string()), +}); +export type EvidenceLinkInspection = z.infer< + typeof evidenceLinkInspectionSchema +>; + +/** Read link membership without copying or parsing any record payload. */ +export const inspectEvidenceLinks = ( + bundle: EvidenceBundle, + filters: EvidenceRecordFilters = {}, + options: { + readonly traversal?: + | z.infer + | undefined; + readonly linkStatus?: + | z.infer + | undefined; + readonly offset?: number | undefined; + readonly limit?: number | undefined; + } = {}, +): EvidenceLinkInspection => { + const traversal = options.traversal ?? "direct"; + const linkStatus = options.linkStatus ?? "missing"; + const offset = options.offset ?? 0; + const limit = options.limit ?? EVIDENCE_LINK_INSPECTION_LIMIT; + const retained = new Map( + bundle.records.map((record) => [record.evidence_id, record]), + ); + const examined = new Set(); + const pending: string[] = []; + for (const { evidence } of matchingEvidenceRecords(bundle, filters)) + pending.push(evidence.evidence_id); + const matching = pending.length; + while (pending.length > 0) { + const id = pending.pop(); + if (id === undefined || examined.has(id)) continue; + examined.add(id); + if (traversal === "direct") continue; + for (const link of retained.get(id)?.evidence_links ?? []) + if (retained.has(link) && !examined.has(link)) pending.push(link); + } + const rows: EvidenceLinkInspection["rows"] = []; + const counts = { total: 0, retained: 0, missing: 0 }; + let matchingLinks = 0; + // Bundle order is canonical, so a page stays stable across requests. + for (const evidence of bundle.records) { + const id = evidence.evidence_id; + if (!examined.has(id)) continue; + for (const link of directLinksOf(evidence)) { + const status = retained.has(link) ? "retained" : "missing"; + counts.total += 1; + counts[status] += 1; + if (linkStatus !== "all" && linkStatus !== status) continue; + if (matchingLinks >= offset && rows.length < limit) + rows.push({ evidence_id: id, linked_evidence_id: link, status }); + matchingLinks += 1; + } + } + const end = offset + rows.length; + return { + kind: "evidence-link-inspection", + filters: selectedFilters(filters), + traversal, + link_status: linkStatus, + total_retained_records: bundle.records.length, + matching_records: matching, + examined_records: examined.size, + links: counts, + // Nothing selected, or a direct read, supports no claim about a closure. + retained_closure: + traversal === "direct" || examined.size === 0 + ? null + : counts.missing === 0 + ? "complete" + : "incomplete", + offset, + limit, + matching_links: matchingLinks, + next_offset: end < matchingLinks ? end : null, + rows, + limitations: [...EVIDENCE_LINK_INSPECTION_LIMITATIONS], + }; +}; diff --git a/src/server/ToolResultDelivery.ts b/src/server/ToolResultDelivery.ts index f16ae5b30a..594ed43bcd 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_LINKS, MCP_TRANSPORT_EVIDENCE_RECORDS, } from "../domain/analysisErrorPresentation.js"; import { @@ -85,6 +86,8 @@ const evidenceBundleOverflowStep = (value: JsonValue): string => { // A summary already used detail: summary; export writes outside one frame. if (projection === "evidence-bundle-summary") return MCP_TRANSPORT_EVIDENCE_EXPORT; + if (projection === "evidence-link-inspection") + return MCP_TRANSPORT_EVIDENCE_LINKS; return projection === "evidence-record-selection" ? MCP_TRANSPORT_EVIDENCE_RECORDS : MCP_TRANSPORT_EVIDENCE_DISCOVERY; diff --git a/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts b/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts index f9aa6a7532..a0df8e8c2c 100644 --- a/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts +++ b/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts @@ -1,3 +1,4 @@ +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; import { build } from "plist"; import { lstat, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -216,11 +217,13 @@ cliTest.skipIf(process.platform !== "darwin")( const retained = await call("get_evidence_bundle", {}); expect(retained.isError).not.toBe(true); - const evidenceBundle = toolContract( - "get_evidence_bundle", - ).outputSchema.parse(retained.structuredContent); + const evidenceBundle = evidenceBundleSchema.parse( + toolContract("get_evidence_bundle").outputSchema.parse( + retained.structuredContent, + ).result, + ); for (const operation of resourceOperations) { - const record = evidenceBundle.result.records.find( + const record = evidenceBundle.records.find( (record) => record.operation === operation, ); expectResourceEvidence(record, operation, firstBundle); diff --git a/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts b/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts index e36bc6659a..a465ce02b8 100644 --- a/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts +++ b/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts @@ -1,3 +1,4 @@ +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; import { spawn } from "node:child_process"; import { once } from "node:events"; import { readFile, writeFile } from "node:fs/promises"; @@ -117,9 +118,11 @@ it("preserves file URL scheme spelling and script identity after real Inspector const retained = await call("get_evidence_bundle", {}); expect(retained.isError, JSON.stringify(retained.content)).not.toBe(true); - const records = toolContract("get_evidence_bundle").outputSchema.parse( - retained.structuredContent, - ).result.records; + const { records } = evidenceBundleSchema.parse( + toolContract("get_evidence_bundle").outputSchema.parse( + retained.structuredContent, + ).result, + ); const original = records.find( (record) => record.evidence_id === first.evidence_id, ); diff --git a/tests/acceptance/tools/reconstructionScopedUnknowns.test.ts b/tests/acceptance/tools/reconstructionScopedUnknowns.test.ts index 9ff527edda..0463442fe6 100644 --- a/tests/acceptance/tools/reconstructionScopedUnknowns.test.ts +++ b/tests/acceptance/tools/reconstructionScopedUnknowns.test.ts @@ -1,3 +1,4 @@ +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; import { expect, it } from "vitest"; import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; @@ -141,9 +142,11 @@ it.each(["pseudocode", "overall"] as const)( claims: { items: [{ unknown_ids: [] }] }, }); const exported = await call("get_evidence_bundle", { detail: "complete" }); - const bundle = toolContract("get_evidence_bundle").outputSchema.parse( - exported.structuredContent, - ).result; + const bundle = evidenceBundleSchema.parse( + toolContract("get_evidence_bundle").outputSchema.parse( + exported.structuredContent, + ).result, + ); for (const source of [left, right, comparison]) expect( bundle.records.find( diff --git a/tests/boundary/cli/analysisViewCli.test.ts b/tests/boundary/cli/analysisViewCli.test.ts index c0c10c3246..d4b0aac213 100644 --- a/tests/boundary/cli/analysisViewCli.test.ts +++ b/tests/boundary/cli/analysisViewCli.test.ts @@ -719,6 +719,54 @@ cliTest( }, ); +cliTest( + "reads link membership from a deliberately partial portable bundle", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-evidence-links-cli-"); + const path = join(root, "partial.json"); + const absent = analysisViewLayoutEvidence(); + const linked = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "trace_feature", + parameters: {}, + result: {}, + evidenceLinks: [absent.evidence_id], + }, + ); + const bundle = createEvidenceBundle([linked]); + await writeFile(path, JSON.stringify(bundle)); + const options = { + detail: "links", + traversal: "retained-closure", + } as const; + const result = await cli.run({ + arguments: [ + "inspect-evidence-bundle", + JSON.stringify({ path, ...options }), + "--json", + ], + environment: { HOME: root, XDG_CONFIG_HOME: root, XDG_CACHE_HOME: root }, + }); + expect(result.exitCode).toBe(0); + const expected = inspectEvidenceBundle(bundle, options); + if (!expected.ok) throw expected.error; + expect(result.json).toEqual(expected.value); + expect(result.json).toMatchObject({ + kind: "evidence-link-inspection", + retained_closure: "incomplete", + rows: [ + { + evidence_id: linked.evidence_id, + linked_evidence_id: absent.evidence_id, + status: "missing", + }, + ], + }); + }, +); + cliTest( "formats the full caller-admitted native inventory page as Markdown and JSON", async ({ cli }) => { diff --git a/tests/boundary/mcp/evidenceLinkInspectionMcp.test.ts b/tests/boundary/mcp/evidenceLinkInspectionMcp.test.ts new file mode 100644 index 0000000000..2981381ca3 --- /dev/null +++ b/tests/boundary/mcp/evidenceLinkInspectionMcp.test.ts @@ -0,0 +1,398 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterAll, expect, it } from "vitest"; +import { z } from "zod"; +import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; +import { MCP_TRANSPORT_EVIDENCE_LINKS } from "../../../src/domain/analysisErrorPresentation.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { + EVIDENCE_LINK_INSPECTION_LIMIT, + EVIDENCE_LINK_INSPECTION_LIMITATIONS, + evidenceBundleSummarySchema, + evidenceLinkInspectionSchema, +} 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 = { + path: "/fixtures/a.macho", + format: "mach-o" as const, + sha256: "a".repeat(64), +}; +const record = (operation: string, evidenceLinks: readonly string[] = []) => + createEvidence(TARGET, GHIDRA, { + operation, + parameters: {}, + result: { operation }, + evidenceLinks, + }); + +// top -> middle -> absent source; top -> leaf; unrelated stands alone. +const source = record("xrefs"); +const leaf = record("procedure_address"); +const middle = record("trace_feature", [source.evidence_id]); +const top = record("analyze_function_summary", [ + middle.evidence_id, + leaf.evidence_id, +]); +const unrelated = record("list_names"); +const PARTIAL = [top, middle, leaf, unrelated]; + +const connect = async ( + options: { + readonly profile?: "full" | "compact"; + readonly records?: readonly ReturnType[]; + readonly maximumResponseBytes?: number; + } = {}, +) => { + const session = createTestBinarySession(() => { + throw new Error("Link inspection must not open a provider"); + }); + const { client } = await createApplicationMcpHarness({ + session, + clientIdentity: { name: "evidence-link-inspection", version: "1" }, + schemaValidator: schemaValidation, + serverOptions: { + environment: { + ...process.env, + REA_MCP_INPUT_SCHEMA_PROFILE: options.profile ?? "full", + REA_MCP_OUTPUT_SCHEMA_PROFILE: options.profile ?? "full", + }, + ...(options.maximumResponseBytes === undefined + ? {} + : { delivery: createToolResultDelivery(options.maximumResponseBytes) }), + }, + }); + for (const retained of options.records ?? PARTIAL) + expect(session.recordEvidence(retained).ok).toBe(true); + const reply = (args: Record) => + client.callTool({ name: "get_evidence_bundle", arguments: args }); + // Every delivered result must satisfy what this profile actually advertises. + const { tools } = await client.listTools(); + const advertised = tools.find(({ name }) => name === "get_evidence_bundle"); + if (advertised?.outputSchema === undefined) + throw new Error("get_evidence_bundle advertises no output schema"); + const inputSchema: Record = advertised.inputSchema; + const outputSchema: Record = advertised.outputSchema; + const validation = schemaValidation.assertions; + expect(validation.validateSchema(inputSchema)).toBe(true); + expect(validation.validateSchema(outputSchema)).toBe(true); + const resultOf = async (args: Record) => { + expect( + validation.validate(inputSchema, args), + JSON.stringify(validation.errors), + ).toBe(true); + const result = await reply(args); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + expect( + validation.validate(outputSchema, result.structuredContent), + JSON.stringify(validation.errors), + ).toBe(true); + return z.object({ result: z.unknown() }).parse(result.structuredContent) + .result; + }; + const links = async (args: Record = {}) => + evidenceLinkInspectionSchema.parse( + await resultOf({ detail: "links", ...args }), + ); + const summary = async (args: Record = {}) => + evidenceBundleSummarySchema.parse( + await resultOf({ detail: "summary", ...args }), + ); + return { client, session, reply, links, summary }; +}; + +const exactly = (record_: { readonly evidence_id: string }) => ({ + filters: { evidence_id: record_.evidence_id }, +}); + +it.each(["full", "compact"] as const)( + "reads direct membership against the whole session, not the selection (%s)", + async (profile) => { + const { links, summary } = await connect({ profile }); + + // A retained-but-unselected source is retained, and direct makes no closure claim. + const selected = await links(exactly(top)); + expect(selected).toEqual({ + kind: "evidence-link-inspection", + filters: exactly(top).filters, + traversal: "direct", + link_status: "missing", + total_retained_records: 4, + matching_records: 1, + examined_records: 1, + links: { total: 2, retained: 2, missing: 0 }, + retained_closure: null, + offset: 0, + limit: EVIDENCE_LINK_INSPECTION_LIMIT, + matching_links: 0, + next_offset: null, + rows: [], + limitations: [...EVIDENCE_LINK_INSPECTION_LIMITATIONS], + }); + + // The record that names the absent source reports it without any payload. + const missing = await links(exactly(middle)); + expect(missing.links).toEqual({ total: 1, retained: 0, missing: 1 }); + expect(missing.rows).toEqual([ + { + evidence_id: middle.evidence_id, + linked_evidence_id: source.evidence_id, + status: "missing", + }, + ]); + expect(JSON.stringify(missing)).not.toContain("trace_feature"); + + // The summary counts direct links only for its matching records. + expect(await summary()).toMatchObject({ + matching_records: 4, + direct_links: 3, + missing_direct_links: 1, + }); + expect(await summary(exactly(top))).toMatchObject({ + matching_records: 1, + direct_links: 2, + missing_direct_links: 0, + }); + }, +); + +it("follows retained links only when asked and leaves the rest unknown", async () => { + const { links } = await connect(); + const walk = { traversal: "retained-closure" }; + + // Top looks closed directly, but its closure reaches the absent source. + const closure = await links({ ...exactly(top), ...walk }); + expect(closure).toMatchObject({ + traversal: "retained-closure", + matching_records: 1, + examined_records: 3, + links: { total: 3, retained: 2, missing: 1 }, + retained_closure: "incomplete", + rows: [ + { + evidence_id: middle.evidence_id, + linked_evidence_id: source.evidence_id, + status: "missing", + }, + ], + }); + + // A branch without an absent link is complete, including a record with no links. + expect(await links({ ...exactly(leaf), ...walk })).toMatchObject({ + examined_records: 1, + links: { total: 0, retained: 0, missing: 0 }, + retained_closure: "complete", + }); + + // Selecting nothing supports no claim about a closure. + expect( + await links({ filters: { operation: "absent_operation" }, ...walk }), + ).toMatchObject({ + matching_records: 0, + examined_records: 0, + links: { total: 0 }, + retained_closure: null, + rows: [], + }); +}); + +it("counts a repeated link once in every delivery", async () => { + const repeated = record("trace_feature", [ + source.evidence_id, + source.evidence_id, + ]); + const { links, summary } = await connect({ records: [repeated] }); + expect(await summary()).toMatchObject({ + direct_links: 1, + missing_direct_links: 1, + }); + const inspected = await links(); + expect(inspected.links).toEqual({ total: 1, retained: 0, missing: 1 }); + expect(inspected.rows).toHaveLength(1); +}); + +it("pages selected link rows in a stable order without skips or duplicates", async () => { + const { links } = await connect(); + const everything = await links({ link_status: "all" }); + expect(everything).toMatchObject({ + link_status: "all", + links: { total: 3, retained: 2, missing: 1 }, + matching_links: 3, + next_offset: null, + }); + const key = ({ + evidence_id: id, + linked_evidence_id: link, + }: (typeof everything.rows)[number]) => `${id}>${link}`; + const order = everything.rows.map(key); + expect(order).toEqual( + [...order].sort((left, right) => (left < right ? -1 : 1)), + ); + + const paged: string[] = []; + let offset: number | null = 0; + while (offset !== null) { + const page: z.output = await links({ + link_status: "all", + offset, + limit: 1, + }); + expect(page.rows).toHaveLength(1); + // Counts describe every inspected link on every page. + expect(page.links.total).toBe(3); + paged.push(...page.rows.map(key)); + offset = page.next_offset; + } + expect(paged).toEqual(order); + + const retainedOnly = await links({ link_status: "retained" }); + expect(retainedOnly.matching_links).toBe(2); + expect(retainedOnly.rows.every(({ status }) => status === "retained")).toBe( + true, + ); + expect(await links({ link_status: "all", offset: 9 })).toMatchObject({ + matching_links: 3, + next_offset: null, + rows: [], + }); +}); + +it("reports the source a composed workflow refuses, and clears it on import", async () => { + const dossier = FUNCTION_COMPARISON_EXAMPLE.left; + if (dossier.subject === null) throw new Error("Expected a subject"); + const health = record("get_project_health"); + const linked = createEvidence( + { + path: dossier.subject.local_path, + sha256: dossier.subject.digest.sha256, + format: dossier.subject.format, + }, + dossier.provider, + { + operation: dossier.operation, + parameters: dossier.parameters, + result: dossier.normalized_result, + confidence: dossier.confidence, + authority: dossier.authority, + evidenceLinks: [health.evidence_id], + }, + ); + const { client, links, summary } = await connect({ records: [linked] }); + const walk = { traversal: "retained-closure" }; + expect(await links(walk)).toMatchObject({ + retained_closure: "incomplete", + rows: [ + { + evidence_id: linked.evidence_id, + linked_evidence_id: health.evidence_id, + }, + ], + }); + expect(await summary()).toMatchObject({ missing_direct_links: 1 }); + + const procedure = z + .looseObject({ procedure: z.looseObject({ address: z.string() }) }) + .parse(dossier.normalized_result).procedure.address; + const callPath = () => + client.callTool({ + name: "build_call_path", + arguments: { + start: { address: procedure }, + goal: { address: procedure }, + functions: [ + { kind: "retained-evidence", evidence_id: linked.evidence_id }, + ], + }, + }); + // The inspection predicts the same absent ID the workflow refuses. + expect(JSON.stringify(parseMcpToolError(await callPath()))).toContain( + health.evidence_id, + ); + + const root = await createTestTempDirectory("rea-evidence-links-"); + const bundlePath = join(root, "source.json"); + await writeFile(bundlePath, JSON.stringify(createEvidenceBundle([health]))); + const imported = await client.callTool({ + name: "import_evidence_bundle", + arguments: { path: bundlePath }, + }); + expect(imported.isError, JSON.stringify(imported)).not.toBe(true); + + // Membership changes, the original record does not, and the workflow proceeds. + expect(await links(walk)).toMatchObject({ + examined_records: 2, + links: { total: 1, retained: 1, missing: 0 }, + retained_closure: "complete", + rows: [], + }); + expect(await summary()).toMatchObject({ missing_direct_links: 0 }); + const proceeded = await callPath(); + expect(proceeded.isError, JSON.stringify(proceeded)).not.toBe(true); + expect(parseEvidence(proceeded.structuredContent).evidence_links).toContain( + linked.evidence_id, + ); +}); + +it("refuses link selections outside link delivery and names a smaller page", async () => { + const { reply } = await connect(); + for (const [detail, field, value, expected] of [ + ["summary", "traversal", "direct", "detail: links"], + ["records", "link_status", "all", "detail: links"], + ["complete", "traversal", "retained-closure", "detail: links"], + ["summary", "offset", 1, "detail: records or detail: links"], + ["summary", "limit", 1, "detail: records or detail: links"], + ] as const) + expect( + parseMcpToolError(await reply({ detail, [field]: value })).error, + ).toMatchObject({ + code: "invalid_request", + details: { + issues: [expect.objectContaining({ path: [field], expected })], + }, + }); + expect( + parseMcpToolError( + await reply({ detail: "complete", filters: exactly(top).filters }), + ).error, + ).toMatchObject({ code: "invalid_request" }); + + // Many links on one record overflow a small response budget with a named step. + const many = Array.from( + { length: 64_000 }, + (_, index) => `ev_${index.toString(16).padStart(64, "0")}`, + ); + const crowded = await connect({ + records: [record("trace_feature", many)], + maximumResponseBytes: 10 * 1024 * 1024, + }); + const large = await crowded.reply({ detail: "links", limit: 64_000 }); + expect(parseMcpToolError(large).error).toMatchObject({ + code: "resource_constraint", + remediation: { + action: expect.stringContaining(MCP_TRANSPORT_EVIDENCE_LINKS), + }, + }); + expect( + evidenceLinkInspectionSchema.parse( + z + .object({ result: z.unknown() }) + .parse( + (await crowded.reply({ detail: "links", limit: 2 })) + .structuredContent, + ).result, + ), + ).toMatchObject({ + links: { total: 64_000, missing: 64_000 }, + next_offset: 2, + }); +}); diff --git a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts index 7660265016..ef1dac9294 100644 --- a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts +++ b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts @@ -356,9 +356,11 @@ describe("MCP composed workflow snapshot replay", () => { arguments: {}, }); expect(retained.isError, JSON.stringify(retained.content)).not.toBe(true); - const retainedBundle = toolContract( - "get_evidence_bundle", - ).outputSchema.parse(retained.structuredContent).result; + const retainedBundle = evidenceBundleSchema.parse( + toolContract("get_evidence_bundle").outputSchema.parse( + retained.structuredContent, + ).result, + ); expect( retainedBundle.records.find( ({ evidence_id }) => evidence_id === primitiveEvidence.evidence_id, diff --git a/tests/boundary/process/mcpVerifierResults.test.ts b/tests/boundary/process/mcpVerifierResults.test.ts index 0aa4891be6..a249e08f80 100644 --- a/tests/boundary/process/mcpVerifierResults.test.ts +++ b/tests/boundary/process/mcpVerifierResults.test.ts @@ -199,6 +199,8 @@ it("retains summary discovery in the generic lifecycle reader but refuses it as total_retained_records: 0, matching_records: 0, unknown_revisions: 0, + direct_links: 0, + missing_direct_links: 0, records: [], }; expect(