功能增强: - System Prompt 注入当前工作空间路径到动态区(LLM 可使用绝对路径调用工具) - ContextBuilder.buildSystemPrompt 新增 workspacePath 参数 Provider 切换修复: - reloadAdapter 返回 boolean,失败时 sendMessage 中止发送 - 切换 Provider 时自动清空 apiKey(不同 Provider key 不通用) - apiKey 为空时显示警告提示 - Provider 切换事件加 sessionId 字段 编码修复: - run_command 使用 encoding: 'buffer' 获取原始字节 - 新增 decodeBuffer 智能解码:UTF-8 严格 → GBK → UTF-8 宽松 - 修复不响应 chcp 的 Windows 命令中文乱码问题 样式优化: - 代码块底色改用主题变量(灰底主题色文字,双主题适配) - 表格标题 th/td 底色和文字色改用主题变量 - CodeBlock 组件 pre 背景从 secondary.main 改为 background.default - 设置面板已自动执行工具行 opacity 0.8→0.15(淡绿底,文字清晰) 版本号 0.2.1 → 0.2.2
222 lines
9.6 KiB
TypeScript
222 lines
9.6 KiB
TypeScript
/**
|
||
* 命令工具(1 个)
|
||
*
|
||
* run_command — 在沙箱环境中执行 Shell 命令
|
||
*
|
||
* 安全增强(v0.2.0):
|
||
* 1. 接入 SandboxManager.scanCode 进行静态代码安全扫描
|
||
* 2. 通过 SandboxManager.validatePath 校验工作目录
|
||
* 3. 命令注入模式检测扩展
|
||
*
|
||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
|
||
* @see standard/开发规范.md — 使用 shell-quote 解析命令(禁止简单字符串匹配)
|
||
*/
|
||
|
||
import { exec } from 'child_process';
|
||
import { promisify } from 'util';
|
||
import { resolve } from 'path';
|
||
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);
|
||
|
||
// ===== 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. 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<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
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';
|
||
const finalCommand = isWindows
|
||
? `chcp 65001 >nul 2>&1 && ${command}`
|
||
: command;
|
||
|
||
// 使用 encoding: 'buffer' 获取原始字节,手动智能解码
|
||
// 部分程序(如 dir、systeminfo)不响应 chcp 65001,仍输出 GBK 字节流
|
||
const { stdout, stderr } = await execAsync(finalCommand, {
|
||
cwd: resolvedWorkdir,
|
||
timeout,
|
||
maxBuffer: 1024 * 1024, // 1MB
|
||
encoding: 'buffer', // 返回 Buffer 而非字符串,便于智能解码
|
||
env: {
|
||
...process.env,
|
||
NODE_ENV: 'production',
|
||
...(isWindows ? {
|
||
// 强制常见程序使用 UTF-8 输出
|
||
PYTHONIOENCODING: 'utf-8',
|
||
LANG: 'zh_CN.UTF-8',
|
||
LC_ALL: 'zh_CN.UTF-8',
|
||
} : {}),
|
||
},
|
||
});
|
||
|
||
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,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 命令安全校验
|
||
*
|
||
* 使用模式匹配检查危险命令。
|
||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — run_command 安全规则
|
||
*/
|
||
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' };
|
||
}
|
||
|
||
// 硬阻止列表(绝对禁止执行)
|
||
// v0.2.0: 扩展危险命令检测模式
|
||
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 };
|
||
}
|
||
}
|