4.3 工具权限与审批:高危工具二次确认
认证确认了「你是谁」,租户隔离圈定了「你能看哪些数据」,但还有最后一类风险不在数据边界上,而在动作本身。Agent 能调用的工具里,search 查错了顶多重来,delete_run、send_email、sql_exec 一旦执行就不可逆。把删除权限直接交给一个会「自作主张」的模型,是很多 Agent 事故的根源。这一节给工具按危险程度分档,让高危动作在真正执行前停下来等人点头。
本节把 ResearchCopilot 推进到「高危可控」:新增
minagent/permissions.py,给ToolRegistry包一层权限,工具分只读 / 写入 / 危险三档,危险档必须拿到人工审批才放行。这复用了卷一第 12.3 节「人类在环」的思路,只是把落点从引擎内部搬到了工具边界上。
4.3.1 为什么是「工具权限」,而不是「模型自律」
一个常见的错误期待是:在 system prompt 里写「删除操作前必须先询问用户」,然后指望模型照做。这条路不可靠,原因有三:
- 提示词是建议,不是约束。 模型可能忽略、可能被注入攻击绕过(第 15 章),可能在一个多步任务里「顺手」就删了。
- 模型看不到权限边界。 它只知道有哪些工具可用,不知道「这个工具在这个租户下是否被允许」。
- 审计要求「有据可查」。 合规场景下,「谁在什么时候批准了这次删除」必须是一条记录,而提示词里的口头承诺留不下证据。
正确的心智模型是:权限属于系统,不属于模型。 模型可以请求调用任何工具,但能不能真的执行由系统决定。这跟 3.1 那句「控制流属于程序,不属于模型」是同一条原则的延伸——执行权也在程序手里。落点就是:在 ToolRegistry.call 前面加一层拦截。
4.3.2 三档权限模型
档位不用太多,三档足够覆盖绝大多数场景:
| 档位 | 语义 | 例子 | 执行策略 |
|---|---|---|---|
只读 readonly | 不改任何状态 | search / read_file / get_run | 直接执行 |
写入 write | 改自己的数据,可回滚 | create_run / update_note | 直接执行,记审计 |
危险 dangerous | 不可逆或影响外部 | delete_run / send_email / sql_exec | 挂起等人工审批 |
分档的关键不是「操作多大」,而是**「能不能撤销」**。update_note 改错了能改回来,算写入;send_email 发出去就收不回,算危险。一条好用的经验法则:如果「撤销」需要一个新动作(重发、道歉、恢复备份),它就是危险档。
写入档要不要也审批?默认不要,否则用户会被无意义的弹窗淹没,反而降低对真正危险操作的警觉(alert fatigue)。但写入档必须留审计日志,为事后追责留证据。
4.3.3 权限包装:PermissionedRegistry
PermissionedRegistry 复用卷一的 Tool 形状,只是在注册时多挂一个档位,并在 call 里拦截:
# minagent/permissions.py
from minagent import Tool, ToolCall
class ToolTier:
READONLY = "readonly"
WRITE = "write"
DANGEROUS = "dangerous"
class ApprovalRequired(Exception):
def __init__(self, tc: ToolCall) -> None:
super().__init__(tc.name)
self.tc = tc
class PermissionedRegistry:
"""包一层 ToolRegistry:每个工具挂一个权限档,危险档必须拿到审批才放行。"""
def __init__(self) -> None:
self._tools: dict[str, Tool] = {}
self._tier: dict[str, str] = {}
def add(self, tool: Tool, tier: str) -> None:
self._tools[tool.name] = tool
self._tier[tool.name] = tier
def tier_of(self, name: str) -> str:
return self._tier[name]
def schemas(self):
return [{"name": t.name, "description": t.description,
"parameters": t.parameters} for t in self._tools.values()]
def call(self, tc: ToolCall, approved: frozenset[str] = frozenset()) -> str:
if self._tier[tc.name] == ToolTier.DANGEROUS and tc.id not in approved:
raise ApprovalRequired(tc) # 拦截点:没有审批就不执行
return str(self._tools[tc.name].func(**tc.arguments))
三个设计点:
- 拦截在
call里,不在循环里。 这样即使将来有别的地方绕过循环直接调工具,权限依然生效。执法点要贴近资源,不能贴近调用方。 approved用frozenset,粒度是tool_call_id。 一次审批只放行这一个ToolCall,不是「这个工具以后都放行」。否则模型后续再发一次delete_run就自动通过了。用 id 而非工具名,是为了防「一次批准、终身放行」。- 抛异常而不是返回错误 JSON。
ToolRegistry.call的契约是「出错返回{"error":...}」,但「待审批」不是错误——它是一个控制流信号,要打断循环、交给外部处理。所以用异常区分「失败」与「挂起」。
4.3.4 带审批的循环
在卷一的 run() 基础上,把「捕获 ApprovalRequired → 调审批人 → 通过则重试」这段插进工具执行处:
# minagent/permissions.py(续)
import json
from minagent import Message, RunResult, Step
def run_with_approval(model, registry, user_input, approver,
system="", max_steps=8):
messages: list[Message] = []
if system:
messages.append(Message(role="system", content=system))
messages.append(Message(role="user", content=user_input))
steps: list[Step] = []
approved: set[str] = set()
for i in range(max_steps):
resp = model.complete(messages, registry.schemas())
msg = resp.message
messages.append(msg)
if not msg.tool_calls:
steps.append(Step(i, "final", msg.content))
return RunResult(messages, steps, msg.content)
for tc in msg.tool_calls:
try:
result = registry.call(tc, frozenset(approved))
except ApprovalRequired as ar:
steps.append(Step(i, "approval_required",
f"{tc.name}({tc.arguments})"))
if not approver(ar.tc): # 挂起等人工;被拒走错误回灌
denied = json.dumps({"error": "approval_denied",
"tool": tc.name})
messages.append(Message(role="tool", content=denied,
tool_call_id=tc.id))
steps.append(Step(i, "approval_denied", tc.name))
continue
approved.add(tc.id)
steps.append(Step(i, "approval_granted", tc.name))
result = registry.call(tc, frozenset(approved))
messages.append(Message(role="tool", content=result,
tool_call_id=tc.id))
steps.append(Step(i, "tool_call",
f"{tc.name}({tc.arguments}) -> {result}"))
steps.append(Step(max_steps, "error", "达到 max_steps 仍未收敛"))
return RunResult(messages, steps, "")
关键点在于审批被拒也走正常回灌:把 {"error": "approval_denied"} 作为一条 tool 消息喂回模型,让模型知道「这个动作被拒了」,从而自己换一条路(比如改用只读工具、或向用户说明)。这与卷一「工具出错不抛异常、包成 JSON 回灌」是同一套哲学——让模型有机会自我纠正,而不是让循环崩掉。
注意 approver 在演示里是个同步回调,返回 True/False。真实系统里它是异步的:把待审批请求写进队列、通过 SSE(3.2)推给前端,然后挂起这个 run(存下 messages 与 steps 做检查点),等用户点「批准/拒绝」后再从检查点续跑。这正是卷一第 12.3 节人类在环的完整形态,本节的同步回调是它的最小骨架。
4.3.5 真跑:一条完整审批轨迹
注册一个只读的 search 和一个危险的 delete_run,用 FakeModel 脚本化三段:先检索、再请求删除、最后给报告。审批人打印一行日志并批准。
# demo_43.py(节选)
reg = PermissionedRegistry()
reg.add(Tool("search", "检索本地语料", {...},
func=lambda q: f"命中 3 条与『{q}』相关的片段"), ToolTier.READONLY)
reg.add(Tool("delete_run", "永久删除一次运行及其产物", {...},
func=lambda run_id: f"已删除 {run_id}"), ToolTier.DANGEROUS)
model = FakeModel([
[ToolCall("c1", "search", {"q": "run_42 摘要"})],
[ToolCall("c2", "delete_run", {"run_id": "run_42"})],
"已检索并清理 run_42,报告如下:……",
])
def approver(tc):
print(f" [人工审批] 工具 {tc.name}({tc.arguments}) 需要确认 -> 通过")
return True
result = run_with_approval(model, reg, "清理 run_42 并给我报告",
approver, system="你是研究助理。")
for s in result.steps:
print(f"[{s.index}] {s.kind:<17} {s.detail}")
print("最终:", result.final)
print("模型调用次数:", len(model.calls))
本机实测(Python 3.14.6)输出:
[人工审批] 工具 delete_run({'run_id': 'run_42'}) 需要确认 -> 通过
[0] tool_call search({'q': 'run_42 摘要'}) -> 命中 3 条与『run_42 摘要』相关的片段
[1] approval_required delete_run({'run_id': 'run_42'})
[1] approval_granted delete_run
[1] tool_call delete_run({'run_id': 'run_42'}) -> 已删除 run_42
[2] final 已检索并清理 run_42,报告如下:……
最终: 已检索并清理 run_42,报告如下:……
模型调用次数: 3
轨迹读起来正是我们想要的节奏:第 0 步 search 是只读,无人打扰直接执行;第 1 步模型请求 delete_run,被 approval_required 拦住,审批人点头后 approval_granted 才真正执行;第 2 步模型给出最终报告。全程 3 次模型调用,search 与 delete_run 的区别处理得干干净净。若把 approver 改成 return False,第 1 步会变成 approval_denied,并把 {"error": "approval_denied"} 回灌给模型——模型下一步大概率会改成「我无权删除,请手动处理」。
4.3.6 审批超时与失败策略
同步回调在演示里没问题,生产里「等人工」会挂很久,必须有超时与兜底。策略如下:
| 策略 | 取值建议 | 理由 |
|---|---|---|
| 审批超时 | 5–15 分钟 | 太久会占满 worker;太短用户来不及看 |
| 超时后行为 | 默认拒绝(fail-closed) | 危险操作「没批准 = 不执行」 |
| 审批未响应时的 run 状态 | awaiting_approval(非终态) | 前端据此显示「待确认」 |
| 被拒后的重试 | 不自动重试危险动作 | 避免「点错了再试」变成反复执行 |
| 审批记录 | 落库:谁、何时、哪个 tool_call、结果 | 合规审计必需 |
最重要的一条是 fail-closed(默认拒绝):超时、审批人离线、审批服务宕机——一律当作「不批准」。危险操作的默认答案必须是「否」,只有拿到明确的人工点头才放行。这与安全领域的通行原则一致:没有明确许可,就不执行。 反之,如果把超时当「默认通过」,攻击者只要让审批系统超时就能绕过审批。
落库的审批记录大致长这样,前端拿到它就能渲染一张「批准 / 拒绝」卡片:
{
"tool_call_id": "c2",
"tool": "delete_run",
"arguments": {"run_id": "run_42"},
"workspace_id": "ws_acme",
"status": "pending",
"expires_at": "2026-09-26T14:15:00+08:00"
}
status 从 pending 流转到 approved / denied / expired 时,worker 从检查点(存下的 messages 与 steps)续跑:批准就放行这次 tool_call,拒绝或过期就把 {"error": "approval_denied"} 回灌给模型。注意 expires_at 是服务端算出来的,不能信客户端——否则用户可以伪造一个「永不过期」的审批请求。
4.3.7 真实环境下会怎样
本节把审批简化成一个同步回调,真实系统要拆成异步 + 可持久化:
| 演示做法 | 生产做法 | 章节 |
|---|---|---|
同步 approver 回调 | 待审批入队 + SSE 推送 + 检查点续跑 | 7.1 / 9.3 |
内存 approved 集合 | 落库 approvals 表 + 审计 | 5.1 / 15.3 |
| 固定档位 | 档位 + 租户策略(某租户禁危险工具) | 10.3 沙箱 |
还有一个容易被忽视的组合:工具权限要和租户隔离(4.2)叠加。同一个 delete_run,对「租户管理员」可能是危险档(需审批),对「只读成员」应当直接拒绝(连审批机会都没有)。也就是说,权限判定 = 工具档位 × 调用者角色,两层缺一不可。
验收清单:
- 每个工具注册时都显式声明档位,没有「默认档」。
- 危险档的拦截在
call里,不在循环里。 -
approved粒度是tool_call_id,不是工具名。 - 审批被拒走错误回灌,模型能自我纠正,循环不崩。
- 超时 / 无响应一律 fail-closed。
到这里,身份(4.1)、权限(4.2)、高危审批(4.3)三道关都立住了,ResearchCopilot 终于「能给别人用」。下一章开始把状态落进 Postgres——先设计会话 / 消息 / 运行 / 记忆四张表与迁移。想回看租户隔离怎么把 workspace_id 写进每条查询,翻 4.2 授权与租户隔离:workspace 行级隔离
。
阅读导航:上一节:4.2 授权与租户隔离:workspace 行级隔离 · 下一节:5.1 Schema 设计:会话/消息/运行/记忆表 + 迁移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。