From 45f92fe066a541c35067ceb240c2de936eff6145 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sun, 21 Dec 2025 18:14:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20MCP=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E9=9B=86=E4=B8=AD=E5=BC=8F=E8=B7=AF=E5=BE=84=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=EF=BC=8C=E5=A2=9E=E5=BC=BA=E5=AE=89=E5=85=A8=E6=80=A7?= =?UTF-8?q?=E5=92=8C=E5=8F=AF=E9=85=8D=E7=BD=AE=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 path-validator.ts:参考 MCP filesystem 服务器设计的集中式路径验证器 - 支持 CCW_PROJECT_ROOT 和 CCW_ALLOWED_DIRS 环境变量配置 - 多层路径验证:绝对路径解析 → 沙箱检查 → 符号链接验证 - 向后兼容:未设置环境变量时回退到 process.cwd() - 更新所有 MCP 工具使用集中式路径验证: - write-file.ts: 使用 validatePath() - edit-file.ts: 使用 validatePath({ mustExist: true }) - read-file.ts: 使用 validatePath() + getProjectRoot() - smart-search.ts: 使用 getProjectRoot() - core-memory.ts: 使用 getProjectRoot() - MCP 服务器启动时输出项目根目录和允许目录信息 - MCP 管理界面增强: - CCW Tools 安装卡片新增路径设置 UI - 支持 CCW_PROJECT_ROOT 和 CCW_ALLOWED_DIRS 配置 - 添加"使用当前项目"快捷按钮 - 支持 Claude 和 Codex 两种模式 - 添加中英文国际化翻译 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ccw/src/mcp-server/index.ts | 13 ++ .../dashboard-js/components/mcp-manager.js | 49 +++++- ccw/src/templates/dashboard-js/i18n.js | 16 ++ .../dashboard-js/views/codexlens-manager.js | 18 ++- .../dashboard-js/views/mcp-manager.js | 72 +++++++++ ccw/src/tools/core-memory.ts | 3 +- ccw/src/tools/edit-file.ts | 7 +- ccw/src/tools/read-file.ts | 5 +- ccw/src/tools/smart-search.ts | 5 +- ccw/src/tools/write-file.ts | 5 +- ccw/src/utils/path-validator.ts | 153 ++++++++++++++++++ 11 files changed, 330 insertions(+), 16 deletions(-) create mode 100644 ccw/src/utils/path-validator.ts diff --git a/ccw/src/mcp-server/index.ts b/ccw/src/mcp-server/index.ts index 8fef0b96..3601fd97 100644 --- a/ccw/src/mcp-server/index.ts +++ b/ccw/src/mcp-server/index.ts @@ -12,10 +12,15 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { getAllToolSchemas, executeTool, executeToolWithProgress } from '../tools/index.js'; import type { ToolSchema, ToolResult } from '../types/tool.js'; +import { getProjectRoot, getAllowedDirectories } from '../utils/path-validator.js'; const SERVER_NAME = 'ccw-tools'; const SERVER_VERSION = '6.2.0'; +// Environment variable names for documentation +const ENV_PROJECT_ROOT = 'CCW_PROJECT_ROOT'; +const ENV_ALLOWED_DIRS = 'CCW_ALLOWED_DIRS'; + // Default enabled tools (core set) const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'smart_search', 'core_memory']; @@ -162,7 +167,15 @@ async function main(): Promise { }); // Log server start (to stderr to not interfere with stdio protocol) + const projectRoot = getProjectRoot(); + const allowedDirs = getAllowedDirectories(); console.error(`${SERVER_NAME} v${SERVER_VERSION} started`); + console.error(`Project root: ${projectRoot}`); + console.error(`Allowed directories: ${allowedDirs.join(', ')}`); + if (!process.env[ENV_PROJECT_ROOT]) { + console.error(`[Warning] ${ENV_PROJECT_ROOT} not set, using process.cwd()`); + console.error(`[Tip] Set ${ENV_PROJECT_ROOT} in your MCP config to specify project directory`); + } } // Run server diff --git a/ccw/src/templates/dashboard-js/components/mcp-manager.js b/ccw/src/templates/dashboard-js/components/mcp-manager.js index e8486a19..d9309bae 100644 --- a/ccw/src/templates/dashboard-js/components/mcp-manager.js +++ b/ccw/src/templates/dashboard-js/components/mcp-manager.js @@ -927,9 +927,28 @@ function selectCcwTools(type) { }); } +// Get CCW path settings from input fields +function getCcwPathConfig() { + const projectRootInput = document.querySelector('.ccw-project-root-input'); + const allowedDirsInput = document.querySelector('.ccw-allowed-dirs-input'); + return { + projectRoot: projectRootInput?.value || '', + allowedDirs: allowedDirsInput?.value || '' + }; +} + +// Set CCW_PROJECT_ROOT to current project path +function setCcwProjectRootToCurrent() { + const input = document.querySelector('.ccw-project-root-input'); + if (input && projectPath) { + input.value = projectPath; + } +} + // Build CCW Tools config with selected tools // Uses isWindowsPlatform from state.js to generate platform-appropriate commands -function buildCcwToolsConfig(selectedTools) { +function buildCcwToolsConfig(selectedTools, pathConfig = {}) { + const { projectRoot, allowedDirs } = pathConfig; // Windows requires 'cmd /c' wrapper to execute npx // Other platforms (macOS, Linux) can run npx directly const config = isWindowsPlatform @@ -948,12 +967,30 @@ function buildCcwToolsConfig(selectedTools) { coreTools.every(t => selectedTools.includes(t)) && selectedTools.every(t => coreTools.includes(t)); + // Initialize env if needed if (selectedTools.length === 15) { config.env = { CCW_ENABLED_TOOLS: 'all' }; } else if (!isDefault && selectedTools.length > 0) { config.env = { CCW_ENABLED_TOOLS: selectedTools.join(',') }; } + // Add path settings if provided + if (!config.env) { + config.env = {}; + } + + if (projectRoot && projectRoot.trim()) { + config.env.CCW_PROJECT_ROOT = projectRoot.trim(); + } + if (allowedDirs && allowedDirs.trim()) { + config.env.CCW_ALLOWED_DIRS = allowedDirs.trim(); + } + + // Remove env object if empty + if (config.env && Object.keys(config.env).length === 0) { + delete config.env; + } + return config; } @@ -965,7 +1002,8 @@ async function installCcwToolsMcp(scope = 'workspace') { return; } - const ccwToolsConfig = buildCcwToolsConfig(selectedTools); + const pathConfig = getCcwPathConfig(); + const ccwToolsConfig = buildCcwToolsConfig(selectedTools, pathConfig); try { const scopeLabel = scope === 'global' ? 'globally' : 'to workspace'; @@ -1032,7 +1070,8 @@ async function updateCcwToolsMcp(scope = 'workspace') { return; } - const ccwToolsConfig = buildCcwToolsConfig(selectedTools); + const pathConfig = getCcwPathConfig(); + const ccwToolsConfig = buildCcwToolsConfig(selectedTools, pathConfig); try { const scopeLabel = scope === 'global' ? 'globally' : 'in workspace'; @@ -1126,7 +1165,8 @@ async function installCcwToolsMcpToCodex() { return; } - const ccwToolsConfig = buildCcwToolsConfig(selectedTools); + const pathConfig = getCcwPathConfig(); + const ccwToolsConfig = buildCcwToolsConfig(selectedTools, pathConfig); try { const isUpdate = codexMcpServers && codexMcpServers['ccw-tools']; @@ -1176,3 +1216,4 @@ window.openMcpCreateModal = openMcpCreateModal; window.toggleProjectConfigType = toggleProjectConfigType; window.getPreferredProjectConfigType = getPreferredProjectConfigType; window.setPreferredProjectConfigType = setPreferredProjectConfigType; +window.setCcwProjectRootToCurrent = setCcwProjectRootToCurrent; diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index f1798e70..ef99167c 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -290,6 +290,8 @@ const i18n = { 'codexlens.modelDeleted': 'Model deleted', 'codexlens.modelDeleteFailed': 'Model deletion failed', 'codexlens.deleteModelConfirm': 'Are you sure you want to delete model', + 'codexlens.modelListError': 'Failed to load models', + 'codexlens.noModelsAvailable': 'No models available', // CodexLens Indexing Progress 'codexlens.indexing': 'Indexing', @@ -609,6 +611,12 @@ const i18n = { 'mcp.claudeMode': 'Claude Mode', 'mcp.codexMode': 'Codex Mode', + // CCW Tools Path Settings + 'mcp.pathSettings': 'Path Settings', + 'mcp.useCurrentDir': 'Use current directory', + 'mcp.useCurrentProject': 'Use current project', + 'mcp.allowedDirsPlaceholder': 'Comma-separated paths (optional)', + // Codex MCP 'mcp.codex.globalServers': 'Codex Global MCP Servers', 'mcp.codex.newServer': 'New Server', @@ -1617,6 +1625,8 @@ const i18n = { 'codexlens.modelDeleted': '模型已删除', 'codexlens.modelDeleteFailed': '模型删除失败', 'codexlens.deleteModelConfirm': '确定要删除模型', + 'codexlens.modelListError': '加载模型列表失败', + 'codexlens.noModelsAvailable': '没有可用模型', // CodexLens 索引进度 'codexlens.indexing': '索引中', @@ -1914,6 +1924,12 @@ const i18n = { 'mcp.claudeMode': 'Claude 模式', 'mcp.codexMode': 'Codex 模式', + // CCW Tools Path Settings + 'mcp.pathSettings': '路径设置', + 'mcp.useCurrentDir': '使用当前目录', + 'mcp.useCurrentProject': '使用当前项目', + 'mcp.allowedDirsPlaceholder': '逗号分隔的路径列表(可选)', + // Codex MCP 'mcp.codex.globalServers': 'Codex 全局 MCP 服务器', 'mcp.codex.newServer': '新建服务器', diff --git a/ccw/src/templates/dashboard-js/views/codexlens-manager.js b/ccw/src/templates/dashboard-js/views/codexlens-manager.js index 6e20c09d..9cb049f1 100644 --- a/ccw/src/templates/dashboard-js/views/codexlens-manager.js +++ b/ccw/src/templates/dashboard-js/views/codexlens-manager.js @@ -415,9 +415,23 @@ async function loadModelList() { var response = await fetch('/api/codexlens/models'); var result = await response.json(); - if (!result.success || !result.result || !result.result.models) { + if (!result.success) { + // Check if the error is specifically about fastembed not being installed + var errorMsg = result.error || ''; + if (errorMsg.includes('fastembed not installed') || errorMsg.includes('Semantic')) { + container.innerHTML = + '
' + t('codexlens.semanticNotInstalled') + '
'; + } else { + // Show actual error message for other failures + container.innerHTML = + '
' + t('codexlens.modelListError') + ': ' + (errorMsg || t('common.unknownError')) + '
'; + } + return; + } + + if (!result.result || !result.result.models) { container.innerHTML = - '
' + t('codexlens.semanticNotInstalled') + '
'; + '
' + t('codexlens.noModelsAvailable') + '
'; return; } diff --git a/ccw/src/templates/dashboard-js/views/mcp-manager.js b/ccw/src/templates/dashboard-js/views/mcp-manager.js index 1d966836..55248cc4 100644 --- a/ccw/src/templates/dashboard-js/views/mcp-manager.js +++ b/ccw/src/templates/dashboard-js/views/mcp-manager.js @@ -40,6 +40,22 @@ function getCcwEnabledToolsCodex() { return CCW_MCP_TOOLS.filter(t => t.core).map(t => t.name); } +// Get current CCW_PROJECT_ROOT from config +function getCcwProjectRoot() { + const currentPath = projectPath; + const projectData = mcpAllProjects[currentPath] || {}; + const ccwConfig = projectData.mcpServers?.['ccw-tools']; + return ccwConfig?.env?.CCW_PROJECT_ROOT || ''; +} + +// Get current CCW_ALLOWED_DIRS from config +function getCcwAllowedDirs() { + const currentPath = projectPath; + const projectData = mcpAllProjects[currentPath] || {}; + const ccwConfig = projectData.mcpServers?.['ccw-tools']; + return ccwConfig?.env?.CCW_ALLOWED_DIRS || ''; +} + async function renderMcpManager() { const container = document.getElementById('mainContent'); if (!container) return; @@ -232,6 +248,34 @@ async function renderMcpManager() { + +
+
+ + ${t('mcp.pathSettings')} +
+
+
+ + + +
+
+ + +
+
+
@@ -418,6 +462,34 @@ async function renderMcpManager() {
+ +
+
+ + ${t('mcp.pathSettings')} +
+
+
+ + + +
+
+ + +
+
+
diff --git a/ccw/src/tools/core-memory.ts b/ccw/src/tools/core-memory.ts index 6f5166dc..4b4e7ec3 100644 --- a/ccw/src/tools/core-memory.ts +++ b/ccw/src/tools/core-memory.ts @@ -9,6 +9,7 @@ import { getCoreMemoryStore, findMemoryAcrossProjects } from '../core/core-memor import * as MemoryEmbedder from '../core/memory-embedder-bridge.js'; import { StoragePaths } from '../config/storage-paths.js'; import { join } from 'path'; +import { getProjectRoot } from '../utils/path-validator.js'; // Zod schemas const OperationEnum = z.enum(['list', 'import', 'export', 'summary', 'embed', 'search', 'embed_status']); @@ -108,7 +109,7 @@ type OperationResult = ListResult | ImportResult | ExportResult | SummaryResult * Get project path from current working directory */ function getProjectPath(): string { - return process.cwd(); + return getProjectRoot(); } /** diff --git a/ccw/src/tools/edit-file.ts b/ccw/src/tools/edit-file.ts index 098219a3..7e2319c4 100644 --- a/ccw/src/tools/edit-file.ts +++ b/ccw/src/tools/edit-file.ts @@ -15,6 +15,7 @@ import { z } from 'zod'; import type { ToolSchema, ToolResult } from '../types/tool.js'; import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { resolve, isAbsolute, dirname } from 'path'; +import { validatePath } from '../utils/path-validator.js'; // Define Zod schemas for validation const EditItemSchema = z.object({ @@ -71,8 +72,8 @@ interface LineModeResult { * @param filePath - Path to file * @returns Resolved path and content */ -function readFile(filePath: string): { resolvedPath: string; content: string } { - const resolvedPath = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath); +async function readFile(filePath: string): Promise<{ resolvedPath: string; content: string }> { + const resolvedPath = await validatePath(filePath, { mustExist: true }); if (!existsSync(resolvedPath)) { throw new Error(`File not found: ${resolvedPath}`); @@ -524,7 +525,7 @@ export async function handler(params: Record): Promise): Promise): Promise { return new Promise((resolve) => { const child = spawn(command, args, { - cwd: path || process.cwd(), + cwd: path || getProjectRoot(), stdio: ['ignore', 'pipe', 'pipe'], }); @@ -1518,7 +1519,7 @@ async function executeFindFilesAction(params: Params): Promise { } const child = spawn('rg', args, { - cwd: path || process.cwd(), + cwd: path || getProjectRoot(), stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/ccw/src/tools/write-file.ts b/ccw/src/tools/write-file.ts index 5420db2c..20590666 100644 --- a/ccw/src/tools/write-file.ts +++ b/ccw/src/tools/write-file.ts @@ -12,6 +12,7 @@ import { z } from 'zod'; import type { ToolSchema, ToolResult } from '../types/tool.js'; import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, statSync } from 'fs'; import { resolve, isAbsolute, dirname, basename } from 'path'; +import { validatePath } from '../utils/path-validator.js'; // Define Zod schema for validation const ParamsSchema = z.object({ @@ -153,8 +154,8 @@ export async function handler(params: Record): Promise process.cwd() + */ +export function getProjectRoot(): string { + return process.env[ENV_PROJECT_ROOT] || process.cwd(); +} + +/** + * Get allowed directories list + * Priority: CCW_ALLOWED_DIRS > [getProjectRoot()] + */ +export function getAllowedDirectories(): string[] { + const envDirs = process.env[ENV_ALLOWED_DIRS]; + if (envDirs) { + return envDirs.split(',').map(d => d.trim()).filter(Boolean); + } + return [getProjectRoot()]; +} + +/** + * Normalize path (unify separators to forward slash) + */ +export function normalizePath(p: string): string { + return normalize(p).replace(/\\/g, '/'); +} + +/** + * Check if path is within allowed directories + */ +export function isPathWithinAllowedDirectories( + targetPath: string, + allowedDirectories: string[] +): boolean { + const normalizedTarget = normalizePath(targetPath); + return allowedDirectories.some(dir => { + const normalizedDir = normalizePath(dir); + // Check if path equals or starts with allowed directory + return normalizedTarget === normalizedDir || + normalizedTarget.startsWith(normalizedDir + '/'); + }); +} + +/** + * Validate and resolve path (core function) + * + * Security model: + * 1. Resolve to absolute path + * 2. Check against allowed directories + * 3. Resolve symlinks and re-verify + * + * @param filePath - Path to validate + * @param options - Validation options + * @returns Validated absolute path + * @throws Error if path is outside allowed directories or validation fails + */ +export async function validatePath( + filePath: string, + options: { + allowedDirectories?: string[]; + mustExist?: boolean; + } = {} +): Promise { + const allowedDirs = options.allowedDirectories || getAllowedDirectories(); + + // 1. Resolve to absolute path + const absolutePath = isAbsolute(filePath) + ? filePath + : resolve(getProjectRoot(), filePath); + const normalizedPath = normalizePath(absolutePath); + + // 2. Initial sandbox check + if (!isPathWithinAllowedDirectories(normalizedPath, allowedDirs)) { + throw new Error( + `Access denied: path "${normalizedPath}" is outside allowed directories. ` + + `Allowed: [${allowedDirs.join(', ')}]` + ); + } + + // 3. Try to resolve symlinks and re-verify + try { + const realPath = await realpath(absolutePath); + const normalizedReal = normalizePath(realPath); + + if (!isPathWithinAllowedDirectories(normalizedReal, allowedDirs)) { + throw new Error( + `Access denied: symlink target "${normalizedReal}" is outside allowed directories` + ); + } + + return normalizedReal; + } catch (error: any) { + // File doesn't exist - validate parent directory + if (error.code === 'ENOENT') { + if (options.mustExist) { + throw new Error(`File not found: ${absolutePath}`); + } + + // Validate parent directory's real path + const parentDir = resolve(absolutePath, '..'); + try { + const realParent = await realpath(parentDir); + const normalizedParent = normalizePath(realParent); + + if (!isPathWithinAllowedDirectories(normalizedParent, allowedDirs)) { + throw new Error( + `Access denied: parent directory "${normalizedParent}" is outside allowed directories` + ); + } + } catch (parentError: any) { + if (parentError.code === 'ENOENT') { + // Parent directory doesn't exist either - return original absolute path + // Let the caller create it if needed + return absolutePath; + } + throw parentError; + } + + return absolutePath; + } + + // Re-throw access denied errors + if (error.message?.includes('Access denied')) { + throw error; + } + throw error; + } +} + +/** + * Resolve project-relative path (simplified, no strict validation) + * Use for cases where strict security validation is not needed + */ +export function resolveProjectPath(...pathSegments: string[]): string { + return resolve(getProjectRoot(), ...pathSegments); +}