@ian2018cs/agenthub 0.1.59 → 0.1.60

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.60",
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
  * 检查单个文件路径的安全性
@@ -589,23 +587,8 @@ const rules = [
589
587
  }
590
588
 
591
589
  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}` };
590
+ const bad = checkResolvedPath(path.resolve(rawPath), context);
591
+ if (bad) return { result: 'deny', reason: `不允许访问项目目录之外的路径: ${bad}` };
609
592
  }
610
593
 
611
594
  // 检查命令复杂度:包含变量替换、子 shell 等 → UNCERTAIN