Agent CLI源码剖析 - 工具系统安全边界 · AI Agent

Agent CLI 源码剖析 03:工具系统与安全边界

工具系统是 Agent 的执行层

LLM 只会生成文本。Agent 能读文件、改代码、跑命令,是因为宿主程序提供了工具。

核心边界:

代码块TEXT · 8 行收起展开
模型可以提出 tool_call
但程序决定:
1. 工具是否存在
2. 参数是否合法
3. 权限是否允许
4. 怎么执行
5. 输出给模型多少
6. 如何审计

如果没有这个边界,Agent 就是“让模型控制你的电脑”。

工具安全边界图

这张图要记住:模型只负责“提出动作”,宿主程序负责“判定动作能不能执行”。

工具调用安全边界 模型只能提出调用;系统负责校验、授权、执行和审计。 意图 用户目标 模型推理 tool_call 校验 工具存在 Schema 业务安全 策略 deny ask allow 拒绝审计 执行 用户批准 执行工具 结果+审计
图中错误含义
INPUT_INVALID参数形状或字段不合法,上层可让模型修正参数
PERMISSION_DENIED路径越界、危险命令或策略拒绝,必须阻断执行

所以读源码时,不要只看某个 readFile() 怎么写;要看模型输出进入执行层之前经过了多少道闸。

Tool 接口设计

一个工程化 Tool 不能只有 run()

代码块TS · 21 行收起展开
// 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

工具注册表负责统一管理工具。

代码块TS · 39 行收起展开
// 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 完整链路

代码块TS · 83 行收起展开
// 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;
  }
}

这段就是工具系统的精华。它把“模型想做什么”和“程序允许做什么”隔开。

参数校验

模型输出不能直接信。

代码块TS · 15 行收起展开
// 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 校验只能保证形状,不能保证安全。比如:

代码块JSON · 1 行收起展开
{"path":"../../secret.txt"}

它是 string,但不安全。路径安全要在文件工具内部继续做。

路径安全

代码块TS · 30 行收起展开
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

代码块TS · 47 行收起展开
// 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 替换。

代码块TS · 55 行收起展开
// 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 字符串。

代码块TS · 36 行收起展开
// 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 // 命令退出码。
      }
    };
  }
});

白名单:

代码块TS · 7 行收起展开
// 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。

代码块TS · 6 行收起展开
// PermissionDecision = 权限决策。
// 审计时需要知道允许/拒绝的来源和原因。
export type PermissionDecision =
  | { type: "allow"; source: "readonly_policy" | "trusted_tool" | "user_approved" } // 允许。
  | { type: "deny"; reason: string } // 拒绝。
  | { type: "ask"; prompt: string }; // 需要用户确认。

这样 audit 能记录“为什么允许/拒绝”。

ToolAudit

代码块TS · 17 行收起展开
// 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
无法复盘无 auditJSONL audit

读源码抓手

看工具系统,按顺序找:

  1. Tool 接口定义。
  2. Registry 如何注册。
  3. Tools schema 怎么交给模型。
  4. executeToolCall 是否统一入口。
  5. 参数校验在哪。
  6. 权限在哪。
  7. 单个工具是否做业务安全。
  8. 输出是否截断。
  9. 审计是否完整。

看懂工具执行器,就看懂 Agent 安全边界的一半。