ai-helper-agent 1.0.0 → 1.2.0
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 +123 -13
- package/bin/agent.js +295 -54
- package/package.json +1 -1
- package/src/auth.js +24 -53
- package/src/config.js +75 -24
- package/src/executor.js +87 -30
- package/src/logger.js +204 -0
- package/src/search.js +375 -0
- package/src/security.js +34 -24
- package/src/server.js +257 -42
package/src/search.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// agent/src/search.js - 文件搜索(fd/rg 优先 + Node.js 回退)
|
|
2
|
+
import { spawn } from 'child_process';
|
|
3
|
+
import { readdirSync, statSync, readFileSync, existsSync } from 'fs';
|
|
4
|
+
import { join, basename, extname, resolve } from 'path';
|
|
5
|
+
import { checkPath } from './security.js';
|
|
6
|
+
|
|
7
|
+
// 缓存检测结果,避免每次启动重复检测
|
|
8
|
+
let fdAvailable = null;
|
|
9
|
+
let rgAvailable = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 检测系统是否安装了 fd(快速文件搜索,带 5s 超时)
|
|
13
|
+
*/
|
|
14
|
+
function checkFdAvailable() {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const proc = spawn('fd', ['--version'], { stdio: 'ignore' });
|
|
17
|
+
const timeout = setTimeout(() => { proc.kill(); resolve(false); }, 5000);
|
|
18
|
+
proc.on('close', (code) => { clearTimeout(timeout); resolve(code === 0); });
|
|
19
|
+
proc.on('error', () => { clearTimeout(timeout); resolve(false); });
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 检测系统是否安装了 rg(ripgrep 快速内容搜索,带 5s 超时)
|
|
25
|
+
*/
|
|
26
|
+
function checkRgAvailable() {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
const proc = spawn('rg', ['--version'], { stdio: 'ignore' });
|
|
29
|
+
const timeout = setTimeout(() => { proc.kill(); resolve(false); }, 5000);
|
|
30
|
+
proc.on('close', (code) => { clearTimeout(timeout); resolve(code === 0); });
|
|
31
|
+
proc.on('error', () => { clearTimeout(timeout); resolve(false); });
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 初始化检测 fd/rg 可用性(仅允许单次初始化)
|
|
37
|
+
*/
|
|
38
|
+
let searchToolsInitPromise = null;
|
|
39
|
+
|
|
40
|
+
export async function initSearchTools() {
|
|
41
|
+
if (searchToolsInitPromise) return searchToolsInitPromise;
|
|
42
|
+
searchToolsInitPromise = (async () => {
|
|
43
|
+
const [fd, rg] = await Promise.all([checkFdAvailable(), checkRgAvailable()]);
|
|
44
|
+
fdAvailable = fd;
|
|
45
|
+
rgAvailable = rg;
|
|
46
|
+
console.log(`[Agent] fd (文件搜索): ${fd ? '✅ 可用' : '❌ 未安装,使用 Node.js 回退'}`);
|
|
47
|
+
console.log(`[Agent] rg (内容搜索): ${rg ? '✅ 可用' : '❌ 未安装,使用 Node.js 回退'}`);
|
|
48
|
+
return { fd: fdAvailable, rg: rgAvailable };
|
|
49
|
+
})();
|
|
50
|
+
return searchToolsInitPromise;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 获取搜索工具可用性
|
|
55
|
+
*/
|
|
56
|
+
export function getSearchToolsAvailable() {
|
|
57
|
+
return { fd: fdAvailable, rg: rgAvailable };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 默认忽略的目录(模拟 .gitignore 行为)
|
|
61
|
+
const DEFAULT_IGNORE_DIRS = new Set([
|
|
62
|
+
'node_modules', '.git', '.svn', '.hg',
|
|
63
|
+
'__pycache__', '.cache', 'dist', 'build',
|
|
64
|
+
'.next', '.nuxt', 'target', 'vendor',
|
|
65
|
+
'.idea', '.vscode', '.DS_Store'
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 文件名模式匹配(支持 glob:*.js, test*.ts, ** / *.js 等)
|
|
70
|
+
*/
|
|
71
|
+
function matchGlob(filename, pattern) {
|
|
72
|
+
// 转换 glob 为简单正则
|
|
73
|
+
const regexStr = pattern
|
|
74
|
+
.replace(/\./g, '\\.')
|
|
75
|
+
.replace(/\*\*/g, '<<<ANY>>>') // 先保护 **
|
|
76
|
+
.replace(/\*/g, '[^/]*')
|
|
77
|
+
.replace(/<<<ANY>>>/g, '.*')
|
|
78
|
+
.replace(/\?/g, '.');
|
|
79
|
+
const regex = new RegExp('^' + regexStr + '$', 'i');
|
|
80
|
+
return regex.test(filename);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 使用 fd 搜索文件(快速)
|
|
85
|
+
*/
|
|
86
|
+
function searchWithFd(rootPath, filePattern, recursive, maxResults) {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
const args = ['--glob', filePattern, '--type', 'f', '--max-results', String(maxResults)];
|
|
89
|
+
if (!recursive) args.push('--max-depth', '1');
|
|
90
|
+
args.push(rootPath);
|
|
91
|
+
|
|
92
|
+
const proc = spawn('fd', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
93
|
+
const stdout = [];
|
|
94
|
+
const stderr = [];
|
|
95
|
+
let timedOut = false;
|
|
96
|
+
|
|
97
|
+
const timeout = setTimeout(() => {
|
|
98
|
+
timedOut = true;
|
|
99
|
+
proc.kill();
|
|
100
|
+
resolve([]);
|
|
101
|
+
}, 30000);
|
|
102
|
+
|
|
103
|
+
proc.stdout.on('data', (chunk) => stdout.push(chunk));
|
|
104
|
+
proc.stderr.on('data', (chunk) => stderr.push(chunk));
|
|
105
|
+
|
|
106
|
+
proc.on('close', (code) => {
|
|
107
|
+
clearTimeout(timeout);
|
|
108
|
+
if (timedOut) return;
|
|
109
|
+
const output = Buffer.concat(stdout).toString('utf-8').trim();
|
|
110
|
+
const lines = output ? output.split('\n').filter(Boolean) : [];
|
|
111
|
+
const results = lines.map(p => {
|
|
112
|
+
try {
|
|
113
|
+
const s = statSync(p);
|
|
114
|
+
return { path: p, name: basename(p), size: s.size, mtime: s.mtimeMs };
|
|
115
|
+
} catch {
|
|
116
|
+
return { path: p, name: basename(p), size: 0, mtime: 0 };
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
resolve(results);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
proc.on('error', () => { clearTimeout(timeout); resolve([]); });
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Node.js 原生递归搜索文件(回退方案)
|
|
128
|
+
*/
|
|
129
|
+
function searchFilesNative(rootPath, filePattern, recursive, maxResults) {
|
|
130
|
+
const results = [];
|
|
131
|
+
|
|
132
|
+
function walk(dir, depth) {
|
|
133
|
+
if (results.length >= maxResults) return;
|
|
134
|
+
if (!recursive && depth > 1) return;
|
|
135
|
+
|
|
136
|
+
let entries;
|
|
137
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); }
|
|
138
|
+
catch { return; }
|
|
139
|
+
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
if (results.length >= maxResults) return;
|
|
142
|
+
const fullPath = join(dir, entry.name);
|
|
143
|
+
|
|
144
|
+
if (entry.isDirectory()) {
|
|
145
|
+
if (DEFAULT_IGNORE_DIRS.has(entry.name)) continue;
|
|
146
|
+
if (entry.name.startsWith('.')) continue;
|
|
147
|
+
walk(fullPath, depth + 1);
|
|
148
|
+
} else if (entry.isFile()) {
|
|
149
|
+
if (matchGlob(entry.name, filePattern)) {
|
|
150
|
+
try {
|
|
151
|
+
const s = statSync(fullPath);
|
|
152
|
+
results.push({ path: fullPath, name: entry.name, size: s.size, mtime: s.mtimeMs });
|
|
153
|
+
} catch {
|
|
154
|
+
results.push({ path: fullPath, name: entry.name, size: 0, mtime: 0 });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
walk(rootPath, 0);
|
|
162
|
+
return results;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 搜索文件(按文件名模式)
|
|
167
|
+
* @param {string} rootPath - 搜索根目录
|
|
168
|
+
* @param {string} filePattern - 文件名模式(glob),如 "*.js"
|
|
169
|
+
* @param {boolean} recursive - 是否递归
|
|
170
|
+
* @param {number} maxResults - 最大结果数
|
|
171
|
+
* @returns {Array<{path, name, size, mtime}>}
|
|
172
|
+
*/
|
|
173
|
+
export async function searchFiles(rootPath, filePattern = '*', recursive = true, maxResults = 200) {
|
|
174
|
+
const pathCheck = checkPath(rootPath);
|
|
175
|
+
if (!pathCheck.allowed) {
|
|
176
|
+
return { success: false, error: pathCheck.reason };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const resolved = pathCheck.resolved;
|
|
180
|
+
if (!existsSync(resolved)) {
|
|
181
|
+
return { success: false, error: '搜索路径不存在' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const s = statSync(resolved);
|
|
185
|
+
if (!s.isDirectory()) {
|
|
186
|
+
return { success: false, error: '搜索路径不是目录' };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let results;
|
|
190
|
+
if (fdAvailable) {
|
|
191
|
+
results = await searchWithFd(resolved, filePattern, recursive, maxResults);
|
|
192
|
+
} else {
|
|
193
|
+
results = searchFilesNative(resolved, filePattern, recursive, maxResults);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
success: true,
|
|
198
|
+
results,
|
|
199
|
+
total: results.length,
|
|
200
|
+
engine: fdAvailable ? 'fd' : 'nodejs',
|
|
201
|
+
path: resolved,
|
|
202
|
+
pattern: filePattern
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 使用 rg 搜索文件内容(快速)
|
|
208
|
+
*/
|
|
209
|
+
function searchContentWithRg(rootPath, pattern, filePattern, maxResults, contextLines) {
|
|
210
|
+
return new Promise((resolve) => {
|
|
211
|
+
const args = [
|
|
212
|
+
'--no-heading', '--line-number',
|
|
213
|
+
'--max-count', String(maxResults),
|
|
214
|
+
'-C', String(contextLines)
|
|
215
|
+
];
|
|
216
|
+
if (filePattern) {
|
|
217
|
+
args.push('--glob', filePattern);
|
|
218
|
+
}
|
|
219
|
+
args.push(pattern, rootPath);
|
|
220
|
+
|
|
221
|
+
const proc = spawn('rg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
222
|
+
const stdout = [];
|
|
223
|
+
let timedOut = false;
|
|
224
|
+
|
|
225
|
+
const timeout = setTimeout(() => {
|
|
226
|
+
timedOut = true;
|
|
227
|
+
proc.kill();
|
|
228
|
+
resolve([]);
|
|
229
|
+
}, 30000);
|
|
230
|
+
|
|
231
|
+
proc.stdout.on('data', (chunk) => stdout.push(chunk));
|
|
232
|
+
|
|
233
|
+
proc.on('close', (code) => {
|
|
234
|
+
clearTimeout(timeout);
|
|
235
|
+
if (timedOut) return;
|
|
236
|
+
if (code !== 0 && code !== 1) { resolve([]); return; } // 1 = no matches
|
|
237
|
+
const output = Buffer.concat(stdout).toString('utf-8').trim();
|
|
238
|
+
resolve(parseRgOutput(output, contextLines));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
proc.on('error', () => { clearTimeout(timeout); resolve([]); });
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 解析 rg 输出格式:file:line:content
|
|
247
|
+
* 上下文行格式:file-line:content
|
|
248
|
+
* 注:Windows 路径含 `:` 可能导致解析偏差,建议在 WSL/Mac/Linux 中使用
|
|
249
|
+
*/
|
|
250
|
+
function parseRgOutput(output, contextLines) {
|
|
251
|
+
if (!output) return [];
|
|
252
|
+
const lines = output.split('\n');
|
|
253
|
+
const results = [];
|
|
254
|
+
|
|
255
|
+
for (const line of lines) {
|
|
256
|
+
// 匹配行分隔符(rg 用 -- 分隔不同文件的匹配)
|
|
257
|
+
if (line === '--') continue;
|
|
258
|
+
|
|
259
|
+
// 匹配文件名:行号:内容(非贪婪匹配文件名)
|
|
260
|
+
const match = line.match(/^(.+?):(\d+):(.*)$/);
|
|
261
|
+
if (match) {
|
|
262
|
+
results.push({
|
|
263
|
+
file: match[1],
|
|
264
|
+
line: parseInt(match[2], 10),
|
|
265
|
+
content: match[3]
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return results;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Node.js 原生搜索文件内容(回退方案)
|
|
275
|
+
*/
|
|
276
|
+
function searchContentNative(rootPath, pattern, filePattern, caseSensitive, maxResults, contextLines) {
|
|
277
|
+
const results = [];
|
|
278
|
+
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
|
|
279
|
+
|
|
280
|
+
function walk(dir) {
|
|
281
|
+
if (results.length >= maxResults) return;
|
|
282
|
+
|
|
283
|
+
let entries;
|
|
284
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); }
|
|
285
|
+
catch { return; }
|
|
286
|
+
|
|
287
|
+
for (const entry of entries) {
|
|
288
|
+
if (results.length >= maxResults) return;
|
|
289
|
+
const fullPath = join(dir, entry.name);
|
|
290
|
+
|
|
291
|
+
if (entry.isDirectory()) {
|
|
292
|
+
if (DEFAULT_IGNORE_DIRS.has(entry.name)) continue;
|
|
293
|
+
if (entry.name.startsWith('.')) continue;
|
|
294
|
+
walk(fullPath);
|
|
295
|
+
} else if (entry.isFile()) {
|
|
296
|
+
if (filePattern && !matchGlob(entry.name, filePattern)) continue;
|
|
297
|
+
// 跳过压缩和映射文件
|
|
298
|
+
if (entry.name.endsWith('.min.js')) continue;
|
|
299
|
+
if (entry.name.endsWith('.map')) continue;
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const s = statSync(fullPath);
|
|
303
|
+
if (s.size > 1024 * 1024) continue; // 跳过 1MB 以上的文件
|
|
304
|
+
|
|
305
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
306
|
+
const fileLines = content.split('\n');
|
|
307
|
+
|
|
308
|
+
for (let i = 0; i < fileLines.length; i++) {
|
|
309
|
+
if (results.length >= maxResults) break;
|
|
310
|
+
const line = caseSensitive ? fileLines[i] : fileLines[i].toLowerCase();
|
|
311
|
+
if (line.includes(searchPattern)) {
|
|
312
|
+
const ctx = [];
|
|
313
|
+
const start = Math.max(0, i - contextLines);
|
|
314
|
+
const end = Math.min(fileLines.length - 1, i + contextLines);
|
|
315
|
+
for (let j = start; j <= end; j++) {
|
|
316
|
+
ctx.push({ line: j + 1, content: fileLines[j], isMatch: j === i });
|
|
317
|
+
}
|
|
318
|
+
results.push({
|
|
319
|
+
file: fullPath,
|
|
320
|
+
line: i + 1,
|
|
321
|
+
content: fileLines[i],
|
|
322
|
+
context: ctx
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
} catch {}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
walk(rootPath);
|
|
332
|
+
return results;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 搜索文件内容
|
|
337
|
+
* @param {string} rootPath - 搜索根目录
|
|
338
|
+
* @param {string} pattern - 搜索文本
|
|
339
|
+
* @param {string} [filePattern] - 可选文件类型过滤,如 "*.js"
|
|
340
|
+
* @param {boolean} caseSensitive - 是否大小写敏感
|
|
341
|
+
* @param {number} maxResults - 最大结果数
|
|
342
|
+
* @param {number} contextLines - 上下文行数
|
|
343
|
+
* @returns {{ success: boolean, results: Array, total: number, engine: string }}
|
|
344
|
+
*/
|
|
345
|
+
export async function searchContent(rootPath, pattern, filePattern = null, caseSensitive = false, maxResults = 100, contextLines = 2) {
|
|
346
|
+
const pathCheck = checkPath(rootPath);
|
|
347
|
+
if (!pathCheck.allowed) {
|
|
348
|
+
return { success: false, error: pathCheck.reason };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const resolved = pathCheck.resolved;
|
|
352
|
+
if (!existsSync(resolved)) {
|
|
353
|
+
return { success: false, error: '搜索路径不存在' };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (!pattern || !pattern.trim()) {
|
|
357
|
+
return { success: false, error: '搜索内容不能为空' };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let results;
|
|
361
|
+
if (rgAvailable) {
|
|
362
|
+
results = await searchContentWithRg(resolved, pattern, filePattern, maxResults, contextLines);
|
|
363
|
+
} else {
|
|
364
|
+
results = searchContentNative(resolved, pattern, filePattern, caseSensitive, maxResults, contextLines);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
success: true,
|
|
369
|
+
results,
|
|
370
|
+
total: results.length,
|
|
371
|
+
engine: rgAvailable ? 'rg' : 'nodejs',
|
|
372
|
+
path: resolved,
|
|
373
|
+
pattern
|
|
374
|
+
};
|
|
375
|
+
}
|
package/src/security.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// agent/src/security.js - 安全管控:文件沙箱 + 命令分级
|
|
2
|
-
import { resolve, normalize } from 'path';
|
|
2
|
+
import { resolve, normalize, sep } from 'path';
|
|
3
3
|
import { realpathSync } from 'fs';
|
|
4
4
|
import { loadConfig } from './config.js';
|
|
5
5
|
|
|
@@ -20,23 +20,22 @@ function checkPath(pathStr) {
|
|
|
20
20
|
const config = loadConfig();
|
|
21
21
|
const allowedPaths = config.allowedPaths.length > 0 ? config.allowedPaths : [config.workdir];
|
|
22
22
|
|
|
23
|
-
//
|
|
23
|
+
// 先做前缀检查(快速路径,兼容 Windows/Unix)
|
|
24
24
|
let prefixMatch = false;
|
|
25
|
-
let matchedAllowed = null;
|
|
26
25
|
for (const allowed of allowedPaths) {
|
|
27
26
|
const allowedResolved = resolve(allowed);
|
|
28
|
-
|
|
27
|
+
// 统一使用 OS 分隔符构造前缀
|
|
28
|
+
const prefix = allowedResolved.endsWith(sep) ? allowedResolved : allowedResolved + sep;
|
|
29
|
+
if (normalized.startsWith(prefix) || normalized === allowedResolved) {
|
|
29
30
|
prefixMatch = true;
|
|
30
|
-
matchedAllowed = allowedResolved;
|
|
31
31
|
break;
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
if (!prefixMatch) {
|
|
35
|
-
return { allowed: false, reason:
|
|
35
|
+
return { allowed: false, reason: '路径不在允许的目录范围内' };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// realpath 校验:防止符号链接绕过
|
|
39
|
-
// 注意:文件可能尚未创建,realpathSync 会抛出 ENOENT
|
|
40
39
|
let realPath;
|
|
41
40
|
try {
|
|
42
41
|
realPath = realpathSync(resolved);
|
|
@@ -44,7 +43,7 @@ function checkPath(pathStr) {
|
|
|
44
43
|
if (err.code === 'ENOENT') {
|
|
45
44
|
// 文件不存在时,检查父目录
|
|
46
45
|
const parentDir = resolve(resolved, '..');
|
|
47
|
-
realPath = realpathSync(parentDir) +
|
|
46
|
+
realPath = realpathSync(parentDir) + sep + normalized.split(sep).pop();
|
|
48
47
|
} else {
|
|
49
48
|
return { allowed: false, reason: `无法解析路径: ${err.message}` };
|
|
50
49
|
}
|
|
@@ -53,12 +52,13 @@ function checkPath(pathStr) {
|
|
|
53
52
|
// 对 realPath 再次做前缀检查
|
|
54
53
|
for (const allowed of allowedPaths) {
|
|
55
54
|
const allowedResolved = resolve(allowed);
|
|
56
|
-
|
|
55
|
+
const prefix = allowedResolved.endsWith(sep) ? allowedResolved : allowedResolved + sep;
|
|
56
|
+
if (realPath.startsWith(prefix) || realPath === allowedResolved) {
|
|
57
57
|
return { allowed: true, resolved: normalized };
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
return { allowed: false, reason:
|
|
61
|
+
return { allowed: false, reason: '路径指向了允许范围之外的位置' };
|
|
62
62
|
} catch (err) {
|
|
63
63
|
return { allowed: false, reason: `路径检查异常: ${err.message}` };
|
|
64
64
|
}
|
|
@@ -84,19 +84,22 @@ const BLACKLIST_PATTERNS = [
|
|
|
84
84
|
/^\s*>\s*\/etc\/sudoers/,
|
|
85
85
|
// fork bomb
|
|
86
86
|
/:\(\)\s*{\s*:\s*\|\s*:\s*&\s*};\s*:/,
|
|
87
|
-
|
|
87
|
+
// shebang 直接执行(出现在命令开头)
|
|
88
|
+
/^\s*#!/,
|
|
88
89
|
// 恶意管道执行
|
|
89
90
|
/(curl|wget|lynx|links)\s+.*\|\s*(ba)?sh/,
|
|
90
91
|
/git\s+clone\s+.*\|\s*(ba)?sh/,
|
|
91
92
|
// 修改根目录权限
|
|
92
93
|
/^\s*chmod\s+(-R\s+)?(0?777|a\+rwx)\s+\//,
|
|
93
94
|
/^\s*chown\s+-R\s+\S+\s+\//,
|
|
94
|
-
// Shell
|
|
95
|
-
/`[^`]*`/,
|
|
96
|
-
/\$\([^)]*\)/,
|
|
97
|
-
/\$\{[^}]*\}/, // ${} 变量替换(可能含命令)
|
|
95
|
+
// Shell 命令替换注入(仅匹配反引号内的可执行命令模式)
|
|
96
|
+
/`[^`]*`/,
|
|
97
|
+
/\$\([^)]*\)/,
|
|
98
98
|
];
|
|
99
99
|
|
|
100
|
+
const SCRIPT_EXTENSIONS = '(sh|bash|zsh|py|js|mjs|rb|pl|php|lua)';
|
|
101
|
+
const SCRIPT_INTERPRETERS = '(bash|sh|zsh|python3?|node|ruby|perl|php|lua)';
|
|
102
|
+
|
|
100
103
|
const GRAYLIST_PATTERNS = [
|
|
101
104
|
{ pattern: /^\s*sudo\s/, reason: '命令需要管理员权限 (sudo)' },
|
|
102
105
|
{ pattern: /npm\s+(i|install)\s+-g\s/, reason: '全局安装 npm 包' },
|
|
@@ -107,11 +110,18 @@ const GRAYLIST_PATTERNS = [
|
|
|
107
110
|
{ pattern: /^\s*(shutdown|reboot|halt|poweroff)/, reason: '关机或重启系统' },
|
|
108
111
|
{ pattern: /^\s*>\s*\/etc\/hosts/, reason: '修改系统 hosts 文件' },
|
|
109
112
|
{ pattern: /\beval\s+/, reason: '使用 eval 执行命令' },
|
|
113
|
+
|
|
114
|
+
// 脚本解释器执行外部文件
|
|
115
|
+
{ pattern: new RegExp(`^\\s*${SCRIPT_INTERPRETERS}(\\s+[^-]\\S*)*\\s+\\S*\\.${SCRIPT_EXTENSIONS}\\b`), reason: '执行外部脚本文件' },
|
|
116
|
+
// 直接执行脚本
|
|
117
|
+
{ pattern: new RegExp(`^\\s*\\.?\\/\\S*\\.${SCRIPT_EXTENSIONS}\\b`), reason: '直接执行脚本文件' },
|
|
118
|
+
// chmod +x 授予执行权限
|
|
119
|
+
{ pattern: /^\s*chmod\s+(\+x|a\+x|u\+x|g\+x|o\+x|[0-7]*[1-7][0-7][0-7])\s/, reason: '修改文件执行权限' },
|
|
110
120
|
];
|
|
111
121
|
|
|
112
122
|
/**
|
|
113
123
|
* 检查命令安全性
|
|
114
|
-
* @param {boolean} [force=false] -
|
|
124
|
+
* @param {boolean} [force=false] - 用户已确认(跳过灰名单但保留黑名单)
|
|
115
125
|
* @returns {{ safe: boolean, level: 'allow'|'confirm'|'deny', reason?: string }}
|
|
116
126
|
*/
|
|
117
127
|
function checkCommand(command, force = false) {
|
|
@@ -121,12 +131,7 @@ function checkCommand(command, force = false) {
|
|
|
121
131
|
|
|
122
132
|
const trimmed = command.trim();
|
|
123
133
|
|
|
124
|
-
//
|
|
125
|
-
if (force) {
|
|
126
|
-
return { safe: true, level: 'allow' };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// 1. 先检查黑名单
|
|
134
|
+
// 1. 黑名单始终生效(即使是 force 模式也不可绕过)
|
|
130
135
|
for (const pattern of BLACKLIST_PATTERNS) {
|
|
131
136
|
if (pattern.test(trimmed)) {
|
|
132
137
|
return {
|
|
@@ -137,7 +142,12 @@ function checkCommand(command, force = false) {
|
|
|
137
142
|
}
|
|
138
143
|
}
|
|
139
144
|
|
|
140
|
-
// 2.
|
|
145
|
+
// 2. 强制模式:跳过灰名单检查
|
|
146
|
+
if (force) {
|
|
147
|
+
return { safe: true, level: 'allow' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 3. 检查灰名单
|
|
141
151
|
for (const entry of GRAYLIST_PATTERNS) {
|
|
142
152
|
if (entry.pattern.test(trimmed)) {
|
|
143
153
|
return {
|
|
@@ -148,7 +158,7 @@ function checkCommand(command, force = false) {
|
|
|
148
158
|
}
|
|
149
159
|
}
|
|
150
160
|
|
|
151
|
-
//
|
|
161
|
+
// 4. 通过安全检查
|
|
152
162
|
return { safe: true, level: 'allow' };
|
|
153
163
|
}
|
|
154
164
|
|