fix(evidence): enforce managed identity and cancel bundle reads

Validate managed reconstruction through the shared artifact and MVID scope boundary, including known/unknown identity contradictions. Forward MCP comparison cancellation into both bundle reads and consolidate pre-cancel coverage.

Verified managed graph/reconstruction and filesystem/MCP cancellation workflows. A controlled missing-signal mutation reads 130 chunks instead of the expected one; cancellation leaves the session unchanged and retry succeeds.
This commit is contained in:
morluto committed 2026-10-12 13:06:30 +08:00
1 parent 83a4445f44
commit 3bcc68c037
8 files changed
+329 -38

No files matched your search

+2 -1
View File
@@ -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<Result<EvidenceBundle, EvidenceReadFailure>> =>
readBundle(path, parseEvidenceBundle);
readBundle(path, parseEvidenceBundle, signal);
/** Read an authenticated immutable snapshot for a subsequent session import. */
export const readImmutableEvidenceBundle = (
@@ -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(
@@ -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 };
};
+11 -11
View File
@@ -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<typeof parseEvidence>,
staticEvidence: Evidence,
members: ManagedMemberInspection,
): StaticObservation => ({
evidence_id: staticEvidence.evidence_id,
@@ -238,7 +238,7 @@ const RECONSTRUCTION_LIMITATIONS = [
const buildReconstructionWithoutId = (
input: ManagedReconstructionImportInput,
staticEvidence: ReturnType<typeof parseEvidence>,
staticEvidence: Evidence,
members: ManagedMemberInspection,
methods: readonly ImportedMethod[],
): Omit<ManagedReconstructionImportResult, "reconstruction_id"> => ({
@@ -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)
+2 -2
View File
@@ -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)
@@ -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-");
@@ -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<void>((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<void>((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<void>((resolve) => {
resolveFirstRead = resolve;
});
let releaseRead: (() => void) | undefined;
const readGate = new Promise<void>((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;
},
};
};
@@ -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", () => {