Skip to content

第 7 章 别让它乱删东西 ​

本章代码 code/ch07/main.py · 离线实验 code/ch07/probe_modes.py · 读完约 18 分钟

7.0 上一章留下的问题 ​

第 5 章我们把工具关进工作区,路径被限制在 ./sandbox 里;第 6 章我们学会了自己写工具,check_permissions 也照着填了。

但那个 check_permissions 当时是这么写的:

python
return PermissionDecision(
    behavior=PermissionBehavior.ALLOW,
    message="build_report 是只读工具,自动放行。",
)

相当于门卫上班第一天就把闸机抬起来焊死了。

现实里的 assistant 不是这样的。它要建文件、改文件、跑命令。工作区管得住"路径不能越界",管不住"在工作区内可以随便删"。rm -rf ./src 完全合法——路径在内,命令合规,结果灾难。

这一章回答三个问题:

  1. 框架给操作分了几个危险等级?
  2. 谁在什么时候做判定,判定顺序是什么?
  3. 需要人类点头的时候,一轮对话怎么暂停、怎么继续?

7.1 先把"危险"这件事拆开 ​

初学者容易把权限理解成一个开关:要么全放,要么全拦。真实系统里至少有三个互相独立的维度:

维度回答的问题举例
操作类型这个动作改不改东西?读文件 vs 写文件
作用对象改的是哪一条?工作区内的 notes.md vs ~/.bashrc
当前场景现在是有人看着,还是无人值守?你在终端里敲回车 vs 凌晨三点的定时任务

三者的组合决定了答案,所以不能用一个布尔量表示。AgentScope 用了两个结构来描述这件事:

  • 模式(mode):粗粒度的默认策略,只有五个取值
  • 规则(rule):细粒度的例外,写多少条都行

先用一句话记住它们的关系:规则是例外,模式是兜底;例外优先于兜底,但有一类安全判定谁都盖不住。

7.2 五种模式 ​

PermissionMode 只有五个取值,枚举名与字符串值如下:

python
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 为例,六个步骤,任何一步给出结论就立即返回:

  1. deny 规则命中 → 拒绝
  2. ask 规则命中 → 询问
  3. check_read_only() 为真 → 放行(只读快通道)
  4. 工具自己的 check_permissions():
    • 返回放行或拒绝 → 直接采纳
    • 返回安全询问(bypass_immune=True)→ 直接采纳,allow 规则无法覆盖
    • 返回普通询问或 PASSTHROUGH → 继续往下
  5. allow 规则命中 → 放行
  6. 模式默认值 → DEFAULT 下为询问

第 4 步里那个 bypass_immune 是整章最值得记住的字段。它的含义是"这条判定不接受豁免"。Bash 工具在四种情况下会打上这个标记:命令含无法静态分析的动态展开、命中危险命令模式、sed 就地修改敏感文件、目标路径落在系统关键目录。

所以你写这样一条规则:

python
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 规则怎么写 ​

一条规则是四个字段:

python
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 上,而这个结构的三个规则容器都是按工具名分组的字典:

python
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(),它负责分组:

python
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:

python
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,
        )

四个对象的职责要分清,写错了会直接抛校验错误:

对象字段谁产生
RequireUserConfirmEventreply_id、tool_calls框架
ConfirmResultconfirmed、tool_call、rules你
UserConfirmResultEventreply_id、confirm_results你
PermissionRuletool_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,喂进三段命令,看五种模式的输出:

python
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):

text
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 也就无从谈起了。

脚本的第二部分是规则实验,它会打出这样的结果:

text
已写入 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...

三行结论,每一行都值得停一下:

  1. 写了 allow 规则,rm -rf / 依然询问。这是 bypass_immune 在起作用。
  2. rm -rf ./build 也依然询问。危险模式的判定是按命令形态走的,不区分目标目录。
  3. ls -la 被判成拒绝,因为后加的 deny 规则排在第 1 位,压过了第 3 位的只读快通道。
  4. 同一个 Write,工作区内的路径在 default 下询问、在 accept_edits 下放行。

第 2 条带来一个实际结论:不要指望用 allow 规则给 rm 开后门。 如果你的业务确实需要频繁删除临时目录,正确的做法是写一个专用的 clean_temp_dir 工具,让它只接受一个白名单内的子目录名——用工具收窄能力,而不是用规则放宽权限。

7.7 三个常见错误 ​

错误 1:把 PermissionContext 当成普通容器

python
context.allow_rules.append(rule)      # AttributeError: 'dict' object has no attribute 'append'

三个规则容器都是"工具名 → 规则列表"的字典。用 PermissionEngine(context).add_rule(rule),别自己往里塞。

错误 2:续跑时忘了带 reply_id

python
inputs = UserConfirmResultEvent(confirm_results=results)   # 缺 reply_id

这个字段的作用是把续跑接回原处。缺了它,框架找不到对应的回复上下文,表现为状态混乱或直接报错。正确写法永远是 reply_id=pending.reply_id——从那个让你 break 的事件里取。

错误 3:在 while 里重新构造 UserMsg

python
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,怎么在有限窗口里活下去?关掉再打开,怎么接着上一句聊?

教程正文采用 CC BY-NC-SA 4.0 许可,code/ 下示例代码可自由使用