diff --git a/src/application/InventoryProjectionEvidence.ts b/src/application/InventoryProjectionEvidence.ts index 40d22de24..bded3a721 100644 --- a/src/application/InventoryProjectionEvidence.ts +++ b/src/application/InventoryProjectionEvidence.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import { - AnalysisInputError, - AnalysisProtocolError, -} from "../domain/analysisErrorCore.js"; +import { AnalysisProtocolError } from "../domain/analysisErrorCore.js"; import { type AnalysisError } from "../domain/analysisErrorBase.js"; import { createEvidence, @@ -13,6 +10,7 @@ import { } from "../domain/evidence.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; +import { requestInputError, workflowInputError } from "./workflowInputError.js"; interface InventoryProjectionInput { readonly inventory_evidence: readonly Evidence[]; @@ -49,7 +47,7 @@ export const projectInventoryEvidence = < const parsed = options.schema.safeParse(options.rawInput); if (!parsed.success) return err( - new AnalysisInputError(options.operation, { cause: parsed.error }), + requestInputError(options.operation, parsed.error, options.rawInput), ); try { const result = options.project(parsed.data); @@ -86,7 +84,7 @@ export const projectInventoryEvidence = < } catch (cause: unknown) { return err( cause instanceof TypeError || cause instanceof z.ZodError - ? new AnalysisInputError(options.operation, { cause }) + ? workflowInputError(options.operation, cause) : new AnalysisProtocolError(options.protocolError, { cause }), ); } diff --git a/src/application/ReconstructionCoverageService.ts b/src/application/ReconstructionCoverageService.ts index c947c9a9a..66cad4e76 100644 --- a/src/application/ReconstructionCoverageService.ts +++ b/src/application/ReconstructionCoverageService.ts @@ -1,9 +1,14 @@ +import { z } from "zod"; + import { reconstructionCoverageEvaluationInputSchema } from "../domain/reconstructionCoverageInput.js"; import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { type AnalysisError } from "../domain/analysisErrorBase.js"; import { evaluateReconstructionClosure } from "../domain/reconstructionCoverage.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; +import { requestInputError, workflowInputError } from "./workflowInputError.js"; + +const OPERATION = "evaluate_reconstruction_coverage"; /** Evaluate inline reconstruction coverage against a named boundary. */ export const evaluateReconstructionCoverage = ( @@ -13,11 +18,7 @@ export const evaluateReconstructionCoverage = ( const parsed = reconstructionCoverageEvaluationInputSchema.safeParse(rawInput); if (!parsed.success) - return err( - new AnalysisInputError("evaluate_reconstruction_coverage", { - cause: parsed.error, - }), - ); + return err(requestInputError(OPERATION, parsed.error, rawInput)); try { return ok( jsonValueSchema.parse( @@ -30,7 +31,9 @@ export const evaluateReconstructionCoverage = ( ); } catch (cause: unknown) { return err( - new AnalysisInputError("evaluate_reconstruction_coverage", { cause }), + cause instanceof TypeError || cause instanceof z.ZodError + ? workflowInputError(OPERATION, cause) + : new AnalysisInputError(OPERATION, { cause }), ); } }; diff --git a/src/application/ReconstructionObligationLedgerService.ts b/src/application/ReconstructionObligationLedgerService.ts index 2b4c9a50d..8314bff3b 100644 --- a/src/application/ReconstructionObligationLedgerService.ts +++ b/src/application/ReconstructionObligationLedgerService.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import { - AnalysisInputError, - AnalysisProtocolError, -} from "../domain/analysisErrorCore.js"; +import { AnalysisProtocolError } from "../domain/analysisErrorCore.js"; import { type AnalysisError } from "../domain/analysisErrorBase.js"; import { createEvidence, type Evidence } from "../domain/evidence.js"; import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js"; @@ -14,6 +11,7 @@ import { type ReconstructionObligationLedger, } from "../domain/reconstructionObligationLedgerSchemas.js"; import { err, ok, type Result } from "../domain/result.js"; +import { requestInputError, workflowInputError } from "./workflowInputError.js"; import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "./InvestigationProviders.js"; import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js"; import { evaluateReconstructionObligationLedger } from "./ReconstructionObligationLedgerEvaluation.js"; @@ -27,7 +25,7 @@ export const resolveReconstructionObligationLedgerRequest = ( const parsed = reconstructionObligationLedgerInputSchema.safeParse(input); return parsed.success ? ok(parsed.data) - : err(new AnalysisInputError(OPERATION, { cause: parsed.error })); + : err(requestInputError(OPERATION, parsed.error, input)); }; /** Build the complete deterministic ledger and wrap it in portable Evidence. */ @@ -48,7 +46,7 @@ export const buildReconstructionObligationLedgerEvidenceValidated = ( return ok(createLedgerEvidence(input, ledger)); } catch (cause: unknown) { if (cause instanceof z.ZodError) - return err(new AnalysisInputError(OPERATION, { cause })); + return err(workflowInputError(OPERATION, cause)); return err( new AnalysisProtocolError( "Reconstruction obligation ledger generation failed", diff --git a/src/application/managed/ManagedApplicationGraphService.ts b/src/application/managed/ManagedApplicationGraphService.ts index 5c27f648f..66ea6a08a 100644 --- a/src/application/managed/ManagedApplicationGraphService.ts +++ b/src/application/managed/ManagedApplicationGraphService.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import { - AnalysisInputError, - AnalysisProtocolError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisProtocolError } from "../../domain/analysisErrorCore.js"; import { type AnalysisError } from "../../domain/analysisErrorBase.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; @@ -16,6 +13,10 @@ import { } from "../../domain/managed/managedApplicationGraph.js"; import { err, ok, type Result } from "../../domain/result.js"; import { MANAGED_WORKFLOW_PROVIDER } from "../InvestigationProviders.js"; +import { + requestInputError, + workflowInputError, +} from "../workflowInputError.js"; const OPERATION = "project_managed_application_graph" as const; @@ -25,14 +26,14 @@ export const projectManagedApplicationGraphEvidence = ( ): Result => { const parsed = projectManagedApplicationGraphInputSchema.safeParse(rawInput); if (!parsed.success) - return err(new AnalysisInputError(OPERATION, { cause: parsed.error })); + return err(requestInputError(OPERATION, parsed.error, rawInput)); try { const result = projectManagedApplicationGraph(parsed.data); return ok(createManagedApplicationGraphEvidence(parsed.data, result)); } catch (cause: unknown) { return err( cause instanceof TypeError || cause instanceof z.ZodError - ? new AnalysisInputError(OPERATION, { cause }) + ? workflowInputError(OPERATION, cause) : new AnalysisProtocolError( "Managed application graph projection produced an invalid result", { cause }, diff --git a/src/application/managed/ManagedMemberComparisonService.ts b/src/application/managed/ManagedMemberComparisonService.ts index e27846f14..9dd459818 100644 --- a/src/application/managed/ManagedMemberComparisonService.ts +++ b/src/application/managed/ManagedMemberComparisonService.ts @@ -9,10 +9,7 @@ import { parseManagedMemberEvidence, type CompareManagedMembersInput, } from "../../domain/managed/managedMemberComparison.js"; -import { - AnalysisInputError, - AnalysisProtocolError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisProtocolError } from "../../domain/analysisErrorCore.js"; import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; import { type AnalysisError } from "../../domain/analysisErrorBase.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; @@ -24,6 +21,7 @@ import { MANAGED_STATIC_PROVIDER, MANAGED_WORKFLOW_PROVIDER, } from "../InvestigationProviders.js"; +import { workflowInputError } from "../workflowInputError.js"; /** Compare managed members from input parsed by a trusted adapter. */ export const compareManagedMembersEvidenceValidated = ( @@ -67,14 +65,10 @@ export const compareManagedMemberPaths = async ( dependencies.resolveTarget(input.leftPath), dependencies.resolveTarget(input.rightPath), ]); - if (!leftTarget.ok) - return err( - new AnalysisInputError(operation, { cause: leftTarget.error }), - ); - if (!rightTarget.ok) - return err( - new AnalysisInputError(operation, { cause: rightTarget.error }), - ); + // Report an unopenable path like every other path-based command does, + // with the failed path and constraint, rather than a bare input error. + if (!leftTarget.ok) return err(leftTarget.error); + if (!rightTarget.ok) return err(rightTarget.error); const [leftBytes, rightBytes] = await Promise.all([ dependencies.readBytes(leftTarget.value.path), dependencies.readBytes(rightTarget.value.path), @@ -175,7 +169,7 @@ const workflowFailure = ( ): Result => err( cause instanceof z.ZodError || cause instanceof TypeError - ? new AnalysisInputError(operation, { cause }) + ? workflowInputError(operation, cause) : new AnalysisProtocolError( "Managed member comparison produced an invalid result", { cause }, diff --git a/src/application/managed/ManagedNativeVerificationService.ts b/src/application/managed/ManagedNativeVerificationService.ts index 6835701ae..c73a235ab 100644 --- a/src/application/managed/ManagedNativeVerificationService.ts +++ b/src/application/managed/ManagedNativeVerificationService.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import { - AnalysisInputError, - AnalysisProtocolError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisProtocolError } from "../../domain/analysisErrorCore.js"; import { type AnalysisError } from "../../domain/analysisErrorBase.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; @@ -15,6 +12,10 @@ import { } from "../../domain/managed/managedNativeVerification.js"; import { err, ok, type Result } from "../../domain/result.js"; import { MANAGED_WORKFLOW_PROVIDER } from "../InvestigationProviders.js"; +import { + requestInputError, + workflowInputError, +} from "../workflowInputError.js"; const OPERATION = "verify_managed_native_boundaries" as const; @@ -24,14 +25,14 @@ export const verifyManagedNativeBoundariesEvidence = ( ): Result => { const parsed = managedNativeVerificationInputSchema.safeParse(rawInput); if (!parsed.success) - return err(new AnalysisInputError(OPERATION, { cause: parsed.error })); + return err(requestInputError(OPERATION, parsed.error, rawInput)); try { const result = verifyManagedNativeBoundaries(parsed.data); return ok(createManagedNativeVerificationEvidence(parsed.data, result)); } catch (cause: unknown) { return err( cause instanceof TypeError || cause instanceof z.ZodError - ? new AnalysisInputError(OPERATION, { cause }) + ? workflowInputError(OPERATION, cause) : new AnalysisProtocolError( "Managed/native verification produced an invalid result", { cause }, diff --git a/src/application/managed/ManagedReconstructionService.test.ts b/src/application/managed/ManagedReconstructionService.test.ts index c980e1c6f..a66b5bd4a 100644 --- a/src/application/managed/ManagedReconstructionService.test.ts +++ b/src/application/managed/ManagedReconstructionService.test.ts @@ -9,6 +9,7 @@ import { } from "../../domain/managed/managedReconstruction.js"; import { managedMemberInspectionSchema } from "../../domain/managed/managedArtifact.js"; import { createEvidence } from "../../domain/evidence.js"; +import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { importManagedReconstructionEvidence } from "./ManagedReconstructionService.js"; const exampleInput = () => @@ -171,3 +172,78 @@ describe("managed decompiler reconstruction import", () => { }); }); }); + +describe("managed reconstruction input diagnostics", () => { + it("lists schema issues for malformed raw input", () => { + const result = importManagedReconstructionEvidence({}); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: expect.arrayContaining([ + expect.objectContaining({ reason: "missing_argument" }), + ]), + }); + }); + + it("keeps the failed domain constraint as an issue message", () => { + const result = importManagedReconstructionEvidence({ + ...MANAGED_RECONSTRUCTION_IMPORT_EXAMPLE, + methods: [ + { + ...exampleMethod(), + reconstruction: { + ...exampleMethod().reconstruction, + text_sha256: "0".repeat(64), + }, + }, + ], + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: [ + { + path: [], + reason: "invalid_value", + message: expect.stringMatching(/text hash mismatch/u), + }, + ], + }); + }); + + it("reports nested Evidence schema issues without a misleading request path", () => { + const input = exampleInput(); + const malformed = createEvidence(undefined, input.static_members.provider, { + operation: "inspect_managed_members", + parameters: {}, + result: { + ...managedMemberInspectionSchema.parse( + input.static_members.normalized_result, + ), + artifact: { sha256: 7 }, + }, + rawResult: null, + }); + const result = importManagedReconstructionEvidence({ + ...MANAGED_RECONSTRUCTION_IMPORT_EXAMPLE, + static_members: malformed, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toMatchObject({ _tag: "AnalysisInputError" }); + const issues = + result.error instanceof AnalysisInputError ? result.error.issues : []; + expect(issues.length).toBeGreaterThan(0); + expect(issues.every(({ path }) => path.length === 0)).toBe(true); + expect(issues).toContainEqual( + expect.objectContaining({ + reason: "invalid_value", + message: expect.stringContaining( + "nested value failed validation at artifact", + ), + }), + ); + }); +}); diff --git a/src/application/managed/ManagedReconstructionService.ts b/src/application/managed/ManagedReconstructionService.ts index 86c38f7f7..0fec81e13 100644 --- a/src/application/managed/ManagedReconstructionService.ts +++ b/src/application/managed/ManagedReconstructionService.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import { - AnalysisInputError, - AnalysisProtocolError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisProtocolError } from "../../domain/analysisErrorCore.js"; import { type AnalysisError } from "../../domain/analysisErrorBase.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; @@ -15,6 +12,10 @@ import { } from "../../domain/managed/managedReconstruction.js"; import { err, ok, type Result } from "../../domain/result.js"; import { MANAGED_WORKFLOW_PROVIDER } from "../InvestigationProviders.js"; +import { + requestInputError, + workflowInputError, +} from "../workflowInputError.js"; const OPERATION = "import_managed_reconstruction" as const; @@ -24,7 +25,7 @@ export const importManagedReconstructionEvidence = ( ): Result => { const parsed = managedReconstructionImportInputSchema.safeParse(rawInput); if (!parsed.success) - return err(new AnalysisInputError(OPERATION, { cause: parsed.error })); + return err(requestInputError(OPERATION, parsed.error, rawInput)); return importManagedReconstructionEvidenceValidated(parsed.data); }; @@ -38,7 +39,7 @@ export const importManagedReconstructionEvidenceValidated = ( } catch (cause: unknown) { return err( cause instanceof TypeError || cause instanceof z.ZodError - ? new AnalysisInputError(OPERATION, { cause }) + ? workflowInputError(OPERATION, cause) : new AnalysisProtocolError( "Managed reconstruction import produced an invalid result", { cause }, diff --git a/src/application/workflowInputError.ts b/src/application/workflowInputError.ts new file mode 100644 index 000000000..33150be22 --- /dev/null +++ b/src/application/workflowInputError.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { projectInputIssues } from "../domain/inputIssueProjection.js"; + +/** Project schema issues of the raw request onto their request paths. */ +export const requestInputError = ( + operation: string, + cause: z.ZodError, + input: unknown, +): AnalysisInputError => + new AnalysisInputError( + operation, + { cause }, + projectInputIssues(cause.issues, input), + ); + +/** + * Keep the failed constraint when a parsed request is rejected later. Nested + * values such as Evidence results are parsed on their own, so their issue + * paths are relative to that value and are reported in the message, not as + * request paths. + */ +export const workflowInputError = ( + operation: string, + cause: z.ZodError | TypeError, +): AnalysisInputError => + new AnalysisInputError( + operation, + { cause }, + cause instanceof z.ZodError + ? cause.issues.map((issue) => ({ + path: [], + reason: "invalid_value" as const, + message: `A nested value failed validation at ${issue.path.length === 0 ? "its root" : issue.path.map(String).join(".")}: ${issue.message}`, + })) + : [{ path: [], reason: "invalid_value", message: cause.message }], + ); diff --git a/tests/boundary/mcp/reconstructionCoverageMcp.test.ts b/tests/boundary/mcp/reconstructionCoverageMcp.test.ts index ce06c2651..297247d2a 100644 --- a/tests/boundary/mcp/reconstructionCoverageMcp.test.ts +++ b/tests/boundary/mcp/reconstructionCoverageMcp.test.ts @@ -34,6 +34,28 @@ describe("reconstruction coverage MCP", () => { boundary_id: boundaryId, summary: { reasons: 0 }, }); + const unknown = await client.callTool({ + name: "evaluate_reconstruction_coverage", + arguments: { + coverage: currentFixtureCoverage(boundaryId), + boundary_id: "replacement.missing", + }, + }); + expect(unknown.isError).toBe(true); + expect(unknown.structuredContent).toMatchObject({ + error: { + code: "invalid_request", + details: { + issues: [ + { + path: [], + reason: "invalid_value", + message: "Unknown reconstruction boundary: replacement.missing", + }, + ], + }, + }, + }); } finally { await client.close(); await server.close(); diff --git a/tests/boundary/providers/managed/managedMemberComparison.test.ts b/tests/boundary/providers/managed/managedMemberComparison.test.ts index a33b7592f..1cb1c2f0e 100644 --- a/tests/boundary/providers/managed/managedMemberComparison.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparison.test.ts @@ -12,6 +12,7 @@ import { import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { managedMemberComparisonResultSchema } from "../../../../src/domain/managed/managedMemberComparison.js"; import { createEvidence } from "../../../../src/domain/evidence.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { jsonValueSchema } from "../../../../src/domain/jsonValue.js"; import { compareManagedMembersInputSchema } from "../../../../src/domain/managed/managedMemberComparison.js"; import { inspectManagedMembersBytes } from "../../../../src/dotnet/ManagedMemberInspector.js"; @@ -126,6 +127,22 @@ describe("managed member comparison path workflow", () => { ).toBe(1); }); + it("reports an unopenable path with the path that failed", async () => { + const directory = await createTestTempDirectory("rea-managed-missing-"); + const leftPath = join(directory, "missing.dll"); + const rightPath = join(directory, "right.dll"); + await writeFile(rightPath, buildManagedPeFixture()); + + const result = await compareManagedMemberPaths({ leftPath, rightPath }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error._tag).toBe("BinaryTargetError"); + expect(projectAnalysisError(result.error)).toMatchObject({ + details: { path: expect.stringContaining("missing.dll") }, + }); + }); + it("rejects a file that changes after target identity is resolved", async () => { const directory = await createTestTempDirectory( "rea-managed-compare-race-",