mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
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 <[email protected]> * 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 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
4b840aa1fb
commit
eb39a07966
15 files changed
+523
-41
No files matched your search
@@ -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`,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+21
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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,
|
||||
) => {
|
||||
|
||||
@@ -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<EvidenceBundle | EvidenceBundleSummary, AnalysisInputError> => {
|
||||
): 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)
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
{
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
/** Caller-selected filters with omitted fields removed, echoed by every selection. */
|
||||
const selectedFilters = (
|
||||
filters: EvidenceRecordFilters,
|
||||
): Record<string, string> => {
|
||||
const selected: Record<string, string> = {};
|
||||
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<typeof nativeSummarySchema>;
|
||||
}> {
|
||||
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],
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof createEvidence>[];
|
||||
} = {},
|
||||
) => {
|
||||
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<string, unknown>) =>
|
||||
client.callTool({ name: "get_evidence_bundle", arguments: args });
|
||||
const select = async (args: Record<string, unknown>) => {
|
||||
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<string, unknown> = 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]);
|
||||
});
|
||||
Reference in new issue
Block a user