Compare commits

...
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 6c5aa93140 fix(evidence): authenticate a single record before classifying it as not-bundle
The schema-only check admitted a record whose evidence_id no longer
matches its content, so a tampered record was reported as a caller
selection error instead of an integrity mismatch. Use the authenticating
Evidence parser; only an authentic record is a mis-selected file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:35:04 +00:00
Kaoru0822andClaude Opus 5.5 31bb73768c fix(evidence): report a single record selected as a bundle as invalid input
Reading a caller-selected Evidence bundle that holds one intact Evidence
record, such as one command's --json result, failed with
evidence_integrity_mismatch. The record authenticates; the caller chose
the wrong kind of file. That is a selection error like a missing or
non-regular path, which #1256 already reports as invalid input.

Classify it at the file-reading boundary as EvidenceFileError reason
not-bundle, projected as invalid_request / invalid_input with the
selected path and the record's evidence_id. The guidance to export a
session bundle is unchanged. Unparseable or tampered bundles remain
evidence_integrity_mismatch.

Fixes #2050

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:08:33 +00:00
7 changed files with 84 additions and 9 deletions
+13
View File
@@ -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,
+4
View File
@@ -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")
+3
View File
@@ -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
? {} ? {}
: { : {
+21 -3
View File
@@ -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))
+11 -1
View File
@@ -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 {