Skip to content

Commit 45f92fe

Browse files
catlog22claude
andcommitted
feat: 实现 MCP 工具集中式路径验证,增强安全性和可配置性
- 新增 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 <[email protected]>
1 parent f492f48 commit 45f92fe

File tree

11 files changed

+330
-16
lines changed

11 files changed

+330
-16
lines changed

ccw/src/mcp-server/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,15 @@ import {
1212
} from '@modelcontextprotocol/sdk/types.js';
1313
import { getAllToolSchemas, executeTool, executeToolWithProgress } from '../tools/index.js';
1414
import type { ToolSchema, ToolResult } from '../types/tool.js';
15+
import { getProjectRoot, getAllowedDirectories } from '../utils/path-validator.js';
1516

1617
const SERVER_NAME = 'ccw-tools';
1718
const SERVER_VERSION = '6.2.0';
1819

20+
// Environment variable names for documentation
21+
const ENV_PROJECT_ROOT = 'CCW_PROJECT_ROOT';
22+
const ENV_ALLOWED_DIRS = 'CCW_ALLOWED_DIRS';
23+
1924
// Default enabled tools (core set)
2025
const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'smart_search', 'core_memory'];
2126

@@ -162,7 +167,15 @@ async function main(): Promise<void> {
162167
});
163168

164169
// Log server start (to stderr to not interfere with stdio protocol)
170+
const projectRoot = getProjectRoot();
171+
const allowedDirs = getAllowedDirectories();
165172
console.error(`${SERVER_NAME} v${SERVER_VERSION} started`);
173+
console.error(`Project root: ${projectRoot}`);
174+
console.error(`Allowed directories: ${allowedDirs.join(', ')}`);
175+
if (!process.env[ENV_PROJECT_ROOT]) {
176+
console.error(`[Warning] ${ENV_PROJECT_ROOT} not set, using process.cwd()`);
177+
console.error(`[Tip] Set ${ENV_PROJECT_ROOT} in your MCP config to specify project directory`);
178+
}
166179
}
167180

168181
// Run server

ccw/src/templates/dashboard-js/components/mcp-manager.js

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -927,9 +927,28 @@ function selectCcwTools(type) {
927927
});
928928
}
929929

930+
// Get CCW path settings from input fields
931+
function getCcwPathConfig() {
932+
const projectRootInput = document.querySelector('.ccw-project-root-input');
933+
const allowedDirsInput = document.querySelector('.ccw-allowed-dirs-input');
934+
return {
935+
projectRoot: projectRootInput?.value || '',
936+
allowedDirs: allowedDirsInput?.value || ''
937+
};
938+
}
939+
940+
// Set CCW_PROJECT_ROOT to current project path
941+
function setCcwProjectRootToCurrent() {
942+
const input = document.querySelector('.ccw-project-root-input');
943+
if (input && projectPath) {
944+
input.value = projectPath;
945+
}
946+
}
947+
930948
// Build CCW Tools config with selected tools
931949
// Uses isWindowsPlatform from state.js to generate platform-appropriate commands
932-
function buildCcwToolsConfig(selectedTools) {
950+
function buildCcwToolsConfig(selectedTools, pathConfig = {}) {
951+
const { projectRoot, allowedDirs } = pathConfig;
933952
// Windows requires 'cmd /c' wrapper to execute npx
934953
// Other platforms (macOS, Linux) can run npx directly
935954
const config = isWindowsPlatform
@@ -948,12 +967,30 @@ function buildCcwToolsConfig(selectedTools) {
948967
coreTools.every(t => selectedTools.includes(t)) &&
949968
selectedTools.every(t => coreTools.includes(t));
950969

970+
// Initialize env if needed
951971
if (selectedTools.length === 15) {
952972
config.env = { CCW_ENABLED_TOOLS: 'all' };
953973
} else if (!isDefault && selectedTools.length > 0) {
954974
config.env = { CCW_ENABLED_TOOLS: selectedTools.join(',') };
955975
}
956976

977+
// Add path settings if provided
978+
if (!config.env) {
979+
config.env = {};
980+
}
981+
982+
if (projectRoot && projectRoot.trim()) {
983+
config.env.CCW_PROJECT_ROOT = projectRoot.trim();
984+
}
985+
if (allowedDirs && allowedDirs.trim()) {
986+
config.env.CCW_ALLOWED_DIRS = allowedDirs.trim();
987+
}
988+
989+
// Remove env object if empty
990+
if (config.env && Object.keys(config.env).length === 0) {
991+
delete config.env;
992+
}
993+
957994
return config;
958995
}
959996

@@ -965,7 +1002,8 @@ async function installCcwToolsMcp(scope = 'workspace') {
9651002
return;
9661003
}
9671004

968-
const ccwToolsConfig = buildCcwToolsConfig(selectedTools);
1005+
const pathConfig = getCcwPathConfig();
1006+
const ccwToolsConfig = buildCcwToolsConfig(selectedTools, pathConfig);
9691007

9701008
try {
9711009
const scopeLabel = scope === 'global' ? 'globally' : 'to workspace';
@@ -1032,7 +1070,8 @@ async function updateCcwToolsMcp(scope = 'workspace') {
10321070
return;
10331071
}
10341072

1035-
const ccwToolsConfig = buildCcwToolsConfig(selectedTools);
1073+
const pathConfig = getCcwPathConfig();
1074+
const ccwToolsConfig = buildCcwToolsConfig(selectedTools, pathConfig);
10361075

10371076
try {
10381077
const scopeLabel = scope === 'global' ? 'globally' : 'in workspace';
@@ -1126,7 +1165,8 @@ async function installCcwToolsMcpToCodex() {
11261165
return;
11271166
}
11281167

1129-
const ccwToolsConfig = buildCcwToolsConfig(selectedTools);
1168+
const pathConfig = getCcwPathConfig();
1169+
const ccwToolsConfig = buildCcwToolsConfig(selectedTools, pathConfig);
11301170

11311171
try {
11321172
const isUpdate = codexMcpServers && codexMcpServers['ccw-tools'];
@@ -1176,3 +1216,4 @@ window.openMcpCreateModal = openMcpCreateModal;
11761216
window.toggleProjectConfigType = toggleProjectConfigType;
11771217
window.getPreferredProjectConfigType = getPreferredProjectConfigType;
11781218
window.setPreferredProjectConfigType = setPreferredProjectConfigType;
1219+
window.setCcwProjectRootToCurrent = setCcwProjectRootToCurrent;

ccw/src/templates/dashboard-js/i18n.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,8 @@ const i18n = {
290290
'codexlens.modelDeleted': 'Model deleted',
291291
'codexlens.modelDeleteFailed': 'Model deletion failed',
292292
'codexlens.deleteModelConfirm': 'Are you sure you want to delete model',
293+
'codexlens.modelListError': 'Failed to load models',
294+
'codexlens.noModelsAvailable': 'No models available',
293295

294296
// CodexLens Indexing Progress
295297
'codexlens.indexing': 'Indexing',
@@ -609,6 +611,12 @@ const i18n = {
609611
'mcp.claudeMode': 'Claude Mode',
610612
'mcp.codexMode': 'Codex Mode',
611613

614+
// CCW Tools Path Settings
615+
'mcp.pathSettings': 'Path Settings',
616+
'mcp.useCurrentDir': 'Use current directory',
617+
'mcp.useCurrentProject': 'Use current project',
618+
'mcp.allowedDirsPlaceholder': 'Comma-separated paths (optional)',
619+
612620
// Codex MCP
613621
'mcp.codex.globalServers': 'Codex Global MCP Servers',
614622
'mcp.codex.newServer': 'New Server',
@@ -1617,6 +1625,8 @@ const i18n = {
16171625
'codexlens.modelDeleted': '模型已删除',
16181626
'codexlens.modelDeleteFailed': '模型删除失败',
16191627
'codexlens.deleteModelConfirm': '确定要删除模型',
1628+
'codexlens.modelListError': '加载模型列表失败',
1629+
'codexlens.noModelsAvailable': '没有可用模型',
16201630

16211631
// CodexLens 索引进度
16221632
'codexlens.indexing': '索引中',
@@ -1914,6 +1924,12 @@ const i18n = {
19141924
'mcp.claudeMode': 'Claude 模式',
19151925
'mcp.codexMode': 'Codex 模式',
19161926

1927+
// CCW Tools Path Settings
1928+
'mcp.pathSettings': '路径设置',
1929+
'mcp.useCurrentDir': '使用当前目录',
1930+
'mcp.useCurrentProject': '使用当前项目',
1931+
'mcp.allowedDirsPlaceholder': '逗号分隔的路径列表(可选)',
1932+
19171933
// Codex MCP
19181934
'mcp.codex.globalServers': 'Codex 全局 MCP 服务器',
19191935
'mcp.codex.newServer': '新建服务器',

ccw/src/templates/dashboard-js/views/codexlens-manager.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,9 +415,23 @@ async function loadModelList() {
415415
var response = await fetch('/api/codexlens/models');
416416
var result = await response.json();
417417

418-
if (!result.success || !result.result || !result.result.models) {
418+
if (!result.success) {
419+
// Check if the error is specifically about fastembed not being installed
420+
var errorMsg = result.error || '';
421+
if (errorMsg.includes('fastembed not installed') || errorMsg.includes('Semantic')) {
422+
container.innerHTML =
423+
'<div class="text-sm text-muted-foreground">' + t('codexlens.semanticNotInstalled') + '</div>';
424+
} else {
425+
// Show actual error message for other failures
426+
container.innerHTML =
427+
'<div class="text-sm text-error">' + t('codexlens.modelListError') + ': ' + (errorMsg || t('common.unknownError')) + '</div>';
428+
}
429+
return;
430+
}
431+
432+
if (!result.result || !result.result.models) {
419433
container.innerHTML =
420-
'<div class="text-sm text-muted-foreground">' + t('codexlens.semanticNotInstalled') + '</div>';
434+
'<div class="text-sm text-muted-foreground">' + t('codexlens.noModelsAvailable') + '</div>';
421435
return;
422436
}
423437

ccw/src/templates/dashboard-js/views/mcp-manager.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@ function getCcwEnabledToolsCodex() {
4040
return CCW_MCP_TOOLS.filter(t => t.core).map(t => t.name);
4141
}
4242

43+
// Get current CCW_PROJECT_ROOT from config
44+
function getCcwProjectRoot() {
45+
const currentPath = projectPath;
46+
const projectData = mcpAllProjects[currentPath] || {};
47+
const ccwConfig = projectData.mcpServers?.['ccw-tools'];
48+
return ccwConfig?.env?.CCW_PROJECT_ROOT || '';
49+
}
50+
51+
// Get current CCW_ALLOWED_DIRS from config
52+
function getCcwAllowedDirs() {
53+
const currentPath = projectPath;
54+
const projectData = mcpAllProjects[currentPath] || {};
55+
const ccwConfig = projectData.mcpServers?.['ccw-tools'];
56+
return ccwConfig?.env?.CCW_ALLOWED_DIRS || '';
57+
}
58+
4359
async function renderMcpManager() {
4460
const container = document.getElementById('mainContent');
4561
if (!container) return;
@@ -232,6 +248,34 @@ async function renderMcpManager() {
232248
<button class="text-primary hover:underline" onclick="selectCcwToolsCodex('all')">All</button>
233249
<button class="text-muted-foreground hover:underline" onclick="selectCcwToolsCodex('none')">None</button>
234250
</div>
251+
<!-- Path Settings -->
252+
<div class="ccw-path-settings mt-3 pt-3 border-t border-border/50">
253+
<div class="flex items-center gap-2 mb-2">
254+
<i data-lucide="folder-root" class="w-4 h-4 text-muted-foreground"></i>
255+
<span class="text-xs font-medium text-muted-foreground">${t('mcp.pathSettings')}</span>
256+
</div>
257+
<div class="grid grid-cols-1 gap-2">
258+
<div class="flex items-center gap-2">
259+
<label class="text-xs text-muted-foreground w-28 shrink-0">CCW_PROJECT_ROOT</label>
260+
<input type="text"
261+
class="ccw-project-root-input flex-1 px-2 py-1 text-xs bg-background border border-border rounded focus:outline-none focus:ring-1 focus:ring-primary"
262+
placeholder="${projectPath || t('mcp.useCurrentDir')}"
263+
value="${getCcwProjectRoot()}">
264+
<button class="p-1 text-muted-foreground hover:text-foreground"
265+
onclick="setCcwProjectRootToCurrent()"
266+
title="${t('mcp.useCurrentProject')}">
267+
<i data-lucide="locate-fixed" class="w-4 h-4"></i>
268+
</button>
269+
</div>
270+
<div class="flex items-center gap-2">
271+
<label class="text-xs text-muted-foreground w-28 shrink-0">CCW_ALLOWED_DIRS</label>
272+
<input type="text"
273+
class="ccw-allowed-dirs-input flex-1 px-2 py-1 text-xs bg-background border border-border rounded focus:outline-none focus:ring-1 focus:ring-primary"
274+
placeholder="${t('mcp.allowedDirsPlaceholder')}"
275+
value="${getCcwAllowedDirs()}">
276+
</div>
277+
</div>
278+
</div>
235279
</div>
236280
</div>
237281
<div class="shrink-0">
@@ -418,6 +462,34 @@ async function renderMcpManager() {
418462
<button class="text-primary hover:underline" onclick="selectCcwTools('all')">All</button>
419463
<button class="text-muted-foreground hover:underline" onclick="selectCcwTools('none')">None</button>
420464
</div>
465+
<!-- Path Settings -->
466+
<div class="ccw-path-settings mt-3 pt-3 border-t border-border/50">
467+
<div class="flex items-center gap-2 mb-2">
468+
<i data-lucide="folder-root" class="w-4 h-4 text-muted-foreground"></i>
469+
<span class="text-xs font-medium text-muted-foreground">${t('mcp.pathSettings')}</span>
470+
</div>
471+
<div class="grid grid-cols-1 gap-2">
472+
<div class="flex items-center gap-2">
473+
<label class="text-xs text-muted-foreground w-28 shrink-0">CCW_PROJECT_ROOT</label>
474+
<input type="text"
475+
class="ccw-project-root-input flex-1 px-2 py-1 text-xs bg-background border border-border rounded focus:outline-none focus:ring-1 focus:ring-primary"
476+
placeholder="${projectPath || t('mcp.useCurrentDir')}"
477+
value="${getCcwProjectRoot()}">
478+
<button class="p-1 text-muted-foreground hover:text-foreground"
479+
onclick="setCcwProjectRootToCurrent()"
480+
title="${t('mcp.useCurrentProject')}">
481+
<i data-lucide="locate-fixed" class="w-4 h-4"></i>
482+
</button>
483+
</div>
484+
<div class="flex items-center gap-2">
485+
<label class="text-xs text-muted-foreground w-28 shrink-0">CCW_ALLOWED_DIRS</label>
486+
<input type="text"
487+
class="ccw-allowed-dirs-input flex-1 px-2 py-1 text-xs bg-background border border-border rounded focus:outline-none focus:ring-1 focus:ring-primary"
488+
placeholder="${t('mcp.allowedDirsPlaceholder')}"
489+
value="${getCcwAllowedDirs()}">
490+
</div>
491+
</div>
492+
</div>
421493
</div>
422494
</div>
423495
<div class="shrink-0 flex gap-2">

ccw/src/tools/core-memory.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { getCoreMemoryStore, findMemoryAcrossProjects } from '../core/core-memor
99
import * as MemoryEmbedder from '../core/memory-embedder-bridge.js';
1010
import { StoragePaths } from '../config/storage-paths.js';
1111
import { join } from 'path';
12+
import { getProjectRoot } from '../utils/path-validator.js';
1213

1314
// Zod schemas
1415
const OperationEnum = z.enum(['list', 'import', 'export', 'summary', 'embed', 'search', 'embed_status']);
@@ -108,7 +109,7 @@ type OperationResult = ListResult | ImportResult | ExportResult | SummaryResult
108109
* Get project path from current working directory
109110
*/
110111
function getProjectPath(): string {
111-
return process.cwd();
112+
return getProjectRoot();
112113
}
113114

114115
/**

ccw/src/tools/edit-file.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { z } from 'zod';
1515
import type { ToolSchema, ToolResult } from '../types/tool.js';
1616
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
1717
import { resolve, isAbsolute, dirname } from 'path';
18+
import { validatePath } from '../utils/path-validator.js';
1819

1920
// Define Zod schemas for validation
2021
const EditItemSchema = z.object({
@@ -71,8 +72,8 @@ interface LineModeResult {
7172
* @param filePath - Path to file
7273
* @returns Resolved path and content
7374
*/
74-
function readFile(filePath: string): { resolvedPath: string; content: string } {
75-
const resolvedPath = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
75+
async function readFile(filePath: string): Promise<{ resolvedPath: string; content: string }> {
76+
const resolvedPath = await validatePath(filePath, { mustExist: true });
7677

7778
if (!existsSync(resolvedPath)) {
7879
throw new Error(`File not found: ${resolvedPath}`);
@@ -524,7 +525,7 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
524525
const { path: filePath, mode = 'update', dryRun = false } = parsed.data;
525526

526527
try {
527-
const { resolvedPath, content } = readFile(filePath);
528+
const { resolvedPath, content } = await readFile(filePath);
528529

529530
let result: UpdateModeResult | LineModeResult;
530531
switch (mode) {

ccw/src/tools/read-file.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { z } from 'zod';
1313
import type { ToolSchema, ToolResult } from '../types/tool.js';
1414
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
1515
import { resolve, isAbsolute, join, relative, extname } from 'path';
16+
import { validatePath, getProjectRoot } from '../utils/path-validator.js';
1617

1718
// Max content per file (truncate if larger)
1819
const MAX_CONTENT_LENGTH = 5000;
@@ -233,7 +234,7 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
233234
maxFiles,
234235
} = parsed.data;
235236

236-
const cwd = process.cwd();
237+
const cwd = getProjectRoot();
237238

238239
// Normalize paths to array
239240
const inputPaths = Array.isArray(paths) ? paths : [paths];
@@ -242,7 +243,7 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
242243
const allFiles: string[] = [];
243244

244245
for (const inputPath of inputPaths) {
245-
const resolvedPath = isAbsolute(inputPath) ? inputPath : resolve(cwd, inputPath);
246+
const resolvedPath = await validatePath(inputPath);
246247

247248
if (!existsSync(resolvedPath)) {
248249
continue; // Skip non-existent paths

ccw/src/tools/smart-search.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
executeCodexLens,
2323
} from './codex-lens.js';
2424
import type { ProgressInfo } from './codex-lens.js';
25+
import { getProjectRoot } from '../utils/path-validator.js';
2526

2627
// Define Zod schema for validation
2728
const ParamsSchema = z.object({
@@ -659,7 +660,7 @@ async function executeRipgrepMode(params: Params): Promise<SearchResult> {
659660

660661
return new Promise((resolve) => {
661662
const child = spawn(command, args, {
662-
cwd: path || process.cwd(),
663+
cwd: path || getProjectRoot(),
663664
stdio: ['ignore', 'pipe', 'pipe'],
664665
});
665666

@@ -1518,7 +1519,7 @@ async function executeFindFilesAction(params: Params): Promise<SearchResult> {
15181519
}
15191520

15201521
const child = spawn('rg', args, {
1521-
cwd: path || process.cwd(),
1522+
cwd: path || getProjectRoot(),
15221523
stdio: ['ignore', 'pipe', 'pipe'],
15231524
});
15241525

0 commit comments

Comments
 (0)