mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
fix(evidence): reject unsupported metadata before authentication
Close persisted Evidence, bundle, profile and unknown record shapes so parsing cannot silently remove caller metadata before checking semantic identity. Keep constructor provider projection explicit and JSON payload fields open. Exercise actual MCP export/import with independent record and manifest mutations, unchanged state after rejection, and a valid round trip. Prune duplicate source assertions. Validation: focused domain/CLI/MCP/filesystem checks, converted JSON Schemas, docs and typecheck/lint.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Record<string, JsonValue>>,
|
||||
): AnalysisProfileCommitment => {
|
||||
const unsigned = unsignedAnalysisProfileSchema.parse({
|
||||
provider,
|
||||
provider: {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
version: provider.version,
|
||||
},
|
||||
parameters,
|
||||
});
|
||||
return analysisProfileSchema.parse({
|
||||
|
||||
@@ -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, {
|
||||
|
||||
+12
-9
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>[];
|
||||
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<typeof createTestBinarySession>,
|
||||
) => {
|
||||
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<string, unknown> => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
throw new Error("Expected object in exported Evidence bundle");
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
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<unknown>(
|
||||
(current, segment) =>
|
||||
typeof segment === "number"
|
||||
? array(current)[segment]
|
||||
: object(current)[segment],
|
||||
root,
|
||||
);
|
||||
object(target)[String(last)] = value;
|
||||
};
|
||||
|
||||
const structured = (result: Awaited<ReturnType<Client["callTool"]>>) => {
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user