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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-helper-agent",
3
- "version": "1.14.0",
3
+ "version": "1.14.1",
4
4
  "description": "AI Helper 代理服务 - 为 Chrome 扩展提供本地文件读写和系统命令执行能力",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
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
- function normalizePathFormat(pathStr) {
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 (isWin32) {
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 (isWin32) {
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 (normalizedPath.startsWith(blocked) || normalizedPath === blocked.slice(0, -1)) {
65
+ if (cmpPath.startsWith(cmpBlocked) || cmpPath === cmpBlocked.slice(0, -1)) {
53
66
  return true;
54
67
  }
55
68
  } else {
56
69
  // 文件:精确匹配
57
- if (normalizedPath === blocked) {
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 });