fix(mcp): advertise absolute paths for tools that reject relative inputs (#9179)

* fix(mcp): advertise absolute paths for tools that reject relative inputs

The domain schemas accepted relative paths, then handlers rejected them
with isAbsolute. Those inputs now use absoluteLocalPathStringSchema, and
the duplicate handler checks are gone.

* fix(javascript): own recovery path admission in the schema

Relative recovery paths now fail in absoluteLocalPathStringSchema, including
the additional_paths index. Drop the duplicate handler check, and make the
CLI, MCP, and tools/list tests assert that schema fact.

* test: assert absolute-path facts instead of error prose

Recovery and web-script tests keep the field, format code, and regex
expectation. They no longer freeze the schema's explanatory sentence.

---------

Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
morlutoandCursor Agent authored and GitHub committed 2026-11-07 15:49:50 +08:00
1 parent 923e6f77e3
commit 562779bd71
19 files changed
+180 -131

No files matched your search

-16
View File
@@ -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<Result<Evidence, AnalysisError>> => {
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(
@@ -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);
@@ -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 },
@@ -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);
@@ -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);
@@ -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",
},
],
});
});
});
@@ -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(
+3 -8
View File
@@ -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) =>
+4 -4
View File
@@ -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
+2 -2
View File
@@ -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
+4 -2
View File
@@ -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()
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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",
),
});
-5
View File
@@ -261,11 +261,6 @@ const recoveryInputLayoutPath = async (
path: string,
signal?: AbortSignal,
): Promise<string> => {
if (!isAbsolute(path))
throw recoveryInputFailure(
"path",
"Select an absolute JavaScript file path",
);
try {
checkRecoveryCancellation(signal);
const parent = dirname(path);
+25 -5
View File
@@ -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",
);
}
},
);
@@ -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(
@@ -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",
},
],
});
@@ -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<string, unknown> => {
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<string, unknown> => ({
...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",
]);
});