Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c5aa93140 | ||
|
|
31bb73768c |
@@ -1,8 +1,11 @@
|
|||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createImmutableEvidenceBundle,
|
createImmutableEvidenceBundle,
|
||||||
describeEvidenceBundleFailure,
|
describeEvidenceBundleFailure,
|
||||||
describeValidationFailure,
|
describeValidationFailure,
|
||||||
parseEvidenceBundle,
|
parseEvidenceBundle,
|
||||||
|
singleEvidenceRecordId,
|
||||||
type EvidenceBundle,
|
type EvidenceBundle,
|
||||||
} from "../domain/evidenceBundle.js";
|
} from "../domain/evidenceBundle.js";
|
||||||
import {
|
import {
|
||||||
@@ -79,6 +82,16 @@ const readBundle = async (
|
|||||||
try {
|
try {
|
||||||
bundle = parseBundle(loaded.value);
|
bundle = parseBundle(loaded.value);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
|
// An intact record is the wrong selected file, not damaged bundle content.
|
||||||
|
const evidenceId = singleEvidenceRecordId(loaded.value);
|
||||||
|
if (evidenceId !== undefined)
|
||||||
|
return err(
|
||||||
|
new EvidenceFileError("read", "not-bundle", {
|
||||||
|
cause,
|
||||||
|
path: resolve(path),
|
||||||
|
evidenceId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
return err(
|
return err(
|
||||||
new EvidenceIntegrityError("Evidence bundle validation failed", {
|
new EvidenceIntegrityError("Evidence bundle validation failed", {
|
||||||
cause,
|
cause,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from "./hopperErrors.js";
|
} from "./hopperErrors.js";
|
||||||
import { ProviderSelectionError } from "./providerSelectionError.js";
|
import { ProviderSelectionError } from "./providerSelectionError.js";
|
||||||
import { UnknownRegistryError } from "./unknownRegistryError.js";
|
import { UnknownRegistryError } from "./unknownRegistryError.js";
|
||||||
|
import { singleEvidenceRecordMessage } from "./evidenceBundle.js";
|
||||||
import { providerRetryAction } from "./providerOperationHealth.js";
|
import { providerRetryAction } from "./providerOperationHealth.js";
|
||||||
import type { AnalysisError, AnalysisErrorTag } from "./analysisErrorBase.js";
|
import type { AnalysisError, AnalysisErrorTag } from "./analysisErrorBase.js";
|
||||||
import type { AnalysisErrorProjection } from "./analysisErrorProjection.js";
|
import type { AnalysisErrorProjection } from "./analysisErrorProjection.js";
|
||||||
@@ -148,6 +149,7 @@ export const analysisErrorCategory = (
|
|||||||
error instanceof EvidenceFileError &&
|
error instanceof EvidenceFileError &&
|
||||||
(error.reason === "missing" ||
|
(error.reason === "missing" ||
|
||||||
error.reason === "not-file" ||
|
error.reason === "not-file" ||
|
||||||
|
error.reason === "not-bundle" ||
|
||||||
error.reason === "exists")
|
error.reason === "exists")
|
||||||
)
|
)
|
||||||
return "invalid_input";
|
return "invalid_input";
|
||||||
@@ -406,6 +408,7 @@ const evidenceFileMessage = ({
|
|||||||
operation,
|
operation,
|
||||||
reason,
|
reason,
|
||||||
published,
|
published,
|
||||||
|
evidenceId,
|
||||||
}: EvidenceFileError): string => {
|
}: EvidenceFileError): string => {
|
||||||
if (published !== undefined)
|
if (published !== undefined)
|
||||||
return "The output file was published, but staging cleanup remains incomplete.";
|
return "The output file was published, but staging cleanup remains incomplete.";
|
||||||
@@ -415,6 +418,7 @@ const evidenceFileMessage = ({
|
|||||||
: "Evidence output directory does not exist. Choose an existing directory and try again.";
|
: "Evidence output directory does not exist. Choose an existing directory and try again.";
|
||||||
if (reason === "not-file")
|
if (reason === "not-file")
|
||||||
return "Evidence path does not point to a regular file. Choose a file and try again.";
|
return "Evidence path does not point to a regular file. Choose a file and try again.";
|
||||||
|
if (reason === "not-bundle") return singleEvidenceRecordMessage(evidenceId);
|
||||||
if (reason === "exists")
|
if (reason === "exists")
|
||||||
return "Evidence file already exists. Choose another path or allow overwrite.";
|
return "Evidence file already exists. Choose another path or allow overwrite.";
|
||||||
if (reason === "invalid-json")
|
if (reason === "invalid-json")
|
||||||
|
|||||||
@@ -311,6 +311,9 @@ const artifactStateErrorDetails = (
|
|||||||
operation: error.operation,
|
operation: error.operation,
|
||||||
reason: error.reason,
|
reason: error.reason,
|
||||||
...(error.path === undefined ? {} : { path: error.path }),
|
...(error.path === undefined ? {} : { path: error.path }),
|
||||||
|
...(error.evidenceId === undefined
|
||||||
|
? {}
|
||||||
|
: { evidence_id: error.evidenceId }),
|
||||||
...(error.published === undefined
|
...(error.published === undefined
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
|
|||||||
@@ -212,14 +212,32 @@ export const describeEvidenceBundleFailure = (
|
|||||||
input: unknown,
|
input: unknown,
|
||||||
cause: unknown,
|
cause: unknown,
|
||||||
): string => {
|
): string => {
|
||||||
const record = evidenceSchema.safeParse(input);
|
const recordId = singleEvidenceRecordId(input);
|
||||||
if (record.success)
|
if (recordId !== undefined) return singleEvidenceRecordMessage(recordId);
|
||||||
return `Expected an Evidence bundle with records and manifest arrays, but this JSON is one Evidence record (${record.data.evidence_id}). Export a session bundle with the MCP export_evidence_bundle tool and supply that file.`;
|
|
||||||
return cause instanceof z.ZodError
|
return cause instanceof z.ZodError
|
||||||
? `Evidence bundle does not match the bundle schema ${describeValidationFailure(cause)}. Supply an unmodified bundle written by export_evidence_bundle or rea evidence-export.`
|
? `Evidence bundle does not match the bundle schema ${describeValidationFailure(cause)}. Supply an unmodified bundle written by export_evidence_bundle or rea evidence-export.`
|
||||||
: `Evidence bundle validation failed: ${describeValidationFailure(cause)}. Recreate or re-export the bundle, then try again.`;
|
: `Evidence bundle validation failed: ${describeValidationFailure(cause)}. Recreate or re-export the bundle, then try again.`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify an authentic single Evidence record supplied where a bundle is
|
||||||
|
* expected. A record whose semantic identifier does not match its content is
|
||||||
|
* damaged, not a mis-selected file, so it remains an integrity failure.
|
||||||
|
*/
|
||||||
|
export const singleEvidenceRecordId = (input: unknown): string | undefined => {
|
||||||
|
try {
|
||||||
|
return parseEvidence(input).evidence_id;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Explain how to obtain a bundle when one Evidence record was supplied. */
|
||||||
|
export const singleEvidenceRecordMessage = (
|
||||||
|
evidenceId: string | undefined,
|
||||||
|
): string =>
|
||||||
|
`Expected an Evidence bundle with records and manifest arrays, but this JSON is one Evidence record${evidenceId === undefined ? "" : ` (${evidenceId})`}. Export a session bundle with the MCP export_evidence_bundle tool and supply that file.`;
|
||||||
|
|
||||||
/** Locate the first schema issue, or keep a thrown constraint message. */
|
/** Locate the first schema issue, or keep a thrown constraint message. */
|
||||||
export const describeValidationFailure = (cause: unknown): string => {
|
export const describeValidationFailure = (cause: unknown): string => {
|
||||||
if (!(cause instanceof z.ZodError))
|
if (!(cause instanceof z.ZodError))
|
||||||
|
|||||||
@@ -50,17 +50,27 @@ export class EvidenceFileError extends AnalysisError {
|
|||||||
readonly path: string | undefined;
|
readonly path: string | undefined;
|
||||||
/** Present only when a write committed before cleanup failed. */
|
/** Present only when a write committed before cleanup failed. */
|
||||||
readonly published: PublishedEvidenceFile | undefined;
|
readonly published: PublishedEvidenceFile | undefined;
|
||||||
|
/** The single Evidence record found where a bundle was expected. */
|
||||||
|
readonly evidenceId: string | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly operation: "read" | "write",
|
readonly operation: "read" | "write",
|
||||||
readonly reason: "not-file" | "exists" | "invalid-json" | "missing" | "io",
|
readonly reason:
|
||||||
|
| "not-file"
|
||||||
|
| "not-bundle"
|
||||||
|
| "exists"
|
||||||
|
| "invalid-json"
|
||||||
|
| "missing"
|
||||||
|
| "io",
|
||||||
options?: AnalysisErrorOptions & {
|
options?: AnalysisErrorOptions & {
|
||||||
readonly path?: string;
|
readonly path?: string;
|
||||||
readonly published?: PublishedEvidenceFile;
|
readonly published?: PublishedEvidenceFile;
|
||||||
|
readonly evidenceId?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
super(`Evidence bundle ${operation} failed: ${reason}`, options);
|
super(`Evidence bundle ${operation} failed: ${reason}`, options);
|
||||||
this.path = options?.path;
|
this.path = options?.path;
|
||||||
this.published = options?.published;
|
this.published = options?.published;
|
||||||
|
this.evidenceId = options?.evidenceId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -439,13 +439,15 @@ describe("evidence bundle filesystem adapter", () => {
|
|||||||
const record = bundle().records[0];
|
const record = bundle().records[0];
|
||||||
const single = join(directory, "record.json");
|
const single = join(directory, "record.json");
|
||||||
await writeFile(single, JSON.stringify(record));
|
await writeFile(single, JSON.stringify(record));
|
||||||
|
// An intact record is the caller's file selection, not damaged content.
|
||||||
expect(await readEvidenceBundle(single)).toMatchObject({
|
expect(await readEvidenceBundle(single)).toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: {
|
error: {
|
||||||
_tag: "EvidenceIntegrityError",
|
_tag: "EvidenceFileError",
|
||||||
userMessage: expect.stringContaining(
|
operation: "read",
|
||||||
`this JSON is one Evidence record (${String(record?.evidence_id)})`,
|
reason: "not-bundle",
|
||||||
),
|
path: single,
|
||||||
|
evidenceId: record?.evidence_id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -477,3 +479,21 @@ describe("evidence bundle filesystem adapter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("evidence bundle single-record selection", () => {
|
||||||
|
it("keeps a tampered single record an integrity failure", async () => {
|
||||||
|
const directory = await createTestTempDirectory("rea-evidence-");
|
||||||
|
const record = bundle().records[0];
|
||||||
|
// A record whose identifier no longer matches its content is damaged,
|
||||||
|
// not a mis-selected file.
|
||||||
|
const tampered = join(directory, "tampered-record.json");
|
||||||
|
await writeFile(
|
||||||
|
tampered,
|
||||||
|
JSON.stringify({ ...record, operation: "tampered_operation" }),
|
||||||
|
);
|
||||||
|
expect(await readEvidenceBundle(tampered)).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { _tag: "EvidenceIntegrityError" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -134,10 +134,17 @@ describe("bundle comparison MCP integration", () => {
|
|||||||
expect(result.isError).toBe(true);
|
expect(result.isError).toBe(true);
|
||||||
expect(parseMcpToolError(result)).toMatchObject({
|
expect(parseMcpToolError(result)).toMatchObject({
|
||||||
error: {
|
error: {
|
||||||
code: "evidence_integrity_mismatch",
|
code: "invalid_request",
|
||||||
|
category: "invalid_input",
|
||||||
message: expect.stringContaining(
|
message: expect.stringContaining(
|
||||||
`this JSON is one Evidence record (${record.evidence_id})`,
|
`this JSON is one Evidence record (${record.evidence_id})`,
|
||||||
),
|
),
|
||||||
|
details: {
|
||||||
|
operation: "read",
|
||||||
|
reason: "not-bundle",
|
||||||
|
path: recordPath,
|
||||||
|
evidence_id: record.evidence_id,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user