跳到主要内容

数据截至 (上游 commit 3a4e2ae3eec0)

第 2 章 · 工具体系与权限引擎

这一章讲:工具怎么注册和分组、一次工具调用要过几道关、以及 AgentScope 怎么判断一条 Bash 命令危不危险。


2.1 一次工具调用要过几道关

先看全景。_execute_tool_callsrc/agentscope/agent/_agent.py:2238)是唯一入口,五道关:

模型给的 tool_call

① 可用性 toolkit.check_tool_available —— 工具存在吗?所在的组激活了吗?

② 入参 _json_loads_with_repair 修复畸形 JSON → jsonschema.validate 校验

③ 权限 _check_permission —— 过中间件链 → PermissionEngine 仲裁
│ ├─ ASK ──► 发确认事件,挂起(第 1 章)
│ └─ DENY ──► 写一条 DENIED 结果,继续
④ 执行 _acting → toolkit.call_tool —— 统一成 ToolChunk 流

⑤ 收尾 超长结果切分卸载 → 写回上下文 → 状态置 FINISHED

每一关失败都不会抛异常给开发者,而是把错误当成工具结果喂回给模型。这类异常有专门的类型 AgentOrientedException(面向 agent 的),与 DeveloperOrientedException(面向开发者的、要真抛出去的)区分开。两个类定义在 src/agentscope/exception/_base.py:5:21;分流点是 call_tool 的兜底 except 段——只有开发者向的异常原样重抛,其余一律转成一条 ERROR 状态的工具结果。

依据:src/agentscope/tool/_toolkit.py:352-355

except Exception as e:
# Raise the developer-oriented exception
if isinstance(e, DeveloperOrientedException):
raise e from None

2.2 Toolkit:工具组与「元工具」

三类东西,一个入口

Toolkitsrc/agentscope/tool/_toolkit.py:66)统一管三种能力来源:

来源是什么怎么给模型
工具ToolBase 子类直接进 tools schema
MCP 服务器MCPClient拉取远端工具列表,转成本地工具
技能(skill)一个目录:SKILL.md + 脚本不进 tools,只在系统提示词里列名字和描述

技能这条路线值得单说。DEFAULT_SKILL_INSTRUCTION 模板(src/agentscope/tool/_toolkit.py:51-63)里写死了一句:

IMPORTANT: Skills are NOT tools, and you cannot call a skill directly.

模型要用技能,得先调 SkillViewer 工具把 SKILL.md 读出来,再照着里面的说明去用别的工具。这是典型的渐进式披露:技能的完整指令不常驻上下文,只在需要时按需加载。

工具组:让 agent 自己开关工具

工具太多会稀释模型注意力。ToolGroupsrc/agentscope/tool/_tool_group.py:10)把工具分组,默认只激活 basic 组;其余组由模型调用内置元工具 ResetTools 自行激活。

Toolkit
├── basic 组(永远激活)── 构造函数里的 tools / mcps / skills
├── 组 A(描述:处理 Excel 相关任务)── 未激活
└── 组 B(描述:数据库查询) ── 未激活

└── 模型调用 ResetTools(groups=["B"]) 后激活

组被激活时,组的 instructions 会随元工具的返回值一起注入(模板见 src/agentscope/tool/_toolkit.py:44-48)。所以「激活一组工具」同时也是「加载一段使用说明」。

没激活就调该组的工具会怎样?check_tool_available:552)抛 ToolGroupInactiveError,错误信息直接告诉模型该先调哪个工具(:581-585)——错误信息本身就是给模型的指令

统一成流

call_tool:225)把工具的四种返回形态归一:

工具返回什么处理
单个 ToolChunk直接 yield,累加
异步生成器逐块 yield,逐块累加
同步生成器同上
其它DeveloperOrientedException

最后 finally 里必定 yield 一个完整的 ToolResponse:386-388)。所以调用方永远能靠「最后一个是 ToolResponse」判断结束——异常路径也不例外。


2.3 并发批次:哪些工具能一起跑

模型一次可能吐 5 个工具调用。全串行太慢,全并发会互相踩。

_batch_tool_callssrc/agentscope/agent/_agent.py:1903)按工具的 is_concurrency_safe 属性保序分段

调用序列: Read Grep Write Edit Glob
安全性: safe safe unsafe unsafe safe
└──┬──┘ └───┬──┘ └┬┘
并发批次1 串行批次2 并发批次3
(一起跑) (一个个跑) (一起跑)

三条设计细节:

  • 保序:批次之间严格按模型给出的顺序执行,不重排。写操作的先后语义得以保留。
  • 未注册的工具算「安全」:1769-1771),理由写在注释里:它根本跑不起来,不会有副作用。
  • 串行批次里一旦遇到需要确认或被中断,立刻停下_execute_sequential_tool_calls:1794),后面的不跑。

并发批次里的确认去重

5 个并发的 Read 全都要确认,弹 5 个框,用户会疯。kept_rules 就是解决这个的(:1905-1911 定义,:2206-2222 使用):

工人1 触发 ASK → 把它的「建议规则」记进 kept_rules → 弹框
工人2 触发 ASK → 先拿自己的入参去匹配 kept_rules
├─ 匹配上 → 不弹框,保持 PENDING,等下一轮重新评估
└─ 没匹配 → 记录并弹框

用户对第一个框点了「允许 src/** 」并加规则后,第二个调用下一轮直接被规则放行。

但安全性 ASK 不去重:2202-2206):

is_safety_ask = (
decision.behavior == PermissionBehavior.ASK
and decision.bypass_immune
)
if kept_rules is not None and not is_safety_ask:
...

因为 allow 规则本来就压不住安全性 ASK,去重会让某个危险操作被静默跳过。下一节讲 bypass_immune 到底是什么。


2.4 权限:两层分工

分工

回答什么问题不知道什么
工具层ToolBase.check_permissions「这次调用本身危不危险?」不知道当前是什么模式
引擎层PermissionEngine.check_permission「按当前模式和用户规则,该放行吗?」不懂具体命令语义

工具层可以返回第四种行为 PASSTHROUGHsrc/agentscope/permission/_types.py:102),意思是「我没意见,你按规则来」。

五种模式

定义在 PermissionModesrc/agentscope/permission/_types.py:18):

模式一句话典型场景
DEFAULT除非规则允许或工具明说安全,一律问默认,最稳
ACCEPT_EDITS工作目录内的读写自动放行人在旁边、快速迭代
EXPLORE只读放行,一切修改直接拒让 agent 先看代码再规划
BYPASS跳过所有安全提示,只剩用户的 deny/ask 规则容器/VM 里的无人值守
DONT_ASK把所有 ASK 转成 DENY定时任务,无人可答

BYPASSDONT_ASK 的对照是这套设计里最有价值的一处:两者都「不问人」,但一个偏向继续跑,一个偏向停下来。源码的类文档里明确建议:无人值守但仍在乎安全,用 DONT_ASK 而不是 BYPASSsrc/agentscope/permission/_types.py:58-59)。

六步评估顺序

每个模式有独立的 _check_<mode> 方法,但骨架相同(以 _check_defaultsrc/agentscope/permission/_engine.py:117 为例):

① deny 规则 命中 ─► DENY (所有模式,最高优先级)
② ask 规则 命中 ─► ASK
③ 只读快速通道 命中 ─► ALLOW
④ 工具 check_permissions
ALLOW / DENY ─► 原样返回
安全性 ASK ─► 返回,allow 规则压不住
其它 ─► 继续往下
⑤ allow 规则 命中 ─► ALLOW
⑥ 模式兜底 DEFAULT→ASK / EXPLORE→DENY / BYPASS→ALLOW / DONT_ASK→DENY

模式之间的差别就在 ④ 和 ⑥ 两格。比如 BYPASS 的 ④ 明确不认任何 ASK(:463-473),EXPLORE 根本不调 check_permissions

bypass_immune:两种 ASK 的区别

这是全仓库注释最长的一个字段(src/agentscope/permission/_decision.py:33-68)。它区分:

  • 偏好性 ASK:「我倾向让用户看一眼」——可以被 allow 规则消音。
  • 安全性 ASKbypass_immune=True):「这事真的危险」——allow 规则不许消音。

各模式对安全性 ASK 的处理:

模式处理
DEFAULT / ACCEPT_EDITS尊重,allow 规则压不住
EXPLORE不适用(走的是只读判定,压根不调 check_permissions
BYPASS故意忽略——这是 BYPASS 的契约
DONT_ASK转成 DENY

注意最后一段注释里的提醒:这个字段是引擎内部元数据,调用方(agent 循环、UI)对两种 ASK 一视同仁,都是弹框。区别只发生在引擎内部。

规则怎么匹配

PermissionRulesrc/agentscope/permission/_rule.py:8)的 rule_content 语义由工具自己解释

工具rule_content 的含义例子
Bash命令前缀 / 子串"git commit:*"
Read/Write/Edit路径 glob"src/**"
其它工具默认只支持 None(工具名级别)整个工具全放行

默认实现只认 Nonesrc/agentscope/tool/_base.py:294-313,方法体就一行 return rule_content is None)——即函数工具和 MCP 工具只能整体开关,不能细到参数,除非自己重写 match_rule。这是个真实的粒度边界。


2.5 Bash 的静态分析:用 tree-sitter 判危

本节的符号横跨三个文件(_constants.py / _bash.py / _bash_parser.py),所以引用一律写全路径。

问题

ls -la 显然安全,rm -rf / 显然危险。但 rm $(find . -name '*.tmp') 呢?靠正则永远判不准。

思路

判不准就别判,直接要求人来看。 BashCommandParsersrc/agentscope/tool/_builtin/_bash_parser.py:148)用 tree-sitter 把命令解析成语法树,遇到「静态分析不了」的结构就退回给用户。

拦截清单

DANGEROUS_NODE_TYPESsrc/agentscope/tool/_constants.py:85-97)列了 11 种节点类型,命中即要求确认:

节点类型长什么样为什么拦
command_substitution$(...) / 反引号执行任意命令
process_substitution<(...)动态文件描述符
expansion${VAR:-default}值在运行时才定
subshell(...)同上
for/while/until/if/case控制流分支不可枚举
function_definition函数定义同上
test_command[[ ... ]]同上

check_injection_risksrc/agentscope/tool/_builtin/_bash_parser.py:862)递归遍历语法树找这些节点。解析失败也算危险:

except Exception:
# If parsing fails, be conservative and require review
return "Command parsing failed, cannot verify safety"

检查顺序很讲究

Bash.check_permissionssrc/agentscope/tool/_builtin/_bash.py:204)的顺序在 docstring 里写死了,注入检查排在只读检查之前src/agentscope/tool/_builtin/_bash.py:255-258):

0. 注入风险 ← 必须最先,否则 `ls $(rm -rf /)` 会被当成只读命令放行
1. 只读命令 → 直接 ALLOW(连 DEFAULT 模式都放行)
2. 危险命令模式(rm -rf / dd / mkfs / chmod 777 ...)
3. sed 约束(禁止 w/W/e/E 和写文件)
4. 敏感路径(~/.bashrc 之类)
5. 危险删除路径(/ 、/usr、/etc、~)
6. ACCEPT_EDITS 下的文件系统命令放行
7. PASSTHROUGH

第 0 步的注释直白:"Must run before read-only check so that $(rm -rf /) inside an otherwise-safe command is caught."

复合命令:全体只读才算只读

is_read_only_commandsrc/agentscope/tool/_builtin/_bash_parser.py:155)对 &&||;| 拆开逐个判,必须全部只读才返回 True。并且命令里只要出现 > 就一律不算只读(src/agentscope/tool/_builtin/_bash_parser.py:175-177,判据是子串 ">" in cmd,比「解析出重定向节点」更粗也更保守)。

还有个细节让人会心一笑:find 默认在只读白名单里,但 find . -delete 显然不是。_is_mutating_find_commandsrc/agentscope/tool/_builtin/_bash_parser.py:239)专门走一遍语法树,看 find 的参数里有没有 FIND_MUTATING_PREDICATES 里的谓词。

危险模式的词边界匹配

check_dangerous_commandsrc/agentscope/tool/_builtin/_bash_parser.py:647)里的小心思:短模式用词边界正则,长模式用子串。

if " " not in pattern and len(pattern) <= 4:
regex = r"\b" + re.escape(pattern) + r"\b"

不这么做,危险模式 "dd" 会把 git add 匹进去。


2.6 执行在哪:Backend 抽象

工具不直接碰文件系统,而是走 BackendBasesrc/agentscope/tool/_builtin/_backend.py:138)。它只要求子类实现三个原语:

抽象方法干什么
exec_shell跑一条 shell 命令
read_file读字节
write_file写字节

其余能力(list_dirstatscandirdelete_path…)基类都用 exec_shell 拼出了默认实现。所以接一个新沙箱,最少只写三个方法LocalBackend:732)额外覆盖了这些方法走本地 API,纯粹为了性能。


2.7 代码地图

主题文件路径符号名
工具管理与统一调用src/agentscope/tool/_toolkit.pyToolkitcall_toolcheck_tool_available
工具组src/agentscope/tool/_tool_group.pyToolGroup
工具协议src/agentscope/tool/_base.pyToolBasecheck_permissionsmatch_rulegenerate_suggestions
工具结果累加src/agentscope/tool/_response.pyToolChunkToolResponse.append_chunk
异常分类src/agentscope/exception/_base.pyAgentOrientedExceptionDeveloperOrientedException
权限模式与行为src/agentscope/permission/_types.pyPermissionModePermissionBehavior
权限仲裁src/agentscope/permission/_engine.pyPermissionEngine_check_default_check_bypass_is_safety_ask
决策对象src/agentscope/permission/_decision.pyPermissionDecision(看 bypass_immune 的注释)
规则模型src/agentscope/permission/_rule.pyPermissionRule
Bash 工具src/agentscope/tool/_builtin/_bash.pyBash.check_permissionsmatch_rule
Bash 语法分析src/agentscope/tool/_builtin/_bash_parser.pyBashCommandParsercheck_injection_riskis_read_only_commandcheck_dangerous_command
危险清单常量src/agentscope/tool/_constants.pyDANGEROUS_COMMANDSDANGEROUS_NODE_TYPES
执行后端src/agentscope/tool/_builtin/_backend.pyBackendBaseLocalBackend
相关测试tests/builtin_bash_test.pypermission_*_test.pytoolkit_*_test.py