一次“帮我查订单”的越权提议
「回声」的客服助手接入了订单查询。用户说“帮我看看 E20261026001 到哪了”,模型返回一个工具提议:
{"id":"call-17","name":"orders.lookup","arguments":{"orderId":"E20261026001","tenantId":"t-other"}}
参数看起来齐全,订单号也对。但 tenantId 不是用户给的,也不是会话上下文里的,而是模型从更早的一段测试数据里“补”出来的。如果网关直接把参数透传给数据访问层,这次查询就会落到另一个租户。
同一天还有第二个提议。用户说“这么慢,退钱吧”,模型直接调用 refund.create,金额 100。坐席的实际角色是 operator,退款工具要求 admin,而且这个租户根本没有开通退款工具。
这两个例子说明,工具调用不是“模型调用了一个函数”,而是“模型提议了一次带副作用的业务动作”。提议可以来自模型,授权和执行不能。
模型只提议,网关才执行
把链路拆成三段,职责就清楚了。
第一段是提议。模型根据对话决定“要不要调用工具、调用哪个、参数是什么”。它输出的是结构化数据,不是执行结果。模型可以犯错、可以被提示注入、可以幻觉出一个不存在的工具,这些都在预期内。
第二段是授权。网关拿到提议后,依次检查工具是否注册、租户是否开通、角色是否足够、参数是否越界、用户是否确认、本轮次数是否超限。任何一项不通过,提议就停在网关,不产生业务副作用。
第三段是执行。只有授权通过的参数才会进入业务适配器。适配器从会话上下文拿租户身份,拼进查询条件,再调用领域服务。模型提供的参数只描述“查什么”,不描述“以谁的身份查”。
为什么不让模型直接调函数
最直接的做法是把函数签名塞进提示,让模型生成调用,再由运行时反射执行。这个做法省事,但把三个本该公司掌握的东西交给了模型:可调用集合、调用者身份、副作用边界。
模型不知道当前租户是谁,也不该知道;模型不知道坐席有没有退款权限,也不该知道;模型更不知道一次退款会不会触发财务对账。把这些判断留在模型外面,不是不信任模型,而是不让一个概率系统承担确定性职责。
工具契约与副作用分级
每个工具在注册表里有一份契约:名称、副作用等级、最低角色、参数 Schema、单轮最大调用次数。参数 Schema 直接复用 7.1 的校验器,工具边界和输出边界共享同一套路径与问题码。
副作用分四级:
| 等级 | 含义 | 例子 | 是否需要确认 |
|---|---|---|---|
read | 只读,不改变任何状态 | 查订单、查物流 | 否 |
draft | 只生成草稿,不对外发送 | 生成回复草稿 | 否 |
write | 改变站内状态 | 修改收货地址、关闭工单 | 是 |
external | 站外或资金动作 | 发起退款、调用第三方物流 | 是 |
分级不是为了给工具“贴标签”,而是为了决定两件事:要不要打断用户确认,以及默认给多高的角色门槛。draft 之所以不需要确认,是因为它没有对外副作用;一旦坐席点了发送,动作就从 draft 变成了 write,应该走另一条授权路径。
六个检查点,顺序不能乱
授权器里的检查顺序是固定的,因为顺序本身会影响安全性和用户体验。
- 工具是否在全局注册表里。未注册的名字直接拒绝,不进入后续任何分支。
- 租户是否开通了这个工具。全局注册表是能力上限,租户白名单是实际开通范围。
- 当前角色是否达到工具的最低角色。角色来自会话,不来自请求参数。
- 参数里是否出现租户字段。模型不得自带
tenantId或tenant_id,嵌套在过滤条件里也要拦住。 - 参数是否符合 Schema。失败时返回具体路径,方便提示修复。
- 副作用是否需要确认,以及本轮调用次数是否超限。
租户白名单放在角色之前,是因为“没开通”比“权限不够”更根本。一个租户根本没买退款能力,讨论坐席是不是管理员没有意义。租户字段检查放在参数校验之前,是因为它属于身份边界,不属于数据格式;即使 Schema 恰好允许这个字段,也应该先被身份规则拦下。
授权器实现
下面的实现把“授权”和“执行”分开。authorizeToolCall 是纯函数,不碰网络、不碰数据库,测试可以直接断言每一个拒绝原因;executeToolCall 只有在授权通过后才查找并调用执行器。
// file: src/tools/policy.ts
// tier: L1
// verify: node --test test/tools/policy.test.ts
import type { SchemaIssue, SchemaNode } from "../structured/schema.ts";
import { validate } from "../structured/schema.ts";
export type TenantRole = "viewer" | "member" | "operator" | "admin";
/** 副作用从只读、草稿、写入到站外操作逐级升高。 */
export type SideEffect = "read" | "draft" | "write" | "external";
const ROLE_RANK: Record<TenantRole, number> = {
viewer: 0,
member: 1,
operator: 2,
admin: 3,
};
const SIDE_EFFECT_RANK: Record<SideEffect, number> = {
read: 0,
draft: 1,
write: 2,
external: 3,
};
export interface ToolSpec {
name: string;
sideEffect: SideEffect;
minRole: TenantRole;
/** 参数契约复用 7.1 的校验器,模型自述的参数不算数。 */
parameters: SchemaNode;
/** 单轮对话允许的最大调用次数,防止模型循环调用。 */
maxCallsPerTurn: number;
}
export interface ToolCall {
/** 模型生成的调用 id,用于把用户确认绑定到具体一次调用。 */
id: string;
name: string;
arguments: unknown;
}
export interface ToolContext {
/** 租户身份只来自会话,不来自模型参数。 */
tenantId: string;
role: TenantRole;
/** 租户开通的工具白名单,与全局注册表取交集。 */
allowedTools: string[];
/** 用户已经明确确认过的调用 id。 */
confirmedCallIds: string[];
/** 由网关维护的本轮调用计数,模型无法修改。 */
callCounts: Record<string, number>;
}
export type DenyReason =
| "unknown-tool"
| "tool-not-allowed"
| "role-denied"
| "tenant-override"
| "invalid-arguments"
| "confirmation-required"
| "call-limit";
export type ToolDecision =
| { allowed: true; tool: ToolSpec; args: Record<string, unknown> }
| { allowed: false; reason: DenyReason; detail: string; issues?: SchemaIssue[] };
export type ToolExecutor = (
args: Record<string, unknown>,
context: ToolContext,
) => unknown | Promise<unknown>;
export interface ToolExecution {
status: "denied" | "executed" | "failed";
decision: ToolDecision;
output?: unknown;
error?: string;
}
const FORBIDDEN_ARGUMENT_KEYS = new Set(["tenantId", "tenant_id"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** 递归查找模型试图自带的租户字段,嵌套在过滤条件里也要拦住。 */
function findTenantKey(value: unknown, path: string): string | null {
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index += 1) {
const found = findTenantKey(value[index], `${path}[${index}]`);
if (found !== null) return found;
}
return null;
}
if (!isRecord(value)) return null;
for (const key of Object.keys(value)) {
const child = /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)
? `${path}.${key}`
: `${path}[${JSON.stringify(key)}]`;
if (FORBIDDEN_ARGUMENT_KEYS.has(key)) return child;
const found = findTenantKey(value[key], child);
if (found !== null) return found;
}
return null;
}
export function needsConfirmation(sideEffect: SideEffect): boolean {
return SIDE_EFFECT_RANK[sideEffect] >= SIDE_EFFECT_RANK.write;
}
/**
* 模型只能提议,授权与执行都在网关侧完成。
* 检查顺序固定:注册表 > 租户白名单 > 角色 > 租户注入 > 参数 > 确认 > 次数。
*/
export function authorizeToolCall(
registry: Record<string, ToolSpec>,
context: ToolContext,
call: ToolCall,
): ToolDecision {
const tool = registry[call.name];
if (tool === undefined) {
return { allowed: false, reason: "unknown-tool", detail: `tool ${call.name} is not registered` };
}
if (!context.allowedTools.includes(call.name)) {
return {
allowed: false,
reason: "tool-not-allowed",
detail: `tenant ${context.tenantId} has not enabled ${call.name}`,
};
}
if (ROLE_RANK[context.role] < ROLE_RANK[tool.minRole]) {
return {
allowed: false,
reason: "role-denied",
detail: `role ${context.role} is below ${tool.minRole} for ${call.name}`,
};
}
const tenantPath = findTenantKey(call.arguments, "$");
if (tenantPath !== null) {
return {
allowed: false,
reason: "tenant-override",
detail: `tenant identity must come from context, not arguments (${tenantPath})`,
};
}
const issues = validate(tool.parameters, call.arguments);
if (issues.length > 0) {
return {
allowed: false,
reason: "invalid-arguments",
detail: `arguments for ${call.name} do not match the contract`,
issues,
};
}
if (needsConfirmation(tool.sideEffect) && !context.confirmedCallIds.includes(call.id)) {
return {
allowed: false,
reason: "confirmation-required",
detail: `${call.name} is a ${tool.sideEffect} operation and needs explicit confirmation`,
};
}
const used = context.callCounts[call.name] ?? 0;
if (used >= tool.maxCallsPerTurn) {
return {
allowed: false,
reason: "call-limit",
detail: `${call.name} already used ${used} times in this turn`,
};
}
if (!isRecord(call.arguments)) {
return { allowed: false, reason: "invalid-arguments", detail: "arguments must be an object" };
}
return { allowed: true, tool, args: call.arguments };
}
/** 只有授权通过才会触达执行器;被拒绝的调用不会产生任何业务副作用。 */
export async function executeToolCall(
registry: Record<string, ToolSpec>,
executors: Record<string, ToolExecutor>,
context: ToolContext,
call: ToolCall,
): Promise<ToolExecution> {
const decision = authorizeToolCall(registry, context, call);
if (!decision.allowed) {
return { status: "denied", decision };
}
const executor = executors[call.name];
if (executor === undefined) {
return { status: "failed", decision, error: `no executor registered for ${call.name}` };
}
try {
const output = await executor(decision.args, context);
return { status: "executed", decision, output };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { status: "failed", decision, error: message };
}
}
有一个细节值得说明:findTenantKey 是递归的。只检查顶层 arguments.tenantId 不够,模型完全可能写成 {"filter":{"tenant_id":"t-other"}}。递归检查会带来一点遍历成本,但参数对象通常很小,这点成本远低于一次跨租户查询的代价。
另一个细节是拒绝原因的类型。DenyReason 是一个有限的字符串联合,不是自由文本。这样日志可以按原因聚合,前端也可以根据原因决定是提示“没有权限”还是“请先确认”。自由文本的拒绝消息看起来更友好,但无法统计。
测试与真实输出
测试覆盖十五个场景,重点不是“正常路径能跑通”,而是每个拒绝原因都能被单独触发,并且被拒绝的调用不会触达执行器。
node --test test/tools/policy.test.ts
本机一次运行的摘要如下,耗时随机器变化:
ℹ tests 15
ℹ suites 0
ℹ pass 15
ℹ fail 0
ℹ duration_ms 172.733958
其中两个断言是这套设计的核心。第一个是“租户白名单先于角色检查”:operator 调用未开通的退款工具,返回的必须是 tool-not-allowed,而不是 role-denied。第二个是“被拒绝的调用不会触达执行器”:测试里放了一个会计数的执行器,拒绝后计数必须还是零。前者保证拒绝原因准确,后者保证副作用没有发生。
确认要绑定到具体调用
很多实现把确认做成“这个工具需要确认”,用户点过一次之后,本轮内所有同名调用都放行。这很危险:模型可以在用户确认“修改地址”之后,再提议一次“修改地址”但换成另一个订单。
正确做法是把确认绑定到调用 id。模型每次提议生成一个 id,前端展示的是这次调用的具体参数,用户确认后网关把 id 放进 confirmedCallIds。授权时比对的是 call.id,不是工具名。这样即使用户确认过同类操作,一次新的、参数不同的调用仍然需要重新确认。
调用次数上限也是同理。maxCallsPerTurn 由网关维护,模型无法通过参数修改。它防的不是恶意攻击,而是模型在失败后反复重试同一个工具,把一次对话变成几十次查询。
执行器与租户上下文
执行器的签名是 (args, context)。args 是模型提议且通过校验的参数,context 是网关从会话里取出的租户、角色和确认信息。业务适配器拼查询时,租户条件只能来自 context.tenantId,不能来自 args。
这条规则要在代码评审里反复强调,因为它的错误形式很隐蔽:where tenant_id = ? 的占位符填了 args.tenantId ?? context.tenantId,看起来做了兜底,实际上把模型参数放在了第一位。只要模型提供了一个值,上下文就被覆盖了。正确的写法是根本不读 args.tenantId,因为授权阶段已经保证它不存在。
执行器还要自己保证幂等。授权通过不等于只执行一次:网络超时、进程重启、上游重试都可能让同一个调用 id 到达执行器两次。修改地址、发起退款这类操作应该带业务幂等键,不能假设“网关只调用一次”。
小结
工具调用的边界可以压成一句话:模型只提议,网关做授权,业务做执行。提议是概率的,授权必须是确定的,执行必须带租户上下文和幂等保护。
白名单、角色、租户注入、参数校验、副作用分级、调用次数,这六个检查点缺一个都会留下不同的洞。顺序也不是随意的:身份和开通范围先于数据格式,确认绑定到具体调用而不是工具名。
下一节处理另一条失败路径:当结构化输出解析失败,而且一次修复也没救回来时,系统应该怎样体面地降级,而不是把空白页丢给用户。
阅读导航:上一节:7.1 结构化输出的契约与校验 · 下一节:7.3 解析失败的重试、修复与降级 。
常见错误与边界
- 让模型直接调用函数。可调用集合、调用者身份和副作用边界都不该由模型决定。
- 只校验参数,不校验租户。参数再合法,落到别的租户就是越权。
- 只检查顶层
tenantId。嵌套在filter、options里的租户字段同样危险。 - 把确认做成“工具级开关”。确认必须绑定到具体调用 id 和具体参数。
- 先查角色再查白名单。未开通的能力应该报“没开通”,而不是“权限不够”。
- 执行器从参数里读租户。租户只能来自会话上下文,参数里的值一律不可信。
- 假设网关只调用一次。执行器要按调用 id 做幂等,重试和重启都可能导致重复执行。
- 拒绝原因用自由文本。没有稳定的原因枚举,日志无法聚合,前端也无法分支。
- 把调用次数限制交给提示。模型不会遵守“最多查三次”,计数器必须在网关。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。