Agent CLI深水区 - 工具执行器权限审计 · AI Agent
Agent CLI 深水区 03:工具执行器、权限与审计
工具执行器为什么单独讲
Agent CLI 的危险点不在“模型会说错话”,而在“模型说错话后,程序真的执行了动作”。
工具执行器是安全边界:
代码块收起展开
模型输出 tool_call
-> 参数校验
-> 工具存在性检查
-> 权限判断
-> 风险评估
-> 执行
-> 输出截断
-> 审计记录
-> 回填给模型任何一步省掉,都可能出问题。
这不是为了画流程,而是为了看清责任边界:QueryEngine 不应该直接调用工具;ToolExecutor 不应该自己决定所有业务权限;单个 Tool 也不能绕开审计。
Tool 定义要比 demo 更严格
代码块收起展开
// 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:每个工具自己声明最大输出。risk加network:WebFetch、WebSearch、MCP 都可能涉及网络。env:工具可能需要受控环境变量。
Tool Registry 需要冻结
注册表最好启动时完成,运行中不要随便改。
代码块收起展开
// 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 复盘。
参数校验不能只相信模型
模型可能输出:
代码块收起展开
{
"path": "../../secret.txt"
}也可能输出:
代码块收起展开
{
"path": 123
}所以要校验 schema,还要做业务校验。
代码块收起展开
// 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_project | registry lookup | 回填可用工具列表 | toolName、错误码 |
| 参数畸形 | path: 123 | JSON Schema | 结构化错误恢复 | 原始 input 摘要 |
| 路径逃逸 | ../../.ssh/id_rsa | resolveWorkspacePath | workspace allowlist | cwd/userPath/abs |
| 误写文件 | oldText 多处命中 | 命中次数检查 | diff preview + 用户批准 | diff 摘要、批准来源 |
| 命令注入 | npm test && curl ... | command/args 分离 | 命令白名单 | command、args、exitCode |
| 输出污染 | grep 返回几十万行 | output limit | summary + truncated 标记 | outputBytes、truncated |
| 权限绕过 | 插件动态注册高危工具 | registry freeze | capability model | plugin/tool 来源 |
路径安全:读写文件必须过这关
代码块收起展开
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。要返回“为什么”。
代码块收起展开
// 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 里要知道:
- 是自动允许?
- 是用户点了同意?
- 是策略拒绝?
- 是工具太危险?
权限判断流程
代码块收起展开
// 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。
代码块收起展开
// 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 直接跑。
更好的输入结构:
代码块收起展开
// BashInput = 结构化命令输入。
// command 和 args 分开,避免模型拼出一整条危险 shell 字符串。
type BashInput = {
command: string; // 命令名,例如 git、npm、node。
args: string[]; // 参数数组,例如 ["test", "--", "auth"]。
timeoutMs?: number; // 可选超时,但程序会再设上限。
};而不是:
代码块收起展开
type BadBashInput = {
command: string; // 坏例子:整条 shell 字符串,可能包含 "rm -rf . && curl ..."
};执行:
代码块收起展开
// 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。
});
}白名单比黑名单强,因为危险命令写法太多。
输出截断
工具输出不能无限塞回模型。
代码块收起展开
// 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
工具审计记录至少包括:
代码块收起展开
// 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; // 失败错误信息。
};写入:
代码块收起展开
// 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 完整版
代码块收起展开
// 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;
}
}这就是工具执行器的“完整骨架”。
生产事故演练:工具调用的红线
工具系统是否成熟,不看“接了多少工具”,看遇到坏输入、坏模型决策、坏插件、坏环境时能不能做到四件事:
- 默认拒绝:权限不清楚、边界不清楚、影响不可逆时,不执行。
- 原因可解释:拒绝不是一句
permission denied,而是能说清楚命中了哪条策略。 - 结果可回放:trace/audit 足够复现当时的工具、参数、权限、输出和错误。
- 失败可转 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 或口头约定里。
代码块收起展开
// 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_file 比 do_anything 安全 |
| 参数结构化 | 不要让模型拼 shell 字符串 |
| 读写分离 | read_file 和 write_file 分开 |
| 默认只读 | 先让 Agent 看懂,再允许修改 |
| 写入可预览 | diff 是批准前提 |
| 输出可控 | 截断、摘要、元数据 |
| 审计完整 | 每次调用都能复盘 |
| 错误可恢复 | 参数错可以让模型重试 |
读源码时看工具系统
读任何 Agent CLI 的工具源码,按这个顺序:
- Tool 类型。
- Registry。
- Tool schema 暴露。
- 参数校验。
- 权限判断。
- 单个工具实现。
- 输出截断。
- audit。
- 错误恢复。
不要一开始就看某个具体工具怎么 grep。先看执行器怎么保证安全。