feat(mcp): 添加 CCW_DISABLE_SANDBOX 环境变量支持禁用工作空间访问限制

- 在 path-validator.ts 中添加 isSandboxDisabled() 函数
- 修改 validatePath() 在沙箱禁用时跳过路径限制检查
- MCP server 启动日志显示沙箱状态
- /api/mcp-install-ccw API 支持 disableSandbox 参数
- Dashboard UI 添加禁用沙箱的复选框选项
- 添加中英文 i18n 翻译支持
This commit is contained in:
catlog22
2026-01-20 11:50:23 +08:00
parent eea859dd6f
commit c1d12384c3
6 changed files with 69 additions and 11 deletions

View File

@@ -1171,13 +1171,17 @@ export async function handleMcpRoutes(ctx: RouteContext): Promise<boolean> {
return { error: 'projectPath is required', status: 400 }; return { error: 'projectPath is required', status: 400 };
} }
// Check if sandbox should be disabled
const disableSandbox = body.disableSandbox === true;
// Generate CCW MCP server config // Generate CCW MCP server config
// Use cmd /c to inherit Claude Code's working directory // Use cmd /c to inherit Claude Code's working directory
const ccwMcpConfig = { const ccwMcpConfig: Record<string, any> = {
command: "cmd", command: "cmd",
args: ["/c", "npx", "-y", "ccw-mcp"], args: ["/c", "npx", "-y", "ccw-mcp"],
env: { env: {
CCW_ENABLED_TOOLS: "all" CCW_ENABLED_TOOLS: "all",
...(disableSandbox && { CCW_DISABLE_SANDBOX: "1" })
} }
}; };

View File

@@ -12,7 +12,7 @@ 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'; import { getProjectRoot, getAllowedDirectories, isSandboxDisabled } 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';
@@ -169,9 +169,14 @@ 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 projectRoot = getProjectRoot();
const allowedDirs = getAllowedDirectories(); const allowedDirs = getAllowedDirectories();
const sandboxDisabled = isSandboxDisabled();
console.error(`${SERVER_NAME} v${SERVER_VERSION} started`); console.error(`${SERVER_NAME} v${SERVER_VERSION} started`);
console.error(`Project root: ${projectRoot}`); console.error(`Project root: ${projectRoot}`);
console.error(`Allowed directories: ${allowedDirs.join(', ')}`); if (sandboxDisabled) {
console.error(`Sandbox: DISABLED (CCW_DISABLE_SANDBOX=true)`);
} else {
console.error(`Allowed directories: ${allowedDirs.join(', ')}`);
}
if (!process.env[ENV_PROJECT_ROOT]) { if (!process.env[ENV_PROJECT_ROOT]) {
console.error(`[Warning] ${ENV_PROJECT_ROOT} not set, using process.cwd()`); 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`); console.error(`[Tip] Set ${ENV_PROJECT_ROOT} in your MCP config to specify project directory`);

View File

@@ -1085,12 +1085,27 @@ function selectCcwTools(type) {
function getCcwPathConfig() { function getCcwPathConfig() {
const projectRootInput = document.querySelector('.ccw-project-root-input'); const projectRootInput = document.querySelector('.ccw-project-root-input');
const allowedDirsInput = document.querySelector('.ccw-allowed-dirs-input'); const allowedDirsInput = document.querySelector('.ccw-allowed-dirs-input');
const disableSandboxCheckbox = document.querySelector('.ccw-disable-sandbox-checkbox');
return { return {
projectRoot: projectRootInput?.value || '', projectRoot: projectRootInput?.value || '',
allowedDirs: allowedDirsInput?.value || '' allowedDirs: allowedDirsInput?.value || '',
disableSandbox: disableSandboxCheckbox?.checked || false
}; };
} }
// Get CCW_DISABLE_SANDBOX checkbox status for Claude Code mode
function getCcwDisableSandbox() {
// Check if already installed and has the setting
const ccwToolsConfig = projectMcpServers?.['ccw-tools'] || globalServers?.['ccw-tools'];
return ccwToolsConfig?.env?.CCW_DISABLE_SANDBOX === '1' || ccwToolsConfig?.env?.CCW_DISABLE_SANDBOX === 'true';
}
// Get CCW_DISABLE_SANDBOX checkbox status for Codex mode
function getCcwDisableSandboxCodex() {
const ccwToolsConfig = codexMcpServers?.['ccw-tools'];
return ccwToolsConfig?.env?.CCW_DISABLE_SANDBOX === '1' || ccwToolsConfig?.env?.CCW_DISABLE_SANDBOX === 'true';
}
// Set CCW_PROJECT_ROOT to current project path // Set CCW_PROJECT_ROOT to current project path
function setCcwProjectRootToCurrent() { function setCcwProjectRootToCurrent() {
const input = document.querySelector('.ccw-project-root-input'); const input = document.querySelector('.ccw-project-root-input');
@@ -1102,7 +1117,7 @@ function setCcwProjectRootToCurrent() {
// Build CCW Tools config with selected tools // Build CCW Tools config with selected tools
// Uses globally installed ccw-mcp command (from claude-code-workflow package) // Uses globally installed ccw-mcp command (from claude-code-workflow package)
function buildCcwToolsConfig(selectedTools, pathConfig = {}) { function buildCcwToolsConfig(selectedTools, pathConfig = {}) {
const { projectRoot, allowedDirs } = pathConfig; const { projectRoot, allowedDirs, disableSandbox } = pathConfig;
// Use globally installed ccw-mcp command directly // Use globally installed ccw-mcp command directly
// Requires: npm install -g claude-code-workflow // Requires: npm install -g claude-code-workflow
const config = { const config = {
@@ -1134,6 +1149,10 @@ function buildCcwToolsConfig(selectedTools, pathConfig = {}) {
if (allowedDirs && allowedDirs.trim()) { if (allowedDirs && allowedDirs.trim()) {
config.env.CCW_ALLOWED_DIRS = allowedDirs.trim(); config.env.CCW_ALLOWED_DIRS = allowedDirs.trim();
} }
// Add sandbox disable option
if (disableSandbox) {
config.env.CCW_DISABLE_SANDBOX = '1';
}
// Remove env object if empty // Remove env object if empty
if (config.env && Object.keys(config.env).length === 0) { if (config.env && Object.keys(config.env).length === 0) {

View File

@@ -972,6 +972,7 @@ const i18n = {
'mcp.useCurrentDir': 'Use current directory', 'mcp.useCurrentDir': 'Use current directory',
'mcp.useCurrentProject': 'Use current project', 'mcp.useCurrentProject': 'Use current project',
'mcp.allowedDirsPlaceholder': 'Comma-separated paths (optional)', 'mcp.allowedDirsPlaceholder': 'Comma-separated paths (optional)',
'mcp.disableSandboxDesc': 'Allow access to any directory',
// Codex MCP // Codex MCP
'mcp.codex.globalServers': 'Codex Global MCP Servers', 'mcp.codex.globalServers': 'Codex Global MCP Servers',
@@ -3299,6 +3300,7 @@ const i18n = {
'mcp.useCurrentDir': '使用当前目录', 'mcp.useCurrentDir': '使用当前目录',
'mcp.useCurrentProject': '使用当前项目', 'mcp.useCurrentProject': '使用当前项目',
'mcp.allowedDirsPlaceholder': '逗号分隔的路径列表(可选)', 'mcp.allowedDirsPlaceholder': '逗号分隔的路径列表(可选)',
'mcp.disableSandboxDesc': '允许访问任意目录',
// Codex MCP // Codex MCP
'mcp.codex.globalServers': 'Codex 全局 MCP 服务器', 'mcp.codex.globalServers': 'Codex 全局 MCP 服务器',

View File

@@ -298,6 +298,14 @@ async function renderMcpManager() {
placeholder="${t('mcp.allowedDirsPlaceholder')}" placeholder="${t('mcp.allowedDirsPlaceholder')}"
value="${getCcwAllowedDirsCodex()}"> value="${getCcwAllowedDirsCodex()}">
</div> </div>
<div class="flex items-center gap-2">
<label class="text-xs text-muted-foreground w-36 shrink-0">CCW_DISABLE_SANDBOX</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="ccw-disable-sandbox-checkbox-codex w-3 h-3"
${getCcwDisableSandboxCodex() ? 'checked' : ''}>
<span class="text-xs text-muted-foreground">${t('mcp.disableSandboxDesc')}</span>
</label>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -512,6 +520,14 @@ async function renderMcpManager() {
placeholder="${t('mcp.allowedDirsPlaceholder')}" placeholder="${t('mcp.allowedDirsPlaceholder')}"
value="${getCcwAllowedDirs()}"> value="${getCcwAllowedDirs()}">
</div> </div>
<div class="flex items-center gap-2">
<label class="text-xs text-muted-foreground w-36 shrink-0">CCW_DISABLE_SANDBOX</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="ccw-disable-sandbox-checkbox w-3 h-3"
${getCcwDisableSandbox() ? 'checked' : ''}>
<span class="text-xs text-muted-foreground">${t('mcp.disableSandboxDesc')}</span>
</label>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -14,6 +14,16 @@ import { constants } from 'fs';
// Environment variable configuration // Environment variable configuration
const ENV_PROJECT_ROOT = 'CCW_PROJECT_ROOT'; const ENV_PROJECT_ROOT = 'CCW_PROJECT_ROOT';
const ENV_ALLOWED_DIRS = 'CCW_ALLOWED_DIRS'; const ENV_ALLOWED_DIRS = 'CCW_ALLOWED_DIRS';
const ENV_DISABLE_SANDBOX = 'CCW_DISABLE_SANDBOX';
/**
* Check if sandbox mode is disabled
* When disabled, path validation allows access to any directory
*/
export function isSandboxDisabled(): boolean {
const value = process.env[ENV_DISABLE_SANDBOX];
return value === '1' || value?.toLowerCase() === 'true';
}
/** /**
* Get project root directory * Get project root directory
@@ -100,6 +110,7 @@ export async function validatePath(
mustExist?: boolean; mustExist?: boolean;
} = {} } = {}
): Promise<string> { ): Promise<string> {
const sandboxDisabled = isSandboxDisabled();
const allowedDirs = options.allowedDirectories || getAllowedDirectories(); const allowedDirs = options.allowedDirectories || getAllowedDirectories();
// 1. Resolve to absolute path // 1. Resolve to absolute path
@@ -108,8 +119,8 @@ export async function validatePath(
: resolve(getProjectRoot(), filePath); : resolve(getProjectRoot(), filePath);
const normalizedPath = normalizePath(absolutePath); const normalizedPath = normalizePath(absolutePath);
// 2. Initial sandbox check // 2. Initial sandbox check (skip if sandbox is disabled)
if (!isPathWithinAllowedDirectories(normalizedPath, allowedDirs)) { if (!sandboxDisabled && !isPathWithinAllowedDirectories(normalizedPath, allowedDirs)) {
throw new Error( throw new Error(
`Access denied: path "${normalizedPath}" is outside allowed directories. ` + `Access denied: path "${normalizedPath}" is outside allowed directories. ` +
`Allowed: [${allowedDirs.join(', ')}]` `Allowed: [${allowedDirs.join(', ')}]`
@@ -121,7 +132,8 @@ export async function validatePath(
const realPath = await realpath(absolutePath); const realPath = await realpath(absolutePath);
const normalizedReal = normalizePath(realPath); const normalizedReal = normalizePath(realPath);
if (!isPathWithinAllowedDirectories(normalizedReal, allowedDirs)) { // Skip sandbox check for symlink target if sandbox is disabled
if (!sandboxDisabled && !isPathWithinAllowedDirectories(normalizedReal, allowedDirs)) {
throw new Error( throw new Error(
`Access denied: symlink target "${normalizedReal}" is outside allowed directories` `Access denied: symlink target "${normalizedReal}" is outside allowed directories`
); );
@@ -135,13 +147,13 @@ export async function validatePath(
throw new Error(`File not found: ${absolutePath}`); throw new Error(`File not found: ${absolutePath}`);
} }
// Validate parent directory's real path // Validate parent directory's real path (skip if sandbox is disabled)
const parentDir = resolve(absolutePath, '..'); const parentDir = resolve(absolutePath, '..');
try { try {
const realParent = await realpath(parentDir); const realParent = await realpath(parentDir);
const normalizedParent = normalizePath(realParent); const normalizedParent = normalizePath(realParent);
if (!isPathWithinAllowedDirectories(normalizedParent, allowedDirs)) { if (!sandboxDisabled && !isPathWithinAllowedDirectories(normalizedParent, allowedDirs)) {
throw new Error( throw new Error(
`Access denied: parent directory "${normalizedParent}" is outside allowed directories` `Access denied: parent directory "${normalizedParent}" is outside allowed directories`
); );