feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m25s
CI / 全量测试 (Electron ABI, experimental) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 10m3s

工程化(从零到一):
- 新增 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:
2026-08-21 13:58:48 +08:00
parent 2230bcec3f
commit 49c9b25538
41 changed files with 6254 additions and 2608 deletions
+113 -27
View File
@@ -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) {