diff --git a/src/application/EvidenceBundleFiles.ts b/src/application/EvidenceBundleFiles.ts index a9ff27990d..1f1a0a0b78 100644 --- a/src/application/EvidenceBundleFiles.ts +++ b/src/application/EvidenceBundleFiles.ts @@ -36,8 +36,9 @@ type EvidenceWriteFailure = /** Read and validate an evidence bundle at the caller-supplied path. */ export const readEvidenceBundle = ( path: string, + signal?: AbortSignal, ): Promise> => - readBundle(path, parseEvidenceBundle); + readBundle(path, parseEvidenceBundle, signal); /** Read an authenticated immutable snapshot for a subsequent session import. */ export const readImmutableEvidenceBundle = ( diff --git a/src/application/managed/ManagedReconstructionService.test.ts b/src/application/managed/ManagedReconstructionService.test.ts index 6e7a28c27a..56b9d94428 100644 --- a/src/application/managed/ManagedReconstructionService.test.ts +++ b/src/application/managed/ManagedReconstructionService.test.ts @@ -53,6 +53,33 @@ describe("managed decompiler reconstruction import", () => { ).toThrow(/does not match/u); }); + it("rejects static Evidence with a token MVID but no module identity", () => { + const input = exampleInput(); + const members = managedMemberInspectionSchema.parse( + input.static_members.normalized_result, + ); + if (members.module === null || members.module.mvid === null) + throw new Error("Expected fixture module identity and MVID"); + const inconsistentEvidence = createEvidence( + undefined, + input.static_members.provider, + { + operation: "inspect_managed_members", + parameters: {}, + result: { ...members, module: null }, + rawResult: null, + limitations: members.limitations, + }, + ); + + expect(() => + importManagedReconstruction({ + ...input, + static_members: inconsistentEvidence, + }), + ).toThrow(/token scope MVID/u); + }); + it("rejects reconstruction locks for partial CIL without normalized identity", () => { const input = exampleInput(); const observed = managedMemberInspectionSchema.parse( diff --git a/src/domain/managed/managedInspectionEvidence.ts b/src/domain/managed/managedInspectionEvidence.ts index fa4712b5d0..84b64ca250 100644 --- a/src/domain/managed/managedInspectionEvidence.ts +++ b/src/domain/managed/managedInspectionEvidence.ts @@ -43,15 +43,14 @@ export const parseManagedInspectionEvidence = < throw new TypeError( `Managed Evidence ${operation} (${evidence.evidence_id}) token scope SHA-256 ${scope.requires_artifact_sha256} does not match normalized artifact SHA-256 ${artifactSha256}`, ); + const moduleMvid = result.module?.mvid ?? null; + const scopedMvid = scope?.requires_mvid; if ( - scope?.requires_mvid !== undefined && - scope.requires_mvid !== null && - result.module?.mvid !== undefined && - result.module.mvid !== null && - scope.requires_mvid.toLowerCase() !== result.module.mvid.toLowerCase() + scopedMvid !== undefined && + scopedMvid?.toLowerCase() !== moduleMvid?.toLowerCase() ) throw new TypeError( - `Managed Evidence ${operation} (${evidence.evidence_id}) token scope MVID ${scope.requires_mvid} does not match module MVID ${result.module.mvid}`, + `Managed Evidence ${operation} (${evidence.evidence_id}) token scope MVID ${String(scopedMvid)} does not match module MVID ${String(moduleMvid)}`, ); return { evidence, result }; }; diff --git a/src/domain/managed/managedReconstruction.ts b/src/domain/managed/managedReconstruction.ts index dc8863aced..e10be0ffd3 100644 --- a/src/domain/managed/managedReconstruction.ts +++ b/src/domain/managed/managedReconstruction.ts @@ -3,15 +3,15 @@ import { createHash } from "node:crypto"; import canonicalize from "canonicalize"; import { z } from "zod"; -import { evidenceSchema, parseEvidence } from "../evidence.js"; +import { evidenceSchema, type Evidence } from "../evidence.js"; import { cliMetadataGuidSchema, managedMemberInspectionSchema, type ManagedMemberInspection, } from "./managedArtifact.js"; import type { JsonValue } from "../jsonValue.js"; -import { digestSchema } from "../digests.js"; -import { prefixedDigestSchema } from "../digests.js"; +import { digestSchema, prefixedDigestSchema } from "../digests.js"; +import { parseManagedInspectionEvidence } from "./managedInspectionEvidence.js"; const evidenceIdSchema = prefixedDigestSchema("ev"); const tokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u); @@ -210,7 +210,7 @@ const buildReconstructionSummary = ( }); const buildStaticObservation = ( - staticEvidence: ReturnType, + staticEvidence: Evidence, members: ManagedMemberInspection, ): StaticObservation => ({ evidence_id: staticEvidence.evidence_id, @@ -238,7 +238,7 @@ const RECONSTRUCTION_LIMITATIONS = [ const buildReconstructionWithoutId = ( input: ManagedReconstructionImportInput, - staticEvidence: ReturnType, + staticEvidence: Evidence, members: ManagedMemberInspection, methods: readonly ImportedMethod[], ): Omit => ({ @@ -257,12 +257,12 @@ const buildReconstructionWithoutId = ( export const importManagedReconstruction = ( input: ManagedReconstructionImportInput, ): ManagedReconstructionImportResult => { - const staticEvidence = parseEvidence(input.static_members); - if (staticEvidence.operation !== "inspect_managed_members") - throw new TypeError("Evidence operation is not inspect_managed_members"); - const members = managedMemberInspectionSchema.parse( - staticEvidence.normalized_result, - ); + const { evidence: staticEvidence, result: members } = + parseManagedInspectionEvidence( + input.static_members, + "inspect_managed_members", + managedMemberInspectionSchema, + ); const methods = input.methods.map((candidate) => { const observed = findObservedMethod(members, candidate); if (observed === undefined) diff --git a/src/server/registerBundleComparisonTool.ts b/src/server/registerBundleComparisonTool.ts index bedb65c449..18e734cf54 100644 --- a/src/server/registerBundleComparisonTool.ts +++ b/src/server/registerBundleComparisonTool.ts @@ -31,8 +31,8 @@ export const registerBundleComparisonTool = ( context.mcpReq.signal, async () => { const [left, right] = await Promise.all([ - readEvidenceBundle(input.left_bundle_path), - readEvidenceBundle(input.right_bundle_path), + readEvidenceBundle(input.left_bundle_path, context.mcpReq.signal), + readEvidenceBundle(input.right_bundle_path, context.mcpReq.signal), ]); if (!left.ok) return server.delivery.toCallToolResult(left, contract); if (!right.ok) diff --git a/tests/boundary/filesystem/evidenceBundleFiles.test.ts b/tests/boundary/filesystem/evidenceBundleFiles.test.ts index d93afe620d..856a545aaa 100644 --- a/tests/boundary/filesystem/evidenceBundleFiles.test.ts +++ b/tests/boundary/filesystem/evidenceBundleFiles.test.ts @@ -39,24 +39,27 @@ const bundle = (result = true) => ), ]); -it("returns import cancellation before inspecting a selected path", async () => { - const root = await createTestTempDirectory("rea-import-pre-cancel-"); - const controller = new AbortController(); - controller.abort(new Error("cancel before import")); - expect( - await readImmutableEvidenceBundle( - join(root, "missing.json"), - controller.signal, - ), - ).toMatchObject({ - ok: false, - error: { - _tag: "AnalysisCancelledError", - operation: "import_evidence_bundle", - }, - }); - expect(await readdir(root)).toEqual([]); -}); +it.each([ + ["immutable", readImmutableEvidenceBundle], + ["mutable", readEvidenceBundle], +] as const)( + "cancels a %s bundle read before opening its selected path", + async (_, read) => { + const root = await createTestTempDirectory("rea-bundle-pre-cancel-"); + const controller = new AbortController(); + controller.abort(new Error("cancel before bundle read")); + expect( + await read(join(root, "missing.json"), controller.signal), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + operation: "import_evidence_bundle", + }, + }); + expect(await readdir(root)).toEqual([]); + }, +); it("preserves the failed-close owner when import is also cancelled", async () => { const root = await createTestTempDirectory("rea-import-cancel-cleanup-"); diff --git a/tests/boundary/mcp/bundleComparisonCancellation.test.ts b/tests/boundary/mcp/bundleComparisonCancellation.test.ts new file mode 100644 index 0000000000..b665c54e1b --- /dev/null +++ b/tests/boundary/mcp/bundleComparisonCancellation.test.ts @@ -0,0 +1,217 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { open, writeFile, type FileHandle } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, onTestFinished, vi } from "vitest"; + +import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { + createEvidenceBundle, + serializeEvidenceBundle, +} from "../../../src/domain/evidenceBundle.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { ToolResultDelivery } from "../../../src/server/toolResult.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("leaves the session unchanged after bundle comparison cancellation and permits a retry", async () => { + const root = await createTestTempDirectory("rea-mcp-compare-cancel-"); + const leftPath = join(root, "left.json"); + const rightPath = join(root, "right.json"); + const provider = { id: "fixture", name: "Fixture", version: "1" }; + const left = createEvidence(undefined, provider, { + operation: "observation", + parameters: { side: "left" }, + result: { side: "left" }, + }); + const right = createEvidence(undefined, provider, { + operation: "observation", + parameters: { side: "right" }, + result: { side: "right" }, + }); + const leftContents = + " ".repeat(8 * 1024 * 1024) + + serializeEvidenceBundle(createEvidenceBundle([left])); + await Promise.all([ + // Just over the native-parse budget, without a large JSON tree. + writeFile(leftPath, leftContents), + writeFile( + rightPath, + serializeEvidenceBundle(createEvidenceBundle([right])), + ), + ]); + + const session = createTestBinarySession(() => { + throw new Error("Bundle comparison must not start a binary provider"); + }); + const existing = createEvidence(undefined, provider, { + operation: "existing_observation", + parameters: {}, + result: { retained: true }, + }); + const recorded = session.recordEvidence(existing); + if (!recorded.ok) throw recorded.error; + const before = session.exportEvidenceBundle(); + const server = createServer({ kind: "session", session }); + const client = new Client({ + name: "bundle-comparison-cancellation", + version: "1", + }); + const controller = new AbortController(); + onTestFinished(async () => { + controller.abort(); + vi.restoreAllMocks(); + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const reader = await holdFirstRead(leftPath); + onTestFinished(reader.release); + + let resolveDispatched: (() => void) | undefined; + const dispatched = new Promise((resolve) => { + resolveDispatched = resolve; + }); + const send = clientTransport.send.bind(clientTransport); + vi.spyOn(clientTransport, "send").mockImplementation( + async (message, ...args) => { + await send(message, ...args); + if ("method" in message && message.method === "tools/call") + resolveDispatched?.(); + }, + ); + let resolveCompleted: (() => void) | undefined; + const completed = new Promise((resolve) => { + resolveCompleted = resolve; + }); + let settledResult: unknown; + const deliver = ToolResultDelivery.prototype.toCallToolResult; + vi.spyOn(ToolResultDelivery.prototype, "toCallToolResult").mockImplementation( + function (this: ToolResultDelivery, result, contract) { + const reply = deliver.call(this, result, contract); + if (contract.name === "compare_bundles") { + settledResult = result; + resolveCompleted?.(); + } + return reply; + }, + ); + + const args = { + left_bundle_path: leftPath, + right_bundle_path: rightPath, + record_pairs: [ + { + left_evidence_id: left.evidence_id, + right_evidence_id: right.evidence_id, + }, + ], + }; + const cancellation = client + .callTool( + { name: "compare_bundles", arguments: args }, + { signal: controller.signal }, + ) + .then( + () => "completed", + (error: unknown) => error, + ); + await dispatched; + await reader.firstRead; + await client.ping(); + controller.abort(new Error("SDK bundle comparison cancellation")); + reader.release(); + expect(await cancellation).toMatchObject({ + message: expect.stringContaining("SDK bundle comparison cancellation"), + }); + // Wait for the handler's delivered Result as well as the client's rejection. + await completed; + expect(settledResult).toMatchObject({ + ok: false, + error: expect.any(AnalysisCancelledError), + }); + expect(reader.readCount).toBe(1); + expect(reader.totalReadBytes).toBeLessThan(leftContents.length); + expect(session.exportEvidenceBundle()).toEqual(before); + + await client.ping(); + const retry = await client.callTool({ + name: "compare_bundles", + arguments: args, + }); + expect(retry.isError, JSON.stringify(retry)).not.toBe(true); + expect(retry.structuredContent).toMatchObject({ + normalized_result: { status: "changed" }, + }); + const after = session.exportEvidenceBundle(); + expect(after.unknowns).toEqual(before.unknowns); + expect(after.records).toEqual( + expect.arrayContaining([ + ...before.records, + left, + right, + expect.objectContaining({ + operation: "compare_bundles", + evidence_links: [left.evidence_id, right.evidence_id], + }), + ]), + ); +}); + +const holdFirstRead = async (path: string) => { + // Hold the first streamed read after it reaches the operating system. + const probe = await open(path, "r"); + const identity = await probe.stat(); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + read( + buffer: Buffer, + offset: number, + length: number, + position: number | null, + ): Promise<{ readonly bytesRead: number; readonly buffer: Buffer }>; + }; + await probe.close(); + let totalReadBytes = 0; + let readCount = 0; + let resolveFirstRead: (() => void) | undefined; + const firstRead = new Promise((resolve) => { + resolveFirstRead = resolve; + }); + let releaseRead: (() => void) | undefined; + const readGate = new Promise((resolve) => { + releaseRead = resolve; + }); + const read = fileHandlePrototype.read; + vi.spyOn(fileHandlePrototype, "read").mockImplementation(async function ( + this: FileHandle, + ...args: [Buffer, number, number, number | null] + ) { + const current = await this.stat(); + const isTarget = + current.dev === identity.dev && current.ino === identity.ino; + const result = await read.call(this, ...args); + if (!isTarget) return result; + totalReadBytes += result.bytesRead; + readCount += 1; + if (readCount === 1) { + resolveFirstRead?.(); + await readGate; + } + return result; + }); + return { + firstRead, + release: () => releaseRead?.(), + get readCount() { + return readCount; + }, + get totalReadBytes() { + return totalReadBytes; + }, + }; +}; diff --git a/tests/composition/analysis-sessions/managedApplicationGraph.test.ts b/tests/composition/analysis-sessions/managedApplicationGraph.test.ts index cd60028d4f..c235356431 100644 --- a/tests/composition/analysis-sessions/managedApplicationGraph.test.ts +++ b/tests/composition/analysis-sessions/managedApplicationGraph.test.ts @@ -507,6 +507,50 @@ describe("managed application graph token scope", () => { "token scope SHA-256", ); }); + + it.each([ + "module identity absent", + "module MVID unknown", + "token scope MVID unknown", + ] as const)("rejects inconsistent identity when %s", (variant) => { + const { memberEvidence, boundaryEvidence } = createManagedInteropEvidence(); + const members = managedMemberInspectionSchema.parse( + parseEvidence(memberEvidence).normalized_result, + ); + if (members.module === null || members.module.mvid === null) + throw new Error("Expected fixture module identity and MVID"); + const module = + variant === "module identity absent" + ? null + : variant === "module MVID unknown" + ? { ...members.module, mvid: null } + : members.module; + const identity_scope = + variant === "token scope MVID unknown" + ? { ...members.identity_scope, requires_mvid: null } + : members.identity_scope; + const inconsistentMembers = createEvidence( + undefined, + MANAGED_STATIC_PROVIDER, + { + operation: "inspect_managed_members", + parameters: {}, + result: { ...members, module, identity_scope }, + rawResult: null, + limitations: members.limitations, + }, + ); + + const projected = projectManagedApplicationGraphEvidence({ + managed_members: inconsistentMembers, + managed_native_boundaries: boundaryEvidence, + }); + + expect(projected.ok).toBe(false); + if (projected.ok) + throw new Error("An inconsistent token scope was accepted"); + expect(JSON.stringify(projected.error)).toContain("token scope MVID"); + }); }); describe("unknown managed application graph token scope", () => {