Agent 安全边界:别把权限交给模型
假设小周对服务台助手说:“帮我看看工单 T-102,再把它改成最高优先级。”助手很热心,先读工单,再调用修改工具。问题是,小周可能根本看不到 T-102,也可能只有查询权限,没有修改权限。
如果系统提示词里写着“没有权限时请拒绝”,模型大多数时候也许会照做,但“大多数时候”不能当安全设计。模型会误判,也可能被用户绕过。真正的权限检查必须放在工具执行边界:即使模型坚持要调用,业务代码也不让它越过门禁。
我们用几段可以单独运行的小代码,每次给服务台助手加一道门。示例使用 Python 标准库,你可以把每一步复制到自己的练习文件里,再按文中的方式故意改坏它。
先画清楚边界
一次工具调用大致会经过这条路:
用户输入
-> 模型建议调用某个工具
-> 权限层判断能不能调用
-> 参数层判断能不能这样调用
-> 业务服务再次检查数据范围
-> 必要时等待人工审批
-> 执行工具
-> 过滤返回结果
-> 写入审计记录注意“模型建议”这四个字。模型产生的是候选动作,不是已经批准的命令。system prompt、工具描述、模型输出都在安全边界之外,不能替代后端授权。
后面的步骤都围绕三个工具展开:
| 工具 | 用途 | 初始风险 |
|---|---|---|
search_policy | 查询公司制度 | 只读、低风险 |
get_ticket | 查询一张工单 | 只读,但受租户和归属限制 |
change_ticket_priority | 修改工单优先级 | 有副作用,需要更严格控制 |
第 1 步:先把身份带到工具边界
这一小步要完成什么
不要让工具从自然语言里猜“你是谁”。身份应该由登录态、API 网关或服务端会话生成,再由程序传给权限层。
先定义一个小型身份上下文:
from dataclasses import dataclass
@dataclass(frozen=True)
class AuthContext:
user_id: str
tenant_id: str
roles: frozenset[str]
xiaozhou = AuthContext(
user_id="u-17",
tenant_id="acme",
roles=frozenset({"employee"}),
)frozen=True 让这个对象在创建后不能随手修改。它不是万能防线,但能减少代码中途给自己“加角色”的低级错误。
身份数据的来源应该是可信服务端,而不是用户消息:
def build_auth_context(session: dict) -> AuthContext:
return AuthContext(
user_id=session["authenticated_user_id"],
tenant_id=session["tenant_id"],
roles=frozenset(session["roles"]),
)用户可以说“我是管理员”,模型也可以在参数里生成 role="admin",但这两个值都不能写进 AuthContext。
运行观察
print(xiaozhou.user_id)
print(xiaozhou.roles)
try:
xiaozhou.roles = frozenset({"admin"})
except Exception as exc:
print(type(exc).__name__)前两行会输出服务端确认的身份,最后一次赋值会失败。现在工具调用已经有了可信的“工牌”,后面每道门都应读取这张工牌。
故意改错
把 build_auth_context() 改成从 model_arguments["role"] 读取角色,再让模型参数传入 admin。你会看到:只要模型能写参数,它就能给自己印一张管理员工牌。
常见错误
- 把
user_id、tenant_id、role暴露为模型可填写的工具参数; - 只传
user_id,查询数据时忘记租户边界; - 在 prompt 中描述用户角色,却没有把可信身份传到工具执行层;
- 为了调试临时写死管理员角色,后来忘了移除。
第 2 步:每种角色只看到允许的工具
这一小步要完成什么
小周有了工牌,下一步是在服务端建立工具白名单。先用最朴素的字典表示:
ROLE_TOOLS = {
"employee": {"search_policy", "get_ticket"},
"support_agent": {
"search_policy",
"get_ticket",
"change_ticket_priority",
},
}
def allowed_tools(ctx: AuthContext) -> set[str]:
result: set[str] = set()
for role in ctx.roles:
result.update(ROLE_TOOLS.get(role, set()))
return result给模型发送工具定义时,只发送当前身份允许看到的部分:
all_tool_schemas = {
"search_policy": {"name": "search_policy"},
"get_ticket": {"name": "get_ticket"},
"change_ticket_priority": {"name": "change_ticket_priority"},
}
def schemas_for(ctx: AuthContext) -> list[dict]:
return [
all_tool_schemas[name]
for name in sorted(allowed_tools(ctx))
]隐藏无权使用的工具可以减少误调用,但这还不够。模型可能生成一个没有展示过的工具名,旧对话里也可能残留以前的调用,所以执行前必须再检查一次:
class PermissionDenied(Exception):
pass
def assert_tool_allowed(ctx: AuthContext, tool_name: str) -> None:
if tool_name not in allowed_tools(ctx):
raise PermissionDenied(f"tool not allowed: {tool_name}")运行观察
print([item["name"] for item in schemas_for(xiaozhou)])
assert_tool_allowed(xiaozhou, "get_ticket")
try:
assert_tool_allowed(xiaozhou, "change_ticket_priority")
except PermissionDenied as exc:
print(exc)普通员工能看到查询工具,但修改优先级会在执行前被拒绝。拒绝发生在 Python 代码里,和模型是否“同意”无关。
故意改错
保留 schemas_for(),删除 assert_tool_allowed(),然后手动构造一次 change_ticket_priority 调用。这个实验会证明:只在模型面前藏起工具,不等于后端禁止了工具。
常见错误
- 把“模型没有看到工具”误当成强制授权;
- 角色名不存在时默认放行全部工具;
- 多个角色合并时误用交集或并集,导致权限意外扩大或缩小;
- 工具改名后只更新 schema,没有更新白名单和测试。
第 3 步:工具有权限,参数也要过关
这一小步要完成什么
即使小周能查询工单,也不能让模型随便拼参数。参数校验至少要检查类型、格式、长度和允许值。
先写两个很小的校验函数:
import re
TICKET_ID_PATTERN = re.compile(r"^T-[0-9]{1,8}$")
ALLOWED_PRIORITIES = {"low", "normal", "high"}
def validate_ticket_id(value: object) -> str:
if not isinstance(value, str) or not TICKET_ID_PATTERN.fullmatch(value):
raise ValueError("ticket_id 格式应为 T-数字")
return value
def validate_priority(value: object) -> str:
if not isinstance(value, str) or value not in ALLOWED_PRIORITIES:
raise ValueError("priority 只能是 low、normal 或 high")
return value再按工具名取出需要的字段。不要把模型给出的整个字典原样传到数据库:
def validate_arguments(tool_name: str, args: dict) -> dict:
if tool_name == "get_ticket":
return {"ticket_id": validate_ticket_id(args.get("ticket_id"))}
if tool_name == "change_ticket_priority":
return {
"ticket_id": validate_ticket_id(args.get("ticket_id")),
"priority": validate_priority(args.get("priority")),
}
raise ValueError("unknown tool")这样,模型偷偷多塞的 tenant_id、approved、sql 等字段不会继续流向业务服务。
运行观察
print(validate_arguments(
"change_ticket_priority",
{"ticket_id": "T-102", "priority": "high", "approved": True},
))
for bad_id in ["102", "T-1 OR 1=1", None]:
try:
validate_arguments("get_ticket", {"ticket_id": bad_id})
except ValueError as exc:
print(bad_id, "->", exc)第一段输出里不会保留模型自称的 approved=True。后面三个异常参数都会被拦住。
参数格式正确仍不代表业务允许。例如 T-102 可能属于其他租户,high 也可能需要额外审批。格式校验只是进门检查,不是最终授权。
故意改错
把 validate_arguments() 改成 return args,再传入 {"ticket_id": "T-102", "priority": "high", "tenant_id": "other-company"}。观察下游代码如果信任这个租户字段,会发生什么。
常见错误
- 只依赖模型的 JSON Schema,不在服务端重复校验;
- 用
if value判断字符串,却没有限制长度、格式和枚举; - 校验通过后仍把原始
args传给业务服务; - 报错时把完整参数写进日志,顺手泄露了敏感数据。
第 4 步:在数据层落实最小权限
这一小步要完成什么
工具名和参数都通过了,还要检查“这张工单是不是你能看的”。最小权限的做法是:查询函数只接收可信身份,只返回完成任务所需的最少字段。
先准备两张演示工单:
TICKETS = {
"T-102": {
"tenant_id": "acme",
"owner_id": "u-17",
"title": "VPN 无法连接",
"priority": "normal",
"reporter_email": "zhou@example.com",
"internal_note": "排查账号风控状态",
},
"T-900": {
"tenant_id": "other-company",
"owner_id": "u-88",
"title": "财务系统报错",
"priority": "high",
"reporter_email": "finance@other.example",
"internal_note": "包含内部服务器地址",
},
}查询时同时限制租户和归属,再裁剪返回字段:
def get_ticket(ctx: AuthContext, ticket_id: str) -> dict:
ticket = TICKETS.get(ticket_id)
if ticket is None:
raise LookupError("ticket not found")
same_tenant = ticket["tenant_id"] == ctx.tenant_id
is_owner = ticket["owner_id"] == ctx.user_id
is_support = "support_agent" in ctx.roles
if not same_tenant or not (is_owner or is_support):
raise PermissionDenied("ticket not accessible")
return {
"ticket_id": ticket_id,
"title": ticket["title"],
"priority": ticket["priority"],
}这里有两层限制:先阻止跨租户,再允许工单本人或支持人员查看。返回结果没有邮箱和内部备注,因为回答“工单现在什么状态”并不需要这些字段。
运行观察
print(get_ticket(xiaozhou, "T-102"))
try:
get_ticket(xiaozhou, "T-900")
except PermissionDenied as exc:
print(exc)小周能看到自己的工单,但不能跨到另一家公司。即使模型准确猜出 T-900,数据层也不会返回它。
OWASP 把给模型过多功能、权限或自主执行能力称为 Excessive Agency 风险。它给出的思路同样朴素:减少扩展、减少权限、减少自主性,并在高影响操作前获得批准。可在这里查看 OWASP 关于 Excessive Agency 的说明。
故意改错
先删掉 same_tenant 检查,再把 is_support 暂时改成 True。运行 get_ticket(xiaozhou, "T-900"),你会得到另一家公司的工单。这个错误不在模型里,而在数据查询边界里。
常见错误
- 先按
ticket_id查出完整记录,再依赖模型决定哪些字段能说; - 只检查角色,不检查租户、资源归属和当前状态;
- 为了方便把数据库整行返回给模型;
- 用“没有找到”与“没有权限”两种差异过大的响应泄露资源是否存在。
第 5 步:把只读操作和副作用操作分开
这一小步要完成什么
查询一次工单和修改一次工单,不该走同样的门。先为工具登记风险,而不是靠名字临时猜:
from typing import Literal
Effect = Literal["read", "write"]
Risk = Literal["low", "medium", "high"]
@dataclass(frozen=True)
class ToolPolicy:
effect: Effect
risk: Risk
requires_approval: bool
TOOL_POLICIES = {
"search_policy": ToolPolicy("read", "low", False),
"get_ticket": ToolPolicy("read", "medium", False),
"change_ticket_priority": ToolPolicy("write", "high", True),
}再写一个小决策函数。它先复用工具白名单,然后返回下一步动作:
def decide_action(ctx: AuthContext, tool_name: str) -> str:
assert_tool_allowed(ctx, tool_name)
policy = TOOL_POLICIES[tool_name]
if policy.effect == "write" and policy.requires_approval:
return "request_approval"
return "execute"别让模型自己给风险分级。模型可能把“删除测试账号”描述成普通清理,也可能把“只查看状态”误判为危险动作。风险等级属于工具注册信息和业务策略,应由服务端维护。
运行观察
先创建一个支持人员身份:
supporter = AuthContext(
user_id="u-2",
tenant_id="acme",
roles=frozenset({"support_agent"}),
)
print(decide_action(supporter, "get_ticket"))
print(decide_action(supporter, "change_ticket_priority"))查询会得到 execute,修改会得到 request_approval。两者都可能是合法操作,但副作用工具多一道确认。
故意改错
把 change_ticket_priority 的 effect 改成 read,再运行一次。代码会跳过审批。这个实验说明风险元数据也是安全配置,需要评审和回归测试,不能把它当无关紧要的说明文字。
常见错误
- 只按“是否需要登录”分级,不区分读取和修改;
- 把所有写操作一律低风险,忽略金额、数量和影响范围;
- 工具内部新增副作用后,没有同步更新策略;
- 看到模型说“用户已经确认”就跳过服务端审批。
第 6 步:高风险动作等待真人批准
这一小步要完成什么
审批不是让模型再问一句“确定吗”。真正的审批记录要由服务端创建,并绑定操作者、工具名和准确参数。审批后如果参数变化,应当重新审批。
先定义待审批记录:
import hashlib
import json
import secrets
@dataclass(frozen=True)
class ApprovalRequest:
approval_id: str
user_id: str
tenant_id: str
tool_name: str
arguments_hash: str
def hash_arguments(arguments: dict) -> str:
payload = json.dumps(
arguments,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()创建审批时使用已经校验过的参数:
def create_approval(
ctx: AuthContext,
tool_name: str,
arguments: dict,
) -> ApprovalRequest:
return ApprovalRequest(
approval_id=secrets.token_urlsafe(16),
user_id=ctx.user_id,
tenant_id=ctx.tenant_id,
tool_name=tool_name,
arguments_hash=hash_arguments(arguments),
)执行前再对一次:
def assert_approval_matches(
ctx: AuthContext,
request: ApprovalRequest,
tool_name: str,
arguments: dict,
) -> None:
matches = (
request.user_id == ctx.user_id
and request.tenant_id == ctx.tenant_id
and request.tool_name == tool_name
and request.arguments_hash == hash_arguments(arguments)
)
if not matches:
raise PermissionDenied("approval does not match this action")练习代码省略了审批人的身份、有效期、一次性消费和持久化状态。生产环境必须把这些也加进去,并在数据库中原子地把审批从 approved 改为 consumed,避免同一审批被重复使用。
运行观察
args = {"ticket_id": "T-102", "priority": "high"}
approval = create_approval(
supporter,
"change_ticket_priority",
args,
)
assert_approval_matches(
supporter,
approval,
"change_ticket_priority",
args,
)
print("approval matched")
try:
assert_approval_matches(
supporter,
approval,
"change_ticket_priority",
{"ticket_id": "T-900", "priority": "high"},
)
except PermissionDenied as exc:
print(exc)原动作可以通过,偷偷把工单换成 T-900 会失败。批准的是一件具体事情,不是一张“接下来随便改”的通行证。
故意改错
从 assert_approval_matches() 中删掉 arguments_hash 比较。创建对 T-102 的审批后,改用同一个审批执行 T-900。你会看到一个很典型的“批 A 做 B”漏洞。
常见错误
- 把模型生成的
approved=true当作人工审批; - 审批只绑定工具名,没有绑定资源和参数;
- 审批永久有效,或者能被使用多次;
- 审批通过后不再校验用户权限,忽略审批等待期间角色可能变化;
- 用户点击取消后,后台重试仍然执行副作用工具。
第 7 步:工具返回后,别把整袋数据倒给模型
这一小步要完成什么
权限控制不能只守入口。工具可能返回内部备注、邮箱、访问令牌或其他租户数据;这些内容一旦进入模型上下文,就可能出现在最终回复、trace 或后续工具参数里。
先用允许字段裁剪工具结果:
SAFE_TICKET_FIELDS = {"ticket_id", "title", "priority"}
def filter_ticket_result(result: dict) -> dict:
return {
key: value
for key, value in result.items()
if key in SAFE_TICKET_FIELDS
}再对最终文本做一道兜底检查:
SECRET_PATTERNS = [
re.compile(r"sk-[A-Za-z0-9_-]{12,}"),
re.compile(r"Bearer\s+[A-Za-z0-9._-]+", re.IGNORECASE),
]
def validate_output(text: str) -> str:
for pattern in SECRET_PATTERNS:
if pattern.search(text):
raise PermissionDenied("output may contain a credential")
return text输出检查是最后一道兜底,不是前面权限控制的替代品。最好的秘密是根本不要送进模型;等到输出阶段再用正则找,可能漏掉改写、拆分和编码后的敏感内容。
对于结构化结果,优先使用字段白名单。对于自然语言输出,可以结合敏感信息检测、引用约束和高风险回答人工复核,但仍要接受检测器会有误报和漏报。
运行观察
raw_result = {
"ticket_id": "T-102",
"title": "VPN 无法连接",
"priority": "normal",
"reporter_email": "zhou@example.com",
"internal_note": "临时令牌 sk-demo-secret-123456",
}
print(filter_ticket_result(raw_result))
try:
validate_output("排查令牌:sk-demo-secret-123456")
except PermissionDenied as exc:
print(exc)过滤后的工具结果不含邮箱和内部备注;故意拼入令牌的最终文本也会被拦下。
故意改错
把 filter_ticket_result() 改成只删除 internal_note,然后给结果再添一个 access_token 字段。黑名单必须不断追赶新字段,白名单则默认不让陌生字段通过。
常见错误
- 认为“模型不会主动说出来”,所以把完整业务对象放进上下文;
- 只过滤最终文字,不过滤工具结果、日志和 trace;
- 用字段黑名单兜底,却忘记新出现的敏感字段;
- 拦截后把包含秘密的原文写入错误日志。
第 8 步:每次放行和拒绝都留下审计事件
这一小步要完成什么
安全问题发生后,团队需要回答:谁在什么时间请求了哪个动作,依据哪条策略放行,是否经过审批,最后有没有执行。普通调试日志未必能稳定回答这些问题,因此要单独设计审计事件。
先定义最小字段:
from datetime import datetime, timezone
def make_audit_event(
*,
trace_id: str,
ctx: AuthContext,
tool_name: str,
decision: str,
reason: str,
approval_id: str | None = None,
) -> dict:
return {
"occurred_at": datetime.now(timezone.utc).isoformat(),
"trace_id": trace_id,
"actor_id": ctx.user_id,
"tenant_id": ctx.tenant_id,
"tool_name": tool_name,
"decision": decision,
"reason": reason,
"approval_id": approval_id,
}权限层拒绝时写一条,审批创建、审批通过和工具执行也各写一条。事件内容应当足够还原决策,但不要记录完整 prompt、原始工具参数、API Key 或整份工单。
event = make_audit_event(
trace_id="trace-1001",
ctx=xiaozhou,
tool_name="change_ticket_priority",
decision="denied",
reason="tool_not_allowed_for_role",
)
print(event)reason 最好使用稳定的机器可读代码,方便统计“本周有多少次跨租户访问被拒绝”。面向用户的提示可以更自然,但不要把内部策略细节全部暴露出去。
运行观察
连续制造三次动作:一次合法查询、一次无权修改、一次参数格式错误。检查三个事件是否使用同一个 trace_id 串起调用过程,是否能从 decision 和 reason 区分失败原因。
还要看一眼事件里没有什么:没有完整工单正文,没有用户输入,没有令牌,也没有模型的整段思考过程。审计要能查事,不等于把所有数据永久保存。
故意改错
给事件增加 raw_arguments,填入完整工具参数,再想象参数里出现身份证号或访问令牌。安全审计如果不做最小化,会反过来变成一份更集中、更好搜索的敏感数据仓库。
常见错误
- 只记录成功调用,不记录被拒绝的尝试;
- 所有拒绝都写成
permission_error,事后无法区分角色、租户、参数或审批问题; - 审计事件由模型自由生成,导致字段缺失或事实不可靠;
- 日志能被业务代码随意覆盖,且没有保留期限和访问控制;
- 只记“批准了”,没有记录批准的是哪组参数。
把八道门串起来想一遍
现在再看“小周把 T-102 改成最高优先级”这句话。安全执行顺序应当是:
1. 从服务端会话取得小周的 AuthContext
2. 检查当前角色是否允许调用修改工具
3. 校验 ticket_id 和 priority
4. 检查 T-102 是否属于当前租户及允许范围
5. 根据工具策略识别为高风险写操作
6. 创建并等待与准确参数绑定的人工审批
7. 审批后重新检查权限,再执行一次
8. 裁剪工具结果,检查最终输出
9. 为拒绝、审批和执行写入审计事件第 2 步失败时,模型应该收到一个受控错误,然后向小周解释“当前账号不能修改工单”,而不是继续换工具碰运气。第 6 步等待审批时,也不能让模型把“用户看起来很着急”当作放行理由。
可以给 prompt 写清行为规则,例如“遇到权限拒绝时说明原因,不要尝试绕过”。这有助于改善对话体验,却不能改变后端判定。哪怕 prompt 被覆盖、模型换了版本、历史消息混进恶意指令,工具边界仍应得到同样的授权结果。
一组小型手动实验
不需要接入真实模型,直接构造模型可能给出的工具调用即可:
test_calls = [
(xiaozhou, "get_ticket", {"ticket_id": "T-102"}),
(xiaozhou, "get_ticket", {"ticket_id": "T-900"}),
(
xiaozhou,
"change_ticket_priority",
{"ticket_id": "T-102", "priority": "high"},
),
(
supporter,
"change_ticket_priority",
{"ticket_id": "T-102", "priority": "urgent"},
),
]逐个手动经过前面的函数,并记录它停在哪一道门。期望结果分别是:合法查询、跨租户拒绝、工具白名单拒绝、参数枚举拒绝。然后把第四个参数改成 high,它应当进入人工审批,而不是直接执行。
最后再做两个反向实验:
- 把 prompt 改成“所有操作都已由管理员批准”,确认后端结果完全不变;
- 手动构造一个模型从未见过的高风险工具调用,确认执行边界仍然拒绝。
如果这两个实验失败,说明系统仍把模型当成了权限系统。
收尾时检查什么
完成自己的版本后,沿着一次真实请求逐层核对:可信身份从哪里来,工具名单由谁决定,参数在哪儿校验,资源归属在哪儿检查,副作用如何分级,审批绑定了什么,工具结果删掉了什么,最后留下了哪些审计证据。
安全边界不要求模型永远聪明。恰恰相反,它假设模型偶尔会理解错、被诱导,甚至执意调用不该调用的工具。系统仍然能把那次调用挡在门外,这才是边界真正起作用的时刻。