fix(evidence): centralize authentication failure ownership

This commit is contained in:
morluto
2026-10-23 13:48:11 +08:00
parent 0407bbfbe6
commit b6a0f32b14
10 changed files with 209 additions and 119 deletions
@@ -3,7 +3,6 @@ import { dispatchQueuedInterrupts } from "../../process/queuedInterrupts.js";
import { readImmutableEvidenceBundle } from "../EvidenceBundleFiles.js";
import { INSPECT_ANALYSIS_VIEW_OPERATION } from "../../domain/analysisView/analysisViewErrors.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import { z } from "zod";
import { ANALYSIS_VIEW_PROVIDER } from "../InvestigationProviders.js";
import type { EvidenceLookup } from "../EvidenceInputResolver.js";
@@ -17,11 +16,8 @@ import {
} from "../../domain/analysisErrorCore.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import {
createEvidence,
parseEvidence,
type Evidence,
} from "../../domain/evidence.js";
import { safeParseEvidence } from "../../domain/evidenceValidation.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import {
inspectAnalysisViewInputSchema,
projectAnalysisView,
@@ -335,30 +331,20 @@ const authenticateEvidence = (
path: readonly (string | number)[],
input: unknown,
): Result<Evidence, AnalysisError> => {
try {
return ok(parseEvidence(value));
} catch (cause: unknown) {
if (cause instanceof z.ZodError)
return err(
analysisInputErrorFromIssues(
INSPECT_ANALYSIS_VIEW_OPERATION,
cause.issues.map((issue) => ({
...issue,
path: [...path, ...issue.path],
})),
input,
{ cause },
),
);
if (cause instanceof TypeError)
return err(
new EvidenceIntegrityError(
cause.message,
cause.message.length > 0 ? { userMessage: cause.message } : undefined,
),
);
throw cause;
}
const parsed = safeParseEvidence(value);
if (parsed.ok) return parsed;
const cause = parsed.error;
return err(
analysisInputErrorFromIssues(
INSPECT_ANALYSIS_VIEW_OPERATION,
cause.issues.map((issue) => ({
...issue,
path: [...path, ...issue.path],
})),
input,
{ cause },
),
);
};
const createAnalysisViewEvidence = (
@@ -1,16 +1,13 @@
import { requireAuthenticationHeadroom } from "../JsonAuthenticationHeadroom.js";
import { z } from "zod";
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import { INSPECT_ANALYSIS_VIEW_OPERATION } from "../../domain/analysisView/analysisViewErrors.js";
import { err, ok, type Result } from "../../domain/result.js";
import { parseEvidence, type Evidence } from "../../domain/evidence.js";
import {
EvidenceFileError,
EvidenceIntegrityError,
} from "../../domain/evidenceErrors.js";
import { err, type Result } from "../../domain/result.js";
import type { Evidence } from "../../domain/evidence.js";
import { safeParseEvidence } from "../../domain/evidenceValidation.js";
import { EvidenceFileError } from "../../domain/evidenceErrors.js";
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
import { readJsonFile } from "../JsonFiles.js";
@@ -51,26 +48,16 @@ export const readEvidenceFileSource = async (
description: "Insufficient heap headroom to authenticate the Evidence file",
});
if (!headroom.ok) return headroom;
try {
return ok(parseEvidence(loaded.value));
} catch (cause: unknown) {
if (cause instanceof z.ZodError)
return err(
const parsed = safeParseEvidence(loaded.value);
return parsed.ok
? parsed
: err(
new EvidenceFileError("read", "wrong-kind", {
path,
cause,
contentIssues: projectInputIssues(cause.issues, loaded.value),
cause: parsed.error,
contentIssues: projectInputIssues(parsed.error.issues, loaded.value),
userMessage:
"The selected file does not contain valid Evidence. Repair or recreate the file and try again.",
}),
);
if (cause instanceof TypeError)
return err(
new EvidenceIntegrityError(cause.message, {
cause,
userMessage: cause.message,
}),
);
throw cause;
}
};
@@ -20,7 +20,8 @@ import {
AnalysisInputError,
type AnalysisInputIssue,
} from "../../domain/analysisErrorCore.js";
import { immutableEvidence, type Evidence } from "../../domain/evidence.js";
import type { Evidence } from "../../domain/evidence.js";
import { safeParseImmutableEvidence } from "../../domain/evidenceValidation.js";
import { parseExactJson } from "../../domain/exactJsonNumber.js";
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
import { immutableJsonSnapshotMember } from "../../domain/immutableJson.js";
@@ -136,20 +137,18 @@ export const parseEvidenceFile = (
role: "manifest" | "evidence" | "state",
value: unknown,
): Result<Evidence, AnalysisError> => {
try {
for (const key of ["parameters", "raw_result", "normalized_result"])
immutableJsonSnapshotMember(value, key);
return ok(immutableEvidence(value));
} catch (cause: unknown) {
if (!(cause instanceof z.ZodError)) throw cause;
return err(
schemaInputError(
operation,
{ prefix: [role], cause, value },
`The ${role} file is not authenticated REA Evidence JSON.`,
),
);
}
for (const key of ["parameters", "raw_result", "normalized_result"])
immutableJsonSnapshotMember(value, key);
const parsed = safeParseImmutableEvidence(value);
return parsed.ok
? parsed
: err(
schemaInputError(
operation,
{ prefix: [role], cause: parsed.error, value },
`The ${role} file is not authenticated REA Evidence JSON.`,
),
);
};
/** A schema refusal of `value`, found at `prefix`; issue paths extend the prefix and the schema failure is retained. */
@@ -13,7 +13,7 @@ import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import { UnknownRegistryError } from "../../domain/unknownRegistryError.js";
import type { Evidence } from "../../domain/evidence.js";
import { readImmutableEvidence } from "../../domain/evidenceValidation.js";
import { safeParseImmutableEvidence } from "../../domain/evidenceValidation.js";
import {
createResidualUnknown,
updateResidualUnknown,
@@ -482,12 +482,12 @@ const readLedgerEvidence = (
input: unknown,
message: string,
): Result<Evidence, EvidenceIntegrityError> => {
const checked = readImmutableEvidence(input);
const checked = safeParseImmutableEvidence(input);
return checked.ok
? checked
: err(
new EvidenceIntegrityError(message, {
cause: checked.error.cause,
cause: checked.error,
}),
);
};
@@ -3,11 +3,8 @@ import {
JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER,
MANAGED_WORKFLOW_PROVIDER,
} from "../InvestigationProviders.js";
import {
isImmutableEvidence,
parseEvidence,
type Evidence,
} from "../../domain/evidence.js";
import { isImmutableEvidence, type Evidence } from "../../domain/evidence.js";
import { safeParseEvidence } from "../../domain/evidenceValidation.js";
import {
type AnalysisInputIssue,
AnalysisInputError,
@@ -355,12 +352,9 @@ const parseEvidenceInput = (
input: unknown,
path: readonly (string | number)[],
): Evidence => {
try {
return parseEvidence(input);
} catch (cause: unknown) {
if (cause instanceof z.ZodError) throw zodInputFailure(cause, input, path);
throw cause;
}
const parsed = safeParseEvidence(input);
if (parsed.ok) return parsed.value;
throw zodInputFailure(parsed.error, input, path);
};
const parseCallerValue = <Value>(
+6 -10
View File
@@ -1,10 +1,9 @@
import { z } from "zod";
import type { z } from "zod";
import { posix, win32 } from "node:path";
import type { JsonValue } from "../../domain/jsonValue.js";
import {
createEvidence,
parseEvidence,
type Evidence,
type EvidenceProvider,
} from "../../domain/evidence.js";
@@ -17,6 +16,7 @@ import {
safeParseProcessCapture,
type ProcessCapture,
} from "../../domain/process/processCaptureParsing.js";
import { safeParseEvidence } from "../../domain/evidenceValidation.js";
import type { ProcessScenario } from "../../domain/process/processScenario.js";
import { err, ok, type Result } from "../../domain/result.js";
@@ -49,14 +49,10 @@ export const parseProcessCaptureEvidence = (
{ readonly record: Evidence; readonly capture: ProcessCapture },
ProcessCaptureEvidenceFailure
> => {
let record: Evidence;
try {
record = parseEvidence(input);
} catch (cause: unknown) {
if (cause instanceof z.ZodError)
return err({ kind: "invalid-evidence", cause });
throw cause;
}
const parsedEvidence = safeParseEvidence(input);
if (!parsedEvidence.ok)
return err({ kind: "invalid-evidence", cause: parsedEvidence.error });
const record = parsedEvidence.value;
if (
record.operation !== PROCESS_CAPTURE_EVIDENCE.operation ||
record.predicate_type !== PROCESS_CAPTURE_EVIDENCE.predicate
+11 -12
View File
@@ -22,10 +22,10 @@ import {
artifactManifestId,
occurrenceIdForLocation,
} from "./artifactIdentity.js";
import { parseEvidence, type Evidence } from "./evidence.js";
import type { Evidence } from "./evidence.js";
import { z } from "zod";
import { EvidenceIntegrityError } from "./evidenceErrors.js";
import { readEvidence } from "./evidenceValidation.js";
import { readEvidence, safeParseEvidence } from "./evidenceValidation.js";
import {
artifactInspectionResultSchema,
type ArtifactInspectionResult,
@@ -191,24 +191,23 @@ const parseInspectionInventoryPayload = (
);
const sourceEvidence =
immutableJsonSnapshotMember(sealedStep, "evidence") ?? step.evidence;
let parsedEvidence: Evidence;
try {
parsedEvidence = parseEvidence(sourceEvidence);
} catch (cause: unknown) {
if (!(cause instanceof z.ZodError)) throw cause;
const parsedEvidence = safeParseEvidence(sourceEvidence);
if (!parsedEvidence.ok)
throw Object.assign(
new z.ZodError(
cause.issues.map((issue) => ({
parsedEvidence.error.issues.map((issue) => ({
...issue,
path: ["substeps", index, "evidence", ...issue.path],
})),
),
{ cause },
{ cause: parsedEvidence.error },
);
}
const evidence = canBorrowParsedJsonSnapshot(sourceEvidence, parsedEvidence)
const evidence = canBorrowParsedJsonSnapshot(
sourceEvidence,
parsedEvidence.value,
)
? sourceEvidence
: parsedEvidence;
: parsedEvidence.value;
immutableJsonSnapshotMember(evidence, "normalized_result");
if (step.evidence_id !== evidence.evidence_id)
throw new ArtifactInventoryValidationError(
+21 -6
View File
@@ -5,27 +5,42 @@ import { EvidenceIntegrityError } from "./evidenceErrors.js";
import { describeValidationFailure } from "./validationFailure.js";
import { err, ok, type Result } from "./result.js";
/** Expected authentication failures, independent of a caller's error contract. */
export const safeParseEvidence = (
input: unknown,
): Result<Evidence, z.ZodError> => authenticateEvidence(input, parseEvidence);
/** Authenticate and seal source records while retaining typed validation failures. */
export const safeParseImmutableEvidence = (
input: unknown,
): Result<Evidence, z.ZodError> =>
authenticateEvidence(input, immutableEvidence);
/** Authenticate one source record, preserving unexpected implementation failures. */
export const readEvidence = (
input: unknown,
): Result<Evidence, EvidenceIntegrityError> =>
authenticateEvidence(input, parseEvidence);
): Result<Evidence, EvidenceIntegrityError> => {
const parsed = safeParseEvidence(input);
return parsed.ok ? parsed : err(integrityFailure(parsed.error));
};
/** Authenticate and seal source records retained independently of mutable input. */
export const readImmutableEvidence = (
input: unknown,
): Result<Evidence, EvidenceIntegrityError> =>
authenticateEvidence(input, immutableEvidence);
): Result<Evidence, EvidenceIntegrityError> => {
const parsed = safeParseImmutableEvidence(input);
return parsed.ok ? parsed : err(integrityFailure(parsed.error));
};
const authenticateEvidence = (
input: unknown,
parse: (input: unknown) => Evidence,
): Result<Evidence, EvidenceIntegrityError> => {
): Result<Evidence, z.ZodError> => {
try {
return ok(parse(input));
} catch (cause: unknown) {
if (!(cause instanceof z.ZodError)) throw cause;
return err(integrityFailure(cause));
return err(cause);
}
};
@@ -1,8 +1,9 @@
import { z } from "zod";
import type { z } from "zod";
import { AnalysisInputError } from "../analysisErrorCore.js";
import { projectInputIssues } from "../inputIssueProjection.js";
import { parseEvidence, type Evidence } from "../evidence.js";
import type { Evidence } from "../evidence.js";
import { safeParseEvidence } from "../evidenceValidation.js";
const OPERATION = "reconcile_javascript_runtime";
@@ -23,13 +24,9 @@ export const authenticateRuntimeEvidence = (
input: unknown,
path: readonly (string | number)[],
): Evidence => {
try {
return parseEvidence(input);
} catch (cause: unknown) {
if (cause instanceof z.ZodError)
throw inputErrorAtPath(cause.issues, input, path, cause);
throw cause;
}
const parsed = safeParseEvidence(input);
if (parsed.ok) return parsed.value;
throw inputErrorAtPath(parsed.error.issues, input, path, parsed.error);
};
/** Represent one cross-field caller constraint as a standard input issue. */
@@ -0,0 +1,117 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished, vi } from "vitest";
import { createApplicationMcpHarness } from "../fixtures/applicationMcpHarness.js";
import {
analysisViewCaptureEvidence,
environmentManifestCaptureFixture,
} from "../fixtures/analysisView.js";
import { createTestBinarySession } from "../fixtures/binarySession.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const unexpectedFailure = (
kind: "TypeError" | "Error",
source: "inline" | "evidence-file",
): Error => {
const message = `injected ${kind} during ${source} Evidence authentication`;
return kind === "TypeError" ? new TypeError(message) : new Error(message);
};
const manifestView = {
kind: "environment-manifest",
template_rules: ["opaque-token", "sibling-pair-variance"],
} as const;
const createOwnedHarness = async () => {
const harness = await createApplicationMcpHarness({
session: createTestBinarySession(() => {
throw new Error("stored Evidence views must not launch a provider");
}),
});
onTestFinished(async () => {
vi.restoreAllMocks();
await harness.close();
});
return harness;
};
it.each(
(["inline", "evidence-file"] as const).flatMap((source) =>
(["TypeError", "Error"] as const).map(
(failureKind) => [source, failureKind] as const,
),
),
)(
"preserves an unexpected parser failure from %s input (%s) without retaining a view",
async (sourceKind, failureKind) => {
const harness = await createOwnedHarness();
const parent = analysisViewCaptureEvidence(
environmentManifestCaptureFixture(),
);
expect(harness.session.recordEvidence(parent).ok).toBe(true);
const before = harness.session.exportEvidenceBundle();
let source: Record<string, unknown>;
if (sourceKind === "inline") {
source = { kind: "inline", evidence: parent };
} else {
const root = await createTestTempDirectory("rea-view-auth-failure-");
const path = join(root, "parent.json");
await writeFile(path, JSON.stringify(parent));
source = { kind: "evidence-file", path };
}
const evidenceModule = await import("../../src/domain/evidence.js");
const failure = unexpectedFailure(failureKind, sourceKind);
vi.spyOn(evidenceModule, "parseEvidence").mockImplementation(() => {
throw failure;
});
const response = await harness.client.callTool({
name: "inspect_analysis_view",
arguments: { source, view: manifestView },
});
expect(response).toMatchObject({
isError: true,
content: [{ type: "text", text: failure.message }],
});
expect(response.structuredContent).toBeUndefined();
expect(harness.session.exportEvidenceBundle()).toEqual(before);
},
);
it("uses retained Evidence without reparsing its parent", async () => {
const harness = await createOwnedHarness();
const parent = analysisViewCaptureEvidence(
environmentManifestCaptureFixture(),
);
expect(harness.session.recordEvidence(parent).ok).toBe(true);
const evidenceModule = await import("../../src/domain/evidence.js");
const originalParse = evidenceModule.parseEvidence;
vi.spyOn(evidenceModule, "parseEvidence").mockImplementation((input) => {
if (
typeof input === "object" &&
input !== null &&
"evidence_id" in input &&
input.evidence_id === parent.evidence_id
)
throw new TypeError("retained source parent was reparsed");
return originalParse(input);
});
const response = await harness.client.callTool({
name: "inspect_analysis_view",
arguments: {
source: { kind: "retained-evidence", evidence_id: parent.evidence_id },
view: manifestView,
},
});
expect(response.isError).not.toBe(true);
expect(response.structuredContent).toBeDefined();
expect(harness.session.exportEvidenceBundle().records).toHaveLength(2);
});