feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
工程化(从零到一): - 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证 - 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁) - 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync - 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies 安全加固: - PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额) - ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道) - Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面 - web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级) 缺陷修复(测试驱动发现): - mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试 - 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢 - 百度复合类名重复收录:class="result c-container" 被双重匹配 测试补齐(113 → 194 用例): - 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器 - 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批 体验升级: - OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡) - SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护) - MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销) - MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
This commit is contained in:
@@ -58,13 +58,22 @@ function decodeBuffer(buf: Buffer): string {
|
||||
function buildSafeCommandEnv(isWindows: boolean): Record<string, string> {
|
||||
// 敏感变量后缀黑名单
|
||||
const SENSITIVE_SUFFIXES = [
|
||||
'_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_PASSWD',
|
||||
'_CREDENTIAL', '_CREDENTIALS', '_PRIVATE_KEY',
|
||||
'_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',
|
||||
'DEEPSEEK_API_KEY',
|
||||
'AGNES_API_KEY',
|
||||
'MIMO_API_KEY',
|
||||
'GITEA_PASSWORD',
|
||||
'DATABASE_PASSWORD',
|
||||
]);
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
@@ -86,12 +95,31 @@ function buildSafeCommandEnv(isWindows: boolean): Record<string, string> {
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.4.1: Windows 白名单命令集合 — 这些工具的简单命令(无 shell 运算符)走
|
||||
* execFile('cmd.exe', ['/c', ...words]) 执行:参数以数组形式显式传递,不经过
|
||||
* shell 解析,从根本上去掉 exec() 的字符串拼接注入面(无法通过参数注入新命令)。
|
||||
*
|
||||
* 仅收录最常见的开发工具(小步灰度);其余命令仍走 exec + 双层校验的既有路径。
|
||||
* Node 18.20+/Electron 35 在 Windows 上直接 spawn .cmd 批处理会被拒绝(EINVAL),
|
||||
* 因此必须通过 cmd.exe /c 中转,但参数分离已足够收窄注入面。
|
||||
*/
|
||||
const WINDOWS_EXEC_FILE_WHITELIST = new Set(['git', 'node', 'npm', 'npx', 'pnpm', 'yarn', 'tsc']);
|
||||
|
||||
/** v0.4.1: 提取命令 basename(处理 C:\Program Files\nodejs\npm.cmd 等路径形式) */
|
||||
function commandBasename(cmd: string): string {
|
||||
const base = cmd.split(/[\\/]/).pop() ?? cmd;
|
||||
// 去掉 .exe/.cmd/.bat 扩展名(大小写不敏感)
|
||||
return base.replace(/\.(exe|cmd|bat)$/i, '');
|
||||
}
|
||||
|
||||
// ===== 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.',
|
||||
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: {
|
||||
@@ -123,7 +151,11 @@ export class RunCommandTool implements IMetonaTool {
|
||||
// 安全校验:workdir 必须在工作空间内
|
||||
const resolvedWorkdir = resolve(context.workspacePath, workdir);
|
||||
if (!isPathWithinWorkspace(workdir, context.workspacePath)) {
|
||||
return { success: false, error: `Working directory must be within workspace: ${workdir}`, command };
|
||||
return {
|
||||
success: false,
|
||||
error: `Working directory must be within workspace: ${workdir}`,
|
||||
command,
|
||||
};
|
||||
}
|
||||
|
||||
// v0.2.0: SandboxManager 双重安全校验 — fail-closed 设计
|
||||
@@ -183,19 +215,32 @@ export class RunCommandTool implements IMetonaTool {
|
||||
let stderr: Buffer;
|
||||
|
||||
// #8 修复 + 审查修复: 简单命令使用 execFile(不经过 shell,防止命令注入)
|
||||
// 但 Windows 上 npm/npx/yarn/pnpm/tsc 等是 .cmd 批处理,execFile 无法执行(ENOENT)
|
||||
// 因此 Windows 上仍用 exec(已有 SandboxManager.scanCode + validateCommand 双层校验)
|
||||
// 非 Windows 上对简单命令用 execFile
|
||||
// 但 Windows 上 npm/npx/yarn/pnpm/tsc 等是 .cmd 批处理,execFile 无法直接执行(ENOENT/EINVAL)
|
||||
// v0.4.1: Windows 上白名单工具(git/node/npm/npx/pnpm/yarn/tsc)的简单命令改用
|
||||
// execFile('cmd.exe', ['/c', ...args]) — 参数显式分离传递,不经 shell 字符串解析,
|
||||
// 相比 exec() 的整串拼接显著收窄注入面
|
||||
// 非 Windows 上对简单命令直接 execFile
|
||||
if (simpleCmd && !isWindows) {
|
||||
const result = await execFileAsync(simpleCmd.command, simpleCmd.args, execOpts);
|
||||
stdout = result.stdout;
|
||||
stderr = result.stderr;
|
||||
} else if (
|
||||
simpleCmd &&
|
||||
isWindows &&
|
||||
WINDOWS_EXEC_FILE_WHITELIST.has(commandBasename(simpleCmd.command))
|
||||
) {
|
||||
// v0.4.1: 白名单工具通过 cmd.exe /c + 参数数组执行(参数不经 shell 解析)
|
||||
const result = await execFileAsync(
|
||||
'cmd.exe',
|
||||
['/c', 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 finalCommand = isWindows ? `chcp 65001 >nul 2>&1 && ${command}` : command;
|
||||
const result = await execAsync(finalCommand, execOpts);
|
||||
stdout = result.stdout;
|
||||
stderr = result.stderr;
|
||||
@@ -236,7 +281,11 @@ export class RunCommandTool implements IMetonaTool {
|
||||
|
||||
// 受保护文件检查:禁止通过命令行读写工作空间根目录的 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' };
|
||||
return {
|
||||
allowed: false,
|
||||
reason:
|
||||
'Access denied: MEMORY.md is managed by the memory system and cannot be accessed via command execution',
|
||||
};
|
||||
}
|
||||
|
||||
// P0-5: 剥离 Windows chcp 前缀("chcp 65001 >nul 2>&1 &&" 会破坏 shell-quote
|
||||
@@ -256,33 +305,61 @@ export class RunCommandTool implements IMetonaTool {
|
||||
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:
|
||||
/\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(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: /\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: /\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' },
|
||||
{
|
||||
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: /\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: /\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' },
|
||||
@@ -342,20 +419,29 @@ export class RunCommandTool implements IMetonaTool {
|
||||
prevWasPipe = false;
|
||||
} else if (typeof obj.op === 'string') {
|
||||
// 跟踪管道运算符,用于下一轮检测 `| sh`
|
||||
prevWasPipe = (obj.op === '|');
|
||||
prevWasPipe = obj.op === '|';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 危险命令名 token(精确匹配,大小写不敏感)
|
||||
const dangerousCommands = new Set([
|
||||
'sudo', 'su', 'doas',
|
||||
'shutdown', 'reboot', 'halt', 'poweroff',
|
||||
'mkfs', 'fdisk', 'format', 'diskpart',
|
||||
'sudo',
|
||||
'su',
|
||||
'doas',
|
||||
'shutdown',
|
||||
'reboot',
|
||||
'halt',
|
||||
'poweroff',
|
||||
'mkfs',
|
||||
'fdisk',
|
||||
'format',
|
||||
'diskpart',
|
||||
]);
|
||||
// 危险参数 token
|
||||
const dangerousArgs = new Set([
|
||||
'-enc', '-encodedcommand', // PowerShell 编码执行
|
||||
'-enc',
|
||||
'-encodedcommand', // PowerShell 编码执行
|
||||
]);
|
||||
|
||||
for (const word of words) {
|
||||
|
||||
Reference in New Issue
Block a user