diff --git a/src/application/WebScriptExportService.ts b/src/application/WebScriptExportService.ts index d0cd45ea31..24bcaab43e 100644 --- a/src/application/WebScriptExportService.ts +++ b/src/application/WebScriptExportService.ts @@ -1,5 +1,4 @@ import { createHash } from "node:crypto"; -import { isAbsolute } from "node:path"; import { z } from "zod"; @@ -85,21 +84,6 @@ export const exportWebScriptsValidated = async ( resources: ArtifactResourceScope, options: ExecutionOptions = {}, ): Promise> => { - const invalidPaths = (["capture_path", "output_directory"] as const).filter( - (field) => !isAbsolute(input[field]), - ); - if (invalidPaths.length > 0) - return err( - new AnalysisInputError( - OPERATION, - undefined, - invalidPaths.map((field) => ({ - path: [field], - reason: "invalid_format" as const, - message: `${field} must be an absolute filesystem path on this host.`, - })), - ), - ); try { if (options.signal?.aborted) return err( diff --git a/src/application/binaryDiagnostics/AppleIpsService.ts b/src/application/binaryDiagnostics/AppleIpsService.ts index 29a15cac4a..25539b3353 100644 --- a/src/application/binaryDiagnostics/AppleIpsService.ts +++ b/src/application/binaryDiagnostics/AppleIpsService.ts @@ -1,11 +1,7 @@ -import { isAbsolute } from "node:path"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { AppleIpsPort } from "./AppleIpsPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisInputError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { appleIpsSchema, @@ -38,16 +34,6 @@ export class AppleIpsService { options?.signal, ); if (!input.ok) return input; - if (!isAbsolute(input.value.path)) - return err( - new AnalysisInputError(OPERATION, undefined, [ - { - path: ["path"], - reason: "invalid_format", - message: "Expected an absolute filesystem path on this host.", - }, - ]), - ); const result = await this.provider.inspect(input.value, options); if (!result.ok) return result; const report = appleIpsSchema.safeParse(result.value); diff --git a/src/application/binaryDiagnostics/ByteEntropyService.ts b/src/application/binaryDiagnostics/ByteEntropyService.ts index 2e5776d587..a845e45b58 100644 --- a/src/application/binaryDiagnostics/ByteEntropyService.ts +++ b/src/application/binaryDiagnostics/ByteEntropyService.ts @@ -1,7 +1,6 @@ import { createHash } from "node:crypto"; import { constants } from "node:fs"; import { open, stat } from "node:fs/promises"; -import { isAbsolute } from "node:path"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import { BYTE_ENTROPY_PROVIDER } from "../../domain/investigationProviders.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -105,10 +104,6 @@ export class ByteEntropyService { ); if (!parsed.ok) return parsed; const input = parsed.value; - if (!isAbsolute(input.path)) - return err( - invalid("path", "Expected an absolute filesystem path on this host."), - ); const evidenceFromReport = (report: ByteEntropy): Evidence => createEvidence( { path: input.path, format: "file", sha256: report.artifact.sha256 }, diff --git a/src/application/binaryDiagnostics/ElfNotesService.ts b/src/application/binaryDiagnostics/ElfNotesService.ts index 47c9468af2..fae180099f 100644 --- a/src/application/binaryDiagnostics/ElfNotesService.ts +++ b/src/application/binaryDiagnostics/ElfNotesService.ts @@ -1,11 +1,7 @@ -import { isAbsolute } from "node:path"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { ElfNotesPort } from "./ElfNotesPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisInputError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { inspectElfNotesInputSchema, @@ -39,16 +35,6 @@ export class ElfNotesService { options?.signal, ); if (!input.ok) return input; - if (!isAbsolute(input.value.path)) - return err( - new AnalysisInputError(OPERATION, undefined, [ - { - path: ["path"], - reason: "invalid_format", - message: "Expected an absolute filesystem path on this host.", - }, - ]), - ); const result = await this.provider.inspect(input.value, options); if (!result.ok) return result; const report = elfNotesSchema.safeParse(result.value); diff --git a/src/application/binaryDiagnostics/KernelModuleService.ts b/src/application/binaryDiagnostics/KernelModuleService.ts index 8f8fbe1dd7..0300327657 100644 --- a/src/application/binaryDiagnostics/KernelModuleService.ts +++ b/src/application/binaryDiagnostics/KernelModuleService.ts @@ -1,11 +1,7 @@ -import { isAbsolute } from "node:path"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { KernelModulePort } from "./KernelModulePort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisInputError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { elfObjectTypeName } from "../../domain/native/elfNotes.js"; import { @@ -47,16 +43,6 @@ export class KernelModuleService { options?.signal, ); if (!input.ok) return input; - if (!isAbsolute(input.value.path)) - return err( - new AnalysisInputError(OPERATION, undefined, [ - { - path: ["path"], - reason: "invalid_format", - message: "Expected an absolute filesystem path on this host.", - }, - ]), - ); const result = await this.provider.inspect(input.value, options); if (!result.ok) return result; const report = kernelModuleSchema.safeParse(result.value); diff --git a/src/application/javascript/JavaScriptRecoveryService.test.ts b/src/application/javascript/JavaScriptRecoveryService.test.ts index 1977b6031b..41282994c0 100644 --- a/src/application/javascript/JavaScriptRecoveryService.test.ts +++ b/src/application/javascript/JavaScriptRecoveryService.test.ts @@ -154,7 +154,13 @@ describe("JavaScript recovery workflow", () => { expect(invoked).toBe(false); if (result.ok) throw new Error("expected invalid path"); expect(result.error).toMatchObject({ - issues: [{ path: ["additional_paths"], reason: "invalid_format" }], + issues: [ + { + path: ["additional_paths", 0], + reason: "invalid_format", + expected: "regex", + }, + ], }); }); }); diff --git a/src/application/javascript/JavaScriptRecoveryService.ts b/src/application/javascript/JavaScriptRecoveryService.ts index 852b5a3a6c..d57c1a9abc 100644 --- a/src/application/javascript/JavaScriptRecoveryService.ts +++ b/src/application/javascript/JavaScriptRecoveryService.ts @@ -1,10 +1,6 @@ import { parseAnalysisInput } from "../AnalysisInput.js"; -import { isAbsolute } from "node:path"; -import { - AnalysisInputError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { @@ -38,27 +34,6 @@ export class JavaScriptRecoveryService { options?.signal, ); if (!parsed.ok) return parsed; - const invalidPaths = (["path", "output_directory"] as const).filter( - (field) => !isAbsolute(parsed.value[field]), - ); - const invalidAdditional = (parsed.value.additional_paths ?? []).filter( - (additional) => !isAbsolute(additional), - ); - if (invalidPaths.length > 0 || invalidAdditional.length > 0) - return err( - new AnalysisInputError(OPERATION, undefined, [ - ...invalidPaths.map((field) => ({ - path: [field], - reason: "invalid_format" as const, - message: `${field} must be an absolute filesystem path on this host.`, - })), - ...invalidAdditional.map((additional) => ({ - path: ["additional_paths"], - reason: "invalid_format" as const, - message: `additional_paths entries must be absolute filesystem paths on this host: ${additional}`, - })), - ]), - ); const execution = await this.provider.recover(parsed.value, options); if (!execution.ok) return execution; const result = javascriptRecoveryResultSchema.safeParse( diff --git a/src/cli/webScriptCommands.ts b/src/cli/webScriptCommands.ts index cade5933a7..3f09e07b2a 100644 --- a/src/cli/webScriptCommands.ts +++ b/src/cli/webScriptCommands.ts @@ -1,6 +1,7 @@ import { Cli, z } from "incur"; import { exportWebScripts } from "../application/WebScriptExportService.js"; +import { exportWebScriptsInputSchema } from "../domain/webScriptExport.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; @@ -18,14 +19,8 @@ export const registerWebScriptCommands = ( description: "Export retained website scripts into an absent directory for static JavaScript analysis", args: z.object({ - capturePath: z - .string() - .describe("Absolute path to saved page or scenario capture JSON"), - outputDirectory: z - .string() - .describe( - "Absolute absent directory for verified scripts and manifest", - ), + capturePath: exportWebScriptsInputSchema.shape.capture_path, + outputDirectory: exportWebScriptsInputSchema.shape.output_directory, }), run: ({ args }) => withCommandCancellation((signal) => diff --git a/src/domain/javascript/javascriptRecovery.ts b/src/domain/javascript/javascriptRecovery.ts index 07f34699a6..7310cd9922 100644 --- a/src/domain/javascript/javascriptRecovery.ts +++ b/src/domain/javascript/javascriptRecovery.ts @@ -1,21 +1,21 @@ import { z } from "zod"; import { digestSchema } from "../digests.js"; import { analyzeJavaScriptApplicationInputSchema } from "./javascriptApplicationAnalysis.js"; -import { localPathStringSchema } from "../localPath.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; /** Recover derived readable modules from explicitly selected local scripts. */ export const javascriptRecoveryInputSchema = z.strictObject({ - path: localPathStringSchema.describe( + path: absoluteLocalPathStringSchema.describe( "Absolute path to a UTF-8 JavaScript file; never executed", ), additional_paths: z - .array(localPathStringSchema) + .array(absoluteLocalPathStringSchema) .max(16) .optional() .describe( "Additional absolute UTF-8 JavaScript files recovered together with path; each is snapshotted separately so imports can resolve across related chunks. All inputs share a 64 MiB total budget", ), - output_directory: localPathStringSchema.describe( + output_directory: absoluteLocalPathStringSchema.describe( "Absolute absent output directory; parent must exist", ), extraction_mode: z diff --git a/src/domain/native/appleIps.ts b/src/domain/native/appleIps.ts index b390b8de5a..b9d3a9fff2 100644 --- a/src/domain/native/appleIps.ts +++ b/src/domain/native/appleIps.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { digestSchema } from "../digests.js"; -import { localPathStringSchema } from "../localPath.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; const uint = z.number().int().min(0).max(0xffffffff); const recordedIntegerSchema = z.strictObject({ @@ -52,7 +52,7 @@ export const APPLE_IPS_IMAGE_SOURCE_MEANING: Readonly< /** Complete bounded inspection of one Apple .ips crash report. */ export const inspectAppleIpsInputSchema = z.strictObject({ - path: localPathStringSchema.describe( + path: absoluteLocalPathStringSchema.describe( "Absolute filesystem path to an Apple .ips crash report", ), max_file_bytes: z diff --git a/src/domain/native/byteEntropy.ts b/src/domain/native/byteEntropy.ts index c4f04082ec..6c21e3d94a 100644 --- a/src/domain/native/byteEntropy.ts +++ b/src/domain/native/byteEntropy.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { localPathStringSchema } from "../localPath.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; const offsetSchema = z .number() @@ -10,7 +10,9 @@ const entropySchema = z.number().min(0).max(8); /** Select blocks relative to an explicit byte window; identity remains file-wide. */ export const inspectByteEntropyInputSchema = z.strictObject({ - path: localPathStringSchema, + path: absoluteLocalPathStringSchema.describe( + "Absolute filesystem path to the file", + ), block_size: z .number() .int() diff --git a/src/domain/native/elfNotes.ts b/src/domain/native/elfNotes.ts index 915b009332..0c67c43dc9 100644 --- a/src/domain/native/elfNotes.ts +++ b/src/domain/native/elfNotes.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { digestSchema } from "../digests.js"; -import { localPathStringSchema } from "../localPath.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; const uint = z.number().int().min(0).max(0xffffffff); const range = z.strictObject({ offset: uint, bytes: uint }); @@ -86,7 +86,7 @@ export const coreNoteTypeName = (type: number): ElfNoteTypeName => { /** Complete bounded inspection of ELF PT_NOTE and SHT_NOTE tables. */ export const inspectElfNotesInputSchema = z.strictObject({ - path: localPathStringSchema.describe( + path: absoluteLocalPathStringSchema.describe( "Absolute filesystem path to an ELF image", ), max_file_bytes: z diff --git a/src/domain/native/kernelModule.ts b/src/domain/native/kernelModule.ts index cb984edb14..944fb55eaa 100644 --- a/src/domain/native/kernelModule.ts +++ b/src/domain/native/kernelModule.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { digestSchema } from "../digests.js"; import { fixedTupleSchema } from "../fixedTupleSchema.js"; -import { localPathStringSchema } from "../localPath.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; import { ELF_OBJECT_TYPE_NAMES } from "./elfNotes.js"; const uint = z.number().int().min(0).max(0xffffffff); @@ -197,7 +197,7 @@ const signatureSchema = z.strictObject({ /** Complete bounded inspection of one relocatable Linux kernel module. */ export const inspectKernelModuleInputSchema = z.strictObject({ - path: localPathStringSchema.describe( + path: absoluteLocalPathStringSchema.describe( "Absolute filesystem path to an ELF kernel module or a compressed .ko container", ), max_file_bytes: z diff --git a/src/domain/webScriptExport.ts b/src/domain/webScriptExport.ts index 9e1e914620..331c06c7ac 100644 --- a/src/domain/webScriptExport.ts +++ b/src/domain/webScriptExport.ts @@ -3,14 +3,14 @@ import { z } from "zod"; import { browserCompletenessSchema } from "./browserCompleteness.js"; import { browserScenarioCompletenessSchema } from "./browserScenarioCaptureValues.js"; import { digestSchema, prefixedDigestSchema } from "./digests.js"; -import { localPathStringSchema } from "./localPath.js"; +import { absoluteLocalPathStringSchema } from "./localPath.js"; /** Export already captured scripts into one absent, caller-selected directory. */ export const exportWebScriptsInputSchema = z.strictObject({ - capture_path: localPathStringSchema.describe( + capture_path: absoluteLocalPathStringSchema.describe( "Absolute filesystem path to saved inspect_web_page or capture_browser_scenario JSON, normalized result or complete Evidence", ), - output_directory: localPathStringSchema.describe( + output_directory: absoluteLocalPathStringSchema.describe( "Absolute absent filesystem directory for verified scripts and manifest; parent directory must exist", ), }); diff --git a/src/javascript/recovery/RecoveryFiles.ts b/src/javascript/recovery/RecoveryFiles.ts index d17937520c..e4830ee3e3 100644 --- a/src/javascript/recovery/RecoveryFiles.ts +++ b/src/javascript/recovery/RecoveryFiles.ts @@ -261,11 +261,6 @@ const recoveryInputLayoutPath = async ( path: string, signal?: AbortSignal, ): Promise => { - if (!isAbsolute(path)) - throw recoveryInputFailure( - "path", - "Select an absolute JavaScript file path", - ); try { checkRecoveryCancellation(signal); const parent = dirname(path); diff --git a/tests/acceptance/nonBinaryComposition.test.ts b/tests/acceptance/nonBinaryComposition.test.ts index 386d0fe71a..5f69868106 100644 --- a/tests/acceptance/nonBinaryComposition.test.ts +++ b/tests/acceptance/nonBinaryComposition.test.ts @@ -241,14 +241,34 @@ cliTest( }); expect(result.exitCode).toBe(1); expect(response.isError).toBe(true); - expect(parseMcpToolError(response)).toEqual({ error: result.json }); expect(result.json).toMatchObject({ - code: "invalid_request", - details: { - operation: "recover_javascript_sources", - issues: [{ path: [field], reason: "invalid_format" }], + code: "VALIDATION_ERROR", + fieldErrors: [ + expect.objectContaining({ + path: field === "path" ? "path" : "outputDirectory", + code: "invalid_format", + }), + ], + }); + expect(parseMcpToolError(response)).toMatchObject({ + error: { + code: "invalid_request", + details: { + operation: "recover_javascript_sources", + issues: [ + { + path: [field], + reason: "invalid_format", + expected: "regex", + }, + ], + }, }, }); + expect(JSON.stringify(result.json)).not.toContain("REA_WAKARU_COMMAND"); + expect(JSON.stringify(parseMcpToolError(response))).not.toContain( + "REA_WAKARU_COMMAND", + ); } }, ); diff --git a/tests/boundary/cli/cliInputSchemaRequired.test.ts b/tests/boundary/cli/cliInputSchemaRequired.test.ts index 747bbc2780..5a9f7a617e 100644 --- a/tests/boundary/cli/cliInputSchemaRequired.test.ts +++ b/tests/boundary/cli/cliInputSchemaRequired.test.ts @@ -158,7 +158,11 @@ describe("CLI advertised input schemas", () => { const validate = ajv.compile(schema.options ?? {}); for (const count of [16, 17]) { const input = { - [field]: Array.from({ length: count }, () => "./selected"), + [field]: Array.from({ length: count }, () => + command === "recover-javascript-sources" + ? "/selected" + : "./selected", + ), }; expect(validate(input)).toBe(count === 16); expect( diff --git a/tests/boundary/filesystem/webScriptExport.test.ts b/tests/boundary/filesystem/webScriptExport.test.ts index 9cbbccf650..a4bb2e544b 100644 --- a/tests/boundary/filesystem/webScriptExport.test.ts +++ b/tests/boundary/filesystem/webScriptExport.test.ts @@ -795,8 +795,7 @@ describe("captured script path validation", () => { { path: ["capture_path"], reason: "invalid_format", - message: - "capture_path must be an absolute filesystem path on this host.", + expected: "regex", }, ], }); @@ -815,8 +814,7 @@ describe("captured script path validation", () => { { path: ["output_directory"], reason: "invalid_format", - message: - "output_directory must be an absolute filesystem path on this host.", + expected: "regex", }, ], }); diff --git a/tests/boundary/mcp/absoluteLocalPathTools.test.ts b/tests/boundary/mcp/absoluteLocalPathTools.test.ts new file mode 100644 index 0000000000..e71e707e6a --- /dev/null +++ b/tests/boundary/mcp/absoluteLocalPathTools.test.ts @@ -0,0 +1,117 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import type { AnySchema } from "ajv"; +import { expect, it } from "vitest"; + +import { JAVASCRIPT_RECOVERY_TOOL_CONTRACTS } from "../../../src/contracts/javascript/javascriptRecoveryToolContracts.js"; +import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "../../../src/contracts/native/binaryDiagnosticsToolContracts.js"; +import { WEB_SCRIPT_TOOL_CONTRACTS } from "../../../src/contracts/webScriptToolContracts.js"; +import { RequestTrackingMcpServer } from "../../../src/server/RequestTrackingMcpServer.js"; +import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js"; + +const CONTRACTS = [ + ...BINARY_DIAGNOSTICS_TOOL_CONTRACTS, + ...WEB_SCRIPT_TOOL_CONTRACTS, + ...JAVASCRIPT_RECOVERY_TOOL_CONTRACTS, +]; + +const PATH_FIELDS = { + inspect_apple_ips: ["path"], + inspect_elf_notes: ["path"], + inspect_kernel_module: ["path"], + inspect_byte_entropy: ["path"], + export_web_scripts: ["capture_path", "output_directory"], + recover_javascript_sources: ["path", "output_directory", "additional_paths"], +} as const; + +const absoluteFile = + process.platform === "win32" ? "C:\\sample.bin" : "/tmp/sample.bin"; +const absoluteDirectory = process.platform === "win32" ? "C:\\out" : "/tmp/out"; + +const contractFor = (name: keyof typeof PATH_FIELDS) => { + const contract = CONTRACTS.find((candidate) => candidate.name === name); + if (contract === undefined) throw new Error(`Missing contract ${name}`); + return contract; +}; + +const validInput = ( + name: keyof typeof PATH_FIELDS, +): Record => { + if (name === "export_web_scripts") + return { + capture_path: absoluteFile, + output_directory: absoluteDirectory, + }; + if (name === "recover_javascript_sources") + return { + path: absoluteFile, + output_directory: absoluteDirectory, + additional_paths: [absoluteFile], + }; + return { path: absoluteFile }; +}; + +const withRelativeField = ( + name: keyof typeof PATH_FIELDS, + field: string, +): Record => ({ + ...validInput(name), + [field]: field === "additional_paths" ? ["sample.bin"] : "sample.bin", +}); + +it("rejects one relative path field at a time on tools/list", async () => { + const server = new RequestTrackingMcpServer({ + name: "absolute-path-schema", + version: "1", + }); + for (const name of Object.keys(PATH_FIELDS) as (keyof typeof PATH_FIELDS)[]) { + const contract = contractFor(name); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + () => ({ content: [] }), + ); + } + const client = new Client({ name: "absolute-path-client", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const checked: string[] = []; + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const listed = await client.listTools(); + for (const name of Object.keys( + PATH_FIELDS, + ) as (keyof typeof PATH_FIELDS)[]) { + const schema = listed.tools.find( + (tool) => tool.name === name, + )?.inputSchema; + expect(schema, name).toBeDefined(); + if (schema === undefined) throw new Error(`Missing schema ${name}`); + const validate = ajv.compile(schema as AnySchema); + expect(validate(validInput(name)), `${name} valid`).toBe(true); + for (const field of PATH_FIELDS[name]) { + expect( + validate(withRelativeField(name, field)), + `${name}.${field}`, + ).toBe(false); + checked.push(`${name}.${field}`); + } + } + } finally { + await client.close(); + await server.close(); + } + expect(checked).toEqual([ + "inspect_apple_ips.path", + "inspect_elf_notes.path", + "inspect_kernel_module.path", + "inspect_byte_entropy.path", + "export_web_scripts.capture_path", + "export_web_scripts.output_directory", + "recover_javascript_sources.path", + "recover_javascript_sources.output_directory", + "recover_javascript_sources.additional_paths", + ]); +});