@zhin.js/agent 0.0.18 → 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 +9 -0
- package/README.md +14 -8
- package/lib/builtin-tools.d.ts +4 -0
- package/lib/builtin-tools.d.ts.map +1 -1
- package/lib/builtin-tools.js +337 -29
- package/lib/builtin-tools.js.map +1 -1
- 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 +1 -0
- 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 +1 -0
- 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 +351 -30
- package/src/file-policy.ts +152 -4
- package/src/index.ts +5 -1
- package/src/init/create-zhin-agent.ts +1 -0
- package/src/init/register-builtin-tools.ts +1 -0
- 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
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';
|
|
@@ -50,6 +50,7 @@ export function createZhinAgentContext(refs: AIServiceRefs): void {
|
|
|
50
50
|
const modelName = provider.models[0] || '';
|
|
51
51
|
const fullConfig = { ...DEFAULT_CONFIG, ...agentConfig } as Required<import('../zhin-agent/config.js').ZhinAgentConfig>;
|
|
52
52
|
const zhinTools = createBuiltinTools({
|
|
53
|
+
plugin,
|
|
53
54
|
skillInstructionMaxChars: resolveSkillInstructionMaxChars(fullConfig, modelName),
|
|
54
55
|
pluginSkillRootsResolver: () => collectPluginSkillSearchRoots(root),
|
|
55
56
|
});
|
|
@@ -29,6 +29,7 @@ export function registerBuiltinTools(refs: AIServiceRefs): void {
|
|
|
29
29
|
const fullCfg = { ...DEFAULT_CONFIG, ...agentCfg } as Required<import('../zhin-agent/config.js').ZhinAgentConfig>;
|
|
30
30
|
const modelName = provider.models[0] || '';
|
|
31
31
|
const builtinTools = createBuiltinTools({
|
|
32
|
+
plugin,
|
|
32
33
|
skillInstructionMaxChars: resolveSkillInstructionMaxChars(fullCfg, modelName),
|
|
33
34
|
pluginSkillRootsResolver: () => collectPluginSkillSearchRoots(root),
|
|
34
35
|
skillFileLookup: (name: string) => {
|
package/src/zhin-agent/config.ts
CHANGED
|
@@ -94,7 +94,7 @@ export interface ZhinAgentConfig {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
export const DEFAULT_CONFIG: Required<ZhinAgentConfig> = {
|
|
97
|
-
persona: 'You are
|
|
97
|
+
persona: 'You are Zhin, an intelligent IM bot assistant that helps users with tasks through conversation. Use tools available to you to assist the user. You are running inside the Zhin.js framework.',
|
|
98
98
|
maxIterations: 5,
|
|
99
99
|
timeout: 60_000,
|
|
100
100
|
preExecTimeout: 10_000,
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ZhinAgent 执行策略 — bash 命令的安全检查与工具包装
|
|
3
|
+
*
|
|
4
|
+
* 参考 Claude Code bashPermissions.ts 的纵深防御策略:
|
|
5
|
+
* 1. 危险命令黑名单 — 即使 full 模式也阻止解释器/提权命令
|
|
6
|
+
* 2. 环境变量前缀剥离 — `FOO=bar cmd` → 按 `cmd` 做白名单匹配
|
|
7
|
+
* 3. Safe wrapper 剥离 — `timeout 10 cmd` → 按 `cmd` 做匹配
|
|
8
|
+
* 4. 复合命令拆分 — `&&` `||` `;` 逐段独立检查,deny 优先
|
|
9
|
+
* 5. 只读命令自动放行 — 与 file-policy classifyBashCommand 集成
|
|
10
|
+
* 6. ask_user 集成 — execAsk=true 时返回需审批标记(而非无法交互的抛错)
|
|
3
11
|
*/
|
|
4
12
|
|
|
5
13
|
import type { AgentTool } from '@zhin.js/core';
|
|
6
14
|
import type { ZhinAgentConfig } from './config.js';
|
|
15
|
+
import { classifyBashCommand } from '../file-policy.js';
|
|
7
16
|
|
|
8
17
|
// ── 预设命令白名单 ──────────────────────────────────────────────────
|
|
9
18
|
|
|
10
|
-
const PRESET_READONLY = ['ls', 'cat', 'pwd', 'date', 'whoami', 'grep', 'find', 'head', 'tail', 'wc'];
|
|
11
|
-
const PRESET_NETWORK = [...PRESET_READONLY, 'curl', 'wget', 'ping', 'dig'];
|
|
12
|
-
const PRESET_DEVELOPMENT = [...PRESET_NETWORK, 'npm', 'npx', 'node', 'git', 'gh', 'python', 'python3', 'pip', 'pnpm', 'yarn'];
|
|
19
|
+
const PRESET_READONLY = ['ls', 'cat', 'pwd', 'date', 'whoami', 'grep', 'find', 'head', 'tail', 'wc', 'stat', 'file'];
|
|
20
|
+
const PRESET_NETWORK = [...PRESET_READONLY, 'curl', 'wget', 'ping', 'dig', 'nslookup', 'host'];
|
|
21
|
+
const PRESET_DEVELOPMENT = [...PRESET_NETWORK, 'npm', 'npx', 'node', 'git', 'gh', 'python', 'python3', 'pip', 'pnpm', 'yarn', 'tsc', 'bun'];
|
|
13
22
|
|
|
14
23
|
export const EXEC_PRESETS: Record<string, string[]> = {
|
|
15
24
|
readonly: PRESET_READONLY,
|
|
@@ -17,6 +26,122 @@ export const EXEC_PRESETS: Record<string, string[]> = {
|
|
|
17
26
|
development: PRESET_DEVELOPMENT,
|
|
18
27
|
};
|
|
19
28
|
|
|
29
|
+
// ── 危险命令黑名单(参考 Claude Code DANGEROUS_COMMANDS)──────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 即使在 full 模式下也会被阻止的危险命令。
|
|
33
|
+
* 这些命令可以执行任意代码、提权或造成不可逆破坏。
|
|
34
|
+
*/
|
|
35
|
+
const DANGEROUS_COMMANDS: ReadonlySet<string> = new Set([
|
|
36
|
+
// 提权
|
|
37
|
+
'sudo', 'su', 'doas',
|
|
38
|
+
// Shell 元命令 — 可执行任意代码
|
|
39
|
+
'eval', 'exec',
|
|
40
|
+
// 系统级破坏
|
|
41
|
+
'dd', 'mkfs', 'fdisk', 'parted',
|
|
42
|
+
// 进程注入
|
|
43
|
+
'gdb', 'strace', 'ltrace', 'ptrace',
|
|
44
|
+
// 环境注入(敏感变量可被设置)
|
|
45
|
+
'export',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 检查命令是否在危险黑名单中。
|
|
50
|
+
*/
|
|
51
|
+
export function isDangerousCommand(cmdName: string): boolean {
|
|
52
|
+
return DANGEROUS_COMMANDS.has(cmdName);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── 环境变量前缀剥离(参考 Claude Code stripEnvVars)──────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 剥离命令前面的 `KEY=value` 环境变量前缀。
|
|
59
|
+
* 例如 `FOO=bar BAZ=1 curl http://...` → `curl http://...`
|
|
60
|
+
*
|
|
61
|
+
* 只剥离安全的 key=value 对,不剥离含特殊字符的值(可能是注入)。
|
|
62
|
+
*/
|
|
63
|
+
export function stripEnvVarPrefix(command: string): string {
|
|
64
|
+
// env 前缀环境变量格式: WORD=VALUE (VALUE 可以被引号或不含空格的字符串)
|
|
65
|
+
return command.replace(
|
|
66
|
+
/^(\s*[A-Za-z_][A-Za-z0-9_]*=(('[^']*'|"[^"]*"|[^\s;|&]*))\s*)+/,
|
|
67
|
+
'',
|
|
68
|
+
).trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Safe wrapper 剥离(参考 Claude Code stripSafeWrappers)──────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Safe wrapper 命令列表 — 这些命令本身是安全的"包装器",
|
|
75
|
+
* 真正需要检查的是它们后面的实际命令。
|
|
76
|
+
*/
|
|
77
|
+
const SAFE_WRAPPERS: ReadonlySet<string> = new Set([
|
|
78
|
+
'timeout', 'time', 'nice', 'nohup', 'ionice', 'stdbuf', 'unbuffer',
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 剥离命令前面的 safe wrapper(如 `timeout 10`、`nice -n 5`)。
|
|
83
|
+
* 只剥离 wrapper + 它的标志/参数(数字、-flag 形式),直到遇到实际命令。
|
|
84
|
+
*/
|
|
85
|
+
export function stripSafeWrappers(command: string): string {
|
|
86
|
+
let remaining = command.trim();
|
|
87
|
+
let changed = true;
|
|
88
|
+
// 防止无限循环,最多剥离 5 层
|
|
89
|
+
let maxIter = 5;
|
|
90
|
+
while (changed && maxIter-- > 0) {
|
|
91
|
+
changed = false;
|
|
92
|
+
const tokens = remaining.split(/\s+/);
|
|
93
|
+
if (tokens.length < 2) break;
|
|
94
|
+
if (SAFE_WRAPPERS.has(tokens[0])) {
|
|
95
|
+
// 跳过 wrapper 本身和它的参数(-flag 或纯数字/duration)
|
|
96
|
+
let i = 1;
|
|
97
|
+
while (i < tokens.length && /^(-[A-Za-z0-9]|[0-9]+[smhd]?$)/.test(tokens[i])) {
|
|
98
|
+
i++;
|
|
99
|
+
}
|
|
100
|
+
if (i < tokens.length) {
|
|
101
|
+
remaining = tokens.slice(i).join(' ');
|
|
102
|
+
changed = true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return remaining;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── 复合命令拆分(参考 Claude Code compound command checking)──────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 将复合命令按 `&&`, `||`, `;` 拆分为独立子命令。
|
|
113
|
+
* 管道 `|` 不拆分 — 管道中的只读性由 classifyBashCommand 判断。
|
|
114
|
+
*
|
|
115
|
+
* 注意:不处理 subshell `$(...)` 和反引号 — 这些场景由危险黑名单覆盖。
|
|
116
|
+
*/
|
|
117
|
+
export function splitCompoundCommand(command: string): string[] {
|
|
118
|
+
// 按 &&, ||, ; 拆分,保留管道作为整体
|
|
119
|
+
return command.split(/\s*(?:&&|\|\||;)\s*/).map(s => s.trim()).filter(Boolean);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 从命令字符串中提取实际的可执行程序名。
|
|
124
|
+
* 先剥离环境变量前缀和 safe wrapper。
|
|
125
|
+
*/
|
|
126
|
+
export function extractCommandName(command: string): string {
|
|
127
|
+
const stripped = stripSafeWrappers(stripEnvVarPrefix(command));
|
|
128
|
+
// 取第一个非管道 token
|
|
129
|
+
const name = stripped.split(/[\s|]/)[0] || '';
|
|
130
|
+
return name;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── 策略检查结果 ────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
export interface ExecPolicyResult {
|
|
136
|
+
allowed: boolean;
|
|
137
|
+
/** 如果不允许,拒绝原因 */
|
|
138
|
+
reason?: string;
|
|
139
|
+
/** 如果需要用户确认(execAsk=true 且命令不在白名单但也不在黑名单) */
|
|
140
|
+
needsApproval?: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── 核心检查函数 ────────────────────────────────────────────────────
|
|
144
|
+
|
|
20
145
|
/**
|
|
21
146
|
* Resolves the effective allowlist by merging preset commands with custom allowlist.
|
|
22
147
|
*/
|
|
@@ -29,21 +154,34 @@ export function resolveExecAllowlist(config: Required<ZhinAgentConfig>): string[
|
|
|
29
154
|
}
|
|
30
155
|
|
|
31
156
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
157
|
+
* 检查单条子命令是否允许执行。
|
|
158
|
+
* 内部函数 — 不做复合命令拆分。
|
|
34
159
|
*/
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
160
|
+
function checkSingleCommand(
|
|
161
|
+
cmdName: string,
|
|
162
|
+
fullSubCommand: string,
|
|
163
|
+
allowlist: string[],
|
|
164
|
+
security: string,
|
|
165
|
+
execAsk: boolean,
|
|
166
|
+
): ExecPolicyResult {
|
|
167
|
+
// 1. 危险黑名单 — 任何模式都拒绝
|
|
168
|
+
if (isDangerousCommand(cmdName)) {
|
|
169
|
+
return { allowed: false, reason: `拒绝执行危险命令「${cmdName}」— 该命令可提权或执行任意代码。` };
|
|
40
170
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
171
|
+
|
|
172
|
+
// 2. full 模式 — 通过黑名单后全部放行
|
|
173
|
+
if (security === 'full') {
|
|
174
|
+
return { allowed: true };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 3. 只读命令自动放行(与 file-policy classifyBashCommand 集成)
|
|
178
|
+
const classification = classifyBashCommand(fullSubCommand);
|
|
179
|
+
if (classification.isReadOnly) {
|
|
180
|
+
return { allowed: true };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 4. 白名单匹配
|
|
184
|
+
const allowed = allowlist.some(pattern => {
|
|
47
185
|
try {
|
|
48
186
|
const re = new RegExp(`^${pattern}$`);
|
|
49
187
|
return re.test(cmdName);
|
|
@@ -51,18 +189,78 @@ export function checkExecPolicy(config: Required<ZhinAgentConfig>, command: stri
|
|
|
51
189
|
return cmdName === pattern;
|
|
52
190
|
}
|
|
53
191
|
});
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
ask
|
|
58
|
-
? '该命令不在允许列表中,需要审批后执行。当前版本请将命令加入 ai.agent.execAllowlist 或联系管理员。'
|
|
59
|
-
: '该命令不在允许列表中,已被拒绝执行。可将允许的命令模式加入 ai.agent.execAllowlist。',
|
|
60
|
-
);
|
|
192
|
+
|
|
193
|
+
if (allowed) {
|
|
194
|
+
return { allowed: true };
|
|
61
195
|
}
|
|
196
|
+
|
|
197
|
+
// 5. 需审批或拒绝
|
|
198
|
+
if (execAsk) {
|
|
199
|
+
return {
|
|
200
|
+
allowed: false,
|
|
201
|
+
needsApproval: true,
|
|
202
|
+
reason: `命令「${cmdName}」不在允许列表中,需要用户确认后执行。`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
allowed: false,
|
|
208
|
+
reason: `命令「${cmdName}」不在允许列表中,已被拒绝。可将命令加入 ai.agent.execAllowlist 或改用 execPreset。`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Check if a bash command is allowed under the current exec policy.
|
|
214
|
+
* 支持复合命令拆分、环境变量剥离、safe wrapper 剥离、只读自动放行。
|
|
215
|
+
*
|
|
216
|
+
* @returns ExecPolicyResult — 允许/拒绝/需审批
|
|
217
|
+
*/
|
|
218
|
+
export function checkExecPolicy(config: Required<ZhinAgentConfig>, command: string): ExecPolicyResult {
|
|
219
|
+
const security = config.execSecurity ?? 'deny';
|
|
220
|
+
if (security === 'deny') {
|
|
221
|
+
return { allowed: false, reason: '当前配置禁止执行 Shell 命令(execSecurity=deny)。如需开放请在配置中设置 ai.agent.execSecurity。' };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const allowlist = resolveExecAllowlist(config);
|
|
225
|
+
const execAsk = config.execAsk ?? false;
|
|
226
|
+
const cmd = (command || '').trim();
|
|
227
|
+
|
|
228
|
+
if (!cmd) {
|
|
229
|
+
return { allowed: false, reason: '命令为空' };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 拆分复合命令 — 每段独立检查,deny 优先
|
|
233
|
+
const subCommands = splitCompoundCommand(cmd);
|
|
234
|
+
let pendingApproval: ExecPolicyResult | null = null;
|
|
235
|
+
|
|
236
|
+
for (const sub of subCommands) {
|
|
237
|
+
const cmdName = extractCommandName(sub);
|
|
238
|
+
if (!cmdName) continue;
|
|
239
|
+
|
|
240
|
+
const result = checkSingleCommand(cmdName, sub, allowlist, security, execAsk);
|
|
241
|
+
|
|
242
|
+
// deny 立即返回(deny > ask 优先级)
|
|
243
|
+
if (!result.allowed && !result.needsApproval) {
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 记录第一个需要审批的
|
|
248
|
+
if (!result.allowed && result.needsApproval && !pendingApproval) {
|
|
249
|
+
pendingApproval = result;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 有需要审批的段
|
|
254
|
+
if (pendingApproval) {
|
|
255
|
+
return pendingApproval;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { allowed: true };
|
|
62
259
|
}
|
|
63
260
|
|
|
64
261
|
/**
|
|
65
262
|
* Wrap `bash` tools with exec policy enforcement.
|
|
263
|
+
* 当 execAsk=true 且命令需审批时,返回提示信息而非抛错。
|
|
66
264
|
*/
|
|
67
265
|
export function applyExecPolicyToTools(config: Required<ZhinAgentConfig>, tools: AgentTool[]): AgentTool[] {
|
|
68
266
|
return tools.map(t => {
|
|
@@ -72,7 +270,14 @@ export function applyExecPolicyToTools(config: Required<ZhinAgentConfig>, tools:
|
|
|
72
270
|
...t,
|
|
73
271
|
execute: async (args: Record<string, any>) => {
|
|
74
272
|
const cmd = args?.command != null ? String(args.command) : '';
|
|
75
|
-
checkExecPolicy(config, cmd);
|
|
273
|
+
const result = checkExecPolicy(config, cmd);
|
|
274
|
+
if (!result.allowed) {
|
|
275
|
+
if (result.needsApproval) {
|
|
276
|
+
// 返回可读消息让 AI 用 ask_user 向用户确认
|
|
277
|
+
return `⚠️ ${result.reason}\n请使用 ask_user 工具询问用户是否允许执行此命令。`;
|
|
278
|
+
}
|
|
279
|
+
throw new Error(result.reason!);
|
|
280
|
+
}
|
|
76
281
|
return original(args);
|
|
77
282
|
},
|
|
78
283
|
};
|