本文是AgentScope 2.0 源码解析系列 第 6 篇(Permission 模块)。
已发:Agent、Event、Message、Model、tool | 下一篇:Middleware。
关注追更,后续会持续更新 Memory、Pipeline、MCP 等模块。
读完这篇你会带走这些源码层面的结论:
permission 模块只有 1 个引擎 PermissionEngine、5 种模式、4 种 behavior——模式决定"要不要问用户"的大方向(人机协同 HITL 的开关),规则做精细控制,两者正交5 种模式对应 5 条独立裁决流水线 _check_default/_explore/_accept_edits/_bypass/_dont_ask,而不是一个塞满 if-else 的巨型方法——每条流水线的求值顺序在 docstring 里白纸黑字写死- bypass_immune 一个布尔位
撑起整套安全栅栏语义:工具把它置 True,意味着"这次操作危险到任何 allow 规则都不能替用户拍板放行"——这是"最小权限"原则下不可降级的那一类操作,连 BYPASS 模式都专门为它开了例外 - 匹配策略委托给工具自己
:引擎不认识 npm install和src/**的区别,全靠tool.match_rule返还布尔值——引擎因此保持工具无关 PASSTHROUGH是工具对引擎说"我没特殊意见,交给规则"的信号,和 ALLOW/DENY/ASK并列第四种 behaviorDONT_ASK 模式有一条铁律不变量"永不返回 ASK",靠 _convert_ask_to_deny把所有本该问用户的路径强制转 DENY 守住PermissionDecision.updated_input目前是声明了但没人消费的死字段——schema 预留了"引擎可改写输入"的能力,当前唯一写入点只是原样回传
适合谁读:在搭需要人机协同(HITL)或无人值守运行的 Agent、想给自己的工具加精细权限控制的开发者。预计阅读:主线约 8 分钟,附录字段表另需 4 分钟。
🎯 如果只记一件事
permission 模块用 bypass_immune 这最小的一个布尔位,在数据结构层面给"普通偏好性询问"和"不可被规则静默跳过的安全栅栏"划出了不可逾越的界限——普通 allow 规则无法跨越它,连 BYPASS 这种"用户已放弃安全提示"的模式都要单独写代码决定是否尊重它。这套「在数据里编码安全语义、让可配置规则无法降级它」的思路,搬到任何需要"可配置但不可降级"的安全控制(审计、合规、金额阈值)都成立。
关于结构:本文分两层——前八节是主线(为什么这样设计、怎么运行、怎么改、最值得学),讲清 permission 模块的全貌;字段表(第四节)较细,第一次读只需精读 PermissionDecision 一张(bypass_immune 的语义全在这里),其余四张按需查阅,不影响理解主线。
一、这个模块到底在解决什么问题?
上一篇 tool 模块里提到,工具执行前要做权限检查,而且工具自己最清楚"这次调用危不危险"。但光有工具的事实判断还不够,一个 Agent 真正跑起来,至少还要回答这几个问题:
默认情况下,每次工具调用都问用户一遍吗?还是给个白名单自动放行? 用户在不在场,决定了能不能弹确认框——"用户盯着屏幕快速迭代"、"Agent 跑无人值守的定时任务"、"Agent 在沙箱里跑可以完全信任",这几种场景的权限策略天差地别,怎么用一套机制覆盖? rm -rf /这种操作,即使用户配了"允许所有 Bash"的宽松规则,也不该被静默放行——"用户授权的规则"和"不可降级的安全栅栏"怎么区分? Bash 的 npm install、Write 的src/**、其它工具的过滤模式,匹配逻辑完全不同,引擎要不要认识每一种?工具被 deny 了,结果怎么回喂给 LLM?被 ask 了,Agent 是阻塞等待,还是怎样把流程挂起?
permission 模块(src/agentscope/permission/)就是这五个问题的答案。一句话概括:它是一个"模式驱动的权限裁决引擎"——5 种模式各定义一套裁决流水线,规则做精细匹配,工具提供危险度的事实判断,三者协作产出最终的 PermissionDecision。
如果没有这个抽象,每种运行场景(交互/无人值守/沙箱)都得各写一套权限逻辑,"安全栅栏不可被规则绕过"这种语义无处安放,agent 主循环会被权限细节塞满。permission 模块把这些收进一个引擎,对外只暴露一个 check_permission(tool, tool_input) 入口。
二、这个模块在整个框架中的位置
先看它和谁打交道:

图 1 · permission 模块在框架中的位置:单点调用 → 模式分发 → 工具提供事实判断
输入:一个 ToolBase 实例 + 这次调用的 tool_input 字典。输出:一个 PermissionDecision(behavior + message + 可选的建议规则)。
它依赖谁:
tool:反向调用 ToolBase的四个契约方法(check_permissions/check_read_only/match_rule/generate_suggestions)——注意依赖方向,是 engine 调 tool,不是反过来state: PermissionContext作为AgentState.permission_context字段被持有(state/_state.py:170),engine 构造时拿到它的引用(_engine.py:47)
谁依赖它:只有 Agent 主循环一处。Agent.__init__ 里实例化 self._engine = PermissionEngine(self.state.permission_context)(agent/_agent.py:156),_execute_tool_call 里单点调用 check_permission(agent/_agent.py:1649)。这是一个非常干净的"单一调用方"边界——整个 src 里没有第二个模块绕过 agent 直接驱动 engine。
三、为什么这样设计?
permission 模块在每个关键岔路口都做了克制的选择,值得逐条拆。
决策 1:模式与规则正交,而不是揉在一起
权限系统最朴素的写法是一张大规则表:if mode == X and rule matches then allow。AgentScope 没有,它把"模式"和"规则"拆成两个正交维度:
- 模式(
PermissionMode)回答"在什么运行场景下,遇到没命中规则的操作,默认怎么办"——是问用户(DEFAULT)、是直接放行(BYPASS)、是转成 DENY(DONT_ASK)。 - 规则(
PermissionRule)回答"针对某个工具的具体调用,用户预先表态过允许/拒绝/要问"。
这两者正交意味着:你可以给同一个 Agent 配上 BYPASS 模式(默认全放行),同时加一条 deny rm -rf / 规则做底线——模式负责宽,规则负责窄,互不干扰。如果揉在一起,"宽松模式下的底线保护"这种语义根本表达不出来。
决策 2:每个模式一条独立流水线,而不是 if-else 巨型方法
这是整个模块最值得学的地方之一。PermissionEngine.check_permission(_engine.py:77-115)是一个纯分发器:
mode = self.context.modeif mode == PermissionMode.DEFAULT: return await self._check_default(tool, tool_input)if mode == PermissionMode.EXPLORE: return await self._check_explore(tool, tool_input)# ... 其余三个模式同理raise ValueError(f"Unknown permission mode: {mode}")然后每个模式有自己的 _check_<mode> 方法。关键在于——这五个方法的求值顺序各不相同,且每个都在自己的 docstring 里把顺序写死了。例如 DEFAULT(_engine.py:117-194)的顺序是:
deny 规则 → DENY ask 规则 → ASK(带建议) tool.check_permissions→ ALLOW/DENY 直接返回;安全 ASK 不可被覆盖;其它继续 allow 规则 → ALLOW 兜底 → ASK(带建议)
而 EXPLORE(_engine.py:196-259)压根不调 check_permissions,只看 check_read_only 的布尔裁决;BYPASS(_engine.py:353-429)的兜底是 ALLOW 而不是 ASK。
如果用一个带 if-else 的巨型方法写,"EXPLORE 不调 check_permissions"、"BYPASS 故意忽略 safety ASK"这些微妙差异会被埋进层层嵌套,根本读不出来。拆成五个方法后,每个模式的策略自包含、可单独 review。这是模板方法模式的一种克制变体——没有用继承,而是用方法分发,因为模式是运行时切换的、不是类型层面的。
决策 3:bypass_immune——用一个布尔位编码不可降级的安全语义
PermissionDecision 里有一个布尔字段 bypass_immune(_decision.py:33),默认 False。工具把它置 True,语义是:
这次操作危险到任何 allow 规则都不能替用户拍板放行——必须用户当下亲自确认。
为什么需要它?考虑这个矛盾:
用户为了少被打扰,配了 allow Bash:*(允许所有 Bash 命令)这种宽松规则但 rm -rf /、写~/.bashrc、命令注入模式这种操作,即使配了宽松规则也不该自动放行
如果只有 ALLOW/DENY/ASK 三种 behavior,这个矛盾无解——要么 allow 规则万能(危险操作被静默放行),要么 allow 规则无效(配了还得问)。AgentScope 的解法是给 ASK 加一个"强度"维度:普通 ASK 可被 allow 规则覆盖(DEFAULT 模式第 4 步),bypass-immune ASK 不可被覆盖(DEFAULT 模式第 3 步,直接返回)。
看这个布尔位如何穿透各个模式(_decision.py:44-55 白纸黑字写明):
- DEFAULT / ACCEPT_EDITS
:尊重——allow 规则无法跨越 - EXPLORE
:不适用(EXPLORE 走 read-only 裁决,根本不调 check_permissions) - BYPASS
:故意忽略——BYPASS 的契约就是"用户已放弃安全提示",safety ASK 也照样放行 - DONT_ASK
:转成 DENY(没用户可问)
注意 BYPASS 那条——它不是"忘了处理" bypass_immune,而是专门写代码决定忽略它(_engine.py:409-417 的注释明说 "any ASK including bypass-immune safety ASK is intentionally NOT honored")。这是设计者深思熟虑后的选择:BYPASS 是用户主动让渡安全的模式,一旦开启,连安全栅栏都失效,只能靠 deny 规则兜底。
用一个布尔位,加上每个模式对它的差异化处理,AgentScope 表达出了"可配置但不可降级"的安全语义。
决策 4:匹配策略委托给工具,引擎保持工具无关
引擎要回答"这条规则匹配这次调用吗",但 Bash 的 npm install 是子串匹配命令、Write 的 src/** 是 glob 匹配文件路径、其它工具各有各的过滤逻辑。如果引擎认识这些,它就得对每种工具的内部语义了如指掌,变成大杂烩。
AgentScope 的解法是 _rule_matches(_engine.py:656-691):
if not rule.rule_content: return True # 空规则匹配一切(工具名级规则)return await _execute_async_or_sync_func( tool.match_rule, # 把匹配判断完全交给工具 rule.rule_content, input_data,)引擎只负责"空规则匹配一切"这个通用约定,剩下的全部委托给 tool.match_rule。结果是引擎零工具特化代码——以后新增第十种工具,引擎一行都不用改。这和上一篇 tool 模块的"匹配内嵌进工具"是同一思路的延续:权限相关的工具特化知识,住在工具里。
顺带一提,_execute_async_or_sync_func(_engine.py:687)这个调用包装不是多余的——它保留了对第三方工具历史同步 def match_rule 的向后兼容(框架签名已改成 async def)。这是一个值得注意的兼容性细节。
决策 5:PASSTHROUGH 作为工具交还决策权的信号
PermissionBehavior 有四个值(_types.py:88-102):ALLOW / DENY / ASK / PASSTHROUGH。前三个好理解,PASSTHROUGH 是什么?
它是工具对引擎说"我没特殊意见,交给你的规则继续走"的信号。上一篇讲过的内置工具(Bash/Write/Edit)在 check_permissions 里检查完自己的特化逻辑后,如果没命中危险路径也没命中放行条件,就返回 PASSTHROUGH(如 tool/_builtin/_write.py:153 的注释所述)。引擎收到 PASSTHROUGH 后,继续走后续的 allow 规则和兜底逻辑。
如果没有这个 behavior,工具要么返回一个强决策(剥夺引擎的规则匹配机会),要么引擎得猜"工具没返回明确意见是什么意思"。PASSTHROUGH 把"工具主动交还决策权"显式化了——四元 behavior 比三元清晰得多。
决策 6:PermissionContext 是可序列化 model,挂在 state 上
PermissionContext(_context.py:24)是 Pydantic BaseModel,不是普通 class。它作为 AgentState.permission_context 字段存在(state/_state.py:170,Field(default_factory=PermissionContext))。这个选择有三个好处:
- 天然随 state 持久化
——Agent 状态存盘时,权限规则、模式、工作目录一并落盘,恢复时自动还原 - 派生子 Agent 时易克隆
——app 层的调度器( app/_manager/scheduler/_scheduler_manager.py:163)给定时任务子 Agent 显式构造PermissionContext(mode=DONT_ASK),agent_create/agent_invite 给 worker 派生 context,都是 model 的直接构造 - 规则按行为分桶
( _context.py:39-46):allow_rules/deny_rules/ask_rules三个dict[str, list[PermissionRule]],key 是工具名。add_rule(_engine.py:49-75)按 behavior 自动归桶,查询时只取对应工具的列表,不用全表扫描
四、跟着我阅读源码
文件优先级(从必读到可跳过):
_engine.py(必读)——模块的大脑,5 条模式流水线全在这里。整篇文章的篇幅主要花在这一个文件上。 _decision.py(必读)—— PermissionDecision的字段语义,尤其bypass_immune的 docstring(_decision.py:33-68)是理解整套安全栅栏的钥匙。_types.py(必读)—— PermissionMode的 docstring(_types.py:18-79)用一张表把 5 种模式的语义和适用场景讲透了,是官方设计文档。_context.py/_rule.py(按需查阅)——数据容器,逻辑简单。
下面是字段表。第一次读只需精读 PermissionDecision 一张(bypass_immune 全在这里),其余四张按需查阅。
4.1 PermissionDecision(核心,必读)
定义于 _decision.py:10-68。这是引擎的输出,也是 agent 主循环分支的依据。每个字段都值得看清:
behavior | PermissionBehavior | ||
message | str | ||
decision_reason | str \| None | None | "Rule: npm install"、"Mode: default") |
updated_input | dict \| None | None | |
suggested_rules | list \| None | None | |
bypass_immune | bool | False | 安全栅栏开关 |
bypass_immune 字段的 docstring(_decision.py:33-68)是整个模块最值得逐字读的注释——它详细列出了四种模式对该字段的处理差异,比本文第三节写的还细。建议直接打开源码读一遍。
4.2 PermissionMode(按需查阅)
定义于 _types.py:18-85。五个枚举值,对应五种运行场景:
DEFAULT | "default" | |
ACCEPT_EDITS | "accept_edits" | |
EXPLORE | "explore" | |
BYPASS | "bypass" | |
DONT_ASK | "dont_ask" |
_types.py:24-64 的源码里有一张 ASCII 表,把每种模式的行为和适用场景列得非常清楚——这是官方的设计意图文档,比任何二手解读都准。
4.3 PermissionBehavior(按需查阅)
定义于 _types.py:88-102。四个枚举值:
ALLOW | "allow" | |
DENY | "deny" | |
ASK | "ask" | |
PASSTHROUGH | "passthrough" |
注意 PASSTHROUGH 只在工具向引擎返回时出现,引擎向 agent 返回的最终 decision 不会是 PASSTHROUGH(agent 主循环把它和 ASK 同等处理,见 agent/_agent.py:1659)。
4.4 PermissionRule(按需查阅)
定义于 _rule.py:8-36。一条用户配置的权限规则:
tool_name | str | "Bash"、"Write") | |
rule_content | str \| None | None 表示工具名级(匹配一切) | |
behavior | PermissionBehavior | ||
source | str | "userSettings"、"projectSettings"、"suggested") |
rule_content 的语义取决于 tool_name(_rule.py:13-22)——这是为什么匹配逻辑必须委托给工具的根本原因。
4.5 PermissionContext(按需查阅)
定义于 _context.py:24-46。引擎的运行时状态:
mode | PermissionMode | DEFAULT | |
working_directories | dict | {} | |
allow_rules | dict | {} | |
deny_rules | dict | {} | |
ask_rules | dict | {} |
三种规则按 behavior 分三个 dict,查询时只取对应工具的列表(如 self.context.deny_rules.get(tool.name, []),_engine.py:592)。
五、代码到底是怎么运行起来的?
用一条具体调用走完全程。场景:用户在 DEFAULT 模式下,让 Agent 跑 Bash(command="rm -rf /tmp/scratch")。我们跟着它从 agent 主循环进到引擎,再看结果如何回流。
注意,以下每一步都能指认到源码行号。各模式的求值顺序是重点。
步骤 0:进入引擎前的门禁
Agent._execute_tool_call(agent/_agent.py:1563)先做一层短路:如果这个工具调用已经被用户确认过(tool_call.state == ToolCallState.ALLOWED),直接用伪 decision 放行,不进引擎(agent/_agent.py:1642-1652)。这是为了避免"用户刚确认过的操作又被问一遍"。我们这次是首次调用,状态不是 ALLOWED,于是进入:
decision = await self._engine.check_permission(tool, parsed_input) # agent/_agent.py:1649步骤 1:按模式分发
PermissionEngine.check_permission(_engine.py:77-115)拿到 self.context.mode == DEFAULT,分发到 _check_default(_engine.py:117)。其余四个模式各有自己的 _check_<mode>,求值顺序各不相同。五种模式的完整求值流程对比如下图:

图 2 · 五种权限模式评估流程对比:共用 deny/ask 前缀,差异在工具自检与默认行为
步骤 2:DEFAULT 流水线的五步裁决
_check_default(_engine.py:117-194)按固定顺序走:
第 1 步 — deny 规则(_engine.py:150-152):查 deny_rules["Bash"],逐条用 tool.match_rule 匹配。假设用户没配 deny 规则,返回 None,继续。
第 2 步 — ask 规则(_engine.py:155-161):查 ask_rules["Bash"],同样匹配。假设没命中,返回 None,继续。
第 3 步 — 工具自己的 check_permissions(_engine.py:164):
tool_decision = await tool.check_permissions(tool_input, self.context)这一步把决策权交给 Bash 工具自己。Bash 的 check_permissions(tool/_builtin/_bash.py:195,上一篇讲过的 7 步流水线)会检查 rm -rf 这种模式。/tmp/scratch 不是危险路径(不是 /、不是 ~/.bashrc),所以 Bash 不认为这是 safety 级别,返回 PASSTHROUGH("我没特殊意见,交给引擎")。
回到 _check_default,引擎检查这个 decision:
不是 ALLOW/DENY( _engine.py:166-170不命中)不是 safety ASK( _is_safety_ask返回 False,因为bypass_immune没置位,_engine.py:172)于是继续往下走
第 4 步 — allow 规则(_engine.py:180-182):查 allow_rules["Bash"]。假设用户配过 allow Bash: rm -rf /tmp/*,命中,返回 ALLOW。如果不配,返回 None,继续。
第 5 步 — 兜底 ASK(_engine.py:185-194):构造一个 ASK decision,附上 _generate_suggestions 生成的建议规则(如 "allow Bash: rm -rf /tmp/*"),返回。
步骤 3:agent 按 behavior 分支
拿到 decision 后,_execute_tool_call 按 behavior 三分支(agent/_agent.py:1659/1678/1690):
- ASK / PASSTHROUGH
( agent/_agent.py:1659-1675):把工具调用状态置为ASKING,把decision.suggested_rules塞进tool_call.suggested_rules,然后产出RequireUserConfirmEvent,立即 return——Agent 不阻塞,本轮就此结束。用户在外部确认后,通过UserConfirmResultEvent回填,重新驱动工具调用 - DENY
( agent/_agent.py:1678-1686):当错误工具调用处理,写入ToolResultState.DENIED,把decision.message作为工具结果回喂 LLM - ALLOW
( agent/_agent.py:1690起):进_acting真正执行工具
我们这次走的是 ASK 分支:Agent 产出确认事件挂起,等待用户拍板。
对比:把 rm -rf / 放到不同模式下会发生什么
同样一条 Bash(command="rm -rf /"),这次是真正的根目录。Bash 的 check_permissions 会识别这是极度危险路径,返回一个 behavior=ASK, bypass_immune=True 的 safety ASK。看不同模式如何处理这个 safety ASK(这正是 bypass_immune 发挥作用的地方):
| DEFAULT | 直接返回 ASK | _engine.py:172-177_is_safety_ask 命中即返回) | |
| ACCEPT_EDITS | 直接返回 ASK | _engine.py:329-334 | |
| EXPLORE | _engine.py:243-259 | ||
| BYPASS | 故意忽略 | _engine.py:409-417 | |
| DONT_ASK | _convert_ask_to_deny转成 DENY | _engine.py:486-491 |
这张表浓缩了整个模块的核心:同一个 safety ASK,五种模式给出四种不同处置(直接问用户 / 根本不调 / 忽略并放行 / 转成 DENY)。bypass_immune 这个布尔位就是穿过这张表的纵向线索。
六、如何开始调试源码
如果你要调试权限相关的行为,按这个顺序下断点最有效:
断点 1:看引擎分发到了哪条流水线
断点 _engine.py:104(mode = self.context.mode),观察 mode 值和随后进入的 _check_<mode>。如果你以为跑在 ACCEPT_EDITS 实际却是 DEFAULT,问题往往在这里就暴露。
断点 2:看工具的 check_permissions 返回了什么
断点 _engine.py:164(tool_decision = await tool.check_permissions(...)),观察返回的 tool_decision.behavior 和 tool_decision.bypass_immune。这是最容易出错的一步——很多"为什么没问用户"的 bug,根因是工具返回了 PASSTHROUGH 而非你预期的 ASK,或者 bypass_immune 没置位导致 safety ASK 被当成普通 ASK 让 allow 规则覆盖了。
断点 3:看规则匹配
断点 _engine.py:687(_execute_async_or_sync_func(tool.match_rule, ...)),观察 rule.rule_content、input_data、返回值。如果你的 deny 规则没生效,多半是 tool.match_rule 的匹配逻辑(子串/glob)和你的规则写法对不上。
断点 4:看 agent 如何处理 decision
断点 agent/_agent.py:1659(behavior 分支入口),观察 decision.behavior 走进哪个分支。如果你的 ASK 没弹确认框,检查是不是被当成了 PASSTHROUGH 直接 return。
最关键的对象:self.context(PermissionContext 实例)和 tool_decision(工具返回的 PermissionDecision)。前者装着所有规则和模式,后者装着工具的危险度判断——权限相关的 bug 99% 能从这两个对象的状态里看出来。
七、如何扩展这个模块
✅ 应该改(推荐路径)
新增一条用户规则:用 engine.add_rule(rule)(_engine.py:49-75),它会按 rule.behavior 自动归到 allow_rules / deny_rules / ask_rules 三个桶。这是最常用、最安全的扩展点。
给自定义工具加权限逻辑:重写 ToolBase.check_permissions(在 tool 侧,tool/_base.py:246,这是抽象方法)。返回 PermissionDecision(behavior=ASK, bypass_immune=True) 来标记你工具的危险操作。注意:bypass_immune 是工具侧设的,不是引擎侧——引擎只读取它。
切换运行场景:改 context.mode(_context.py:31)。例如定时任务启动前把模式置成 DONT_ASK(这正是 app/_manager/scheduler/_scheduler_manager.py:163 的做法)。
⚠️ 不应该改(有更优替代)
想改变某个模式的求值顺序:不要去改 _check_default 这些方法的步骤顺序。每个模式的顺序是它的契约,改了会破坏 docstring 里承诺的不变量。正确的做法是新增一个模式(见下)。
想认识具体工具的内部语义:不要在引擎里加 if tool.name == "Bash" 这种判断。把匹配逻辑写进工具的 match_rule、把危险判断写进工具的 check_permissions——引擎的工具无关性是它的核心价值。
🚫 千万不要改(动了会破坏不变量)
_is_safety_ask 的判定逻辑(_engine.py:550-573):它就是"behavior == ASK and bypass_immune"两条件。如果放宽它(比如允许 allow 规则覆盖 safety ASK),整个安全栅栏失效——rm -rf / 会被静默放行。
_check_dont_ask 的"never return ASK"不变量(_engine.py:431-507):这个方法每一条路径都必须返回 DENY 或 ALLOW,绝不返回 ASK。如果哪条新增路径漏了 _convert_ask_to_deny 转换,DONT_ASK 模式在无人值守时会抛出无人应答的确认事件,任务卡死。
BYPASS 对 bypass_immune 的故意忽略(_engine.py:409-417):这是设计者深思熟虑的红线。如果"修"成尊重 safety ASK,BYPASS 模式就不再是"完全信任",沙箱场景的契约被破坏。
新增一个模式
如果你要加一个新模式(比如"只允许特定工具"的白名单模式):
在 PermissionMode枚举加一个值(_types.py:81-85)实现 _check_xxx方法,docstring 里写死求值顺序在 check_permission分发器加一个分支(_engine.py:104-115)在 docstring 里声明你对 bypass_immune的处理策略
第 4 步最容易漏——新模式必须明确表态是尊重、忽略还是转 DENY,否则 safety 语义会出现断层。
八、本模块最值得学习的设计
1. bypass_immune:用最小数据结构编码最强安全语义
安全系统的一个永恒难题是:用户授权的规则 vs 不可降级的安全栅栏,怎么区分? 很多系统的解法是加权限层级、加角色、加复杂策略引擎。AgentScope 的解法很克制——给 ASK 加一个布尔位,True 表示"不可被 allow 规则覆盖"。
这一个比特穿透了 5 种模式,每种模式对它的差异化处理(尊重 / 不适用 / 故意忽略 / 转 DENY)共同构成了完整的安全语义。它没有用复杂的权限模型,而是用一个布尔位 + 每个模式的差异化分支,表达了"可配置但不可降级"。
这套思路可以原样搬到:审计日志的"不可删除"标记、合规检查的"不可跳过"标志、金额审批的"超阈值必须人工"开关——任何需要"普通规则可配置、但某类操作不可降级"的场景。
2. 模式独立流水线:把策略差异显式化
五个 _check_<mode> 方法是模板方法的克制变体。它没用继承(因为模式是运行时切换的),而是用方法分发。每个模式的求值顺序、对 safety ASK 的处理、兜底策略,都白纸黑字写在自己的 docstring 里。
如果用 if-else 巨型方法,"EXPLORE 不调 check_permissions"、"BYPASS 故意忽略 safety ASK"、"DONT_ASK 兜底是 DENY 而非 ASK"这些微妙差异会被埋进嵌套,根本 review 不出来。拆成五个方法后,每个模式的策略自成一篇,可独立审计。这是处理"同一接口、多种策略"的范本。
3. 匹配策略委托:引擎保持工具无关
_rule_matches(_engine.py:656-691)只保留"空规则匹配一切"这一个通用约定,其余全委托给 tool.match_rule。引擎因此零工具特化代码。这和上一篇 tool 模块的"权限检查内嵌进工具"是同一个设计哲学的两侧:工具提供事实判断,引擎做规则裁决。两者职责清晰,互不侵入。
4. PASSTHROUGH:显式化"交还决策权"
四元 behavior 比三元清晰得多。如果没有 PASSTHROUGH,工具要么返回强决策(剥夺引擎的规则匹配机会),要么引擎得猜"工具返回 None 是什么意思"。PASSTHROUGH 把"工具主动交还决策权"变成了一个一等公民的 behavior,让工具和引擎的协作契约完整无歧义。
5. DONT_ASK 的不变量守护
_check_dont_ask(_engine.py:431-507)的不变量是"永不返回 ASK"。它不是靠注释约束,而是靠 _convert_ask_to_deny(_engine.py:509-547)这个静态方法把所有本该 ASK 的路径机械地转成 DENY,同时保留 decision_reason 和 suggested_rules 以便事后追溯。把不变量编码进机械转换,而不是靠开发者自觉,这是可靠的工程做法。
📌 一个值得指出的小瑕疵:updated_input 是 dead field
PermissionDecision.updated_input(_decision.py:27-28)当前是"声明即未消费"的死字段。它的唯一写入点是 _check_allow_rules 命中时原样回传 input(_engine.py:652),但 agent 主循环在 ALLOW 分支(agent/_agent.py:1690起)用的是步骤 1 解析的 parsed_input,没有读 decision.updated_input。schema 预留了"引擎可以清洗/改写工具输入"的能力(比如规范化路径),但当前这条管道没接上。读源码时知道这点即可,不必当成设计缺陷——它可能是有意为未来扩展预留的接口。
九、阅读建议
源码阅读顺序:
- 先读
_types.py:18-85—— PermissionMode的 docstring 是官方设计文档,那张 ASCII 表把 5 种模式的意图讲透了。先建立"为什么有 5 种模式"的整体认知。 - 再读
_decision.py:33-68—— bypass_immune的 docstring 把整套安全栅栏语义讲透了,逐字读。它会告诉你四种模式如何处理这个字段。 - 然后读
_engine.py的 check_permission(77-115)——看分发器,理解"每模式一条流水线"的结构。 - 挑一个模式深读,推荐 DEFAULT(117-194)
——它最完整,5 步求值顺序是理解其它模式的基础。 - 对比阅读 BYPASS(353-429)和 DONT_ASK(431-507)
——这两个是 safety ASK 处理的两个极端(忽略 vs 转 DENY),对比着读最能体会 bypass_immune在不同模式下的作用。 - 最后扫
_context.py/_rule.py——数据容器,逻辑简单,知道规则按 behavior 分桶即可。
可以跳过的地方:_engine.py:509-547 的 _convert_ask_to_deny 第一次读知道它是"把 ASK 机械转 DENY"就够了,细节按需查阅。_engine.py:575-654 的三个 _check_<x>_rules 方法模式高度重复,读一个即可。
十、阅读完成以后
读完这篇,你应该能回答:
- 为什么需要这个模块
:工具自己知道"危不危险",但"在什么场景下怎么处理"需要统一的模式策略;安全栅栏需要不可被规则降级的语义,这些 permission 模块集中承载。 - 为什么这样设计
:模式与规则正交(宽窄分离)、每模式独立流水线(策略可审计)、 bypass_immune用最小结构编码最强语义、匹配委托给工具(引擎工具无关)。 - 代码怎么运行
:agent 主循环单点调用 check_permission→ 按模式分发到五条流水线之一 → 流水线内按固定顺序查 deny/ask 规则、调工具的 check_permissions/read_only、查 allow 规则、兜底 → 返回 decision → agent 按 behavior 分支处理。 - 如果要改,改哪里
:加规则用 add_rule;给工具加权限逻辑重写check_permissions(工具侧);切换场景改context.mode;加新模式仿照_check_<mode>+ 分发器分支。不要碰_is_safety_ask、_check_dont_ask的不变量、BYPASS 对 bypass_immune 的处理。
真正应该带走的:用最小的数据结构(一个布尔位)表达最强的安全语义,让可配置的规则无法跨越它——这套「在数据里编码安全语义」的思路,是 permission 模块给所有需要"可配置但不可降级"控制的系统的示范。
留两个问题
你平时跑 Agent 用过哪几种权限模式?有没有踩过"明明配了 allow 规则却还是被问"或者"BYPASS 模式下以为安全其实被静默放行"的坑?
想深入的同学再想一个:如果你要让自己的 Agent 跑一个无人值守的定时任务(DONT_ASK 模式),但任务里必须包含一次文件写入——你会怎么配规则,让写入能通过、又不会破坏 DONT_ASK 的安全不变量?
觉得有用?点个「在看」或转发给同样在搞 Agent 的朋友。系列持续更新,关注不迷路。
附录:源码元信息
Repository:https://github.com/agentscope-ai/agentscope.git
Branch:v2.0.4
夜雨聆风