Agent 工具调用安全设计:最小权限、参数校验与审计闭环
Agent 真正危险的地方,往往不是“回答错了”,而是它把自然语言请求转换成了真实操作:读取文件、查询数据库、发送消息、修改配置,甚至调用其他系统。只要工具具备写权限,一句被污染的网页内容就可能变成一次越权操作。
因此,Agent 安全不能只依赖提示词里的一句“不要执行危险操作”。更可靠的做法,是把模型输出视为不可信输入,在工具网关中完成身份、权限、参数和副作用的确定性校验。
一、先识别真正的威胁
设计工具权限前,可以先列出四类风险:
- 提示词注入:不可信文档或网页要求 Agent 忽略原任务,转而读取其他资源。
- 权限混淆:Agent 继承了用户的全部权限,却代表用户操作了另一个租户的数据。
- 参数越界:工具名合法,但参数中的资源 ID、路径或收件人被替换。
- 副作用放大:重试、循环调用或重复确认导致同一操作执行多次。
这些问题有一个共同点:模型可以提出调用建议,但不应该成为最终的授权决策者。授权必须在模型之外完成。
二、把“工具”拆成最小能力
一个常见错误是提供 file_operation、database_execute 这类大而全的工具。工具越通用,校验越难,审计结果也越模糊。
更合理的拆分方式是围绕业务能力设计窄接口:
read_report(report_id):只能读取当前租户的指定报告。list_reports(page):只返回脱敏后的元数据。create_draft(content):只创建草稿,不直接发布。publish_report(report_id, approval_token):需要有效审批凭证才能执行。delete_report(report_id, approval_token):高风险操作独立暴露,并默认关闭。
读写工具分开后,可以给它们设置不同的超时、配额和审批策略。例如,读工具可以高频调用,写工具必须限流并记录审批人。
三、权限必须落到资源级
“用户有报告读取权限”仍然过于宽泛。真正要校验的是:
- 当前用户是谁;
- 当前租户是谁;
- 请求的是哪一个资源;
- 操作类型是什么;
- 请求上下文是否一致。
资源 ID 不能由模型自由生成,也不应该在授权前就被信任。工具网关应先校验参数格式,再从可信数据源查询资源归属,最后执行权限判断。
一个简单的授权判断可以抽象为:
allow = subject.tenant_id == resource.tenant_id
and action in policy[subject.role]
and resource.state == "active"
and request.trace_id is not None
这里的关键是“资源级”和“服务端查询”。不要只检查前端传来的 tenant_id,也不要把权限范围写进由模型控制的参数。
四、参数校验要拒绝歧义
自然语言天然存在歧义,而工具参数必须明确。网关应使用白名单校验:
- 资源 ID 必须匹配固定格式并存在;
- 枚举值只能来自服务端定义;
- 路径必须规范化,禁止
..和跨目录访问; - URL 必须校验协议、域名和重定向目标;
- 文本长度、分页大小和并发数都要有上限;
- 不在允许字段内的参数直接拒绝,而不是静默忽略。
对高风险参数,建议采用“先解析、再规范化、最后校验”的顺序。例如文件名先做 Unicode 规范化,再检查目录边界,避免只在字符串表面做替换。
五、副作用操作需要独立确认
发布、转账、删除、授权这类动作,不应和普通查询共用同一条自动执行链路。可以采用三层控制:
- 草稿层:Agent 只能生成变更计划或草稿。
- 确认层:展示目标资源、变更内容和影响范围,由用户明确确认。
- 执行层:校验一次性审批令牌,执行后立即失效。
审批应绑定具体操作摘要,而不是一个宽泛的“允许所有操作”。例如审批令牌应包含资源 ID、动作、参数哈希、发起人和过期时间。执行层再次校验摘要,避免审批后参数被替换。
六、示例:工具网关的最小骨架
下面是一个不依赖具体框架的 Python 示例,重点展示“模型只提交意图,网关负责授权”的结构。
from dataclasses import dataclass
from hashlib import sha256
from typing import Any
class ToolDenied(Exception):
pass
@dataclass(frozen=True)
class Actor:
user_id: str
tenant_id: str
roles: frozenset[str]
@dataclass(frozen=True)
class Resource:
resource_id: str
tenant_id: str
state: str
READ_ROLES = frozenset({"viewer", "editor", "owner"})
WRITE_ROLES = frozenset({"editor", "owner"})
def authorize(actor: Actor, resource: Resource, action: str) -> None:
if resource.tenant_id != actor.tenant_id:
raise ToolDenied("cross-tenant access denied")
if resource.state != "active":
raise ToolDenied("resource is not active")
allowed_roles = READ_ROLES if action == "read" else WRITE_ROLES
if actor.roles.isdisjoint(allowed_roles):
raise ToolDenied("insufficient role")
def operation_digest(
action: str,
resource_id: str,
params: dict[str, Any],
) -> str:
payload = f"{action}|{resource_id}|{sorted(params.items())}"
return sha256(payload.encode("utf-8")).hexdigest()
def read_report(actor: Actor, resource: Resource, report_id: str) -> str:
if resource.resource_id != report_id:
raise ToolDenied("resource mismatch")
authorize(actor, resource, "read")
return load_report_from_trusted_store(resource)
def publish_report(
actor: Actor,
resource: Resource,
report_id: str,
approval_digest: str,
) -> None:
params = {"visibility": "tenant"}
expected = operation_digest("publish", report_id, params)
if approval_digest != expected:
raise ToolDenied("approval does not match operation")
authorize(actor, resource, "write")
consume_approval_once(approval_digest)
publish_in_transaction(resource, params)
示例中,Actor 和 Resource 应由认证系统与服务端数据库填充,不能直接采用模型参数。审批摘要绑定了动作和资源,执行前还会再次检查资源归属。
七、审计日志要能还原一次决策
只知道“工具调用成功”没有太大价值。可用的审计日志至少应记录:
- 会话 ID 和调用链 ID;
- 用户、租户和 Agent 版本;
- 工具名、规范化后的参数摘要;
- 命中的策略及决策结果;
- 审批人、审批时间和令牌是否被消费;
- 外部系统的请求 ID 和最终状态;
- 输出是否经过脱敏。
敏感参数不应以明文入库。可以使用不可逆哈希做幂等键,使用字段级加密保存必要上下文,并为日志设置与业务数据一致的保留周期。
八、上线前的检查清单
- [ ] 每个工具只暴露一个清晰能力。
- [ ] 读写权限分离,高风险写操作默认关闭。
- [ ] 资源归属从可信数据源查询,不能信任模型传入的租户信息。
- [ ] 参数采用白名单校验,并设置长度、数量和频率上限。
- [ ] 发布、删除、授权等操作绑定一次性审批令牌。
- [ ] 所有副作用操作支持幂等键和超时后的状态查询。
- [ ] 工具输出进入模型前完成敏感信息过滤。
- [ ] 审计日志能够还原“谁、在什么上下文、基于哪条策略、做了什么”。
结语
Agent 的能力上限取决于工具,而安全底线取决于授权边界。提示词可以减少误触发,但不能替代权限系统。把模型输出当作不可信输入,把能力拆细,把授权放在服务端,把副作用纳入审批和审计,才是更稳妥的工程路径。
一个判断设计是否合格的标准很直接:即使模型完全失控,它也只能在已经明确授予的资源范围内执行有限动作,而且每一次变更都能被追溯和撤销。
- 点赞
- 收藏
- 关注作者
评论(0)