diff --git a/packages/ast-grep-mcp/src/mcp.test.ts b/packages/ast-grep-mcp/src/mcp.test.ts index e2b51815d..5bf869fca 100644 --- a/packages/ast-grep-mcp/src/mcp.test.ts +++ b/packages/ast-grep-mcp/src/mcp.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleAstGrepMcpRequest } from "./mcp"; @@ -160,6 +160,98 @@ describe("ast-grep MCP", () => { expect(didRun).toBe(false); }); + it("#given paths contains an absolute path inside the workspace #when search and replace are called #then it is normalized to relative and processed normally", async () => { + const captured: RunOptions[] = []; + const workspaceDirectory = createTemporaryDirectory("omo-ast-grep-absolute-inside-"); + const sourcePath = join(workspaceDirectory, "foo.ts"); + writeFileSync(sourcePath, "console.log('hello')\n"); + + const runSg = async (options: RunOptions): Promise => { + captured.push(options); + return emptyResult; + }; + + const searchResponse = await handleAstGrepMcpRequest( + { + jsonrpc: "2.0", + id: "search-absolute-inside", + method: "tools/call", + params: { name: "search", arguments: { pattern: "console.log($$$)", lang: "typescript", paths: [sourcePath] } }, + }, + { workspaceDirectory, runSg }, + ); + const replaceResponse = await handleAstGrepMcpRequest( + { + jsonrpc: "2.0", + id: "replace-absolute-inside", + method: "tools/call", + params: { name: "replace", arguments: { pattern: "console.log($MSG)", rewrite: "logger.info($MSG)", lang: "typescript", paths: [sourcePath] } }, + }, + { workspaceDirectory, runSg }, + ); + + expect(searchResponse?.result?.isError).toBe(false); + expect(replaceResponse?.result?.isError).toBe(false); + expect(captured.map((options) => options.paths)).toEqual([["foo.ts"], ["foo.ts"]]); + }); + + it("#given paths contains an absolute path outside the workspace #when search is called #then it is rejected", async () => { + const workspaceDirectory = createTemporaryDirectory("omo-ast-grep-absolute-outside-workspace-"); + const outsideDirectory = createTemporaryDirectory("omo-ast-grep-absolute-outside-"); + const outsidePath = join(outsideDirectory, "foo.ts"); + writeFileSync(outsidePath, "console.log('outside')\n"); + let didRun = false; + + const response = await handleAstGrepMcpRequest( + { + jsonrpc: "2.0", + id: "absolute-outside", + method: "tools/call", + params: { name: "search", arguments: { pattern: "console.log($$$)", lang: "typescript", paths: [outsidePath] } }, + }, + { + workspaceDirectory, + runSg: async () => { + didRun = true; + return emptyResult; + }, + }, + ); + + expect(response?.result?.isError).toBe(true); + expect(response?.result?.content?.[0]?.text).toContain("stay inside the workspace"); + expect(didRun).toBe(false); + }); + + it("#given paths contains an absolute path whose realpath resolves inside workspace through a symlink #when search is called #then it is allowed", async () => { + const captured: { value?: RunOptions } = {}; + const workspaceDirectory = createTemporaryDirectory("omo-ast-grep-symlink-target-"); + const symlinkParent = createTemporaryDirectory("omo-ast-grep-symlink-parent-"); + const sourcePath = join(workspaceDirectory, "foo.ts"); + const symlinkPath = join(symlinkParent, "workspace-link"); + writeFileSync(sourcePath, "console.log('via symlink')\n"); + symlinkSync(workspaceDirectory, symlinkPath); + + const response = await handleAstGrepMcpRequest( + { + jsonrpc: "2.0", + id: "absolute-symlink-inside", + method: "tools/call", + params: { name: "search", arguments: { pattern: "console.log($$$)", lang: "typescript", paths: [join(symlinkPath, "foo.ts")] } }, + }, + { + workspaceDirectory, + runSg: async (options) => { + captured.value = options; + return emptyResult; + }, + }, + ); + + expect(response?.result?.isError).toBe(false); + expect(captured.value?.paths).toEqual(["foo.ts"]); + }); + it("#given tools list request #when handled #then preserves detailed ast-grep guidance", async () => { const response = await handleAstGrepMcpRequest({ jsonrpc: "2.0", id: "tools", method: "tools/list" }); const searchTool = response?.result?.tools?.find((tool) => tool.name === "search"); diff --git a/packages/ast-grep-mcp/src/workspace-paths.ts b/packages/ast-grep-mcp/src/workspace-paths.ts index b3be6bc6e..1afb76887 100644 --- a/packages/ast-grep-mcp/src/workspace-paths.ts +++ b/packages/ast-grep-mcp/src/workspace-paths.ts @@ -15,7 +15,7 @@ function resolveWorkspacePath(rawPath: string, workspaceDirectory: string): stri if (rawPath.length === 0) throw new Error("paths entries must be non-empty strings"); if (rawPath.startsWith("-")) throw new Error(`paths entries must not start with '-': ${rawPath}`); if (rawPath.includes("\0")) throw new Error("paths entries must not contain null bytes"); - if (isAbsolute(rawPath)) throw new Error(`paths entries must be relative to the workspace: ${rawPath}`); + if (isAbsolute(rawPath)) return resolveAbsoluteWorkspacePath(rawPath, workspaceDirectory); const absolutePath = resolve(workspaceDirectory, rawPath); assertInsideWorkspace(absolutePath, workspaceDirectory, rawPath); @@ -29,6 +29,20 @@ function resolveWorkspacePath(rawPath: string, workspaceDirectory: string): stri return normalizedPath === "" ? "." : normalizedPath; } +function resolveAbsoluteWorkspacePath(rawPath: string, workspaceDirectory: string): string { + let realPath: string; + try { + realPath = realpathSync(rawPath); + } catch { + throw new Error(`absolute path entry does not exist: ${rawPath}`); + } + + assertInsideWorkspace(realPath, workspaceDirectory, rawPath); + + const normalizedPath = relative(workspaceDirectory, realPath); + return normalizedPath === "" ? "." : normalizedPath; +} + function assertInsideWorkspace(candidatePath: string, workspaceDirectory: string, rawPath: string): void { const workspaceRelativePath = relative(workspaceDirectory, candidatePath); if (workspaceRelativePath === "" || (!workspaceRelativePath.startsWith("..") && !isAbsolute(workspaceRelativePath))) return;