diff --git a/src/application/ProviderOperationExecution.ts b/src/application/ProviderOperationExecution.ts index 36ea645b82..ce441b57a9 100644 --- a/src/application/ProviderOperationExecution.ts +++ b/src/application/ProviderOperationExecution.ts @@ -6,7 +6,6 @@ import type { } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, - AnalysisInputError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; import type { Evidence } from "../domain/evidence.js"; @@ -15,7 +14,7 @@ import { isImmutableJsonSnapshot, } from "../domain/immutableJson.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; -import { projectInputIssues } from "../domain/inputIssueProjection.js"; +import { analysisOperationInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../domain/result.js"; import type { AnalysisExecution, @@ -61,15 +60,11 @@ export const executeProviderOperation = async < const request = schema.safeParse({ operation, input }); if (!request.success) return err( - new AnalysisInputError( + analysisOperationInputErrorFromIssues( operation, - { cause: request.error }, - projectInputIssues(request.error.issues, { operation, input }).map( - (issue) => - issue.path[0] === "input" - ? { ...issue, path: issue.path.slice(1) } - : issue, - ), + request.error.issues, + input, + request.error, ), ); const executed = await provider.execute(request.data, options); diff --git a/src/application/android/AndroidAnalysisService.ts b/src/application/android/AndroidAnalysisService.ts index 2d3c26f9a2..f31dff0dc7 100644 --- a/src/application/android/AndroidAnalysisService.ts +++ b/src/application/android/AndroidAnalysisService.ts @@ -1,4 +1,4 @@ -import { projectInputIssues } from "../../domain/inputIssueProjection.js"; +import { analysisOperationInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import type { EvidenceWriter } from "../investigation/InvestigationRecordPort.js"; import { summarizeRetainedAndroidSearch } from "./AndroidSearchEvidenceSummary.js"; import type { AndroidAnalysisPort } from "./AndroidAnalysisPort.js"; @@ -8,10 +8,7 @@ import { androidRequestSchema, type AndroidOperation, } from "../../domain/android/androidAnalysis.js"; -import { - AnalysisCancelledError, - AnalysisInputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { jsonObjectSchema } from "../../domain/jsonValue.js"; @@ -38,15 +35,11 @@ export class AndroidAnalysisService { const request = androidRequestSchema.safeParse({ operation, input }); if (!request.success) return err( - new AnalysisInputError( + analysisOperationInputErrorFromIssues( operation, - { cause: request.error }, - projectInputIssues(request.error.issues, { operation, input }).map( - (issue) => - issue.path[0] === "input" - ? { ...issue, path: issue.path.slice(1) } - : issue, - ), + request.error.issues, + input, + request.error, ), ); const target = await parseBinaryTarget( diff --git a/src/application/pcap/PcapAnalysisService.test.ts b/src/application/pcap/PcapAnalysisService.test.ts index b19295e422..bf94fc6158 100644 --- a/src/application/pcap/PcapAnalysisService.test.ts +++ b/src/application/pcap/PcapAnalysisService.test.ts @@ -1,3 +1,4 @@ +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { expect, it } from "vitest"; import { createAnalysisExecution } from "../AnalysisProvider.js"; @@ -183,3 +184,25 @@ it.each(["inspect_pcap_client", "inspect_pcap_capture"] as const)( expect(result.error.diagnostics).toEqual({ provider_detail: "retained" }); }, ); + +it("preserves pcap caller field constraints before provider execution", async () => { + let calls = 0; + const service = new PcapAnalysisService({ + inspectAvailability: () => Promise.reject(new Error("unused")), + close: () => Promise.resolve(), + execute: () => { + calls++; + return Promise.reject(new Error("provider must not run")); + }, + }); + const result = await service.execute("inspect_pcap_capture", { path: "" }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "invalid_request", + details: { + issues: [{ path: ["path"], reason: "out_of_range", minimum: 1 }], + }, + }); + expect(calls).toBe(0); +}); diff --git a/src/application/pcap/PcapAnalysisService.ts b/src/application/pcap/PcapAnalysisService.ts index 14e1536823..0a69d14ae7 100644 --- a/src/application/pcap/PcapAnalysisService.ts +++ b/src/application/pcap/PcapAnalysisService.ts @@ -1,13 +1,11 @@ +import { analysisOperationInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import type { PcapAnalysisPort } from "./PcapAnalysisPort.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import { pcapRequestSchema, type PcapOperation, } from "../../domain/pcap/pcapAnalysis.js"; -import { - AnalysisCancelledError, - AnalysisInputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { Evidence } from "../../domain/evidence.js"; import { createPcapEvidence } from "../../domain/pcap/pcapEvidence.js"; @@ -32,7 +30,14 @@ export class PcapAnalysisService { ); const request = pcapRequestSchema.safeParse({ operation, input }); if (!request.success) - return err(new AnalysisInputError(operation, { cause: request.error })); + return err( + analysisOperationInputErrorFromIssues( + operation, + request.error.issues, + input, + request.error, + ), + ); const executed = await this.provider.execute(request.data, options); if (!executed.ok) return executed; return completeProviderEvidence( diff --git a/src/domain/inputIssueProjection.ts b/src/domain/inputIssueProjection.ts index c0392848f3..a768d819d6 100644 --- a/src/domain/inputIssueProjection.ts +++ b/src/domain/inputIssueProjection.ts @@ -26,6 +26,23 @@ export const analysisInputErrorFromIssues = ( [...projectInputIssues(issues, input), ...(options.additionalIssues ?? [])], ); +/** Remove only the internal operation/input envelope from caller correction paths. */ +export const analysisOperationInputErrorFromIssues = ( + operation: string, + issues: readonly z.core.$ZodIssue[], + input: unknown, + cause: unknown, +): AnalysisInputError => + new AnalysisInputError( + operation, + { cause }, + projectInputIssues(issues, { operation, input }).map((issue) => + issue.path[0] === "input" + ? { ...issue, path: issue.path.slice(1) } + : issue, + ), + ); + /** Project Zod failures to secret-safe caller correction metadata. */ export const projectInputIssues = ( issues: readonly z.core.$ZodIssue[], diff --git a/tests/boundary/process/androidAnalysisMcp.test.ts b/tests/boundary/process/androidAnalysisMcp.test.ts index 117fb80cbd..734073e95c 100644 --- a/tests/boundary/process/androidAnalysisMcp.test.ts +++ b/tests/boundary/process/androidAnalysisMcp.test.ts @@ -1,3 +1,4 @@ +import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server"; import { Ajv2020 } from "ajv/dist/2020.js"; @@ -23,6 +24,36 @@ import { verifyJadxFixtureCleanup, } from "../../fixtures/android/jadx.js"; +test("preserves Android caller field constraints before target or provider access", async () => { + let calls = 0; + const service = new AndroidAnalysisService({ + inspectAvailability: () => Promise.reject(new Error("unused")), + close: () => Promise.resolve(), + execute: () => { + calls++; + return Promise.reject(new Error("provider must not run")); + }, + }); + const result = await service.execute("inspect_android_method", { + path: "", + class_name: "", + method_name: "", + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { path: ["path"], reason: "out_of_range", minimum: 1 }, + { path: ["class_name"], reason: "out_of_range", minimum: 1 }, + { path: ["method_name"], reason: "out_of_range", minimum: 1 }, + ], + }, + }); + expect(calls).toBe(0); +}); + const it = test.skipIf(process.platform === "win32"); it("keeps a replacement server usable after the SDK discards its discovery probe", async () => {