diff --git a/src/features/claude-code-mcp-loader/scope-filter.ts b/src/features/claude-code-mcp-loader/scope-filter.ts index 690421e0c..6da03829c 100644 --- a/src/features/claude-code-mcp-loader/scope-filter.ts +++ b/src/features/claude-code-mcp-loader/scope-filter.ts @@ -1,17 +1,6 @@ -import { existsSync, realpathSync } from "fs" -import { resolve } from "path" +import { containsPath } from "../../shared/contains-path" import type { ClaudeCodeMcpServer } from "./types" -function normalizePath(path: string): string { - const resolvedPath = resolve(path) - - if (!existsSync(resolvedPath)) { - return resolvedPath - } - - return realpathSync(resolvedPath) -} - export function shouldLoadMcpServer( server: Pick, cwd = process.cwd() @@ -24,5 +13,5 @@ export function shouldLoadMcpServer( return false } - return normalizePath(server.projectPath) === normalizePath(cwd) + return containsPath(server.projectPath, cwd) } diff --git a/src/features/claude-code-mcp-loader/scope-filtering.test.ts b/src/features/claude-code-mcp-loader/scope-filtering.test.ts index e90136b24..16618c879 100644 --- a/src/features/claude-code-mcp-loader/scope-filtering.test.ts +++ b/src/features/claude-code-mcp-loader/scope-filtering.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" import { mkdirSync, rmSync, writeFileSync } from "fs" import { tmpdir } from "os" import { join } from "path" +import { shouldLoadMcpServer } from "./scope-filter" const TEST_DIR = join(tmpdir(), `mcp-scope-filtering-test-${Date.now()}`) const TEST_HOME = join(TEST_DIR, "home") @@ -27,6 +28,56 @@ describe("loadMcpConfigs", () => { rmSync(TEST_DIR, { recursive: true, force: true }) }) + describe("#given local MCP scope checks", () => { + it("#when cwd exactly matches project path #then the server is loaded", () => { + const result = shouldLoadMcpServer( + { + scope: "local", + projectPath: "/tmp/repo", + }, + "/tmp/repo" + ) + + expect(result).toBe(true) + }) + + it("#when cwd is a subdirectory of project path #then the server is loaded", () => { + const result = shouldLoadMcpServer( + { + scope: "local", + projectPath: "/tmp/repo", + }, + "/tmp/repo/packages/app" + ) + + expect(result).toBe(true) + }) + + it("#when cwd does not overlap project path #then the server is not loaded", () => { + const result = shouldLoadMcpServer( + { + scope: "local", + projectPath: "/tmp/repo", + }, + "/tmp/other" + ) + + expect(result).toBe(false) + }) + + it("#when cwd is the parent of project path #then the server is not loaded", () => { + const result = shouldLoadMcpServer( + { + scope: "local", + projectPath: "/tmp/repo", + }, + "/tmp" + ) + + expect(result).toBe(false) + }) + }) + describe("#given user-scoped MCP entries with local scope metadata", () => { it("#when loading configs #then only servers matching the current project path are loaded", async () => { writeFileSync( diff --git a/src/features/claude-code-plugin-loader/mcp-server-loader.test.ts b/src/features/claude-code-plugin-loader/mcp-server-loader.test.ts index 7f474b4cc..8bb1ea034 100644 --- a/src/features/claude-code-plugin-loader/mcp-server-loader.test.ts +++ b/src/features/claude-code-plugin-loader/mcp-server-loader.test.ts @@ -6,12 +6,14 @@ import type { LoadedPlugin } from "./types" const TEST_DIR = join(tmpdir(), `plugin-mcp-loader-test-${Date.now()}`) const PROJECT_DIR = join(TEST_DIR, "project") +const PROJECT_SUBDIRECTORY = join(PROJECT_DIR, "packages", "app") const PLUGIN_DIR = join(TEST_DIR, "plugin") const MCP_CONFIG_PATH = join(PLUGIN_DIR, "mcp.json") describe("loadPluginMcpServers", () => { beforeEach(() => { mkdirSync(PROJECT_DIR, { recursive: true }) + mkdirSync(PROJECT_SUBDIRECTORY, { recursive: true }) mkdirSync(PLUGIN_DIR, { recursive: true }) mock.module("../../shared/logger", () => ({ log: () => {}, @@ -24,7 +26,7 @@ describe("loadPluginMcpServers", () => { }) describe("#given plugin MCP entries with local scope metadata", () => { - it("#when loading plugin MCP servers #then only entries matching the current cwd are included", async () => { + it("#when loading plugin MCP servers from a project subdirectory #then only entries within the same project are included", async () => { writeFileSync( MCP_CONFIG_PATH, JSON.stringify({ @@ -45,6 +47,12 @@ describe("loadPluginMcpServers", () => { scope: "local", projectPath: join(PROJECT_DIR, "other-project"), }, + parentLocal: { + command: "npx", + args: ["parent-plugin-local"], + scope: "local", + projectPath: join(PROJECT_SUBDIRECTORY, "nested-project"), + }, }, }) ) @@ -59,7 +67,7 @@ describe("loadPluginMcpServers", () => { } const originalCwd = process.cwd() - process.chdir(PROJECT_DIR) + process.chdir(PROJECT_SUBDIRECTORY) try { const { loadPluginMcpServers } = await import("./mcp-server-loader") @@ -68,6 +76,7 @@ describe("loadPluginMcpServers", () => { expect(servers).toHaveProperty("demo-plugin:globalServer") expect(servers).toHaveProperty("demo-plugin:matchingLocal") expect(servers).not.toHaveProperty("demo-plugin:nonMatchingLocal") + expect(servers).not.toHaveProperty("demo-plugin:parentLocal") } finally { process.chdir(originalCwd) } diff --git a/src/shared/binary-downloader.ts b/src/shared/binary-downloader.ts index 28b737311..9b0ce7f04 100644 --- a/src/shared/binary-downloader.ts +++ b/src/shared/binary-downloader.ts @@ -4,6 +4,10 @@ import { spawn } from "bun"; import { validateArchiveEntries, type ArchiveEntry } from "./archive-entry-validator"; import { extractZip } from "./zip-extractor"; +function isTarTraversalErrorOutput(output: string): boolean { + return /path contains '\.\.'|member name contains '\.\.'|removing leading [`'\"]?\.\.\//i.test(output) +} + export function getCachedBinaryPath(cacheDir: string, binaryName: string): string | null { const binaryPath = path.join(cacheDir, binaryName); return existsSync(binaryPath) ? binaryPath : null; @@ -43,6 +47,11 @@ export async function extractTarGz( const exitCode = await proc.exited; if (exitCode !== 0) { const stderr = await new Response(proc.stderr).text(); + + if (isTarTraversalErrorOutput(stderr)) { + throw new Error(`Unsafe archive entry: path contains path traversal (${archivePath})`) + } + throw new Error(`tar extraction failed (exit ${exitCode}): ${stderr}`); } } @@ -102,6 +111,10 @@ async function listTarEntries(archivePath: string, cwd?: string): Promise entry !== null) } +export function isPythonZipListingAvailable(): boolean { + const proc = spawnSync(["python3", "--version"], { + stdout: "ignore", + stderr: "ignore", + }) + + return proc.exitCode === 0 +} + +export async function listZipEntriesWithPython(archivePath: string): Promise { + const script = [ + "import json, stat, sys, zipfile", + "entries = []", + "with zipfile.ZipFile(sys.argv[1], 'r') as archive:", + " for info in archive.infolist():", + " mode = (info.external_attr >> 16) & 0xFFFF", + " if stat.S_ISLNK(mode):", + " entry_type = 'symlink'", + " link_path = archive.read(info).decode('utf-8', 'surrogateescape')", + " elif info.filename.endswith('/'):", + " entry_type = 'directory'", + " link_path = None", + " else:", + " entry_type = 'file'", + " link_path = None", + " entry = {'path': info.filename, 'type': entry_type}", + " if link_path is not None:", + " entry['linkPath'] = link_path", + " entries.append(entry)", + "print(json.dumps(entries))", + ].join("\n") + + const proc = spawn(["python3", "-c", script, archivePath], { + stdout: "pipe", + stderr: "pipe", + }) + + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + + if (exitCode !== 0) { + throw new Error(`zip entry listing failed (exit ${exitCode}): ${stderr}`) + } + + return JSON.parse(stdout) as ArchiveEntry[] +} + export async function listZipEntriesWithPowerShell( archivePath: string, escapePowerShellPath: (path: string) => string, diff --git a/src/shared/zip-extractor.ts b/src/shared/zip-extractor.ts index 58da48ebf..8bb77b42c 100644 --- a/src/shared/zip-extractor.ts +++ b/src/shared/zip-extractor.ts @@ -2,7 +2,12 @@ import { spawn, spawnSync } from "bun" import { release } from "os" import { validateArchiveEntries } from "./archive-entry-validator" -import { listZipEntriesWithPowerShell, listZipEntriesWithTar } from "./zip-entry-listing" +import { + isPythonZipListingAvailable, + listZipEntriesWithPowerShell, + listZipEntriesWithPython, + listZipEntriesWithTar, +} from "./zip-entry-listing" const WINDOWS_BUILD_WITH_TAR = 17134 @@ -98,5 +103,9 @@ async function listZipEntries(archivePath: string) { return listZipEntriesWithPowerShell(archivePath, escapePowerShellPath, extractor) } + if (isPythonZipListingAvailable()) { + return listZipEntriesWithPython(archivePath) + } + return listZipEntriesWithTar(archivePath) }