diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index b6990b3c9a..c4169dd73f 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -21,6 +21,12 @@ directly to `compare_functions`, and `inspect_artifact` Evidence to `compare_artifacts`. Use `get_evidence_bundle` when the task needs broader retained session history or an explicit bundle for transfer. +Evidence and bundle imports reject unsupported fields in their envelopes, +identities, manifests and other fixed metadata before authentication. Imports +leave retained session state unchanged when validation fails. Keep application +metadata in the supported JSON payloads (`parameters`, `raw_result`, +`normalized_result`, or analysis-profile parameters), where keys are preserved. + Tool execution failures return `isError: true` and the complete canonical `{ "error": ... }` diagnostic as JSON in text `content`. Read that diagnostic for the error code, target details, partial observations, and remediation. diff --git a/src/domain/analysisProfile.test.ts b/src/domain/analysisProfile.test.ts index 1fb18f0ae4..a39fb389d4 100644 --- a/src/domain/analysisProfile.test.ts +++ b/src/domain/analysisProfile.test.ts @@ -65,4 +65,11 @@ describe("analysis profile commitments", () => { analysisProfileSchema.parse({ ...profile, digest: "0".repeat(64) }), ).toThrow(/digest/u); }); + + it("projects known provider identity fields at construction", () => { + const liveProvider = { ...PROVIDER, transient_status: "starting" }; + const profile = createAnalysisProfile(liveProvider, { loader: "default" }); + expect(profile.provider).toEqual(PROVIDER); + expect(analysisProfileSchema.parse(profile)).toEqual(profile); + }); }); diff --git a/src/domain/analysisProfile.ts b/src/domain/analysisProfile.ts index 6719e2ccdc..f6e6e00047 100644 --- a/src/domain/analysisProfile.ts +++ b/src/domain/analysisProfile.ts @@ -7,13 +7,13 @@ import { jsonObjectSchema, type JsonValue } from "./jsonValue.js"; import { digestSchema } from "./../domain/digests.js"; /** Concrete provider identity committed by an analysis profile. */ -export const committedProviderSchema = z.object({ +export const committedProviderSchema = z.strictObject({ id: z.string().min(1), name: z.string().min(1), version: z.string().min(1), }); -const unsignedAnalysisProfileSchema = z.object({ +const unsignedAnalysisProfileSchema = z.strictObject({ provider: committedProviderSchema, parameters: jsonObjectSchema, }); @@ -40,7 +40,11 @@ export const createAnalysisProfile = ( parameters: Readonly>, ): AnalysisProfileCommitment => { const unsigned = unsignedAnalysisProfileSchema.parse({ - provider, + provider: { + id: provider.id, + name: provider.name, + version: provider.version, + }, parameters, }); return analysisProfileSchema.parse({ diff --git a/src/domain/evidence.test.ts b/src/domain/evidence.test.ts index c5898aecc7..5cdf30a276 100644 --- a/src/domain/evidence.test.ts +++ b/src/domain/evidence.test.ts @@ -52,6 +52,30 @@ it("preserves omitted relationship diagnostics and authenticates reported diagno ).toThrow("semantic identifier"); }); +it("projects known provider fields and preserves open JSON payloads", () => { + const liveProvider = { ...PROVIDER, transient_status: "starting" }; + const evidence = createEvidence(TARGET, liveProvider, { + operation: "inspect", + parameters: { caller_metadata: { note: "kept" } }, + result: { producer_extension: { value: "kept" } }, + analysisProfile: PROFILE, + environment: { + id: "fixture", + platform: "linux", + architecture: "x86_64", + isolation: "process", + }, + locations: [{ kind: "address", address: "0x1000" }], + }); + + expect(evidence.provider).toEqual(PROVIDER); + expect(evidence.parameters).toEqual({ caller_metadata: { note: "kept" } }); + expect(evidence.normalized_result).toEqual({ + producer_extension: { value: "kept" }, + }); + expect(parseEvidence(evidence)).toEqual(evidence); +}); + it("preserves identity and bytes when reusing an owned immutable result", () => { const value = { nested: { values: ["observed", 1] } }; const mutable = createEvidence(TARGET, PROVIDER, { diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 8e21686e5a..8a450f4783 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -33,14 +33,14 @@ import { } from "./relationshipCoverage.js"; /** Provider identity schema shared by evidence-bearing persistence formats. */ -export const providerSchema = z.object({ +export const providerSchema = z.strictObject({ id: z.string().min(1), name: z.string().min(1), version: z.string().nullable(), }); -const subjectSchema = z.object({ +const subjectSchema = z.strictObject({ name: z.string().min(1), - digest: z.object({ sha256: digestSchema }), + digest: z.strictObject({ sha256: digestSchema }), format: z.enum([ "hopper", "analysis-database", @@ -77,15 +77,18 @@ const subjectSchema = z.object({ /** Source location attached to an evidence observation. */ /** Source-location schema shared by evidence-bearing persistence formats. */ export const evidenceLocationSchema = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("address"), address: z.string().min(1) }), - z.object({ + z.strictObject({ kind: z.literal("address"), address: z.string().min(1) }), + z.strictObject({ kind: z.literal("address-range"), start: z.string().min(1), end: z.string().min(1), }), - z.object({ kind: z.literal("artifact-path"), path: z.string().min(1) }), - z.object({ kind: z.literal("file-offset"), offset: z.number().int().min(0) }), - z.object({ + z.strictObject({ kind: z.literal("artifact-path"), path: z.string().min(1) }), + z.strictObject({ + kind: z.literal("file-offset"), + offset: z.number().int().min(0), + }), + z.strictObject({ kind: z.literal("file-offset-range"), start: z.number().int().min(0), end: z.number().int().min(0), @@ -100,7 +103,7 @@ const evidenceAuthoritySchema = z.enum([ "analyst-inference", ]); -const executionEnvironmentSchema = z.object({ +const executionEnvironmentSchema = z.strictObject({ id: z.string().min(1), platform: z.string().min(1), architecture: z.string().min(1), diff --git a/src/domain/evidenceBundle.ts b/src/domain/evidenceBundle.ts index c1fa3ce1c6..7e6fd2b3bb 100644 --- a/src/domain/evidenceBundle.ts +++ b/src/domain/evidenceBundle.ts @@ -16,24 +16,27 @@ import { } from "./residualUnknown.js"; import { freezeJsonSnapshot } from "./immutableJson.js"; -const artifactManifestSchema = evidenceSchema.shape.subject.unwrap().pick({ - digest: true, - format: true, - architecture: true, -}); +const artifactManifestSchema = evidenceSchema.shape.subject + .unwrap() + .pick({ + digest: true, + format: true, + architecture: true, + }) + .strict(); const providerManifestSchema = providerSchema; const environmentManifestSchema = evidenceSchema.shape.environment.unwrap(); -const scenarioManifestSchema = z.object({ +const scenarioManifestSchema = z.strictObject({ evidence_id: evidenceSchema.shape.evidence_id, operation: z.string().min(1), authority: z.literal("controlled-replay"), }); -const captureManifestSchema = z.object({ +const captureManifestSchema = z.strictObject({ evidence_id: evidenceSchema.shape.evidence_id, predicate_type: z.string().min(1), }); -export const evidenceBundleSchema = z.object({ +export const evidenceBundleSchema = z.strictObject({ artifacts: z.array(artifactManifestSchema), providers: z.array(providerManifestSchema), environments: z.array(environmentManifestSchema), diff --git a/src/domain/residualUnknown.ts b/src/domain/residualUnknown.ts index 0819a3964f..e5db1cc549 100644 --- a/src/domain/residualUnknown.ts +++ b/src/domain/residualUnknown.ts @@ -12,7 +12,7 @@ import { functionComparisonDimensionNameSchema } from "./functionComparisonSchem const evidenceIdSchema = prefixedDigestSchema("ev"); const unknownIdSchema = prefixedDigestSchema("unk"); const text = z.string().trim().min(1); -const environmentRequirementSchema = z.object({ +const environmentRequirementSchema = z.strictObject({ id: z.string().min(1).nullable(), platform: z.string().min(1).nullable(), architecture: z.string().min(1).nullable(), @@ -39,7 +39,7 @@ const stableUnknownId = ( ) .digest("hex")}`; -const probeSchema = z.object({ +const probeSchema = z.strictObject({ operation: z.string().min(1), rationale: text, }); @@ -60,17 +60,17 @@ const comparisonConclusionScopeSchema = z message: "Comparison dimensions must be sorted and unique", }); }); -const relationshipSchema = z.object({ +const relationshipSchema = z.strictObject({ type: z.enum(["depends-on", "related-to", "contradicts"]), unknown_id: unknownIdSchema, }); const resolutionSchema = z.discriminatedUnion("disposition", [ - z.object({ + z.strictObject({ disposition: z.literal("verified"), rationale: text, evidence_ids: z.array(evidenceIdSchema).min(1), }), - z.object({ + z.strictObject({ disposition: z.enum(["withdrawn", "out-of-scope"]), rationale: text, evidence_ids: z.array(evidenceIdSchema), @@ -78,7 +78,7 @@ const resolutionSchema = z.discriminatedUnion("disposition", [ ]); /** Strict durable record for one unresolved or evidence-qualified question. */ -const residualUnknownObjectSchema = z.object({ +const residualUnknownObjectSchema = z.strictObject({ unknown_id: unknownIdSchema, revision: z.number().int().min(1), previous_revision_digest: z diff --git a/tests/boundary/mcp/evidenceBundleInline.test.ts b/tests/boundary/mcp/evidenceBundleInline.test.ts index adb95ef8f8..ae07a1be55 100644 --- a/tests/boundary/mcp/evidenceBundleInline.test.ts +++ b/tests/boundary/mcp/evidenceBundleInline.test.ts @@ -1,10 +1,20 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + import { ok as resultOk } from "../../../src/domain/result.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { expect, it } from "vitest"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { observed } from "../../fixtures/analysisExecution.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; import { createEvidence } from "../../../src/domain/evidence.js"; +import { ANALYSIS_SNAPSHOT_TARGET } from "../../../src/domain/analysisSnapshot.fixture.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js"; +import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js"; import { createServer } from "../../../src/server/createServer.js"; it("returns the complete session Evidence bundle in the tool result", async () => { @@ -44,3 +54,251 @@ it("returns the complete session Evidence bundle in the tool result", async () = await Promise.allSettled([client.close(), server.close(), session.close()]); } }); + +it("rejects bundle field stripping at MCP import without changing retained evidence", async () => { + const root = await createTestTempDirectory("rea-evidence-bundle-mcp-"); + const sourceSession = createBundleSourceSession(); + const targetSession = createTestBinarySession(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(resultOk(null)), + })); + const source = await connectEvidenceMcp("bundle-source", sourceSession); + const target = await connectEvidenceMcp("bundle-target", targetSession); + const { client: sourceClient } = source; + const { client: targetClient } = target; + const bundlePath = join(root, "exported.json"); + const targetExportPath = join(root, "round-trip.json"); + + try { + const { sourceJson, records, evidenceIndex } = await exportFixtureBundle( + sourceClient, + bundlePath, + ); + const initial = structured( + await targetClient.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }), + ).result; + for (const [label, path] of unsupportedBundleFields(evidenceIndex)) { + const candidate = structuredClone(sourceJson); + setPath(candidate, path, "unsupported-metadata"); + const malformedPath = join(root, `${label.replaceAll(" ", "-")}.json`); + await writeFile(malformedPath, JSON.stringify(candidate)); + const rejected = await targetClient.callTool({ + name: "import_evidence_bundle", + arguments: { path: malformedPath }, + }); + expect(parseMcpToolError(rejected), label).toMatchObject({ + error: { code: "evidence_integrity_mismatch" }, + }); + expect( + structured( + await targetClient.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }), + ).result, + label, + ).toEqual(initial); + } + + const imported = await targetClient.callTool({ + name: "import_evidence_bundle", + arguments: { path: bundlePath }, + }); + if (imported.isError === true) + throw new Error(JSON.stringify(parseMcpToolError(imported))); + await targetClient.callTool({ + name: "export_evidence_bundle", + arguments: { path: targetExportPath }, + }); + expect(JSON.parse(await readFile(targetExportPath, "utf8"))).toEqual( + sourceJson, + ); + const observedRecord = records[evidenceIndex]!; + expect(observedRecord.parameters).toEqual({ caller: { note: "preserve" } }); + expect(observedRecord.normalized_result).toEqual({ + producer: { extension: "preserve" }, + }); + expect(object(observedRecord.analysis_profile).parameters).toEqual({ + loader: { mode: "full" }, + }); + } finally { + await Promise.allSettled([ + sourceClient.close(), + source.server.close(), + sourceSession.close(), + targetClient.close(), + target.server.close(), + targetSession.close(), + ]); + } +}); + +const createBundleSourceSession = () => { + const provider = { id: "fixture", name: "Fixture", version: "1" }; + const evidence = createEvidence(ANALYSIS_SNAPSHOT_TARGET, provider, { + operation: "observe", + parameters: { caller: { note: "preserve" } }, + result: { producer: { extension: "preserve" } }, + analysisProfile: createAnalysisProfile(provider, { + loader: { mode: "full" }, + }), + environment: { + id: "fixture-linux", + platform: "linux", + architecture: "x86_64", + isolation: "process", + }, + locations: [{ kind: "address", address: "0x1000" }], + }); + const session = createTestBinarySession(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(resultOk(null)), + })); + expect(session.recordEvidence(evidence).ok).toBe(true); + for (const extra of [ + createEvidence(ANALYSIS_SNAPSHOT_TARGET, provider, { + operation: "controlled_replay", + parameters: {}, + result: { completed: true }, + authority: "controlled-replay", + }), + createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: "rea.process-capture", + operation: "capture_process_scenario", + parameters: {}, + result: jsonValueSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE), + confidence: "observed", + authority: "controlled-replay", + }), + ]) + expect(session.recordEvidence(extra).ok).toBe(true); + return session; +}; + +const exportFixtureBundle = async ( + client: Client, + path: string, +): Promise<{ + readonly sourceJson: unknown; + readonly records: Record[]; + readonly evidenceIndex: number; +}> => { + const unknown = await client.callTool({ + name: "record_unknown", + arguments: { + question: "Does this remain unknown?", + severity: "medium", + domain: "bundle-format", + required_authority: null, + required_confidence: "derived", + required_environment: null, + recommended_probes: [{ operation: "observe", rationale: "Inspect it." }], + relationships: [], + }, + }); + expect(unknown.isError).not.toBe(true); + const exported = await client.callTool({ + name: "export_evidence_bundle", + arguments: { path }, + }); + expect(exported.isError).not.toBe(true); + const sourceJson = JSON.parse(await readFile(path, "utf8")) as unknown; + const records = array(object(sourceJson).records).map(object); + const evidenceIndex = records.findIndex( + (record) => record.operation === "observe", + ); + expect(evidenceIndex).toBeGreaterThanOrEqual(0); + return { sourceJson, records, evidenceIndex }; +}; + +const unsupportedBundleFields = ( + evidenceIndex: number, +): readonly [string, readonly (string | number)[]][] => [ + ["bundle envelope", ["schema_version"]], + ["record provider", ["records", evidenceIndex, "provider", "future_field"]], + ["provider manifest", ["providers", 0, "future_field"]], + ["record subject", ["records", evidenceIndex, "subject", "future_field"]], + [ + "subject digest", + ["records", evidenceIndex, "subject", "digest", "future_field"], + ], + ["artifact manifest", ["artifacts", 0, "future_field"]], + [ + "record environment", + ["records", evidenceIndex, "environment", "future_field"], + ], + ["environment manifest", ["environments", 0, "future_field"]], + [ + "source location", + ["records", evidenceIndex, "locations", 0, "future_field"], + ], + ["scenario manifest", ["scenarios", 0, "future_field"]], + ["capture manifest", ["captures", 0, "future_field"]], + [ + "analysis profile", + ["records", evidenceIndex, "analysis_profile", "future_field"], + ], + [ + "profile provider", + ["records", evidenceIndex, "analysis_profile", "provider", "future_field"], + ], + ["unknown revision", ["unknowns", 0, "future_field"]], + ["unknown probe", ["unknowns", 0, "recommended_probes", 0, "future_field"]], +]; + +const connectEvidenceMcp = async ( + name: string, + session: ReturnType, +) => { + const server = createServer({ kind: "session", session }); + const client = new Client({ name, version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return { client, server }; +}; + +const object = (value: unknown): Record => { + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new Error("Expected object in exported Evidence bundle"); + return value as Record; +}; + +const array = (value: unknown): unknown[] => { + if (!Array.isArray(value)) + throw new Error("Expected array in Evidence bundle"); + return value; +}; + +const setPath = ( + root: unknown, + path: readonly (string | number)[], + value: unknown, +): void => { + const [last, ...parents] = [...path].reverse(); + if (last === undefined) throw new Error("Expected a nonempty bundle path"); + const target = parents + .reverse() + .reduce( + (current, segment) => + typeof segment === "number" + ? array(current)[segment] + : object(current)[segment], + root, + ); + object(target)[String(last)] = value; +}; + +const structured = (result: Awaited>) => { + if (result.isError === true) throw parseMcpToolError(result); + if ( + typeof result.structuredContent !== "object" || + result.structuredContent === null + ) + throw new Error("Missing structured MCP result"); + return result.structuredContent as Record; +};