第4章 工具体系与权限模型
2026.07.29工具是 agent 与世界的接口——粒度决定了 agent 能做什么、不能做什么,以及在什么时候需要问。
模式层
4.1 工具粒度的设计哲学
Agent 的工具集设计面临一个经典的粒度矛盾:太粗还是太细?
太粗的极端:一个工具做所有事。 比如只有一个 execute 工具,接受自然语言描述,agent 自己决定是读文件还是跑命令。这种设计的好处是工具集极小(LLM 永远不会选错工具),但代价是权限无法细分——你无法对"读文件"和"跑命令"授予不同的权限级别。而且,LLM 在"自然语言描述 → 具体操作"的转换中可能出错——你说"读这个文件",它解析成了"执行这个命令"。
太细的极端:每个操作一个工具。 read_file、read_file_with_encoding、read_file_chunk、read_file_lines……每个工具都有独立的签名。LLM 需要记住几十个工具的精确名称和参数。选错工具的概率随工具数量上升。
CodeCoder 选择了 26 个工具的分界线。这个数字不是来自理论推导,而是来自工程实践:从最初的 10 个工具开始,每次遇到"这个操作应该用哪个工具"的模糊场景时,就新增一个工具或调整现有工具的边界,最终收敛到 26 个。
26 个工具可以分为六类:
| 类别 | 工具 | 权限级别 |
|---|---|---|
| 文件操作 | read_file、write_file、diff | None / Ask |
| 搜索 | glob、grep | None |
| 执行 | run_command | Ask |
| Git | commit | Ask |
| 流程控制 | plan、milestone、review、reason | None / Ask |
| 自修改 | generate_skill、generate_prompt、generate_capability、use_skill、run_capability | None / Ask |
| 交互 | ask_user、confirm、memory | None |
| 子代理 | agent | None |
每类工具的权限 key 设计为最窄粒度。run_command 的 key 不是 run_command 而是 run_command:git、run_command:cargo、run_command:docker 等。这使得用户可以为不同命令授予不同的权限级别。
4.2 权限的四个层次
CodeCoder 的权限模型有四个层次,从宽松到严格:
第一层:None(免问)。 工具声明 Permission::None,执行时不触发任何权限检查。read_file、glob、grep、diff、memory 等只读操作属于这一层。它们的共同特征是:不修改系统状态、不执行外部代码、不产生网络请求。
第二层:Ask{key}(询问)。 工具声明 Permission::Ask(key),执行时弹出确认窗口。用户可以选择:
Once:仅本次允许AlwaysThisSession:本会话内不再询问AlwaysThisProject:本项目内永久允许(写入codecoder.json)
run_command、write_file、commit 等修改性操作属于这一层。
第三层:SessionAllowlist(会话白名单)。 当用户选择 AlwaysThisSession 时,权限被记录在运行期的 SessionAllowlist 中。SessionAllowlist 是一个内存中的 HashMap<(ToolName, PermissionKey), i64>,键是工具名 + 权限 key,值是过期时间戳(或 -1 表示永不过期)。session 结束时,这个列表被丢弃。
第四层:ProjectAllowlist(项目白名单)。 当用户选择 AlwaysThisProject 时,权限被持久化到 codecoder.json 文件中。格式如下:
{
"allowlist": {
"run_command:git": "AlwaysThisProject",
"run_command:cargo": "AlwaysThisSession",
"write_file:tests/**": "AlwaysThisProject"
}
}
ProjectAllowlist 的条目可以在文件编辑器中手动修改——但系统不会自动重新加载。修改后需要通过 /reload 或重启 daemon 生效。
查找链是:先查 ProjectAllowlist → 再查 SessionAllowlist → 否则 Ask。
4.3 天花板规则
权限的第四个层次(ProjectAllowlist)并非对所有工具开放。关键规则是:@shell 环境的工具,最高只能到 SessionAllowlist。
这意味着 run_command 的所有变体(run_command:git、run_command:cargo 等)都不能被写入 codecoder.json 的 ProjectAllowlist。用户每次新 session 都需要重新授权。
为什么?Shell 环境的破坏力太大了。run_command 可以执行任意 shell 命令——改文件、连网络、格式化磁盘、启动服务。如果 run_command:git 被写入 ProjectAllowlist,agent 可以用 git push 到远程仓库,推送后无法撤销。
Wasm 和 Docker 环境的工具可以升到 ProjectAllowlist。因为 Wasm 的沙箱在编译时隔离,Docker 的容器有文件系统隔离。即使工具执行了恶意代码,它造成的破坏也被限制在沙箱内。这是可以授予永久信任的前提。
天花板规则是一个安全设计上的"安全阀"——它确保即使 ProjectAllowlist 被错误配置,最危险的工具也不能被永久授权。
案例层
4.4 Tool trait 设计
Tool trait 是 CodeCoder 工具体系的核心接口:
trait Tool {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn permission(&self) -> Permission;
fn execute(&self, args: &Args, context: &Context) -> Result<ToolResult, ToolError>;
fn parameters(&self) -> Vec<ParameterDescriptor>;
}
enum Permission {
None,
Ask(PermissionKey),
}
struct PermissionKey {
tool: String, // 如 "run_command"
action: String, // 如 "git"
raw: String, // 完整 key 字符串 "run_command:git"
}
Tool trait 的四个关键方法:
name():返回工具名称,也是 LLM 调用时使用的名称description():返回工具描述,用于 LLM 选择工具时的参考permission():返回工具的权限级别——None 或 Ask{key}execute():执行工具的核心逻辑,接收参数和执行上下文,返回结果或错误
permission() 和 execute() 的分离是安全设计的关键。permission() 在 execute() 之前被调用,如果权限检查不通过,execute() 永远不会被执行。这确保了即使工具实现中有 bug 或恶意代码,它也无法在权限检查通过之前运行。
4.5 权限 key 的精度
权限 key 的精度选择决定了用户授权的粒度。CodeCoder 的设计是:权限 key 在"操作类型"级别,不在"具体命令"级别。
run_command:git 允许 agent 执行所有 git 命令——git status、git diff、git commit、git push、git checkout。用户不能只授权 git status 而拒绝 git push。
为什么不在更细的粒度?因为 LLM 在选择工具时,提供给 LLM 的是 run_command 工具(带一个 command 参数),而不是 run_command:git 或 run_command:git status。权限 key 是工具执行时的检查机制,不是工具选择时的约束机制。如果 LLM 能看到 run_command:git status 和 run_command:git push 两个工具,它会选择 run_command:git status 来执行 git status——但 LLM 的可靠性不足以在此粒度上依赖。
CodeCoder 的权限 key 体系包括了通配符。run_command:git:* 匹配所有 git 子命令。run_command:* 匹配所有命令。但复合命令(包含管道、分号、&& 的命令)的 keying 规则不同——见下一节。
4.6 复合命令的 keying 规则
复合命令是指包含 shell 元字符(|、;、&&、||、`)的命令。CodeCoder 对复合命令的 keying 规则是:整串 keying,不可通过前缀预授权。
# 简单命令
git status → key: run_command:git
# 复合命令——整串 keying
git status && git add . → key: run_command:git-status-&&-git-add
这条规则(ADR 0036)的动机是:防止通过简单命令的预授权绕过安全限制。假设用户为 run_command:git 授予了 SessionAllowlist。如果 agent 执行 git status && git push origin main,这个复合命令的 key 是 run_command:git-status-&&-git-push——不在 allowlist 中,需要重新确认。
如果不这样做,攻击者可以在 git status 后面拼接 && git push origin main,通过 git status 的预授权绕过 git push 的权限检查。
4.7 权限查找链的完整实现
权限查找流程的示意图如下:
graph TB
subgraph "工具执行请求"
TOOL[Tool.execute 被调用]
KEY[获取 Permission Key]
end
subgraph "第一层:Permission::None"
CHECK0{permission() == None?}
PASS0[直接通过<br/>无权限检查]
end
subgraph "第二层:ProjectAllowlist"
CHECK1{在 codecoder.json<br/>allowlist 中?}
CEILING1{天花板规则检查}
PASS1[允许执行<br/>AlwaysThisProject]
end
subgraph "第三层:SessionAllowlist"
CHECK2{在运行期<br/>SessionAllowlist 中?}
EXPIRY{未过期?}
PASS2[允许执行<br/>AlwaysThisSession]
end
subgraph "第四层:Ask 模式"
ASK[弹窗询问用户]
USER_ONCE[Once<br/>仅本次允许]
USER_SESSION[AlwaysThisSession<br/>写入 SessionAllowlist]
USER_PROJECT[AlwaysThisProject<br/>写入 codecoder.json]
CEILING2{天花板规则检查}
DENY[用户拒绝 → 返回错误]
end
TOOL --> KEY
KEY --> CHECK0
CHECK0 -->|是| PASS0
CHECK0 -->|否| CHECK1
CHECK1 -->|是| CEILING1
CEILING1 -->|通过| PASS1
CEILING1 -->|拒绝| ASK
CHECK1 -->|否| CHECK2
CHECK2 -->|是| EXPIRY
EXPIRY -->|有效| PASS2
EXPIRY -->|过期| ASK
CHECK2 -->|否| ASK
ASK -->|Once| USER_ONCE
ASK -->|ThisSession| USER_SESSION
ASK -->|ThisProject| CEILING2
ASK -->|No| DENY
CEILING2 -->|通过| USER_PROJECT
CEILING2 -->|拒绝| DENY
style PASS0 fill:#e8f5e9
style PASS1 fill:#e8f5e9
style PASS2 fill:#e8f5e9
style USER_ONCE fill:#fff3e0
style USER_SESSION fill:#fff3e0
style USER_PROJECT fill:#fff3e0
style DENY fill:#fce4ec
权限查找链的伪代码实现:
fn check_permission(tool: &dyn Tool) -> Result<(), PermissionDenied> {
let key = tool.permission_key();
// 1. 如果工具是 None 权限,直接通过
if let Permission::None = tool.permission() {
return Ok(());
}
// 2. 查 ProjectAllowlist(持久化)
if let Some(entry) = project_allowlist.get(&key) {
if entry.allows_ceiling(&key) { // 检查天花板规则
return Ok(());
}
}
// 3. 查 SessionAllowlist(运行期内存)
if let Some(entry) = session_allowlist.get(&key) {
if entry.is_valid() { // 检查过期时间
return Ok(());
}
}
// 4. 以上都不匹配 → 触发 Ask 流程
let response = ask_user(format!("允许 {} 吗?", key))?;
match response {
Once => Ok(()),
ThisSession => { session_allowlist.insert(key, SessionEntry::new()); Ok(()) }
ThisProject => {
if ceiling_rule.allows_project(&key) { // 检查天花板规则
project_allowlist.insert(key, ProjectEntry::new());
Ok(())
} else {
Err(PermissionDenied::CeilingViolation(key))
}
}
}
}
注意步骤 2 和步骤 4 中各有一个天花板规则检查。步骤 2 检查的是已有的 ProjectAllowlist 条目是否与当前工具的天花板规则一致(防止手动编辑 codecoder.json 添加了不应被永久授权的条目)。步骤 4 检查的是用户选择 ThisProject 时是否允许。
4.8 子 agent 的只读工具集
子 agent 通过 agent 工具创建,它的工具集与父 agent 不同。CodeCoder 强制子 agent 使用只读工具集——它只能执行 9 个工具,全部是 Permission::None 级别:
fn read_only_child_tools() -> Vec<Box<dyn Tool>> {
vec![
Box::new(ReadFile),
Box::new(Glob),
Box::new(Grep),
Box::new(Diff),
Box::new(WebSearch),
Box::new(WebFetch),
Box::new(GitHubSearch),
Box::new(Agent), // 子 agent 可以再创建子 agent,但深度锁定为 1
Box::new(Reason),
]
}
子 agent 不能写文件、不能跑命令、不能提交 git、不能生成 skill/capability、不能执行 capability。这意味着子 agent 是一个"纯分析"的实例——它能读一切、能搜索一切,但什么都不能改。
这个设计的推理路径是:子 agent 由父 agent 创建,父 agent 的意图可能是分析性任务(代码审查、方案评估、风险分析)。如果子 agent 能修改系统状态,父 agent 可能失去对子 agent 行为的控制——子 agent 的执行结果可能包含未预期的副作用。只读约束消除了这种风险。
子 agent 的深度锁定为 1——子 agent 可以再创建子 agent(Agent 工具在只读工具集中),但深度计数器递增,达到 1 后不再允许创建新的子 agent。这防止了递归子 agent 的无限增长。
ADR 深度阅读
自撰安全回路(ADR 0022)的演进
ADR 0022 记录了 CodeCoder 自撰安全回路的设计演进。最初的版本中没有"天花板规则"——所有工具都可以被授予 ProjectAllowlist。问题是:当 run_command 被授予 ProjectAllowlist 后,一个 agent 生成的 Capability 可以执行任意 shell 命令——不需要经过权限检查。
解决方案是引入天花板规则:@shell 环境最高只能到 SessionAllowlist。但即使这样做了,另一个问题也暴露出来:天花板规则只在运行时检查,不在配置时检查。
用户可以通过手动编辑 codecoder.json 来添加 run_command:git 到 ProjectAllowlist——因为文件是 JSON 格式,没有编译时检查。天花板规则在运行时拒绝这个条目,但用户可能不知道"为什么我加了但没生效"。后来在步骤 2 的查找链中增加了"检查已有的 ProjectAllowlist 条目是否与天花板规则一致"的验证——至少会返回一个 CeilingViolation 错误,而不是静默跳过。
复合命令 keying 规则(ADR 0036)的来源
ADR 0036 的触发事件是一个具体的 bug 报告:用户为 run_command:git 授予了 SessionAllowlist,agent 执行了 git pull && git push --force——复合命令被拆分成了 run_command:git 的两次调用,因为 shell 解析后 git pull 和 git push 是两个独立的命令。
修复方案不是禁止分号,而是改变 keying 规则。简单命令 = 按命令名 keying;复合命令 = 整串 keying。这个规则的核心在于:同一个权限 key 不能同时覆盖简单命令和复合命令。 如果 run_command:git 在 allowlist 中,它只匹配 git <subcommand> 形式的简单命令,不匹配 git <subcommand> && <command> 形式的复合命令。
下一章深入子代理与协作式取消——agent 如何创建和销毁子 agent,以及在取消时的安全保证。