diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index a5127e94ca..96f034d5ce 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -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 => { - 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 = ( diff --git a/src/application/analysisView/evidenceFileSource.ts b/src/application/analysisView/evidenceFileSource.ts index 753a3b35ef..a1ca150ce4 100644 --- a/src/application/analysisView/evidenceFileSource.ts +++ b/src/application/analysisView/evidenceFileSource.ts @@ -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; - } }; diff --git a/src/application/environment/environmentSource.ts b/src/application/environment/environmentSource.ts index 3fcee1d524..3fe73515a6 100644 --- a/src/application/environment/environmentSource.ts +++ b/src/application/environment/environmentSource.ts @@ -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 => { - 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. */ diff --git a/src/application/investigation/EvidenceLedger.ts b/src/application/investigation/EvidenceLedger.ts index a8fc6da9b6..6691c5ccf2 100644 --- a/src/application/investigation/EvidenceLedger.ts +++ b/src/application/investigation/EvidenceLedger.ts @@ -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 => { - const checked = readImmutableEvidence(input); + const checked = safeParseImmutableEvidence(input); return checked.ok ? checked : err( new EvidenceIntegrityError(message, { - cause: checked.error.cause, + cause: checked.error, }), ); }; diff --git a/src/application/javascript/JavaScriptApplicationEvidenceGraph.ts b/src/application/javascript/JavaScriptApplicationEvidenceGraph.ts index 0e7b519ec4..cc6e73570e 100644 --- a/src/application/javascript/JavaScriptApplicationEvidenceGraph.ts +++ b/src/application/javascript/JavaScriptApplicationEvidenceGraph.ts @@ -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 = ( diff --git a/src/application/process/ProcessEvidence.ts b/src/application/process/ProcessEvidence.ts index fd3e79137b..230afedd5d 100644 --- a/src/application/process/ProcessEvidence.ts +++ b/src/application/process/ProcessEvidence.ts @@ -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 diff --git a/src/domain/artifactInventoryEvidence.ts b/src/domain/artifactInventoryEvidence.ts index 2c1e963ed8..c19de27874 100644 --- a/src/domain/artifactInventoryEvidence.ts +++ b/src/domain/artifactInventoryEvidence.ts @@ -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( diff --git a/src/domain/evidenceValidation.ts b/src/domain/evidenceValidation.ts index fc853d91fa..b108f41970 100644 --- a/src/domain/evidenceValidation.ts +++ b/src/domain/evidenceValidation.ts @@ -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 => authenticateEvidence(input, parseEvidence); + +/** Authenticate and seal source records while retaining typed validation failures. */ +export const safeParseImmutableEvidence = ( + input: unknown, +): Result => + authenticateEvidence(input, immutableEvidence); + /** Authenticate one source record, preserving unexpected implementation failures. */ export const readEvidence = ( input: unknown, -): Result => - authenticateEvidence(input, parseEvidence); +): Result => { + 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 => - authenticateEvidence(input, immutableEvidence); +): Result => { + const parsed = safeParseImmutableEvidence(input); + return parsed.ok ? parsed : err(integrityFailure(parsed.error)); +}; const authenticateEvidence = ( input: unknown, parse: (input: unknown) => Evidence, -): Result => { +): Result => { try { return ok(parse(input)); } catch (cause: unknown) { if (!(cause instanceof z.ZodError)) throw cause; - return err(integrityFailure(cause)); + return err(cause); } }; diff --git a/src/domain/javascript/javascriptRuntimeReconciliationInputValidation.ts b/src/domain/javascript/javascriptRuntimeReconciliationInputValidation.ts index 2a1328f7c3..e35b2ff704 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationInputValidation.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationInputValidation.ts @@ -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. */ diff --git a/tests/process-global/analysisViewAuthenticationFailures.test.ts b/tests/process-global/analysisViewAuthenticationFailures.test.ts new file mode 100644 index 0000000000..68f39a2d59 --- /dev/null +++ b/tests/process-global/analysisViewAuthenticationFailures.test.ts @@ -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; + 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); +});