/** * 命令工具(1 个) * * run_command — 在沙箱环境中执行 Shell 命令 * * 安全增强(v0.2.0): * 1. 接入 SandboxManager.scanCode 进行静态代码安全扫描 * 2. 通过 SandboxManager.validatePath 校验工作目录 * 3. 命令注入模式检测扩展 * * C-4 修复(v0.3.1):双层命令注入防御 * 1. 主层 — 使用 shell-quote 解析命令为 token 数组,对每个 token 做模式匹配 * 2. 补充层 — 保留原正则检测作为 fallback,覆盖 Windows cmd 语法 * * @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 + run_command 安全规则 */ import { exec, execFile } from 'child_process'; import { promisify } from 'util'; import { resolve } from 'path'; import { parse as shellQuoteParse } from 'shell-quote'; import log from 'electron-log'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard'; import type { SandboxManager } from '../../sandbox/sandbox'; const execAsync = promisify(exec); const execFileAsync = promisify(execFile); // ===== Windows 编码智能解码 ===== // 某些 Windows 程序(dir、systeminfo、ipconfig 等)不响应 chcp 65001, // 仍按系统代码页(GBK/CP936)输出字节流。 // 策略:先尝试严格 UTF-8 解码,失败则用 GBK 解码,最后降级为 UTF-8 宽松模式。 function decodeBuffer(buf: Buffer): string { if (buf.length === 0) return ''; try { // 严格模式:包含非法 UTF-8 字节序列时抛错 return new TextDecoder('utf-8', { fatal: true }).decode(buf); } catch { // UTF-8 解码失败 → 用 GBK/CP936 解码(Windows 中文系统默认代码页) try { return new TextDecoder('gbk').decode(buf); } catch { // 最终降级:UTF-8 宽松模式(用 替换字符 代替非法字节) return buf.toString('utf-8'); } } } /** * #9 修复 + 审查修复: 构建安全的子进程环境变量 * * 审查修复: 原白名单方案遗漏了 GIT_*/PYTHONPATH/HTTP_PROXY 等常用变量,导致子进程功能破坏。 * 改为黑名单方案:剔除包含敏感后缀的变量,保留其余。 */ function buildSafeCommandEnv(isWindows: boolean): Record { // 敏感变量后缀黑名单 const SENSITIVE_SUFFIXES = [ '_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_PASSWD', '_CREDENTIAL', '_CREDENTIALS', '_PRIVATE_KEY', ]; // 敏感变量名黑名单(精确匹配) const SENSITIVE_KEYS = new Set([ 'DEEPSEEK_API_KEY', 'AGNES_API_KEY', 'MIMO_API_KEY', 'GITEA_PASSWORD', 'DATABASE_PASSWORD', ]); const env: Record = {}; for (const [key, val] of Object.entries(process.env)) { if (!val) continue; if (SENSITIVE_KEYS.has(key)) continue; if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue; env[key] = val; } // 添加必要的运行时变量 env.NODE_ENV = 'production'; if (isWindows) { env.PYTHONIOENCODING = 'utf-8'; env.LANG = 'zh_CN.UTF-8'; env.LC_ALL = 'zh_CN.UTF-8'; } return env; } // ===== 9. run_command ===== export class RunCommandTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'run_command', description: 'Execute a shell command in a sandboxed environment. Commands run in the workspace directory. High-risk commands require user confirmation. Passes through SandboxManager static code scan and path validation.', parameters: { type: 'object', properties: { command: { type: 'string', description: 'Shell command to execute' }, workdir: { type: 'string', description: 'Working directory (default: workspace root)' }, timeout: { type: 'number', description: 'Timeout in milliseconds (default 120000)' }, }, required: ['command'], }, category: MetonaToolCategory.CODE_EXECUTION, riskLevel: MetonaRiskLevel.HIGH, requiresPermission: true, timeoutMs: 120_000, }; /** v0.2.0: 可选注入 SandboxManager 进行双重安全校验 */ private sandboxManager: SandboxManager | null = null; /** 注入 SandboxManager(由 main.ts 在注册时调用) */ setSandboxManager(manager: SandboxManager): void { this.sandboxManager = manager; } async execute(args: Record, context: ToolExecutionContext): Promise { const command = args.command as string; const workdir = (args.workdir as string) ?? context.workspacePath; const timeout = Math.min(300_000, Math.max(1_000, (args.timeout as number) ?? 120_000)); // 安全校验:workdir 必须在工作空间内 const resolvedWorkdir = resolve(context.workspacePath, workdir); if (!isPathWithinWorkspace(workdir, context.workspacePath)) { return { success: false, error: `Working directory must be within workspace: ${workdir}`, command }; } // v0.2.0: SandboxManager 双重安全校验 — fail-closed 设计 // 若 SandboxManager 未注入(配置错误),拒绝执行高风险命令 if (!this.sandboxManager) { log.error('[RunCommandTool] SandboxManager not injected — refusing to execute (fail-closed)'); return { success: false, error: 'Security sandbox unavailable — command execution disabled', command, }; } // SandboxManager 静态代码扫描 const scanResult = this.sandboxManager.scanCode(command); if (!scanResult.safe) { return { success: false, error: 'Command blocked by security policy', command }; } // SandboxManager 路径校验 const pathResult = this.sandboxManager.validatePath(resolvedWorkdir); if (!pathResult.allowed) { return { success: false, error: 'Command blocked by security policy', command }; } // 命令安全校验(内置硬阻止列表) const validation = this.validateCommand(command); if (!validation.allowed) { return { success: false, error: validation.reason, command }; } try { // Windows 中文编码修复:通过 chcp 65001 切换控制台到 UTF-8 代码页 // 避免 GBK/CP936 输出被 Node.js 按 UTF-8 解码导致中文乱码 const isWindows = process.platform === 'win32'; // #8 修复: 优先使用 execFile(不经过 shell,从根本上防止命令注入) // 仅当命令为简单命令(无管道、重定向、&& 等 shell 运算符)时使用 execFile // 复杂命令(含 shell 语法)降级到 exec(已有 SandboxManager + validateCommand 双层校验) const simpleCmd = this.parseCommandSimple(command); // 使用 encoding: 'buffer' 获取原始字节,手动智能解码 // 部分程序(如 dir、systeminfo)不响应 chcp 65001,仍输出 GBK 字节流 // #9 修复: 不透传完整 process.env,仅保留子进程运行所需的最小环境变量集合 // 防止 API keys / tokens / passwords 通过环境变量泄露给子进程 const execEnv = buildSafeCommandEnv(isWindows); const execOpts = { cwd: resolvedWorkdir, timeout, maxBuffer: 1024 * 1024, // 1MB encoding: 'buffer' as const, // 返回 Buffer 而非字符串,便于智能解码 env: execEnv, }; let stdout: Buffer; let stderr: Buffer; // #8 修复 + 审查修复: 简单命令使用 execFile(不经过 shell,防止命令注入) // 但 Windows 上 npm/npx/yarn/pnpm/tsc 等是 .cmd 批处理,execFile 无法执行(ENOENT) // 因此 Windows 上仍用 exec(已有 SandboxManager.scanCode + validateCommand 双层校验) // 非 Windows 上对简单命令用 execFile if (simpleCmd && !isWindows) { const result = await execFileAsync(simpleCmd.command, simpleCmd.args, execOpts); stdout = result.stdout; stderr = result.stderr; } else { // 复杂命令(含管道/重定向/&& 等 shell 语法)或 Windows — 使用 exec // 已有 SandboxManager.scanCode + validateCommand 双层安全校验 const finalCommand = isWindows ? `chcp 65001 >nul 2>&1 && ${command}` : command; const result = await execAsync(finalCommand, execOpts); stdout = result.stdout; stderr = result.stderr; } return { success: true, stdout: decodeBuffer(stdout).slice(0, 50_000), stderr: decodeBuffer(stderr).slice(0, 10_000), command, }; } catch (error) { const err = error as { message: string; code?: number; stdout?: Buffer; stderr?: Buffer }; return { success: false, error: err.message, exit_code: err.code, stdout: decodeBuffer(err.stdout ?? Buffer.alloc(0)).slice(0, 10_000), stderr: decodeBuffer(err.stderr ?? Buffer.alloc(0)).slice(0, 10_000), command, }; } } /** * 命令安全校验 * * C-4 修复: 采用双层防御 * 1. 主层 — 使用 shell-quote 解析命令为 token 数组,对每个 token 做模式匹配 * 可防御所有字符串拼接绕过(如 `r"m" -rf /`、`$'rm'`、`$(echo rm)`、`r''m`) * 2. 补充层 — 保留原正则检测作为 fallback,覆盖 Windows cmd 语法和 shell-quote 无法解析的场景 * * @see docs/MetonaAI-Desktop 架构与交互设计.html — run_command 安全规则 * @see standard/开发规范.md — 使用 shell-quote 解析命令(禁止简单字符串匹配) */ private validateCommand(command: string): { allowed: boolean; reason?: string } { const cmd = command.trim().toLowerCase(); // 受保护文件检查:禁止通过命令行读写工作空间根目录的 MEMORY.md if (commandTouchesProtectedFile(command)) { return { allowed: false, reason: 'Access denied: MEMORY.md is managed by the memory system and cannot be accessed via command execution' }; } // ===== 主层: shell-quote token-level 检测 ===== // 解析失败(Windows cmd 语法等)时降级到正则补充层 const tokenBlock = this.checkTokens(command); if (tokenBlock !== null) return tokenBlock; // ===== 补充层: 原正则检测(保留所有原模式) ===== // 覆盖 shell-quote 无法解析的场景: // - Windows cmd 语法(&、|、>nul、chcp 等) // - 复杂管道序列(curl ... | sh) // - Fork bomb 等特殊语法 const hardBlocks = [ // 文件系统破坏 { pattern: /\brm\b.*\//, reason: 'rm with absolute path is forbidden' }, { pattern: /\brm\s+-rf?\s+\/(?:[^|;&\s]*\s)*?(?:bin|boot|dev|etc|lib|proc|root|sbin|sys|usr|var)\b/i, reason: 'rm on system directories is forbidden' }, { pattern: /\b(sudo|su|doas)\b/, reason: 'Privilege escalation commands are forbidden' }, // 系统控制 { pattern: /\b(shutdown|reboot|halt|poweroff)\b/, reason: 'System shutdown commands are forbidden' }, { pattern: /\b(killall|pkill)\s+-9\b/, reason: 'Force kill all processes is forbidden' }, // 远程代码执行 { pattern: /curl.*\|\s*(ba)?sh/, reason: 'Remote code execution via pipe is forbidden' }, { pattern: /wget.*\|\s*(ba)?sh/, reason: 'Remote code execution via pipe is forbidden' }, { pattern: /\bcurl\s+.*\s*-o\s+\/etc\//i, reason: 'Writing to system directories via curl is forbidden' }, // 设备文件 { pattern: /\bdd\b.*of=\/dev\//, reason: 'Writing to device files is forbidden' }, // 磁盘格式化 { pattern: /\b(mkfs|fdisk)\b/, reason: 'Disk formatting commands are forbidden' }, // 权限滥用 { pattern: /\bchmod\s+777\b/, reason: 'chmod 777 is forbidden' }, { pattern: /\bchown\s+-R\s+\S+\s+\/(?:\s|$)/i, reason: 'Recursive chown on root is forbidden' }, // 环境变量窃取 { pattern: /\b(env|export|printenv)\s*\|.*\b(curl|wget|nc|ncat)\b/i, reason: 'Exfiltrating environment variables is forbidden' }, // 反向 shell { pattern: /\b(bash|sh|zsh)\s+-i\s+>\s*&\s*\/dev\/tcp\//i, reason: 'Reverse shell via /dev/tcp is forbidden' }, { pattern: /\bnc\s+.*\s+-e\s+(bash|sh)/i, reason: 'Reverse shell via netcat is forbidden' }, // Windows 危险命令 { pattern: /\b(format|diskpart)\b/i, reason: 'Disk formatting commands are forbidden' }, { pattern: /\bshutdown\s*\//i, reason: 'System shutdown commands are forbidden' }, { pattern: /\breg\s+(add|delete|import|restore)/i, reason: 'Registry modification commands are forbidden' }, { pattern: /\b(taskkill|kill)\s*\//i, reason: 'Process termination with system flags is forbidden' }, { pattern: /\bpowershell\s+-enc\s+/i, reason: 'PowerShell encoded command execution is forbidden' }, // 后台进程与管道炸弹 { pattern: /&\s*\(/, reason: 'Background subshell execution is forbidden' }, { pattern: /\|\s*&/, reason: 'Pipe to background process is forbidden' }, { pattern: /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/, reason: 'Fork bomb is forbidden' }, ]; for (const block of hardBlocks) { if (block.pattern.test(cmd)) { return { allowed: false, reason: block.reason }; } } return { allowed: true }; } /** * C-4 修复: shell-quote token-level 安全检测 * * 将命令解析为 token 数组,提取所有命令名和参数(忽略 shell 运算符), * 对每个 token 做精确匹配。可防御所有字符串拼接绕过: * - `r"m" -rf /` → 解析为 ['rm', '-rf', '/'] → 命中 rm 检测 * - `$'rm'` → 解析为 ['rm'] → 命中 rm 检测 * - `$(echo rm) -rf /` → 命令替换会被 shell-quote 识别为运算符序列 * * @returns null 表示通过检测;非 null 表示被阻止(含 reason) */ private checkTokens(command: string): { allowed: boolean; reason?: string } | null { let tokens: ReturnType; try { tokens = shellQuoteParse(command); } catch { // 解析失败(Windows cmd 语法、不完整的引号等)— 降级到正则补充层 return null; } // 提取所有 word token(命令名和参数),忽略运算符(&&、|、; 等) // 同时跟踪管道运算符,检测危险组合(如 `| sh`) const words: string[] = []; let prevWasPipe = false; for (const entry of tokens) { if (typeof entry === 'string') { // 裸字符串 token — 若前一 token 是管道,检测是否为 sh/bash(远程代码执行) if (prevWasPipe && /^(ba)?sh$/i.test(entry)) { return { allowed: false, reason: 'Remote code execution via pipe is forbidden' }; } words.push(entry); prevWasPipe = false; } else if (typeof entry === 'object' && entry !== null) { const obj = entry as { word?: unknown; op?: unknown }; if (typeof obj.word === 'string') { // 引号包裹或变量替换后的 token if (prevWasPipe && /^(ba)?sh$/i.test(obj.word)) { return { allowed: false, reason: 'Remote code execution via pipe is forbidden' }; } words.push(obj.word); prevWasPipe = false; } else if (typeof obj.op === 'string') { // 跟踪管道运算符,用于下一轮检测 `| sh` prevWasPipe = (obj.op === '|'); } } } // 危险命令名 token(精确匹配,大小写不敏感) const dangerousCommands = new Set([ 'sudo', 'su', 'doas', 'shutdown', 'reboot', 'halt', 'poweroff', 'mkfs', 'fdisk', 'format', 'diskpart', ]); // 危险参数 token const dangerousArgs = new Set([ '-enc', '-encodedcommand', // PowerShell 编码执行 ]); for (const word of words) { const lower = word.toLowerCase(); // 1. 危险命令名(精确匹配,或 mkfs/fdisk 前缀匹配如 mkfs.ext4) if (dangerousCommands.has(lower) || /^(mkfs|fdisk)\b/.test(lower)) { if (['sudo', 'su', 'doas'].includes(lower)) { return { allowed: false, reason: 'Privilege escalation commands are forbidden' }; } if (['shutdown', 'reboot', 'halt', 'poweroff'].includes(lower)) { return { allowed: false, reason: 'System shutdown commands are forbidden' }; } // mkfs/fdisk/format/diskpart + mkfs.ext4 等前缀匹配 return { allowed: false, reason: 'Disk formatting commands are forbidden' }; } // 2. 危险参数(精确匹配) if (dangerousArgs.has(lower)) { return { allowed: false, reason: 'PowerShell encoded command execution is forbidden' }; } // 3. dd 写设备文件:of=/dev/...(不依赖系统目录前置,独立检测) if (/^of=\/dev\//.test(lower)) { return { allowed: false, reason: 'Writing to device files is forbidden' }; } } // 4. 检测 rm 与系统目录的组合(token 序列检测) // 例如: ['rm', '-rf', '/etc'] 应被拦截 let hasRm = false; let hasSystemPath = false; for (const word of words) { const lower = word.toLowerCase(); if (lower === 'rm') hasRm = true; if (/^\/(?:bin|boot|dev|etc|lib|proc|root|sbin|sys|usr|var)\b/.test(lower)) { hasSystemPath = true; } } if (hasRm && hasSystemPath) { return { allowed: false, reason: 'rm on system directories is forbidden' }; } return null; } /** * #8 修复: 将命令解析为简单的 command + args 数组 * * 使用 shell-quote 解析命令,如果命令只包含 word tokens(无管道、重定向、&& 等 shell 运算符), * 返回 { command, args } 供 execFile 使用(不经过 shell,从根本上防止命令注入)。 * 如果包含 shell 运算符或解析失败,返回 null,调用方降级到 exec(已有安全校验)。 * * @returns 简单命令的 { command, args },或 null 表示复杂命令 */ private parseCommandSimple(command: string): { command: string; args: string[] } | null { let tokens: ReturnType; try { tokens = shellQuoteParse(command); } catch { // 解析失败(Windows cmd 语法、不完整引号等)— 降级到 exec return null; } const words: string[] = []; for (const entry of tokens) { if (typeof entry === 'string') { words.push(entry); } else if (typeof entry === 'object' && entry !== null) { const obj = entry as { word?: unknown; op?: unknown }; if (typeof obj.word === 'string') { words.push(obj.word); } else if (typeof obj.op === 'string') { // 遇到 shell 运算符(&&、|、;、> 等)— 复杂命令,降级到 exec return null; } } } if (words.length === 0) return null; return { command: words[0], args: words.slice(1) }; } }