From 25d3c7237ffeffc4f9e6ab87be2f83a643cf5cbb Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:14:48 +0800 Subject: [PATCH] fix(paths): enforce selected path contracts at admission (#9465) --- .../apiContract/ApiContractService.ts | 5 ----- .../apiContract/GraphqlContractImport.ts | 8 ------- .../apkSignature/ApkSignatureService.ts | 16 +------------- .../binaryDiagnostics/DevicetreeService.ts | 16 +------------- .../playwrightTrace/PlaywrightTraceService.ts | 13 ------------ src/application/python/PythonPycService.ts | 16 +------------- .../PyInstallerArtifactReader.test.ts | 4 ++-- src/artifacts/pyinstaller/PyzArchive.test.ts | 4 ++-- src/cli/coreNativeCommands.ts | 2 +- src/cli/decompileDirectoryCommand.ts | 2 +- src/cli/nativeCallCommands.ts | 3 ++- src/domain/apiContract/apiContractQuery.ts | 6 +++--- src/domain/firmware/devicetree.ts | 4 ++-- .../playwrightTrace/playwrightTraceQuery.ts | 4 ++-- src/domain/python/pythonPyc.ts | 17 ++++++++++----- src/python/PythonPycParser.ts | 6 +++--- .../acceptance/localPathAdmissionCli.test.ts | 3 +++ .../mcp/absoluteLocalPathTools.test.ts | 21 +++++++++++++++++++ 18 files changed, 57 insertions(+), 93 deletions(-) diff --git a/src/application/apiContract/ApiContractService.ts b/src/application/apiContract/ApiContractService.ts index a7c1f63705..6fb7bbef9e 100644 --- a/src/application/apiContract/ApiContractService.ts +++ b/src/application/apiContract/ApiContractService.ts @@ -1,4 +1,3 @@ -import { isAbsolute } from "node:path"; import { getHeapStatistics } from "node:v8"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -125,10 +124,6 @@ export const inspectApiContractEvidence = async ( ); if (!admitted.ok) return admitted; const input = admitted.value; - if (!isAbsolute(input.path)) - return err( - inputFailure("Expected an absolute filesystem path on this host."), - ); if (input.format === "graphql-sdl") { const { importGraphqlContract } = await import("./GraphqlContractImport.js"); diff --git a/src/application/apiContract/GraphqlContractImport.ts b/src/application/apiContract/GraphqlContractImport.ts index fe763cfbfd..245b210f2a 100644 --- a/src/application/apiContract/GraphqlContractImport.ts +++ b/src/application/apiContract/GraphqlContractImport.ts @@ -1,4 +1,3 @@ -import { isAbsolute } from "node:path"; import { getHeapStatistics } from "node:v8"; import { version } from "graphql"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; @@ -153,13 +152,6 @@ export const importGraphqlContract = async ( resources: ArtifactResourceScope, signal?: AbortSignal, ) => { - if (input.operations_path !== undefined && !isAbsolute(input.operations_path)) - return err( - inputFailure( - "operations_path", - "Expected an absolute filesystem path on this host.", - ), - ); const loaded = await readApiContract(input, resources, signal); if (!loaded.ok) return loaded; const artifact = { diff --git a/src/application/apkSignature/ApkSignatureService.ts b/src/application/apkSignature/ApkSignatureService.ts index 019f8d3914..4903f8db02 100644 --- a/src/application/apkSignature/ApkSignatureService.ts +++ b/src/application/apkSignature/ApkSignatureService.ts @@ -4,15 +4,11 @@ import { projectRetainedAnalysis, type AnalysisViewRetention, } from "../analysisView/AnalysisViewService.js"; -import { isAbsolute } from "node:path"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { ApkSignaturePort } from "./ApkSignaturePort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisInputError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { apkSignatureMatchesSubject, apkSignatureResultSchema, @@ -48,16 +44,6 @@ export class ApkSignatureService { 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 { detail, view, ...parameters } = input.value; if (detail === "summary") { const ready = requireAnalysisViewRetention(OPERATION, retention); diff --git a/src/application/binaryDiagnostics/DevicetreeService.ts b/src/application/binaryDiagnostics/DevicetreeService.ts index 44e58443c4..b5b54dde5e 100644 --- a/src/application/binaryDiagnostics/DevicetreeService.ts +++ b/src/application/binaryDiagnostics/DevicetreeService.ts @@ -1,11 +1,7 @@ -import { isAbsolute } from "node:path"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { DevicetreePort } from "./DevicetreePort.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 { inspectDevicetreeInputSchema, @@ -37,16 +33,6 @@ export class DevicetreeService { 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 = devicetreeSchema.safeParse(result.value); diff --git a/src/application/playwrightTrace/PlaywrightTraceService.ts b/src/application/playwrightTrace/PlaywrightTraceService.ts index 8968bfb222..64a6f2d7be 100644 --- a/src/application/playwrightTrace/PlaywrightTraceService.ts +++ b/src/application/playwrightTrace/PlaywrightTraceService.ts @@ -2,10 +2,7 @@ import { primaryAnalysisError, type AnalysisError, } from "../../domain/analysisErrorBase.js"; -import { isAbsolute } from "node:path"; -import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { createEvidence } from "../../domain/evidence.js"; -import { err } from "../../domain/result.js"; import { inspectPlaywrightTraceInputSchema } from "../../domain/playwrightTrace/playwrightTraceQuery.js"; import { PLAYWRIGHT_TRACE_OPERATION as operation, @@ -49,16 +46,6 @@ export class PlaywrightTraceService { signal, ); if (!parsed.ok) return parsed; - if (!isAbsolute(parsed.value.path)) - return err( - new AnalysisInputError(operation, undefined, [ - { - path: ["path"], - reason: "invalid_format", - message: "Expected an absolute filesystem trace archive path.", - }, - ]), - ); const loaded = await this.port.inspect(parsed.value.path, signal); if (!loaded.ok) { const source = diff --git a/src/application/python/PythonPycService.ts b/src/application/python/PythonPycService.ts index c891efd5f4..99d4aafd6d 100644 --- a/src/application/python/PythonPycService.ts +++ b/src/application/python/PythonPycService.ts @@ -1,11 +1,7 @@ -import { isAbsolute } from "node:path"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { PythonPycPort } from "./PythonPycPort.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 { inspectPythonPycInputSchema, @@ -37,16 +33,6 @@ export class PythonPycService { 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 = pythonPycSchema.safeParse(result.value); diff --git a/src/artifacts/pyinstaller/PyInstallerArtifactReader.test.ts b/src/artifacts/pyinstaller/PyInstallerArtifactReader.test.ts index 98d850b1c6..edd75429df 100644 --- a/src/artifacts/pyinstaller/PyInstallerArtifactReader.test.ts +++ b/src/artifacts/pyinstaller/PyInstallerArtifactReader.test.ts @@ -15,7 +15,7 @@ import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirect import { PyInstallerArtifactReader } from "../PyInstallerArtifactReader.js"; import { reconstructedPycHeader } from "./PythonBytecode.js"; import { parsePythonPyc } from "../../python/PythonPycParser.js"; -import { inspectPythonPycInputSchema } from "../../domain/python/pythonPyc.js"; +import { pythonPycLimitsSchema } from "../../domain/python/pythonPyc.js"; import { PYTHON_39_MAGIC, cArchiveExecutable, @@ -117,7 +117,7 @@ it("preserves legacy pyc bytes and derives only modern headerless CArchive bytec parsePythonPyc( bytes, { path: module.name, bytes: bytes.length, sha256: sha256(bytes) }, - inspectPythonPycInputSchema.parse({ path: module.name }), + pythonPycLimitsSchema.parse({}), ).coverage.status, ).toBe("complete"); expect(await buffer(await reader.open(entry))).toEqual(module.bytes); diff --git a/src/artifacts/pyinstaller/PyzArchive.test.ts b/src/artifacts/pyinstaller/PyzArchive.test.ts index a54dd7f6c4..f7ffe27202 100644 --- a/src/artifacts/pyinstaller/PyzArchive.test.ts +++ b/src/artifacts/pyinstaller/PyzArchive.test.ts @@ -1,6 +1,6 @@ import { pythonPycFixture } from "../../python/PythonPyc.fixture.js"; import { parsePythonPyc } from "../../python/PythonPycParser.js"; -import { inspectPythonPycInputSchema } from "../../domain/python/pythonPyc.js"; +import { pythonPycLimitsSchema } from "../../domain/python/pythonPyc.js"; import { createHash } from "node:crypto"; import { open, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -209,7 +209,7 @@ it.each([ bytes: pyc.length, sha256: createHash("sha256").update(pyc).digest("hex"), }, - inspectPythonPycInputSchema.parse({ path: "/fixtures/module.pyc" }), + pythonPycLimitsSchema.parse({}), ); expect(standalone.header.python_version).toBe(version); const image = pyzArchive({ diff --git a/src/cli/coreNativeCommands.ts b/src/cli/coreNativeCommands.ts index 1a7f5f9656..0efef425e4 100644 --- a/src/cli/coreNativeCommands.ts +++ b/src/cli/coreNativeCommands.ts @@ -356,7 +356,7 @@ export const registerCoreNativeCommands = ( cli.command(CLI_COMMANDS.inspectNativeDataType, { description: "Inspect one recovered database type or typed data object", args: z.object({ - path: z.string().describe("Local native executable or app path"), + path: localPathStringSchema.describe("Local native executable or app path"), }), options: z.object({ type: z.string().optional().describe("Exact database type category path"), diff --git a/src/cli/decompileDirectoryCommand.ts b/src/cli/decompileDirectoryCommand.ts index a80241eddc..c32f13199f 100644 --- a/src/cli/decompileDirectoryCommand.ts +++ b/src/cli/decompileDirectoryCommand.ts @@ -26,7 +26,7 @@ export const registerDecompileDirectoryCommand = ( description: "Write every function's pseudocode into a directory and return a manifest", args: z.object({ - path: z.string().describe("App or program path"), + path: localPathStringSchema.describe("App or program path"), }), options: z.object({ output: localPathStringSchema.describe( diff --git a/src/cli/nativeCallCommands.ts b/src/cli/nativeCallCommands.ts index f8d5ee96ba..dcd95e2a4b 100644 --- a/src/cli/nativeCallCommands.ts +++ b/src/cli/nativeCallCommands.ts @@ -8,6 +8,7 @@ import { logCliCommand } from "../cliLogging.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; +import { localPathStringSchema } from "../domain/localPath.js"; import { nativeCallObservationInputSchema } from "../domain/native/nativeCallObservation.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; @@ -24,7 +25,7 @@ export const registerNativeCallCommands = ( description: "Launch a Mach-O under LLDB and record entries into selected functions or Objective-C methods", args: z.object({ - path: z.string().describe("Mach-O or app path"), + path: localPathStringSchema.describe("Mach-O or app path"), inputJson: z .string() .describe( diff --git a/src/domain/apiContract/apiContractQuery.ts b/src/domain/apiContract/apiContractQuery.ts index 08a3942946..1e2cf14965 100644 --- a/src/domain/apiContract/apiContractQuery.ts +++ b/src/domain/apiContract/apiContractQuery.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { localPathStringSchema } from "../localPath.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; import { graphqlContractViewRequestSchema } from "./graphqlContractQuery.js"; import { API_CONTRACT_MAX_FILE_BYTES, @@ -59,7 +59,7 @@ export const apiContractViewSelectionSchema = z.discriminatedUnion("kind", [ graphqlContractViewRequestSchema, ]); const commonInput = { - path: localPathStringSchema.describe( + path: absoluteLocalPathStringSchema.describe( "Absolute path to a local API contract document", ), max_file_bytes: apiContractFileBytesSchema, @@ -79,7 +79,7 @@ export const inspectApiContractInputSchema = z z.strictObject({ ...commonInput, format: z.literal("graphql-sdl"), - operations_path: localPathStringSchema + operations_path: absoluteLocalPathStringSchema .optional() .describe( "Optional absolute path to one local GraphQL operation/fragment document; parsed without execution", diff --git a/src/domain/firmware/devicetree.ts b/src/domain/firmware/devicetree.ts index c7903d3cc9..c6ac82628f 100644 --- a/src/domain/firmware/devicetree.ts +++ b/src/domain/firmware/devicetree.ts @@ -1,10 +1,10 @@ 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); export const MAX_DEVICETREE_FILE_BYTES = 16777216; export const inspectDevicetreeInputSchema = z.strictObject({ - path: localPathStringSchema, + path: absoluteLocalPathStringSchema, max_file_bytes: z .number() .int() diff --git a/src/domain/playwrightTrace/playwrightTraceQuery.ts b/src/domain/playwrightTrace/playwrightTraceQuery.ts index 2f67b4a908..19cd7b98ac 100644 --- a/src/domain/playwrightTrace/playwrightTraceQuery.ts +++ b/src/domain/playwrightTrace/playwrightTraceQuery.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { localPathStringSchema } from "../localPath.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; import { traceDiagnosticSchema, traceEventSchema, @@ -56,7 +56,7 @@ export const playwrightTraceViewRequestSchema = z.discriminatedUnion( ], ); export const inspectPlaywrightTraceInputSchema = z.strictObject({ - path: localPathStringSchema.describe( + path: absoluteLocalPathStringSchema.describe( "Absolute path to an offline Playwright v10 trace ZIP", ), detail: z.enum(["summary", "complete"]).default("summary"), diff --git a/src/domain/python/pythonPyc.ts b/src/domain/python/pythonPyc.ts index 780926e9d1..8a9956b8dc 100644 --- a/src/domain/python/pythonPyc.ts +++ b/src/domain/python/pythonPyc.ts @@ -1,6 +1,9 @@ import { z } from "zod"; import { digestSchema } from "../digests.js"; -import { localPathStringSchema } from "../localPath.js"; +import { + absoluteLocalPathStringSchema, + localPathStringSchema, +} from "../localPath.js"; export const PYTHON_PYC_HEADER_BYTES = 16; export const PYTHON_PYC_HASH_BASED = 0x1; @@ -70,10 +73,7 @@ export const PYTHON_PYC_LIMIT_MAXIMA = { max_string_bytes: 8 * 1024 * 1024, } as const; -export const inspectPythonPycInputSchema = z.strictObject({ - path: localPathStringSchema.describe( - "Absolute filesystem path to the selected CPython .pyc file", - ), +export const pythonPycLimitsSchema = z.strictObject({ max_file_bytes: z .number() .int() @@ -112,6 +112,13 @@ export const inspectPythonPycInputSchema = z.strictObject({ ), }); +export type PythonPycLimits = z.infer; +export const inspectPythonPycInputSchema = pythonPycLimitsSchema.extend({ + path: absoluteLocalPathStringSchema.describe( + "Absolute filesystem path to the selected CPython .pyc file", + ), +}); + export const PYTHON_PYC_CONST_PREVIEW_LIMIT = 80; /** Integers up to 256 bits fit the 80-character preview in decimal. */ export const PYTHON_PYC_INT_PREVIEW_BITS = 256; diff --git a/src/python/PythonPycParser.ts b/src/python/PythonPycParser.ts index 0931ff47bc..85bfd9ae86 100644 --- a/src/python/PythonPycParser.ts +++ b/src/python/PythonPycParser.ts @@ -8,7 +8,7 @@ import { PYTHON_PYC_HEADER_BYTES, PYTHON_PYC_INT_PREVIEW_BITS, pythonPycMarshalProfile, - type InspectPythonPycInput, + type PythonPycLimits, type PythonPyc, type PythonPycCodeLayout, type PythonPycCodeObject, @@ -202,7 +202,7 @@ const sha256 = (bytes: Buffer): string => { export const parsePythonPyc = ( bytes: Buffer, artifact: { path: string; sha256: string; bytes: number }, - input: InspectPythonPycInput, + input: PythonPycLimits, ): PythonPyc => { if (bytes.length !== artifact.bytes) malformed("Selected artifact length disagrees with the retained bytes."); @@ -351,7 +351,7 @@ class MarshalReader { readonly bytes: Buffer, readonly layout: PythonPycCodeLayout, readonly magic: number, - readonly input: InspectPythonPycInput, + readonly input: PythonPycLimits, readonly rejectNegativeCodeFields: boolean, ) {} diff --git a/tests/acceptance/localPathAdmissionCli.test.ts b/tests/acceptance/localPathAdmissionCli.test.ts index d721db6eef..3cbf24d9fc 100644 --- a/tests/acceptance/localPathAdmissionCli.test.ts +++ b/tests/acceptance/localPathAdmissionCli.test.ts @@ -27,6 +27,7 @@ const fileCommands = [ "inspect-rust-binary", "inspect-gpu-code-objects", "inspect-il2cpp-metadata", + "inspect-native-data-type", ] as const; cliTest( @@ -38,6 +39,8 @@ cliTest( arguments: [command, ""], field: "path", })), + { arguments: ["decompile-all", "", "--output", "out"], field: "path" }, + { arguments: ["observe-native-calls", "", "{}"], field: "path" }, { arguments: ["inspect-evm-interface", "", "hex"], field: "path" }, { arguments: ["inspect-web-network-capture", "", "har"], diff --git a/tests/boundary/mcp/absoluteLocalPathTools.test.ts b/tests/boundary/mcp/absoluteLocalPathTools.test.ts index e71e707e6a..d0f9cfee2b 100644 --- a/tests/boundary/mcp/absoluteLocalPathTools.test.ts +++ b/tests/boundary/mcp/absoluteLocalPathTools.test.ts @@ -6,6 +6,9 @@ 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 { API_CONTRACT_TOOL_CONTRACTS } from "../../../src/contracts/apiContractToolContracts.js"; +import { PLAYWRIGHT_TRACE_TOOL_CONTRACTS } from "../../../src/contracts/playwrightTraceToolContracts.js"; +import { PYTHON_PYC_TOOL_CONTRACTS } from "../../../src/contracts/python/pythonPycToolContracts.js"; import { RequestTrackingMcpServer } from "../../../src/server/RequestTrackingMcpServer.js"; import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js"; @@ -13,6 +16,9 @@ const CONTRACTS = [ ...BINARY_DIAGNOSTICS_TOOL_CONTRACTS, ...WEB_SCRIPT_TOOL_CONTRACTS, ...JAVASCRIPT_RECOVERY_TOOL_CONTRACTS, + ...API_CONTRACT_TOOL_CONTRACTS, + ...PLAYWRIGHT_TRACE_TOOL_CONTRACTS, + ...PYTHON_PYC_TOOL_CONTRACTS, ]; const PATH_FIELDS = { @@ -20,6 +26,10 @@ const PATH_FIELDS = { inspect_elf_notes: ["path"], inspect_kernel_module: ["path"], inspect_byte_entropy: ["path"], + inspect_api_contract: ["path", "operations_path"], + inspect_devicetree: ["path"], + inspect_playwright_trace: ["path"], + inspect_python_pyc: ["path"], export_web_scripts: ["capture_path", "output_directory"], recover_javascript_sources: ["path", "output_directory", "additional_paths"], } as const; @@ -37,6 +47,12 @@ const contractFor = (name: keyof typeof PATH_FIELDS) => { const validInput = ( name: keyof typeof PATH_FIELDS, ): Record => { + if (name === "inspect_api_contract") + return { + format: "graphql-sdl", + path: absoluteFile, + operations_path: absoluteFile, + }; if (name === "export_web_scripts") return { capture_path: absoluteFile, @@ -108,6 +124,11 @@ it("rejects one relative path field at a time on tools/list", async () => { "inspect_elf_notes.path", "inspect_kernel_module.path", "inspect_byte_entropy.path", + "inspect_api_contract.path", + "inspect_api_contract.operations_path", + "inspect_devicetree.path", + "inspect_playwright_trace.path", + "inspect_python_pyc.path", "export_web_scripts.capture_path", "export_web_scripts.output_directory", "recover_javascript_sources.path",