乐于分享
好东西不私藏

AgentScope 2.0源码解析系列第六篇 Permission模块(HITL / 工具权限控制)

AgentScope 2.0源码解析系列第六篇 Permission模块(HITL / 工具权限控制)

本文是AgentScope 2.0 源码解析系列 第 6 篇(Permission 模块)。

已发:Agent、Event、Message、Model、tool | 下一篇:Middleware

关注追更,后续会持续更新 Memory、Pipeline、MCP 等模块。

读完这篇你会带走这些源码层面的结论:

  • permission 模块只有 1 个引擎 PermissionEngine5 种模式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 并列第四种 behavior
  • DONT_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 真正跑起来,至少还要回答这几个问题:

  1. 默认情况下,每次工具调用都问用户一遍吗?还是给个白名单自动放行?
  2. 用户在不在场,决定了能不能弹确认框——"用户盯着屏幕快速迭代"、"Agent 跑无人值守的定时任务"、"Agent 在沙箱里跑可以完全信任",这几种场景的权限策略天差地别,怎么用一套机制覆盖?
  3. rm -rf /
     这种操作,即使用户配了"允许所有 Bash"的宽松规则,也不该被静默放行——"用户授权的规则"和"不可降级的安全栅栏"怎么区分
  4. Bash 的 npm install、Write 的 src/**、其它工具的过滤模式,匹配逻辑完全不同,引擎要不要认识每一种?
  5. 工具被 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_permissionagent/_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)的顺序是:

  1. deny 规则 → DENY
  2. ask 规则 → ASK(带建议)
  3. tool.check_permissions
     → ALLOW/DENY 直接返回;安全 ASK 不可被覆盖;其它继续
  4. allow 规则 → ALLOW
  5. 兜底 → 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:170Field(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 自动归桶,查询时只取对应工具的列表,不用全表扫描

四、跟着我阅读源码

文件优先级(从必读到可跳过):

  1. _engine.py(必读)
    ——模块的大脑,5 条模式流水线全在这里。整篇文章的篇幅主要花在这一个文件上。
  2. _decision.py(必读)
    ——PermissionDecision 的字段语义,尤其 bypass_immune 的 docstring(_decision.py:33-68)是理解整套安全栅栏的钥匙。
  3. _types.py(必读)
    ——PermissionMode 的 docstring(_types.py:18-79)用一张表把 5 种模式的语义和适用场景讲透了,是官方设计文档。
  4. _context.py / _rule.py(按需查阅)
    ——数据容器,逻辑简单。

下面是字段表。第一次读只需精读 PermissionDecision 一张bypass_immune 全在这里),其余四张按需查阅。

4.1 PermissionDecision(核心,必读)

定义于 _decision.py:10-68。这是引擎的输出,也是 agent 主循环分支的依据。每个字段都值得看清:

字段
类型
默认值
含义
behaviorPermissionBehavior
—(必填)
裁决结果:ALLOW / DENY / ASK / PASSTHROUGH
messagestr
—(必填)
给人看的裁决说明,DENY 时会作为工具结果回喂 LLM
decision_reasonstr \| NoneNone
为什么这么裁决(如 "Rule: npm install""Mode: default"
updated_inputdict \| NoneNone
预留的"引擎改写后的输入"——当前无人消费,详见第八节
suggested_ruleslist \| NoneNone
给用户的建议规则,ASK 时附上,用户确认时可一键落库
bypass_immuneboolFalse安全栅栏开关
:True 表示此 ASK 不可被 allow 规则覆盖,连 BYPASS 都专门处理它

bypass_immune 字段的 docstring(_decision.py:33-68)是整个模块最值得逐字读的注释——它详细列出了四种模式对该字段的处理差异,比本文第三节写的还细。建议直接打开源码读一遍。

4.2 PermissionMode(按需查阅)

定义于 _types.py:18-85。五个枚举值,对应五种运行场景:

字符串
适用场景(来自 docstring)
DEFAULT"default"
默认最严:除非 allow 规则命中或工具自己 ALLOW,否则都问用户
ACCEPT_EDITS"accept_edits"
用户在场快速迭代:工作目录内的读写、文件系统命令自动放行
EXPLORE"explore"
只读探查:只允许只读工具和只读命令,修改一律拒绝
BYPASS"bypass"
沙箱/完全信任:跳过所有安全检查(包括 safety ASK),仅 deny/ask 规则兜底
DONT_ASK"dont_ask"
无人值守定时任务:把所有本该 ASK 的路径强制转 DENY,永不问用户

_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_namestr
—(必填)
规则作用于哪个工具(如 "Bash""Write"
rule_contentstr \| None
—(必填)
过滤模式:Bash 是子串、Write/Read 是 glob、None 表示工具名级(匹配一切)
behaviorPermissionBehavior
—(必填)
allow / deny / ask
sourcestr
—(必填)
规则来源(如 "userSettings""projectSettings""suggested"

rule_content 的语义取决于 tool_name_rule.py:13-22)——这是为什么匹配逻辑必须委托给工具的根本原因。

4.5 PermissionContext(按需查阅)

定义于 _context.py:24-46。引擎的运行时状态:

字段
类型
默认值
含义
modePermissionModeDEFAULT
当前权限模式
working_directoriesdict{}
ACCEPT_EDITS 模式下自动放行的额外工作目录,按路径索引
allow_rulesdict{}
allow 规则,按工具名分桶
deny_rulesdict{}
deny 规则,按工具名分桶
ask_rulesdict{}
ask 规则,按工具名分桶

三种规则按 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_callagent/_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_permissionstool/_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 发挥作用的地方):

模式
求值到哪一步
safety ASK 的命运
依据
DEFAULT
第 3 步
直接返回 ASK
,allow 规则无法覆盖
_engine.py:172-177
_is_safety_ask 命中即返回)
ACCEPT_EDITS
第 4 步(同 DEFAULT)
直接返回 ASK
,allow 规则无法覆盖
_engine.py:329-334
EXPLORE
第 3 步(read-only 裁决)
不适用——EXPLORE 压根不调 check_permissions,Bash 非只读直接 DENY
_engine.py:243-259
BYPASS
第 3 步
故意忽略
 safety ASK,继续走 allow 规则,最终 fallback ALLOW
_engine.py:409-417
(注释明说 intentionally NOT honored)
DONT_ASK
第 3 步
safety ASK 被 _convert_ask_to_deny转成 DENY
_engine.py:486-491

这张表浓缩了整个模块的核心:同一个 safety ASK,五种模式给出四种不同处置(直接问用户 / 根本不调 / 忽略并放行 / 转成 DENY)。bypass_immune 这个布尔位就是穿过这张表的纵向线索。


六、如何开始调试源码

如果你要调试权限相关的行为,按这个顺序下断点最有效:

断点 1:看引擎分发到了哪条流水线

断点 _engine.py:104mode = self.context.mode),观察 mode 值和随后进入的 _check_<mode>。如果你以为跑在 ACCEPT_EDITS 实际却是 DEFAULT,问题往往在这里就暴露。

断点 2:看工具的 check_permissions 返回了什么

断点 _engine.py:164tool_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_contentinput_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 模式就不再是"完全信任",沙箱场景的契约被破坏。

新增一个模式

如果你要加一个新模式(比如"只允许特定工具"的白名单模式):

  1. 在 PermissionMode 枚举加一个值(_types.py:81-85
  2. 实现 _check_xxx 方法,docstring 里写死求值顺序
  3. 在 check_permission 分发器加一个分支(_engine.py:104-115
  4. 在 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 预留了"引擎可以清洗/改写工具输入"的能力(比如规范化路径),但当前这条管道没接上。读源码时知道这点即可,不必当成设计缺陷——它可能是有意为未来扩展预留的接口。

九、阅读建议

源码阅读顺序:

  1. 先读 _types.py:18-85
    ——PermissionMode 的 docstring 是官方设计文档,那张 ASCII 表把 5 种模式的意图讲透了。先建立"为什么有 5 种模式"的整体认知。
  2. 再读 _decision.py:33-68
    ——bypass_immune 的 docstring 把整套安全栅栏语义讲透了,逐字读。它会告诉你四种模式如何处理这个字段。
  3. 然后读 _engine.py 的 check_permission(77-115)
    ——看分发器,理解"每模式一条流水线"的结构。
  4. 挑一个模式深读,推荐 DEFAULT(117-194)
    ——它最完整,5 步求值顺序是理解其它模式的基础。
  5. 对比阅读 BYPASS(353-429)和 DONT_ASK(431-507)
    ——这两个是 safety ASK 处理的两个极端(忽略 vs 转 DENY),对比着读最能体会 bypass_immune 在不同模式下的作用。
  6. 最后扫 _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