给 Agent 加一个 Approval Gate
echo-agent 前身为 2025 年 11 月启动的个人助理项目 fubot,最初面向长期陪伴型个人智能体,围绕认知记忆、上下文延续、用户偏好沉淀、任务闭环与持续自我优化展开。随着真实场景迭代,项目逐步形成多入口接入、统一事件模型、消息总线、Agent Loop、多模型抽象、工具调用、MCP 接入、任务调度、权限审批、运行轨迹、长期记忆和受控自演进等能力。目前已支持微信、QQ、CLI、Gateway、Webhook、Cron 等入口,服务用户超过 20 万、累计下载超过 50 万,是面向长期运行、记忆增强和可持续成长智能体的开源 Agent Runtime。
项目地址:https://github.com/fuyuxiang/echo-agent
你让 Agent “帮我修复测试失败”。
它读完日志,判断需要运行测试。第一次是pytest,第二次可能是安装依赖,第三次可能是删除临时文件。每一步看起来都像正常工程动作,但它们的副作用完全不同:有的只是读取状态,有的会写文件,有的会联网,有的可能不可恢复。
这时真正的问题不是“模型能不能想到正确命令”,而是系统能不能在命令执行前回答:这一步是否允许执行、是否必须拒绝、是否需要用户授权。
这就是 Approval Gate 要解决的问题。
问题入口
如果只看传统文本型 Chatbot,它的基本形态仍是:用户输入文本,模型返回文本。回答错了可以追问,理解偏了可以纠正,系统本身不会因为一句话就改文件、跑命令、发消息。
Agent 不一样。它把模型输出接到了工具系统上。模型不再只是生成答案,而是在生成行动计划:读文件、写文件、执行命令、访问网络、启动进程。行动一旦接入真实环境,安全问题就从“回答质量”变成了“执行授权”。
上一章讲的安全基础层已经能产生很多信号:工具是否暴露、命令是否危险、路径是否敏感、网络是否允许、风险等级是什么。但信号本身还不是决策。系统还需要一个统一入口,把这些信号合成最后结果。
Approval Gate 不是一个“确认弹窗”,而是 Agent 行动系统的最后决策门。
为了不停留在抽象层面,下面以 echo-agent 的实现为例。它把所有模型发起的工具调用先送入InferenceStage,再进入ApprovalGate.check。工具只有通过这道门,才会继续执行。
审批门
Approval Gate 的输出不是简单的 true / false,而是一个ApprovalCheck。
它有两个核心字段:denial和approved_actions。如果denial是None,表示工具可以继续执行;如果denial是ToolResult,表示工具调用被拒绝或审批超时;approved_actions则记录本次已经批准的动作标识,供后续工具内部策略继续使用。
@dataclass class ApprovalCheck: denial: ToolResult | None = None approved_actions: frozenset[str] = frozenset()
这个设计有一个很重要的工程含义:审批失败不是异常退出,而是一个模型可观察到的工具结果。InferenceStage会把拒绝信息写回模型上下文,模型可以解释失败原因、请求用户授权,或者选择更安全的替代方案。
例如,用户要求“清理项目里没用的文件”。模型如果直接生成递归删除命令,Approval Gate 可以拒绝或请求审批;拒绝结果会作为 tool 消息返回。模型接下来可以改成先列出候选文件、生成删除计划,再让用户确认范围。
审批门因此不是中断对话,而是把风险决策放回 Agent Loop。
决策链
echo-agent 当前的ApprovalGate.check有清晰的判断顺序。这个顺序比单个规则更重要。
硬阻断必须最先发生。已经被 static guard 判定为危险的命令,不能再被auto_approve、trusted channel 或 Smart Approval 放行。Elevated 权限也要早于普通风险放行,因为 local、remote 或 full security 的执行范围可能超出沙箱。
可以把这条链压缩成几层:
| 阶段 | 作用 |
|---|---|
| Static guard | 命中破坏性命令、敏感路径、禁用工具、network deny 时直接拒绝 |
| Elevated check | 对 local、remote、full security 等高权限执行做通道和用户校验 |
| Risk classification | 把工具调用分为 read-only、write、exec、dangerous |
| Low-risk pass | read-only 和 write 默认放行,但仍受路径、沙箱和工具内部策略约束 |
| Auto paths | 处理 auto_approve、personal CLI、trusted channel、approval mode off |
| Required check | 根据 auto_deny、guard ask、推理确认、require_approval、dangerous 判断是否需要审批 |
| Allowlist / unattended | 复用已批准 pattern,或在无人值守场景按策略拒绝 |
| Smart / manual | 低风险 exec 可智能判断,否则进入人工审批 |
最小伪代码大致是这样:
async def check(tool_name, arguments, event): guard = evaluate_tool_call(config, tool_name, arguments) if guard.denied: return deny(guard.reason) if requires_elevated(tool_name) and not elevated_allowed(event): return deny("requires elevated execution rights") risk = classify_risk(tool_name, arguments) if risk in {READ_ONLY, WRITE}: return allow() if auto_approved(tool_name, risk, event): return allow() if not approval_required(tool_name, guard, risk): return allow() pattern_key = build_pattern_key(tool_name, arguments) if risk == EXEC and allowlist.approved(event.session_key, pattern_key): return allow() if unattended(event): return handle_unattended_policy(risk, pattern_key) if risk == EXEC and mode == "smart": return await smart_or_escalate(tool_name, arguments, guard) return await manual_approval(tool_name, arguments, event, guard)这里最容易误解的是 read-only 和 write 的放行。它不等于“写操作没有风险”,而是说普通写操作的主要边界放在路径策略、safe write root、工具暴露策略和具体工具内部校验里。
例如write_file可以写普通用户文件,但仍应阻断~/.ssh/id_rsa、/etc/passwd、系统路径和safe_write_root外路径。Approval Gate 不应该把每次正常编辑都变成弹窗,否则 Agent 会失去可用性;它要把注意力集中在 exec、dangerous 和上下文不确定的动作上。
好的审批系统不是把所有动作都交给人,而是知道哪些动作可以自动化,哪些动作必须拒绝,哪些动作需要授权。
授权范围
人工审批最怕两个极端:范围太窄,用户反复确认;范围太宽,一次批准变成长期风险。
echo-agent 用 allowlist 处理这个问题。审批级别分三种:ONCE、SESSION、ALWAYS。一次性批准只对当前请求生效;session 批准写入当前会话;always 批准写入永久 allowlist 并保存到文件。
allowlist 不是直接保存完整命令字符串,而是通过build_pattern_key做归一化。比如exec执行python3 -m pytest,pattern 可能是exec:python3;execute_code执行 Python,pattern 是code:python;process启动 npm,pattern 是process:npm。
这是一种折中。完整参数过细,会导致相似动作不断重复审批;只按工具名过粗,又会把完全不同风险混在一起。pattern_key让系统可以复用“同类动作”的授权,同时保留重新判断的空间。
审批通过后,approved_actions会包含工具名、guard 的pattern_key和approval_action。后续ToolExecutionContext会把这些信息传给工具。ShellTool、CodeExecTool、ProcessTool等工具可以再次检查:这次执行是否真的包含被批准的动作。
这解决了一个常见脱节问题:Gate 层判断allowlist_miss需要审批,用户批准后,工具内部再次扫描命令仍可能得到 ask。如果没有approved_actions,工具会再次拦住同一个动作;有了它,工具能知道这个模式已经被本轮授权覆盖。
审批范围的原则可以压成一句话:批准应覆盖完成当前任务所需的最小行动集合。
人工审批
当自动路径不能放行时,系统进入 manual approval flow。
echo-agent 会通过ApprovalManager.request_approval创建审批请求。请求有签名,签名由用户、动作、工具名和参数生成;如果相同请求已经 pending,系统会返回已有请求,避免重复创建审批项。
审批请求不是临时变量,而是状态对象。它要包含请求 ID、工具名、参数、发起用户、当前状态、审批结果,并支持等待、去重、查询和持久化。
用户侧的控制命令很直接:
/approvals /approve <request_id> /approve <request_id> session /approve <request_id> always /deny <request_id> [原因]
这些命令是控制面命令,不需要进入完整模型推理。/approvals会列出当前用户可见的 pending 请求;/approve和/deny会直接改变审批状态。
谁可以审批也不能靠模型判断。书稿里的规则是:如果配置了admin_users,只有管理员能审批;如果没有配置管理员,请求发起者可以审批自己的请求。这让授权关系绑定到用户和通道,而不是绑定到模型的一句话。
如果审批超时,ApprovalGate会返回 denial。无人值守场景也会单独处理:cron、scheduler 或带_unattendedmetadata 的事件,默认策略是 deny;allow_safe也只放行 write,或者已经命中 allowlist 的 exec。没有人在场时,系统不应该默默执行高风险动作。
Smart Approval
Smart Approval 的价值是降低误报,不是替代责任归属。
在 echo-agent 中,它只出现在较靠后的阶段:风险为EXEC、approval mode 为smart、provider 可用时才运行。它可以返回三种结果:approve、deny、escalate。approve 会把 pattern 写入本 session;deny 会返回拒绝;escalate 则继续进入人工审批。
例如,python -c "print('hello')"可能因为 inline interpreter 被标记为需要审批,但上下文显示风险很低。Smart Approval 可以减少这种低价值打断。
但它不能覆盖 static guard。破坏性删除、写块设备、格式化文件系统、读取敏感凭证、network deny 下访问外网,这些动作不应该因为另一个模型判断“看起来没问题”就被放行。
智能审批的边界是降低打扰,不是转移授权责任。
这也是为什么 Approval Gate 必须有固定时序。Smart Approval 在硬阻断之后,只处理规则无法充分判断、但又可能低风险的 exec 场景。真正有副作用、外部可见、涉及成本或凭证的动作,仍应由明确策略或用户授权承担责任。
生产可用性
判断一个 Approval Gate 是否接近生产级,不能只看“有没有 approve 按钮”。更硬的检查项应该是这些:
| 检查项 | 可验证标准 |
|---|---|
| 执行前入口 | 所有模型工具调用是否先进入统一 gate,再进入工具执行 |
| 硬阻断优先 | static guard 是否早于 auto approve、trusted channel、smart approval |
| 权限绑定 | elevated 执行是否绑定 channel、sender_id、admin_users |
| 风险分流 | 是否区分 read-only、write、exec、dangerous,并有不同路径 |
| 审批结果 | 拒绝是否作为 tool result 写回上下文,而不是静默失败 |
| 授权范围 | 是否支持 once、session、always,并能解释 pattern 范围 |
| 无人值守 | cron、scheduler 是否默认保守,避免等待不到审批或静默执行 |
| 状态持久化 | pending 请求、一次性授权、审批历史是否能跨重启恢复 |
| 工具协同 | 工具内部是否读取approved_actions,避免 gate 与工具策略脱节 |
| 回归测试 | 是否覆盖请求创建、去重、批准、拒绝、超时、持久化和审批命令 |
这些检查项背后的共同原则是:审批不是 UI 功能,而是执行控制协议。它要让系统能解释每次行动为什么被允许、为什么被拒绝、为什么需要人参与,以及批准范围到哪里结束。
审批和澄清也要分开。用户说“删除没用的文件”,系统缺的首先是范围信息,其次才是删除授权。正确做法是先澄清“哪些文件算没用”,再对具体删除动作请求审批。把澄清当审批,会让用户批准一个自己并未理解的行动;把审批当澄清,又会让系统显得啰嗦。
小结
Approval Gate 的核心不是让用户多点几次确认,而是把 Agent 的行动授权显式化。
模型负责提出候选行动,系统策略负责识别风险,人类在规则无法充分判断且影响较大的地方做授权。低风险、可恢复、边界清楚的动作自动放行;硬阻断动作直接拒绝;高影响或上下文不确定的动作进入审批。
这样设计之后,Agent 不只是“会调用工具”,而是知道每次工具调用如何被授权、如何被记录、如何在失败后回到对话中继续推进任务。
(全篇完)
本文为 echo-agent 设计笔记系列第 16 篇。项目源码已开源至 GitHub。如果你对工业级 Agent 的工程落地感兴趣,欢迎加入技术交流群参与日常讨论。下一篇我们将探讨 《Agent Memory 设计笔记:从短期上下文到长期经验》,敬请期待。