From 17f704050f431c195ba2280aca162e698aea408a Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:18:26 +0800 Subject: [PATCH] feat(evidence): discover retained records without full payloads (#1682) * feat(evidence): discover retained records without full payloads * fix(evidence): guide oversized results to retained discovery * style(tests): format retained discovery recovery assertion --- .../skills/reverse-engineer-anything/SKILL.md | 10 ++ docs/mcp-contracts.md | 17 ++ .../investigation/InspectEvidenceBundle.ts | 35 ++++ src/cli/analysisViewCommands.ts | 53 ++++++ src/cliCommandNames.ts | 1 + src/contracts/sessionToolContracts.ts | 2 +- src/contracts/sessionToolSchemas.ts | 8 +- src/contracts/toolContractExamples.ts | 4 + src/contracts/toolOutputSchemaGroups.ts | 5 +- src/domain/analysisErrorPresentation.ts | 2 +- src/domain/evidenceRecordSummary.test.ts | 55 ++++++ src/domain/evidenceRecordSummary.ts | 166 ++++++++++++++++++ src/server/registerSessionRecordTools.ts | 5 +- tests/boundary/cli/analysisViewCli.test.ts | 47 +++++ tests/boundary/mcp/analysisViewMcp.test.ts | 111 ++++++++++++ 15 files changed, 514 insertions(+), 7 deletions(-) create mode 100644 src/application/investigation/InspectEvidenceBundle.ts create mode 100644 src/domain/evidenceRecordSummary.test.ts create mode 100644 src/domain/evidenceRecordSummary.ts diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 7e35c0db04..b46416786c 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -135,6 +135,16 @@ session to a selected path. The analysis remains complete in the ledger; avoid repeating it merely to request the same oversized response. Complete CLI JSON 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. +`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`, +`detail: "summary"` and the same `filters`. Reading that file still requires +validating its full contents within the local memory budget. + A client can also truncate a successful tool result without a REA transport error. Treat that preview as incomplete; use the retained parent ID for smaller views or export when the full record is needed. Do not parse the preview as diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 69ce9dce05..ee2c062738 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -308,6 +308,23 @@ that reference for a summary, one section/module, or a stable page, or call bundle exports stream canonical JSON into an atomically published file. A broad follow-up or `get_evidence_bundle` can also exceed the response budget; exporting preserves the complete session without sending it through a single MCP frame. +To discover existing records without their large payloads, call +`get_evidence_bundle` with `detail: "summary"`. Its `result.records` contain +Evidence IDs, artifact/provider identities, analysis-profile digests and native +procedure metadata. Optional `filters` match exact `evidence_id`, `operation`, +`target_sha256`, `analysis_profile_digest` or `procedure_address`. Use a discovered +ID with `inspect_analysis_view`; neither discovery nor retained selection starts +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. + +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`. +The CLI validates and materializes that local bundle under its existing memory +budget; summary delivery does not make arbitrary large files cheap to import. + Cancelling an export stops further serialization and removes its staging file. The destination changes only when a complete export is atomically published. diff --git a/src/application/investigation/InspectEvidenceBundle.ts b/src/application/investigation/InspectEvidenceBundle.ts new file mode 100644 index 0000000000..755440f727 --- /dev/null +++ b/src/application/investigation/InspectEvidenceBundle.ts @@ -0,0 +1,35 @@ +import type { EvidenceBundle } from "../../domain/evidenceBundle.js"; +import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { + summarizeEvidenceBundle, + type EvidenceBundleSummary, + type EvidenceRecordFilters, +} from "../../domain/evidenceRecordSummary.js"; + +/** Select complete bundle delivery or discovery metadata without analysis payloads. */ +export const inspectEvidenceBundle = ( + bundle: EvidenceBundle, + options: { + readonly detail: "complete" | "summary"; + readonly filters?: EvidenceRecordFilters | undefined; + }, +): Result => { + if (options.detail === "complete" && options.filters !== undefined) + return err( + new AnalysisInputError("get_evidence_bundle", undefined, [ + { + path: ["filters"], + reason: "invalid_value", + message: + "Record filters require detail: summary; complete returns the canonical bundle.", + expected: "detail: summary", + }, + ]), + ); + return ok( + options.detail === "summary" + ? summarizeEvidenceBundle(bundle, options.filters) + : bundle, + ); +}; diff --git a/src/cli/analysisViewCommands.ts b/src/cli/analysisViewCommands.ts index a193d27400..cbe63ab25e 100644 --- a/src/cli/analysisViewCommands.ts +++ b/src/cli/analysisViewCommands.ts @@ -1,6 +1,12 @@ import { z } from "incur"; import { inspectAnalysisViewValidated } from "../application/analysisView/AnalysisViewService.js"; +import { readEvidenceBundle } from "../application/EvidenceBundleFiles.js"; +import { inspectEvidenceBundle } from "../application/investigation/InspectEvidenceBundle.js"; +import { + getEvidenceBundleInputSchema, + importEvidenceBundleInputSchema, +} from "../contracts/sessionToolSchemas.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { parseCliJsonInput } from "../cliJsonInput.js"; import { logCliCommand } from "../cliLogging.js"; @@ -16,6 +22,53 @@ export const registerAnalysisViewCommands = ( cli: CliInstance, logger: Logger, ): void => { + cli.command(CLI_COMMANDS.inspectEvidenceBundle, { + description: + "Inspect complete or selected discovery metadata from a local Evidence bundle", + args: z.object({ + inputJson: z + .string() + .describe( + "JSON or JSON file with absolute path, detail and optional record filters", + ), + }), + run: ({ args }) => + logCliCommand(logger, CLI_COMMANDS.inspectEvidenceBundle, async () => { + const input = await parseCliJsonInput( + args.inputJson, + CLI_COMMANDS.inspectEvidenceBundle, + ); + if (!input.ok) return input.error; + const schema = getEvidenceBundleInputSchema.safeExtend({ + path: importEvidenceBundleInputSchema.shape.path, + }); + const parsed = schema.safeParse(input.value); + if (!parsed.success) + return { + error: "Evidence bundle inspection failed", + ...projectAnalysisError( + analysisInputErrorFromIssues( + CLI_COMMANDS.inspectEvidenceBundle, + parsed.error.issues, + input.value, + ), + ), + }; + const loaded = await readEvidenceBundle(parsed.data.path); + if (!loaded.ok) + return { + error: "Evidence bundle inspection failed", + ...projectAnalysisError(loaded.error), + }; + const result = inspectEvidenceBundle(loaded.value, parsed.data); + return result.ok + ? result.value + : { + error: "Evidence bundle inspection failed", + ...projectAnalysisError(result.error), + }; + }), + }); cli.command(CLI_COMMANDS.inspectAnalysisView, { description: "Project a selected view of completed layout, JavaScript application or native function Evidence JSON", diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 4312cbd556..507ac91568 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -43,6 +43,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectBinaryLayout: "inspect-binary-layout", inspectPeResources: "inspect-pe-resources", inspectAnalysisView: "inspect-analysis-view", + inspectEvidenceBundle: "inspect-evidence-bundle", inspectFirmwareRegions: "inspect-firmware-regions", extractFirmware: "extract-firmware", inspectAndroidPackage: "inspect-android-package", diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index ef99a809f6..e758335d76 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -159,7 +159,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "get_evidence_bundle", - "Return every Evidence record and residual unknown currently retained by this session as one inline bundle for direct inspection or follow-up workflows.", + "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.", getEvidenceBundleInputSchema, ), session( diff --git a/src/contracts/sessionToolSchemas.ts b/src/contracts/sessionToolSchemas.ts index 191acdb63f..5ef55b967e 100644 --- a/src/contracts/sessionToolSchemas.ts +++ b/src/contracts/sessionToolSchemas.ts @@ -6,9 +6,13 @@ import { localPathStringSchema } from "../domain/localPath.js"; import { processTraceSpecificationSchema } from "../domain/process/processTraceSpecification.js"; import { evidenceSchema } from "../domain/evidence.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; +import { evidenceRecordFiltersSchema } from "../domain/evidenceRecordSummary.js"; -/** Return the current canonical Evidence bundle inline. */ -export const getEvidenceBundleInputSchema = z.strictObject({}); +/** Complete retained Evidence, or selected discovery metadata without payloads. */ +export const getEvidenceBundleInputSchema = z.strictObject({ + detail: z.enum(["complete", "summary"]).default("complete"), + filters: evidenceRecordFiltersSchema.optional(), +}); /** Optional document selection for volatile navigation context. */ export const navigationContextInputSchema = z.strictObject({ diff --git a/src/contracts/toolContractExamples.ts b/src/contracts/toolContractExamples.ts index 42a6f49407..cdc65ee92b 100644 --- a/src/contracts/toolContractExamples.ts +++ b/src/contracts/toolContractExamples.ts @@ -53,6 +53,10 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly< trace_native_ui_action: { action: "buildTapped:" }, open_binary: { path: "/tmp/fixture" }, export_evidence_bundle: { path: "/tmp/evidence.json" }, + get_evidence_bundle: { + detail: "summary", + filters: { operation: "analyze_function", procedure_address: "0x1000" }, + }, inspect_address_context: { address: "0x1000" }, import_evidence_bundle: { path: "/tmp/evidence.json" }, capture_process_scenario: { diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index cf954083a5..f9b4c0b3a5 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { evidenceBundleSummarySchema } from "../domain/evidenceRecordSummary.js"; import { BINARY_ARCHITECTURES } from "../domain/binaryTargetTypes.js"; import { nativeFunctionAnnotationsSchema } from "../domain/native/nativeFunctionAnnotations.js"; import { nativeLoadImageSchema } from "../domain/native/nativeLoadImage.js"; @@ -470,7 +471,9 @@ export const sessionOutputSchemas = { unknowns: z.number().int().min(0), }), ), - get_evidence_bundle: lifecycleResultOf(evidenceBundleSchema), + get_evidence_bundle: lifecycleResultOf( + z.union([evidenceBundleSchema, evidenceBundleSummarySchema]), + ), get_navigation_context: lifecycleResultOf( z.object({ document: z.string(), diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 3153cfd84f..dd40bfbe60 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -47,7 +47,7 @@ export const analysisErrorRemediationAction = ( return ( error.remediationAction ?? (error.resource === "transport" - ? "Export retained session evidence through export_evidence_bundle to a caller-selected path, or use complete CLI JSON output. Tools that accept retained-evidence references can inspect compatible records. The connection remains usable." + ? "Discover retained records with get_evidence_bundle and detail: summary, then inspect compatible records through inspect_analysis_view using an exact Evidence reference. Export complete retained session evidence through export_evidence_bundle to a caller-selected path, or use complete CLI JSON output. The connection remains usable." : error.resource === "cpu" ? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits." : error.resource === "file-size" diff --git a/src/domain/evidenceRecordSummary.test.ts b/src/domain/evidenceRecordSummary.test.ts new file mode 100644 index 0000000000..91142ef312 --- /dev/null +++ b/src/domain/evidenceRecordSummary.test.ts @@ -0,0 +1,55 @@ +import { expect, it } from "vitest"; + +import { createEvidence } from "./evidence.js"; +import { createEvidenceBundle } from "./evidenceBundle.js"; +import { summarizeEvidenceBundle } from "./evidenceRecordSummary.js"; + +it("preserves native identity independently of missing or incompatible value-flow metadata", () => { + const records = [ + undefined, + { available: true, truncated: true }, + { unexpected: "legacy" }, + { available: false, reason: "Decompiler unavailable" }, + ].map((flow) => + createEvidence( + { path: "/fixtures/native.exe", format: "pe", sha256: "a".repeat(64) }, + { id: "provider", name: "Provider", version: "1" }, + { + operation: "analyze_function", + parameters: {}, + result: { + procedure: { address: "space:0x10", name: "entry" }, + ...(flow === undefined ? {} : { native_value_flow: flow }), + }, + }, + ), + ); + const summary = summarizeEvidenceBundle(createEvidenceBundle(records), { + procedure_address: "space:0x10", + }); + expect(summary.matching_records).toBe(4); + const native = summary.records.map((r) => r.native_dossier); + expect( + native.every((r) => r.available && r.procedure_address === "space:0x10"), + ).toBe(true); + expect(native).toEqual( + expect.arrayContaining([ + expect.objectContaining({ value_flow: { status: "not-recorded" } }), + expect.objectContaining({ + value_flow: { + status: "observed", + truncated: true, + omitted_operations_lower_bound: null, + known_omitted_inputs: null, + known_omitted_edges: null, + }, + }), + expect.objectContaining({ + value_flow: { status: "unknown", reason: expect.any(String) }, + }), + expect.objectContaining({ + value_flow: { status: "unavailable", reason: "Decompiler unavailable" }, + }), + ]), + ); +}); diff --git a/src/domain/evidenceRecordSummary.ts b/src/domain/evidenceRecordSummary.ts new file mode 100644 index 0000000000..b037224b88 --- /dev/null +++ b/src/domain/evidenceRecordSummary.ts @@ -0,0 +1,166 @@ +import { z } from "zod"; + +import { digestSchema, prefixedDigestSchema } from "./digests.js"; +import { evidenceSchema, type Evidence } from "./evidence.js"; +import type { EvidenceBundle } from "./evidenceBundle.js"; + +/** Exact retained-record selectors; selection never resolves a provider address. */ +export const evidenceRecordFiltersSchema = z.strictObject({ + evidence_id: prefixedDigestSchema("ev").optional(), + operation: z.string().min(1).optional(), + target_sha256: digestSchema.optional(), + analysis_profile_digest: digestSchema.optional(), + procedure_address: z.string().min(1).optional(), +}); +export type EvidenceRecordFilters = z.infer; + +const countSchema = z.number().int().nonnegative(); +const nativeMetadataSchema = z.object({ + procedure: z.object({ address: z.string(), name: z.string().nullish() }), + native_value_flow: z.unknown().optional(), +}); +const valueFlowMetadataSchema = z.union([ + z.object({ + available: z.literal(true), + truncated: z.boolean(), + omitted_operations_lower_bound: countSchema.optional(), + known_omitted_inputs: countSchema.optional(), + known_omitted_edges: countSchema.optional(), + }), + z.object({ available: z.literal(false), reason: z.string() }), +]); + +const nativeSummarySchema = z.discriminatedUnion("available", [ + z.strictObject({ + available: z.literal(true), + procedure_address: z.string(), + procedure_name: z.string().nullable(), + value_flow: z.discriminatedUnion("status", [ + z.strictObject({ + status: z.literal("observed"), + truncated: z.boolean(), + omitted_operations_lower_bound: countSchema.nullable(), + known_omitted_inputs: countSchema.nullable(), + known_omitted_edges: countSchema.nullable(), + }), + z.strictObject({ status: z.literal("unavailable"), reason: z.string() }), + z.strictObject({ status: z.literal("not-recorded") }), + z.strictObject({ status: z.literal("unknown"), reason: z.string() }), + ]), + }), + z.strictObject({ + available: z.literal(false), + reason: z.enum(["different-operation", "not-recorded-or-incompatible"]), + }), +]); + +/** Metadata for complete retained records, independent of analysis completeness. */ +export const evidenceBundleSummarySchema = z.strictObject({ + kind: z.literal("evidence-bundle-summary"), + filters: z.record(z.string(), z.string()), + total_retained_records: countSchema, + matching_records: countSchema, + unknown_revisions: countSchema, + records: z.array( + z.strictObject({ + evidence_id: evidenceSchema.shape.evidence_id, + subject: evidenceSchema.shape.subject, + provider: evidenceSchema.shape.provider, + operation: evidenceSchema.shape.operation, + confidence: evidenceSchema.shape.confidence, + authority: evidenceSchema.shape.authority, + analysis_profile_digest: digestSchema.nullable(), + retention: z.literal("complete-record"), + limitation_count: countSchema, + native_dossier: nativeSummarySchema, + }), + ), +}); +export type EvidenceBundleSummary = z.infer; + +const nativeSummary = ( + evidence: Evidence, +): z.infer => { + if (evidence.operation !== "analyze_function") + return { available: false, reason: "different-operation" }; + // Read only producer metadata. Parsing the full dossier would allocate every + // large collection merely to discover its already authenticated parent ID. + const parsed = nativeMetadataSchema.safeParse(evidence.normalized_result); + if (!parsed.success) + return { available: false, reason: "not-recorded-or-incompatible" }; + const flow = valueFlowMetadataSchema.safeParse(parsed.data.native_value_flow); + return { + available: true, + procedure_address: parsed.data.procedure.address, + procedure_name: parsed.data.procedure.name ?? null, + value_flow: + parsed.data.native_value_flow == null + ? { status: "not-recorded" } + : !flow.success + ? { + status: "unknown", + reason: "Value-flow metadata has an incompatible representation.", + } + : flow.data.available + ? { + status: "observed", + truncated: flow.data.truncated, + omitted_operations_lower_bound: + flow.data.omitted_operations_lower_bound ?? null, + known_omitted_inputs: flow.data.known_omitted_inputs ?? null, + known_omitted_edges: flow.data.known_omitted_edges ?? null, + } + : { status: "unavailable", reason: flow.data.reason }, + }; +}; + +/** Discover retained Evidence without reading or copying its complete payloads. */ +export const summarizeEvidenceBundle = ( + bundle: EvidenceBundle, + filters: EvidenceRecordFilters = {}, +): EvidenceBundleSummary => { + const selectedFilters: Record = {}; + for (const [field, value] of Object.entries(filters)) + if (value !== undefined) selectedFilters[field] = value; + const records: EvidenceBundleSummary["records"] = []; + for (const evidence of bundle.records) { + if ( + (filters.evidence_id !== undefined && + filters.evidence_id !== evidence.evidence_id) || + (filters.operation !== undefined && + filters.operation !== evidence.operation) || + (filters.target_sha256 !== undefined && + filters.target_sha256 !== evidence.subject?.digest.sha256) || + (filters.analysis_profile_digest !== undefined && + filters.analysis_profile_digest !== evidence.analysis_profile?.digest) + ) + continue; + const native = nativeSummary(evidence); + if ( + filters.procedure_address !== undefined && + (!native.available || + filters.procedure_address !== native.procedure_address) + ) + continue; + records.push({ + evidence_id: evidence.evidence_id, + subject: evidence.subject, + provider: evidence.provider, + operation: evidence.operation, + confidence: evidence.confidence, + authority: evidence.authority, + analysis_profile_digest: evidence.analysis_profile?.digest ?? null, + retention: "complete-record", + limitation_count: evidence.limitations.length, + native_dossier: native, + }); + } + return { + kind: "evidence-bundle-summary", + filters: selectedFilters, + total_retained_records: bundle.records.length, + matching_records: records.length, + unknown_revisions: bundle.unknowns.length, + records, + }; +}; diff --git a/src/server/registerSessionRecordTools.ts b/src/server/registerSessionRecordTools.ts index 4c1f79304b..998fa5d59a 100644 --- a/src/server/registerSessionRecordTools.ts +++ b/src/server/registerSessionRecordTools.ts @@ -8,6 +8,7 @@ import { import { toolContract } from "../contracts/toolContracts.js"; import type { EvidenceBundle } from "../domain/evidenceBundle.js"; import { ok } from "../domain/result.js"; +import { inspectEvidenceBundle } from "../application/investigation/InspectEvidenceBundle.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; interface EvidenceToolRegistration { @@ -72,9 +73,9 @@ const registerSnapshotEvidenceTool = ({ server.registerTool( snapshotContract.name, toolRegistrationOptions(snapshotContract), - () => + (input) => server.delivery.toCallToolResult( - ok(bundleForSerialization(session)), + inspectEvidenceBundle(bundleForSerialization(session), input), snapshotContract, ), ); diff --git a/tests/boundary/cli/analysisViewCli.test.ts b/tests/boundary/cli/analysisViewCli.test.ts index 2abaf700ac..d8de7df533 100644 --- a/tests/boundary/cli/analysisViewCli.test.ts +++ b/tests/boundary/cli/analysisViewCli.test.ts @@ -9,6 +9,53 @@ import { cliTest } from "../../support/cli/cliFixture.js"; import { analysisViewLayoutEvidence } from "../../fixtures/analysisView.js"; import { createEvidence } from "../../../src/domain/evidence.js"; import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { inspectEvidenceBundle } from "../../../src/application/investigation/InspectEvidenceBundle.js"; + +cliTest( + "discovers native records from a complete portable bundle using the shared workflow", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-evidence-discovery-cli-"); + const parent = createEvidence( + { path: "/fixtures/native.exe", format: "pe", sha256: "a".repeat(64) }, + { id: "ghidra", name: "Ghidra", version: "12.1.4" }, + { + operation: "analyze_function", + parameters: {}, + result: ghidraFunctionDossier(), + }, + ); + const bundle = createEvidenceBundle([parent]); + const path = join(root, "evidence.json"); + await writeFile(path, JSON.stringify(bundle)); + const options = { + detail: "summary" as const, + filters: { evidence_id: parent.evidence_id }, + }; + 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); + const complete = await cli.run({ + arguments: [ + "inspect-evidence-bundle", + JSON.stringify({ path }), + "--json", + ], + environment: { HOME: root, XDG_CONFIG_HOME: root, XDG_CACHE_HOME: root }, + }); + expect(complete.exitCode).toBe(0); + expect(complete.json).toEqual(bundle); + }, +); cliTest( "projects one section from a JSON file of inline layout Evidence", async ({ cli }) => { diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index 0de1f0b026..8c800e5f9a 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -7,6 +7,8 @@ import { z } from "zod"; import { BinaryLayoutService } from "../../../src/application/binaryDiagnostics/BinaryLayoutService.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { jsonObjectSchema } from "../../../src/domain/jsonValue.js"; import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js"; import { ok } from "../../../src/domain/result.js"; @@ -46,6 +48,115 @@ const connect = async (binaryLayout?: BinaryLayoutService) => { return { client, session }; }; +it("discovers an oversized native dossier by exact metadata and reads its retained view", async () => { + const { client, session } = await connect(); + const provider = { id: "ghidra", name: "Ghidra", version: "12.1.4" }; + const profile = createAnalysisProfile(provider, { language: "fixture" }); + const parent = createEvidence( + { path: "/fixtures/large.exe", format: "pe", sha256: "a".repeat(64) }, + provider, + { + operation: "analyze_function", + parameters: { procedure: "0x401000" }, + analysisProfile: profile, + result: { + ...jsonObjectSchema.parse(ghidraFunctionDossier()), + pseudocode: "x".repeat(6 * 1024 * 1024), + }, + rawResult: null, + }, + ); + const other = createEvidence( + { path: "/fixtures/other.exe", format: "pe", sha256: "b".repeat(64) }, + provider, + { + operation: "analyze_function", + parameters: {}, + result: ghidraFunctionDossier(), + }, + ); + for (const record of [parent, other]) { + const retained = session.recordEvidence(record); + if (!retained.ok) throw retained.error; + } + const broad = await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + const broadError = parseMcpToolError(broad).error; + expect(broadError.code).toBe("resource_constraint"); + expect(broadError.remediation?.action).toContain( + "get_evidence_bundle and detail: summary", + ); + const response = await client.callTool({ + name: "get_evidence_bundle", + arguments: { + detail: "summary", + filters: { + operation: "analyze_function", + target_sha256: "a".repeat(64), + analysis_profile_digest: profile.digest, + procedure_address: "0x401000", + }, + }, + }); + expect(response.isError).not.toBe(true); + const result = toolContract("get_evidence_bundle").outputSchema.parse( + response.structuredContent, + ).result; + expect(result).toMatchObject({ + kind: "evidence-bundle-summary", + total_retained_records: 2, + matching_records: 1, + records: [ + { + evidence_id: parent.evidence_id, + analysis_profile_digest: profile.digest, + retention: "complete-record", + native_dossier: { available: true, procedure_address: "0x401000" }, + }, + ], + }); + expect(JSON.stringify(response)).not.toContain("pseudocode"); + expect( + JSON.parse(response.content.find((c) => c.type === "text")?.text ?? "null"), + ).toEqual(response.structuredContent); + const page = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, + view: { kind: "native", facet: "pseudocode", offset: 0, limit: 32 }, + }, + }); + expect(page.isError).not.toBe(true); + expect( + toolContract("inspect_analysis_view").outputSchema.parse( + page.structuredContent, + ).normalized_result, + ).toMatchObject({ + item: { text: "x".repeat(32) }, + coverage: { total: 6 * 1024 * 1024, exhausted: false }, + }); + const empty = await client.callTool({ + name: "get_evidence_bundle", + arguments: { + detail: "summary", + filters: { evidence_id: `ev_${"e".repeat(64)}` }, + }, + }); + expect( + toolContract("get_evidence_bundle").outputSchema.parse( + empty.structuredContent, + ).result, + ).toMatchObject({ matching_records: 0, records: [] }); + await client.ping(); + const invalid = await client.callTool({ + name: "get_evidence_bundle", + arguments: { filters: { operation: "analyze_function" } }, + }); + expect(parseMcpToolError(invalid).error.code).toBe("invalid_request"); +}); + it("advertises exact schemas and projects one layout section from retained Evidence", async () => { const layout = analysisViewLayoutFixture(); const service = new BinaryLayoutService({