@zhin.js/agent 0.0.18 → 0.0.20

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +14 -8
  3. package/lib/builtin-tools.d.ts +4 -0
  4. package/lib/builtin-tools.d.ts.map +1 -1
  5. package/lib/builtin-tools.js +436 -29
  6. package/lib/builtin-tools.js.map +1 -1
  7. package/lib/file-policy.d.ts +41 -4
  8. package/lib/file-policy.d.ts.map +1 -1
  9. package/lib/file-policy.js +126 -4
  10. package/lib/file-policy.js.map +1 -1
  11. package/lib/index.d.ts +1 -1
  12. package/lib/index.d.ts.map +1 -1
  13. package/lib/index.js +1 -1
  14. package/lib/index.js.map +1 -1
  15. package/lib/init/create-zhin-agent.d.ts.map +1 -1
  16. package/lib/init/create-zhin-agent.js +1 -0
  17. package/lib/init/create-zhin-agent.js.map +1 -1
  18. package/lib/init/register-ai-trigger.d.ts.map +1 -1
  19. package/lib/init/register-ai-trigger.js +10 -3
  20. package/lib/init/register-ai-trigger.js.map +1 -1
  21. package/lib/init/register-builtin-tools.d.ts.map +1 -1
  22. package/lib/init/register-builtin-tools.js +1 -0
  23. package/lib/init/register-builtin-tools.js.map +1 -1
  24. package/lib/zhin-agent/config.js +1 -1
  25. package/lib/zhin-agent/config.js.map +1 -1
  26. package/lib/zhin-agent/exec-policy.d.ts +48 -2
  27. package/lib/zhin-agent/exec-policy.d.ts.map +1 -1
  28. package/lib/zhin-agent/exec-policy.js +184 -23
  29. package/lib/zhin-agent/exec-policy.js.map +1 -1
  30. package/lib/zhin-agent/prompt.d.ts +14 -0
  31. package/lib/zhin-agent/prompt.d.ts.map +1 -1
  32. package/lib/zhin-agent/prompt.js +192 -45
  33. package/lib/zhin-agent/prompt.js.map +1 -1
  34. package/package.json +3 -3
  35. package/src/builtin-tools.ts +457 -30
  36. package/src/file-policy.ts +152 -4
  37. package/src/index.ts +5 -1
  38. package/src/init/create-zhin-agent.ts +1 -0
  39. package/src/init/register-ai-trigger.ts +15 -3
  40. package/src/init/register-builtin-tools.ts +1 -0
  41. package/src/zhin-agent/config.ts +1 -1
  42. package/src/zhin-agent/exec-policy.ts +229 -24
  43. package/src/zhin-agent/prompt.ts +209 -47
  44. package/tests/exec-policy.test.ts +355 -0
  45. package/tests/file-policy.test.ts +189 -1
@@ -7,6 +7,7 @@
7
7
  * 计划: todo_read, todo_write
8
8
  * 记忆: read_memory, write_memory (AGENTS.md)
9
9
  * 技能: activate_skill, install_skill
10
+ * 交互: ask_user(基于 Prompt 类的用户确认/提问工具)
10
11
  *
11
12
  * 发现逻辑已拆分到 discover-skills.ts / discover-agents.ts / discover-tools.ts
12
13
  */
@@ -15,9 +16,13 @@ import * as fs from 'fs';
15
16
  import * as path from 'path';
16
17
  import { exec } from 'child_process';
17
18
  import { promisify } from 'util';
18
- import { Logger, type PropertySchema } from '@zhin.js/core';
19
+ import { Logger, Prompt, Adapter, type Plugin, type PropertySchema, type MessageMiddleware, type SendOptions } from '@zhin.js/core';
19
20
  import { ZhinTool } from '@zhin.js/core';
20
- import { assertFileAccess, checkBashCommandSafety, shellEscape } from './file-policy.js';
21
+ import {
22
+ assertFileAccess, checkBashCommandSafety, shellEscape,
23
+ isBlockedDevicePath, MAX_READ_FILE_SIZE, MAX_EDIT_FILE_SIZE,
24
+ classifyBashCommand, getFileMtime, isFileStale,
25
+ } from './file-policy.js';
21
26
  import {
22
27
  errMsg, expandHome, getDataDir, mergeSkillDirsWithResolver, nodeErrToFileMessage,
23
28
  } from './discovery-utils.js';
@@ -26,11 +31,189 @@ import { checkSkillDeps, extractSkillInstructions } from './discover-skills.js';
26
31
  const execAsync = promisify(exec);
27
32
  const logger = new Logger(null, 'builtin-tools');
28
33
 
34
+ // ── 引号归一化 + 模糊匹配(参考 Claude Code FileEditTool/utils.ts) ──
35
+
36
+ /** 将弯引号归一化为直引号 */
37
+ function normalizeQuotes(str: string): string {
38
+ return str
39
+ .replace(/\u2018/g, "'") // '
40
+ .replace(/\u2019/g, "'") // '
41
+ .replace(/\u201C/g, '"') // "
42
+ .replace(/\u201D/g, '"'); // "
43
+ }
44
+
45
+ interface FuzzyMatchResult {
46
+ /** 文件中实际匹配到的字符串 */
47
+ actual: string;
48
+ /** 匹配次数 */
49
+ count: number;
50
+ /** 是否通过引号归一化匹配 */
51
+ wasNormalized: boolean;
52
+ }
53
+
54
+ /**
55
+ * 在文件内容中查找字符串,支持精确匹配和引号归一化模糊匹配。
56
+ * 参考 Claude Code `findActualString`。
57
+ */
58
+ function findActualStringInFile(fileContent: string, searchString: string): FuzzyMatchResult | null {
59
+ // 精确匹配
60
+ const exactCount = fileContent.split(searchString).length - 1;
61
+ if (exactCount > 0) {
62
+ return { actual: searchString, count: exactCount, wasNormalized: false };
63
+ }
64
+
65
+ // 引号归一化匹配
66
+ const normalizedSearch = normalizeQuotes(searchString);
67
+ const normalizedFile = normalizeQuotes(fileContent);
68
+ const idx = normalizedFile.indexOf(normalizedSearch);
69
+ if (idx !== -1) {
70
+ // 提取文件中实际的字符串(保留原始弯引号)
71
+ const actual = fileContent.substring(idx, idx + searchString.length);
72
+ const normalizedCount = normalizedFile.split(normalizedSearch).length - 1;
73
+ return { actual, count: normalizedCount, wasNormalized: true };
74
+ }
75
+
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * 将 new_string 中的直引号替换为文件中原始的弯引号风格。
81
+ * 参考 Claude Code `preserveQuoteStyle`。
82
+ */
83
+ function preserveQuoteStyleInEdit(oldString: string, actualOldString: string, newString: string): string {
84
+ if (oldString === actualOldString) return newString;
85
+
86
+ const hasDouble = actualOldString.includes('\u201C') || actualOldString.includes('\u201D');
87
+ const hasSingle = actualOldString.includes('\u2018') || actualOldString.includes('\u2019');
88
+ if (!hasDouble && !hasSingle) return newString;
89
+
90
+ let result = newString;
91
+ if (hasDouble) {
92
+ // 简单启发式:前面是空白/行首时用左引号,否则右引号
93
+ const chars = [...result];
94
+ const out: string[] = [];
95
+ for (let i = 0; i < chars.length; i++) {
96
+ if (chars[i] === '"') {
97
+ const prev = i > 0 ? chars[i - 1] : ' ';
98
+ const isOpening = /[\s(\[{]/.test(prev) || i === 0;
99
+ out.push(isOpening ? '\u201C' : '\u201D');
100
+ } else {
101
+ out.push(chars[i]);
102
+ }
103
+ }
104
+ result = out.join('');
105
+ }
106
+ if (hasSingle) {
107
+ const chars = [...result];
108
+ const out: string[] = [];
109
+ for (let i = 0; i < chars.length; i++) {
110
+ if (chars[i] === "'") {
111
+ const prev = i > 0 ? chars[i - 1] : ' ';
112
+ const next = i < chars.length - 1 ? chars[i + 1] : ' ';
113
+ // 两个字母之间是缩写,用右引号
114
+ if (/\p{L}/u.test(prev) && /\p{L}/u.test(next)) {
115
+ out.push('\u2019');
116
+ } else {
117
+ const isOpening = /[\s(\[{]/.test(prev) || i === 0;
118
+ out.push(isOpening ? '\u2018' : '\u2019');
119
+ }
120
+ } else {
121
+ out.push(chars[i]);
122
+ }
123
+ }
124
+ result = out.join('');
125
+ }
126
+ return result;
127
+ }
128
+
129
+ // ── 图片格式检测(参考 Claude Code FileReadTool imageResizer) ──
130
+
131
+ /** 支持的图片扩展名 */
132
+ const IMAGE_EXTENSIONS: ReadonlySet<string> = new Set([
133
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico',
134
+ ]);
135
+
136
+ function isImageFile(filePath: string): boolean {
137
+ return IMAGE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
138
+ }
139
+
140
+ // ============================================================================
141
+ // ask_user 辅助函数
142
+ // ============================================================================
143
+
144
+ /**
145
+ * 私聊 Owner 场景:使用 Prompt 类直接交互(原有行为)
146
+ */
147
+ async function askViaPrompt(
148
+ plugin: Plugin,
149
+ message: any,
150
+ args: Record<string, any>,
151
+ questionType: string,
152
+ timeoutMs: number,
153
+ ): Promise<string> {
154
+ const prompt = new Prompt(plugin, message);
155
+ try {
156
+ switch (questionType) {
157
+ case 'number': {
158
+ const defaultNum = args.default_value != null ? Number(args.default_value) : undefined;
159
+ const result = await prompt.number(args.question, timeoutMs, defaultNum, '输入超时,已取消');
160
+ return String(result);
161
+ }
162
+ case 'confirm': {
163
+ const result = await prompt.confirm(args.question, 'yes', timeoutMs, false, '确认超时,已取消');
164
+ return result ? 'yes' : 'no';
165
+ }
166
+ case 'pick': {
167
+ if (!args.options?.length) {
168
+ return 'Error: type=pick 时必须提供 options 选项列表';
169
+ }
170
+ const pickOptions = (args.options as string[]).map((o: string) => ({ label: o, value: o }));
171
+ const result = await prompt.pick(args.question, {
172
+ type: 'text' as const,
173
+ options: pickOptions,
174
+ timeout: timeoutMs,
175
+ }, '选择超时,已取消');
176
+ return String(result);
177
+ }
178
+ case 'text':
179
+ default: {
180
+ const result = await prompt.text(args.question, timeoutMs, args.default_value || '', '输入超时,已取消');
181
+ return result;
182
+ }
183
+ }
184
+ } catch (e: unknown) {
185
+ return `Owner 未响应或输入错误: ${errMsg(e)}`;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * 将 Owner 私聊回复格式化为对应类型的结果
191
+ */
192
+ function formatOwnerResponse(raw: string, questionType: string, args: Record<string, any>): string {
193
+ switch (questionType) {
194
+ case 'confirm':
195
+ return raw.trim().toLowerCase() === 'yes' ? 'yes' : 'no';
196
+ case 'number':
197
+ return String(Number(raw) || 0);
198
+ case 'pick': {
199
+ const idx = Number(raw.trim());
200
+ const options = (args.options as string[]) || [];
201
+ if (idx >= 1 && idx <= options.length) return options[idx - 1];
202
+ return raw;
203
+ }
204
+ case 'text':
205
+ default:
206
+ return raw;
207
+ }
208
+ }
209
+
29
210
  // ============================================================================
30
211
  // 工具工厂函数
31
212
  // ============================================================================
32
213
 
33
214
  export interface BuiltinToolsOptions {
215
+ /** 插件实例,用于 ask_user 工具创建 Prompt 交互 */
216
+ plugin?: Plugin;
34
217
  /** Max chars for skill instruction extraction (model-size-aware) */
35
218
  skillInstructionMaxChars?: number;
36
219
  /**
@@ -52,13 +235,14 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
52
235
  const skillMaxChars = options?.skillInstructionMaxChars ?? 4000;
53
236
  const skillDirList = () => mergeSkillDirsWithResolver(options?.pluginSkillRootsResolver);
54
237
  const skillFileLookup = options?.skillFileLookup;
238
+ const pluginRef = options?.plugin;
55
239
 
56
240
  const tools: ZhinTool[] = [];
57
241
 
58
- // ── read_file(清晰描述 + 强关键词) ──
242
+ // ── read_file(清晰描述 + 强关键词 + 图片检测 + 安全防护) ──
59
243
  tools.push(
60
244
  new ZhinTool('read_file')
61
- .desc('读取指定路径的文件内容。用于查看、打开或读取任意文本文件。')
245
+ .desc('读取指定路径的文件内容。用于查看、打开或读取任意文本文件。图片文件返回 Base64 数据。')
62
246
  .keyword('读文件', '读取文件', '查看文件', '打开文件', '文件内容', 'read file', 'read', 'cat', '查看', '打开')
63
247
  .tag('file', 'read')
64
248
  .kind('file')
@@ -68,8 +252,27 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
68
252
  .execute(async (args) => {
69
253
  try {
70
254
  const fp = expandHome(args.file_path);
255
+ // 设备路径拦截(参考 Claude Code BLOCKED_DEVICE_PATHS)
256
+ if (isBlockedDevicePath(fp)) {
257
+ return `Error: 禁止读取设备文件 ${fp}(会导致进程挂起或注入攻击)`;
258
+ }
71
259
  assertFileAccess(fp);
72
260
  const stat = await fs.promises.stat(fp);
261
+ // 文件大小限制(参考 Claude Code MAX_EDIT_FILE_SIZE)
262
+ if (stat.size > MAX_READ_FILE_SIZE) {
263
+ return `Error: 文件过大 (${(stat.size / 1024 / 1024).toFixed(1)} MiB),超过 ${MAX_READ_FILE_SIZE / 1024 / 1024} MiB 限制。请使用 offset/limit 分段读取。`;
264
+ }
265
+
266
+ // 图片文件检测(参考 Claude Code FileReadTool 的图片处理)
267
+ if (isImageFile(fp)) {
268
+ const buffer = await fs.promises.readFile(fp);
269
+ const ext = path.extname(fp).toLowerCase().replace('.', '');
270
+ const mimeType = ext === 'jpg' ? 'jpeg' : ext === 'svg' ? 'svg+xml' : ext;
271
+ const b64 = buffer.toString('base64');
272
+ const sizeKb = (buffer.length / 1024).toFixed(1);
273
+ return `[Image: ${path.basename(fp)}, ${sizeKb} KB, type: image/${mimeType}]\ndata:image/${mimeType};base64,${b64.slice(0, 200)}...(total ${b64.length} chars)`;
274
+ }
275
+
73
276
  const content = await fs.promises.readFile(fp, 'utf-8');
74
277
  const lines = content.split('\n');
75
278
  const offset = args.offset ?? 0;
@@ -105,10 +308,10 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
105
308
  }),
106
309
  );
107
310
 
108
- // ── edit_file(old_text 必须精确匹配) ──
311
+ // ── edit_file(支持精确匹配 + 引号归一化模糊匹配)──
109
312
  tools.push(
110
313
  new ZhinTool('edit_file')
111
- .desc('在文件中查找并替换一段文本。old_string 必须在文件中精确存在且唯一;建议包含完整行或足够上下文以避免重复匹配。')
314
+ .desc('在文件中查找并替换一段文本。old_string 必须在文件中精确存在且唯一;建议包含完整行或足够上下文以避免重复匹配。支持弯引号/直引号自动归一化。')
112
315
  .keyword('编辑文件', '修改文件', '替换内容', '查找替换', 'edit file', 'edit', '修改', '替换')
113
316
  .tag('file', 'edit')
114
317
  .kind('file')
@@ -119,11 +322,32 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
119
322
  try {
120
323
  const fp = expandHome(args.file_path);
121
324
  assertFileAccess(fp);
325
+ // 文件大小限制
326
+ const stat = await fs.promises.stat(fp);
327
+ if (stat.size > MAX_EDIT_FILE_SIZE) {
328
+ return `Error: 文件过大 (${(stat.size / 1024 / 1024).toFixed(1)} MiB),超过 ${MAX_EDIT_FILE_SIZE / 1024 / 1024} MiB 限制。`;
329
+ }
330
+ // 记录 mtime 用于防并发覆写
331
+ const mtimeBefore = stat.mtimeMs;
122
332
  const content = await fs.promises.readFile(fp, 'utf-8');
123
- const count = content.split(args.old_string).length - 1;
124
- if (count === 0) return `Error: old_string not found in file. Make sure it matches exactly.`;
125
- if (count > 1) return `Warning: old_string appears ${count} times. Please provide more context to make it unique.`;
126
- const newContent = content.replace(args.old_string, args.new_string);
333
+
334
+ // 精确匹配 引号归一化模糊匹配
335
+ const matchResult = findActualStringInFile(content, args.old_string);
336
+ if (!matchResult) return `Error: old_string not found in file. Make sure it matches exactly (also tried quote normalization).`;
337
+ if (matchResult.count > 1) return `Warning: old_string appears ${matchResult.count} times. Please provide more context to make it unique.`;
338
+
339
+ // 如果通过引号归一化匹配,保持文件的引号风格
340
+ const effectiveNew = matchResult.wasNormalized
341
+ ? preserveQuoteStyleInEdit(args.old_string, matchResult.actual, args.new_string)
342
+ : args.new_string;
343
+
344
+ const newContent = content.replace(matchResult.actual, effectiveNew);
345
+
346
+ // 写入前再检查 mtime 防止并发修改
347
+ const currentStat = await fs.promises.stat(fp);
348
+ if (isFileStale(mtimeBefore, currentStat.mtimeMs)) {
349
+ return `Error: 文件 ${fp} 在读取后被外部修改。请重新读取文件后再编辑,避免覆盖他人的修改。`;
350
+ }
127
351
  await fs.promises.writeFile(fp, newContent, 'utf-8');
128
352
 
129
353
  const oldLines = args.old_string.split('\n');
@@ -195,29 +419,68 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
195
419
  }),
196
420
  );
197
421
 
198
- // ── grep ──
422
+ // ── grep(支持上下文行、大小写、多行、ripgrep 自动检测) ──
199
423
  tools.push(
200
424
  new ZhinTool('grep')
201
- .desc('按正则搜索文件内容,返回匹配行和行号')
202
- .keyword('搜索', '查找内容', 'grep', '正则')
425
+ .desc('按正则搜索文件内容,返回匹配行和行号。优先使用 ripgrep (rg),回退到 grep。')
426
+ .keyword('搜索', '查找内容', 'grep', '正则', 'rg', 'ripgrep')
203
427
  .tag('search', 'regex')
204
428
  .kind('file')
205
429
  .param('pattern', { type: 'string', description: '正则表达式' }, true)
206
430
  .param('path', { type: 'string', description: '搜索路径(默认 .)' })
207
431
  .param('include', { type: 'string', description: '文件类型过滤(如 *.ts)' })
432
+ .param('context', { type: 'number', description: '匹配行上下文行数(-C 参数)' })
433
+ .param('before', { type: 'number', description: '匹配行之前显示行数(-B 参数)' })
434
+ .param('after', { type: 'number', description: '匹配行之后显示行数(-A 参数)' })
435
+ .param('ignore_case', { type: 'boolean', description: '大小写不敏感搜索(-i 参数)' } as any)
436
+ .param('multiline', { type: 'boolean', description: '多行模式,. 匹配换行(仅 ripgrep 支持)' } as any)
437
+ .param('limit', { type: 'number', description: '最多返回结果行数(默认 50)' })
208
438
  .execute(async (args) => {
209
439
  try {
210
440
  const searchPath = args.path || '.';
211
441
  assertFileAccess(path.resolve(process.cwd(), searchPath));
212
- // 安全转义 pattern 和 include 参数防止命令注入
213
442
  const safePattern = shellEscape(args.pattern);
214
443
  const safePath = shellEscape(searchPath);
215
- const includeFlag = args.include ? `--include=${shellEscape(args.include)}` : '';
216
- const { stdout } = await execAsync(
217
- `grep -rn ${includeFlag} ${safePattern} ${safePath} 2>/dev/null | head -50`,
218
- { cwd: process.cwd() },
219
- );
220
- return stdout.trim() || `No matches for '${args.pattern}'`;
444
+ const limit = args.limit ?? 50;
445
+
446
+ // 检测 ripgrep 是否可用
447
+ let useRipgrep = false;
448
+ try {
449
+ await execAsync('rg --version', { timeout: 3000 });
450
+ useRipgrep = true;
451
+ } catch { /* ripgrep 不可用,回退到 grep */ }
452
+
453
+ let cmd: string;
454
+ if (useRipgrep) {
455
+ // ripgrep 命令构建
456
+ const rgFlags: string[] = ['-n']; // 行号
457
+ if (args.ignore_case) rgFlags.push('-i');
458
+ if (args.multiline) rgFlags.push('-U', '--multiline-dotall');
459
+ if (args.context) rgFlags.push(`-C${args.context}`);
460
+ else {
461
+ if (args.before) rgFlags.push(`-B${args.before}`);
462
+ if (args.after) rgFlags.push(`-A${args.after}`);
463
+ }
464
+ if (args.include) rgFlags.push(`--glob=${shellEscape(args.include)}`);
465
+ cmd = `rg ${rgFlags.join(' ')} ${safePattern} ${safePath} 2>/dev/null | head -${limit}`;
466
+ } else {
467
+ // 传统 grep 回退
468
+ const grepFlags: string[] = ['-rn'];
469
+ if (args.ignore_case) grepFlags.push('-i');
470
+ if (args.context) grepFlags.push(`-C${args.context}`);
471
+ else {
472
+ if (args.before) grepFlags.push(`-B${args.before}`);
473
+ if (args.after) grepFlags.push(`-A${args.after}`);
474
+ }
475
+ const includeFlag = args.include ? `--include=${shellEscape(args.include)}` : '';
476
+ cmd = `grep ${grepFlags.join(' ')} ${includeFlag} ${safePattern} ${safePath} 2>/dev/null | head -${limit}`;
477
+ }
478
+
479
+ const { stdout } = await execAsync(cmd, { cwd: process.cwd() });
480
+ const engine = useRipgrep ? '(ripgrep)' : '(grep)';
481
+ return stdout.trim()
482
+ ? `${engine}\n${stdout.trim()}`
483
+ : `No matches for '${args.pattern}' ${engine}`;
221
484
  } catch (e: unknown) {
222
485
  const err = e as { code?: number; message?: string };
223
486
  if (err.code === 1) return `No matches for '${args.pattern}'`;
@@ -226,10 +489,10 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
226
489
  }),
227
490
  );
228
491
 
229
- // ── bash ──
492
+ // ── bash(安全检查 + 命令读写分类) ──
230
493
  tools.push(
231
494
  new ZhinTool('bash')
232
- .desc('执行 Shell 命令(带超时保护)')
495
+ .desc('执行 Shell 命令(带超时保护和命令分类)。返回结果中会标注命令类型(只读/搜索/写入)。')
233
496
  .keyword('执行', '运行', '命令', '终端', 'shell', 'bash')
234
497
  .tag('shell', 'exec')
235
498
  .kind('shell')
@@ -243,15 +506,20 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
243
506
  // 检查命令是否可能泄漏敏感信息
244
507
  const safety = checkBashCommandSafety(cmd);
245
508
  if (!safety.safe) return `Error: ${safety.reason}`;
509
+ // 命令读写分类
510
+ const classification = classifyBashCommand(cmd);
246
511
  const { stdout, stderr } = await execAsync(cmd, {
247
512
  cwd: args.cwd || process.cwd(),
248
513
  timeout,
249
514
  maxBuffer: 1024 * 1024,
250
515
  });
251
516
  let result = '';
517
+ const tag = classification.isReadOnly
518
+ ? (classification.isSearch ? '[搜索]' : classification.isList ? '[列出]' : '[只读]')
519
+ : '[执行]';
252
520
  if (stdout.trim()) result += `STDOUT:\n${stdout.trim()}`;
253
521
  if (stderr.trim()) result += `${result ? '\n' : ''}STDERR:\n${stderr.trim()}`;
254
- return result || '(no output)';
522
+ return `${tag} ${result || '(no output)'}`;
255
523
  } catch (e: unknown) {
256
524
  const err = e as { code?: number; message?: string; stdout?: string; stderr?: string };
257
525
  return `Error (exit ${err.code || '?'}): ${errMsg(e)}\nSTDOUT:\n${err.stdout || ''}\nSTDERR:\n${err.stderr || ''}`;
@@ -259,17 +527,27 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
259
527
  }),
260
528
  );
261
529
 
262
- // ── web_search(搜索网页,返回标题、URL、摘要) ──
530
+ // ── web_search(搜索网页,返回标题、URL、摘要 + 域名过滤 + 次数限制) ──
531
+ let searchCount = 0;
532
+ const MAX_SEARCH_COUNT = 20; // 单次会话搜索次数上限
263
533
  tools.push(
264
534
  new ZhinTool('web_search')
265
- .desc('在互联网上搜索,返回匹配的标题、URL 和摘要片段。用于查资料、找网页。')
535
+ .desc('在互联网上搜索,返回匹配的标题、URL 和摘要片段。用于查资料、找网页。支持域名过滤。')
266
536
  .keyword('搜索', '网上搜', '网页搜索', '搜索引擎', 'search', 'google', '百度', '查询', '搜一下')
267
537
  .tag('web', 'search')
268
538
  .kind('web')
269
539
  .param('query', { type: 'string', description: '搜索关键词或完整查询语句' }, true)
270
540
  .param('limit', { type: 'number', description: '返回结果数量(默认 5,建议 1–10)' })
541
+ .param('allowed_domains', { type: 'array', description: '仅保留这些域名的结果(可选,如 ["github.com", "stackoverflow.com"])' } as any)
542
+ .param('blocked_domains', { type: 'array', description: '排除这些域名的结果(可选)' } as any)
271
543
  .execute(async (args) => {
272
544
  try {
545
+ // 搜索次数限制
546
+ searchCount++;
547
+ if (searchCount > MAX_SEARCH_COUNT) {
548
+ return `Error: 搜索次数已达上限 (${MAX_SEARCH_COUNT})。请使用已获取的信息回答。`;
549
+ }
550
+
273
551
  const limit = args.limit ?? 5;
274
552
  const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(args.query)}`;
275
553
  const res = await fetch(url, {
@@ -312,8 +590,23 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
312
590
  }
313
591
  }
314
592
 
315
- if (results.length === 0) return 'No results found.';
316
- return results.map((r, i) =>
593
+ // 域名过滤
594
+ let filtered = results;
595
+ if (args.allowed_domains?.length) {
596
+ const allowed = new Set((args.allowed_domains as string[]).map(d => d.toLowerCase()));
597
+ filtered = filtered.filter(r => {
598
+ try { return allowed.has(new URL(r.url).hostname.toLowerCase()); } catch { return false; }
599
+ });
600
+ }
601
+ if (args.blocked_domains?.length) {
602
+ const blocked = new Set((args.blocked_domains as string[]).map(d => d.toLowerCase()));
603
+ filtered = filtered.filter(r => {
604
+ try { return !blocked.has(new URL(r.url).hostname.toLowerCase()); } catch { return true; }
605
+ });
606
+ }
607
+
608
+ if (filtered.length === 0) return 'No results found.';
609
+ return `(${searchCount}/${MAX_SEARCH_COUNT} searches)\n` + filtered.map((r, i) =>
317
610
  `${i + 1}. ${r.title}\n URL: ${r.url}\n ${r.snippet}`,
318
611
  ).join('\n\n');
319
612
  } catch (e: unknown) {
@@ -322,29 +615,67 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
322
615
  }),
323
616
  );
324
617
 
325
- // ── web_fetch(抓取 URL 并提取正文) ──
618
+ // ── web_fetch(抓取 URL 并提取正文 + SSRF 防护 + 改进的内容提取) ──
326
619
  tools.push(
327
620
  new ZhinTool('web_fetch')
328
- .desc('抓取指定 URL 的网页内容并提取正文(去除广告等),返回可读文本。用于读文章、获取网页内容。')
621
+ .desc('抓取指定 URL 的网页内容并提取正文(去除广告、脚本等),返回可读文本。仅支持 http/https 协议。')
329
622
  .keyword('抓取网页', '打开链接', '获取网页', '读网页', 'fetch', 'url', '链接内容', '网页内容')
330
623
  .tag('web', 'fetch')
331
624
  .kind('web')
332
625
  .param('url', { type: 'string', description: '要抓取的完整 URL(需 http 或 https)' }, true)
626
+ .param('max_length', { type: 'number', description: '最大返回字符数(默认 20480)' })
333
627
  .execute(async (args) => {
334
628
  try {
629
+ // SSRF 防护:仅允许 http/https 协议
630
+ let parsedUrl: URL;
631
+ try {
632
+ parsedUrl = new URL(args.url);
633
+ } catch {
634
+ return `Error: 无效的 URL 格式`;
635
+ }
636
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
637
+ return `Error: 仅支持 http/https 协议,拒绝 ${parsedUrl.protocol}`;
638
+ }
639
+ // 阻止内网地址(SSRF 关键防护)
640
+ const hostname = parsedUrl.hostname.toLowerCase();
641
+ if (
642
+ hostname === 'localhost' ||
643
+ hostname === '127.0.0.1' ||
644
+ hostname === '::1' ||
645
+ hostname === '0.0.0.0' ||
646
+ hostname.endsWith('.local') ||
647
+ hostname.startsWith('10.') ||
648
+ hostname.startsWith('192.168.') ||
649
+ /^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
650
+ ) {
651
+ return `Error: 禁止访问内网地址 ${hostname}(SSRF 防护)`;
652
+ }
653
+
335
654
  const response = await fetch(args.url, {
336
655
  headers: { 'User-Agent': 'Mozilla/5.0 (compatible; ZhinBot/1.0)' },
337
656
  signal: AbortSignal.timeout(15000),
657
+ redirect: 'follow',
338
658
  });
339
659
  if (!response.ok) return `HTTP ${response.status}: ${response.statusText}`;
340
660
  const html = await response.text();
661
+ // 改进的内容提取:去除脚本、样式、导航、页脚、表单等
341
662
  const text = html
342
663
  .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
343
664
  .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
665
+ .replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
666
+ .replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
667
+ .replace(/<header[^>]*>[\s\S]*?<\/header>/gi, ' ')
668
+ .replace(/<form[^>]*>[\s\S]*?<\/form>/gi, '')
669
+ .replace(/<!--[\s\S]*?-->/g, '')
344
670
  .replace(/<[^>]+>/g, ' ')
671
+ .replace(/&nbsp;/gi, ' ')
672
+ .replace(/&amp;/g, '&')
673
+ .replace(/&lt;/g, '<')
674
+ .replace(/&gt;/g, '>')
675
+ .replace(/&quot;/g, '"')
345
676
  .replace(/\s+/g, ' ')
346
677
  .trim();
347
- const maxLen = 20 * 1024;
678
+ const maxLen = args.max_length ?? 20 * 1024;
348
679
  return text.length > maxLen ? text.slice(0, maxLen) + '\n...(truncated)' : text;
349
680
  } catch (e: unknown) {
350
681
  return `Error: ${errMsg(e)}`;
@@ -527,5 +858,101 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
527
858
  }),
528
859
  );
529
860
 
861
+ // ── ask_user(基于 Prompt 类的用户确认/提问工具) ──
862
+ // 安全策略:在群聊中 ask_user 只向 owner 私聊确认,防止非 owner 用户操控安全敏感决策
863
+ tools.push(
864
+ new ZhinTool('ask_user')
865
+ .desc('向 Bot Owner 发送问题并等待回复。用于需要确认、补充信息或做出选择时。在群聊中始终通过私聊向 Owner 确认,确保安全性。')
866
+ .keyword('询问', '确认', '提问', '用户输入', 'ask', 'confirm', 'prompt', '选择', '请问')
867
+ .tag('interaction', 'prompt')
868
+ .kind('interaction')
869
+ .param('question', { type: 'string', description: '要向 Owner 提出的问题文本' }, true)
870
+ .param('type', { type: 'string', description: '问题类型: text(文本输入)、number(数字输入)、confirm(是/否确认)、pick(选项选择)。默认 text' })
871
+ .param('options', { type: 'array', description: '选项列表(type=pick 时必填),每项为字符串,如 ["选项A","选项B","选项C"]' })
872
+ .param('default_value', { type: 'string', description: 'Owner 超时未回复时使用的默认值' })
873
+ .param('timeout', { type: 'number', description: '等待 Owner 回复的超时时间(秒),默认 120' })
874
+ .execute(async (args, context) => {
875
+ if (!context?.message) {
876
+ return 'Error: 当前上下文没有消息来源,无法向 Owner 提问。请改为在回复中直接询问。';
877
+ }
878
+ if (!pluginRef) {
879
+ return 'Error: 插件实例不可用,无法创建交互式提问。请改为在回复中直接询问。';
880
+ }
881
+
882
+ const timeoutMs = (args.timeout ?? 120) * 1000;
883
+ const questionType = args.type || 'text';
884
+
885
+ // 从 adapter 的 bot 配置中查找 owner
886
+ const platform = context.platform!;
887
+ const botId = context.botId!;
888
+ const adapter = pluginRef.inject(platform) as Adapter | undefined;
889
+ const bot = adapter?.bots?.get(botId);
890
+ const botOwner: string | undefined = (bot?.$config as any)?.owner;
891
+ const isPrivateOwner = context.scope === 'private'
892
+ && botOwner != null && String(context.senderId) === String(botOwner);
893
+
894
+ // ── 私聊 + 发送者是 Owner → 直接用 Prompt(原有行为) ──
895
+ if (isPrivateOwner) {
896
+ return askViaPrompt(pluginRef, context.message, args, questionType, timeoutMs);
897
+ }
898
+
899
+ // ── 非私聊 Owner → 必须通过私聊向 Owner 确认 ──
900
+ if (!botOwner) {
901
+ return 'Error: 当前 Bot 未配置 owner,无法进行安全确认。请在 bots 配置中设置 owner 字段。';
902
+ }
903
+
904
+ if (!adapter || typeof adapter.sendMessage !== 'function') {
905
+ return `Error: 无法获取适配器 ${platform},无法向 Owner 发送私聊确认。`;
906
+ }
907
+
908
+ // 构建发送给 Owner 的问题文本(包含来源上下文)
909
+ const sourceInfo = context.scope !== 'private'
910
+ ? `来源: ${context.scope}(${context.sceneId}) 用户: ${context.senderId}`
911
+ : `来源: 私聊 用户: ${context.senderId}`;
912
+ let questionText = `🔐 AI 安全确认\n${sourceInfo}\n\n${args.question}`;
913
+ if (questionType === 'confirm') {
914
+ questionText += '\n输入"yes"以确认';
915
+ } else if (questionType === 'pick' && args.options?.length) {
916
+ questionText += '\n' + (args.options as string[]).map((o, i) => `${i + 1}.${o}`).join('\n');
917
+ } else if (questionType === 'number') {
918
+ questionText += '\n(请输入数字)';
919
+ }
920
+
921
+ try {
922
+ await adapter.sendMessage({
923
+ context: platform,
924
+ bot: botId,
925
+ id: botOwner,
926
+ type: 'private',
927
+ content: questionText,
928
+ } satisfies SendOptions);
929
+ } catch (e: unknown) {
930
+ return `Error: 无法向 Owner 发送私聊消息: ${errMsg(e)}`;
931
+ }
932
+
933
+ // 注册一次性中间件等待 Owner 私聊回复
934
+ return new Promise<string>((resolve) => {
935
+ const middleware: MessageMiddleware = async (message, next) => {
936
+ if (message.$channel?.type !== 'private') return next();
937
+ if (String(message.$sender.id) !== String(botOwner)) return next();
938
+ if (String(message.$bot) !== String(botId)) return next();
939
+ dispose();
940
+ clearTimeout(timer);
941
+ const raw = message.$raw;
942
+ resolve(formatOwnerResponse(raw, questionType, args));
943
+ };
944
+ const dispose = pluginRef!.addMiddleware(middleware);
945
+ const timer = setTimeout(() => {
946
+ dispose();
947
+ if (args.default_value != null) {
948
+ resolve(String(args.default_value));
949
+ } else {
950
+ resolve('Owner 未在规定时间内响应,操作已取消。');
951
+ }
952
+ }, timeoutMs);
953
+ });
954
+ }),
955
+ );
956
+
530
957
  return tools;
531
958
  }