fix!: guide snapshot inputs to the restore workflow (#8867)

BREAKING CHANGE: Snapshot-shaped bundle import failures use invalid_request rather than evidence_integrity_mismatch.
This commit is contained in:
morluto authored and GitHub committed 2026-11-05 01:50:53 +08:00
1 parent 6df2ac1d7e
commit 30228c9a5f
3 files changed
+111

No files matched your search

+16
View File
@@ -4,6 +4,7 @@ import { jsonValueValidationIssue } from "../domain/jsonValue.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { requireAuthenticationHeadroom } from "./JsonAuthenticationHeadroom.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { resemblesAnalysisSnapshot } from "../domain/analysisSnapshot.js";
import { describeValidationFailure } from "../domain/validationFailure.js";
import {
@@ -157,6 +158,21 @@ export const authenticateEvidenceBundleValue = (
userMessage: failure.message,
}),
);
if (
operation === "import_evidence_bundle" &&
typeof value === "object" &&
value !== null &&
!Object.hasOwn(value, "records") &&
resemblesAnalysisSnapshot(value)
)
return err(
new EvidenceFileError("read", "wrong-kind", {
path: absoluteCallerPath(path),
cause: failure.cause,
userMessage:
"This JSON appears to be an analysis snapshot rather than an Evidence bundle. Its snapshot content has not been validated. To restore it, use a matching target, provider and analysis profile: call open_binary with snapshot_path set to this file, or pass --snapshot to a compatible CLI analysis command (which loads and updates the snapshot). Alternatively, supply a bundle from export_evidence_bundle.",
}),
);
return err(
new EvidenceIntegrityError("Evidence bundle validation failed", {
cause: failure.cause,
+21
View File
@@ -190,6 +190,27 @@ export type AnalysisSnapshotWorkflowEntry = z.infer<typeof workflowEntrySchema>;
export type AnalysisSnapshotTarget = z.infer<typeof targetSchema>;
export type AnalysisSnapshotBinding = z.infer<typeof bindingSchema>;
/** Recognize an already-parsed JSON file envelope; this does not validate or authenticate a snapshot. */
export const resemblesAnalysisSnapshot = (value: unknown): boolean => {
if (typeof value !== "object" || value === null || Array.isArray(value))
return false;
const record = value as Record<string, unknown>;
const objectMember = (key: string) =>
Object.hasOwn(record, key) &&
typeof record[key] === "object" &&
record[key] !== null &&
!Array.isArray(record[key]);
return (
objectMember("target") &&
objectMember("binding") &&
objectMember("evidence_bundle") &&
Object.hasOwn(record, "entries") &&
Array.isArray(record.entries) &&
Object.hasOwn(record, "workflow_entries") &&
Array.isArray(record.workflow_entries)
);
};
// These handles only represent entries parsed in this realm. File readers can
// release their payloads while retaining an authenticated comparison operand.
const preparedEntryBrand = Symbol("prepared-snapshot-entry");
@@ -553,6 +553,7 @@ describe("MCP investigation history snapshot replay", () => {
replay,
"investigation-history-replay",
);
await expectSnapshotImportGuidance(replayMcp, snapshotPath);
const reopened = await replayMcp.callTool({
name: "open_binary",
arguments: { path: firstPath, snapshot_path: snapshotPath },
@@ -577,6 +578,79 @@ describe("MCP investigation history snapshot replay", () => {
);
});
const expectSnapshotImportGuidance = async (mcp: Client, path: string) => {
const before = await readFile(path);
const imported = await mcp.callTool({
name: "import_evidence_bundle",
arguments: { path },
});
const error = parseMcpToolError(imported).error;
expect(error.code).toBe("invalid_request");
expect(error.remediation.action).toContain("open_binary");
expect(error.remediation.action).toContain("snapshot_path");
expect(error.remediation.action).toContain("--snapshot");
expect(error.remediation.action).toContain("loads and updates");
expect(error.remediation.action).toContain("not been validated");
expect((await readFile(path)).equals(before)).toBe(true);
};
it("does not authenticate snapshot-shaped or unrelated malformed bundle input", async () => {
const { snapshotPath } = await createWorkflowFiles(
"rea-mcp-snapshot-kind-",
[],
);
const { mcp } = await connectWorkflowMcp(
createWorkflowSession(makeProvider([], [])),
"snapshot-kind",
);
await writeFile(
snapshotPath,
JSON.stringify({
target: {},
binding: {},
evidence_bundle: {},
entries: [],
workflow_entries: [],
}),
);
const snapshotLike = parseMcpToolError(
await mcp.callTool({
name: "import_evidence_bundle",
arguments: { path: snapshotPath },
}),
).error;
expect(snapshotLike.code).toBe("invalid_request");
expect(snapshotLike.remediation.action).toContain("not been validated");
await writeFile(
snapshotPath,
JSON.stringify({
target: {},
binding: {},
evidence_bundle: {},
entries: [],
workflow_entries: [],
records: [],
}),
);
const ambiguousBundle = parseMcpToolError(
await mcp.callTool({
name: "import_evidence_bundle",
arguments: { path: snapshotPath },
}),
).error;
expect(ambiguousBundle.code).toBe("evidence_integrity_mismatch");
await writeFile(snapshotPath, JSON.stringify({ records: [] }));
const malformedBundle = parseMcpToolError(
await mcp.callTool({
name: "import_evidence_bundle",
arguments: { path: snapshotPath },
}),
).error;
expect(malformedBundle.code).toBe("evidence_integrity_mismatch");
});
const createWorkflowFiles = async (
prefix: string,
filenames: readonly string[],