强调模型输出本质上是来自公网的无认证请求,需对每个工具调用做身份验证、权限授权、参数校验,防范OWASP LLM06过度代理风险。
Agent 安全中,每一层强制控制最终都落在同一个位置:模型决定调用工具,到工具实际执行之间那段代码。如果那段代码只是一个根据工具名 switch 的语句,那就没有任何边界可言。
模型不是安全组件。它也不可能是,因为它的输入包含攻击者编写的文本,输出则是一次概率采样。它产出的是一个请求——一个名字加一个 JSON 对象——正确的思维模型是:这个请求来自公共互联网,未经认证,但附带了用户的会话。
这样来看,需求和你熟悉的任何 API 别无二致:认证调用方、授权特定操作、根据 schema 和业务规则验证每个参数、限定凭证作用域、记录日志、限流。这些都不新鲜。新鲜的是这一切有多容易被跳过,因为调用方看起来像是你自己的代码。
OWASP 将这种失败命名为 LLM06,过度授权(Excessive Agency),并将其清晰地分为三类:过度功能(工具做了超出任务需要的操作)、过度权限(凭证比任务需要的更宽泛)、过度自主(行为在未评估影响的情况下直接执行)。
你没有编写的工具在这分析中值得单独一行。MCP server、插件或第三方集成是远程代码,拿着你交给它的凭证执行,而它的工具描述是进入你上下文窗口的文本——这使得第三方工具目录既是注入面,也是一个依赖项。请锁定版本、检查描述内容、每个集成使用独立的限定凭证而不是共享凭证,这样移除被入侵的集成只是一次配置变更,而非一次密钥轮换事件。
在编写策略之前,先将每个工具按「如果用攻击者选择的参数触发它,代价是什么」来分类。这个分类法决定了其他一切,它应该是工具定义上的一个字段,而不是团队口口相传的知识。
分类工作比看起来更有价值。团队经常发现一个以为只读的工具实际上会写审计记录、设置读取标志或触发通知——而通知是一条消息,消息是一条外泄通道。
每个工具调用都会经过的一个函数,它失败时是关闭的,没有任何工具实现可以绕过它,因为它是唯一持有 handlers 的东西:
type Tier = "read-internal" | "read-external" | "write-reversible" | "write-irreversible";
type ToolDef<A> = {
name: string;
tier: Tier;
parse: (raw: unknown) => A; // schema validation, e.g. zod
scopes: string[]; // required session scopes
run: (args: A, ctx: Ctx) => Promise<unknown>;
};
export async function callTool(
def: ToolDef<unknown>,
raw: unknown,
ctx: Ctx,
): Promise<ToolResult> {
// 1. Authorise against the SESSION, never against the prompt.
for (const s of def.scopes) {
if (!ctx.session.scopes.includes(s)) {
return deny(def, "missing_scope", { scope: s }, ctx);
}
}
// 2. Validate. A parse failure is a denial, not a retry loop --
// repeated malformed calls are themselves a signal.
let args: unknown;
try {
args = def.parse(raw);
} catch (e) {
return deny(def, "invalid_arguments", { error: String(e) }, ctx);
}
// 3. Budget: tool calls, tokens and money are all exhaustible.
if (!ctx.budget.tryConsume(def.name)) {
return deny(def, "budget_exhausted", {}, ctx);
}
// 4. Approval, sized by blast radius. The human sees RESOLVED args.
if (def.tier === "write-irreversible") {
const ok = await ctx.approvals.request({
tool: def.name,
args, // exactly what will run
requestedBy: ctx.session.userId,
traceId: ctx.traceId,
});
if (!ok) return deny(def, "not_approved", {}, ctx);
}
// 5. Execute with a credential minted for THIS user and THIS action.
const cred = await ctx.credentials.mintScoped(def.name, ctx.session);
const out = await def.run(args, { ...ctx, cred });
ctx.audit.record({ tool: def.name, args, ctx, outcome: "ok" });
// 6. Everything coming back is untrusted input from here on.
return { ok: true, untrusted: out };
}
这个结构的论点不言而喻。授权检查的是会话而非提示词,所以任何话术都无法扩大其权限。验证在任何东西被使用之前完成。预算检查和授权放在一起,因为资源耗尽本身就是一种攻击。审批根据声明的层级而非逐个调用来判断。凭证是每次调用时 mint 的,而不是由 agent 持有。返回值在类型上被标记为 untrusted,这是阻止下一个开发者在不经思考的情况下将其拼接到 prompt 中的最廉价方式。
Schema 验证只证明形状,不证明安全。以下每一项在存在 Agent 之前,就已经是普通 API 中真实的漏洞类型,而模型会因为文档告诉它这么做,愉快地产生所有这些问题:
任何文件参数中的路径遍历。先解析,再检查结果是否在允许的根目录内——永远不要在解析之前做字符串匹配。
任何 URL 参数中的服务端请求伪造(SSRF)。白名单主机、解析 DNS 并拒绝私有地址和链路本地地址、禁用重定向或在每次跳转后重新检查。
下游注入——SQL、shell、模板。使用参数化查询和参数数组,永远不要根据模型输出构建命令字符串。
标识符替换。参数中的账户 ID 必须根据会话的授权来检查,而不是因为它出现在一个格式良好的对象中就信任它。
数量。对每个数字参数设置上限。1000 万行的限制就是一个有着友好名字的拒绝服务工具。
确认对话框只有在人类正在阅读它的时候才是有效的强制控制。三条规则保持这一点:按不可逆性而非按活动来设卡,这样提示才会罕见;显示解析后的参数而非模型写的摘要,因为摘要是模型输出,而模型输出正是你要检查的东西;让代价高的路径成为显式路径——批量审批和「批准此会话的所有操作」把你最后的强制控制转换成在攻击到来之前就已经按下的一次点击。
预算和审批属于同一层强制控制,而且是少数真正在应用外部更简单的控制之一:每个 key 硬性的消费上限约束了一个被诱导进入循环的 Agent,无论循环相信自己在做什么。按被入侵的情况来设定大小,而不是按预期情况。