@ian2018cs/agenthub 0.1.54 → 0.1.55
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/dist/assets/{index-_a9nlevD.js → index-8gJOK0K5.js} +20 -20
- package/dist/index.html +1 -1
- package/package.json +2 -2
- package/server/claude-sdk.js +41 -0
- package/server/services/tool-guard/index.js +75 -0
- package/server/services/tool-guard/llm-reviewer.js +128 -0
- package/server/services/tool-guard/rules.js +635 -0
package/dist/index.html
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
<!-- Prevent zoom on iOS -->
|
|
27
27
|
<meta name="format-detection" content="telephone=no" />
|
|
28
|
-
<script type="module" crossorigin src="/assets/index-
|
|
28
|
+
<script type="module" crossorigin src="/assets/index-8gJOK0K5.js"></script>
|
|
29
29
|
<link rel="modulepreload" crossorigin href="/assets/vendor-react-BeVl62c0.js">
|
|
30
30
|
<link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-C_VWDoZS.js">
|
|
31
31
|
<link rel="modulepreload" crossorigin href="/assets/vendor-utils-00TdZexr.js">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ian2018cs/agenthub",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.55",
|
|
4
4
|
"description": "A web-based UI for AI Agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server/index.js",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"access": "public"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@anthropic-ai/claude-agent-sdk": "^0.2.
|
|
54
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.72",
|
|
55
55
|
"@codemirror/lang-css": "^6.3.1",
|
|
56
56
|
"@codemirror/lang-html": "^6.4.9",
|
|
57
57
|
"@codemirror/lang-javascript": "^6.2.4",
|
package/server/claude-sdk.js
CHANGED
|
@@ -22,6 +22,7 @@ import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
|
|
22
22
|
import { getUserPaths } from './services/user-directories.js';
|
|
23
23
|
import { usageDb } from './database/db.js';
|
|
24
24
|
import { calculateCost, normalizeModelName } from './services/pricing.js';
|
|
25
|
+
import { evaluate as evaluateToolGuard } from './services/tool-guard/index.js';
|
|
25
26
|
|
|
26
27
|
// Session tracking: Map of session IDs to active query instances
|
|
27
28
|
const activeSessions = new Map();
|
|
@@ -520,6 +521,46 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|
|
520
521
|
tempImagePaths = imageResult.tempImagePaths;
|
|
521
522
|
tempDir = imageResult.tempDir;
|
|
522
523
|
|
|
524
|
+
// ===== 安全守卫(PreToolUse hook,最高优先级)=====
|
|
525
|
+
// 使用 PreToolUse hook 而非 canUseTool 回调,因为 hook 会在每次工具执行前触发,
|
|
526
|
+
// 包括已被 allowedTools 自动放行的工具和 bypassPermissions 模式下的工具。
|
|
527
|
+
// canUseTool 仅在 CLI 发送 can_use_tool 控制消息时触发(即工具未被预批准时)。
|
|
528
|
+
if (userUuid) {
|
|
529
|
+
sdkOptions.hooks = {
|
|
530
|
+
...(sdkOptions.hooks || {}),
|
|
531
|
+
PreToolUse: [
|
|
532
|
+
...((sdkOptions.hooks || {}).PreToolUse || []),
|
|
533
|
+
{
|
|
534
|
+
hooks: [async (hookInput) => {
|
|
535
|
+
try {
|
|
536
|
+
const guardResult = await evaluateToolGuard(hookInput.tool_name, hookInput.tool_input, {
|
|
537
|
+
userUuid,
|
|
538
|
+
cwd: sdkOptions.cwd,
|
|
539
|
+
});
|
|
540
|
+
if (!guardResult.allowed) {
|
|
541
|
+
console.log(`[ToolGuard] DENIED ${hookInput.tool_name} for user ${userUuid}: ${guardResult.reason}`);
|
|
542
|
+
return {
|
|
543
|
+
hookSpecificOutput: {
|
|
544
|
+
hookEventName: 'PreToolUse',
|
|
545
|
+
permissionDecision: 'deny',
|
|
546
|
+
permissionDecisionReason: `[系统安全策略] ${guardResult.reason}`,
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
} catch (err) {
|
|
551
|
+
console.error(`[ToolGuard] Error evaluating ${hookInput.tool_name}:`, err.message);
|
|
552
|
+
// 守卫出错时不阻塞正常流程,降级为原有权限控制
|
|
553
|
+
}
|
|
554
|
+
if (process.env.TOOL_GUARD_VERBOSE === 'true') {
|
|
555
|
+
console.log(`[ToolGuard] HOOK-ALLOW ${hookInput.tool_name}`);
|
|
556
|
+
}
|
|
557
|
+
return {};
|
|
558
|
+
}]
|
|
559
|
+
}
|
|
560
|
+
]
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
523
564
|
// Gate tool usage with explicit UI approval when not auto-approved.
|
|
524
565
|
// This does not render UI or persist permissions; it only bridges to the UI
|
|
525
566
|
// via WebSocket and waits for the response, introduced so tool calls pause
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Guard — 工具调用安全守卫
|
|
3
|
+
*
|
|
4
|
+
* 在每次工具执行前进行安全检查,优先级高于所有用户权限模式。
|
|
5
|
+
* 双层检测:正则规则(快速)→ LLM 审查(兜底)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { evaluateRules } from './rules.js';
|
|
9
|
+
import { reviewWithLLM } from './llm-reviewer.js';
|
|
10
|
+
import { getUserPaths, DATA_DIR } from '../user-directories.js';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
|
|
13
|
+
const VERBOSE = process.env.TOOL_GUARD_VERBOSE === 'true';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 评估工具调用是否安全
|
|
17
|
+
* @param {string} toolName - 工具名称(如 Bash, Read, Write, Edit, Glob, Grep)
|
|
18
|
+
* @param {Record<string, unknown>} input - 工具参数
|
|
19
|
+
* @param {{ userUuid: string, cwd?: string }} opts
|
|
20
|
+
* @returns {Promise<{ allowed: boolean, reason?: string }>}
|
|
21
|
+
*/
|
|
22
|
+
export async function evaluate(toolName, input, { userUuid, cwd }) {
|
|
23
|
+
if (process.env.TOOL_GUARD_ENABLED === 'false') {
|
|
24
|
+
return { allowed: true };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const context = buildContext(userUuid, cwd);
|
|
28
|
+
|
|
29
|
+
// Phase 1: 正则规则快速检查
|
|
30
|
+
const { denied, uncertain, denyReasons, uncertainReasons } = evaluateRules(toolName, input, context);
|
|
31
|
+
|
|
32
|
+
if (denied) {
|
|
33
|
+
if (VERBOSE) console.log(`[ToolGuard] RULE-DENY ${toolName}: ${denyReasons.join('; ')}`);
|
|
34
|
+
return { allowed: false, reason: denyReasons.join('; ') };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!uncertain) {
|
|
38
|
+
if (VERBOSE) console.log(`[ToolGuard] RULE-ALLOW ${toolName}`);
|
|
39
|
+
return { allowed: true };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Phase 2: LLM 兜底审查
|
|
43
|
+
if (process.env.TOOL_GUARD_LLM_ENABLED === 'false') {
|
|
44
|
+
if (VERBOSE) console.log(`[ToolGuard] LLM disabled, UNCERTAIN→ALLOW ${toolName}`);
|
|
45
|
+
return { allowed: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const llmResult = await reviewWithLLM(toolName, input, context, uncertainReasons);
|
|
50
|
+
if (VERBOSE) console.log(`[ToolGuard] LLM-${llmResult.allowed ? 'ALLOW' : 'DENY'} ${toolName}: ${llmResult.reason || ''}`);
|
|
51
|
+
return llmResult;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error(`[ToolGuard] LLM review error for ${toolName}:`, err.message);
|
|
54
|
+
// LLM 失败时安全兜底:拒绝
|
|
55
|
+
return { allowed: false, reason: 'LLM 审查失败,出于安全考虑已拒绝' };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 构建安全检查上下文
|
|
61
|
+
*/
|
|
62
|
+
function buildContext(userUuid, cwd) {
|
|
63
|
+
const resolvedDataDir = path.resolve(DATA_DIR);
|
|
64
|
+
return {
|
|
65
|
+
userUuid,
|
|
66
|
+
cwd: cwd ? path.resolve(cwd) : null,
|
|
67
|
+
allowedPrefixes: [
|
|
68
|
+
path.join(resolvedDataDir, 'user-data', userUuid),
|
|
69
|
+
path.join(resolvedDataDir, 'user-projects', userUuid),
|
|
70
|
+
path.join(resolvedDataDir, 'images', userUuid),
|
|
71
|
+
],
|
|
72
|
+
dataDir: resolvedDataDir,
|
|
73
|
+
tempDirs: ['/tmp', '/var/tmp', '/private/tmp'],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Guard — LLM 审查模块
|
|
3
|
+
*
|
|
4
|
+
* 当正则规则无法确定安全性时(返回 UNCERTAIN),调用 LLM 进行兜底审查。
|
|
5
|
+
* 复用 server/services/llm.js 的 chatCompletion(),使用 OpenAI 兼容格式。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { chatCompletion } from '../llm.js';
|
|
9
|
+
|
|
10
|
+
const MODEL = process.env.TOOL_GUARD_LLM_MODEL || 'gemini-3.1-flash-lite-preview';
|
|
11
|
+
const TIMEOUT_MS = parseInt(process.env.TOOL_GUARD_LLM_TIMEOUT_MS || '5000', 10);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 使用 LLM 审查工具调用安全性
|
|
15
|
+
* @param {string} toolName
|
|
16
|
+
* @param {Record<string, unknown>} input
|
|
17
|
+
* @param {object} context - { userUuid, allowedPrefixes, cwd, tempDirs }
|
|
18
|
+
* @param {string[]} uncertainReasons - 触发 LLM 审查的原因
|
|
19
|
+
* @returns {Promise<{ allowed: boolean, reason?: string }>}
|
|
20
|
+
*/
|
|
21
|
+
export async function reviewWithLLM(toolName, input, context, uncertainReasons) {
|
|
22
|
+
const { userUuid, allowedPrefixes, cwd } = context;
|
|
23
|
+
|
|
24
|
+
// 如果 OPENAI_API_KEY 未配置,降级为放行
|
|
25
|
+
if (!process.env.OPENAI_API_KEY) {
|
|
26
|
+
console.warn('[ToolGuard] OPENAI_API_KEY 未配置,LLM 审查降级为放行');
|
|
27
|
+
return { allowed: true };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const systemPrompt = buildSystemPrompt(userUuid, allowedPrefixes, cwd);
|
|
31
|
+
const userPrompt = buildUserPrompt(toolName, input, uncertainReasons);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// 带超时的 LLM 调用
|
|
35
|
+
const result = await Promise.race([
|
|
36
|
+
chatCompletion({
|
|
37
|
+
systemPrompt,
|
|
38
|
+
userPrompt,
|
|
39
|
+
model: MODEL,
|
|
40
|
+
maxTokens: 256,
|
|
41
|
+
temperature: 0,
|
|
42
|
+
}),
|
|
43
|
+
new Promise((_, reject) =>
|
|
44
|
+
setTimeout(() => reject(new Error('LLM 审查超时')), TIMEOUT_MS)
|
|
45
|
+
),
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
return parseResponse(result);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err.message === 'LLM 审查超时') {
|
|
51
|
+
return { allowed: false, reason: 'LLM 审查超时,出于安全考虑已拒绝' };
|
|
52
|
+
}
|
|
53
|
+
throw err; // 其他错误向上抛出,由 index.js 的 catch 处理
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 构建系统提示词
|
|
59
|
+
*/
|
|
60
|
+
function buildSystemPrompt(userUuid, allowedPrefixes, cwd) {
|
|
61
|
+
return `你是一个多用户 AI 编程平台的安全审查员。你需要判断一个 AI 助手的工具调用是否安全。
|
|
62
|
+
|
|
63
|
+
## 用户上下文
|
|
64
|
+
- 用户 ID: ${userUuid}
|
|
65
|
+
- 用户允许的目录:
|
|
66
|
+
- ${allowedPrefixes[0]}
|
|
67
|
+
- ${allowedPrefixes[1]}
|
|
68
|
+
- 当前工作目录: ${cwd || '未指定'}
|
|
69
|
+
- 临时目录要求: 必须使用 /tmp/${userUuid}/ 子文件夹
|
|
70
|
+
|
|
71
|
+
## 安全规则
|
|
72
|
+
1. 用户只能访问自己目录下的文件,不能访问其他用户的文件或系统文件
|
|
73
|
+
2. 临时目录(/tmp/、/var/tmp/)必须使用用户 ID 子文件夹(/tmp/${userUuid}/)
|
|
74
|
+
3. 不允许修改系统配置(nginx、docker、systemd、防火墙等)
|
|
75
|
+
4. 不允许执行危险命令(sudo、kill 系统进程、rm -rf /、shutdown 等)
|
|
76
|
+
5. 不允许全局安装包(npm -g、pip 非虚拟环境等),本地安装允许
|
|
77
|
+
6. 允许的操作:运行用户自己项目(node/npm/python)、本地安装依赖、git 操作、创建虚拟环境
|
|
78
|
+
|
|
79
|
+
## 判断原则
|
|
80
|
+
- 如果操作明显安全(在用户目录内操作、本地安装依赖等),判定为 safe
|
|
81
|
+
- 如果操作有潜在风险但无法确定,倾向于判定为 unsafe(宁可误拒不可漏放)
|
|
82
|
+
- 判定为 unsafe 时,在 reason 中给出简短的中文说明,帮助用户理解被拒绝的原因
|
|
83
|
+
|
|
84
|
+
## 响应格式
|
|
85
|
+
仅输出 JSON,不要输出其他任何内容:
|
|
86
|
+
{"safe": true, "reason": "安全操作说明"}
|
|
87
|
+
或
|
|
88
|
+
{"safe": false, "reason": "拒绝原因说明"}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 构建用户提示词
|
|
93
|
+
*/
|
|
94
|
+
function buildUserPrompt(toolName, input, uncertainReasons) {
|
|
95
|
+
const inputStr = typeof input === 'object' ? JSON.stringify(input, null, 2) : String(input);
|
|
96
|
+
return `## 待审查的工具调用
|
|
97
|
+
- 工具名称: ${toolName}
|
|
98
|
+
- 工具参数:
|
|
99
|
+
${inputStr}
|
|
100
|
+
|
|
101
|
+
## 需要额外关注的原因
|
|
102
|
+
${uncertainReasons.map(r => `- ${r}`).join('\n')}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 解析 LLM 响应
|
|
107
|
+
*/
|
|
108
|
+
function parseResponse(responseText) {
|
|
109
|
+
try {
|
|
110
|
+
// 尝试提取 JSON(LLM 可能返回 markdown 包裹的 JSON)
|
|
111
|
+
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
|
112
|
+
if (!jsonMatch) {
|
|
113
|
+
return { allowed: false, reason: 'LLM 审查返回格式异常,出于安全考虑已拒绝' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
117
|
+
|
|
118
|
+
if (typeof parsed.safe !== 'boolean') {
|
|
119
|
+
return { allowed: false, reason: 'LLM 审查返回格式异常,出于安全考虑已拒绝' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return parsed.safe
|
|
123
|
+
? { allowed: true }
|
|
124
|
+
: { allowed: false, reason: parsed.reason || 'LLM 审查判定为不安全操作' };
|
|
125
|
+
} catch {
|
|
126
|
+
return { allowed: false, reason: 'LLM 审查响应解析失败,出于安全考虑已拒绝' };
|
|
127
|
+
}
|
|
128
|
+
}
|