mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
83a4445f44
commit
3bcc68c037
8 files changed
+329
-38
No files matched your search
@@ -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 };
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user