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:
morluto
2026-10-13 12:59:17 +08:00
parent c105391db5
commit acf75e6700
8 changed files with 331 additions and 26 deletions
+6
View File
@@ -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.
+7
View File
@@ -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 -3
View File
@@ -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({
+24
View File
@@ -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
View File
@@ -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),
+11 -8
View File
@@ -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),
+6 -6
View File
@@ -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>;
};