mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-14 02:42:04 +08:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -12,10 +12,15 @@ import {
|
|||||||
} from '@modelcontextprotocol/sdk/types.js';
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { getAllToolSchemas, executeTool, executeToolWithProgress } from '../tools/index.js';
|
import { getAllToolSchemas, executeTool, executeToolWithProgress } from '../tools/index.js';
|
||||||
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
||||||
|
import { getProjectRoot, getAllowedDirectories } from '../utils/path-validator.js';
|
||||||
|
|
||||||
const SERVER_NAME = 'ccw-tools';
|
const SERVER_NAME = 'ccw-tools';
|
||||||
const SERVER_VERSION = '6.2.0';
|
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)
|
// Default enabled tools (core set)
|
||||||
const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'smart_search', 'core_memory'];
|
const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'smart_search', 'core_memory'];
|
||||||
|
|
||||||
@@ -162,7 +167,15 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Log server start (to stderr to not interfere with stdio protocol)
|
// 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(`${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
|
// Run server
|
||||||
|
|||||||
@@ -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
|
// Build CCW Tools config with selected tools
|
||||||
// Uses isWindowsPlatform from state.js to generate platform-appropriate commands
|
// 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
|
// Windows requires 'cmd /c' wrapper to execute npx
|
||||||
// Other platforms (macOS, Linux) can run npx directly
|
// Other platforms (macOS, Linux) can run npx directly
|
||||||
const config = isWindowsPlatform
|
const config = isWindowsPlatform
|
||||||
@@ -948,12 +967,30 @@ function buildCcwToolsConfig(selectedTools) {
|
|||||||
coreTools.every(t => selectedTools.includes(t)) &&
|
coreTools.every(t => selectedTools.includes(t)) &&
|
||||||
selectedTools.every(t => coreTools.includes(t));
|
selectedTools.every(t => coreTools.includes(t));
|
||||||
|
|
||||||
|
// Initialize env if needed
|
||||||
if (selectedTools.length === 15) {
|
if (selectedTools.length === 15) {
|
||||||
config.env = { CCW_ENABLED_TOOLS: 'all' };
|
config.env = { CCW_ENABLED_TOOLS: 'all' };
|
||||||
} else if (!isDefault && selectedTools.length > 0) {
|
} else if (!isDefault && selectedTools.length > 0) {
|
||||||
config.env = { CCW_ENABLED_TOOLS: selectedTools.join(',') };
|
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;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -965,7 +1002,8 @@ async function installCcwToolsMcp(scope = 'workspace') {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ccwToolsConfig = buildCcwToolsConfig(selectedTools);
|
const pathConfig = getCcwPathConfig();
|
||||||
|
const ccwToolsConfig = buildCcwToolsConfig(selectedTools, pathConfig);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const scopeLabel = scope === 'global' ? 'globally' : 'to workspace';
|
const scopeLabel = scope === 'global' ? 'globally' : 'to workspace';
|
||||||
@@ -1032,7 +1070,8 @@ async function updateCcwToolsMcp(scope = 'workspace') {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ccwToolsConfig = buildCcwToolsConfig(selectedTools);
|
const pathConfig = getCcwPathConfig();
|
||||||
|
const ccwToolsConfig = buildCcwToolsConfig(selectedTools, pathConfig);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const scopeLabel = scope === 'global' ? 'globally' : 'in workspace';
|
const scopeLabel = scope === 'global' ? 'globally' : 'in workspace';
|
||||||
@@ -1126,7 +1165,8 @@ async function installCcwToolsMcpToCodex() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ccwToolsConfig = buildCcwToolsConfig(selectedTools);
|
const pathConfig = getCcwPathConfig();
|
||||||
|
const ccwToolsConfig = buildCcwToolsConfig(selectedTools, pathConfig);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isUpdate = codexMcpServers && codexMcpServers['ccw-tools'];
|
const isUpdate = codexMcpServers && codexMcpServers['ccw-tools'];
|
||||||
@@ -1176,3 +1216,4 @@ window.openMcpCreateModal = openMcpCreateModal;
|
|||||||
window.toggleProjectConfigType = toggleProjectConfigType;
|
window.toggleProjectConfigType = toggleProjectConfigType;
|
||||||
window.getPreferredProjectConfigType = getPreferredProjectConfigType;
|
window.getPreferredProjectConfigType = getPreferredProjectConfigType;
|
||||||
window.setPreferredProjectConfigType = setPreferredProjectConfigType;
|
window.setPreferredProjectConfigType = setPreferredProjectConfigType;
|
||||||
|
window.setCcwProjectRootToCurrent = setCcwProjectRootToCurrent;
|
||||||
|
|||||||
@@ -290,6 +290,8 @@ const i18n = {
|
|||||||
'codexlens.modelDeleted': 'Model deleted',
|
'codexlens.modelDeleted': 'Model deleted',
|
||||||
'codexlens.modelDeleteFailed': 'Model deletion failed',
|
'codexlens.modelDeleteFailed': 'Model deletion failed',
|
||||||
'codexlens.deleteModelConfirm': 'Are you sure you want to delete model',
|
'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 Progress
|
||||||
'codexlens.indexing': 'Indexing',
|
'codexlens.indexing': 'Indexing',
|
||||||
@@ -609,6 +611,12 @@ const i18n = {
|
|||||||
'mcp.claudeMode': 'Claude Mode',
|
'mcp.claudeMode': 'Claude Mode',
|
||||||
'mcp.codexMode': 'Codex 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
|
// Codex MCP
|
||||||
'mcp.codex.globalServers': 'Codex Global MCP Servers',
|
'mcp.codex.globalServers': 'Codex Global MCP Servers',
|
||||||
'mcp.codex.newServer': 'New Server',
|
'mcp.codex.newServer': 'New Server',
|
||||||
@@ -1617,6 +1625,8 @@ const i18n = {
|
|||||||
'codexlens.modelDeleted': '模型已删除',
|
'codexlens.modelDeleted': '模型已删除',
|
||||||
'codexlens.modelDeleteFailed': '模型删除失败',
|
'codexlens.modelDeleteFailed': '模型删除失败',
|
||||||
'codexlens.deleteModelConfirm': '确定要删除模型',
|
'codexlens.deleteModelConfirm': '确定要删除模型',
|
||||||
|
'codexlens.modelListError': '加载模型列表失败',
|
||||||
|
'codexlens.noModelsAvailable': '没有可用模型',
|
||||||
|
|
||||||
// CodexLens 索引进度
|
// CodexLens 索引进度
|
||||||
'codexlens.indexing': '索引中',
|
'codexlens.indexing': '索引中',
|
||||||
@@ -1914,6 +1924,12 @@ const i18n = {
|
|||||||
'mcp.claudeMode': 'Claude 模式',
|
'mcp.claudeMode': 'Claude 模式',
|
||||||
'mcp.codexMode': 'Codex 模式',
|
'mcp.codexMode': 'Codex 模式',
|
||||||
|
|
||||||
|
// CCW Tools Path Settings
|
||||||
|
'mcp.pathSettings': '路径设置',
|
||||||
|
'mcp.useCurrentDir': '使用当前目录',
|
||||||
|
'mcp.useCurrentProject': '使用当前项目',
|
||||||
|
'mcp.allowedDirsPlaceholder': '逗号分隔的路径列表(可选)',
|
||||||
|
|
||||||
// Codex MCP
|
// Codex MCP
|
||||||
'mcp.codex.globalServers': 'Codex 全局 MCP 服务器',
|
'mcp.codex.globalServers': 'Codex 全局 MCP 服务器',
|
||||||
'mcp.codex.newServer': '新建服务器',
|
'mcp.codex.newServer': '新建服务器',
|
||||||
|
|||||||
@@ -415,9 +415,23 @@ async function loadModelList() {
|
|||||||
var response = await fetch('/api/codexlens/models');
|
var response = await fetch('/api/codexlens/models');
|
||||||
var result = await response.json();
|
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 =
|
||||||
|
'<div class="text-sm text-muted-foreground">' + t('codexlens.semanticNotInstalled') + '</div>';
|
||||||
|
} else {
|
||||||
|
// Show actual error message for other failures
|
||||||
|
container.innerHTML =
|
||||||
|
'<div class="text-sm text-error">' + t('codexlens.modelListError') + ': ' + (errorMsg || t('common.unknownError')) + '</div>';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.result || !result.result.models) {
|
||||||
container.innerHTML =
|
container.innerHTML =
|
||||||
'<div class="text-sm text-muted-foreground">' + t('codexlens.semanticNotInstalled') + '</div>';
|
'<div class="text-sm text-muted-foreground">' + t('codexlens.noModelsAvailable') + '</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,22 @@ function getCcwEnabledToolsCodex() {
|
|||||||
return CCW_MCP_TOOLS.filter(t => t.core).map(t => t.name);
|
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() {
|
async function renderMcpManager() {
|
||||||
const container = document.getElementById('mainContent');
|
const container = document.getElementById('mainContent');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -232,6 +248,34 @@ async function renderMcpManager() {
|
|||||||
<button class="text-primary hover:underline" onclick="selectCcwToolsCodex('all')">All</button>
|
<button class="text-primary hover:underline" onclick="selectCcwToolsCodex('all')">All</button>
|
||||||
<button class="text-muted-foreground hover:underline" onclick="selectCcwToolsCodex('none')">None</button>
|
<button class="text-muted-foreground hover:underline" onclick="selectCcwToolsCodex('none')">None</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Path Settings -->
|
||||||
|
<div class="ccw-path-settings mt-3 pt-3 border-t border-border/50">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<i data-lucide="folder-root" class="w-4 h-4 text-muted-foreground"></i>
|
||||||
|
<span class="text-xs font-medium text-muted-foreground">${t('mcp.pathSettings')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-xs text-muted-foreground w-28 shrink-0">CCW_PROJECT_ROOT</label>
|
||||||
|
<input type="text"
|
||||||
|
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"
|
||||||
|
placeholder="${projectPath || t('mcp.useCurrentDir')}"
|
||||||
|
value="${getCcwProjectRoot()}">
|
||||||
|
<button class="p-1 text-muted-foreground hover:text-foreground"
|
||||||
|
onclick="setCcwProjectRootToCurrent()"
|
||||||
|
title="${t('mcp.useCurrentProject')}">
|
||||||
|
<i data-lucide="locate-fixed" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-xs text-muted-foreground w-28 shrink-0">CCW_ALLOWED_DIRS</label>
|
||||||
|
<input type="text"
|
||||||
|
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"
|
||||||
|
placeholder="${t('mcp.allowedDirsPlaceholder')}"
|
||||||
|
value="${getCcwAllowedDirs()}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
@@ -418,6 +462,34 @@ async function renderMcpManager() {
|
|||||||
<button class="text-primary hover:underline" onclick="selectCcwTools('all')">All</button>
|
<button class="text-primary hover:underline" onclick="selectCcwTools('all')">All</button>
|
||||||
<button class="text-muted-foreground hover:underline" onclick="selectCcwTools('none')">None</button>
|
<button class="text-muted-foreground hover:underline" onclick="selectCcwTools('none')">None</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Path Settings -->
|
||||||
|
<div class="ccw-path-settings mt-3 pt-3 border-t border-border/50">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<i data-lucide="folder-root" class="w-4 h-4 text-muted-foreground"></i>
|
||||||
|
<span class="text-xs font-medium text-muted-foreground">${t('mcp.pathSettings')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-xs text-muted-foreground w-28 shrink-0">CCW_PROJECT_ROOT</label>
|
||||||
|
<input type="text"
|
||||||
|
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"
|
||||||
|
placeholder="${projectPath || t('mcp.useCurrentDir')}"
|
||||||
|
value="${getCcwProjectRoot()}">
|
||||||
|
<button class="p-1 text-muted-foreground hover:text-foreground"
|
||||||
|
onclick="setCcwProjectRootToCurrent()"
|
||||||
|
title="${t('mcp.useCurrentProject')}">
|
||||||
|
<i data-lucide="locate-fixed" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-xs text-muted-foreground w-28 shrink-0">CCW_ALLOWED_DIRS</label>
|
||||||
|
<input type="text"
|
||||||
|
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"
|
||||||
|
placeholder="${t('mcp.allowedDirsPlaceholder')}"
|
||||||
|
value="${getCcwAllowedDirs()}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0 flex gap-2">
|
<div class="shrink-0 flex gap-2">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getCoreMemoryStore, findMemoryAcrossProjects } from '../core/core-memor
|
|||||||
import * as MemoryEmbedder from '../core/memory-embedder-bridge.js';
|
import * as MemoryEmbedder from '../core/memory-embedder-bridge.js';
|
||||||
import { StoragePaths } from '../config/storage-paths.js';
|
import { StoragePaths } from '../config/storage-paths.js';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { getProjectRoot } from '../utils/path-validator.js';
|
||||||
|
|
||||||
// Zod schemas
|
// Zod schemas
|
||||||
const OperationEnum = z.enum(['list', 'import', 'export', 'summary', 'embed', 'search', 'embed_status']);
|
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
|
* Get project path from current working directory
|
||||||
*/
|
*/
|
||||||
function getProjectPath(): string {
|
function getProjectPath(): string {
|
||||||
return process.cwd();
|
return getProjectRoot();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { z } from 'zod';
|
|||||||
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||||
import { resolve, isAbsolute, dirname } from 'path';
|
import { resolve, isAbsolute, dirname } from 'path';
|
||||||
|
import { validatePath } from '../utils/path-validator.js';
|
||||||
|
|
||||||
// Define Zod schemas for validation
|
// Define Zod schemas for validation
|
||||||
const EditItemSchema = z.object({
|
const EditItemSchema = z.object({
|
||||||
@@ -71,8 +72,8 @@ interface LineModeResult {
|
|||||||
* @param filePath - Path to file
|
* @param filePath - Path to file
|
||||||
* @returns Resolved path and content
|
* @returns Resolved path and content
|
||||||
*/
|
*/
|
||||||
function readFile(filePath: string): { resolvedPath: string; content: string } {
|
async function readFile(filePath: string): Promise<{ resolvedPath: string; content: string }> {
|
||||||
const resolvedPath = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
|
const resolvedPath = await validatePath(filePath, { mustExist: true });
|
||||||
|
|
||||||
if (!existsSync(resolvedPath)) {
|
if (!existsSync(resolvedPath)) {
|
||||||
throw new Error(`File not found: ${resolvedPath}`);
|
throw new Error(`File not found: ${resolvedPath}`);
|
||||||
@@ -524,7 +525,7 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
|
|||||||
const { path: filePath, mode = 'update', dryRun = false } = parsed.data;
|
const { path: filePath, mode = 'update', dryRun = false } = parsed.data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { resolvedPath, content } = readFile(filePath);
|
const { resolvedPath, content } = await readFile(filePath);
|
||||||
|
|
||||||
let result: UpdateModeResult | LineModeResult;
|
let result: UpdateModeResult | LineModeResult;
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { z } from 'zod';
|
|||||||
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
||||||
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
|
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
|
||||||
import { resolve, isAbsolute, join, relative, extname } from 'path';
|
import { resolve, isAbsolute, join, relative, extname } from 'path';
|
||||||
|
import { validatePath, getProjectRoot } from '../utils/path-validator.js';
|
||||||
|
|
||||||
// Max content per file (truncate if larger)
|
// Max content per file (truncate if larger)
|
||||||
const MAX_CONTENT_LENGTH = 5000;
|
const MAX_CONTENT_LENGTH = 5000;
|
||||||
@@ -233,7 +234,7 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
|
|||||||
maxFiles,
|
maxFiles,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
|
|
||||||
const cwd = process.cwd();
|
const cwd = getProjectRoot();
|
||||||
|
|
||||||
// Normalize paths to array
|
// Normalize paths to array
|
||||||
const inputPaths = Array.isArray(paths) ? paths : [paths];
|
const inputPaths = Array.isArray(paths) ? paths : [paths];
|
||||||
@@ -242,7 +243,7 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
|
|||||||
const allFiles: string[] = [];
|
const allFiles: string[] = [];
|
||||||
|
|
||||||
for (const inputPath of inputPaths) {
|
for (const inputPath of inputPaths) {
|
||||||
const resolvedPath = isAbsolute(inputPath) ? inputPath : resolve(cwd, inputPath);
|
const resolvedPath = await validatePath(inputPath);
|
||||||
|
|
||||||
if (!existsSync(resolvedPath)) {
|
if (!existsSync(resolvedPath)) {
|
||||||
continue; // Skip non-existent paths
|
continue; // Skip non-existent paths
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
executeCodexLens,
|
executeCodexLens,
|
||||||
} from './codex-lens.js';
|
} from './codex-lens.js';
|
||||||
import type { ProgressInfo } from './codex-lens.js';
|
import type { ProgressInfo } from './codex-lens.js';
|
||||||
|
import { getProjectRoot } from '../utils/path-validator.js';
|
||||||
|
|
||||||
// Define Zod schema for validation
|
// Define Zod schema for validation
|
||||||
const ParamsSchema = z.object({
|
const ParamsSchema = z.object({
|
||||||
@@ -659,7 +660,7 @@ async function executeRipgrepMode(params: Params): Promise<SearchResult> {
|
|||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const child = spawn(command, args, {
|
const child = spawn(command, args, {
|
||||||
cwd: path || process.cwd(),
|
cwd: path || getProjectRoot(),
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1518,7 +1519,7 @@ async function executeFindFilesAction(params: Params): Promise<SearchResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const child = spawn('rg', args, {
|
const child = spawn('rg', args, {
|
||||||
cwd: path || process.cwd(),
|
cwd: path || getProjectRoot(),
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { z } from 'zod';
|
|||||||
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
||||||
import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, statSync } from 'fs';
|
import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, statSync } from 'fs';
|
||||||
import { resolve, isAbsolute, dirname, basename } from 'path';
|
import { resolve, isAbsolute, dirname, basename } from 'path';
|
||||||
|
import { validatePath } from '../utils/path-validator.js';
|
||||||
|
|
||||||
// Define Zod schema for validation
|
// Define Zod schema for validation
|
||||||
const ParamsSchema = z.object({
|
const ParamsSchema = z.object({
|
||||||
@@ -153,8 +154,8 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
|
|||||||
encoding,
|
encoding,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
|
|
||||||
// Resolve path
|
// Validate and resolve path
|
||||||
const resolvedPath = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
|
const resolvedPath = await validatePath(filePath);
|
||||||
const fileExists = existsSync(resolvedPath);
|
const fileExists = existsSync(resolvedPath);
|
||||||
|
|
||||||
// Create parent directories if needed
|
// Create parent directories if needed
|
||||||
|
|||||||
153
ccw/src/utils/path-validator.ts
Normal file
153
ccw/src/utils/path-validator.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Centralized Path Validation Utility
|
||||||
|
*
|
||||||
|
* Provides secure path validation and resolution for MCP tools.
|
||||||
|
* Prevents path traversal attacks and ensures operations stay within allowed directories.
|
||||||
|
*
|
||||||
|
* Inspired by MCP filesystem server's security model.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolve, isAbsolute, normalize, relative } from 'path';
|
||||||
|
import { realpath, access } from 'fs/promises';
|
||||||
|
import { constants } from 'fs';
|
||||||
|
|
||||||
|
// Environment variable configuration
|
||||||
|
const ENV_PROJECT_ROOT = 'CCW_PROJECT_ROOT';
|
||||||
|
const ENV_ALLOWED_DIRS = 'CCW_ALLOWED_DIRS';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get project root directory
|
||||||
|
* Priority: CCW_PROJECT_ROOT > 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<string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user