@ian2018cs/agenthub 0.1.59 → 0.1.61

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": "@ian2018cs/agenthub",
3
- "version": "0.1.59",
3
+ "version": "0.1.61",
4
4
  "description": "A web-based UI for AI Agents",
5
5
  "type": "module",
6
6
  "main": "server/index.js",
@@ -549,6 +549,16 @@ async function queryClaudeSDK(command, options = {}, ws) {
549
549
  }
550
550
  };
551
551
  }
552
+ // 路径重写:将 .claude 路径替换为用户专属目录后告知 SDK 使用新参数
553
+ if (guardResult.modifiedInput) {
554
+ return {
555
+ hookSpecificOutput: {
556
+ hookEventName: 'PreToolUse',
557
+ permissionDecision: 'allow',
558
+ updatedInput: guardResult.modifiedInput,
559
+ }
560
+ };
561
+ }
552
562
  } catch (err) {
553
563
  console.error(`[ToolGuard] Error evaluating ${hookInput.tool_name}:`, err.message);
554
564
  // 守卫出错时不阻塞正常流程,降级为原有权限控制
@@ -7,27 +7,85 @@
7
7
 
8
8
  import { evaluateRules } from './rules.js';
9
9
  import { reviewWithLLM } from './llm-reviewer.js';
10
- import { getUserPaths, DATA_DIR } from '../user-directories.js';
10
+ import { DATA_DIR, getUserPaths } from '../user-directories.js';
11
11
  import path from 'path';
12
12
 
13
13
  const VERBOSE = process.env.TOOL_GUARD_VERBOSE === 'true';
14
14
 
15
+ /**
16
+ * 将工具输入中的 .claude 路径重写为用户专属 claudeDir
17
+ *
18
+ * 处理以下模式(以便多用户环境下正确访问技能、配置等):
19
+ * ~/.claude/... → claudeDir/...
20
+ * $HOME/.claude/... → claudeDir/...
21
+ * ./.claude/... → claudeDir/...(相对路径,常见于技能脚本调用)
22
+ *
23
+ * @param {string} toolName
24
+ * @param {Record<string, unknown>} input
25
+ * @param {string} claudeDir - 用户的 .claude 绝对路径
26
+ * @returns {Record<string, unknown>|null} 有改动时返回新 input 对象,否则返回 null
27
+ */
28
+ function rewriteClaudePaths(toolName, input, claudeDir) {
29
+ if (!input || !claudeDir) return null;
30
+
31
+ function replaceInStr(str) {
32
+ if (!str || typeof str !== 'string') return str;
33
+ let s = str;
34
+ // ~/.claude
35
+ s = s.replace(/~\/\.claude(\/|$)/g, `${claudeDir}$1`);
36
+ // $HOME/.claude
37
+ s = s.replace(/\$HOME\/\.claude(\/|$)/g, `${claudeDir}$1`);
38
+ // ./.claude(前面是行首、空白或引号)
39
+ s = s.replace(/(^|[\s"'])\.\/\.claude(\/|$)/g, `$1${claudeDir}$2`);
40
+ return s;
41
+ }
42
+
43
+ const newInput = { ...input };
44
+ let modified = false;
45
+
46
+ if (['Read', 'Write', 'Edit'].includes(toolName) && typeof input.file_path === 'string') {
47
+ const rewritten = replaceInStr(input.file_path);
48
+ if (rewritten !== input.file_path) { newInput.file_path = rewritten; modified = true; }
49
+ }
50
+
51
+ if (['Glob', 'Grep'].includes(toolName) && typeof input.path === 'string') {
52
+ const rewritten = replaceInStr(input.path);
53
+ if (rewritten !== input.path) { newInput.path = rewritten; modified = true; }
54
+ }
55
+
56
+ if (toolName === 'Bash' && typeof input.command === 'string') {
57
+ const rewritten = replaceInStr(input.command);
58
+ if (rewritten !== input.command) { newInput.command = rewritten; modified = true; }
59
+ }
60
+
61
+ return modified ? newInput : null;
62
+ }
63
+
15
64
  /**
16
65
  * 评估工具调用是否安全
17
66
  * @param {string} toolName - 工具名称(如 Bash, Read, Write, Edit, Glob, Grep)
18
67
  * @param {Record<string, unknown>} input - 工具参数
19
68
  * @param {{ userUuid: string, cwd?: string }} opts
20
- * @returns {Promise<{ allowed: boolean, reason?: string }>}
69
+ * @returns {Promise<{ allowed: boolean, reason?: string, modifiedInput?: Record<string, unknown> }>}
21
70
  */
22
71
  export async function evaluate(toolName, input, { userUuid, cwd }) {
23
72
  if (process.env.TOOL_GUARD_ENABLED === 'false') {
24
73
  return { allowed: true };
25
74
  }
26
75
 
76
+ // 路径适配:将 .claude 相关路径重写为用户专属目录(多用户模式兼容)
77
+ const { claudeDir } = getUserPaths(userUuid);
78
+ const rewrittenInput = rewriteClaudePaths(toolName, input, claudeDir);
79
+ const effectiveInput = rewrittenInput || input;
80
+
81
+ if (rewrittenInput && VERBOSE) {
82
+ console.log(`[ToolGuard] PATH-REWRITE ${toolName}: .claude paths → ${claudeDir}`);
83
+ }
84
+
27
85
  const context = buildContext(userUuid, cwd);
28
86
 
29
87
  // Phase 1: 正则规则快速检查
30
- const { denied, uncertain, denyReasons, uncertainReasons } = evaluateRules(toolName, input, context);
88
+ const { denied, uncertain, denyReasons, uncertainReasons } = evaluateRules(toolName, effectiveInput, context);
31
89
 
32
90
  if (denied) {
33
91
  if (VERBOSE) console.log(`[ToolGuard] RULE-DENY ${toolName}: ${denyReasons.join('; ')}`);
@@ -36,19 +94,21 @@ export async function evaluate(toolName, input, { userUuid, cwd }) {
36
94
 
37
95
  if (!uncertain) {
38
96
  if (VERBOSE) console.log(`[ToolGuard] RULE-ALLOW ${toolName}`);
39
- return { allowed: true };
97
+ return rewrittenInput ? { allowed: true, modifiedInput: rewrittenInput } : { allowed: true };
40
98
  }
41
99
 
42
100
  // Phase 2: LLM 兜底审查
43
101
  if (process.env.TOOL_GUARD_LLM_ENABLED === 'false') {
44
102
  if (VERBOSE) console.log(`[ToolGuard] LLM disabled, UNCERTAIN→ALLOW ${toolName}`);
45
- return { allowed: true };
103
+ return rewrittenInput ? { allowed: true, modifiedInput: rewrittenInput } : { allowed: true };
46
104
  }
47
105
 
48
106
  try {
49
- const llmResult = await reviewWithLLM(toolName, input, context, uncertainReasons);
107
+ const llmResult = await reviewWithLLM(toolName, effectiveInput, context, uncertainReasons);
50
108
  if (VERBOSE) console.log(`[ToolGuard] LLM-${llmResult.allowed ? 'ALLOW' : 'DENY'} ${toolName}: ${llmResult.reason || ''}`);
51
- return llmResult;
109
+ return rewrittenInput && llmResult.allowed
110
+ ? { ...llmResult, modifiedInput: rewrittenInput }
111
+ : llmResult;
52
112
  } catch (err) {
53
113
  console.error(`[ToolGuard] LLM review error for ${toolName}:`, err.message);
54
114
  // LLM 失败时安全兜底:拒绝
@@ -86,19 +86,8 @@ function checkPathsInContent(content, context) {
86
86
  const pathPattern = /(?:^|\s|=|"|')(\/[^\s"'`;|&><){}$]+)/gm;
87
87
  let match;
88
88
  while ((match = pathPattern.exec(nonCommentContent)) !== null) {
89
- const resolvedPath = path.resolve(match[1]);
90
- // 安全路径跳过
91
- if (SAFE_SPECIAL_PATHS.includes(resolvedPath)) continue;
92
- if (SAFE_COMMAND_PATHS.some(prefix => resolvedPath.startsWith(prefix))) continue;
93
- if (isPathAllowed(resolvedPath, context)) continue;
94
- if (isInTempDir(resolvedPath, context)) continue;
95
- // 占位符/示例路径跳过
96
- if (PLACEHOLDER_PATH_PREFIXES.some(prefix => resolvedPath.startsWith(prefix))) continue;
97
- // API 端点路径跳过(/v1/..., /api/... 等,不是文件系统路径)
98
- if (API_PATH_PATTERN.test(resolvedPath)) continue;
99
- // 路径在文件系统上不存在 → 不是真实文件(API 路径、变量占位符等),无法被访问
100
- if (!existsSync(resolvedPath)) continue;
101
- return { denied: true, reason: `访问项目目录之外的路径: ${resolvedPath}` };
89
+ const bad = checkResolvedPath(path.resolve(match[1]), context);
90
+ if (bad) return { denied: true, reason: `访问项目目录之外的路径: ${bad}` };
102
91
  }
103
92
  return { denied: false };
104
93
  }
@@ -151,8 +140,19 @@ function hasTempUserSubdir(resolvedPath, context) {
151
140
  return resolvedPath.includes('/' + context.userUuid + '/') || resolvedPath.includes('/' + context.userUuid);
152
141
  }
153
142
 
154
- /** 系统敏感路径前缀 */
155
- const SYSTEM_PATHS = ['/etc/', '/usr/', '/var/', '/sys/', '/proc/', '/dev/', '/boot/', '/sbin/', '/lib/', '/bin/'];
143
+ /**
144
+ * 对已解析的绝对路径执行统一白名单检查(供内容扫描和 Bash 命令扫描复用)
145
+ * @returns {string|null} null = 通过;字符串 = 越界的路径
146
+ */
147
+ function checkResolvedPath(resolvedPath, context) {
148
+ if (SAFE_SPECIAL_PATHS.includes(resolvedPath)) return null;
149
+ if (SAFE_COMMAND_PATHS.some(prefix => resolvedPath.startsWith(prefix))) return null;
150
+ if (isPathAllowed(resolvedPath, context)) return null;
151
+ if (isInTempDir(resolvedPath, context)) return null;
152
+ if (PLACEHOLDER_PATH_PREFIXES.some(prefix => resolvedPath.startsWith(prefix))) return null;
153
+ if (!existsSync(resolvedPath)) return null;
154
+ return resolvedPath;
155
+ }
156
156
 
157
157
  /** 安全命令路径前缀(用于 Bash 路径提取时排除) */
158
158
  const SAFE_COMMAND_PATHS = ['/usr/bin/', '/usr/local/bin/', '/bin/', '/sbin/', '/usr/sbin/', '/opt/homebrew/bin/'];
@@ -163,8 +163,6 @@ const SAFE_SPECIAL_PATHS = ['/dev/null', '/dev/stdin', '/dev/stdout', '/dev/stde
163
163
  /** 常见占位符路径前缀(示例/文档用途,不应被视为真实访问路径) */
164
164
  const PLACEHOLDER_PATH_PREFIXES = ['/path/to/', '/your/', '/example/', '/placeholder/'];
165
165
 
166
- /** API 路径模式:匹配 REST API 风格路径(如 /v1/images/generations),不是文件系统路径 */
167
- const API_PATH_PATTERN = /^\/(?:v\d+|api|graphql|rpc|rest|openapi|swagger)\//;
168
166
 
169
167
  /**
170
168
  * 检查单个文件路径的安全性
@@ -516,6 +514,10 @@ const rules = [
516
514
  const scriptFile = interpreterMatch?.[1] || langMatch?.[1] || directMatch?.[1];
517
515
  if (!scriptFile) return { result: 'allow' };
518
516
 
517
+ // 编程语言脚本(python/node/ruby 等)的字符串字面量(URL 路径、os.sep 等)
518
+ // 会被路径正则误判为文件系统路径,路径越界检查仅对 shell 脚本有意义
519
+ const isShellScript = !!interpreterMatch || (!langMatch && !!directMatch);
520
+
519
521
  // 解析脚本路径
520
522
  const resolvedScript = path.isAbsolute(scriptFile)
521
523
  ? path.resolve(scriptFile)
@@ -547,10 +549,12 @@ const rules = [
547
549
  return { result: 'deny', reason: `脚本文件${portCheck.reason}` };
548
550
  }
549
551
 
550
- // 检查路径越界
551
- const pathCheck = checkPathsInContent(content, context);
552
- if (pathCheck.denied) {
553
- return { result: 'deny', reason: `脚本文件${pathCheck.reason}` };
552
+ // 检查路径越界(仅 shell 脚本,编程语言脚本的字符串字面量会误判为文件路径)
553
+ if (isShellScript) {
554
+ const pathCheck = checkPathsInContent(content, context);
555
+ if (pathCheck.denied) {
556
+ return { result: 'deny', reason: `脚本文件${pathCheck.reason}` };
557
+ }
554
558
  }
555
559
  } catch {
556
560
  // 文件不存在或无法读取 — 交由其他规则处理
@@ -589,23 +593,8 @@ const rules = [
589
593
  }
590
594
 
591
595
  for (const rawPath of extractedPaths) {
592
- const resolvedPath = path.resolve(rawPath);
593
-
594
- // 白名单:只有以下路径允许通过,其他一律拒绝
595
- // 1. 安全的特殊路径(/dev/null 等)
596
- if (SAFE_SPECIAL_PATHS.includes(resolvedPath)) continue;
597
-
598
- // 2. 命令路径(/usr/bin/ 等)— 执行命令本身,不是访问文件
599
- if (SAFE_COMMAND_PATHS.some(prefix => resolvedPath.startsWith(prefix))) continue;
600
-
601
- // 3. 在用户允许的目录内(user-data/{uuid}/, user-projects/{uuid}/, cwd)
602
- if (isPathAllowed(resolvedPath, context)) continue;
603
-
604
- // 4. 在临时目录中且有用户子目录 → 已由 temp-dir-isolation 规则处理,跳过
605
- if (isInTempDir(resolvedPath, context)) continue;
606
-
607
- // 不在白名单中 → DENY
608
- return { result: 'deny', reason: `不允许访问项目目录之外的路径: ${resolvedPath}` };
596
+ const bad = checkResolvedPath(path.resolve(rawPath), context);
597
+ if (bad) return { result: 'deny', reason: `不允许访问项目目录之外的路径: ${bad}` };
609
598
  }
610
599
 
611
600
  // 检查命令复杂度:包含变量替换、子 shell 等 → UNCERTAIN