ai-helper-agent 1.14.0 → 1.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/executor.js +19 -1
- package/src/security.js +20 -7
- package/src/server.js +20 -1
package/package.json
CHANGED
package/src/executor.js
CHANGED
|
@@ -86,6 +86,12 @@ const SAFE_ENV_KEYS = [
|
|
|
86
86
|
'GIT_EDITOR', 'GIT_PAGER', 'GIT_SSH_COMMAND',
|
|
87
87
|
// 平台相关
|
|
88
88
|
'TMPDIR', 'TEMPDIR', 'TEMP', 'TMP',
|
|
89
|
+
// Windows 必要环境变量(cmd/powershell 及其子进程依赖)
|
|
90
|
+
// 缺失 COMSPEC/PATHEXT/SystemRoot 会导致 cmd 命令解析异常、npm 等工具找不到;
|
|
91
|
+
// 缺失 PSModulePath 会导致 PowerShell 找不到模块
|
|
92
|
+
'COMSPEC', 'PATHEXT', 'SystemRoot', 'WINDIR',
|
|
93
|
+
'APPDATA', 'LOCALAPPDATA', 'USERPROFILE', 'PROGRAMDATA',
|
|
94
|
+
'PSModulePath', 'NUMBER_OF_PROCESSORS', 'PROCESSOR_ARCHITECTURE',
|
|
89
95
|
// 常用工具
|
|
90
96
|
'NVM_DIR', 'NVM_BIN', 'JAVA_HOME', 'GOPATH', 'GOROOT', 'CARGO_HOME',
|
|
91
97
|
'RUSTUP_HOME', 'PYTHONPATH', 'VIRTUAL_ENV', 'CONDA_PREFIX',
|
|
@@ -134,11 +140,23 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
|
|
|
134
140
|
const timeout = config.commandTimeout || 300000;
|
|
135
141
|
|
|
136
142
|
const { shell, args } = getShellForExec();
|
|
143
|
+
// detached 的取舍(按平台 + shell 类型):
|
|
144
|
+
// - Unix(macOS/Linux):任何 shell(zsh/bash/fish 等)都不弹窗(无控制台窗口概念),
|
|
145
|
+
// 且 detached 才能让 killProcess 的 process.kill(-pid) 杀掉整个进程组 → 始终启用。
|
|
146
|
+
// - Windows:分两类 shell——
|
|
147
|
+
// 1) MSYS/Cygwin 系(Git Bash/MSYS2/Cygwin 的 bash/zsh/fish 等):通过 pty 模拟层,
|
|
148
|
+
// 不依赖 Windows 控制台,detached 不会 AllocConsole → 不弹窗,可安全 detach。
|
|
149
|
+
// 2) Windows 原生控制台程序(cmd/powershell/pwsh 等):detached 脱离父控制台后会
|
|
150
|
+
// AllocConsole 创建可见窗口(黑窗),windowsHide 管不住 → 必须禁用 detached。
|
|
151
|
+
// 进程树终止改靠 taskkill /T /PID(killProcess Windows 分支),不依赖 detached。
|
|
152
|
+
// 用黑名单精确识别会弹窗的原生控制台程序,其余(含 MSMS/Cygwin 系)默认 detach。
|
|
153
|
+
const isWin = os.platform() === 'win32';
|
|
154
|
+
const isWinConsoleShell = isWin && /\b(cmd|powershell|pwsh)\b/i.test(shell);
|
|
137
155
|
const proc = spawn(shell, [...args, command], {
|
|
138
156
|
cwd: workdir,
|
|
139
157
|
env: buildSafeEnv(),
|
|
140
158
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
141
|
-
detached:
|
|
159
|
+
detached: !isWinConsoleShell,
|
|
142
160
|
windowsHide: true
|
|
143
161
|
});
|
|
144
162
|
|
package/src/security.js
CHANGED
|
@@ -6,14 +6,22 @@ import { loadConfig, MEMORY_DIR } from './config.js';
|
|
|
6
6
|
|
|
7
7
|
const isWin32 = platform() === 'win32';
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* 路径格式规整(跨平台)
|
|
11
|
+
* @param {string} pathStr - 待规整的路径
|
|
12
|
+
* @param {string} [platformOverride] - 平台覆盖('win32' | 'darwin' | 'linux'),用于单元测试 mock;
|
|
13
|
+
* 不传则使用当前运行平台 os.platform()
|
|
14
|
+
* @returns {string} 规整后的路径
|
|
15
|
+
*/
|
|
16
|
+
function normalizePathFormat(pathStr, platformOverride) {
|
|
10
17
|
if (!pathStr || typeof pathStr !== 'string') return pathStr;
|
|
18
|
+
const isWin = platformOverride !== undefined ? platformOverride === 'win32' : isWin32;
|
|
11
19
|
// 1. 把异常的 "/C:/" 形式(带前导斜杠的盘符)规整为 "C:/"
|
|
12
20
|
// 仅匹配 /<字母>:/ ,不会误伤 /home/ 等普通路径段
|
|
13
21
|
pathStr = pathStr.replace(/\/([a-zA-Z]):(\/|$)/g, '$1:$2');
|
|
14
22
|
// 2. MSYS/Git Bash 风格路径:/d/Users/... → D:/Users/...
|
|
15
23
|
// 前导 / + 单字母盘符 + / ,无冒号,是 MSYS 特有的挂载点表示法
|
|
16
|
-
if (
|
|
24
|
+
if (isWin) {
|
|
17
25
|
pathStr = pathStr.replace(/^\/([a-zA-Z])\//, '$1:/');
|
|
18
26
|
}
|
|
19
27
|
// 3. 统一为正斜杠(path.resolve/normalize 在所有平台都能正确处理正斜杠并归一化到平台分隔符)
|
|
@@ -21,7 +29,7 @@ function normalizePathFormat(pathStr) {
|
|
|
21
29
|
// 4. Windows 盘符统一为大写:d:/ → D:/
|
|
22
30
|
// path.resolve 保留盘符原始大小写,而白名单前缀比较是大小写敏感的字符串比较,
|
|
23
31
|
// 小写盘符路径与大写盘符白名单前缀比较会失败(d:\Users\.startsWith(D:\Users\) → false)
|
|
24
|
-
if (
|
|
32
|
+
if (isWin) {
|
|
25
33
|
pathStr = pathStr.replace(/^([a-zA-Z]):/, m => m.toUpperCase());
|
|
26
34
|
}
|
|
27
35
|
return pathStr;
|
|
@@ -44,17 +52,22 @@ const HARD_BLOCKED_PATHS = [
|
|
|
44
52
|
|
|
45
53
|
/**
|
|
46
54
|
* 检查路径是否命中核心敏感文件
|
|
55
|
+
* 注意:Windows 文件系统不区分大小写,必须做大小写不敏感比较,
|
|
56
|
+
* 否则 .AI-HELPER-AGENT/config.JSON 等大小写变换可绕过硬阻止
|
|
47
57
|
*/
|
|
48
|
-
function isHardBlocked(normalizedPath) {
|
|
58
|
+
function isHardBlocked(normalizedPath, platformOverride) {
|
|
59
|
+
const isWin = platformOverride !== undefined ? platformOverride === 'win32' : isWin32;
|
|
60
|
+
const cmpPath = isWin ? normalizedPath.toLowerCase() : normalizedPath;
|
|
49
61
|
for (const blocked of HARD_BLOCKED_PATHS) {
|
|
62
|
+
const cmpBlocked = isWin ? blocked.toLowerCase() : blocked;
|
|
50
63
|
if (blocked.endsWith(sep)) {
|
|
51
64
|
// 目录:检查是否在该目录下
|
|
52
|
-
if (
|
|
65
|
+
if (cmpPath.startsWith(cmpBlocked) || cmpPath === cmpBlocked.slice(0, -1)) {
|
|
53
66
|
return true;
|
|
54
67
|
}
|
|
55
68
|
} else {
|
|
56
69
|
// 文件:精确匹配
|
|
57
|
-
if (
|
|
70
|
+
if (cmpPath === cmpBlocked) {
|
|
58
71
|
return true;
|
|
59
72
|
}
|
|
60
73
|
}
|
|
@@ -314,4 +327,4 @@ function checkCommand(command, force = false) {
|
|
|
314
327
|
return { safe: true, level: 'allow' };
|
|
315
328
|
}
|
|
316
329
|
|
|
317
|
-
export { checkPath, checkCommand };
|
|
330
|
+
export { checkPath, checkCommand, normalizePathFormat, isHardBlocked };
|
package/src/server.js
CHANGED
|
@@ -12,7 +12,7 @@ import { randomBytes } from 'crypto';
|
|
|
12
12
|
import os from 'os';
|
|
13
13
|
import { loadConfig, saveConfig } from './config.js';
|
|
14
14
|
import { verifyToken, startPairCodeRotation, stopPairCodeRotation, handlePairRequest } from './auth.js';
|
|
15
|
-
import { checkPath, checkCommand } from './security.js';
|
|
15
|
+
import { checkPath, checkCommand, normalizePathFormat } from './security.js';
|
|
16
16
|
import { moveToTrash, restoreFromTrash, listTrash, startPeriodicCleanup, stopPeriodicCleanup } from './trash.js';
|
|
17
17
|
import { executeCommand, executeCommandSync, addWsClient, disconnectWsClient, killProcess, getRunningProcesses } from './executor.js';
|
|
18
18
|
import { setConsoleOutput, logAuth, logFs, logExec, logSecurity, logSystem, logError, queryLogs, getLogDates } from './logger.js';
|
|
@@ -801,6 +801,9 @@ export function startServer() {
|
|
|
801
801
|
} else if (resolvedWorkdir.startsWith('~/')) {
|
|
802
802
|
resolvedWorkdir = join(homedir(), resolvedWorkdir.slice(2));
|
|
803
803
|
}
|
|
804
|
+
// 规整路径格式(MSYS /d/Users/... → D:/Users/...、盘符大写统一),
|
|
805
|
+
// 必须在 isAbsolute/resolve 之前,否则 Windows 上 /d/Users 会被当作 Unix 绝对路径错误解析
|
|
806
|
+
resolvedWorkdir = normalizePathFormat(resolvedWorkdir);
|
|
804
807
|
|
|
805
808
|
// 必须是绝对路径(跨平台:path.isAbsolute 覆盖 Unix /、Windows 盘符、UNC 路径)
|
|
806
809
|
if (!isAbsolute(resolvedWorkdir)) {
|
|
@@ -857,6 +860,8 @@ export function startServer() {
|
|
|
857
860
|
} else if (resolvedPath.startsWith('~/')) {
|
|
858
861
|
resolvedPath = join(homedir(), resolvedPath.slice(2));
|
|
859
862
|
}
|
|
863
|
+
// 规整 MSYS 路径格式,与 workdir 切换端点保持一致
|
|
864
|
+
resolvedPath = normalizePathFormat(resolvedPath);
|
|
860
865
|
if (!isAbsolute(resolvedPath)) {
|
|
861
866
|
return jsonResponse(res, 400, { success: false, error: 'path 必须是绝对路径' });
|
|
862
867
|
}
|
|
@@ -1385,6 +1390,10 @@ export function startServer() {
|
|
|
1385
1390
|
if (newName.includes('/') || newName.includes('\\')) {
|
|
1386
1391
|
return jsonResponse(res, 400, { success: false, error: '新名称不能包含路径分隔符' });
|
|
1387
1392
|
}
|
|
1393
|
+
// Windows 文件名非法字符(仅 Windows 拦截;Unix 允许这些字符)
|
|
1394
|
+
if (os.platform() === 'win32' && /[:*?"<>|]/.test(newName)) {
|
|
1395
|
+
return jsonResponse(res, 400, { success: false, error: '新名称包含 Windows 非法字符 (: * ? " < > |)' });
|
|
1396
|
+
}
|
|
1388
1397
|
const check = await checkPath(oldPath);
|
|
1389
1398
|
if (!check.allowed) {
|
|
1390
1399
|
logSecurity('fs_rename_blocked', { path: oldPath, reason: check.reason });
|
|
@@ -1400,6 +1409,12 @@ export function startServer() {
|
|
|
1400
1409
|
logSecurity('fs_rename_blocked', { path: newFullPath, reason: newCheck.reason });
|
|
1401
1410
|
return jsonResponse(res, 403, { success: false, error: `目标路径不允许: ${newCheck.reason}` });
|
|
1402
1411
|
}
|
|
1412
|
+
// 防止覆盖:目标已存在时统一返回 409。
|
|
1413
|
+
// macOS/Linux 的 fs.rename 会静默覆盖目标文件(仅 Windows 报 EEXIST),
|
|
1414
|
+
// 主动检查避免跨平台数据丢失风险
|
|
1415
|
+
if (await exists(newFullPath)) {
|
|
1416
|
+
return jsonResponse(res, 409, { success: false, error: '目标名称已存在' });
|
|
1417
|
+
}
|
|
1403
1418
|
try {
|
|
1404
1419
|
await rename(check.resolved, newFullPath);
|
|
1405
1420
|
logFs('rename', { from: check.resolved, to: newFullPath });
|
|
@@ -1443,6 +1458,10 @@ export function startServer() {
|
|
|
1443
1458
|
logSecurity('fs_move_blocked', { path: destPath, reason: destCheck2.reason });
|
|
1444
1459
|
return jsonResponse(res, 403, { success: false, error: `移动目标路径不允许: ${destCheck2.reason}` });
|
|
1445
1460
|
}
|
|
1461
|
+
// 防止覆盖:目标已存在时统一返回 409(与 rename 端点一致,避免 macOS/Linux 静默覆盖)
|
|
1462
|
+
if (await exists(destPath)) {
|
|
1463
|
+
return jsonResponse(res, 409, { success: false, error: '目标位置已存在同名文件/目录' });
|
|
1464
|
+
}
|
|
1446
1465
|
try {
|
|
1447
1466
|
await rename(srcCheck.resolved, destPath);
|
|
1448
1467
|
logFs('move', { from: srcCheck.resolved, to: destPath });
|