Agent CLI源码剖析 - 工具系统安全边界 · AI Agent
Agent CLI 源码剖析 03:工具系统与安全边界
工具系统是 Agent 的执行层
LLM 只会生成文本。Agent 能读文件、改代码、跑命令,是因为宿主程序提供了工具。
核心边界:
代码块收起展开
模型可以提出 tool_call
但程序决定:
1. 工具是否存在
2. 参数是否合法
3. 权限是否允许
4. 怎么执行
5. 输出给模型多少
6. 如何审计如果没有这个边界,Agent 就是“让模型控制你的电脑”。
工具安全边界图
这张图要记住:模型只负责“提出动作”,宿主程序负责“判定动作能不能执行”。
| 图中错误 | 含义 |
|---|---|
INPUT_INVALID | 参数形状或字段不合法,上层可让模型修正参数 |
PERMISSION_DENIED | 路径越界、危险命令或策略拒绝,必须阻断执行 |
所以读源码时,不要只看某个 readFile() 怎么写;要看模型输出进入执行层之前经过了多少道闸。
Tool 接口设计
一个工程化 Tool 不能只有 run()。
代码块收起展开
// ToolRisk = 工具风险等级。
// 用来决定是否需要用户批准、是否需要更严格审计。
export type ToolRisk = "read" | "write" | "execute" | "network";
// Tool = 模型可调用能力的程序定义。
export type Tool<Input, Output> = {
name: string; // 暴露给模型的工具名,必须稳定。
description: string; // 工具说明,影响模型选择工具。
inputSchema: JsonSchema; // 参数 JSON Schema,运行前校验。
risk: ToolRisk; // 风险等级。
timeoutMs: number; // 超时时间。
maxOutputBytes: number; // 最大输出字节数。
run(input: Input, ctx: ToolContext): Promise<Output>; // 工具执行函数。
};
// ToolContext = 工具运行时的受控环境。
export type ToolContext = {
cwd: string; // 当前工作区。
sessionId: string; // 会话 ID,用于审计。
env: Record<string, string | undefined>; // 受控环境变量。
};字段解释:
| 字段 | 作用 |
|---|---|
name | 模型调用时用的稳定名字 |
description | 告诉模型什么时候使用 |
inputSchema | 参数结构,供模型和校验器使用 |
risk | 权限策略依据 |
timeoutMs | 防止工具卡死 |
maxOutputBytes | 防止输出撑爆上下文 |
run | 真正执行逻辑 |
工具调用和普通函数调用的区别:
| 维度 | 普通函数调用 | Agent Tool Call |
|---|---|---|
| 调用者 | 程序员写死 | 模型动态生成 |
| 入参可信度 | 取决于代码路径 | 默认不可信 |
| 错误处理 | 抛异常即可 | 要反馈给模型恢复 |
| 权限 | 通常由业务入口保证 | 工具层必须再次判断 |
| 输出 | 给程序继续处理 | 会进入模型上下文 |
| 审计 | 可选 | 必须可复盘 |
Tool Registry
工具注册表负责统一管理工具。
代码块收起展开
// registry = 工具注册表。
const registry = new Map<string, Tool<any, any>>();
// frozen = 注册表是否已冻结。
let frozen = false;
// registerTool = 注册工具。
export function registerTool(tool: Tool<any, any>) {
// 冻结后禁止注册,保证运行中工具列表稳定。
if (frozen) {
throw new Error("Tool registry is frozen.");
}
// 工具名只允许安全字符,避免 provider/tool 协议兼容问题。
if (!/^[a-zA-Z0-9_:-]+$/.test(tool.name)) {
throw new Error(`Invalid tool name: ${tool.name}`);
}
// 禁止重复工具名。
if (registry.has(tool.name)) {
throw new Error(`Duplicate tool: ${tool.name}`);
}
registry.set(tool.name, tool);
}
// freezeTools = 启动结束后冻结注册表。
export function freezeTools() {
frozen = true;
}
// getToolSchemas = 把工具注册表转换成模型可见 schema。
export function getToolSchemas(): ToolSchema[] {
return [...registry.values()].map(tool => ({
name: tool.name, // 工具名。
description: tool.description, // 工具说明。
input_schema: tool.inputSchema // 参数 schema。
}));
}为什么要 freeze?
- 避免运行中工具表变化。
- 保证模型看到的工具列表和实际执行一致。
- 方便 trace 复盘。
executeToolCall 完整链路
代码块收起展开
// executeToolCall = 工具调用统一入口。
// 模型发出的 tool_call 必须经过这里,不能直接调用具体工具。
export async function executeToolCall(call: ToolCall): Promise<ToolResult> {
// 记录开始时间,用于 audit duration。
const startedAt = Date.now();
// 查找工具。
const tool = registry.get(call.name);
if (!tool) {
throw new AgentError("TOOL_NOT_FOUND", `Unknown tool: ${call.name}`);
}
// 校验输入参数。
const input = validateToolInput(tool, call.input);
// 创建工具上下文。
const ctx = createToolContext(call.sessionId);
// 权限决策。
const decision = await checkPermission(tool, input, ctx);
if (decision.type === "deny") {
throw new AgentError("TOOL_PERMISSION_DENIED", decision.reason);
}
if (decision.type === "ask") {
// 需要用户确认。
const approved = await askUser(decision.prompt);
if (!approved) {
throw new AgentError("TOOL_PERMISSION_DENIED", "User denied tool call.");
}
}
try {
// 执行工具并加超时。
const raw = await withTimeout(tool.run(input, ctx), tool.timeoutMs);
// 统一输出并截断。
const result = normalizeToolOutput(raw, tool.maxOutputBytes);
// 成功审计。
await appendToolAudit({
sessionId: call.sessionId,
callId: call.id,
toolName: tool.name,
risk: tool.risk,
input,
decision,
ok: true,
startedAt: new Date(startedAt).toISOString(),
endedAt: new Date().toISOString(),
durationMs: Date.now() - startedAt,
outputSummary: result.summary,
outputBytes: Buffer.byteLength(result.content, "utf8")
});
// 返回给 QueryEngine,之后会回填给模型。
return result;
} catch (error) {
// 错误归一化。
const agentError = normalizeToolError(error);
// 失败审计。
await appendToolAudit({
sessionId: call.sessionId,
callId: call.id,
toolName: tool.name,
risk: tool.risk,
input,
decision,
ok: false,
startedAt: new Date(startedAt).toISOString(),
endedAt: new Date().toISOString(),
durationMs: Date.now() - startedAt,
errorCode: agentError.code,
errorMessage: agentError.message
});
// 抛给状态机 recover。
throw agentError;
}
}这段就是工具系统的精华。它把“模型想做什么”和“程序允许做什么”隔开。
参数校验
模型输出不能直接信。
代码块收起展开
// validateToolInput = 参数 schema 校验。
// 只能证明形状正确,不能证明业务安全。
export function validateToolInput(tool: Tool<any, any>, input: unknown) {
const result = validateJson(input, tool.inputSchema);
if (!result.ok) {
throw new AgentError("TOOL_INPUT_INVALID", result.message, {
tool: tool.name,
input
});
}
// 返回校验后的 input。真实系统可做类型转换。
return input;
}schema 校验只能保证形状,不能保证安全。比如:
代码块收起展开
{"path":"../../secret.txt"}它是 string,但不安全。路径安全要在文件工具内部继续做。
路径安全
代码块收起展开
import { resolve, relative, isAbsolute } from "node:path";
// resolveWorkspacePath = 路径安全检查。
// 任何文件读写工具都应该先过这关。
export function resolveWorkspacePath(cwd: string, userPath: string) {
// 空路径/NUL 字符拒绝。
if (!userPath || userPath.includes("\0")) {
throw new AgentError("TOOL_INPUT_INVALID", "Invalid path.");
}
// 解析为绝对路径。
const abs = isAbsolute(userPath)
? resolve(userPath)
: resolve(cwd, userPath);
// 判断 abs 是否仍在 cwd 内。
const rel = relative(cwd, abs);
// .. 或绝对 rel 都说明越界。
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
throw new AgentError("TOOL_PERMISSION_DENIED", "Path escapes workspace.", {
cwd,
userPath,
abs
});
}
// 返回安全绝对路径。
return abs;
}任何读写文件工具都应该过这关。
ReadFileTool
代码块收起展开
// read_file 工具注册。
registerTool({
name: "read_file",
description: "Read a UTF-8 text file inside the workspace. Use line ranges for large files.",
risk: "read",
timeoutMs: 5_000,
maxOutputBytes: 64_000,
inputSchema: {
type: "object",
properties: {
path: { type: "string" },
startLine: { type: "number" },
endLine: { type: "number" }
},
required: ["path"]
},
async run(input: ReadFileInput, ctx) {
// 路径必须限制在 workspace 内。
const abs = resolveWorkspacePath(ctx.cwd, input.path);
// 读取 UTF-8 文本。
const text = await readFile(abs, "utf8");
// 按行切分,兼容 LF/CRLF。
const lines = text.split(/\r?\n/);
// 没传行号就读全文件;真实系统还要限制大文件。
const start = input.startLine ?? 1;
const end = input.endLine ?? lines.length;
// 输出时加行号,方便后续 edit 和审查定位。
const selected = lines
.slice(start - 1, end)
.map((line, index) => `${start + index}: ${line}`)
.join("\n");
return {
summary: `Read ${input.path}:${start}-${end}`,
content: selected,
metadata: {
totalLines: lines.length, // 文件总行数。
startLine: start, // 本次开始行。
endLine: end // 本次结束行。
}
};
}
});为什么要带行号?
- 方便后续 edit。
- 方便用户定位。
- 方便 trace 复盘。
EditFileTool
修改文件不应该直接整文件覆盖。更安全的是 oldText/newText 替换。
代码块收起展开
// EditInput = 精确替换工具的输入。
type EditInput = {
path: string; // 要编辑的文件。
oldText: string; // 必须精确匹配的旧文本。
newText: string; // 替换后的新文本。
replaceAll?: boolean;// 是否替换全部命中。
};
// edit_file 工具注册。
registerTool({
name: "edit_file",
description: "Perform a precise text replacement. oldText must match current file content exactly.",
risk: "write",
timeoutMs: 5_000,
maxOutputBytes: 128_000,
inputSchema: editInputSchema,
async run(input: EditInput, ctx) {
// 路径安全检查。
const abs = resolveWorkspacePath(ctx.cwd, input.path);
// 读当前文件。
const original = await readFile(abs, "utf8");
// 统计 oldText 命中次数。
const count = countOccurrences(original, input.oldText);
// 找不到 oldText,说明模型上下文过期或路径错。
if (count === 0) {
throw new AgentError("TOOL_INPUT_INVALID", "oldText not found.");
}
// oldText 多处出现时默认拒绝,防止误改。
if (count > 1 && !input.replaceAll) {
throw new AgentError("TOOL_INPUT_INVALID", "oldText appears multiple times.");
}
// 在内存中生成新内容。
const updated = input.replaceAll
? original.split(input.oldText).join(input.newText)
: original.replace(input.oldText, input.newText);
// 程序生成真实 diff,写入前给用户审查。
const diff = createUnifiedDiff(original, updated, input.path);
await requireWriteApproval({ path: input.path, diff });
// 写入时保留原换行风格。
await writeFile(abs, preserveLineEnding(original, updated), "utf8");
return {
summary: `Edited ${input.path}`,
content: diff,
metadata: { replacements: input.replaceAll ? count : 1 }
};
}
});保护点:
oldText找不到就失败。- 多次出现就失败,除非明确
replaceAll。 - 写前 diff。
- 写前批准。
- 保留换行。
BashTool
命令执行工具最危险。推荐结构化输入,而不是一整条 shell 字符串。
代码块收起展开
// BashInput = 命令执行输入。
// command/args 分离,避免模型拼一整条 shell 字符串。
type BashInput = {
command: string; // 命令名,例如 git。
args: string[]; // 参数数组。
timeoutMs?: number; // 可选超时。
};
// bash 工具注册。
registerTool({
name: "bash",
description: "Run an allowed command in the workspace.",
risk: "execute",
timeoutMs: 30_000,
maxOutputBytes: 512_000,
inputSchema: bashInputSchema,
async run(input: BashInput, ctx) {
// 先检查命令白名单。
assertAllowedCommand(input.command);
// execFileWithLimit 应该不经过 shell,并限制 timeout/output。
const result = await execFileWithLimit(input.command, input.args, {
cwd: ctx.cwd,
timeoutMs: Math.min(input.timeoutMs ?? 10_000, 30_000),
maxOutputBytes: 512_000
});
return {
summary: `Ran ${input.command} ${input.args.join(" ")}`,
content: result.stdout || result.stderr,
metadata: {
exitCode: result.exitCode // 命令退出码。
}
};
}
});白名单:
代码块收起展开
// assertAllowedCommand = 命令白名单检查。
function assertAllowedCommand(command: string) {
const allowed = new Set(["git", "npm", "node", "python", "pytest"]);
if (!allowed.has(command)) {
throw new AgentError("TOOL_PERMISSION_DENIED", `Command not allowed: ${command}`);
}
}不要只靠黑名单。危险命令写法太多。
命令工具最小安全矩阵:
| 风险点 | 坏设计 | 更稳设计 |
|---|---|---|
| Shell 注入 | exec(commandString) | execFile(command, args) |
| 命令范围 | 黑名单过滤 rm | 白名单允许 git/npm/node |
| 工作目录 | 默认进程 cwd | 固定 workspace cwd |
| 输出 | 原样全量返回 | maxOutputBytes + truncated 标记 |
| 超时 | 无限制 | 每个工具声明 timeout |
| 复盘 | 只给最终报错 | trace + audit 记录输入、退出码、耗时 |
PermissionDecision
权限不要只返回 boolean。
代码块收起展开
// PermissionDecision = 权限决策。
// 审计时需要知道允许/拒绝的来源和原因。
export type PermissionDecision =
| { type: "allow"; source: "readonly_policy" | "trusted_tool" | "user_approved" } // 允许。
| { type: "deny"; reason: string } // 拒绝。
| { type: "ask"; prompt: string }; // 需要用户确认。这样 audit 能记录“为什么允许/拒绝”。
ToolAudit
代码块收起展开
// ToolAuditRecord = 工具审计记录。
export type ToolAuditRecord = {
sessionId: string; // 会话 ID。
callId: string; // 工具调用 ID。
toolName: string; // 工具名。
risk: ToolRisk; // 风险等级。
input: unknown; // 实际输入,敏感字段应脱敏。
decision: PermissionDecision; // 权限决策。
startedAt: string; // 开始时间。
endedAt: string; // 结束时间。
durationMs: number; // 耗时。
ok: boolean; // 是否成功。
outputSummary?: string; // 成功输出摘要。
outputBytes?: number; // 输出大小。
errorCode?: string; // 失败错误码。
errorMessage?: string; // 失败错误信息。
};工具审计能回答:
- Agent 调了什么工具?
- 参数是什么?
- 用户是否批准?
- 工具输出多大?
- 是否超时?
- 是否失败?
常见失败模式
| 失败 | 根因 | 修复 |
|---|---|---|
| 工具名被模型编造 | schema/工具列表不清晰 | validate + recover |
| 参数类型错 | 模型输出不稳定 | JSON schema |
| 路径越界 | 文件工具无路径约束 | resolveWorkspacePath |
| 写错位置 | oldText 不唯一 | 唯一性检查 |
| 命令危险 | bash 太宽泛 | 白名单 + 权限 |
| 输出撑爆上下文 | 不截断 | maxOutputBytes |
| 无法复盘 | 无 audit | JSONL audit |
读源码抓手
看工具系统,按顺序找:
- Tool 接口定义。
- Registry 如何注册。
- Tools schema 怎么交给模型。
- executeToolCall 是否统一入口。
- 参数校验在哪。
- 权限在哪。
- 单个工具是否做业务安全。
- 输出是否截断。
- 审计是否完整。
看懂工具执行器,就看懂 Agent 安全边界的一半。