ai-helper-agent 1.14.0 → 1.14.1
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/security.js +20 -7
- package/src/server.js +20 -1
package/package.json
CHANGED
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 });
|