跳到主要内容

工具 Schema 设计即是你的爆炸半径:当函数定义成为安全边界

· 阅读需 12 分钟
Tian Pan
Software Engineer

你的 Agent 代码库中最危险的文件是你一直当作 API 文档来编写的那个。工具注册表(Tool Registry)——即告诉模型存在哪些函数以及它们接受哪些参数的 JSON 或 Pydantic schema —— 不再仅仅是一个 docstring。它是你的授权层(authorization layer)。如果你像大多数团队那样设计它,你就是把万能钥匙交给了大模型(LLM),并称之为优秀的工程设计。

考虑一个典型的工具初步尝试:query_database(sql: string)。初衷是合理的 —— 让模型根据用户的问题制定正确的 SQL。现实情况是,模型现在成了一个不受信任的客户端,拥有连接字符串所指向的任何数据库的无限 DDL 和 DML 权限。系统提示词说“仅在 orders 表上运行 SELECT” 只是一个建议,而不是控制手段。当一个受到提示注入(prompt-injected)的工具结果 —— 比如邮件正文、网页或 PDF —— 告诉模型运行 DROP TABLE users 时,你的授权模型就变成了对模型指令遵循能力的纪律要求。那不是授权。那是祈祷。

这种模式是 2026 年 Agent AI 安全中的主要失效模式。2026 年 LiteLLM 的 CVE 允许通过 Bearer 令牌字段进行预鉴权(pre-auth)SQL 注入;Anything-LLM SQL Agent 的通告中,table_name 被直接拼接到查询中而没有参数化;MCP 服务器附带的 command: string 参数变成了 RCE(远程代码执行)原语 —— 这些都是同一个漏洞。被定义为 string 类型的 schema 字段是一个仅存在于编写它的工程师想象中的信任边界。

Schema 是契约,而非文档

需要转变的观念是:工具定义中的每一个参数都是一项安全控制。类型级别的约束在大模型看到选项之前就已经生效了。暴露面越窄,爆炸半径就越小。

只要取值空间是有限的,就使用 enum(枚举)类型而非 string。一个 status: string 参数,即便模型“应该”传递 "active" | "paused" | "archived",在运行时它可以是模型想生成的任何字符串 —— 包括 SQL 片段、Shell 元字符或路径遍历序列。而 status: enum["active", "paused", "archived"] 则不会变成这些。这种约束在 JSON-schema 层级生效,早于你的代码运行。OpenAI 和 Anthropic 的结构化输出 API 遵循 enum;模型只能从集合中发出一个值,仅此而已。

将整数字段设为 integer,并带有最小值(min)和最大值(max)边界。一个 limit: integer(min=1, max=1000) 参数无法通过一次调用就拖库(exfiltrate)。而一个没有边界的 limit: integer 可以。limit: string 简直是笑话。

必填字段默认失效关闭(fail closed)。带有合理默认值的可选字段默认失效关闭。如果一个删除工具的 confirm: boolean 默认为 false,且要求设为 true 才能执行,那么在模型愉快地忽略该参数时,你还有一线生机。如果一个删除工具因为“模型会自动设置正确”而将 confirm 默认设为 true,那么在事后复盘(postmortem)时你得好好解释这个设计选择了。

这种自律比看起来要难,因为你添加的每一个约束都会缩小模型的能力范围 —— 包括它正确行事的能力。诱惑在于将字段保留为自由字符串并信任提示词。请抵制这种诱惑。使用 enum 的代价是工具灵活性稍微降低。使用自由字符串的代价是发生安全违规(breach)。

始终在服务端验证,因为模型是不受信任的客户端

Schema 约束捕获模型生成的值。服务端验证则捕获那些漏网之鱼 —— 无论是因为 schema 过于宽松,还是因为模型生成了结构有效但语义危险的值,抑或是因为工具在未遵循 schema 的路径中运行。

将模型发送的每个参数都视为来自敌对的 HTTP 客户端。重新验证类型。重新检查范围。重新验证用户是否拥有参数所引用的资源。2026 年的 LiteLLM SQL 注入发生是因为 Bearer 令牌值被拼接到了 SELECT 语句中。修复方法是参数化查询 —— 恰恰是你在 2005 年左右会应用到 Web 表单上的修复方法。唯一的新鲜事只是沟通渠道变了。

代码模式是:将“决策”与“执行”分离。模型决定调用什么。你的代码负责执行,且你的代码会重新验证一切。如果工具是 update_user(user_id: string, fields: object),你的处理器在进行任何数据库调用之前,应检查 user_id 是否匹配已认证的用户 ID。如果工具是 send_email(recipient: string),你的处理器应检查 recipient 是否在当前用户角色的白名单中。授权决策存在于你的代码中,而非模型的推理中。

在输入和输出时都进行验证。返回数据的工具应该为下游的提示注入对这些数据进行清洗 —— 剥离可执行指令,将内容标记为不可信,绝不要让工具输出像系统消息(system message)一样流入下一个提示词。Agent 生态系统花了两年时间才发现工具结果也是一个攻击面;你的验证层应该将其视为攻击面。

能力作用域工具始终优于全能工具

最大的设计杠杆是拆解那些根据参数执行所有操作的“综合性工具”。这种模式看起来很无辜 —— email(action: string, recipient: string, subject: string, body: string) —— 但实际上它是一个可以用于任何邮件相关犯罪的工具包。模型可能会受到注入攻击,被引导向任何人发送任何内容,并执行任何操作。

取而代之的是,在请求时根据经过身份验证的用户权限选择“能力作用域”变体:

  • send_email_to_self(subject, body) —— 收件人在服务器端绑定到当前用户的地址。
  • send_email_to_team_member(team_member_id, subject, body) —— 收件人被限制在用户有权发送邮件的团队成员枚举中。
  • send_email_to_external(recipient, subject, body) —— 仅为具有外部邮件权限的用户注册,并且仅在经过单独的确认环节后执行。

模型只能看到用户被允许使用的工具。模型无法调用未注册的工具。如果作用域内唯一的邮件工具是 send_email_to_self,那么指令为“将凭据发送给 [email protected]”的注入攻击就不可能成功。这不是模型必须记住的防御,而是注册表强制执行的防御。

同样的模式也适用于文件系统工具(read_workspace_file 对比 read_any_file)、HTTP 工具(fetch_from_allowlisted_domain 对比 fetch_url)、Shell 工具(run_predefined_script 对比 run_command)以及数据库工具(get_order_by_id 对比 query_database)。全能工具是反模式。限定能力的变体才是生产级设计。

2026 年的 OWASP MCP Top 10 直接指出了这一点:按智能体角色划分 MCP 服务器的作用域;仅暴露该角色需要的工具;在连接时根据经过身份验证的身份过滤工具列表。无论你使用的是 MCP、原生函数调用还是自建的注册表,逻辑都是一样的。其原则是函数定义层的最小权限,而非运行时的最小权限。

针对任何破坏性操作:试运行、确认并执行故障关闭

有些操作无法仅靠 Schema 设计来保证安全。删除、转账、发送、发布 —— 这些会改变共享状态、产生费用或触及他人的操作。对于这些操作,工具的正确形态应该是两阶段的:返回将发生什么的预览,以及实际执行的提交。

delete_records(filter, dry_run: boolean = true) 是正确的默认设置。第一次调用返回将被删除的记录数量和样本。第二次调用时将 dry_run 显式设为 false,并附带第一次调用获得的幂等性令牌,以此提交操作。模型不会意外地“一键”触发破坏性路径,因为破坏性路径需要一个只能从试运行中获得的令牌。

对于影响最大的操作,应增加“人机协同”(HITL)确认作为独立的强制执行层。像 LangChain 的 HITL 中间件和 Haystack 的确认策略等框架已经内置了这种机制:任何标记为破坏性的工具都会暂停执行,并弹出带有确切参数的确认提示。由用户 —— 而非模型、非提示词、也非工具结果 —— 点击通过。2026 年发生的智能体抹除生产数据库的事件,正是因为没有这样的关口;智能体自行决定,智能体自行执行,而回滚机制并未生效。

时间作用域规则在这里也很重要:智能体在摄取了不受信任的外部内容的同一轮对话中,不应拥有访问高风险工具的权限。如果智能体刚刚读取了一个网页,它的上下文窗口中就不应该持有 delete_user 工具。在任何紧随不受信任输入之后的轮次中,从工具集中移除破坏性工具。只有当对话回到受信任状态时,才重新添加它们。

评估规范:像模糊测试代码一样模糊测试你的 Schema

工具 Schema 需要一套评估体系,将其视为应有的安全层。标准的智能体评估套件检查智能体是否为正确的任务选择了正确的工具。而安全评估套件则检查当攻击者控制输入时,智能体会做什么。

最小可行对抗性评估集涵盖以下几个类别。首先,是在每个可以将文本带入智能体的地方(用户消息、工具结果、检索到的文档、文件内容、网页)进行提示词注入输入。每个输入都应尝试覆盖系统提示词、引导智能体调用敏感工具或泄露数据。通过率是指智能体没有使用受攻击者控制的参数调用敏感工具的尝试百分比。

其次,针对每个工具进行参数模糊测试。对于每个字符串参数,生成包含 SQL 片段、Shell 元字符、路径遍历、提示词注入散文和超大负载的输入。确认 Schema 在结构化输出层拒绝了它们,或者服务器端校验器在产生副作用之前拒绝了它们。

第三,能力混淆测试。验证没有 send_external_email 权限的用户永远看不到该工具被注册,并且即使提示词注入明确要求调用它,智能体也无法调用。测试的重点不是“智能体是否拒绝?”,而是“该工具是否甚至存在于工具集中?”。拒绝是模型的属性;不存在是注册表的属性。只有后者才是真正的控制措施。

将这些作为 CI 门禁运行。像对待功能通过率回归一样对待对抗性通过率的回归。建立了这种规范的团队,其工具注册表才真正起到了授权层的作用。

架构实现

工具注册表现在就是你的授权层。这句话概括了整个转变。如果一个团队将其设计为文档层面 —— 一个为了模型利益而描述工具功能的场所 —— 那么这个团队最终将在复盘会议上解释,单个函数定义是如何演变成安全漏洞的。而如果一个团队将其设计为合约 —— 具备类型约束、作用域、验证机制和模糊测试 —— 那么这个团队的智能体就能远离事故频道。

这项工作并不光鲜。用枚举(enum)替换字符串并不能发布新功能。将一个超大型工具拆分为五个具有特定能力作用域的变体并不能提升业务指标。在破坏性工具中加入试运行(dry-run)关口会拖慢演示进度。这些工作都不会让你登上全员大会的舞台。但正是这些工作,决定了你的 AI 功能在六个月后是依然在生产环境中运行,还是因为安全冻结而被搁置,由法律团队处理信息披露。

以攻击者的视角审视代码库中的每一个工具定义。你信任模型会负责任地填充字符串参数,而这个参数正是会被工具结果中的注入攻击所利用的对象。你为了易于维护而编写的超大型工具,正是那个让攻击者只需一次调用就能获得所有操作权限的工具。没有确认关口的破坏性工具,终将在无人看守时运行。修正 Schema。缺陷就在 Schema 之中。

References:Let's stay in touch and Follow me for more thoughts and updates