From 5b172b1042fc09a4e12d956018f15d86cf397ffe Mon Sep 17 00:00:00 2001 From: Dery Ferdika <114888155+DeryFerd@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:31:55 +0700 Subject: [PATCH] fix(contracts)!: require absolute local paths for session filesystem inputs (#948) fix(contracts): require absolute local paths for session filesystem inputs Snapshot, evidence-bundle export, and managed-target inputs accepted any non-empty string, so relative paths were resolved against the MCP server working directory instead of the caller's intent. Refine these contract fields to absolute host paths (Windows drive and forward-slash forms included), advertise the constraint on the property descriptions, and regenerate the derived catalog, product catalog, and skill catalog digest. Relative CLI options are unaffected because CLI flows do not parse these MCP contracts. Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/product-catalog.json | 4 +- .../managed-conformance-manifest.json | 4 +- skills/reverse-engineer-anything/SKILL.md | 2 +- src/contracts/managed/managedToolContracts.ts | 11 +- src/contracts/sessionLifecycleInputs.ts | 17 ++- src/contracts/sessionPathInputs.test.ts | 121 ++++++++++++++++++ src/contracts/sessionToolContracts.ts | 12 +- src/domain/localPath.ts | 4 + tests/boundary/mcp/sessionMcp.test.ts | 31 +++++ 9 files changed, 195 insertions(+), 11 deletions(-) create mode 100644 src/contracts/sessionPathInputs.test.ts diff --git a/docs/product-catalog.json b/docs/product-catalog.json index 061f98abef..01f67a6b39 100644 --- a/docs/product-catalog.json +++ b/docs/product-catalog.json @@ -675,9 +675,9 @@ "mcp_prompts": 6 }, "digests": { - "tools_sha256": "af7fe81ec689c2a77f04579b6f4beb4abe86aa71d49c4844d8e0f4d7ed7a3a1e", + "tools_sha256": "8816080083d86cb4c05db8d1ef452938abef2e6fdeead566252592ffbb55d828", "prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d", - "combined_sha256": "83b4c3445ed0ec003c0aeefa3188f123d9ae319484f8b1e2b611d2fe278c9c72", + "combined_sha256": "9b4e896d2585645f1ed1f9f3eef270cf798f08035c70091178b7bd6119b22bf5", "providers_sha256": "aea15f517c822d80918ccaba57a4fa06557f484cf27b8acbaed4e1a660fac896" } } diff --git a/docs/verification/managed-conformance-manifest.json b/docs/verification/managed-conformance-manifest.json index 6d124d44a1..d427f4a886 100644 --- a/docs/verification/managed-conformance-manifest.json +++ b/docs/verification/managed-conformance-manifest.json @@ -1,5 +1,5 @@ { - "manifest_id": "ecm_880356f1ceceb9e29b43e3e89f7a326aa83132e1e1fae0573245848ba714f404", + "manifest_id": "ecm_82b2f9406099c2d152fcfa020c3acb23deb22d82405d42a610553e2bd0da94cc", "verifier": { "id": "rea-managed-conformance", "version": "1" @@ -13,7 +13,7 @@ "skill_digests": [ { "skill_id": "reverse-engineer-anything", - "sha256": "97dc167b6e0d41ac69953501c96a918319fa6e4b3be0affe2fc2119294e7101d" + "sha256": "77c5acdd9315573e4a1a0843e4ca643626674d2872729fd5db491261d386f110" } ], "claims": [ diff --git a/skills/reverse-engineer-anything/SKILL.md b/skills/reverse-engineer-anything/SKILL.md index f7cab0f3a1..fe96353d46 100644 --- a/skills/reverse-engineer-anything/SKILL.md +++ b/skills/reverse-engineer-anything/SKILL.md @@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi metadata: version: "27" tool_count: 133 - catalog_digest: "83b4c3445ed0ec003c0aeefa3188f123d9ae319484f8b1e2b611d2fe278c9c72" + catalog_digest: "9b4e896d2585645f1ed1f9f3eef270cf798f08035c70091178b7bd6119b22bf5" --- # REA diff --git a/src/contracts/managed/managedToolContracts.ts b/src/contracts/managed/managedToolContracts.ts index dc57023227..56071c4fce 100644 --- a/src/contracts/managed/managedToolContracts.ts +++ b/src/contracts/managed/managedToolContracts.ts @@ -4,16 +4,21 @@ import type { ToolContract } from "../toolContracts.js"; import { managedOutputSchemas } from "../toolOutputSchemas.js"; import { toolContractMetadata } from "../toolEffects.js"; import { requireOutputSchema } from "../toolOutputSchemaPrimitives.js"; +import { isAbsoluteLocalPath } from "../../domain/localPath.js"; /** Exact caller boundary for execution-free PE/CLI triage and identity. */ const managedTargetInputSchema = z.object({ path: z .string() .min(1) - .optional() + .refine(isAbsoluteLocalPath, { + message: + "path must be an absolute local filesystem path (for example /tmp/Example.dll or C:\\Example.dll)", + }) .describe( - "Explicit managed PE/CLI path; omit to reuse the previously selected managed target", - ), + "Explicit absolute managed PE/CLI path; omit to reuse the previously selected managed target", + ) + .optional(), }); export const managedArtifactInputSchema = managedTargetInputSchema; diff --git a/src/contracts/sessionLifecycleInputs.ts b/src/contracts/sessionLifecycleInputs.ts index fd0a1d58db..7a62d107ee 100644 --- a/src/contracts/sessionLifecycleInputs.ts +++ b/src/contracts/sessionLifecycleInputs.ts @@ -1,7 +1,20 @@ import { executableFormatHintSchema } from "../domain/dosCom.js"; +import { isAbsoluteLocalPath } from "../domain/localPath.js"; import { z } from "zod"; import { analysisProviderSelectorSchema } from "./providerSelection.js"; +/** Caller-supplied analysis snapshot path; must name its destination absolutely. */ +const snapshotPathSchema = z + .string() + .min(1) + .refine(isAbsoluteLocalPath, { + message: + "snapshot_path must be an absolute local filesystem path (for example /tmp/rea/analysis.json or C:\\rea\\analysis.json)", + }) + .describe( + "Absolute local filesystem path for the analysis snapshot; relative paths are rejected.", + ); + /** Input contract for opening a target with an optional staged snapshot. */ export const openBinaryInputSchema = z.object({ path: z.string().min(1), @@ -11,11 +24,11 @@ export const openBinaryInputSchema = z.object({ "Explicit headerless DOS COM interpretation; omission preserves header-based detection", ), provider_id: analysisProviderSelectorSchema.optional(), - snapshot_path: z.string().min(1).optional(), + snapshot_path: snapshotPathSchema.optional(), }); /** Input contract for closing a target after an optional atomic snapshot. */ export const closeBinaryInputSchema = z.object({ - snapshot_path: z.string().min(1).optional(), + snapshot_path: snapshotPathSchema.optional(), overwrite: z.boolean().default(false), }); diff --git a/src/contracts/sessionPathInputs.test.ts b/src/contracts/sessionPathInputs.test.ts new file mode 100644 index 0000000000..4d4755418c --- /dev/null +++ b/src/contracts/sessionPathInputs.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; + +import { managedArtifactInputSchema } from "./managed/managedToolContracts.js"; +import { exportEvidenceBundleInputSchema } from "./sessionToolContracts.js"; +import { + closeBinaryInputSchema, + openBinaryInputSchema, +} from "./sessionLifecycleInputs.js"; + +const openBinaryAbsolute = (snapshot_path: string) => + openBinaryInputSchema.safeParse({ path: "/tmp/fixture", snapshot_path }); + +describe("session snapshot path inputs require absolute local paths", () => { + it("rejects a relative snapshot path when opening a binary", () => { + expect(openBinaryAbsolute("relative/analysis.json").success).toBe(false); + }); + + it("rejects a parent-relative snapshot path when opening a binary", () => { + expect(openBinaryAbsolute("../outside/analysis.json").success).toBe(false); + }); + + it("accepts an absolute snapshot path when opening a binary", () => { + expect(openBinaryAbsolute("/tmp/rea/analysis.json").success).toBe(true); + }); + + it("still accepts open_binary without a snapshot path", () => { + expect( + openBinaryInputSchema.safeParse({ path: "/tmp/fixture" }).success, + ).toBe(true); + }); + + it("rejects a relative snapshot path when closing a binary", () => { + expect( + closeBinaryInputSchema.safeParse({ snapshot_path: "analysis.json" }) + .success, + ).toBe(false); + }); + + it("accepts an absolute snapshot path when closing a binary", () => { + expect( + closeBinaryInputSchema.safeParse({ snapshot_path: "/tmp/analysis.json" }) + .success, + ).toBe(true); + }); + + it("still accepts close_binary without a snapshot path", () => { + expect(closeBinaryInputSchema.safeParse({}).success).toBe(true); + }); +}); + +describe("evidence bundle export path requires an absolute local path", () => { + it("rejects a relative export path", () => { + expect( + exportEvidenceBundleInputSchema.safeParse({ path: "out/bundle.json" }) + .success, + ).toBe(false); + }); + + it("rejects a parent-relative export path", () => { + expect( + exportEvidenceBundleInputSchema.safeParse({ + path: "../../bundle.json", + }).success, + ).toBe(false); + }); + + it("accepts an absolute export path and keeps overwrite semantics", () => { + expect( + exportEvidenceBundleInputSchema.safeParse({ + path: "/tmp/bundle.json", + }), + ).toMatchObject({ success: true, data: { overwrite: false } }); + }); +}); + +describe("managed target path requires an absolute local path", () => { + it("rejects a relative managed target path", () => { + expect( + managedArtifactInputSchema.safeParse({ path: "bin/app.dll" }).success, + ).toBe(false); + }); + + it("accepts an absolute managed target path", () => { + expect( + managedArtifactInputSchema.safeParse({ path: "/tmp/app.dll" }).success, + ).toBe(true); + }); + + it("still accepts omitting the path to reuse the active target", () => { + expect(managedArtifactInputSchema.safeParse({}).success).toBe(true); + }); +}); + +describe.runIf(process.platform === "win32")( + "windows absolute path forms on windows hosts", + () => { + it("accepts drive-letter backslash snapshot paths", () => { + expect( + closeBinaryInputSchema.safeParse({ + snapshot_path: "C:\\rea\\analysis.json", + }).success, + ).toBe(true); + }); + + it("accepts drive-letter forward-slash snapshot paths", () => { + expect( + closeBinaryInputSchema.safeParse({ + snapshot_path: "C:/rea/analysis.json", + }).success, + ).toBe(true); + }); + + it("accepts forward-slash export paths", () => { + expect( + exportEvidenceBundleInputSchema.safeParse({ + path: "C:/rea/bundle.json", + }).success, + ).toBe(true); + }); + }, +); diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index 81ce1836c8..ebd7983bd1 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { isAbsoluteLocalPath } from "../domain/localPath.js"; import { sessionOutputSchemas, requireOutputSchema, @@ -47,7 +48,16 @@ const session = ( /** Session-owned Evidence bundle export options. */ export const exportEvidenceBundleInputSchema = z.strictObject({ - path: z.string().min(1), + path: z + .string() + .min(1) + .refine(isAbsoluteLocalPath, { + message: + "path must be an absolute local filesystem path (for example /tmp/rea/evidence.json or C:\\rea\\evidence.json)", + }) + .describe( + "Absolute local filesystem path for the exported evidence bundle; relative paths are rejected.", + ), overwrite: z.boolean().default(false), }); diff --git a/src/domain/localPath.ts b/src/domain/localPath.ts index 43b491cf98..9248de4d71 100644 --- a/src/domain/localPath.ts +++ b/src/domain/localPath.ts @@ -1,5 +1,9 @@ import { isAbsolute, relative, sep } from "node:path"; +/** True when the value is an absolute filesystem path on the host platform. */ +export const isAbsoluteLocalPath = (value: string): boolean => + isAbsolute(value); + /** Test lexical containment on the host platform; callers must resolve symlinks first. */ export const isPathWithinRoot = (root: string, path: string): boolean => { const value = relative(root, path); diff --git a/tests/boundary/mcp/sessionMcp.test.ts b/tests/boundary/mcp/sessionMcp.test.ts index 5ba1562a2a..0f20b3a5fe 100644 --- a/tests/boundary/mcp/sessionMcp.test.ts +++ b/tests/boundary/mcp/sessionMcp.test.ts @@ -229,6 +229,37 @@ describe("target-free MCP workflow", () => { }, 10_000); }); +describe("session filesystem path boundaries over MCP", () => { + it("rejects relative snapshot and export paths with an absolute-path error", async () => { + directory = await createTestTempDirectory("rea-mcp-path-boundary-"); + const { mcp, first } = await createSessionMcpHarness( + directory, + provider, + resources, + ); + + const exported = await mcp.callTool({ + name: "export_evidence_bundle", + arguments: { path: "relative-bundle.json" }, + }); + expect(exported.isError, JSON.stringify(exported.content)).toBe(true); + expect(JSON.stringify(exported.content)).toContain("absolute"); + + const opened = await mcp.callTool({ + name: "open_binary", + arguments: { path: first }, + }); + expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true); + const closed = await mcp.callTool({ + name: "close_binary", + arguments: { snapshot_path: "relative-analysis.json" }, + }); + expect(closed.isError, JSON.stringify(closed.content)).toBe(true); + expect(JSON.stringify(closed.content)).toContain("absolute"); + await mcp.callTool({ name: "close_binary", arguments: {} }); + }, 10_000); +}); + describe("process residuals over MCP", () => { it("records process residuals linked to capture Evidence", async () => { if (!(await probeProcessCaptureCapability()).available) return;