@ian2018cs/agenthub 0.1.54 → 0.1.56
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/README.md +173 -226
- package/dist/assets/index-Darx5qlF.js +162 -0
- package/dist/assets/index-gz7uqQ7s.css +32 -0
- package/dist/assets/{vendor-icons-D0_WToWG.js → vendor-icons-twCb5Vhp.js} +72 -87
- package/dist/index.html +3 -18
- package/package.json +7 -8
- package/server/claude-sdk.js +44 -1
- package/server/cli.js +61 -18
- package/server/index.js +1 -15
- package/server/middleware/auth.js +0 -31
- package/server/projects.js +1 -1
- package/server/services/feishu/command-handler.js +1 -1
- package/server/services/feishu/sdk-bridge.js +1 -1
- 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 +653 -0
- package/dist/assets/index-BdtjtPre.css +0 -32
- package/dist/assets/index-_a9nlevD.js +0 -162
- package/dist/clear-cache.html +0 -85
- package/dist/icons/cursor-white.svg +0 -12
- package/dist/icons/cursor.svg +0 -1
- package/dist/screenshots/cli-selection.png +0 -0
- package/dist/screenshots/desktop-main.png +0 -0
- package/dist/screenshots/mobile-chat.png +0 -0
- package/dist/screenshots/tools-modal.png +0 -0
- package/dist/sw.js +0 -49
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ian2018cs/agenthub",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.56",
|
|
4
4
|
"description": "A web-based UI for AI Agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server/index.js",
|
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
"dist/",
|
|
20
20
|
"README.md"
|
|
21
21
|
],
|
|
22
|
-
"homepage": "https://github.com/IAn2018cs/
|
|
22
|
+
"homepage": "https://github.com/IAn2018cs/AgentHub",
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
25
|
-
"url": "git+https://github.com/IAn2018cs/
|
|
25
|
+
"url": "git+https://github.com/IAn2018cs/AgentHub.git"
|
|
26
26
|
},
|
|
27
27
|
"bugs": {
|
|
28
|
-
"url": "https://github.com/IAn2018cs/
|
|
28
|
+
"url": "https://github.com/IAn2018cs/AgentHub/issues"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
|
|
@@ -34,8 +34,7 @@
|
|
|
34
34
|
"build": "vite build",
|
|
35
35
|
"preview": "vite preview",
|
|
36
36
|
"start": "npm run build && npm run server",
|
|
37
|
-
"release": "./release.sh"
|
|
38
|
-
"setup-feishu-cards": "node scripts/setup-feishu-cards.mjs"
|
|
37
|
+
"release": "./release.sh"
|
|
39
38
|
},
|
|
40
39
|
"keywords": [
|
|
41
40
|
"claude coode",
|
|
@@ -44,14 +43,14 @@
|
|
|
44
43
|
"ui",
|
|
45
44
|
"mobile"
|
|
46
45
|
],
|
|
47
|
-
"author": "
|
|
46
|
+
"author": "IAn2018cs",
|
|
48
47
|
"license": "MIT",
|
|
49
48
|
"publishConfig": {
|
|
50
49
|
"registry": "https://registry.npmjs.org",
|
|
51
50
|
"access": "public"
|
|
52
51
|
},
|
|
53
52
|
"dependencies": {
|
|
54
|
-
"@anthropic-ai/claude-agent-sdk": "^0.2.
|
|
53
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.72",
|
|
55
54
|
"@codemirror/lang-css": "^6.3.1",
|
|
56
55
|
"@codemirror/lang-html": "^6.4.9",
|
|
57
56
|
"@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();
|
|
@@ -204,10 +205,12 @@ function mapCliOptionsToSDK(options = {}) {
|
|
|
204
205
|
console.log(`Using model: ${sdkOptions.model}`);
|
|
205
206
|
|
|
206
207
|
// Map system prompt configuration
|
|
208
|
+
const baseAppend = 'You are an AI assistant running inside AgentHub, a platform that supports diverse work tasks beyond coding.';
|
|
209
|
+
const extraAppend = options.appendSystemPrompt ? `\n\n${options.appendSystemPrompt}` : '';
|
|
207
210
|
sdkOptions.systemPrompt = {
|
|
208
211
|
type: 'preset',
|
|
209
212
|
preset: 'claude_code', // Required to use CLAUDE.md
|
|
210
|
-
|
|
213
|
+
append: baseAppend + extraAppend,
|
|
211
214
|
};
|
|
212
215
|
|
|
213
216
|
// Map setting sources for CLAUDE.md loading
|
|
@@ -520,6 +523,46 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|
|
520
523
|
tempImagePaths = imageResult.tempImagePaths;
|
|
521
524
|
tempDir = imageResult.tempDir;
|
|
522
525
|
|
|
526
|
+
// ===== 安全守卫(PreToolUse hook,最高优先级)=====
|
|
527
|
+
// 使用 PreToolUse hook 而非 canUseTool 回调,因为 hook 会在每次工具执行前触发,
|
|
528
|
+
// 包括已被 allowedTools 自动放行的工具和 bypassPermissions 模式下的工具。
|
|
529
|
+
// canUseTool 仅在 CLI 发送 can_use_tool 控制消息时触发(即工具未被预批准时)。
|
|
530
|
+
if (userUuid) {
|
|
531
|
+
sdkOptions.hooks = {
|
|
532
|
+
...(sdkOptions.hooks || {}),
|
|
533
|
+
PreToolUse: [
|
|
534
|
+
...((sdkOptions.hooks || {}).PreToolUse || []),
|
|
535
|
+
{
|
|
536
|
+
hooks: [async (hookInput) => {
|
|
537
|
+
try {
|
|
538
|
+
const guardResult = await evaluateToolGuard(hookInput.tool_name, hookInput.tool_input, {
|
|
539
|
+
userUuid,
|
|
540
|
+
cwd: sdkOptions.cwd,
|
|
541
|
+
});
|
|
542
|
+
if (!guardResult.allowed) {
|
|
543
|
+
console.log(`[ToolGuard] DENIED ${hookInput.tool_name} for user ${userUuid}: ${guardResult.reason}`);
|
|
544
|
+
return {
|
|
545
|
+
hookSpecificOutput: {
|
|
546
|
+
hookEventName: 'PreToolUse',
|
|
547
|
+
permissionDecision: 'deny',
|
|
548
|
+
permissionDecisionReason: `[系统安全策略] ${guardResult.reason}`,
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
} catch (err) {
|
|
553
|
+
console.error(`[ToolGuard] Error evaluating ${hookInput.tool_name}:`, err.message);
|
|
554
|
+
// 守卫出错时不阻塞正常流程,降级为原有权限控制
|
|
555
|
+
}
|
|
556
|
+
if (process.env.TOOL_GUARD_VERBOSE === 'true') {
|
|
557
|
+
console.log(`[ToolGuard] HOOK-ALLOW ${hookInput.tool_name}`);
|
|
558
|
+
}
|
|
559
|
+
return {};
|
|
560
|
+
}]
|
|
561
|
+
}
|
|
562
|
+
]
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
523
566
|
// Gate tool usage with explicit UI approval when not auto-approved.
|
|
524
567
|
// This does not render UI or persist permissions; it only bridges to the UI
|
|
525
568
|
// via WebSocket and waits for the response, introduced so tool calls pause
|
package/server/cli.js
CHANGED
|
@@ -135,9 +135,9 @@ function showStatus() {
|
|
|
135
135
|
|
|
136
136
|
console.log('\n' + c.dim('═'.repeat(60)));
|
|
137
137
|
console.log(`\n${c.tip('[TIP]')} Hints:`);
|
|
138
|
-
console.log(` ${c.dim('>')} Use ${c.bright('
|
|
139
|
-
console.log(` ${c.dim('>')} Use ${c.bright('
|
|
140
|
-
console.log(` ${c.dim('>')} Run ${c.bright('
|
|
138
|
+
console.log(` ${c.dim('>')} Use ${c.bright('agenthub --port 8080')} to run on a custom port`);
|
|
139
|
+
console.log(` ${c.dim('>')} Use ${c.bright('agenthub --database-path /path/to/db')} for custom database`);
|
|
140
|
+
console.log(` ${c.dim('>')} Run ${c.bright('agenthub help')} for all options`);
|
|
141
141
|
console.log(` ${c.dim('>')} Access the UI at http://localhost:${process.env.PORT || '3001'}\n`);
|
|
142
142
|
}
|
|
143
143
|
|
|
@@ -149,8 +149,7 @@ function showHelp() {
|
|
|
149
149
|
╚═══════════════════════════════════════════════════════════════╝
|
|
150
150
|
|
|
151
151
|
Usage:
|
|
152
|
-
|
|
153
|
-
cloudcli [command] [options]
|
|
152
|
+
agenthub [command] [options]
|
|
154
153
|
|
|
155
154
|
Commands:
|
|
156
155
|
start Start the Claude Code UI server (default)
|
|
@@ -167,11 +166,11 @@ Options:
|
|
|
167
166
|
-v, --version Show version information
|
|
168
167
|
|
|
169
168
|
Examples:
|
|
170
|
-
$
|
|
171
|
-
$
|
|
172
|
-
$
|
|
173
|
-
$
|
|
174
|
-
$
|
|
169
|
+
$ agenthub # Start with defaults
|
|
170
|
+
$ agenthub --port 8080 # Start on port 8080
|
|
171
|
+
$ agenthub -p 3000 # Short form for port
|
|
172
|
+
$ agenthub start --port 4000 # Explicit start command
|
|
173
|
+
$ agenthub status # Show configuration
|
|
175
174
|
|
|
176
175
|
Environment Variables:
|
|
177
176
|
PORT Set server port (default: 3001)
|
|
@@ -180,10 +179,10 @@ Environment Variables:
|
|
|
180
179
|
CONTEXT_WINDOW Set context window size (default: 160000)
|
|
181
180
|
|
|
182
181
|
Documentation:
|
|
183
|
-
${packageJson.homepage || 'https://github.com/
|
|
182
|
+
${packageJson.homepage || 'https://github.com/IAn2018cs/AgentHub'}
|
|
184
183
|
|
|
185
184
|
Report Issues:
|
|
186
|
-
${packageJson.bugs?.url || 'https://github.com/
|
|
185
|
+
${packageJson.bugs?.url || 'https://github.com/IAn2018cs/AgentHub/issues'}
|
|
187
186
|
`);
|
|
188
187
|
}
|
|
189
188
|
|
|
@@ -203,17 +202,49 @@ function isNewerVersion(v1, v2) {
|
|
|
203
202
|
return false;
|
|
204
203
|
}
|
|
205
204
|
|
|
205
|
+
// Download a file with optional GitHub token auth, following redirects
|
|
206
|
+
async function downloadWithAuth(url, destPath, token) {
|
|
207
|
+
const https = await import('https');
|
|
208
|
+
const fs = await import('fs');
|
|
209
|
+
return new Promise((resolve, reject) => {
|
|
210
|
+
const headers = { 'User-Agent': 'agenthub-cli' };
|
|
211
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
212
|
+
const download = (downloadUrl) => {
|
|
213
|
+
const req = https.get(downloadUrl, { headers }, (res) => {
|
|
214
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
215
|
+
download(res.headers.location);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (res.statusCode !== 200) {
|
|
219
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const file = fs.createWriteStream(destPath);
|
|
223
|
+
res.pipe(file);
|
|
224
|
+
file.on('finish', () => file.close(resolve));
|
|
225
|
+
file.on('error', reject);
|
|
226
|
+
});
|
|
227
|
+
req.on('error', reject);
|
|
228
|
+
req.setTimeout(60000, () => { req.destroy(); reject(new Error('Download timeout')); });
|
|
229
|
+
};
|
|
230
|
+
download(url);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
206
234
|
// Check for updates via GitHub Releases
|
|
207
235
|
async function checkForUpdates(silent = false) {
|
|
208
236
|
try {
|
|
209
237
|
const https = await import('https');
|
|
210
|
-
const REPO = 'IAn2018cs/
|
|
238
|
+
const REPO = 'IAn2018cs/AgentHub';
|
|
211
239
|
const currentVersion = packageJson.version;
|
|
240
|
+
const githubToken = process.env.GITHUB_TOKEN;
|
|
241
|
+
const headers = { 'User-Agent': 'agenthub-cli' };
|
|
242
|
+
if (githubToken) headers['Authorization'] = `Bearer ${githubToken}`;
|
|
212
243
|
|
|
213
244
|
const latestVersion = await new Promise((resolve, reject) => {
|
|
214
245
|
const req = https.get(
|
|
215
246
|
`https://api.github.com/repos/${REPO}/releases/latest`,
|
|
216
|
-
{ headers
|
|
247
|
+
{ headers },
|
|
217
248
|
(res) => {
|
|
218
249
|
let data = '';
|
|
219
250
|
res.on('data', chunk => data += chunk);
|
|
@@ -270,15 +301,27 @@ async function updatePackage() {
|
|
|
270
301
|
|
|
271
302
|
console.log(`${c.info('[INFO]')} Updating from ${currentVersion} to ${latestVersion}...`);
|
|
272
303
|
const tgzName = `ian2018cs-agenthub-${latestVersion}.tgz`;
|
|
273
|
-
const url = `https://github.com/IAn2018cs/
|
|
274
|
-
|
|
304
|
+
const url = `https://github.com/IAn2018cs/AgentHub/releases/download/v${latestVersion}/${tgzName}`;
|
|
305
|
+
const githubToken = process.env.GITHUB_TOKEN;
|
|
306
|
+
if (githubToken) {
|
|
307
|
+
const os = await import('os');
|
|
308
|
+
const path = await import('path');
|
|
309
|
+
const fsSync = await import('fs');
|
|
310
|
+
const tmpFile = path.join(os.tmpdir(), tgzName);
|
|
311
|
+
console.log(`${c.info('[INFO]')} Downloading with GitHub token...`);
|
|
312
|
+
await downloadWithAuth(url, tmpFile, githubToken);
|
|
313
|
+
execSync(`npm install -g "${tmpFile}"`, { stdio: 'inherit' });
|
|
314
|
+
fsSync.unlinkSync(tmpFile);
|
|
315
|
+
} else {
|
|
316
|
+
execSync(`npm install -g "${url}"`, { stdio: 'inherit' });
|
|
317
|
+
}
|
|
275
318
|
console.log(`${c.ok('[OK]')} Update complete!`);
|
|
276
319
|
console.log(` Please restart the server to use the new version:`);
|
|
277
320
|
console.log(` - pm2: ${c.bright('pm2 restart agenthub')} or ${c.bright('./pm2.sh restart')}`);
|
|
278
321
|
console.log(` - manual: stop and run ${c.bright('agenthub')} again`);
|
|
279
322
|
} catch (e) {
|
|
280
323
|
console.error(`${c.error('[ERROR]')} Update failed: ${e.message}`);
|
|
281
|
-
console.log(`${c.tip('[TIP]')} Check releases: https://github.com/IAn2018cs/
|
|
324
|
+
console.log(`${c.tip('[TIP]')} Check releases: https://github.com/IAn2018cs/AgentHub/releases`);
|
|
282
325
|
}
|
|
283
326
|
}
|
|
284
327
|
|
|
@@ -392,7 +435,7 @@ async function main() {
|
|
|
392
435
|
break;
|
|
393
436
|
default:
|
|
394
437
|
console.error(`\n❌ Unknown command: ${command}`);
|
|
395
|
-
console.log(' Run "
|
|
438
|
+
console.log(' Run "agenthub help" for usage information.\n');
|
|
396
439
|
process.exit(1);
|
|
397
440
|
}
|
|
398
441
|
}
|
package/server/index.js
CHANGED
|
@@ -38,7 +38,6 @@ import os from 'os';
|
|
|
38
38
|
import http from 'http';
|
|
39
39
|
import cors from 'cors';
|
|
40
40
|
import { promises as fsPromises } from 'fs';
|
|
41
|
-
import { spawn } from 'child_process';
|
|
42
41
|
import pty from 'node-pty';
|
|
43
42
|
import fetch from 'node-fetch';
|
|
44
43
|
import mime from 'mime-types';
|
|
@@ -225,19 +224,6 @@ const wss = new WebSocketServer({
|
|
|
225
224
|
verifyClient: (info) => {
|
|
226
225
|
console.log('WebSocket connection attempt to:', info.req.url);
|
|
227
226
|
|
|
228
|
-
// Platform mode: always allow connection
|
|
229
|
-
if (process.env.VITE_IS_PLATFORM === 'true') {
|
|
230
|
-
const user = authenticateWebSocket(null); // Will return first user
|
|
231
|
-
if (!user) {
|
|
232
|
-
console.log('[WARN] Platform mode: No user found in database');
|
|
233
|
-
return false;
|
|
234
|
-
}
|
|
235
|
-
info.req.user = user;
|
|
236
|
-
console.log('[OK] Platform mode WebSocket authenticated for user:', user.username);
|
|
237
|
-
return true;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Normal mode: verify token
|
|
241
227
|
// Extract token from query parameters or headers
|
|
242
228
|
const url = new URL(info.req.url, 'http://localhost');
|
|
243
229
|
const token = url.searchParams.get('token') ||
|
|
@@ -2359,7 +2345,7 @@ async function startServer() {
|
|
|
2359
2345
|
console.log('');
|
|
2360
2346
|
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://0.0.0.0:' + PORT)}`);
|
|
2361
2347
|
console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
|
|
2362
|
-
console.log(`${c.tip('[TIP]')} Run "
|
|
2348
|
+
console.log(`${c.tip('[TIP]')} Run "agenthub status" for full configuration details`);
|
|
2363
2349
|
console.log('');
|
|
2364
2350
|
|
|
2365
2351
|
// Start usage scanner service
|
|
@@ -20,22 +20,6 @@ const validateApiKey = (req, res, next) => {
|
|
|
20
20
|
|
|
21
21
|
// JWT authentication middleware
|
|
22
22
|
const authenticateToken = async (req, res, next) => {
|
|
23
|
-
// Platform mode: use single database user
|
|
24
|
-
if (process.env.VITE_IS_PLATFORM === 'true') {
|
|
25
|
-
try {
|
|
26
|
-
const user = userDb.getFirstUser();
|
|
27
|
-
if (!user) {
|
|
28
|
-
return res.status(500).json({ error: 'Platform mode: No user found in database' });
|
|
29
|
-
}
|
|
30
|
-
req.user = user;
|
|
31
|
-
return next();
|
|
32
|
-
} catch (error) {
|
|
33
|
-
console.error('Platform mode error:', error);
|
|
34
|
-
return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Normal OSS JWT validation
|
|
39
23
|
const authHeader = req.headers['authorization'];
|
|
40
24
|
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
|
41
25
|
|
|
@@ -88,21 +72,6 @@ const generateToken = (user) => {
|
|
|
88
72
|
|
|
89
73
|
// WebSocket authentication function
|
|
90
74
|
const authenticateWebSocket = (token) => {
|
|
91
|
-
// Platform mode: bypass token validation, return first user
|
|
92
|
-
if (process.env.VITE_IS_PLATFORM === 'true') {
|
|
93
|
-
try {
|
|
94
|
-
const user = userDb.getFirstUser();
|
|
95
|
-
if (user) {
|
|
96
|
-
return { userId: user.id, username: user.username };
|
|
97
|
-
}
|
|
98
|
-
return null;
|
|
99
|
-
} catch (error) {
|
|
100
|
-
console.error('Platform mode WebSocket error:', error);
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Normal OSS JWT validation
|
|
106
75
|
if (!token) {
|
|
107
76
|
return null;
|
|
108
77
|
}
|
package/server/projects.js
CHANGED
|
@@ -888,7 +888,7 @@ async function addProjectManually(projectPath, displayName = null, userUuid) {
|
|
|
888
888
|
}
|
|
889
889
|
|
|
890
890
|
// Allow adding projects even if the directory exists - this enables tracking
|
|
891
|
-
// existing Claude Code
|
|
891
|
+
// existing Claude Code projects in the UI
|
|
892
892
|
|
|
893
893
|
const createdAt = new Date().toISOString();
|
|
894
894
|
|
|
@@ -397,7 +397,7 @@ class FakeSendWriter {
|
|
|
397
397
|
* @param {string} opts.messageId 原始消息 ID
|
|
398
398
|
* @param {string} opts.content 用户输入文字
|
|
399
399
|
* @param {Array|null} opts.images 图片附件(已转换为 data URI 格式)
|
|
400
|
-
* @param {string} opts.userUuid
|
|
400
|
+
* @param {string} opts.userUuid agenthub 用户 UUID
|
|
401
401
|
* @param {Object} opts.state feishu_session_state 行
|
|
402
402
|
* @param {Object} opts.larkClient LarkClient 实例
|
|
403
403
|
* @param {Map} opts.pendingApprovals engine 共享的 pendingApprovals Map
|
|
@@ -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 非虚拟环境、pip --user 等);pip 必须在虚拟环境内执行(venv/bin/pip 或 .venv/bin/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
|
+
}
|