第 7 章 别让它乱删东西
本章代码
code/ch07/main.py· 离线实验code/ch07/probe_modes.py· 读完约 18 分钟
7.0 上一章留下的问题
第 5 章我们把工具关进工作区,路径被限制在 ./sandbox 里;第 6 章我们学会了自己写工具,check_permissions 也照着填了。
但那个 check_permissions 当时是这么写的:
return PermissionDecision(
behavior=PermissionBehavior.ALLOW,
message="build_report 是只读工具,自动放行。",
)相当于门卫上班第一天就把闸机抬起来焊死了。
现实里的 assistant 不是这样的。它要建文件、改文件、跑命令。工作区管得住"路径不能越界",管不住"在工作区内可以随便删"。rm -rf ./src 完全合法——路径在内,命令合规,结果灾难。
这一章回答三个问题:
- 框架给操作分了几个危险等级?
- 谁在什么时候做判定,判定顺序是什么?
- 需要人类点头的时候,一轮对话怎么暂停、怎么继续?
7.1 先把"危险"这件事拆开
初学者容易把权限理解成一个开关:要么全放,要么全拦。真实系统里至少有三个互相独立的维度:
| 维度 | 回答的问题 | 举例 |
|---|---|---|
| 操作类型 | 这个动作改不改东西? | 读文件 vs 写文件 |
| 作用对象 | 改的是哪一条? | 工作区内的 notes.md vs ~/.bashrc |
| 当前场景 | 现在是有人看着,还是无人值守? | 你在终端里敲回车 vs 凌晨三点的定时任务 |
三者的组合决定了答案,所以不能用一个布尔量表示。AgentScope 用了两个结构来描述这件事:
- 模式(mode):粗粒度的默认策略,只有五个取值
- 规则(rule):细粒度的例外,写多少条都行
先用一句话记住它们的关系:规则是例外,模式是兜底;例外优先于兜底,但有一类安全判定谁都盖不住。
7.2 五种模式
PermissionMode 只有五个取值,枚举名与字符串值如下:
PermissionMode.DEFAULT # "default"
PermissionMode.ACCEPT_EDITS # "accept_edits"
PermissionMode.EXPLORE # "explore"
PermissionMode.BYPASS # "bypass"
PermissionMode.DONT_ASK # "dont_ask"下面的表格来自框架源码各处 _check_* 方法的文档字符串,是行为规范而不是概括:
表 7-1 五种权限模式
| 模式 | 只读操作 | 工作区内写入 | 工作区外写入 | 高危命令(rm -rf /) | 适用场景 |
|---|---|---|---|---|---|
DEFAULT | 放行 | 询问 | 询问 | 询问,且不可被规则覆盖 | 日常交互 |
ACCEPT_EDITS | 放行 | 放行 | 询问 | 询问,且不可被规则覆盖 | 你正在盯着改代码 |
EXPLORE | 放行 | 拒绝 | 拒绝 | 拒绝 | 只想让它读、不想让它写 |
BYPASS | 放行 | 放行 | 放行 | 放行(跳过安全询问) | 一次性容器 / 沙箱 |
DONT_ASK | 放行 | 放行 | 拒绝 | 拒绝(询问转拒绝) | 定时任务、后台跑 |
三条容易看错的地方:
第一,DEFAULT 下写工作区内也要问。 很多人以为"在工作区里就安全",框架不这么认为。工作区的边界只管住"不许出去",不管"里面能不能动手"。
第二,EXPLORE 不查 allow 规则。 源码注释写得很直接:只读保证不能被你配置的规则让渡出去。哪怕你写了 allow: Bash *,在 EXPLORE 下照样拒绝。
第三,DONT_ASK 永远不会返回"询问"。 它的不变量是:任何本该弹出询问的路径,一律转换为拒绝。这是为无人值守准备的——没有人能回答,那就当作不允许。
至于 BYPASS,源码里专门写了一句提醒:它会把所有工具发出的安全询问跳过,包括 rm -rf /、写 ~/.bashrc、命令注入这些。仅用于容器或你完全信任行为的场合。如果只是想让程序无人值守地跑,应该用 DONT_ASK,不是 BYPASS。
7.3 判定的真实顺序
新手最容易误解的一点是:以为先看模式,模式说问就弹窗。实际顺序正好相反——模式排在最后一位。

图 7-1 权限判定的六级过滤——顺序本身即安全设计
以 DEFAULT 为例,六个步骤,任何一步给出结论就立即返回:
- deny 规则命中 → 拒绝
- ask 规则命中 → 询问
check_read_only()为真 → 放行(只读快通道)- 工具自己的
check_permissions():- 返回放行或拒绝 → 直接采纳
- 返回安全询问(
bypass_immune=True)→ 直接采纳,allow 规则无法覆盖 - 返回普通询问或
PASSTHROUGH→ 继续往下
- allow 规则命中 → 放行
- 模式默认值 →
DEFAULT下为询问
第 4 步里那个 bypass_immune 是整章最值得记住的字段。它的含义是"这条判定不接受豁免"。Bash 工具在四种情况下会打上这个标记:命令含无法静态分析的动态展开、命中危险命令模式、sed 就地修改敏感文件、目标路径落在系统关键目录。
所以你写这样一条规则:
engine.add_rule(PermissionRule(tool_name="Bash", rule_content="rm -rf", behavior=ALLOW))看起来给了 rm -rf 通行证,实际上第 4 步就把它拦下了。这不是 bug,是设计:能让你一键放行一切的安全机制,等于没有安全机制。
顺带说一个版本差异。上面这套丰富的静态分析在 Bash 工具里;Windows 上工作区给你的是 PowerShell 工具,它的自检弱得多,多数命令直接交给引擎兜底。这意味着在 Windows 的 DEFAULT 模式下,连 ls 都会问你一句。这不是配置错误,是平台实现差异——真要做细粒度放行,就用规则把它压下去。
7.4 规则怎么写
一条规则是四个字段:
from agentscope.permission import (
PermissionBehavior,
PermissionContext,
PermissionEngine,
PermissionRule,
)
rule = PermissionRule(
tool_name="Write", # 工具名,规则按它分组存放
rule_content="src/**", # 匹配内容,语义随工具而变
behavior=PermissionBehavior.ALLOW,
source="user", # 来源标记,便于审计
)rule_content 的语义不是统一的,这一点必须按工具分别记忆:
| 工具 | rule_content 的含义 | 示例 |
|---|---|---|
Bash / PowerShell | 命令字符串的子串匹配 | "npm install" 命中 npm install express |
Read / Write / Edit | 文件路径的 glob | "src/**" 命中 src/main.py |
| 其他工具 | 工具自定义的过滤串 | 由工具自己解释 |
留空 "" | 匹配一切 | 等价于"这个工具全放行" |
注意最后一行。rule_content="" 是一把万能钥匙,第 7.5 节的"始终允许"就是靠它实现的——用户按下 a 之后,我们给他写的就是一条空内容的 allow 规则。
规则挂在 PermissionContext 上,而这个结构的三个规则容器都是按工具名分组的字典:
PermissionContext(
mode=PermissionMode.DEFAULT,
working_directories={}, # dict
allow_rules={}, # dict[str, list[PermissionRule]]
deny_rules={},
ask_rules={},
)所以不要写 context.allow_rules.append(rule),那会报 AttributeError: 'dict' object has no attribute 'append'。正确做法是用引擎的 add_rule(),它负责分组:
engine = PermissionEngine(agent.state.permission_context)
engine.add_rule(rule)PermissionEngine 是无状态的薄壳,构造时只保存一个 context 引用。也就是说,通过引擎加的规则直接写进了 Agent 的状态,下一次判定立刻生效。
7.5 人在回路:暂停与续跑
判定结果是"询问"之后,框架不会自己弹窗。它做的是:往事件流里抛一个 RequireUserConfirmEvent,然后停下来。
这个"停下来"很关键。不是这一轮回复结束了,而是异步生成器停在了中间——模型的上下文、已经产生的工具调用、reply_id,全都还活着。

图 7-2 人在回路的暂停与续跑——两次调用,同一个 reply_id
代码上就是一个 break 加一个 while True:
async def run_with_confirmation(agent: Agent, question: str) -> None:
inputs = UserMsg(name="user", content=question)
while True:
pending = None
async for event in agent.reply_stream(inputs):
if event.type == EventType.REQUIRE_USER_CONFIRM:
pending = event
break # 事件流在这里断掉
render(event)
if pending is None: # 没有待确认项,这一轮真的结束了
return
results = []
for tool_call in pending.tool_calls:
print(f"需要确认:{tool_call.name} 参数:{tool_call.input}")
answer = input("允许执行?[y] 允许 / [n] 拒绝 / [a] 始终允许 > ").strip().lower()
rules = []
if answer == "a":
rule = PermissionRule(
tool_name=tool_call.name,
rule_content="",
behavior=PermissionBehavior.ALLOW,
source="user",
)
PermissionEngine(agent.state.permission_context).add_rule(rule)
rules.append(rule)
results.append(
ConfirmResult(
confirmed=answer in ("y", "a"),
tool_call=tool_call,
rules=rules,
),
)
inputs = UserConfirmResultEvent(
reply_id=pending.reply_id, # 靠它把续跑接回原处
confirm_results=results,
)四个对象的职责要分清,写错了会直接抛校验错误:
| 对象 | 字段 | 谁产生 |
|---|---|---|
RequireUserConfirmEvent | reply_id、tool_calls | 框架 |
ConfirmResult | confirmed、tool_call、rules | 你 |
UserConfirmResultEvent | reply_id、confirm_results | 你 |
PermissionRule | tool_name、rule_content、behavior、source | 你(可选) |
ConfirmResult.tool_call 是必填的,不接受 None。因为它是一个 ToolCallBlock,里面带着 id,框架要靠这个 id 把"批准"对应到具体哪一次调用。一次询问可能同时包含多个工具调用(模型并行发起),所以 confirm_results 是列表,逐条对应。
rules 字段的用途是"这次批准要记住"。框架会把它合并进上下文,效果和你手动调 add_rule() 一样。两者都写也不会重复——但没必要,选一个即可。
7.6 一个不需要模型的实验
权限判定完全是本地计算,不碰模型。这意味着你可以把上面那张表亲眼跑出来,而且不花一分钱 token。
code/ch07/probe_modes.py 直接调用 PermissionEngine,喂进三段命令,看五种模式的输出:
tool = Bash()
for mode in PermissionMode:
engine = PermissionEngine(PermissionContext(mode=mode))
for command in ("ls -la", "rm -rf /", "echo hi >> ~/.bashrc"):
decision = await engine.check_permission(tool, {"command": command})真实输出(Windows,agentscope 2.0.8):
mode command decision immune reason
----------------------------------------------------------------------------------------------------
default ls -la allow False Permission granted for Bash (read-on
default rm -rf / ask True Permission required: Command contain
default echo hi >> ~/.bashrc ask True Permission required: Bash command op
accept_edits ls -la allow False Permission granted for Bash (read-on
accept_edits rm -rf / ask True Permission required: Command contain
accept_edits echo hi >> ~/.bashrc ask True Permission required: Bash command op
explore ls -la allow False Permission granted for Bash (read-on
explore rm -rf / deny False Permission denied for Bash (explore
explore echo hi >> ~/.bashrc deny False Permission denied for Bash (explore
bypass ls -la allow False Permission granted for Bash (read-on
bypass rm -rf / allow False Permission granted for Bash (bypass
bypass echo hi >> ~/.bashrc allow False Permission granted for Bash (bypass
dont_ask ls -la allow False Permission granted for Bash (read-on
dont_ask rm -rf / deny False Permission denied for Bash (dont_a
dont_ask echo hi >> ~/.bashrc deny False Permission denied for Bash (dont_a对照表 7-1 逐行看,行为完全一致。特别注意 immune 那一列:只有 default 和 accept_edits 下的高危命令是 True。到了 explore 和 dont_ask,判定已经在更前面变成了拒绝,bypass_immune 也就无从谈起了。
脚本的第二部分是规则实验,它会打出这样的结果:
已写入 allow 规则:Bash / rm -rf
rm -rf / -> ask immune=True Permission required: Command contains da
rm -rf ./build -> ask immune=True Permission required: Command contains da
ls -la -> deny (deny 规则压过只读快通道)
Write default -> ask Permission required for Write
Write accept_edits -> allow Permission granted for writing C:\Users\xion...三行结论,每一行都值得停一下:
- 写了 allow 规则,
rm -rf /依然询问。这是bypass_immune在起作用。 rm -rf ./build也依然询问。危险模式的判定是按命令形态走的,不区分目标目录。ls -la被判成拒绝,因为后加的 deny 规则排在第 1 位,压过了第 3 位的只读快通道。- 同一个
Write,工作区内的路径在default下询问、在accept_edits下放行。
第 2 条带来一个实际结论:不要指望用 allow 规则给 rm 开后门。 如果你的业务确实需要频繁删除临时目录,正确的做法是写一个专用的 clean_temp_dir 工具,让它只接受一个白名单内的子目录名——用工具收窄能力,而不是用规则放宽权限。
7.7 三个常见错误
错误 1:把 PermissionContext 当成普通容器
context.allow_rules.append(rule) # AttributeError: 'dict' object has no attribute 'append'三个规则容器都是"工具名 → 规则列表"的字典。用 PermissionEngine(context).add_rule(rule),别自己往里塞。
错误 2:续跑时忘了带 reply_id
inputs = UserConfirmResultEvent(confirm_results=results) # 缺 reply_id这个字段的作用是把续跑接回原处。缺了它,框架找不到对应的回复上下文,表现为状态混乱或直接报错。正确写法永远是 reply_id=pending.reply_id——从那个让你 break 的事件里取。
错误 3:在 while 里重新构造 UserMsg
while True:
async for event in agent.reply_stream(UserMsg(...)): # 每一轮都是新问题续跑的输入必须是 UserConfirmResultEvent,不是又发一遍原始问题。发原始问题会让模型以为用户在重复提问,然后重新规划一遍,可能又发起同一个工具调用,形成死循环。
7.8 小结
- 模式是粗粒度默认策略,规则是细粒度例外;规则先于模式
- 判定有六个步骤,deny 规则排第一,模式排最后
check_read_only()是快通道,但 deny / ask 规则能压过它bypass_immune=True的安全询问不接受 allow 规则豁免,这是防护的底线EXPLORE不查 allow 规则,DONT_ASK永不返回询问,BYPASS跳过全部安全询问- 询问用
break暂停生成器,用reply_id续跑,不是重新发起对话 - 规则容器是按工具名分组的字典,用
add_rule()写入
到这里,Buddy 已经会干活、会守边界、也知道什么时候该问人。但它的记忆只活在进程内存里——关掉终端,一切归零。
下一章要解决的问题:一轮对话跑了几十步、上下文涨到几万 token,怎么在有限窗口里活下去?关掉再打开,怎么接着上一句聊?
