试读章节:Tool Runtime 与权限审批
这篇继续放《Agent Runtime 工程化》的试读。
上一篇讲 Agent Loop 的生命周期。
这一篇讲工具。
更准确地说,是 Tool Runtime。
因为在生产级 Agent 里,工具不是函数列表。
工具是 Agent 行动的治理入口。
工具不是 Map<string, Function>
很多 demo 会这样写:
const tools = {
read_file,
write_file,
run_command,
};
模型返回:
{"name":"read_file","arguments":{"path":"README.md"}}
程序执行:
await tools[call.name](call.arguments);
这能跑。
但只适合 demo。
一旦工具能写文件、跑命令、联网、访问数据库,你就不能只关心'函数怎么调用'。
你要关心:
这个工具是否存在?
参数是否合法?
参数行为是否安全?
风险等级是什么?
是否需要用户批准?
输出太大怎么办?
失败如何反馈模型?
副作用如何进入 checkpoint?
所以工具要先'上户口'。
ToolDefinition 至少要说清楚什么
一个工具定义,最少要覆盖四类读者。
模型要读工具名、描述和 schema,用来决定是否调用。
Permission Gate 要读风险、副作用、资源范围和 preview,用来决定是否允许执行。
Executor 要读 timeout、并行安全、取消信号和输出策略,用来控制执行。
Context Builder 要读 output policy,用来决定工具结果如何进入上下文。
可以从这样的结构开始:
type ToolDefinition<TInput = unknown> = {
name: string;
version: string;
description: string;
inputSchema: ObjectSchema;
risk: "read" | "write" | "shell" | "network" | "dangerous";
parallelSafe: boolean;
source: "local" | "mcp";
outputPolicy: {
maxChars: number;
modelSummary: "full" | "head_tail" | "summary_only";
};
execute(input: TInput, context: ToolContext): Promise<ToolResult>;
};
少这些字段,系统不会立刻报错。
但后面某个模块一定会用猜的。
工程事故常常就是从'先随便填一下'开始的。
Schema 只说明长得对,不说明能执行
以 read_file 为例。
schema 可能只要求:
const ReadFileInput = {
type: "object",
required: ["path"],
properties: {
path: { type: "string" },
startLine: { type: "integer", optional: true },
endLine: { type: "integer", optional: true },
},
};
这能证明什么?
只能证明 path 是字符串。
它不能证明:
path 在 workspace 内
path 不是 symlink escape
path 不是 .env
文件大小适合进入上下文
用户有权限读取
所以要分三层:
Schema validation:参数形状对不对。
Policy validation:这个行为是否允许。
Approval decision:是否需要人确认。
把这三层混在一起,后面测试会很痛。
Tool Registry 先生成计划
模型返回 tool call 后,不应该直接 execute。
Runtime 应先生成工具计划:
type ToolPlan = {
call: ToolCall;
tool?: ToolDefinition;
validationErrors: string[];
};
示例:
class ToolRegistry {
plan(call: ToolCall): ToolPlan {
const tool = this.tools.get(call.name);
if (!tool) {
return { call, validationErrors: ["UNKNOWN_TOOL"] };
}
const validation = validateObjectSchema(tool.inputSchema, call.input);
if (!validation.ok) {
return { call, tool, validationErrors: validation.errors };
}
return { call, tool, validationErrors: [] };
}
}
计划阶段的价值,是把风险摊开。
工具不存在,不执行。
参数非法,不执行。
高风险工具,进入审批。
只读工具,可以自动允许。
写入工具,生成 preview。
Runtime 要在执行前知道自己准备做什么。
PermissionGate 是 Runtime 的裁判
工具自己不应该决定'我能不能执行'。
工具可以声明风险。
Runtime 根据工具风险、参数、用户策略和当前模式做裁决。
一个最小决策对象:
type PermissionDecision = {
callId: string;
toolName: string;
action: "allow" | "deny" | "ask";
reason: string;
risk: "read" | "write" | "shell" | "network" | "dangerous";
decidedAt: string;
preview?: string;
};
demo 里可以这样处理:
read:自动允许
shell:如果 allowShellReadonly=true,允许只读 shell
network:默认拒绝
write:需要显式 allowWrites
dangerous:默认拒绝
真实产品里,ask 会进入 UI 审批。
但不管是 policy 自动裁决,还是用户点击批准,都要写入 trace。
审批是系统事实。
不是前端弹窗状态。
写文件为什么必须 preview
写文件工具不能只显示:
Allow write_file?
用户不知道它要写哪里,也不知道改什么。
一个可用 preview 至少要说清:
目标路径
内容长度
是否新建文件
diff 摘要
写入原因
是否会覆盖已有内容
学习 demo 可以先简化:
function previewWrite(plan: ToolPlan): string | undefined {
if (plan.call.name !== "write_text") return undefined;
const input = plan.call.input as { path?: unknown; content?: unknown };
return `准备写入 ${String(input.path ?? "unknown")},内容长度 ${String(input.content ?? "").length} 字符。`;
}
生产系统里,最好给 diff。
因为用户批准的不是'运行一个工具'。
用户批准的是具体副作用。
Shell 工具要保守
Shell 是最有用也最危险的工具。
npm test 看起来安全,但 test script 可以做任何事。
node script.js 看起来普通,但脚本可以联网、删文件、读环境变量。
curl 可能只是查资料,也可能上传数据。
所以 shell policy 第一版应该保守。
示例:
const READONLY_COMMANDS = new Set(["pwd", "ls", "find", "rg", "cat", "sed", "wc"]);
function classifyReadonlyCommand(command: string) {
const first = command.trim().split(/\s+/)[0] ?? "";
if (!READONLY_COMMANDS.has(first)) return "DENY_UNKNOWN_COMMAND";
if (/[>|;&`$]/.test(command)) return "DENY_SHELL_OPERATOR";
if (/\b(rm|mv|cp|chmod|chown|curl|wget|ssh|sudo)\b/.test(command)) {
return "DENY_RISKY_TOKEN";
}
return "ALLOW_READONLY_COMMAND";
}
这不是完美沙箱。
它是第一层策略。
后面还要配合:
workspace cwd
环境变量过滤
timeout
输出上限
进程树清理
网络策略
sandbox profile
trace
不要指望一个正则表达式守住所有安全边界。
Workspace 边界是底线
文件工具必须限制在 workspace 内。
这件事不能靠 prompt。
路径检查也不能只做字符串前缀。
更稳的方式是:
先 path.resolve
再 realpath
再 path.relative 判断是否逃出 workspace
写入时检查父目录真实路径
拒绝写入 symlink
本书 demo 里,resolveForWrite 会拒绝 symlink 写入:
if (stat.isSymbolicLink()) {
throw new WorkspaceSecurityError("拒绝写入符号链接目标", "DENY_SYMLINK_WRITE");
}
这类代码看着不酷。
但很重要。
很多工具事故不是发生在模型'想作恶'。
而是发生在路径、符号链接、环境变量、默认工作目录这些细节里。
ToolResult 要同时服务模型和工程系统
工具结果不能只返回:
done
也不能把完整日志一股脑塞回模型。
一个更可用的结构:
type ToolResult = {
callId: string;
toolName: string;
status: "ok" | "error" | "denied" | "timeout" | "cancelled";
content: string;
errorCode?: string;
metadata: {
durationMs: number;
outputChars: number;
truncated: boolean;
retryable: boolean;
sideEffects?: ToolSideEffect[];
};
};
content 是给模型继续推理的 observation。
metadata 是给 Runtime、trace、eval 和 checkpoint 的证据。
如果工具被拒绝,要返回 denied。
如果命令超时,要返回 timeout。
如果输出被截断,要标记 truncated。
如果写了文件,要记录 sideEffects。
这就是 Tool Runtime 和普通函数调用的差别。
权限拒绝也要写回模型
当写入被拒绝时,不应该直接崩溃。
更好的做法是生成一个 tool result:
status: denied
errorCode: PERMISSION_DENIED
content: 写入工具需要显式开启 --allow-writes。
retryable: false
然后写回 message history。
模型下一步应该基于这个 observation 停止,或者找低风险替代方案。
如果 Runtime 只是抛异常,模型看不到拒绝原因,用户也只看到程序失败。
一条工具治理管线
把整个流程串起来,是这样:
ToolDefinition
↓
ToolRegistry.plan()
↓
schema validation
↓
policy validation
↓
PermissionGate.review()
↓
preview / allow / ask / deny
↓
ToolExecutor.execute()
↓
ToolResult normalize
↓
MessageStore + Trace + Checkpoint
这条线少一步都容易出事。
少 schema validation,模型乱填参数。
少 policy validation,路径越界和危险命令混进来。
少 PermissionGate,高风险动作自动执行。
少 ToolResult normalize,模型只能猜失败原因。
少 trace,事故后没人知道发生了什么。
少 checkpoint,恢复时不知道副作用到哪一步。
试读小结
Tool Runtime 的目标不是让模型拥有更多按钮。
目标是让 Runtime 能稳定、安全、可追溯地使用这些按钮。
模型负责提出:
我想调用哪个工具,参数是什么。
Runtime 负责判断:
这个工具是否存在,参数是否合法,风险是否可接受,用户是否批准,怎样执行,失败如何反馈。
这是生产级 Agent 的分水岭。


