@zhin.js/agent 0.0.17 → 0.0.19
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/CHANGELOG.md +18 -0
- package/README.md +14 -8
- package/lib/builtin-tools.d.ts +5 -137
- package/lib/builtin-tools.d.ts.map +1 -1
- package/lib/builtin-tools.js +321 -732
- package/lib/builtin-tools.js.map +1 -1
- package/lib/discover-agents.d.ts +28 -0
- package/lib/discover-agents.d.ts.map +1 -0
- package/lib/discover-agents.js +116 -0
- package/lib/discover-agents.js.map +1 -0
- package/lib/discover-skills.d.ts +49 -0
- package/lib/discover-skills.d.ts.map +1 -0
- package/lib/discover-skills.js +297 -0
- package/lib/discover-skills.js.map +1 -0
- package/lib/discover-tools.d.ts +56 -0
- package/lib/discover-tools.d.ts.map +1 -0
- package/lib/discover-tools.js +263 -0
- package/lib/discover-tools.js.map +1 -0
- package/lib/discovery-utils.d.ts +27 -0
- package/lib/discovery-utils.d.ts.map +1 -0
- package/lib/discovery-utils.js +96 -0
- package/lib/discovery-utils.js.map +1 -0
- package/lib/file-policy.d.ts +41 -4
- package/lib/file-policy.d.ts.map +1 -1
- package/lib/file-policy.js +126 -4
- package/lib/file-policy.js.map +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/init/create-zhin-agent.d.ts.map +1 -1
- package/lib/init/create-zhin-agent.js +3 -1
- package/lib/init/create-zhin-agent.js.map +1 -1
- package/lib/init/register-builtin-tools.d.ts.map +1 -1
- package/lib/init/register-builtin-tools.js +51 -54
- package/lib/init/register-builtin-tools.js.map +1 -1
- package/lib/zhin-agent/config.js +1 -1
- package/lib/zhin-agent/config.js.map +1 -1
- package/lib/zhin-agent/exec-policy.d.ts +48 -2
- package/lib/zhin-agent/exec-policy.d.ts.map +1 -1
- package/lib/zhin-agent/exec-policy.js +184 -23
- package/lib/zhin-agent/exec-policy.js.map +1 -1
- package/lib/zhin-agent/prompt.d.ts +14 -0
- package/lib/zhin-agent/prompt.d.ts.map +1 -1
- package/lib/zhin-agent/prompt.js +192 -45
- package/lib/zhin-agent/prompt.js.map +1 -1
- package/package.json +3 -3
- package/src/builtin-tools.ts +333 -835
- package/src/discover-agents.ts +138 -0
- package/src/discover-skills.ts +325 -0
- package/src/discover-tools.ts +302 -0
- package/src/discovery-utils.ts +96 -0
- package/src/file-policy.ts +152 -4
- package/src/index.ts +5 -1
- package/src/init/create-zhin-agent.ts +3 -1
- package/src/init/register-builtin-tools.ts +51 -62
- package/src/zhin-agent/config.ts +1 -1
- package/src/zhin-agent/exec-policy.ts +229 -24
- package/src/zhin-agent/prompt.ts +209 -47
- package/tests/exec-policy.test.ts +355 -0
- package/tests/file-policy.test.ts +189 -1
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件化 Tool 发现(*.tool.md 文件扫描与构建)
|
|
3
|
+
*
|
|
4
|
+
* 加载顺序与 skills/agents 一致:Workspace > ~/.zhin > data > 插件包
|
|
5
|
+
* 同名先发现者优先
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import { spawn } from 'child_process';
|
|
12
|
+
import { Logger, type Plugin, type ToolParametersSchema } from '@zhin.js/core';
|
|
13
|
+
import { getDataDir } from './discovery-utils.js';
|
|
14
|
+
|
|
15
|
+
const logger = new Logger(null, 'builtin-tools');
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// 类型
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
export interface ToolParamShorthand {
|
|
22
|
+
type: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
required?: boolean;
|
|
25
|
+
enum?: string[];
|
|
26
|
+
default?: any;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ToolMeta {
|
|
30
|
+
name: string;
|
|
31
|
+
description: string;
|
|
32
|
+
/** 简写参数定义(frontmatter 格式) */
|
|
33
|
+
parameters?: Record<string, ToolParamShorthand>;
|
|
34
|
+
/** 命令配置 */
|
|
35
|
+
command?: {
|
|
36
|
+
pattern?: string;
|
|
37
|
+
alias?: string[];
|
|
38
|
+
examples?: string[];
|
|
39
|
+
};
|
|
40
|
+
platforms?: string[];
|
|
41
|
+
scopes?: string[];
|
|
42
|
+
permissionLevel?: string;
|
|
43
|
+
tags?: string[];
|
|
44
|
+
keywords?: string[];
|
|
45
|
+
kind?: string;
|
|
46
|
+
hidden?: boolean;
|
|
47
|
+
/** handler 文件路径(相对于 .tool.md) */
|
|
48
|
+
handler?: string;
|
|
49
|
+
/** *.tool.md 文件的绝对路径 */
|
|
50
|
+
filePath: string;
|
|
51
|
+
/** body 内容(无 handler 时作为 prompt 模板) */
|
|
52
|
+
templateBody?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ============================================================================
|
|
56
|
+
// 目录收集
|
|
57
|
+
// ============================================================================
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 从根插件树收集:根插件与直接子插件包目录下的 `tools/`
|
|
61
|
+
*/
|
|
62
|
+
export function collectPluginToolSearchRoots(root: Plugin | null | undefined): string[] {
|
|
63
|
+
if (!root) return [];
|
|
64
|
+
const dirs: string[] = [];
|
|
65
|
+
const push = (d: string) => { if (d && !dirs.includes(d)) dirs.push(d); };
|
|
66
|
+
const fromPlugin = (p: Plugin) => {
|
|
67
|
+
if (!p?.filePath) return;
|
|
68
|
+
const dir = path.dirname(p.filePath);
|
|
69
|
+
push(path.join(dir, 'tools'));
|
|
70
|
+
const dirName = path.basename(dir);
|
|
71
|
+
if (dirName === 'src' || dirName === 'lib') {
|
|
72
|
+
push(path.join(path.dirname(dir), 'tools'));
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
fromPlugin(root);
|
|
76
|
+
for (const child of root.children || []) fromPlugin(child);
|
|
77
|
+
return dirs;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 获取所有 tool 搜索目录(标准目录 + 插件包 tools/)
|
|
82
|
+
*/
|
|
83
|
+
export function getToolSearchDirectories(root?: Plugin | null): string[] {
|
|
84
|
+
const list = [
|
|
85
|
+
path.join(process.cwd(), 'tools'),
|
|
86
|
+
path.join(os.homedir(), '.zhin', 'tools'),
|
|
87
|
+
path.join(getDataDir(), 'tools'),
|
|
88
|
+
];
|
|
89
|
+
for (const d of collectPluginToolSearchRoots(root ?? undefined)) {
|
|
90
|
+
if (!list.includes(d)) list.push(d);
|
|
91
|
+
}
|
|
92
|
+
return list;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// 发现
|
|
97
|
+
// ============================================================================
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 扫描 tools/ 目录,发现 *.tool.md 文件
|
|
101
|
+
*/
|
|
102
|
+
export async function discoverWorkspaceTools(root?: Plugin | null): Promise<ToolMeta[]> {
|
|
103
|
+
const tools: ToolMeta[] = [];
|
|
104
|
+
const seenNames = new Set<string>();
|
|
105
|
+
const toolDirs = getToolSearchDirectories(root);
|
|
106
|
+
|
|
107
|
+
for (const toolsDir of toolDirs) {
|
|
108
|
+
if (!fs.existsSync(toolsDir)) continue;
|
|
109
|
+
|
|
110
|
+
let entries: fs.Dirent[];
|
|
111
|
+
try {
|
|
112
|
+
entries = await fs.promises.readdir(toolsDir, { withFileTypes: true });
|
|
113
|
+
} catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
let toolMdPath: string | undefined;
|
|
119
|
+
if (entry.isFile() && entry.name.endsWith('.tool.md')) {
|
|
120
|
+
toolMdPath = path.join(toolsDir, entry.name);
|
|
121
|
+
} else if (entry.isDirectory()) {
|
|
122
|
+
const nested = path.join(toolsDir, entry.name, `${entry.name}.tool.md`);
|
|
123
|
+
if (fs.existsSync(nested)) toolMdPath = nested;
|
|
124
|
+
}
|
|
125
|
+
if (!toolMdPath) continue;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const content = await fs.promises.readFile(toolMdPath, 'utf-8');
|
|
129
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/);
|
|
130
|
+
if (!match) {
|
|
131
|
+
logger.debug(`Tool文件 ${toolMdPath} 没有有效的frontmatter格式`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let jsYaml: any;
|
|
136
|
+
try {
|
|
137
|
+
jsYaml = await import('js-yaml');
|
|
138
|
+
if (jsYaml.default) jsYaml = jsYaml.default;
|
|
139
|
+
} catch (e) {
|
|
140
|
+
logger.warn(`Unable to import js-yaml module: ${e}`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const metadata = jsYaml.load(match[1]);
|
|
145
|
+
if (!metadata || !metadata.name || !metadata.description) {
|
|
146
|
+
logger.debug(`Tool文件 ${toolMdPath} 缺少必需的 name/description 字段`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (seenNames.has(metadata.name)) {
|
|
151
|
+
logger.debug(`Tool '${metadata.name}' 已由先序目录加载,跳过: ${toolMdPath}`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
seenNames.add(metadata.name);
|
|
155
|
+
|
|
156
|
+
const body = content.replace(/^---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, '').trim();
|
|
157
|
+
|
|
158
|
+
tools.push({
|
|
159
|
+
name: metadata.name,
|
|
160
|
+
description: metadata.description,
|
|
161
|
+
parameters: metadata.parameters || undefined,
|
|
162
|
+
command: metadata.command || undefined,
|
|
163
|
+
platforms: metadata.platforms,
|
|
164
|
+
scopes: metadata.scopes,
|
|
165
|
+
permissionLevel: metadata.permissionLevel,
|
|
166
|
+
tags: metadata.tags || [],
|
|
167
|
+
keywords: metadata.keywords || [],
|
|
168
|
+
kind: metadata.kind,
|
|
169
|
+
hidden: metadata.hidden,
|
|
170
|
+
handler: metadata.handler,
|
|
171
|
+
filePath: toolMdPath,
|
|
172
|
+
templateBody: !metadata.handler && body ? body : undefined,
|
|
173
|
+
});
|
|
174
|
+
logger.debug(`Tool发现成功: ${metadata.name}`);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
logger.warn(`Failed to parse tool.md in ${toolMdPath}:`, e);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return tools;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ============================================================================
|
|
184
|
+
// 构建
|
|
185
|
+
// ============================================================================
|
|
186
|
+
|
|
187
|
+
function shorthandToSchema(params: Record<string, ToolParamShorthand>): ToolParametersSchema {
|
|
188
|
+
const properties: Record<string, any> = {};
|
|
189
|
+
const required: string[] = [];
|
|
190
|
+
for (const [key, param] of Object.entries(params)) {
|
|
191
|
+
properties[key] = {
|
|
192
|
+
type: param.type || 'string',
|
|
193
|
+
description: param.description || key,
|
|
194
|
+
};
|
|
195
|
+
if (param.enum) properties[key].enum = param.enum;
|
|
196
|
+
if (param.default !== undefined) properties[key].default = param.default;
|
|
197
|
+
if (param.required) required.push(key);
|
|
198
|
+
}
|
|
199
|
+
return { type: 'object', properties, required: required.length > 0 ? required : undefined };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function loadToolHandler(handlerPath: string, toolMdPath: string): Promise<((args: any, context?: any) => any) | undefined> {
|
|
203
|
+
const resolved = path.resolve(path.dirname(toolMdPath), handlerPath);
|
|
204
|
+
if (!fs.existsSync(resolved)) {
|
|
205
|
+
logger.warn(`Tool handler 文件不存在: ${resolved}`);
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
209
|
+
|
|
210
|
+
// Python script handler: spawn subprocess, pass args via JSON stdin, return stdout
|
|
211
|
+
if (ext === '.py') {
|
|
212
|
+
return async (args: any) => {
|
|
213
|
+
const input = JSON.stringify(args);
|
|
214
|
+
const pythonBin = process.env.PYTHON_BIN || 'python3';
|
|
215
|
+
try {
|
|
216
|
+
const result = await new Promise<string>((resolve, reject) => {
|
|
217
|
+
const child = spawn(pythonBin, [resolved], {
|
|
218
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
219
|
+
timeout: 30_000,
|
|
220
|
+
});
|
|
221
|
+
let stdout = '';
|
|
222
|
+
let stderr = '';
|
|
223
|
+
child.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
|
|
224
|
+
child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
|
|
225
|
+
child.on('close', (code) => {
|
|
226
|
+
if (stderr) logger.debug(`[Python handler] ${resolved} stderr: ${stderr.trim()}`);
|
|
227
|
+
if (code !== 0) reject(new Error(`Python exited with code ${code}: ${stderr.trim()}`));
|
|
228
|
+
else resolve(stdout.trim());
|
|
229
|
+
});
|
|
230
|
+
child.on('error', reject);
|
|
231
|
+
child.stdin.write(input);
|
|
232
|
+
child.stdin.end();
|
|
233
|
+
});
|
|
234
|
+
return result;
|
|
235
|
+
} catch (e: any) {
|
|
236
|
+
return `Error running Python handler: ${e.message ?? String(e)}`;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// JS/TS handler: ESM import
|
|
242
|
+
try {
|
|
243
|
+
const fileUrl = `file://${resolved}?t=${Date.now()}`;
|
|
244
|
+
const mod = await import(fileUrl);
|
|
245
|
+
const fn = mod.default || mod;
|
|
246
|
+
if (typeof fn !== 'function') {
|
|
247
|
+
logger.warn(`Tool handler 未导出函数: ${resolved}`);
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
return fn;
|
|
251
|
+
} catch (e) {
|
|
252
|
+
logger.warn(`Tool handler 加载失败 (${resolved}): ${e instanceof Error ? e.message : String(e)}`);
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function buildTemplateExecute(body: string): (args: Record<string, any>) => string {
|
|
258
|
+
return (args: Record<string, any>) => body.replace(/\{\{(\w+)\}\}/g, (_, k) => {
|
|
259
|
+
const val = args[k];
|
|
260
|
+
return val !== undefined && val !== null ? String(val) : '';
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 将 ToolMeta 转换为 Tool 对象(包含 execute 函数)
|
|
266
|
+
*/
|
|
267
|
+
export async function buildToolFromMeta(meta: ToolMeta): Promise<import('@zhin.js/core').Tool | null> {
|
|
268
|
+
let execute: ((args: any, context?: any) => any) | undefined;
|
|
269
|
+
|
|
270
|
+
if (meta.handler) {
|
|
271
|
+
execute = await loadToolHandler(meta.handler, meta.filePath);
|
|
272
|
+
if (!execute) return null;
|
|
273
|
+
} else if (meta.templateBody) {
|
|
274
|
+
execute = buildTemplateExecute(meta.templateBody);
|
|
275
|
+
} else {
|
|
276
|
+
logger.warn(`Tool '${meta.name}' 既没有 handler 也没有模板 body,跳过`);
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const parameters = meta.parameters
|
|
281
|
+
? shorthandToSchema(meta.parameters)
|
|
282
|
+
: { type: 'object' as const, properties: {} };
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
name: meta.name,
|
|
286
|
+
description: meta.description,
|
|
287
|
+
parameters,
|
|
288
|
+
execute,
|
|
289
|
+
tags: meta.tags,
|
|
290
|
+
keywords: meta.keywords,
|
|
291
|
+
platforms: meta.platforms,
|
|
292
|
+
scopes: meta.scopes as any,
|
|
293
|
+
permissionLevel: meta.permissionLevel as any,
|
|
294
|
+
hidden: meta.hidden,
|
|
295
|
+
kind: meta.kind,
|
|
296
|
+
command: meta.command ? {
|
|
297
|
+
pattern: meta.command.pattern,
|
|
298
|
+
alias: meta.command.alias,
|
|
299
|
+
examples: meta.command.examples,
|
|
300
|
+
} : undefined,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 发现模块共用的工具函数
|
|
3
|
+
*
|
|
4
|
+
* 被 builtin-tools / discover-skills / discover-agents / discover-tools 共同依赖,
|
|
5
|
+
* 独立出来以避免循环导入。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import type { Plugin } from '@zhin.js/core';
|
|
12
|
+
|
|
13
|
+
/** 将 unknown 错误转为字符串 */
|
|
14
|
+
export function errMsg(e: unknown): string {
|
|
15
|
+
return e instanceof Error ? e.message : String(e);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 获取 data/ 目录路径,自动创建 */
|
|
19
|
+
export function getDataDir(): string {
|
|
20
|
+
const dir = path.join(process.cwd(), 'data');
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 展开路径中的 ~ 为实际 home 目录 */
|
|
26
|
+
export function expandHome(p: string): string {
|
|
27
|
+
if (p === '~') return os.homedir();
|
|
28
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2));
|
|
29
|
+
return p;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Workspace / ~/.zhin / data 下 skills 根目录(与 activate_skill 扫描顺序一致的前缀) */
|
|
33
|
+
export function buildStandardSkillDirs(): string[] {
|
|
34
|
+
return [
|
|
35
|
+
path.join(process.cwd(), 'skills'),
|
|
36
|
+
path.join(os.homedir(), '.zhin', 'skills'),
|
|
37
|
+
path.join(getDataDir(), 'skills'),
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 从根插件树收集:根插件与**直接子插件**包目录下的 `skills/`(其下为 `<name>/SKILL.md`)
|
|
43
|
+
*/
|
|
44
|
+
export function collectPluginSkillSearchRoots(root: Plugin | null | undefined): string[] {
|
|
45
|
+
if (!root) return [];
|
|
46
|
+
const dirs: string[] = [];
|
|
47
|
+
const push = (d: string) => {
|
|
48
|
+
if (d && !dirs.includes(d)) dirs.push(d);
|
|
49
|
+
};
|
|
50
|
+
const fromPlugin = (p: Plugin) => {
|
|
51
|
+
if (!p?.filePath) return;
|
|
52
|
+
const dir = path.dirname(p.filePath);
|
|
53
|
+
push(path.join(dir, 'skills'));
|
|
54
|
+
// Also check package root when filePath is under src/ or lib/
|
|
55
|
+
const dirName = path.basename(dir);
|
|
56
|
+
if (dirName === 'src' || dirName === 'lib') {
|
|
57
|
+
push(path.join(path.dirname(dir), 'skills'));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
fromPlugin(root);
|
|
61
|
+
for (const child of root.children || []) {
|
|
62
|
+
fromPlugin(child);
|
|
63
|
+
}
|
|
64
|
+
return dirs;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 技能发现与 activate_skill 查找共用:标准目录 + 已加载插件包 skills/
|
|
69
|
+
*/
|
|
70
|
+
export function getSkillSearchDirectories(root?: Plugin | null): string[] {
|
|
71
|
+
const list = [...buildStandardSkillDirs()];
|
|
72
|
+
for (const d of collectPluginSkillSearchRoots(root ?? undefined)) {
|
|
73
|
+
if (!list.includes(d)) list.push(d);
|
|
74
|
+
}
|
|
75
|
+
return list;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function mergeSkillDirsWithResolver(resolver?: () => string[]): string[] {
|
|
79
|
+
const list = [...buildStandardSkillDirs()];
|
|
80
|
+
for (const d of resolver?.() ?? []) {
|
|
81
|
+
if (d && !list.includes(d)) list.push(d);
|
|
82
|
+
}
|
|
83
|
+
return list;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 将 Node 文件错误转为 miniclawd 风格的结构化短句,便于模型区分并重试 */
|
|
87
|
+
export function nodeErrToFileMessage(err: unknown, filePath: string, kind: 'read' | 'write' | 'edit' | 'list'): string {
|
|
88
|
+
const e = err as NodeJS.ErrnoException;
|
|
89
|
+
if (e?.code === 'ENOENT') {
|
|
90
|
+
if (kind === 'list') return `Error: Directory not found: ${filePath}`;
|
|
91
|
+
return `Error: File not found: ${filePath}`;
|
|
92
|
+
}
|
|
93
|
+
if (e?.code === 'EACCES') return `Error: Permission denied: ${filePath}`;
|
|
94
|
+
const action = kind === 'read' ? 'reading file' : kind === 'write' ? 'writing file' : kind === 'edit' ? 'editing file' : 'listing directory';
|
|
95
|
+
return `Error ${action}: ${e?.message ?? String(err)}`;
|
|
96
|
+
}
|
package/src/file-policy.ts
CHANGED
|
@@ -4,15 +4,61 @@
|
|
|
4
4
|
* 防止 AI Agent 读写敏感文件(如 .env、密钥、证书等),
|
|
5
5
|
* 并将 bash/grep/glob 等工具的命令注入风险降到最低。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* 1.
|
|
9
|
-
* 2.
|
|
10
|
-
* 3.
|
|
7
|
+
* 四层防御:
|
|
8
|
+
* 1. 设备路径阻止 — 阻止 /dev/zero, /dev/stdin 等挂起进程的设备文件
|
|
9
|
+
* 2. 敏感文件名/路径模式 — 阻止 .env、私钥、证书等
|
|
10
|
+
* 3. 敏感目录 — 阻止 .ssh, .gnupg 等
|
|
11
|
+
* 4. bash 安全分类 — 环境变量泄漏 + 命令读写分类
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import * as path from 'path';
|
|
14
15
|
import * as os from 'os';
|
|
15
16
|
|
|
17
|
+
// ── 设备路径阻止(参考 Claude Code FileReadTool BLOCKED_DEVICE_PATHS)──
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 会导致进程挂起的设备文件路径。
|
|
21
|
+
* 检查纯路径即可(无 I/O),安全设备如 /dev/null 不在此列。
|
|
22
|
+
*/
|
|
23
|
+
const BLOCKED_DEVICE_PATHS: ReadonlySet<string> = new Set([
|
|
24
|
+
// 无限输出 — 永远无法 EOF
|
|
25
|
+
'/dev/zero',
|
|
26
|
+
'/dev/random',
|
|
27
|
+
'/dev/urandom',
|
|
28
|
+
'/dev/full',
|
|
29
|
+
// 阻塞等待输入
|
|
30
|
+
'/dev/stdin',
|
|
31
|
+
'/dev/tty',
|
|
32
|
+
'/dev/console',
|
|
33
|
+
// 对读取无意义
|
|
34
|
+
'/dev/stdout',
|
|
35
|
+
'/dev/stderr',
|
|
36
|
+
// stdio fd 别名
|
|
37
|
+
'/dev/fd/0',
|
|
38
|
+
'/dev/fd/1',
|
|
39
|
+
'/dev/fd/2',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 检测路径是否为被阻止的设备文件(含 Linux /proc/ fd 别名)。
|
|
44
|
+
*/
|
|
45
|
+
export function isBlockedDevicePath(filePath: string): boolean {
|
|
46
|
+
if (BLOCKED_DEVICE_PATHS.has(filePath)) return true;
|
|
47
|
+
// /proc/self/fd/0-2 和 /proc/<pid>/fd/0-2 是 Linux 的 stdio 别名
|
|
48
|
+
if (
|
|
49
|
+
filePath.startsWith('/proc/') &&
|
|
50
|
+
(filePath.endsWith('/fd/0') || filePath.endsWith('/fd/1') || filePath.endsWith('/fd/2'))
|
|
51
|
+
) return true;
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── 文件大小限制(参考 Claude Code FileEditTool MAX_EDIT_FILE_SIZE)──
|
|
56
|
+
|
|
57
|
+
/** 读取文件最大字节数(256 MiB),防止 OOM */
|
|
58
|
+
export const MAX_READ_FILE_SIZE = 256 * 1024 * 1024;
|
|
59
|
+
/** 编辑文件最大字节数(1 GiB),防止 V8 字符串长度溢出 */
|
|
60
|
+
export const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024;
|
|
61
|
+
|
|
16
62
|
// ── 敏感文件名模式 ──────────────────────────────────────────────────
|
|
17
63
|
|
|
18
64
|
/**
|
|
@@ -174,6 +220,108 @@ export function checkBashCommandSafety(command: string): { safe: boolean; reason
|
|
|
174
220
|
return { safe: true };
|
|
175
221
|
}
|
|
176
222
|
|
|
223
|
+
// ── bash 命令读写分类(参考 Claude Code BashTool isSearchOrReadBashCommand)──
|
|
224
|
+
|
|
225
|
+
/** 搜索类命令 */
|
|
226
|
+
const BASH_SEARCH_COMMANDS: ReadonlySet<string> = new Set([
|
|
227
|
+
'find', 'grep', 'rg', 'ag', 'ack', 'locate', 'which', 'whereis',
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
/** 读取/查看类命令 */
|
|
231
|
+
const BASH_READ_COMMANDS: ReadonlySet<string> = new Set([
|
|
232
|
+
'cat', 'head', 'tail', 'less', 'more', 'wc', 'stat', 'file', 'strings',
|
|
233
|
+
'jq', 'awk', 'cut', 'sort', 'uniq', 'tr',
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
/** 目录列出类命令 */
|
|
237
|
+
const BASH_LIST_COMMANDS: ReadonlySet<string> = new Set([
|
|
238
|
+
'ls', 'tree', 'du',
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
/** 语义中性命令 — 纯输出/状态命令,不影响管道是否只读 */
|
|
242
|
+
const BASH_NEUTRAL_COMMANDS: ReadonlySet<string> = new Set([
|
|
243
|
+
'echo', 'printf', 'true', 'false', ':',
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
export interface BashCommandClassification {
|
|
247
|
+
/** 是否为搜索命令 */
|
|
248
|
+
isSearch: boolean;
|
|
249
|
+
/** 是否为读取命令 */
|
|
250
|
+
isRead: boolean;
|
|
251
|
+
/** 是否为列出命令 */
|
|
252
|
+
isList: boolean;
|
|
253
|
+
/** 综合判断:命令是否只读(搜索/读取/列出) */
|
|
254
|
+
isReadOnly: boolean;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 对 bash 命令进行读/写分类。
|
|
259
|
+
*
|
|
260
|
+
* 对管道命令(如 `cat file | grep pattern`),所有非中性部分
|
|
261
|
+
* 都必须是搜索/读/列出类,整条命令才算只读。
|
|
262
|
+
*
|
|
263
|
+
* 参考 Claude Code BashTool `isSearchOrReadBashCommand`。
|
|
264
|
+
*/
|
|
265
|
+
export function classifyBashCommand(command: string): BashCommandClassification {
|
|
266
|
+
const trimmed = command.trim();
|
|
267
|
+
// 按管道和操作符拆分
|
|
268
|
+
const parts = trimmed.split(/\s*(?:\|\||&&|\||;)\s*/);
|
|
269
|
+
|
|
270
|
+
let hasSearch = false;
|
|
271
|
+
let hasRead = false;
|
|
272
|
+
let hasList = false;
|
|
273
|
+
let hasNonNeutral = false;
|
|
274
|
+
|
|
275
|
+
for (const part of parts) {
|
|
276
|
+
const baseCmd = part.trim().split(/\s+/)[0];
|
|
277
|
+
if (!baseCmd) continue;
|
|
278
|
+
|
|
279
|
+
// 跳过中性命令
|
|
280
|
+
if (BASH_NEUTRAL_COMMANDS.has(baseCmd)) continue;
|
|
281
|
+
|
|
282
|
+
hasNonNeutral = true;
|
|
283
|
+
if (BASH_SEARCH_COMMANDS.has(baseCmd)) hasSearch = true;
|
|
284
|
+
else if (BASH_READ_COMMANDS.has(baseCmd)) hasRead = true;
|
|
285
|
+
else if (BASH_LIST_COMMANDS.has(baseCmd)) hasList = true;
|
|
286
|
+
else {
|
|
287
|
+
// 包含非只读命令 → 整条命令不是只读
|
|
288
|
+
return { isSearch: false, isRead: false, isList: false, isReadOnly: false };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 全部中性命令(如 echo "hi")— 视为只读
|
|
293
|
+
if (!hasNonNeutral) {
|
|
294
|
+
return { isSearch: false, isRead: false, isList: false, isReadOnly: true };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
isSearch: hasSearch,
|
|
299
|
+
isRead: hasRead,
|
|
300
|
+
isList: hasList,
|
|
301
|
+
isReadOnly: hasSearch || hasRead || hasList,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── 文件 mtime 比对(参考 Claude Code FileEditTool stale detection)──
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 文件修改时间快照,用于检测编辑前是否被并发修改。
|
|
309
|
+
*/
|
|
310
|
+
export async function getFileMtime(filePath: string): Promise<number> {
|
|
311
|
+
const { default: fsPromises } = await import('fs/promises');
|
|
312
|
+
const stat = await fsPromises.stat(filePath);
|
|
313
|
+
return stat.mtimeMs;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 检查文件在读取后是否被修改。
|
|
318
|
+
* @returns true 表示文件已被修改(stale),应中止编辑
|
|
319
|
+
*/
|
|
320
|
+
export function isFileStale(savedMtime: number, currentMtime: number): boolean {
|
|
321
|
+
// 允许 1ms 的精度误差
|
|
322
|
+
return Math.abs(currentMtime - savedMtime) > 1;
|
|
323
|
+
}
|
|
324
|
+
|
|
177
325
|
// ── 命令参数转义 ────────────────────────────────────────────────────
|
|
178
326
|
|
|
179
327
|
/**
|
package/src/index.ts
CHANGED
|
@@ -40,7 +40,11 @@ export { ZhinAgent } from './zhin-agent/index.js';
|
|
|
40
40
|
export type { ZhinAgentConfig, OnChunkCallback } from './zhin-agent/index.js';
|
|
41
41
|
|
|
42
42
|
export { PERM_MAP, DEFAULT_CONFIG as ZHIN_AGENT_DEFAULT_CONFIG, SECTION_SEP } from './zhin-agent/config.js';
|
|
43
|
-
export {
|
|
43
|
+
export {
|
|
44
|
+
checkExecPolicy, applyExecPolicyToTools, resolveExecAllowlist, EXEC_PRESETS,
|
|
45
|
+
isDangerousCommand, stripEnvVarPrefix, stripSafeWrappers, splitCompoundCommand, extractCommandName,
|
|
46
|
+
type ExecPolicyResult,
|
|
47
|
+
} from './zhin-agent/exec-policy.js';
|
|
44
48
|
export { collectRelevantTools, toAgentTool } from './zhin-agent/tool-collector.js';
|
|
45
49
|
export { buildRichSystemPrompt, buildContextHint, buildEnhancedPersona, buildUserMessageWithHistory, contentToText } from './zhin-agent/prompt.js';
|
|
46
50
|
export type { RichSystemPromptContext } from './zhin-agent/prompt.js';
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import * as path from 'path';
|
|
6
6
|
import { getPlugin, Scheduler, getScheduler, setScheduler, type MessageType, type SendOptions } from '@zhin.js/core';
|
|
7
7
|
import { ZhinAgent } from '../zhin-agent/index.js';
|
|
8
|
-
import {
|
|
8
|
+
import { createBuiltinTools } from '../builtin-tools.js';
|
|
9
|
+
import { collectPluginSkillSearchRoots } from '../discovery-utils.js';
|
|
9
10
|
import { resolveSkillInstructionMaxChars, DEFAULT_CONFIG } from '../zhin-agent/config.js';
|
|
10
11
|
import { PersistentCronEngine, setCronManager } from '../cron-engine.js';
|
|
11
12
|
import type { AIServiceRefs } from './shared-refs.js';
|
|
@@ -49,6 +50,7 @@ export function createZhinAgentContext(refs: AIServiceRefs): void {
|
|
|
49
50
|
const modelName = provider.models[0] || '';
|
|
50
51
|
const fullConfig = { ...DEFAULT_CONFIG, ...agentConfig } as Required<import('../zhin-agent/config.js').ZhinAgentConfig>;
|
|
51
52
|
const zhinTools = createBuiltinTools({
|
|
53
|
+
plugin,
|
|
52
54
|
skillInstructionMaxChars: resolveSkillInstructionMaxChars(fullConfig, modelName),
|
|
53
55
|
pluginSkillRootsResolver: () => collectPluginSkillSearchRoots(root),
|
|
54
56
|
});
|