Agent CLI深水区 - 工具执行器权限审计 · AI Agent

Agent CLI 深水区 03:工具执行器、权限与审计

工具执行器为什么单独讲

Agent CLI 的危险点不在“模型会说错话”,而在“模型说错话后,程序真的执行了动作”。

工具执行器是安全边界:

代码块TEXT · 9 行收起展开
模型输出 tool_call
-> 参数校验
-> 工具存在性检查
-> 权限判断
-> 风险评估
-> 执行
-> 输出截断
-> 审计记录
-> 回填给模型

任何一步省掉,都可能出问题。

ToolExecutor 权限审计链路 Deterministic path tool_call QueryEngine Executor lookup schema policy deny audit ask approval Tool.run normalize audit ToolResult error

这不是为了画流程,而是为了看清责任边界:QueryEngine 不应该直接调用工具;ToolExecutor 不应该自己决定所有业务权限;单个 Tool 也不能绕开审计。

Tool 定义要比 demo 更严格

代码块TS · 23 行收起展开
// ToolRisk = 工具风险等级。
// 风险等级决定是否需要审批、审计强度、默认是否允许。
export type ToolRisk = "read" | "write" | "execute" | "network";

// Tool = 一个可被模型调用的工具定义。
// Input/Output 是泛型,表示这个工具自己的入参和返回类型。
export type Tool<Input, Output> = {
  name: string;              // 工具名,暴露给模型,例如 read_file。
  description: string;       // 工具说明,影响模型什么时候调用它。
  inputSchema: JsonSchema;   // 参数 schema,运行前必须校验。
  risk: ToolRisk;            // 风险等级:读、写、执行命令、网络。
  timeoutMs: number;         // 最大执行时间,防止工具卡死。
  maxOutputBytes: number;    // 最大返回内容大小,防止撑爆上下文。
  run(input: Input, ctx: ToolContext): Promise<Output>; // 真正的工具实现。
};

// ToolContext = 工具执行时的受控上下文。
// 不要直接给工具全局 process/env,应该只给它需要的最小信息。
export type ToolContext = {
  cwd: string;                              // 当前工作区根目录。
  sessionId: string;                        // 用于 audit/trace。
  env: Record<string, string | undefined>;  // 受控环境变量。
};

比入门版多了:

  • timeoutMs:每个工具自己声明超时。
  • maxOutputBytes:每个工具自己声明最大输出。
  • risknetwork:WebFetch、WebSearch、MCP 都可能涉及网络。
  • env:工具可能需要受控环境变量。

Tool Registry 需要冻结

注册表最好启动时完成,运行中不要随便改。

代码块TS · 32 行收起展开
// registry = 工具注册表。
// key 是工具名,value 是工具定义。
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.");
  }

  // 工具名必须是稳定、安全、可序列化的名字。
  if (!/^[a-zA-Z0-9_:-]+$/.test(tool.name)) {
    throw new Error(`Invalid tool name: ${tool.name}`);
  }

  // 禁止重名。重名会导致模型以为调用 A,实际执行 B。
  if (registry.has(tool.name)) {
    throw new Error(`Duplicate tool: ${tool.name}`);
  }

  registry.set(tool.name, tool);
}

// freezeTools = 启动阶段结束后冻结工具注册表。
export function freezeTools() {
  frozen = true;
}

为什么要 freeze?

  • 避免运行中被插件篡改。
  • 保证模型看到的工具列表和实际工具一致。
  • 方便 trace 复盘。

参数校验不能只相信模型

模型可能输出:

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

也可能输出:

代码块JSON · 3 行收起展开
{
  "path": 123
}

所以要校验 schema,还要做业务校验。

代码块TS · 16 行收起展开
// validateToolInput = 工具参数校验。
// 模型输出的 input 不可信,必须先过 schema。
export function validateToolInput(tool: Tool<any, any>, input: unknown) {
  // validateJson 只检查 JSON 形状是否符合 inputSchema。
  const schemaResult = validateJson(input, tool.inputSchema);
  if (!schemaResult.ok) {
    // 参数不合法时抛结构化错误,后续可让模型重试。
    throw new AgentError("TOOL_INPUT_INVALID", schemaResult.message, {
      tool: tool.name,
      input
    });
  }

  // schema 通过后返回 input。真实系统可在这里做类型收窄。
  return input;
}

schema 只能保证类型,不保证安全。路径越界、命令危险、文件过大,要在工具内部继续判断。

威胁模型与防线

工具系统的设计目标不是“让模型更聪明”,而是“模型犯错时损失可控”。

威胁例子第一防线第二防线审计要记
工具伪造调用不存在的 delete_projectregistry lookup回填可用工具列表toolName、错误码
参数畸形path: 123JSON Schema结构化错误恢复原始 input 摘要
路径逃逸../../.ssh/id_rsaresolveWorkspacePathworkspace allowlistcwd/userPath/abs
误写文件oldText 多处命中命中次数检查diff preview + 用户批准diff 摘要、批准来源
命令注入npm test && curl ...command/args 分离命令白名单command、args、exitCode
输出污染grep 返回几十万行output limitsummary + truncated 标记outputBytes、truncated
权限绕过插件动态注册高危工具registry freezecapability modelplugin/tool 来源

路径安全:读写文件必须过这关

代码块TS · 30 行收起展开
import { resolve, relative, isAbsolute } from "node:path";

// resolveWorkspacePath = 文件工具的路径安全闸门。
// 目标:把用户路径解析成绝对路径,同时禁止逃出 workspace。
export function resolveWorkspacePath(cwd: string, userPath: string) {
  // 空路径和 NUL 字符都拒绝。NUL 可能导致底层文件 API 行为异常。
  if (!userPath || userPath.includes("\0")) {
    throw new AgentError("TOOL_INPUT_INVALID", "Invalid path.");
  }

  // 如果用户给绝对路径,就规范化;否则相对 cwd 解析。
  const abs = isAbsolute(userPath)
    ? resolve(userPath)
    : resolve(cwd, userPath);

  // 计算 abs 相对于 cwd 的相对路径。
  const rel = relative(cwd, abs);

  // rel 以 .. 开头或仍是绝对路径,说明 abs 不在 workspace 内。
  if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
    throw new AgentError("TOOL_PERMISSION_DENIED", "Path escapes workspace.", {
      cwd,
      userPath,
      abs
    });
  }

  // 返回安全的绝对路径。
  return abs;
}

这段是文件工具的底线:

  • 禁止空路径。
  • 禁止 NUL 字符。
  • 规范化绝对路径。
  • relative 判断是否逃出工作区。

PermissionDecision

权限系统不要只返回 boolean。要返回“为什么”。

代码块TS · 18 行收起展开
// PermissionDecision = 权限判断结果。
// 不是 boolean,因为我们还要知道允许/拒绝的原因,方便 audit。
type PermissionDecision =
  | {
      type: "allow";
      // source 表示为什么允许:只读策略、用户批准、可信工具等。
      source: "readonly_policy" | "user_approved" | "trusted_tool";
    }
  | {
      type: "deny";
      // 拒绝原因要写清楚,最终要能反馈给用户和 trace。
      reason: string;
    }
  | {
      type: "ask";
      // ask 表示需要用户确认,prompt 是展示给用户看的批准文案。
      prompt: string;
    };

为什么?

因为 audit 里要知道:

  • 是自动允许?
  • 是用户点了同意?
  • 是策略拒绝?
  • 是工具太危险?

权限判断流程

代码块TS · 38 行收起展开
// checkPermission = 根据工具风险和输入决定是否允许执行。
export async function checkPermission(
  tool: Tool<any, any>,
  input: unknown,
  ctx: ToolContext
): Promise<PermissionDecision> {
  // 只读工具默认允许,但工具内部仍要拦敏感路径。
  if (tool.risk === "read") {
    return { type: "allow", source: "readonly_policy" };
  }

  // 某些工具/输入可以被策略信任,例如固定安全命令。
  if (isTrustedTool(tool.name, input)) {
    return { type: "allow", source: "trusted_tool" };
  }

  // 执行命令风险高,默认询问用户。
  if (tool.risk === "execute") {
    return {
      type: "ask",
      prompt: `Allow command execution by ${tool.name}?`
    };
  }

  // 写文件也默认询问用户,最好展示 diff。
  if (tool.risk === "write") {
    return {
      type: "ask",
      prompt: `Allow file modification by ${tool.name}?`
    };
  }

  // 没处理到的风险类型默认拒绝,安全上 fail closed。
  return {
    type: "deny",
    reason: `Unhandled risk type: ${tool.risk}`
  };
}

注意:

read 自动允许不代表所有 read 都安全。读敏感路径仍然要在工具里拦。

写入前 diff preview

写文件不要直接覆盖。先生成 diff。

代码块TS · 21 行收起展开
// WritePreview = 写文件前的预览对象。
// 权限提示应该展示这个对象里的 diff,而不是只说“我要写文件”。
type WritePreview = {
  path: string;       // 目标文件路径。
  oldContent: string; // 写入前内容。新建文件时为空。
  newContent: string; // 计划写入的新内容。
  diff: string;       // old/new 生成的统一 diff。
};

// previewWrite = 生成写入前 diff。
export async function previewWrite(path: string, newContent: string): Promise<WritePreview> {
  // 文件不存在时按新建处理。
  const oldContent = await readFile(path, "utf8").catch(() => "");

  return {
    path,
    oldContent,
    newContent,
    diff: createUnifiedDiff(oldContent, newContent)
  };
}

权限提示应该展示:

  • 文件路径。
  • 新增/删除行数。
  • diff 片段。
  • 是否新建文件。

这样用户知道自己批准了什么。

执行命令:白名单优先

执行命令工具最容易出事。不要用一整条 shell string 直接跑。

更好的输入结构:

代码块TS · 7 行收起展开
// BashInput = 结构化命令输入。
// command 和 args 分开,避免模型拼出一整条危险 shell 字符串。
type BashInput = {
  command: string;    // 命令名,例如 git、npm、node。
  args: string[];     // 参数数组,例如 ["test", "--", "auth"]。
  timeoutMs?: number; // 可选超时,但程序会再设上限。
};

而不是:

代码块TS · 3 行收起展开
type BadBashInput = {
  command: string; // 坏例子:整条 shell 字符串,可能包含 "rm -rf . && curl ..."
};

执行:

代码块TS · 18 行收起展开
// runAllowedCommand = 执行白名单命令。
// 入门版只允许固定命令集合,真实系统还要按风险和用户批准细分。
export async function runAllowedCommand(input: BashInput, ctx: ToolContext) {
  // 白名单比黑名单安全,因为危险命令变体太多。
  const allowed = new Set(["git", "npm", "node", "python"]);

  // 不在白名单就拒绝。
  if (!allowed.has(input.command)) {
    throw new AgentError("TOOL_PERMISSION_DENIED", `Command not allowed: ${input.command}`);
  }

  // execFile 不走 shell 展开,command/args 分离,注入风险更低。
  return execFileWithLimit(input.command, input.args, {
    cwd: ctx.cwd,                                      // 限制在当前工作区执行。
    timeoutMs: Math.min(input.timeoutMs ?? 10_000, 30_000), // 用户传的超时不能超过上限。
    maxOutputBytes: 512_000                            // 输出最多 512KB。
  });
}

白名单比黑名单强,因为危险命令写法太多。

输出截断

工具输出不能无限塞回模型。

代码块TS · 19 行收起展开
// truncateOutput = 截断工具输出。
// 目的:避免命令输出、grep 结果、日志把模型上下文撑爆。
export function truncateOutput(text: string, maxBytes: number) {
  // 按 UTF-8 字节数算,不按字符串长度算,因为 token/传输更接近字节成本。
  const bytes = Buffer.byteLength(text, "utf8");
  if (bytes <= maxBytes) {
    return { text, truncated: false };
  }

  // 截取前 maxBytes 个字节,再转回字符串。
  // 真实系统要注意不要截断在多字节字符中间。
  const sliced = Buffer.from(text, "utf8").subarray(0, maxBytes).toString("utf8");

  return {
    // 明确告诉模型输出被截断,不能让模型误以为这是完整输出。
    text: `${sliced}\n\n[Output truncated: ${bytes} bytes total]`,
    truncated: true
  };
}

截断要告诉模型,不要假装完整输出。

Tool Audit

工具审计记录至少包括:

代码块TS · 18 行收起展开
// ToolAuditRecord = 工具调用审计记录。
// 它回答:模型请求了什么工具、传了什么参数、权限怎么判、结果如何。
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;          // 失败错误信息。
};

写入:

代码块TS · 8 行收起展开
// appendToolAudit = 追加一条工具审计记录。
// JSONL 一行一个记录,适合不断追加和后续 grep/分析。
export async function appendToolAudit(record: ToolAuditRecord) {
  await appendFile(
    ".agent/tool-audit.jsonl",
    JSON.stringify(record) + "\n"
  );
}

没有 audit,你无法回答:

  • Agent 改了哪个文件?
  • 谁批准的?
  • 哪个命令失败了?
  • 输出被截断了吗?
  • 工具耗时多少?

审计字段的排查价值:

字段主要回答的问题
sessionId / callId这次工具调用属于哪轮任务,能否串回完整 trace
toolName / risk模型选择了什么能力,风险等级是否合理
input错误是否来自参数生成、路径、命令或范围
decision是策略自动允许、用户批准,还是被拒绝
durationMs是否卡在工具执行、网络或命令超时
outputBytes是否可能把上下文撑爆
errorCode上层状态机应该重试、让模型修参,还是终止

executeToolCall 完整版

代码块TS · 88 行收起展开
// executeToolCall = 工具执行总入口。
// 所有工具调用都必须经过这里:查工具 -> 校验参数 -> 权限 -> 执行 -> 截断 -> 审计。
export async function executeToolCall(call: ToolCall): Promise<ToolResult> {
  // 记录开始时间,用于审计 durationMs。
  const startedAt = Date.now();

  // 从注册表查找工具定义。
  const tool = registry.get(call.name);

  if (!tool) {
    // 工具不存在,抛结构化错误。上层可把可用工具列表回填给模型。
    throw new AgentError("TOOL_NOT_FOUND", `Unknown tool: ${call.name}`);
  }

  // 参数 schema 校验。模型生成的 input 不可信。
  const input = validateToolInput(tool, call.input);

  // 创建工具上下文,例如 cwd、sessionId、受控 env。
  const ctx = createToolContext(call.sessionId);

  // 权限判断:可能 allow、deny、ask。
  const decision = await checkPermission(tool, input, ctx);

  if (decision.type === "deny") {
    // 策略拒绝,直接失败。
    throw new AgentError("TOOL_PERMISSION_DENIED", decision.reason);
  }

  if (decision.type === "ask") {
    // 需要用户批准。真实 CLI 会弹确认或终端询问。
    const approved = await askUser(decision.prompt);
    if (!approved) {
      throw new AgentError("TOOL_PERMISSION_DENIED", "User denied tool call.");
    }
  }

  try {
    // withTimeout 包住工具执行,防止工具无限挂起。
    const raw = await withTimeout(
      tool.run(input, ctx),
      tool.timeoutMs
    );

    // 统一工具输出形状,并按 maxOutputBytes 截断。
    const normalized = normalizeToolOutput(raw, tool.maxOutputBytes);

    // 成功也要写审计。
    await appendToolAudit({
      sessionId: call.sessionId,
      callId: call.id,
      toolName: tool.name,
      risk: tool.risk,
      input,
      decision,
      startedAt: new Date(startedAt).toISOString(),
      endedAt: new Date().toISOString(),
      durationMs: Date.now() - startedAt,
      ok: true,
      outputSummary: normalized.summary,
      outputBytes: Buffer.byteLength(normalized.content, "utf8")
    });

    // 返回给 QueryEngine,随后会作为 tool message 回填给模型。
    return normalized;
  } catch (error) {
    // 工具内部抛的任何错误,都归一化成 AgentError。
    const agentError = normalizeToolError(error);

    // 失败也要写审计。没有失败审计,最关键的排查信息就丢了。
    await appendToolAudit({
      sessionId: call.sessionId,
      callId: call.id,
      toolName: tool.name,
      risk: tool.risk,
      input,
      decision,
      startedAt: new Date(startedAt).toISOString(),
      endedAt: new Date().toISOString(),
      durationMs: Date.now() - startedAt,
      ok: false,
      errorCode: agentError.code,
      errorMessage: agentError.message
    });

    // 继续抛给 QueryEngine,由状态机决定是否 recover。
    throw agentError;
  }
}

这就是工具执行器的“完整骨架”。

生产事故演练:工具调用的红线

工具系统是否成熟,不看“接了多少工具”,看遇到坏输入、坏模型决策、坏插件、坏环境时能不能做到四件事:

  1. 默认拒绝:权限不清楚、边界不清楚、影响不可逆时,不执行。
  2. 原因可解释:拒绝不是一句 permission denied,而是能说清楚命中了哪条策略。
  3. 结果可回放:trace/audit 足够复现当时的工具、参数、权限、输出和错误。
  4. 失败可转 eval:每一次事故都能沉淀成一个固定回归用例。
事故类型典型触发正确行为trace/audit 必须留下eval 怎么写
路径越界../、符号链接、绝对路径逃逸拒绝执行,返回 PATH_OUTSIDE_WORKSPACE原始 path、规范化 path、允许根目录、拒绝原因构造越界路径,断言工具未执行
Prompt 注入借工具越权文档内容诱导“忽略规则并删除文件”把文档当数据,不把文档指令升格为系统指令prompt 来源、tool request 来源、权限决策RAG chunk 中放恶意指令,断言未写入
Shell 注入参数里夹 && rm / 管道 / 重定向schema 校验失败或命令白名单拒绝命令模板、参数数组、拒绝规则断言未拼接 shell 字符串
破坏性写入覆盖大文件、删除目录、批量移动必须 ask,展示 diff/影响范围diff 摘要、影响文件数、用户批准状态无批准时必须失败
大输出淹没上下文grep/搜索返回几十万字符截断、摘要、分页,标记 truncated=true原始字节数、返回字节数、截断策略断言输出小于预算且有分页提示
工具 schema 漂移client 看到旧 schema,server 按新 schema 执行版本不兼容时拒绝或走兼容分支schemaVersion、clientVersion、serverVersion旧参数调用新工具,断言结构化错误
重试导致重复副作用网络抖动后模型再次提交同一写操作使用 idempotency key 或拒绝重复提交callId、idempotencyKey、sideEffectHash同一 key 连续调用,结果只能生效一次
超时后的半副作用工具超时但外部命令可能还在跑取消子进程,记录不确定状态,禁止假装成功timeoutMs、cancelResult、partialState超时用例必须返回 retryable/unknown

生产级工具定义里要显式写出风险画像。不要让风险散落在注释、prompt 或口头约定里。

代码块TS · 28 行收起展开
// ToolRiskProfile = 工具上线前的风险画像。
// 它回答:这个工具能碰什么资源、会不会产生副作用、什么时候必须问用户。
type ToolRiskProfile = {
  toolName: string;                  // 稳定工具名,必须能和 audit/tool call 对上。
  capability: "read" | "write" | "network" | "execute"; // 工具能力类别,用于默认策略。
  sideEffect: "none" | "idempotent" | "destructive";    // 是否会改变外部状态。
  schemaVersion: string;             // 参数契约版本,防止 client/server 漂移。

  workspaceBoundary?: {
    mode: "allowlist";               // 生产环境优先白名单,不靠黑名单猜危险路径。
    roots: string[];                 // 允许访问的根目录。
    denyGlobs: string[];             // 即使在根目录内也禁止访问的路径模式。
  };

  approval: {
    requiredWhen: string[];           // 触发人工批准的条件,例如 write/delete/execute。
    promptFields: string[];           // 批准界面必须展示哪些字段,例如 diff、target、reason。
  };

  limits: {
    timeoutMs: number;                // 单次工具最大执行时间。
    maxInputBytes: number;            // 防止模型塞入超大参数。
    maxOutputBytes: number;           // 防止工具输出挤爆上下文。
    maxAffectedFiles?: number;        // 文件类工具的影响半径。
  };

  auditFields: string[];              // 必须落盘的审计字段,不能靠日志文本临时 grep。
};

工具上线门槛可以直接照这个表检查:

门槛不合格表现合格表现
边界工具内部直接使用模型给的路径/命令参数先 schema 校验,再业务边界校验
权限readOnly 只是描述,没有强制策略executor 按 capability/sideEffect 强制判定
批准只问“是否允许执行”展示目标、diff、影响范围、风险原因
审计只记录自然语言日志记录结构化 callId/input/decision/result
回归事故靠人记忆事故转成固定 eval case
降级工具失败后模型继续编工具失败会进入 recover/refuse/ask 分支

工具设计的高级原则

原则解释
小工具优先read_filedo_anything 安全
参数结构化不要让模型拼 shell 字符串
读写分离read_filewrite_file 分开
默认只读先让 Agent 看懂,再允许修改
写入可预览diff 是批准前提
输出可控截断、摘要、元数据
审计完整每次调用都能复盘
错误可恢复参数错可以让模型重试

读源码时看工具系统

读任何 Agent CLI 的工具源码,按这个顺序:

  1. Tool 类型。
  2. Registry。
  3. Tool schema 暴露。
  4. 参数校验。
  5. 权限判断。
  6. 单个工具实现。
  7. 输出截断。
  8. audit。
  9. 错误恢复。

不要一开始就看某个具体工具怎么 grep。先看执行器怎么保证安全。